This project adds PID temperature control to my Gaggia Classic espresso machine. Inspired by similar DIY efforts, I wanted to challenge myself by designing and implementing the modification entirely from scratch.
A PID controller offers more precise and consistent temperature regulation compared to the stock thermostat, which helps improve shot quality.
The Gaggia machine regulates temperature using basic thermostat, which functions as a switch that cuts off power when the water temperature exceeds its rated threshold — 107°C for brewing. However, this approach results in a sluggish system response. Once the temperature reaches the cutoff point, it tends to slowly oscillate around the target, deviating by several degrees. This rudimentary control method lacks the precision needed for accurate temperature regulation.
To achieve more precise control, we need to continuously monitor the water temperature and adjust the power to the heating element dynamically based on real-time measurements. This allows for smoother and more accurate temperature control.
To implement such a system, following components are required:
- Temperature sensor
- Power switch
- Microcontroller board
The application requires measuring relatively short-term changes in temperature over a wide range (~0°C to 200°C) with high accuracy (better than ±0.5°C).
A common use case that fits these requirements is temperature control in a water heater, where RTD (Resistance Temperature Detector) sensors are frequently employed. For this purpose, a Pt100 RTD sensor was selected. It meets the accuracy and range specifications and is readily available with an M4 adapter suitable for mounting to a boiler.
However, achieving accurate temperature readings from an RTD sensor requires a precise analog-to-digital converter (ADC) and amplification. Fortunately, this problem has been effectively solved with the MAX31865 — a user-friendly resistance-to-digital converter optimized for platinum RTDs like the Pt100. With the MAX31865, you simply connect the sensor, configure the device, and it provides accurate digital temperature readings.
Adafruit offers high-quality breakout boards based on the MAX31865 chip, along with comprehensive documentation and example code. The sensor itself I found on AliExpress with integrated M4 screw and of 3-wire type. See examples of MAX31865 and RTD sensor below.
With the thermostat removed, an alternative method is needed to switch the power on and off. A mechanical relay is the most straightforward option and probably could suffice.
However, solid-state relays (SSRs) are often preferred in high-power scenarios for several practical reasons. While both options are capable of handling the load, SSRs offer significant advantages:
-
Silent operation: unlike mechanical relays that produce an audible "click" during switching, SSRs operate silently.
-
Longer lifespan: SSRs have no moving parts, which drastically reduces mechanical wear and increases operational longevity, particularly under frequent switching conditions.
-
Faster switching: the ability of SSRs to switch much faster than mechanical relays.
-
Reduced electrical noise: SSRs can be zero-crossing triggered, minimizing electrical noise and transients during switching.
Given these benefits, I opted for a commonly available 40A-rated SSR. These are widely used, reliable, and relatively inexpensive, making them a solid choice for high-power switching applications. According to the specifications, the control method is listed as zero-crossing. The SSR model is shown on the image below.
There are many microcontrollers that could handle this task, as long as they provide enough GPIO pins, support both I2C and SPI communication, and have sufficient memory resources.
I had a Raspberry Pi Pico on hand that I hadn’t worked with before, so I decided to use it as the microcontroller for this project.
The components mentioned above cover the core functionality, but a few important elements are still missing.
First, the Raspberry Pi Pico needs a power source. While it can be powered via USB during development and debugging, this is only a temporary solution. For a standalone setup, an AC/DC power supply is required to draw power from the machine itself. I found a small module on AliExpress that outputs up to 600mA at 5V, which is more than sufficient for powering the Pico and a few peripherals.
The module turned out to be of poor quality. Its output was quite noisy, and there were grounding issues. While most components in the system would tolerate these, the MAX31865 is considerably more sensitive. As a result, it frequently reported faults during temperature readings.
Fortunately, I had an AC/DC power supply from my broken speakers on hand, so I used it for this project. Adding a pair of decoupling capacitors to its output (100µF electrolytic and 0.1µF ceramic) resulted in a very smooth voltage curve. However, the output measured at ~6V, which is around absolute maximum rating of the on-board regulator. I used a diode to introduce a voltage drop. Additionally, the power supply discharged very slowly after the power was cut off. To address this, I soldered in a low-value bleed resistor to speed up the discharge. In the end, the Pico is powered via the VSYS pin with a stable ~5.1V supply.
Second, we need a way to monitor the machine's temperature in real time. A small display would be a practical addition for this purpose, allowing for quick and convenient observation of temperature readings. I came across a very nice 1.5" 128×128 grayscale OLED display from Adafruit, which includes comprehensive documentation.
Regarding the hydraulics upgrade, one of the most popular modifications is the dimmer mod. This approach controls the pump flow by chopping the AC supply using a phase-angle dimmer circuit. While it allows for pressure profiling, it is worth noting that this method can lead to increased wear on the pump, as this vibration pump is not designed to operate under such conditions.
A simpler alternative is pump time control. By interrupting power to the pump, you can implement programmable shot timing — effectively automating brew durations without modifying flow or pressure directly. All that is required for this mod is a relay board and some wiring to detect when the brew button has been pressed.
Since I dragged this project out for too long, I ultimately decided to skip the relay part of the mod — it would not have made a meaningful difference for my use case anyway. However, I wired the brew button to the Pico.
On the EU model of the machine, there is a switch-off board included. Wires on the left side of the brew switch (in my case, blue and green ones) are connected to it so that the power-off timer is reset when brewing. I removed these two wires and shorted them (to make the board think the brew is on at all time). The available connections can now be used to detect when the button is pressed.
Since the Pico board was chosen, the MCU provides plenty of resources to write a somewhat abstract code :)
To make the implementation easier to write and more readable, I followed an OOP approach. Each component with a specific purpose is encapsulated in its own class. For example, each communication protocol (I2C/SPI) is implemented as a separate class. The drivers for external components, such as MAX31865 and the OLED display, are also organized into classes that utilize the I2C or SPI class instances for communication.
Here is a high-level overview of the software:
Temperature measurements are obtained via the MAX31865 chip, which uses SPI for communication. Hence, the SPI driver class was implemented. Due to specific timing requirements of the MAX31865, slight modification were done to the native Pico SDK SPI calls. The details are available in the implementation file — src/spi.cpp. Below are examples of SPI read and write operations captured with a logic analyzer:
The MAX31865 driver implements the chip-specific logic and utilizes the SPI class instance for communication. Details can be found here — src/max31865.cpp
Not much to explain here — a PID temperature controller logic is encapsulated within the PID class. The implementation is minimal, only includes basic P-I-D action, no filtering, and includes integral anti-windup to prevent the accumulation of integral action (which is important in such slow control systems).
The PID compute method takes temperature setpoint and the measurement as inputs (error is calculated inside), and outputs the PWM percentage (duty cycle). Implementation can be found here — src/pid.cpp
The SSR is controlled via PWM, hence the logic is put into PWM class. The small problem here is that the control systems is quite slow leading to a long PWM period, too long to be controlled using Pico's dedicated PWM peripherals. Therefore, the PWM logic was implemented via combination of timer peripheral and GPIO actions. Check details here — src/pwm.cpp
An interesting consideration here is how much power is delivered to the heating element during each PWM period. Here's what I mean: suppose the PID controller outputs a 10% duty cycle. Depending on the length of the PWM period, this 10% can represent different chunks of time — for example, 0.11 sec.
In the EU, the AC mains frequency is 50 Hz, meaning each full AC cycle lasts 20 ms. However, 0.11 sec is not an even multiple of the AC period (0.11 ÷ 0.02 = 5.5 cycles), so the PWM period slices through the AC waveform at arbitrary points. As a result, depending on exactly where in the AC cycle the PWM turns on or off, the average power delivered can fluctuate between periods.
To achieve more consistent power delivery, it is desirable to align the PWM period so that it is an integer multiple of the AC cycle period — for example, setting the PWM period to 2 seconds (100 full AC cycles). This way, for any given duty cycle, the on-time will always correspond to a whole number of complete AC periods, rather than cutting through the AC waveform at arbitrary points. As a result, the average power delivered becomes more predictable and stable.
Of course, there are some caveats here, and the topic can get quite technical — including issues like the non-ideal nature of mains frequency, inrush current when switching at AC peaks, and other electrical nuances. That said, these details are not critical for this particular system in my opinion, especially since the SSR I chose features zero-crossing control. Still, it's an interesting topic to explore.
As this mod is not exactly stress-tested, some form of error handler was logical to implement.
The approach I took was straightforward: during a cycle, the system checks whether input conditions are met. If a condition fails and the associated error has a higher priority than previously stored error, the system stores the new error code. At the end of the loop, the following actions are taken:
-
Disable PWM output if the error is severe enough.
-
Display the current error code. If no error is present, reset the error status and re-enable the PWM.
Possible error codes are summarized in the table below:
Error Code | Error Context | Description |
---|---|---|
P0 | Protection | Watchdog just rebooted the MCU (execution will continue shortly) |
P1 | VSYS ADC is not configured correctly | |
P2 | Over voltage detected on VSYS | |
P3 | Under voltage detected on VSYS | |
P4 | Boiler too hot (higher than typical steaming temperature) | |
P5 | Machine was running for too long | |
S0 | Sensing | MAX31865 reports under/overvoltage |
S1 | MAX31865 reports RTD input voltage is too low | |
S2 | MAX31865 reports reference input voltage is too high | |
S3 | MAX31865 reports reference input voltage is too low | |
S4 | MAX31865 reports RTD resistance is lower than the configured low threshold | |
S5 | MAX31865 reports RTD resistance is higher than the configured high threshold | |
S6 | Abnormal / not realistic boiler temperature (without MAX31865 reporting faults) | |
C0 | Communication | Error occurred during I2C communication |
C1 | SPI device seems to be disconnected | |
HI | User info | Warning to the user that boiler temperature above setpoint (does not disable the PWM) |
LO | Warning to the user that boiler temperature below setpoint (does not disable the PWM) | |
OK | Boiler temperature close to setpoint and no errors detected |
Check implementation here — src/error.cpp
The OLED display uses SSD1327 driver chip with I2C as the default communication protocol. I chose to structure the display logic in 3 layers:
-
The I2C driver class handles communication. The default timings in the Pico SDK were sufficient, so no modifications were necessary. Check here — src/i2c.cpp
-
The SSD1327 driver manages the logic for updating the display, such as filling or clearing the screen, drawing specific regions, and more. Implementation can be found here — src/ssd1327.cpp
-
The GUI driver oversees the display layout, including how quickly the display is updated and how individual numbers or letters correspond to an actual pixel data. Source file — src/gui.cpp
Image below shows the layout of 128x128 display:
It is split into three rows. These rows are:
- Thermometer icon and three digit fields to show the temperature of the boiler.
- Status icon and two fields to show the status code. Error codes are represented as a letter and a number (except for HI/LO/OK status codes).
- Cup icon and two digit fields to show the shot timer. When the brew switch is pressed, the timer starts. After the button is released, the timer remains visible for 5 seconds before automatically resetting to zero. The cup icon changes if the brew timer is active.
Images below show examples of what can be displayed.
All the icons, letters and numbers were drawn by me using an online designer for pixel art — Pixilart. The original .pixil files, generated png images, etc. are stored in docs/display/
The pump logic is based on a simple state machine. When the brew button is pressed, the normally closed (NC) relay remains inactive, allowing the water to flow. After the programmed time elapses, the relay opens, cutting the power to the pump. After the brew button is released by the user, the relay returns to its default closed position. For details, see — src/pump.cpp
This setup enables precise shot timing without the need for manual tracking of time. While I have not set up the relay part myself, I've left the code in place.
The part of the code that is used though, is detection of brew button presses. This enables real-time display of the shot extraction time.
Source code also includes:
-
Clock class provides current uptime of the MCU in ms, sec and min. All the methods are static as the class does not need to be instantiated, it just encapsulates the logic relevant to time. Check src/clock.cpp
-
Led class controls the on-board LED. Same as clock, all methods are static. Check src/led.cpp
-
Vsys monitor class provides ability to measure the voltage on VSYS line. As the ADC handling is minimal in the software, I decided to not create a separate class for it. Check src/vses.cpp
-
Watchdog that reboots the MCU if it is not updated in a defined amount of time. It has no class of its own, direct SDK calls are used.
-
For debug and test data recording, custom print functions were implemented. They communicate data over serial to a connected computer. Check src/myprint.h
To aid the development, I made several scripts which are stored in the tools/ folder.
Script | Description |
---|---|
flash.sh | Flash the binary on the Pico and print its size |
listen.py | Connect to serial port and record data transmitted from the Pico |
process-data.py | Parse and plot the temperature data recorded from serial |
png-to-bytes.py | Convert png image into raw data to be displayed on the OLED |
To calibrate the PID, I began by recording performance data. The graphs below show the final results I was able to achieve after some calibration.
The boiler heat-up is quite smooth, reaching operating temperature in about 100 seconds. The system seems slightly over damped, requiring some effort to reach the final few degrees. Once at temperature, it oscillates with a ±1°C deviation for a short while. Given the system's slow thermal response, I consider this level of accuracy quite acceptable. By around the 4-minute mark, the error narrows to about ±0.5°C and continues to improve over time.
The result of a shot pull test is also shown below. During brewing, the water temperature drops by almost 3°C. After, it overshoots by about 1.5°C but stabilizes relatively quickly, with full recovery achieved within a minute.
Additionally, I tested how well my modification works with the stock steam control. As expected, once the boiler was heated, I reset the steam switch, and the temperature gradually decreased. After such a prolonged (passive) cooling, the integral term accumulated significantly, resulting in an initial temperature undershoot (visible at around the 600-second mark). However, within about one and a half minutes, the controller compensated and returned the system to the setpoint.
While this is not a realistic usage scenario — doubt anyone would really wait 10 minutes for the boiler to cool passively (while having the machine on) — I conducted a more practical test next. After reaching steam temperature, I manually dropped the temperature by flushing water. This caused the temperature to fall below the setpoint, but the controller responded quickly. Two factors contributed to the improved response: first, the integral term had less time to accumulate error; second, the steep drop triggered a strong derivative response. In this test, the system recovered rapidly and stabilized efficiently.
Overall, the controller is stable, and the system's performance is more than adequate for real-world use — substantially better than the stock configuration. Although further PID tuning is possible, the current setup delivers great results.
The circuit diagram of the final assembly is shown below. It illustrates how most of the components were wired together.
The photos below provide a rough overview of the assembly process. In the first image, I have highlighted the main modification. Here is a brief explanation of the numbered tags:
- Power Source – Power is drawn from the machine using piggyback connectors, which are active when the main power switch is turned on.
- Power Supply Box – Contains the AC/DC power supply and a small piece of protoboard with the necessary components soldered in.
- RTD Sensor – Replaces the machine's original thermostat.
- Solid-State Relay (SSR) – Its AC side is connected to the two wires that were previously attached to the brew thermostat.
- Brew Switch Wiring – Two wires are connected to the brew switch to detect when it’s pressed. The original disconnected wires are shorted to prevent the switch-off board from shutting down the machine.
- Control Box – Houses the Raspberry Pi Pico and the MAX31865.
In the second image, you can see the fully assembled machine. The display is mounted in a case attached to the side of the machine.
Finally, let's talk about the coffee itself. As a not-so-hardcore coffee enthusiast, here is my take on the stock machine. Even with specialty beans, it produced only decent results, definitely better than your average non-specialty cafe, but still nothing I would drink without milk. The espresso often leaned too acidic, and not in a pleasant way. Once in a while, I would even get a hardly drinkable shot (skill issue, gonna admit).
The quality of the shots I got while testing the mod were surprising. I finally was able to taste those flavor "notes" that coffee influencers always talk about. The acidity was still present, but now it felt balanced — bright, not sharp — allowing more complex flavors to emerge.
Even more surprising is that in a test setup and very little effort put into puck preparation, the espresso tasted substantially better than on the stock machine.
Material on how to approach the mod:
- Gagginno project - https://gaggiuino.github.io/#/
- BaristaGadgets kit, specifically this video - https://www.youtube.com/watch?v=gj9qLIDaF9g
- Guide on how Gaggia machine works - https://comoricoffee.com/en/gaggia-classic-pro-circuit-diagram-en/
Example code from Adafruit for drivers development:
- MAX31865:
- SSD1327:
MAX31865 setup guide:
SSD1327 display setup guide: