Skip to main content

Battery Backup and Power Management

A clock that loses power loses its purpose. This page explains the design choice behind a discrete backup circuit instead of a full li-ion battery system, how the ATtiny85 and DS3231 work together to fire the alarm without the ESP32, and how the firmware detects and responds to power loss.


Why Discrete Instead of Li-ion?

The obvious approach to a "clock that works through a power cut" is a li-ion cell with a boost converter — the ESP32 keeps running on battery, the display stays on, audio plays normally. That design works but has real costs:

Li-ion + boost approachDiscrete backup approach
ESP32 runs continuously (~100 mA)ESP32 is off; ATtiny sleeps (~0.1 µA)
18650 cell (3400 mAh) lasts ~24 hAA batteries last 5–10+ years
Needs charging circuit (PowerBoost 1000C)No charging circuit
Boost converter required (li-ion is 3.7 V, amp needs 5 V)No boost converter
Cell degrades after ~500 charge cyclesAlkaline AAs have no cycle limit
Full alarm audio and display in battery modeBuzzer-only alarm during outage

The trade-off is clear: accept a simpler alarm sound during power outages, and in return gain dramatically lower parts cost, simpler wiring, and batteries that outlast the project.

For a bedside clock on a stable outlet, a power outage is a rare edge case. A buzzer is sufficient to wake someone; the full audio experience is a nice-to-have.


The Backup Circuit Topology

The backup circuit has four components:

  1. 2× AA batteries (3 V total) — the power source
  2. ATtiny85 microcontroller (DIP-8) — the "brain" of the backup circuit
  3. PN2222 NPN transistor — drives the buzzer from the ATtiny's output pin
  4. Active piezo buzzer (3 V rated) — makes the sound

What "active" means: a piezo buzzer comes in two variants. A passive buzzer is just a piezoelectric disc — you must apply a PWM signal at the resonant frequency to get a tone. An active buzzer has a built-in oscillator; apply DC power and it produces a fixed tone automatically. Use an active buzzer here — the ATtiny just drives a GPIO high, no frequency generation required.

Why a transistor? The ATtiny85's GPIO output can source or sink about 40 mA maximum. An active piezo buzzer can draw 30–100 mA. Rather than risk overloading the ATtiny pin, a PN2222 transistor sits between the pin and the buzzer: the ATtiny drives a small base current (~1 mA through a ~1 kΩ base resistor), and the transistor's collector-emitter path handles the full buzzer current.

For software engineers: this is the same pattern as using a MOSFET driver IC to control a high-current LED — your GPIO controls the gate, the gate controls the high-current path.

Discrete backup circuit diagram


The DS3231 SQW Pin as the Trigger

The DS3231 RTC has two internal alarm registers (Alarm 1 and Alarm 2). These store a time-of-day value — when the RTC's internal clock matches that value, the DS3231 pulls its SQW/INT pin LOW.

The SQW pin is open-drain: the DS3231 can only pull it low; an external pull-up resistor (10 kΩ to 3.3 V, typically on the breakout board) holds it high at all other times.

The SQW line is wired to two places:

  • ESP32 GPIO42 — when USB power is present, the firmware catches the low edge as an interrupt and fires the alarm normally (audio + display)
  • ATtiny85 PB3 / INT0 — when USB power is absent (ESP32 is off), the ATtiny is the only listener; the low edge wakes it from deep sleep

Sharing the line between two listeners is safe because it is open-drain and the DS3231 is the only device that drives it. Both the ESP32 and the ATtiny are passive listeners.

The key insight: the alarm time is set by the ESP32 while USB power is present. The ESP32 writes the next alarm into the DS3231's Alarm 1 registers over I2C every time an alarm is configured. When USB power is later lost, those registers remain intact — the RTC keeps running on its CR2032 coin cell and fires at exactly the right time regardless of whether the ESP32 is on.


ATtiny Deep Sleep and Wake-on-Interrupt

The ATtiny85 spends almost all of its life in power-down sleep mode — the deepest sleep available, with the CPU, most oscillators, and peripherals stopped. Current draw in this state: approximately 0.1 µA. The only thing still running is the external interrupt logic, which watches the INT0 pin (PB3).

When the DS3231 pulls SQW LOW, INT0 fires a low-level interrupt. The ATtiny wakes, executes the interrupt service routine (ISR), and begins sounding the alarm:

  • ATtiny wakes on INT0 (SQW LOW)
    • drive PB0 HIGH → transistor conducts → buzzer sounds
    • watch PB1 (SNOOZE) and PB2 (DISMISS) for button presses
    • if SNOOZE pressed: stop buzzer, wait 9 min via internal timer, restart
    • if DISMISS pressed: stop buzzer, go back to deep sleep
    • if no press after N cycles: stop buzzer anyway (prevent indefinite sounding)

No I2C is involved. The ATtiny never talks to the DS3231; it simply reacts to the SQW signal. This keeps the ATtiny firmware very small (fits comfortably in the ATtiny85's 8 KB flash) and avoids any need for I2C bus arbitration between the ATtiny and ESP32.


The External Pull-up Requirement for SNOOZE and DISMISS

The SNOOZE (GPIO38) and DISMISS (GPIO39) button lines connect to both the ESP32 and the ATtiny. This is straightforward when both are powered — but when the ESP32 is off, there is a subtle problem.

The ESP32 has internal pull-up resistors on its GPIO pins. When firmware configures GPIO38 as an input with GPIO_PULLUP_ENABLE, the internal pull-up (≈45 kΩ) holds the pin at 3.3 V when the button is not pressed. This keeps the ATtiny input HIGH and stable.

But the internal pull-up only works when the ESP32 is powered and the GPIO is configured. When the ESP32 has no power, the internal pull-up is off. GPIO38 and GPIO39 are connected to the button (which goes to GND on press), the ATtiny input pin, and nothing else — they float at an indeterminate voltage. The ATtiny would read random noise as button presses and could snooze or dismiss the alarm spuriously.

Solution: add 10 kΩ external pull-up resistors from the SNOOZE and DISMISS lines to 3.3 V. These are always present, powered or not, so the ATtiny always sees a clean HIGH when the buttons are not pressed.

Shared pull-up: 3.3 V through 10 kΩ to a junction feeding ATtiny PB1 (SNOOZE input) and, one tap lower, ESP32 GPIO38; a button below connects the line to GND

This is a general principle for shared signal lines: any input that must be valid while one side of the shared connection is unpowered needs an external pull-up or pull-down to provide a stable bias independently of either chip's internal circuitry.


Detecting Power Loss in Firmware: GPIO41

The ESP32 still needs to know when USB power disappears, so it can disable WiFi (which draws 200–300 mA and serves no purpose during an outage) and update the display with a warning.

A simple voltage divider off the USB 5 V rail feeds GPIO41:

Voltage divider: USB 5 V through R1 (100 kΩ) to a junction, then R2 (100 kΩ) to GND; GPIO41 taps the midpoint to detect USB presence

When USB is connected: the divider midpoint is ~2.5 V → GPIO41 reads HIGH.
When USB is removed: the divider input is 0 V → GPIO41 reads LOW.

The ESP32 power_monitor task polls GPIO41 every few seconds and posts EVT_POWER_LOSS or EVT_POWER_RESTORED to the state machine event queue. In response to EVT_POWER_LOSS the firmware:

  1. Stops WiFi (saves 200–300 mA and avoids spurious NTP updates)
  2. Updates the display with a power-loss indicator

That is all. The ESP32 does not enter light sleep, does not set up DS3231 alarm interrupts for itself, and does not hand off audio playback. Those responsibilities belong to the ATtiny. The ESP32 will simply go dark when USB power drops — and the ATtiny will handle the alarm independently at the programmed time.

See the power loss firmware doc for the implementation.


AA Battery Life Calculation

ParameterValue
ATtiny deep sleep current~0.1 µA
Buzzer firing current~30–50 mA (brief, during alarm only)
2× AA capacity~2500 mAh at 1.5 V each = 3 V supply
Theoretical quiescent life2500 mAh ÷ 0.0001 mA = 25,000,000 hours
Practical limitAlkaline self-discharge: 1–2% per year → 5–10 years

The quiescent current is so low that the batteries will exhaust themselves through self-discharge long before the ATtiny drains them actively. Replace the AA batteries annually as a matter of routine, or whenever you replace the clock's power supply.


Summary

ConceptKey takeaway
Discrete backup vs li-ionTrade display+audio-in-outage for 5–10 year battery life and no charging circuit
Active piezoBuilt-in oscillator — apply DC, get tone; no PWM needed
PN2222 transistorIsolates ATtiny GPIO from full buzzer current
DS3231 SQW (open-drain)Fires at alarm time regardless of ESP32 state; both ESP32 and ATtiny listen passively
ATtiny deep sleep~0.1 µA; wakes on INT0 low edge from SQW
External pull-ups10 kΩ on SNOOZE/DISMISS lines; ESP32 internal pull-ups are inactive when ESP32 is off
GPIO41 voltage dividerTells the ESP32 whether USB is present while it is still running