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 approach | Discrete backup approach |
|---|---|
| ESP32 runs continuously (~100 mA) | ESP32 is off; ATtiny sleeps (~0.1 µA) |
| 18650 cell (3400 mAh) lasts ~24 h | AA 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 cycles | Alkaline AAs have no cycle limit |
| Full alarm audio and display in battery mode | Buzzer-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:
- 2× AA batteries (3 V total) — the power source
- ATtiny85 microcontroller (DIP-8) — the "brain" of the backup circuit
- PN2222 NPN transistor — drives the buzzer from the ATtiny's output pin
- 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.
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.
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:
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:
- Stops WiFi (saves 200–300 mA and avoids spurious NTP updates)
- 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
| Parameter | Value |
|---|---|
| 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 life | 2500 mAh ÷ 0.0001 mA = 25,000,000 hours |
| Practical limit | Alkaline 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
| Concept | Key takeaway |
|---|---|
| Discrete backup vs li-ion | Trade display+audio-in-outage for 5–10 year battery life and no charging circuit |
| Active piezo | Built-in oscillator — apply DC, get tone; no PWM needed |
| PN2222 transistor | Isolates 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-ups | 10 kΩ on SNOOZE/DISMISS lines; ESP32 internal pull-ups are inactive when ESP32 is off |
| GPIO41 voltage divider | Tells the ESP32 whether USB is present while it is still running |