This is a project borne of the frustration of many, many failed Panettone.
For those who don't know, Panettone is a traditional Italian sweetbread cooked for Easter and Christmas. It has an ‘impossibly’ light and airy texture, which requires a highly active yeast from a Sourdough Starter.
Back another step - what's a Sourdough Starter? Simply put - a community of bacteria and yeast that, upon consuming fresh flour and water, release gases that create airy, risen bread. The yeast needs to be strong enough to fight the gravity load of the bread or panettone dough. In the case of bread - not so bad. In the case of Panettone - we are fighting copious amounts of butter, fruit and sugar - a tough ask for any yeast.
When is a sourdough starter strong enough to be used in Panettone? Simple: When it triples in volume (i.e. height) in 4 hours, while being maintained at 27C. Well, sounds simple anyway. Maintaining that is difficult. Nobody really wants to heat their house up to 27C just for the sake of their starter, let alone keep that consistent for weeks on end while the strength of the starter increases. Imagine the electricity bills... And how do you know if it has tripled in size in 4 hours? Do we check constantly? Set alarms and keep a tape measure in the kitchen? Too much, too difficult, too involved. Definitely best to let the robots sort this one out.
What happens when the starter isn't strong enough?
It's dense, difficult to eat, and looks like failure.
And when it is strong enough?
Look at the air! You just know it's delicate, fluffy, airy, velvety, sweet, crunchy, buttery,........., everything you want it to be.
So, it's currently May, and I have a while before my next batch of Panettone are due (December). When we were asked in Embedded Systems to solve a 'real world problem' - Easter had just passed, and I'd just failed a bunch more Panettone. Seemed like a problem worth solving.
I'd thought about buying a fermentation station or something similar - but they're are EXPENSIVE items considering what I intended to build had even more features (and was, on the whole, cheaper).
So, lets begin. What do we want the project to do?
1. Manage the temperature of the Sourdough Starter
2. Tell us whether its strong enough for Panettone
So, I need:
- Something to track the height of the starter inside the jar (this will tell me whether the starter has tripled in size in 4 hours)
- Something to track the temperature of the starter inside the jar
- Something to heat or cool the jar so ensure it always remains around 27C
- Something to notify me of important events (i.e. the 4 hour mark)
- A GUI so I can reset the sourdough starter after a feed
- A GUI so I can track key variables (temperature, growth) remotely - so I don't need to be near the kitchen all day.
I wanted to utilise component-based design principles so I could always isolate issues to one area. As such, I decided upon:
- SENSORS:Raspberry Pi 3B+ - to manage the DHT and Distance Sensors, and transmit that data wirelessly (to the Particle) and via USB serial (to the Arduino)
- HEATING/COOLING:ArduinoUNO - to turn on the heat pads and fan when required to maintain the temperature of the starter
- GUI / NOTIFICATIONS / RISE: Particle Argon - to give the user a remote GUI, an LCD GUI, and provide alterts about significant events
Here's a quick sketch of the system architecture:
We need a simple way to get everything talking to everything else. I don't have significant experience in this field, and I felt IFTTT provided the simplest and easiest way to get this communication happening.
I created an IFTTT account and then generated an applet with a Webhook as the 'IF'. Essential, IF the webhook recieves a web request with a JSON payload (i.e. a request from the RPi with some accompanying JSON data), THEN it triggers a Particle function. The Particle can then use the JSON data however it needs!
The following link helped me tremendously:
https://pimylifeup.com/using-ifttt-with-the-raspberry-pi/
Most importantly, the webhook will give you an address of the form:
"https://maker.ifttt.com/trigger/tempsent/json/with/key/XXXXXX" (I have removed my personal key here)
All we then need to do is use that URL in our code to post when the time is appropriate to trigger this applet.
The second IFTTT is a connection between the Particle and the phone - this is quite simple. The IF waits for the Particle to publish an event of a specific name, then sends a notification to the IFTTT app on the phone when recieved.
The Raspberry PiNow, let's get all the sensors working, and the RPi talking to everything. To connect up the circuit:
- DHT - Connect power pin on DHT to RPI 5V, the second pin to RPI GPIO4, and the fourth pin to RPI GND. The third pin on the DHT is unused. Note - it would have been preferable to use a DHT22 for accuracy, however mine was faulty and replaced with a DHT11 for the final product.
- VL53L1XLaser - Connect Vin to RPI 3.3V, GND to RPI GND, SDA to RPI GPIO2, and SCL to RPI GPIO3. This is able to communicate via serial between the Raspberry Pi and the Laser.
I note here I chose a laser over an ultrasonic sensor for a few reasons:
- More accurate
- More reliable (particularly given it was a soft surface we are bouncing off)
- I don't like annoying my cats with constant ultrasonic noises all day
A photo and schematic are included below. Net ports are shown in the schematic to improve visibility of the wiring. I note the schematic does not show the USB connection between the Arduino and the Raspberry Pi, however that is a critical component for communication.
Now - code time. Create a new python file on the Raspberry Pi and start editing - I use Thonny as I find it really easy to use. Lets start with the imports:
- Adafruit DHT (to use the DHT sensor)
- PiicoDev_VL53L1X (from Core Electronics in Australia - for the laser)
- Time / Sleep
- Requests - this will allow us to use the Webhooks and communicate wirelessly
- Serial - this will allow communication via USB with the Arduino
A few setup items:
- Flags were used to signal to the Particle whether the Fan or Heater was on
- The 'airmessage' was the integer transmitted via serial to the Arduino to 'do something'
- The serial was set up at the correct address by checking in the console: ls /dev/tty*
I created an 'average readings' method for robustness - this took 5 consecutive readings and averaged them out before reporting back to the webhooks / serial etc. This ensured single erroneous readings didn't mistakenly trigger events. It also included a fault management code check that sends the user a notification if 100 faulty readings are observed.
There are a lot of comments in the main loop - mainly used for debugging purposes. The main loop gets the average then decides what to do:
- If temperature is less than 24, send the signal to turn on the heater
- Allow temperature to rise to 27, then turn off the heater
- If temperature is greater than 30, send the signal to turn on the fan
- Allow temperature to reduce to 27, then turn off the fan
While the temperature just ranges between 24 and 30, no device is turned on (this is the 'optimal zone' for the starter).
This results in the following python script:
import time
time.sleep(15)
#DHT imports
import Adafruit_DHT
# Laser imports
from PiicoDev_VL53L1X import PiicoDev_VL53L1X
# Webhook imports
import requests
#Communication with Arduino
import serial
DHT_SENSOR = Adafruit_DHT.DHT11
DHT_PIN = 4
distSensor = PiicoDev_VL53L1X()
#give the sensors 5 seconds to set up
time.sleep(5)
#flags - to be sent to PARTICLE (for LCD display)
fanflag = " "
heatflag = " "
#airmessage legend - to be sent via serial to ARDUINO (temp control):
# - 0 = start
# - 10 = heat on
# - 20 = heat off
# - 30 = fan on
# - 40 = fan off
airmessage = "0"
# for robustness of measurement
tempsum = 0.0
distsum = 0.0
tempavg = 0.0
distavg = 0.0
#serial setup
ser = serial.Serial('/dev/ttyACM0', 9600, timeout=1)
ser.flush()
def avgreadings():
i=0 #to check the 'accurate reading' loop
j=0 #loop will fail after 50 incorrect readings
global tempsum
global distsum
global tempavg
global distavg
while (i < 5 and j < 100):
humidity, temperature = Adafruit_DHT.read_retry(DHT_SENSOR, DHT_PIN)
dist = distSensor.read() # read the distance in millimetres
print("Temp={0:0.1f}C".format(temperature))
if(temperature is not None and dist is not None):
tempsum += temperature
distsum += dist
i += 1
time.sleep(3)
else:
print("Reading skipped due to faulty hardware")
j += 1
time.sleep(3)
tempavg = tempsum / 5
distavg = distsum / 5
#main loop
while True:
avgreadings()
#overrides for debugging (test cases)
#tempavg = 20
#tempavg = 25
#tempavg = 28
#tempavg = 31
#distavg = 233
#this just prevents display issues during testing - won't cause issues during actual run
if (distavg > 1000):
distavg = 999
tempStr = "{:2.1f}".format(tempavg)
distStr = "{:3.0f}".format(distavg)
if tempavg is not None and distavg is not None:
#pi console log:
print("Temp={0:0.1f}C Distance={1:0.1f}mm".format(tempavg, distavg))
if (tempavg < 24):
airmessage = "10" #for the serial (arduino)
heatflag = "H" #for the LCD (particle)
elif (tempavg >=24 and tempavg < 27):
airmessage = "40"
fanflag = " "
elif (tempavg >=27 and tempavg < 30):
airmessage = "20"
heatflag = " "
elif (tempavg >=30):
airmessage = "30"
fanflag = "F"
#ifttt trigger is used by the particle for display:
requests.post('https://maker.ifttt.com/trigger/tempsent/json/with/key/XXXXX',
data={"value1":tempStr, "value2":distStr, "value3":fanflag,"value4":heatflag})
else:
print("Major sensor failure. No reading taken in 100 attempts. Check wiring. Loop will end.")
requests.post('https://maker.ifttt.com/trigger/faults/json/with/key/XXXXX',
data={"value1":"Sensor Fault - No Reading Taken"})
break
#overrides for debugging (test cases)
#airmessage = "10" #heat on
#airmessage = "20" #heat off
#airmessage = "30" #fan on
#airmessage = "40" #fan off
ser.write(airmessage.encode('utf-8')) #serial code to be sent
tempsum = 0.0
distsum = 0.0
ArduinoThe Arduino setup was more interesting from a power supply perspective. The fan takes 12V - far more than can be provided by the Arduino. Further, the heat pads take 5V but have a 6ohm resistance - meaning the current required is close to 1A - again far more than is tolerable for the Arduino. As such, we have to use external power supplies and a relay that connects those power supplies to the fan / heater when triggered by the Arduino.
The setup is as follows:
- Connect Pin 12 of the Arduino to one side of the relay coil, and the other side to the Arduino GND (this is for the heater)
- Connect Pin 10 of the Arduino to one side of the relay coil, and the other side to the Arduino GND (this is for the fan)
- Connect the positive of the power supply to the relevant common rails of the relay (5V for the heat pads, 12V for the fan)
- Connect the positive of the heat pads / fan to the 'always off' side of the relay switches
- Connect the GND of the heat pads / fans to the GND of the power supply
- Connect a 9V power supply to the Arduino - this ensures the Arduino is powered by the battery, NOT the Raspberry Pi via USB
Photo and schematic below:
The Arduino is getting data from the RPi via USB (serial) - messages encoded into utf-8. All we need to do is get the serial code and perform the relevant action depending on that code. Massive amount of help recieved from the following link to achieve this:
https://automaticaddison.com/2-way-communication-between-raspberry-pi-and-arduino/
This is managed via an if statement in the main loop of the code below. The code is generated within the Arduino IDE as an.ino file.
#define HEAT_PIN 12
#define FAN_PIN 10
byte lastButtonState = LOW;
byte currentButtonState = LOW;
unsigned long lastButtonDebounceTime = 0;
unsigned long buttonDebounceDelay = 20;
int serialcode = 0;
void powerOffAll()
{
digitalWrite(HEAT_PIN, LOW);
digitalWrite(FAN_PIN, LOW);
}
void setup()
{
Serial.begin(9600);
pinMode(HEAT_PIN, OUTPUT);
pinMode(FAN_PIN, OUTPUT);
powerOffAll();
}
void loop()
{
if(Serial.available() > 0) {
serialcode = Serial.parseInt();
}
if (serialcode == 10) {
digitalWrite(HEAT_PIN, HIGH);
}
if (serialcode == 20) {
digitalWrite(HEAT_PIN, LOW);
}
if (serialcode == 30) {
digitalWrite(FAN_PIN, HIGH);
}
if (serialcode == 40) {
digitalWrite(FAN_PIN, LOW);
}
}
Particle ArgonThe Particle Argon plays a pivotal role in communication between the user and the code / devices. In terms of physical connections, we need to connect the LCD / Potentiometer and the LEDs. The LCD should have been very simple - i.e. connection via I2C (through SDA/SCL), but I just could not get it to work. I had to connect it pin by pin, following the directions in the below Hackster project:
https://www.hackster.io/395300/weather-sensor-and-clothing-suggestion-generator-megr-3171-b82b8b
I then connected two LEDs - green for 'starter is good!' and red for 'starter is not good'. These were connected to Argon pins D6 and D7, and then grounded via a 200ohm resistor. This resulted in the following photo / schematic:
The Particle code, generated within the Particle IDE, is the longeand most complicated code as it ties everything together. Walking through:
- We create the lcd object, the empty strings, initialise integers etc - in preparation for the variables to be filled in later
- We hard code the initial jar height and dough starting height, as these are known and locked values
- We then create a 'reset button' method - this is activated remotely through the HTML to reset the timer once the 4 hours has expired (and we know our results)
The setup:
- We clear the LCD and display an intro message
- We initialise the two functions that interact with IFTTT - displayTemp (when called, will run the tempDisplay method) and reset (when called, will run the resetbutton method)
- We record the time at start
- We turn off the LEDs
The loop:
- We put a 10 second delay on so we aren't bombarding the LCD screen
- We print the current commands (cmd1 and cmd2) - these are set in the tempDisplay method
- We calculate the growth of the starter
- If the timer surpasses 4 hours, we send a new message to the display with the final outcome
The tempDisplay method:
- This is where we recieve that JSON data from the webhook we mentioned earlier - this is ALL the critical distance data from the Raspberry Pi, transmitted wirelessly to our Particle
- Unfortunately, I could not get the JSON to parse so that it was easily accessible. Instead, I just took it as a string and broke it down into substrings, which was very simple. I ensured the RPi formatting rules were strict so that this would always work correctly. This JSON contained the following data: Distance, Temperature, and whether the fan or heater was on.
- This data was used to manage both the HTML and the LCD screen.
Here is the code:
// This #include statement was automatically added by the Particle IDE.
#include <LiquidCrystal.h>
LiquidCrystal lcd(5, 4, 3, 2, 1, 0);
String cmd1;
String cmd2;
String cmd1sub;
String distanceStr;
String growthStr;
String timeStr;
int jarHeight;
int doughHeightStart;
int distance;
int growth = 0;
int timeStart;
int flag;
double pcgrowth = 0;
int led1 = D6;
int led2 = D7;
int timeElapsed;
int hours;
int mins;
const char *EVENT_NAME_A = "tempEvent";
const char *EVENT_NAME_B = "growEvent";
const char *EVENT_NAME_C = "timeEvent";
int resetbutton(String command)
{
lcd.clear();
lcd.begin(16, 2);
cmd1 = "Restarting";
cmd2 = "";
cmd1sub = "";
timeStart = (int)Time.now();
flag = 0;
digitalWrite(led1, LOW);
digitalWrite(led2, LOW);
return 0;
}
void setup() {
lcd.clear();
lcd.begin(16, 2);
Particle.function("displayTemp",tempDisplay);
Particle.function("reset", resetbutton);
cmd1 = "Panettone";
cmd2 = "By Daniel Fantin";
cmd1sub = "";
timeStart = (int)Time.now();
flag = 0;
pinMode(led1, OUTPUT);
digitalWrite(led1, LOW);
pinMode(led2, OUTPUT);
digitalWrite(led2, LOW);
jarHeight = 170;
doughHeightStart = 30;
}
void timeString() {
timeElapsed = (int)Time.now() - timeStart;
hours = timeElapsed / 3600;
mins = (timeElapsed - hours * 3600) / 60;
timeStr = String(hours) + "hr " + String(mins) + "min";
}
void loop() {
delay(10000);
lcd.clear();
lcd.setCursor(0,0);
lcd.print(cmd1);
lcd.setCursor(0,1);
lcd.print(cmd2);
Particle.publish(EVENT_NAME_A, cmd1sub);
Particle.publish(EVENT_NAME_B, growthStr);
timeString();
Particle.publish(EVENT_NAME_C, timeStr);
if ((int)Time.now() - timeStart > 14400 and flag == 0) // 4 hour timer
{
String pcstring;
if(pcgrowth < 200) {
pcstring = String((int)pcgrowth) + "% rise in 4hrs. You need to increase feed frequency!";
cmd1 = String((int)pcgrowth) + "% rise in 4hrs.";
cmd2 = "Increase feeding";
digitalWrite(led2, HIGH);
} else {
pcstring = String((int)pcgrowth) + "% rise in 4hrs. Your starter is ready to use!";
cmd1 = String((int)pcgrowth) + "% rise in 4hrs.";
cmd2 = "Ready to use!";
digitalWrite(led1, HIGH);
}
Particle.publish("EVENT_NAME_TIMER", pcstring);
flag = 1;
}
}
int tempDisplay(String input) {
distanceStr = input.substring(27,30);
distance = distanceStr.toInt();
growth = jarHeight - distance - doughHeightStart;
if (growth < 0 )
{
growth = 0;
}
pcgrowth = ((double)growth/(double)doughHeightStart)*100.0;
growthStr = String(growth);
if(flag != 1)
{
cmd1 = "Temp: " + input.substring(11,15) + "C " + input.substring(42,43) + input.substring(55,56);
cmd2 = "Rise: " + growthStr + "mm=" + String((int)pcgrowth) + "%";
cmd1sub = cmd1.substring(6,10);
}
return 0;
}
HTMLThe HTML was used to enable remote access to the system. This let me reset the timer when the 4 hours had expired, as well as monitor temperature and growth from anywhere that had internet access.
I won't post all the code here, just a few functions. The majority of the code is taken from the Particle tutorial on calling functions from the web:
https://docs.particle.io/datasheets/app-notes/an032-calling-api-from-web-page/
The HTML's key functions were to:
- Display live temperature data (by subscribing to an Event Stream)
- Display live growth data (by subscribing to an Event Stream)
- Resetting the timer (by calling a function when pressed)
Again, this code is NOT whole - it's just some of the key relevant functions needed. Here is GETTING data from the Event Stream:
particle.getEventStream({ name: 'tempEvent', auth: sessionStorage.particleToken }).then(
function (stream) {
console.log('starting event stream');
stream.on('event', function (eventData) {
showTemp(eventData)
});
});
particle.getEventStream({ name: 'growEvent', auth: sessionStorage.particleToken }).then(
function (stream) {
console.log('starting event stream');
stream.on('event', function (eventData) {
showGrow(eventData)
});
});
particle.getEventStream({ name: 'timeEvent', auth: sessionStorage.particleToken }).then(
function (stream) {
console.log('starting event stream');
stream.on('event', function (eventData) {
showTime(eventData)
});
});
And here is CALLING a function by pressing a button (Particle ID removed):
function resetControl(cmd) {
//const deviceId = $('#deviceSelect').val();
$('#statusSpan').text('');
particle.callFunction({ deviceId: 'X', name: 'reset', argument: cmd, auth: sessionStorage.particleToken }).then(
function (data) {
$('#statusSpan').text('Reset completed');
},
function (err) {
$('#statusSpan').text('Error calling device: ' + err);
}
);
}
Action ShotsSome photos of the LCD display at various points:
That's it. Everything working and all synced up. This weekend I'll be putting it all together into a physical insulated box and uploading some more photos in its final form.
UpdateHere is the box in its final form:
Comments