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.
Subsystem | States |
---|---|
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.
Test Name | Given | When | Then |
---|---|---|---|
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() |
Test Name | Given | When | Then |
---|---|---|---|
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() |
Test Name | Given | When | Then |
---|---|---|---|
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() |
Test Name | Given | When | Then |
---|---|---|---|
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.