From Architecture to Detailed Design: A Hydroponic System Case Study (Part 3)

From Architecture to Detailed Design: A Hydroponic System Case Study (Part 3)

In the previous parts of our hydroponic control system case study, we laid the foundation of a robust software design by starting with clear product requirements using the EARS methodology (Part 1) and then translating them into a modular, event-driven architecture (Part 2).

Now it’s time to move closer to the actual implementation. In this post, we dive into the detailed design of the control system, specifically focusing on building a state machine to model system behavior. We'll also explore how we use Test-Driven Development (TDD) to ensure this control logic is robust and maintainable from day one.


From Sequence Diagrams to State Machines

Our architecture is built around components responsible for controlling:

  • pH levels
  • Nutrient concentration
  • Dissolved oxygen
  • Water level
  • Recirculation timing

Each subsystem transitions through well-defined states in response to signals from sensors and timers. This made the state machine model a natural fit.

State Machine Diagram

The diagram below illustrates the control system's full state machine. Each subsystem (pH, Nutrients, DO, Water Level, Recirculation) is modeled as an independent state machine, reacting to sensor signals, timers, and error conditions.

Control System State Machine.svg

Subsystem State Overview

SubsystemStates
pH Control
PH Control Idle, Waiting, Adding Acid / Adding Base
Nutrients Control
Idle, Waiting, Adding Nutrients / Adding Water
Oxygen Control
Idle, Adding Oxygen
Water Level Control
Ok, Filling, Draining
Recirculation Control
On, Off (cyclical timers)

Each state responds to signals like PH_VALUE_SIG, WATER_LEVEL_VALUE_SIG, or timeout triggers such as PH_WAIT_TIMEOUT_SIG. Internal guards such as [check_ph_value_too_high()] determine the path of transitions.


Test-Driven Development (TDD) for Control Logic

Instead of implementing the control logic first and testing afterward, we’re taking a TDD-first approach. This means we define test cases for all valid transitions, edge cases, and timeout behaviors before writing the actual state machine code.

Why TDD?

  • Ensures implementation aligns with design
  • Simplifies debugging and refactoring
  • Encourages modular, testable logic
  • Prevents regressions when evolving the system

High-Level Unit Test Plan

Here's a sample set of test cases that will guide development and ensure correctness across all control modules.

PH Control

Test NameGivenWhenThen
test_ph_value_too_low_enters_waiting
Idle
PH_VALUE_SIG where check_ph_value_too_low() is true
Transition to Waiting, arm_ph_wait_timer()
test_ph_wait_timeout_adds_base
Waiting
PH_WAIT_TIMEOUT_SIG
Adding Base, ph_increase_pump_on()
test_ph_timeout_stops_pump
Adding Base
PH_INCREASE_PUMP_TIMEOUT_SIG
Idle, ph_increase_pump_off()

Nutrients Control

Test NameGivenWhenThen
test_nutrients_value_too_low_enters_waiting
Idle
NUTRIENTS_VALUE_SIG with low value
Waiting, arm_nutrients_wait_timer()
test_nutrients_wait_timeout_starts_pump
Waiting
NUTRIENTS_WAIT_TIMEOUT_SIG
Adding Nutrients, nutrients_pump_on()
test_pump_timeout_stops_nutrients
Adding Nutrients
NC_NUTRIENTS_PUMP_TIMEOUT_SIG
Idle, nutrients_pump_off()

Oxygen & Recirculation

Test NameGivenWhenThen
test_oxygen_on_starts_pump
Idle
DO_VALUE_SIG
Adding Oxygen, oxygen_pump_on()
test_oxygen_timeout
Adding Oxygen
OXYGEN_PUMP_TIMEOUT_SIG
Idle, oxygen_pump_off()
test_recirculation_on_timeout
Recirculation On
RECIRCULATION_ON_PERIOD_TIMEOUT_SIG
Recirculation Off, recirculation_off()
test_recirculation_off_timeout
Recirculation Off
RECIRCULATION_OFF_PERIOD_TIMEOUT_SIG
Recirculation On, recirculation_on()

Water Level

Test NameGivenWhenThen
test_water_level_too_low_fills
Ok
WATER_LEVEL_VALUE_SIG → too low
Filling, water_pump_on()
test_water_level_too_high_drains
Ok
WATER_LEVEL_VALUE_SIG → too high
Draining, water_pump_on()
test_water_level_normal_stops_pumps
Filling or Draining
WATER_LEVEL_VALUE_SIG → ok
Ok, water_pump_off()

These tests form the contract for the upcoming implementation and can be executed in CI environments without relying on hardware, enabling faster iteration.


What’s Next: Simulating the System Without Hardware

In the next post, we’ll demonstrate how to run the application on a development PC using a functional simulator, simulating sensors, signals, and timers. This allows:

  • Rapid iteration without physical hardware
  • Verification of control logic using real test scenarios
  • Early UX exploration and integration testing

Stay tuned to see how we make embedded software testable and robust from day one, without waiting for hardware to arrive.