196. Mini-project: a thermostat on the BluePill
The 🔧 recipes in the sections are building bricks: an LED, the ADC, the UART, writing to Flash. A mini-project is a finished device made of several bricks, with everything that sets a device apart from an example: it survives a sensor failure, does not click its relay like a machine gun, remembers its settings after power-off and answers on the console.
The first device is a thermostat: a thermistor on the ADC, a heater relay, a UART console, settings in Flash. And the main point of the project's structure: the device logic knows nothing about registers. Everything hardware-related is in one platform file; that is why the same logic can be run on a computer together with a model of the room, and you can see how the device behaves over four hours before you have even built the board.
The project files are in _verify/proj196/: thermo.h, thermo.c (logic),
plat_f103.c (the BluePill platform), sim.c (the model platform).
The firmware is built for Cortex-M3, the model is run on a computer.
Checked by _verify/proj196_check.py.
Terms used in the documentation
Term Meaning thermostat, bang-bang control a thermostat, on/off control hysteresis, deadband hysteresis, a band where the output does not react NTC thermistor, B-constant a thermistor with a negative temperature coefficient, the B constant minimum on/off time the shortest time between switchings platform layer, hardware abstraction the platform layer: everything that touches the hardware host-based test, plant model a run on the computer, a model of the controlled object
196.1 What the device does
| Function | How |
|---|---|
| measure the temperature | a 10 kΩ NTC in a divider on PA0, the ADC, 16 samples, a table with interpolation |
| hold the setpoint | turn heating on at T ≤ setpoint − 0.5 °C, turn it off at T ≥ setpoint + 0.5 °C |
| protect the relay | switch no more often than once every 60 s |
| survive a sensor failure | no plausible reading for 3 s — turn heating off, LED on PC13, АВАРИЯ ("alarm") on the console |
| listen to the console | get, set 22.5, hyst 0.3 over USART1, 115200 baud |
| remember the settings | the last Flash page (0x0800FC00), under a CRC-16; if corrupted — factory defaults |
Wiring:
| BluePill pin | Connected to |
|---|---|
| PA0 | the divider midpoint: a 10 kΩ NTC to 3.3 V, 10 kΩ to ground |
| PB12 | the base of the relay switch transistor (section 52), a diode across the coil |
| PC13 | the board LED: lit means alarm |
| PA9 / PA10 | USART1 TX / RX → a USB-UART adapter |
196.2 Program structure: logic separate from hardware
The logic talks to the world only through six platform functions:
int16_t plat_read_temp(void); /* десятые доли °C или TEMP_FAIL */
void plat_relay(int on);
uint32_t plat_now_ms(void);
void plat_puts(const char *s);
int plat_cfg_load(struct thermo_cfg *c); /* 0 — прочитано */
void plat_cfg_save(const struct thermo_cfg *c);
On the board they are implemented by plat_f103.c on registers, on the computer by sim.c
with the room model. The logic (thermo.c) is one and the same, without a single line written
separately for the computer. This is the same technique as in section 184: a module that
can be tested without a board.
The main loop on the board is simple, with no RTOS: thermo_tick every 100 ms,
every received character goes to thermo_rx. Time comes from SysTick, 1 ms.
The state logic has three states: "idle", "heating", "alarm". Transitions:
- "idle" → "heating": T ≤ setpoint − hysteresis, and 60 s have passed since the last switching;
- "heating" → "idle": T ≥ setpoint + hysteresis, and 60 s have passed;
- any → "alarm": no plausible reading for 3 s — the relay is switched off at once, without waiting 60 s;
- "alarm" → "idle": the sensor responds again.
196.3 The sensor: a table instead of a logarithm
The resistance of an NTC depends on temperature exponentially, and the exact formula needs
a logarithm. On a Cortex-M3 without an FPU that is expensive, so the temperature is computed from
a table of 17 points (every 5 °C from −20 to 60) with linear interpolation between
them. The table was computed by ntc_table.py from the formula with the constant B = 3950:
| °C | −20 | 0 | 25 | 50 | 60 |
|---|---|---|---|---|---|
| ADC code | 355 | 939 | 2048 | 3014 | 3280 |
- At 25 °C the resistances are equal — the divider halves the voltage, code 2048.
- The largest interpolation error over all codes in the range is 0.13 °C (near −17.5 °C); in the room range it is smaller.
- Around 25 °C there are 45.5 ADC units per degree — tenths of a degree can be distinguished.
- A code outside the table (less than 355 or more than 3280) is treated by the function as a failure,
and it returns
TEMP_FAIL. A real open circuit gives a code near 0 (the NTC is connected to 3.3 V, and the input is pulled to ground by a resistor), a short gives about 4095; codes between those and the table edges are simply temperatures below −20 or above 60 °C. For a room that is an alarm, but a thermostat in an unheated garage cannot work this way: there the table is extended downwards, and only codes near the ends of the scale count as an alarm.
196.4 A run on the room model
The model: the room cools towards an outdoor 5 °C with a time constant of 30 minutes,
the heater could raise it to 35 °C; the sensor has noise of ±0.1 °C. The step is
100 ms, four hours. The scenario: start at 15 °C; after an hour, get and set 22.5
on the console; after two hours the sensor disappears for a minute; at the end,
corrupted settings and invalid commands.
| What | Result |
|---|---|
| time from start to 21 °C | 642 s |
| swing in steady state (setpoint 21.0) | 20.65…21.35 °C |
| heating cycle period | 169 s |
| relay switchings in the first hour | 36 |
| reaction to a sensor failure | heating off after 3.1 s |
| heater on | 59.6 % of the time |
setpoint in "Flash" after set 22.5 |
225, CRC matches |
| after corrupting one bit of the settings | setpoint 210 — factory default |
The swing is narrower than the set band of ±0.5 °C: the relay switches on the noisy reading, which every now and then jumps to the threshold 0.15 °C early: 0.1 is noise, 0.05 is rounding to tenths.
The console during the run:
cfg: заводские
t=21.2 set=21.0 ждёт
ok
АВАРИЯ: датчик
датчик в норме
t=22.2 set=22.5 ждёт
Invalid commands (set 99, hyst abc) got a hint; a line longer than
31 characters was discarded with a message.
CRC check. The function thermo_crc16 on the string 123456789 gives
0x29B1 — the check value of CRC-16/CCITT-FALSE: the implementation is correct.
196.5 Firmware for the BluePill
The plat_f103.c platform works on registers through the CMSIS header for the F103:
- clock: the internal HSI 8 MHz, no PLL needed;
- USART1: divider 69 → 115942 baud, the difference from 115200 is 0.64 %;
- ADC: sample time 239.5 cycles, calibration after power-on; writing ADON again starts a conversion;
- settings: Flash unlocked with two keys, page erase, writing in half-words (section 11.5).
Built with arm-none-eabi-gcc -Os for Cortex-M3, with no warnings:
| What | Bytes |
|---|---|
| whole firmware (Flash) | 1968 |
| RAM (variables) | 64 |
of which the thermo.c logic — code |
904 |
| logic — message strings | 248 |
Less than 2 KB out of 64 — there is room both for a measurement log and for a display.
196.6 What to improve
- A log: once a minute write the temperature to a ring buffer in Flash
(section 11.8) and output it on the
logcommand. - A PI controller instead of hysteresis — if the heater is driven by PWM rather than a relay (sections 47 and 57).
- A watchdog timer (section 12): if the program hangs with heating on, it resets; after the reset the relay pin is an input, and the switch is turned off by the resistor on the base.
- A second sensor and a comparison of readings: if they differ by more than 2 °C, it is an alarm.
196.7 Pitfalls
The relay clicks on every bit of noise. There is no hysteresis, or the band is narrower than the sensor noise. In the model, the noise of ±0.1 °C and the rounding of the reading to tenths (another 0.05 °C) together shift the thresholds by 0.15 °C: heating turns on already at 20.65 °C and turns off at 21.35 °C — a swing of ±0.35 instead of the set ±0.5.
The relay "stuck" in the on position when the sensor wire broke. The failure was not recognised: on an open circuit the ADC code is at the edge of the range, and it must be treated as an alarm, not as −20 °C.
Switching off on alarm waits 60 seconds. The relay protection against frequent switching was also applied to the alarm. An alarm switches off at once.
After reflashing — cfg: заводские ("factory defaults"). Flashing with a full chip erase
also erased the settings page. To keep them, erase only the pages occupied
by the program.
The settings "got lost" after a power loss during a write. The page was already erased, but the new data had not been written yet. The CRC catches this — you get the factory defaults; more reliable is two pages used in turn (like the metadata in section 195).
The console "hung". A line without a newline keeps accumulating; if it is longer than the buffer, throw it away and say so.
196.8 Summary
- Thermostat = ADC + relay + console + Flash, but the main thing is the behaviour on failures and the relay protection.
- The logic is separated from the hardware by six platform functions — and runs on a computer with a room model.
- The NTC is read from a table of 17 points: the error is no more than 0.13 °C, 45.5 ADC units per degree.
- The model: 642 s to the setpoint, a swing of 20.65…21.35 °C, a 169 s cycle, reaction to a sensor failure in 3.1 s, corrupted settings detected by the CRC.
- The BluePill firmware takes 1968 bytes of Flash and 64 bytes of RAM.
Full sources
thermo.h — the interface of the device logic and of the platform functions.
/* Мини-проект «Термостат»: логика прибора без железа.
* Всё, что касается выводов, датчика, времени и памяти настроек, — в функциях платформы (plat_*),
* которые на плате пишутся поверх регистров, а на компьютере — поверх модели комнаты. */
#ifndef THERMO_H
#define THERMO_H
#include <stdint.h>
#define TEMP_FAIL (-32768) /* датчик не ответил */
#define TEMP_MIN_C10 (-400) /* правдоподобный диапазон, десятые доли градуса */
#define TEMP_MAX_C10 (1250)
/* настройки: хранятся с CRC-16, иначе берутся заводские */
struct thermo_cfg {
int16_t setpoint_c10; /* уставка, 0,1 °C */
int16_t hyst_c10; /* половина полосы гистерезиса, 0,1 °C */
uint16_t min_switch_s; /* реле не переключать чаще, секунд */
uint16_t crc;
};
enum thermo_state { T_IDLE, T_HEAT, T_FAULT };
struct thermo {
struct thermo_cfg cfg;
enum thermo_state state;
int16_t temp_c10;
uint32_t last_switch_ms;
uint32_t last_good_ms;
uint32_t switches;
char line[32];
uint8_t line_len;
};
/* платформа */
int16_t plat_read_temp(void); /* десятые доли °C или TEMP_FAIL */
void plat_relay(int on);
uint32_t plat_now_ms(void);
void plat_puts(const char *s);
int plat_cfg_load(struct thermo_cfg *c); /* 0 — прочитано */
void plat_cfg_save(const struct thermo_cfg *c);
/* логика */
uint16_t thermo_crc16(const void *p, uint32_t n);
void thermo_init(struct thermo *t);
void thermo_tick(struct thermo *t); /* раз в 100 мс */
void thermo_rx(struct thermo *t, char c); /* символ из консоли */
#endif
Next: section 197 "Mini-project: a frequency counter on a timer".