Heated Tipping Bucket Rain Gauge (TBRG) on ATTiny85 (I2C)

This sketch turns an ATtiny (or Arduino) into:
• a rain‐gauge collector (counting bucket‐tips via a reed switch)
• a temperature sensor (NTC thermistor)
• a heater‐controller (PWM output)
• an I²C-slave interface to report rain and temperature data

You can choose, at compile time, whether each rain tip is converted to millimetres by

  1. supplying a bucket volume (mL) and collection area (m²), or
  2. hard-coding a fixed mm-per-tip value

via the single switch at the top of the sketch:

• set #define USE_AREA_CALC 1
–– sketch computes


• set #define USE_AREA_CALC 0
–– sketch uses your FIXED_MM_PER_PULSE

I²C registers (master → slave via Wire.requestFrom):
• reg 0: rain in current 1-min window (hundredths mm)
• reg 2: rain in last 1-min window
• reg 4: rain in last 10 min (sum of ten 1-min windows)
• reg 6: temperature, in tenths °C (signed 16-bit)
• reg 8: heater PWM duty (0–255)

#include <Arduino.h>
#include <Wire.h>   

// ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
// CONFIGURATION SWITCH:
//   if 1 → compute MM_PER_PULSE from bucket volume + area
//   if 0 → use a fixed MM_PER_PULSE value directly
#define USE_AREA_CALC   1
// ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––

// PIN ASSIGNMENTS (Arduino numbering)
#define RAIN_PIN        4   // PB4 (physical pin 3)
#define NTC_PIN         A3  // PB3/ADC3 (physical pin 2)
#define HEATER_PWM_PIN  1   // PB1/OC1A (physical pin 6)

// I²C slave address
#define SLAVE_ADDR      0x20

// RAIN CALIBRATION
#if USE_AREA_CALC

  // compute mm per pulse from bucket volume and collecting area
  const float TIP_VOLUME_mL = 0.2;   // mL per tip
  const float AREA_M2       = 0.01;  // m²
  const float MM_PER_PULSE  = TIP_VOLUME_mL / (1000.0 * AREA_M2);

#else

  // hard-code the rainfall depth per tip (in millimeters)
  // e.g. if each tip = 0.5 mm of rain:
  const float FIXED_MM_PER_PULSE = 0.5;
  const float MM_PER_PULSE       = FIXED_MM_PER_PULSE;

#endif

// NTC β‐formula constants
const float R_REF = 10000.0, T0_K = 298.15, BETA = 3950.0, R0 = 10000.0;

// HEATER CURVE (°C)
const float T_FULL = 15.0;  // ≤ → 100% duty
const float T_ZERO = 25.0;  // ≥ →   0% duty

// TIMING
const uint32_t INTERVAL_MS = 60000UL;  // 1 min
const uint32_t DEBOUNCE_MS = 50UL;     // ms

// STATE
volatile uint16_t rainTips     = 0;     // count in current minute
uint16_t        rainCounts[10] = {0};   // last 10 one-min counts
uint8_t         bufIndex       = 0;

// for debounce & edge detect
volatile uint32_t lastTipMs    = 0;
volatile bool     lastPinState = LOW;

// I²C‐REQUEST handler prototype
void onRequest();

// RAIN‐TIP INTERRUPT (debounced, rising-edge only)
ISR(PCINT0_vect) {
  uint32_t now = millis();
  bool cur = digitalRead(RAIN_PIN);
  // ignore if still bouncing
  if (now - lastTipMs < DEBOUNCE_MS) {
    lastPinState = cur;
    return;
  }
  // count only LOW→HIGH
  if (cur == HIGH && lastPinState == LOW) {
    rainTips++;
    lastTipMs = now;
  }
  lastPinState = cur;
}

void onRequest() {
  uint8_t reg = Wire.read();
  switch (reg) {
    case 0: {  // current minute
      uint16_t v = uint16_t(rainTips * MM_PER_PULSE * 100.0 + 0.5);
      Wire.write(lowByte(v));
      Wire.write(highByte(v));
      break;
    }
    case 2: {  // last minute
      uint16_t tips = rainCounts[(bufIndex + 9) % 10];
      uint16_t v    = uint16_t(tips * MM_PER_PULSE * 100.0 + 0.5);
      Wire.write(lowByte(v));
      Wire.write(highByte(v));
      break;
    }
    case 4: {  // last 10 minutes
      uint32_t sum = 0;
      for (uint8_t i = 0; i < 10; i++) sum += rainCounts[i];
      uint16_t v = uint16_t(sum * MM_PER_PULSE * 100.0 + 0.5);
      Wire.write(lowByte(v));
      Wire.write(highByte(v));
      break;
    }
    case 6: {  // temperature
      extern int16_t lastTemp10;
      uint16_t t10 = uint16_t(lastTemp10);
      Wire.write(lowByte(t10));
      Wire.write(highByte(t10));
      break;
    }
    case 8:    // PWM duty
      extern uint8_t lastOCR;
      Wire.write(lastOCR);
      break;
    default:
      Wire.write((uint8_t)0);
  }
}

int16_t  lastTemp10 = 0;  // tenths °C
uint8_t  lastOCR    = 0;  // 0–255 PWM duty
uint32_t lastInterval;

void setup() {
  // rain pin + interrupt
  pinMode(RAIN_PIN, INPUT_PULLUP);
  GIMSK  = _BV(PCIE);
  PCMSK  = _BV(PCINT4);

  // heater PWM pin
  pinMode(HEATER_PWM_PIN, OUTPUT);
  analogWrite(HEATER_PWM_PIN, 0);

  // I²C slave
  Wire.begin(SLAVE_ADDR);
  Wire.onRequest(onRequest);

  lastInterval = millis();
}

void loop() {
  uint32_t now = millis();

  // every minute, shift buffer
  if (now - lastInterval >= INTERVAL_MS) {
    lastInterval += INTERVAL_MS;
    rainCounts[bufIndex] = rainTips;
    bufIndex = (bufIndex + 1) % 10;
    rainTips = 0;
  }

  // read NTC via analogRead()
  int raw = analogRead(NTC_PIN);
  float vR = float(raw) / 1023.0;
  float r  = R_REF * (1.0 / vR - 1.0);
  float invT = 1.0 / T0_K + log(r / R0) / BETA;
  float tK   = 1.0 / invT;
  float tC   = tK - 273.15;
  lastTemp10 = int16_t(round(tC * 10.0));

  // compute linear PWM duty
  float dutyF;
  if      (tC <= T_FULL) dutyF = 1.0;
  else if (tC >= T_ZERO) dutyF = 0.0;
  else                   dutyF = (T_ZERO - tC) / (T_ZERO - T_FULL);

  lastOCR = uint8_t(dutyF * 255.0 + 0.5);
  analogWrite(HEATER_PWM_PIN, lastOCR);
}


WIRING

Below is a wiring guide for the SLAVE unit (your rain-gauge + NTC + heater controller).

  1. Power
    • VCC → +5 V (or +3.3 V if your ATtiny/Arduino runs at 3.3 V)
    • GND → common ground
  2. I²C bus (shared with the master)
    • SDA → MCU SDA pin (ATtiny85 PB0 / Arduino A4)
    • SCL → MCU SCL pin (ATtiny85 PB2 / Arduino A5)
    • Pull-ups: 4.7 kΩ resistors from SDA→VCC and SCL→VCC
  3. Rain-gauge “bucket‐tip” input
    • RAIN_PIN (PB4 / Arduino D4) ←→ one side of your reed switch or optocoupler output
    • Other side of reed switch ←→ GND
    • Internal pull-up is enabled in software, so no external pull-up resistor is needed on PB4
  4. NTC thermistor
    • Connect NTC between NTC_PIN (PB3 / Arduino A3) and GND
    • Connect R_REF (10 kΩ reference resistor) between NTC_PIN and VCC
    • This forms a voltage divider: V(NTC_PIN) = VCC × R_NTC/(R_NTC + R_REF)
  5. Heater PWM output
    • HEATER_PWM_PIN (PB1 / Arduino D1) → gate of an N-channel MOSFET (or transistor)
    • MOSFET source → GND; MOSFET drain → low side of heater element
    • Other side of heater element → VCC (or separate heater supply, same ground)
    • Add a flyback/free-wheeling diode if heater is inductive

In simplest form:
HEATER_PWM_PIN → [gate MOSFET]
MOSFET DRAIN → HEATER(–)
HEATER(+) → +VCC
MOSFET SOURCE → GND

  1. Summary table

Pin Type MCU Connect to
────── ───────── ───── ─────────────────────────
VCC power VCC +5 V (or +3.3 V)
GND power GND ground
SDA I²C data PB0/A4 master SDA, +4.7 k pull-up
SCL I²C clock PB2/A5 master SCL, +4.7 k pull-up
RAIN_PIN digital PB4/D4 reed switch → GND
NTC_PIN analog PB3/A3 NTC → GND; 10 kΩ → VCC
HEATER_PWM PWM PB1/D1 gate of MOSFET→heater→VCC

With this wiring in place, upload the slave sketch. The internal pull-up on RAIN_PIN cleanly biases the rain-gauge input high until the reed switch closes to GND, triggering the interrupt. The NTC divider on A3 measures temperature, and the PWM pin drives your heater via the MOSFET.

MASTER SKETCH WITH PULSE TESTER

Below is an enhanced “master” sketch that:

  1. Generates simulated rain‐gauge pulses on a digital pin (so you can wire that pin to the slave’s RAIN_PIN).
  2. Polls the slave once per second to read actual rain and temperature data.
  3. Prints both the slave’s measured rain and the master’s expected rain (based on the pulse rate you chose).

Wire-up:
• Connect Master D2 → Slave RAIN_PIN (with a pull-up on the slave)—this lets the master drive the reed-switch input.
• I²C SDA, SCL shared between master and slave as usual.

#include <Wire.h>

#define SLAVE_ADDR       0x20

// pulse generator on D2 → slave's rain pin
const uint8_t  PULSE_PIN        = 2;
// how many pulses per minute you want to generate:
const uint16_t PULSES_PER_MIN   = 30;
// and how many mm each pulse should represent (match your slave's MM_PER_PULSE):
const float    MM_PER_PULSE_SIM = 0.5;

// derived timing in ms between rising edges:
const float INTERVAL_MS_PER_PULSE = 60000.0 / float(PULSES_PER_MIN);
uint32_t lastPulseTime = 0;
bool     pulseState    = LOW;

//———————————————————————————————————————————————————————————————
// helper to read a 16-bit little-endian register from the slave
uint16_t readReg16(uint8_t reg) {
  Wire.beginTransmission(SLAVE_ADDR);
  Wire.write(reg);
  Wire.endTransmission(false);
  Wire.requestFrom(SLAVE_ADDR, uint8_t(2));
  uint8_t lo = Wire.read();
  uint8_t hi = Wire.read();
  return uint16_t(lo) | (uint16_t(hi) << 8);
}

void setup() {
  Serial.begin(115200);
  Wire.begin();               // join I²C as master

  // set up the pulse-generator pin
  pinMode(PULSE_PIN, OUTPUT);
  digitalWrite(PULSE_PIN, LOW);
  lastPulseTime = millis();
}

void loop() {
  uint32_t now = millis();

  // ————————————— Pulse generator —————————————
  // toggle D2 at half the interval to form a LOW→HIGH transition each INTERVAL_MS_PER_PULSE
  if (now - lastPulseTime >= INTERVAL_MS_PER_PULSE / 2) {
    pulseState = !pulseState;
    digitalWrite(PULSE_PIN, pulseState);
    lastPulseTime += INTERVAL_MS_PER_PULSE / 2;
  }

  // ———————————— Poll the slave every 1 s ————————————
  static uint32_t lastPoll = 0;
  if (now - lastPoll >= 1000) {
    lastPoll = now;

    // read 16-bit regs
    uint16_t rawNow   = readReg16(0);  // hundredths mm
    uint16_t rawLast  = readReg16(2);
    uint16_t raw10min = readReg16(4);
    uint16_t rawTemp  = readReg16(6);  // tenths °C
    // read PWM duty:
    Wire.beginTransmission(SLAVE_ADDR);
    Wire.write(8);
    Wire.endTransmission(false);
    Wire.requestFrom(SLAVE_ADDR, uint8_t(1));
    uint8_t rawPWM = Wire.read();

    // convert
    float rainNow   = rawNow   / 100.0;        // mm in current 1-min
    float rainLast  = rawLast  / 100.0;        // mm last 1-min
    float rain10min = raw10min / 100.0;        // mm last 10-min
    float tempC     = int16_t(rawTemp) / 10.0; // °C
    float pwmDuty   = rawPWM / 255.0 * 100.0;  // % duty

    // expected rain (master’s generator) in mm/min
    float expectedRainPerMin = PULSES_PER_MIN * MM_PER_PULSE_SIM;

    // print results
    Serial.print("Generated pulses: ");
      Serial.print(PULSES_PER_MIN); Serial.print(" p/min → ");
      Serial.print(expectedRainPerMin,2); Serial.println(" mm/min");
    Serial.print(" Slave   now: ");   Serial.print(rainNow,2);   Serial.print(" mm/min, ");
    Serial.print("last: ");           Serial.print(rainLast,2);  Serial.print(" mm, ");
    Serial.print("10-min: ");         Serial.print(rain10min,2); Serial.println(" mm");
    Serial.print(" Temp: ");          Serial.print(tempC,1);     Serial.print(" °C, ");
    Serial.print("Heater PWM: ");     Serial.print(pwmDuty,1);   Serial.println(" %");
    Serial.println();
  }
}

How it works

  1. In loop(), we flip PULSE_PIN every half‐interval so that each LOW→HIGH edge occurs every
    INTERVAL_MS_PER_PULSE = 60 000 ms / PULSES_PER_MIN.
  2. Those edges trigger the slave’s rain‐tip interrupt just as if a physical bucket tipped.
  3. Once per second we read registers 0,2,4,6,8 from the slave and convert raw units into mm, °C, and %PWM.
  4. We also compute “expected” rain in mm/min = PULSES_PER_MIN × MM_PER_PULSE_SIM so you can compare generator vs. slave measurement.

Using the master’s settings in the example sketch:

• PULSES_PER_MIN = 30 pulses/min
• MM_PER_PULSE_SIM = 0.5 mm/pulse

Therefore the master “generator” produces:

– Pulses per minute: 30 p/min
– Rainfall per pulse: 0.5 mm/pulse
– ⇒ Expected rain rate:
30 p/min × 0.5 mm/p = 15 mm/min

Broken down further:
• 15 mm per minute
• 0.25 mm per second

If you change either parameter, just recompute:
expected rain (mm/min) = PULSES_PER_MIN × MM_PER_PULSE_SIM

Leave a Reply