A firefly lamp for the ATtiny412. Twelve LEDs simulate independent fireflies that glow, fade, and loosely synchronize with each other — no fixed blink pattern, each run looks different.
Port of the Firefly2026 project (ATtiny45 @ 8 MHz) to the ATtiny412 @ 8 MHz (16 MHz oscillator / 2), preserving identical PWM timing and wave playback characteristics while using the newer chip's cleaner sleep management and simpler code.
Based on the original concept from the mikrocontroller.net thread by H. Reddmann.
| Item | Detail |
|---|---|
| MCU | ATtiny412 @ 8 MHz (16 MHz oscillator, /2 prescaler) |
| Package | 8-pin SOIC |
| LEDs | 12, wired as antiparallel pairs on 4 pins (charlieplexing) |
| LDR | PA7 / AIN7 — capacitor discharge method for day/night detection |
| Power | Battery (3V coin cell or 2xAA); deep-sleep between flashes |
| Programmer | UPDI (PA0, pin 6) — any UPDI-capable programmer (SNAP, SerialUPDI, etc.) |
ATtiny412
8-Pin SOIC
┌──────────────┐
VDD 1 │● │ 8 GND
PA6 2 │ LED Row 3 │ 7 PA3 — LED Row 2
PA7 3 │ LDR (AIN7) │ 6 PA0 — UPDI (reserved)
PA1 4 │ LED Row 0 │ 5 PA2 — LED Row 1
└──────────────┘
| Pin | Port | Function | Direction |
|---|---|---|---|
| 1 | VDD | Power supply (1.8–5.5V) | — |
| 2 | PA6 | LED Row 3 (charlieplex) | Output (when active) |
| 3 | PA7 | LDR / ADC input (AIN7) | I/O (charge/measure) |
| 4 | PA1 | LED Row 0 (charlieplex) | Output (when active) |
| 5 | PA2 | LED Row 1 (charlieplex) | Output (when active) |
| 6 | PA0 | UPDI programming | Reserved |
| 7 | PA3 | LED Row 2 (charlieplex) | Output (when active) |
| 8 | GND | Ground | — |
Four pins drive 12 LEDs in a charlieplex arrangement. Each LED sits between two pins, with its polarity determining which one it is.
PA1 PA2 PA3 PA6
│ │ │ │
Row 0: ├──►D1─┤ ├──►D2─┤ ├──►D3─┤
(PA1 H) │ │ │ │
Row 1: ├──◄D4─┤ ├──►D5─┤ ├──►D6─┤
(PA2 H) │ │ │ │
Row 2: ├──◄D7─┤ ├──◄D8─┤ ├──►D9─┤
(PA3 H) │ │ │ │
Row 3: ├──◄D10┤ ├──◄D11┤ ├──◄D12┤
(PA6 H) │ │ │ │
Each row: one pin drives HIGH, the other 3 pins selectively sink current (set as output LOW) to light individual LEDs. Direction of the LED (anode towards row pin or away) determines which of the 12 positions it occupies.
Current limiting: Each LED needs a series resistor (47–220 Ohm depending on LED type and desired brightness). In the original design, the short duty cycle from multiplexing provides implicit current limiting with low-value resistors.
10k ┌───────┬───────┐
PA7/AIN7 ────/\/\/─────────┤ │ │
┌───┐ ┌───┐
│LDR│ │10n│
└───┘ └───┘
│ │
GND GND
PA7 connects through a 10k series resistor to a node that carries the LDR and a 10 nF capacitor, both in parallel to GND.
Measurement principle (capacitor charge/discharge):
- PA7 driven HIGH — charges the 10 nF cap through the 10k (tau ≈ 100 µs).
- PA7 switched to high-impedance input — with no current through the 10k, the pin reads the node voltage directly.
- The cap discharges through the LDR; the ADC reads the remaining voltage after a short fixed delay.
A high ADC value means the capacitor discharged slowly (LDR is high impedance) — it is dark (night mode active, fireflies enabled).
A low ADC value means fast discharge (LDR is low impedance) — it is bright (day mode, fireflies disabled, deep sleep).
The threshold is defined as LDR_THRESHOLD in firefly.h (default: 512).
Adjust based on your LDR and desired light sensitivity.
The project uses PlatformIO. No Arduino framework is used — this is bare-metal AVR-libc.
# Build
pio run
# Flash (SerialUPDI on /dev/ttyUSB0)
pio run --target upload
# Flash fuses (needed once for 20MHz + WDT)
pio run --target fusesThe default configuration uses SerialUPDI on /dev/ttyUSB0. To change
the programmer or port, edit platformio.ini:
upload_protocol = serialupdi
upload_flags =
-P
/dev/ttyUSB0Other supported programmers: snap_updi, pickit4_updi, atmelice_updi.
| Fuse | Value | Meaning |
|---|---|---|
| WDTCFG | 0x0B | WDT enabled, normal mode, 8s timeout (crash guard) |
| BODCFG | 0x00 | Brown-out detection disabled |
| OSCCFG | 0x01 | 16 MHz internal oscillator (divided to 8 MHz in software) |
| TCD0CFG | 0x00 | Default (TCD0 not used) |
| SYSCFG0 | 0xC9 | Reset pin as RESET (safe for UPDI recovery), CRC disabled |
| APPEND | 0x00 | No append section |
| BOOTEND | 0x00 | No bootloader section |
Important: SYSCFG0 = 0xC9 keeps PA0 as a reset/UPDI pin. This ensures you can always reprogram the chip without an HV programmer.
┌─────────────────────────────────────────────────────────┐
│ FAST DOMAIN (ISRs) │
│ │
│ TCA0 (488 Hz overflow, 122 Hz per LED) │
│ ├─ OVF: all LEDs off, read waves, sort, set PORTOUT │
│ ├─ CMP0: turn ON brightest LED (fires first) │
│ ├─ CMP1: add medium LED │
│ └─ CMP2: add dimmest LED (fires last) │
│ │
│ One CMP channel armed per active firefly, per row │
│ │
├─────────────────────────────────────────────────────────┤
│ SLOW DOMAIN (main loop) │
│ │
│ RTC/PIT ──► pit_tick ──► update_fireflies() │
│ (16ms–1s) energy/hungry logic │
│ │
│ Periodic ──► measure_isnight() ──► day/night switch │
│ │
│ WDT (8s) ──► crash guard (hardware reset) │
└─────────────────────────────────────────────────────────┘
The firmware uses a single timer (TCA0) for both wave playback and charlieplex PWM — the same approach as the original ATtiny45 project. TCA0 runs at 8 MHz / 64 / 256 = 488 Hz overflow rate. With 4 charlieplex rows cycling, each LED refreshes at 122 Hz — identical to the original.
Wave pointers advance once per row visit, giving 122 Hz per firefly. The longer of the two wave tables shipped by the wave editor is used (1654 samples), which is what makes the animations slow and smooth rather than nervous — the author doubled the sample count, not the playback period.
Each overflow handles one row of 3 fireflies:
- Reads the current wave sample for each firefly
- Sorts brightness values descending
- Negates them (converts to "time until turn-on")
- Programs CMP0/CMP1/CMP2 with the negated values
- Sets PORTOUT for the row drive pin
The three TCA0 compare channels then progressively turn LEDs ON:
- CMP0 fires first (negated brightest = smallest value) → brightest LED on
- CMP1 fires next → medium LED joins
- CMP2 fires last (negated dimmest = largest value) → dimmest LED joins
- All LEDs stay on until the next overflow resets everything
This progressive turn-on approach eliminates ghost glow completely: LEDs start OFF at each overflow and only get activated by their specific compare match. No LED is ever briefly pulsed unintentionally.
Charlieplex matrices are prone to ghost lighting — parasitic current through floating pins can dimly illuminate unintended LEDs. The progressive turn-on approach avoids this entirely:
- OVF ISR clears both DIR and OUT — all pins become high-impedance inputs with no output driver. Zero current flows.
- OUT is set to row drive value — but DIR is still 0, so no pin actually drives anything yet.
- CMP ISRs enable DIR only for active LEDs — pins are only made outputs when it's time for their specific LED to conduct.
- Only channels with an active firefly are armed — see below.
This means at no point during the PWM cycle does an unintended LED get even a brief pulse of current.
The negation compare = 0 - brightness maps raw values 1…255 to
compare points 255…1, but raw 0 maps to 0 — the earliest possible
compare point in the cycle. An idle firefly would therefore trigger a
compare match at count 0 and, through the cumulative DIR mask, switch
the active LED of the same row to full brightness for the entire
cycle. The result is binary on/off behaviour with no visible dimming
whenever a row contains an idle firefly — which is the normal steady
state once the startup burst settles.
To prevent this, the OVF ISR arms one compare channel per active
firefly and disables the remaining channels for that row via
TCA0.SINGLE.INTCTRL:
| Active fireflies in row | Armed channels |
|---|---|
| 3 | CMP0, CMP1, CMP2 |
| 2 | CMP0, CMP1 |
| 1 | CMP0 |
| 0 | none (row stays dark) |
Because brightness values are sorted descending before negation, the active fireflies are always the leading entries, so the armed channels are always contiguous from CMP0.
All four interrupt flags are cleared at the very end of the OVF ISR.
This suppresses compare matches that elapsed while the ISR was still
running and prevents them from leaking into the next row's cycle —
the same purpose the original serves with its TIFR write.
Each overflow processes one row (3 fireflies). With 4 rows cycling, each firefly's wave pointer advances once every 4 overflows = 488 / 4 = 122 Hz per firefly — identical to the original. The longest wave (293 samples) plays for ~2.4 seconds.
Each firefly has:
wave_ptr— current position in a brightness wavewave_end— end address of the current wave segmenthungry— how long until the firefly can flash againenergy— accumulated energy from neighbours
When a firefly is idle (wave_ptr == 0, hungry == 0),
update_fireflies() picks one of 32 wave segments at random using a
32-bit LFSR, activates it, and distributes energy to the next fireflies
in the ring (full, half, quarter...). The hungry counter prevents
immediate re-flashing, creating natural spacing.
| Condition | Sleep Mode | Active Peripherals | Current |
|---|---|---|---|
| Fireflies active | IDLE | TCA0, PIT | ~3–4 mA |
| Between flashes (night) | STANDBY | PIT only | ~0.7 µA |
| Daytime (waiting) | STANDBY | PIT only | ~0.7 µA |
| LDR measurement | STANDBY | ADC (RUNSTDBY) + PIT | ~0.7 µA + ADC |
In STANDBY the chip draws only the 32.768 kHz ULP oscillator current. With a CR2032 coin cell (~220 mAh), theoretical standby life exceeds the battery's self-discharge limit.
The WDT is configured via fuse (WDTCFG = 0x0B) as a pure crash guard
with an 8-second timeout. The main loop kicks it (wdr) every iteration.
If main() hangs, the WDT resets the chip after 8 seconds. No dual-purpose
interrupt/timing — that's handled by the PIT.
The wave data is generated by H. Reddmann's WaveEditor tool. The file
wave.h contains:
wave[1654]— raw brightness samples (0–255)wave_data[32]— descriptors pointing intowave[]with start, end, and energy values
The wave editor output is used directly (PROGMEM removed for ATtiny412's memory-mapped flash). The number of waves is always a power of 2 (typically 32). Sample count is typically ~1600.
Flash: 3804 / 4096 bytes (92.9%)
RAM: 111 / 256 bytes (43.4%)
The wave table (1654 bytes) dominates flash usage. If larger wave tables are needed, consider the ATtiny1614 (16 KB flash, same pinout family).
| File | Contents |
|---|---|
src/firefly.h |
Types, macros, flag definitions, pin assignments, extern declarations |
src/firefly.c |
DDR table, EEPROM, init(), TCA0 ISRs (OVF+CMP), update_fireflies() |
src/main.c |
Main loop, LDR measurement, PIT ISR, sleep management |
src/lfsr32.c/h |
32-bit LFSR pseudo-random number generator |
src/wave.h |
Wave table data (1654 bytes) and 32 wave descriptors |
platformio.ini |
Build configuration, fuses, upload settings |
| Aspect | ATtiny45 (old) | ATtiny412 (new) |
|---|---|---|
| Clock | 8 MHz | 8 MHz (16 MHz osc / 2) |
| PWM frequency | 122 Hz per LED | 122 Hz per LED (identical) |
| PWM method | Timer0 OVF + single OCR reprogram | TCA0 OVF + 3 CMP channels (no reprogram) |
| PWM approach | Progressive LED turn-ON | Same progressive turn-ON (anti-ghost) |
| Wave sample rate | 122 Hz | 122 Hz (identical) |
| Wave timer | Same as PWM timer | Same as PWM timer (TCA0 only) |
| Sleep timing | WDT (dual-purpose) | RTC/PIT (dedicated, RUNSTDBY) |
| Crash guard | WDT interrupt+reset trick | WDT pure reset (8s fuse) |
| Flash access | PROGMEM + pgm_read | Direct pointer (memory-mapped) |
| Pin access | PORTB/DDRB (SBI/CBI) | VPORTA (single-cycle) |
| ADC measurement | CPU polling in ADC noise reduction | RUNSTDBY (CPU sleeps during conversion) |
| Programming | ISP (SPI) | UPDI (single-wire) |
| Assembler | pgm_read_word_inc macro | None |
Same as the original Firefly2026 project.