From 28f49e84637e6deaa1d45246565310a86cb8a0e1 Mon Sep 17 00:00:00 2001 From: Thomas Gohle Date: Sun, 8 Nov 2026 16:50:13 +0100 Subject: [PATCH] SW to v0.4, timing only via watchdog, filled a little bit README.MD --- README.md | 52 ++- .../ATTINY85_2026_MorseThrowie.ino | 298 +++++++++++------- 2 files changed, 240 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 9a25442..c5832db 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,16 @@ FireFly Solar is a small outdoor LED blinker inspired by the idea of an LED thro During the day, the solar cell charges the energy storage. At night, the circuit wakes up and sends a simple Morse-style light signal. -The project is partly a small garden object, partly a low-power electronics experiment, and partly a design exercise for future DIY kits. +## About this Project + +FireFly Solar is my **technology playground**. It is a small garden object, but its real purpose is to learn by building something complete. With it I learn about: + +- **Circuit design at the edge**: in this case, as low power as possible +- **PCB design** +- **Weatherproof casing** +- **ATtiny programming** + +Possible next technology step: everything about DIY SMD design and soldering. ## Project Goals @@ -24,6 +33,40 @@ The project is partly a small garden object, partly a low-power electronics expe - Test mechanical concepts for outdoor 3D-printed parts - Learn what is needed to turn a small electronics project into a possible DIY kit +## Software + +The firmware runs on an **ATtiny85** at 1 MHz (internal clock, ATTinyCore: `clock=1internal`, `millis=disabled`). The LED itself is used as the light sensor. When it is dark, the firefly sends a short Morse text ("TGO 26") about once a minute. + +### Current Firmware version: v0.4 (2026-11-08) + +Goal: lowest possible average current. + +Changes vs v0.3: + +- No `delay()` / `millis()`; Timer0 and Timer1 are gated off in PRR. +- The watchdog (interrupt mode) is the only timebase: Morse units (~96 ms), the sensing window and the 8 s sleeps. The CPU sleeps in power-down in between. +- Darkness sensing: the CPU sleeps in 16 ms steps while the LED junction discharges, instead of a busy `digitalRead()` loop. Bright light exits after the first step. `darknessThreshold` is replaced by `darkSteps`. +- The ADC is disabled before it is gated off in PRR; pins are set up before PRR. +- After a power-on or external reset, no blinking for `RECOVER_CYCLES` (about 16 min), so the supercap can recharge. +- `DEBUG_TEST` switch for bench testing: 8 s cycle, no recovery wait, short flash when the sketch decides "bright". Set it to 0 for the field. +- Optional `BOD_FUSE_ENABLED` (BOD off during sleep); unused with the BOD fuse off. + +Tested: compiles with ATTinyCore and runs on hardware. Current draw is not yet measured. + +## PCB + +- Finished: a batch of 10 is assembled, tested and working. +- Latest adjustment: fine-tuned the resistors for the LED, and the 3.9 V Zener diode is confirmed safe avoid overcharging 4V supercap. +- Next version: the PCB should be a little shorter. The wiring space is a bit too tight now. + +## Casing + +Works basically as intended. Lessons for the next version: + +- Better locking of the solar modules. +- The rope structure works, but building it is too complicated and takes a lot of nerves. +- Using material tension to secure the modules via rope was basically a good idea. + ## Naming The English project name remains: @@ -39,9 +82,12 @@ The name roughly means **sun-disc firefly**. It keeps the solar theme, adds a sl --- ## Contributing -PRs and issues welcome. Keep it simple. + +PRs and issues welcome. Keep it simple. Photos of your build you send me go in `Documentation/gallery/` with a short caption and license notice. --- -## Default license**: [CC-BY-NC-4.0](https://creativecommons.org/licenses/by-nc/4.0/) \ No newline at end of file +## License + +Default license: [CC-BY-NC-4.0](https://creativecommons.org/licenses/by-nc/4.0/) \ No newline at end of file diff --git a/firmware/ATTINY85_2026_MorseThrowie/ATTINY85_2026_MorseThrowie.ino b/firmware/ATTINY85_2026_MorseThrowie/ATTINY85_2026_MorseThrowie.ino index 6e12380..9bf8241 100644 --- a/firmware/ATTINY85_2026_MorseThrowie/ATTINY85_2026_MorseThrowie.ino +++ b/firmware/ATTINY85_2026_MorseThrowie/ATTINY85_2026_MorseThrowie.ino @@ -6,19 +6,33 @@ Project definitions, sources ----------------------------------------------------------------------------- - Version: 0.3 - ATTiny85, 1 MHz, BOD fuse disabled + Version: 0.4 - ATTiny85, 1 MHz, BOD fuse disabled gitea : https://gitea.togo-lab.io/tgohle/0001-FireFly - Date : 2026-06-14 + Date : 2026-11-08 -Key changes Version 0.3: +Key changes Version 0.4 (goal: lowest possible average current): -- Removed unnecessary ADC enable/disable from loop() -- Disabled ADC and analog comparator in setup() -- Added INPUT_PULLUP for unused Arduino pins 0, 1, 2 will save power -- Added safer watchdog setup with interrupt protection to save run near brownout -- Added wdt_reset() before sleep for cleaner 8 s timing -- Added lowercase-to-uppercase handling -- Unsupported characters are now ignored without adding fake timing gaps +- No delay(), no millis(): Timer0 and Timer1 are not needed any more and + are switched off in PRR. +- ALL timing (Morse units, sensing window, 8 s sleep) is done by the watchdog + in interrupt mode while the CPU is in POWER-DOWN. The CPU runs only for a + few microseconds per wake-up. LED pins keep their state in power-down. +- Darkness sensing no longer polls digitalRead() in a busy loop (that was + ~0.3-1 s awake per cycle). The LED junction is charged, then the CPU sleeps + in 16 ms steps and checks the pin: bright light exits after the first step. +- ADC is disabled BEFORE it is gated off in PRR (order matters). +- After a power-on / external reset the sketch waits RECOVER_CYCLES cycles + before it blinks again, so a nearly empty supercap can recharge first. + Default ~16 minutes, therefore added a debug option. +- Optional: BOD disable during sleep (only useful if BOD fuse is enabled). + Not tested yet. +- Field Test: After a sunny day in Winter, outdoor -1 ... 8°C, with a payload + of 10 signs) it blinks for >> 12h. + +Timing note: the watchdog oscillator is not precise (changes with Vcc and +temperature). All Morse timing comes from the same clock, so dit/dah/gap +ratios stay correct; only the absolute speed drifts a little. +The HW actual allowes 5V and 3.3V, standard is 3.3V. So don't expec exact timing. ----------------------------------------------------------------------------- @@ -60,6 +74,12 @@ Inspired by Karl Lunt's FireFly project: #include #include +// --------------------------------------------------------------------------- +// DEBUG_TEST 1 = bench testing: no reset-recovery wait, a blink attempt every +// 8 s, and a short flash when the sketch thinks it is BRIGHT. +// SET TO 0 FOR THE FIELD (48 s cycle, 16 min recovery wait after a reset). +#define DEBUG_TEST 0 + #ifndef cbi #define cbi(sfr, bit) (_SFR_BYTE(sfr) &= ~_BV(bit)) #endif @@ -68,16 +88,55 @@ Inspired by Karl Lunt's FireFly project: #endif // --------------------------------------------------------------------------- -// Timing: unit length in ms. -// At 1 MHz, delay() is accurate when F_CPU=1000000L is set in boards.txt. -// 100 ms gives readable optical Morse; raise to 150 ms if readability is poor. -#define unitLength 100 +// Watchdog timeout codes (nominal, at ~5 V; real values vary with Vcc/temp): +// actual HW uses 3.3 (standard) or 5V (for blue LED experimental setup) +// so don't expect exact timing) +// 0=16ms 1=32ms 2=64ms 3=128ms 4=250ms 5=500ms +// 6=1s 7=2s 8=4s 9=8s +// +// One Morse unit = UNIT_WDT_A + UNIT_WDT_B -> 64 ms + 32 ms = ~96 ms +// A=3, B=0 -> ~144 ms (easier to read, more LED time) +// A=3, B=0xFF -> ~128 ms +// A=2, B=0xFF -> ~64 ms (fast, less LED time, harder to read by eye) +// Use 0xFF for B to skip the second sleep. +#define UNIT_WDT_A 2 +#define UNIT_WDT_B 1 // --------------------------------------------------------------------------- -// Sleep interval. -// Watchdog setup uses 8 s. For final use, change sleepCycles from 4 to 6 -// for about 32s - 48s between sensing/blinking activations. Goal ~ 1 blink/min -const uint8_t sleepCycles = 6; +// Sleep interval between two sensing/blink attempts: sleepCycles x 8 s. +// 6 x 8 s = 48 s. Goal ~ 1 blink/min. +#if DEBUG_TEST +const uint8_t sleepCycles = 1; +#else +const uint8_t sleepCycles = 5; +#endif + +// --------------------------------------------------------------------------- +// Darkness window in 16 ms steps (nominal). +// After charging the LED junction the CPU sleeps step by step. If the pin +// reads LOW within the window the LED discharged quickly = bright = no blink. +// Still HIGH after the full window = dark = blink. +// Longer window = triggers in brighter conditions (like the old threshold). +// The window costs almost no energy (CPU sleeps), so it is only a threshold. +// Calibrate at real dusk. Old threshold 17000 loops: measure the old +// sensDarkness() time with micros() at your dusk level and divide it by 16 ms. +const uint8_t darkSteps = 40; // ~640 ms + +// --------------------------------------------------------------------------- +// After a power-on / external reset (e.g. supercap was empty, DC/DC restart) +// do not blink for this many cycles (x ~48 s) so the supercap can recharge. +// 20 -> ~16 min. Set to 0 while testing/uploading (upload = external reset). +#if DEBUG_TEST +#define RECOVER_CYCLES 0 +#else +#define RECOVER_CYCLES 20 +#endif + +// --------------------------------------------------------------------------- +// Set to 1 only if the BOD fuse is enabled (e.g. hfuse 0xDE = 1.8 V). +// Then BOD is switched off during power-down to save its ~20 uA. +// With BOD fuse disabled this has no effect, leave it 0. +#define BOD_FUSE_ENABLED 0 // --------------------------------------------------------------------------- // LED pin definitions (N-side = cathode, P-side = anode for sensing/driving). @@ -95,28 +154,25 @@ const uint8_t sleepCycles = 6; // ATTiny85 has 512 bytes SRAM; this keeps the Morse text in flash. // Only uppercase letters, digits, spaces, and the special chars in the switch // below are emitted. Lowercase letters are converted to uppercase. -// Test setup: 20 x "0" because 0 = "-----" gives maximum LED ON time for test. -// shorter text will better, more text will cost more power. -const char morseText[] PROGMEM = "TGO 26"; -// Ruler: 0....0....1...1....2 -// Ruler: 0....5....0...5....0 -// --------------------------------------------------------------------------- -// Darkness threshold. -// Higher value = triggers in brighter conditions. -// Best calibrated at actual dusk/dawn with the chosen LED type. -const unsigned int darknessThreshold = 17000; +// Shorter text = less LED time = less energy. +const char morseText[] PROGMEM = "TOGO-LAB.IO FIREFLY"; +// Ruler: 0....0....1....1....2 +// Ruler: 01...5....0....5....0 // --------------------------------------------------------------------------- -// Watchdog interrupt flag — volatile because it is written in an ISR. -volatile bool f_wdt = true; +// Watchdog interrupt flag - volatile because it is written in an ISR. +volatile bool f_wdt = false; + +// Remaining cycles without blinking after a reset. +static uint8_t recoverCycles = 0; // --------------------------------------------------------------------------- // Forward declarations. -void setup_watchdog(uint8_t ii); -void system_sleep(); +static void sleepWdt(uint8_t ii); +static void waitUnits(uint8_t n); void configureUnusedPins(); void releaseLedPins(int LED_N, int LED_P); -unsigned int sensDarkness(int LED_N, int LED_P); +bool isDark(int LED_N, int LED_P); void morse(int LED_N, int LED_P); void dit(int LED_P); void dah(int LED_P); @@ -126,22 +182,28 @@ void dah(int LED_P); // =========================================================================== void setup() { - // Disable unused peripherals immediately to save power. - // PRTIM1, PRUSI: unused, safe to gate off. - // PRADC: ADC is not used; LED sensing is done with digitalRead(). - // PRTIM0: must stay ON while awake because delay() depends on Timer0. - PRR = (1 << PRTIM1) | (1 << PRUSI) | (1 << PRADC); - - // ADC off. It is not needed for digitalRead()-based LED sensing. - cbi(ADCSRA, ADEN); - - // Analog comparator off. Saves a little sleep current. - ACSR |= (1 << ACD); + // Remember why we restarted, then clear the flags. + uint8_t resetCause = MCUSR; + MCUSR = 0; + if (resetCause & ((1 << PORF) | (1 << BORF) | (1 << EXTRF))) { + recoverCycles = RECOVER_CYCLES; + } + // Pins first (while the Arduino core can still reach all timer registers + // for its turnOffPWM() handling in digitalWrite()). configureUnusedPins(); releaseLedPins(LED1_N_SIDE, LED1_P_SIDE); - setup_watchdog(9); // 8-second watchdog interval. + // ADC off FIRST, then gate its clock. With PRADC set, ADC register + // writes are ignored, so the order matters. + cbi(ADCSRA, ADEN); + + // Analog comparator off. + ACSR |= (1 << ACD); + + // Power reduction: ADC, USI, Timer0, Timer1 all off. + // Nothing in this sketch uses them: no delay(), no millis(), no analogWrite(). + PRR = (1 << PRADC) | (1 << PRUSI) | (1 << PRTIM0) | (1 << PRTIM1); } // =========================================================================== @@ -149,22 +211,29 @@ void setup() // =========================================================================== void loop() { - if (f_wdt) { - f_wdt = false; - - if (sensDarkness(LED1_N_SIDE, LED1_P_SIDE) > darknessThreshold) { - morse(LED1_N_SIDE, LED1_P_SIDE); - } - - // Return LED pins to high-Z before sleeping. - // Internal pullups stay disabled for the LED pins. - releaseLedPins(LED1_N_SIDE, LED1_P_SIDE); + if (recoverCycles > 0) { + recoverCycles--; + } else if (isDark(LED1_N_SIDE, LED1_P_SIDE)) { + morse(LED1_N_SIDE, LED1_P_SIDE); } +#if DEBUG_TEST + else { + // Sketch decided "bright": one short ~128 ms flash so you can see it. + pinMode(LED1_N_SIDE, OUTPUT); + pinMode(LED1_P_SIDE, OUTPUT); + digitalWrite(LED1_N_SIDE, LOW); + digitalWrite(LED1_P_SIDE, HIGH); + sleepWdt(3); + digitalWrite(LED1_P_SIDE, LOW); + } +#endif + + // Return LED pins to high-Z before sleeping. + // Internal pullups stay disabled for the LED pins. + releaseLedPins(LED1_N_SIDE, LED1_P_SIDE); - // Test setup: 1 x 8 s sleep. - // Final setup: set sleepCycles = 4 for about 32 s. for (uint8_t i = 0; i < sleepCycles; i++) { - system_sleep(); + sleepWdt(9); // 8 s each } } @@ -188,87 +257,102 @@ void releaseLedPins(int LED_N, int LED_P) } // =========================================================================== -// Sleep helpers +// Sleep helpers (watchdog = only timebase) // =========================================================================== -void system_sleep() +ISR(WDT_vect) { - // Reset watchdog counter so each sleep cycle starts with a fresh interval. - wdt_reset(); - - set_sleep_mode(SLEEP_MODE_PWR_DOWN); - - // Atomic sleep entry pattern. - // After sei(), AVR executes the next instruction before servicing interrupts, - // so sleep_cpu() is not skipped by a just-pending interrupt. - cli(); - sleep_enable(); - sei(); - sleep_cpu(); - - // WDT interrupt wakes the CPU here. - sleep_disable(); + f_wdt = true; } -// Watchdog setup - ii selects timeout: -// 0=16ms 1=32ms 2=64ms 3=128ms 4=250ms 5=500ms -// 6=1s 7=2s 8=4s 9=8s -void setup_watchdog(uint8_t ii) +// Sleep in POWER-DOWN until the watchdog interrupt fires after timeout code ii +// (0=16 ms ... 9=8 s). Reconfigures the watchdog on every call, so the full +// interval always starts fresh. +static void sleepWdt(uint8_t ii) { if (ii > 9) ii = 9; uint8_t bb = ii & 7; if (ii > 7) bb |= (1 << WDP3); - uint8_t oldSREG = SREG; - cli(); + // Interrupt-only mode (WDE = 0). WDIF written as 1 clears a stale flag. + const uint8_t cfg = bb | (1 << WDIE) | (1 << WDIF); + f_wdt = false; + cli(); wdt_reset(); MCUSR &= ~(1 << WDRF); - // Timed sequence: enable configuration change, then set interrupt-only WDT. - WDTCR |= (1 << WDCE) | (1 << WDE); - WDTCR = bb | (1 << WDIE); + // Timed sequence: the two writes must follow each other within 4 cycles. + WDTCR = (1 << WDCE) | (1 << WDE); + WDTCR = cfg; - SREG = oldSREG; + set_sleep_mode(SLEEP_MODE_PWR_DOWN); + + // Atomic sleep entry: after sei() the next instruction (sleep_cpu) still + // executes before any interrupt is serviced, so the wake-up cannot be missed. + // Loop again if something else woke the CPU. + while (!f_wdt) { + sleep_enable(); +#if BOD_FUSE_ENABLED + sleep_bod_disable(); +#endif + sei(); + sleep_cpu(); + sleep_disable(); + cli(); + } + sei(); } -ISR(WDT_vect) +// Wait n Morse units in power-down. +static void waitUnits(uint8_t n) { - f_wdt = true; + while (n--) { + sleepWdt(UNIT_WDT_A); +#if UNIT_WDT_B != 0xFF + sleepWdt(UNIT_WDT_B); +#endif + } } // =========================================================================== // Light sensor // =========================================================================== -// Returns a "darkness level": higher = darker. -// Charges the LED junction capacitance, then times how long it takes to bleed -// back through the reverse-biased LED until the input reads LOW. -// ~30000 = pitch black, ~0 = bright light -unsigned int sensDarkness(int LED_N, int LED_P) +// Returns true if it is dark. +// Charges the LED junction capacitance, then lets it bleed through the +// reverse-biased LED while the CPU sleeps. Checks the pin every ~16 ms. +// LOW within the window -> bright (discharged fast) +// still HIGH at the end -> dark +// In power-down the input buffer of the floating pin is disabled by hardware, +// so the slowly falling voltage causes no shoot-through current. +bool isDark(int LED_N, int LED_P) { - unsigned int i; - // Charge the LED junction capacitance. pinMode(LED_N, OUTPUT); pinMode(LED_P, OUTPUT); digitalWrite(LED_N, HIGH); digitalWrite(LED_P, LOW); - // Let the N-side float and measure bleed-down time. + // Let the N-side float (no pullup) and measure bleed-down time. pinMode(LED_N, INPUT); - digitalWrite(LED_N, LOW); // disable internal pullup + digitalWrite(LED_N, LOW); - for (i = 0; i < 30000; i++) { - if (digitalRead(LED_N) == LOW) break; + bool dark = true; + for (uint8_t k = 0; k < darkSteps; k++) { + sleepWdt(0); // ~16 ms + if (digitalRead(LED_N) == LOW) { + dark = false; // bright: stop early + break; + } } - // Clean up after sensing: both LED pins low, then caller releases them. + // Clean up after sensing: both LED pins low, caller releases them. pinMode(LED_N, OUTPUT); digitalWrite(LED_N, LOW); pinMode(LED_P, OUTPUT); digitalWrite(LED_P, LOW); - return i; + return dark; } // =========================================================================== @@ -277,17 +361,17 @@ unsigned int sensDarkness(int LED_N, int LED_P) void dit(int LED_P) { digitalWrite(LED_P, HIGH); - delay(unitLength); + waitUnits(1); digitalWrite(LED_P, LOW); - delay(unitLength); + waitUnits(1); } void dah(int LED_P) { digitalWrite(LED_P, HIGH); - delay(unitLength * 3); + waitUnits(3); digitalWrite(LED_P, LOW); - delay(unitLength); + waitUnits(1); } // =========================================================================== @@ -371,7 +455,7 @@ void morse(int LED_N, int LED_P) // Previous symbol already ended with 1 unit OFF and then received the // normal 2 unit character gap. Add 4 units here -> total word gap 7 units. case ' ': - delay(unitLength * 4); + waitUnits(4); break; // Unsupported chars are ignored without adding an artificial gap. @@ -382,10 +466,10 @@ void morse(int LED_N, int LED_P) // Inter-character gap: 3 units total. // dit()/dah() already trail 1 unit OFF, so add the remaining 2 units. if (symbolSent) { - delay(unitLength * 2); + waitUnits(2); } } digitalWrite(LED_P, LOW); digitalWrite(LED_N, LOW); -} \ No newline at end of file +}