// 8.3 Greenhouse climate controller - the modular version, written for review.
// Three layers: drivers (classes that own the hardware), logic (pure functions
// that know nothing about pins), and glue (setup and loop).
// Nothing in loop() blocks: a millis() scheduler runs the 2 s job.
#include <Wire.h>
#include <SPI.h>
// ---------- pins and constants: one place, no magic numbers further down ----------
const uint8_t PIN_SDA = 21;
const uint8_t PIN_SCL = 22;
const uint8_t PIN_SCK = 18;
const uint8_t PIN_MOSI = 23;
const uint8_t PIN_CS = 5; // MAX7219 chip select
const uint8_t PIN_FAN = 26; // MOSFET gate, never the fan itself
const uint8_t PIN_POT = 34; // set-point potentiometer, ADC1, input only
const uint32_t SAMPLE_MS = 2000; // sampling period
const float HYST = 1.0f; // fan on above set+1, off below set-1
const uint16_t LOG_N = 720; // 24 h of history, one entry every 2 min
const uint8_t LOG_EVERY = 60; // 60 samples x 2 s = 2 min
// ======================= DRIVER LAYER =======================
// BME280 over raw I2C registers. Temperature only: the humidity and pressure
// compensation formulas are NOT implemented here, and the class does not
// pretend otherwise.
class Bme280 {
public:
explicit Bme280(uint8_t addr) : addr_(addr) {}
bool begin() {
if (read8(0xD0) != 0x60) return false; // chip-ID register must read 0x60
t1_ = read16(0x88); // factory calibration, LSB first
t2_ = (int16_t)read16(0x8A);
t3_ = (int16_t)read16(0x8C);
write8(0xF2, 0x01); // humidity oversampling x1
write8(0xF4, 0x27); // temp x1, pressure x1, normal mode
return true;
}
// Returns degrees Celsius, or NAN when the bus transaction fails.
float readTempC() {
Wire.beginTransmission(addr_);
Wire.write(0xFA);
if (Wire.endTransmission() != 0) return NAN;
if (Wire.requestFrom(addr_, (uint8_t)3) != 3) return NAN;
int32_t msb = Wire.read(); // three separate statements:
int32_t lsb = Wire.read(); // the order of the reads matters
int32_t xlsb = Wire.read();
int32_t adc = (msb << 12) | (lsb << 4) | (xlsb >> 4);
// Integer compensation from the datasheet; the result is in 0.01 degC.
int32_t v1 = (((adc >> 3) - ((int32_t)t1_ << 1)) * (int32_t)t2_) >> 11;
int32_t d = (adc >> 4) - (int32_t)t1_;
int32_t v2 = (((d * d) >> 12) * (int32_t)t3_) >> 14;
int32_t centi = ((v1 + v2) * 5 + 128) >> 8;
return centi / 100.0f;
}
private:
uint8_t addr_;
uint16_t t1_ = 0;
int16_t t2_ = 0, t3_ = 0;
void write8(uint8_t reg, uint8_t val) {
Wire.beginTransmission(addr_);
Wire.write(reg);
Wire.write(val);
Wire.endTransmission();
}
uint8_t read8(uint8_t reg) {
Wire.beginTransmission(addr_);
Wire.write(reg);
if (Wire.endTransmission() != 0) return 0;
if (Wire.requestFrom(addr_, (uint8_t)1) != 1) return 0;
return Wire.read();
}
uint16_t read16(uint8_t reg) {
uint8_t lo = read8(reg);
uint8_t hi = read8((uint8_t)(reg + 1));
return ((uint16_t)hi << 8) | lo;
}
};
// MAX7219 eight-digit display over SPI. Its inputs need 3.5 V for a logic
// high, so the three lines pass through a level shifter on the way there.
class Max7219 {
public:
explicit Max7219(uint8_t cs) : cs_(cs) {}
void begin() {
pinMode(cs_, OUTPUT);
digitalWrite(cs_, HIGH);
SPI.begin(PIN_SCK, -1, PIN_MOSI, cs_);
send(0x0F, 0x00); // display test off
send(0x09, 0xFF); // BCD decode on all digits
send(0x0B, 0x07); // scan all eight digits
send(0x0A, 0x04); // brightness
for (uint8_t d = 1; d <= 8; d++) send(d, BLANK);
send(0x0C, 0x01); // leave shutdown mode
}
// Shows a value given in tenths: 234 is displayed as 23.4
void showTenths(int16_t v) {
bool neg = v < 0;
uint16_t a = neg ? -v : v;
if (a > 999) a = 999;
uint8_t tens = a / 100;
send(1, a % 10); // digit 1 is the rightmost
send(2, ((a / 10) % 10) | 0x80); // bit 7 lights the decimal point
send(3, tens ? tens : (neg ? MINUS : BLANK));
send(4, (tens && neg) ? MINUS : BLANK);
}
private:
static const uint8_t MINUS = 0x0A, BLANK = 0x0F;
uint8_t cs_;
void send(uint8_t reg, uint8_t val) {
SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
digitalWrite(cs_, LOW);
SPI.transfer(reg);
SPI.transfer(val);
digitalWrite(cs_, HIGH); // data is latched on this edge
SPI.endTransaction();
}
};
// The 12 V fan behind a logic-level MOSFET.
class Fan {
public:
explicit Fan(uint8_t pin) : pin_(pin) {}
bool begin() {
bool ok = ledcAttach(pin_, 25000, 8); // 25 kHz, 8-bit duty
ledcWrite(pin_, 0);
return ok;
}
void set(bool on) {
on_ = on;
ledcWrite(pin_, on ? 255 : 0);
}
bool isOn() const { return on_; }
private:
uint8_t pin_;
bool on_ = false;
};
// Set-point potentiometer: the full turn covers 15 to 35 degC.
class SetPot {
public:
explicit SetPot(uint8_t pin) : pin_(pin) {}
void begin() { analogReadResolution(12); }
float readC() { return 15.0f + analogRead(pin_) * 20.0f / 4095.0f; }
private:
uint8_t pin_;
};
// ======================= LOGIC LAYER =======================
// No pins, no Wire, no SPI below this line - which is why it can be tested
// on the bench with nothing connected.
// Hysteresis rule. Inside the band the fan keeps its present state.
bool fanWanted(float t, float tSet, bool on) {
if (t > tSet + HYST) return true;
if (t < tSet - HYST) return false;
return on;
}
// 23.44 degC becomes 234: two bytes instead of four, exact to 0.1 degC.
int16_t toTenths(float t) {
return (int16_t)lroundf(t * 10.0f);
}
int16_t logBuf[LOG_N]; // 720 x 2 = 1440 bytes
uint16_t logHead = 0, logCount = 0;
// Ring buffer: when it is full the oldest entry is overwritten.
void logPush(int16_t tenths) {
logBuf[logHead] = tenths;
logHead = (logHead + 1) % LOG_N;
if (logCount < LOG_N) logCount++;
}
// Unit test of the logic layer. It needs no sensor, no display and no fan.
bool selfTest() {
struct Case { float t, tSet; bool on, want; };
static const Case cases[] = {
{26.1f, 25.0f, false, true }, // above the upper threshold
{23.9f, 25.0f, true, false}, // below the lower threshold
{25.5f, 25.0f, false, false}, // inside the band, was off
{25.5f, 25.0f, true, true }, // inside the band, was on
{26.0f, 25.0f, false, false}, // exactly on the upper threshold
{24.0f, 25.0f, true, true }, // exactly on the lower threshold
};
uint8_t fails = 0;
for (const Case &c : cases) {
bool got = fanWanted(c.t, c.tSet, c.on);
if (got != c.want) {
fails++;
Serial.printf("FAIL fanWanted(%.1f, %.1f, %d) = %d\n", c.t, c.tSet, c.on, got);
}
}
if (toTenths(23.44f) != 234) { fails++; Serial.println("FAIL toTenths(23.44)"); }
if (toTenths(-5.04f) != -50) { fails++; Serial.println("FAIL toTenths(-5.04)"); }
Serial.println(fails ? "selfTest: FAIL" : "selfTest: PASS");
return fails == 0;
}
// ======================= GLUE =======================
Bme280 sensor(0x76);
Max7219 disp(PIN_CS);
Fan fan(PIN_FAN);
SetPot pot(PIN_POT);
void setup() {
Serial.begin(115200);
Wire.begin(PIN_SDA, PIN_SCL);
selfTest(); // the logic is checked first
if (!sensor.begin()) Serial.println("BME280 not found: chip ID is not 0x60");
if (!fan.begin()) Serial.println("fan PWM could not be attached");
disp.begin();
pot.begin();
}
void loop() {
static uint32_t last = 0;
static uint8_t sinceLog = 0;
uint32_t now = millis();
if (now - last >= SAMPLE_MS) { // unsigned math survives rollover
last += SAMPLE_MS; // fixed step: the period cannot drift
float t = sensor.readTempC();
if (isnan(t)) {
fan.set(true); // blind controller: ventilate
Serial.println("sensor error - fan forced on");
return;
}
float tSet = pot.readC();
fan.set(fanWanted(t, tSet, fan.isOn()));
int16_t tenths = toTenths(t);
disp.showTenths(tenths);
if (++sinceLog >= LOG_EVERY) {
sinceLog = 0;
logPush(tenths);
}
Serial.printf("T=%.2f set=%.1f fan=%d log=%u\n", t, tSet, fan.isOn(), logCount);
}
// Execution falls through here thousands of times a second, so a button,
// a serial command or a second timer can be served without waiting.
}