/** * @file main.c * @brief Nachtlicht firmware (ATtiny412). * * Pins: PA6 = boost converter enable, PA2 = LED via NPN (TCA0 WO2), PA1 = button to GND. * * A press while off turns the LED on at the last brightness. While on, a short * press turns it off, holding the button ramps the brightness; ramp direction * alternates per long press. * * Everything runs off the 32.768 kHz ULP oscillator (20 MHz osc stays off). * PWM: TCA0 with PER = 127 -> 256 Hz, 128 levels. While the LED is off the CPU * sits in POWER-DOWN (~0.1 uA, wake on PA1 edge), while on in IDLE so the PWM * keeps running. The PIT (7.8 ms tick, CYC256 of 32.768 kHz) only runs while a * press is handled, for debounce and ramp timing; PA1 irq is masked meanwhile. */ #include #include #include #include #include #define DEBOUNCE_TICKS 3u /**< ~23 ms stable level for press/release */ #define LONG_PRESS_TICKS 77u /**< ~600 ms hold before the ramp starts */ #define DIM_STEP_TICKS 2u /**< ~16 ms per step -> ~2 s full sweep */ #define BOOST_SETTLE_TICKS 2u /**< ~16 ms boost ramp-up before LED on */ #define MIN_BRIGHT 1u /**< ~0.8% duty */ #define MAX_BRIGHT 128u /**< PER+1, compare never matches -> 100% duty */ #define DEFAULT_BRIGHT 48u /**< first power-up brightness (~37%) */ #define PWM_TOP 127u /**< TCA0 PER */ /** * @brief Read the button, active low (pull-up, button to GND). * @return true while pressed */ static inline bool pressed(void) { return (PORTA.IN & PIN1_bm) == 0; } /** * @brief Arm or mask the PA1 edge interrupt. Pull-up stays on either way. * @param on true = both edges, false = irq disabled */ static void pin_irq(bool on) { PORTA.PIN1CTRL = PORT_PULLUPEN_bm | (on ? PORT_ISC_BOTHEDGES_gc : PORT_ISC_INTDISABLE_gc); } /** * @brief Sleep until the PA1 irq reports a press. * * pressed() is re-checked before every sleep, so a press that sneaks in during * setup (edge already eaten by the ISR) can't get lost. * * @param deep true = POWER-DOWN (LED off), false = IDLE (PWM keeps running) */ static void sleep_until_press(bool deep) { while (!pressed()) { SLPCTRL.CTRLA = (deep ? SLPCTRL_SMODE_PDOWN_gc : SLPCTRL_SMODE_IDLE_gc) | SLPCTRL_SEN_bm; sleep_cpu(); } } /** @brief IDLE until the next PIT tick. Only valid while the PIT runs. */ static void wait_tick(void) { SLPCTRL.CTRLA = SLPCTRL_SMODE_IDLE_gc | SLPCTRL_SEN_bm; sleep_cpu(); } /** * @brief Start/stop the PIT tick. * @param on true = CYC256 (~7.8 ms), false = off */ static void pit_set(bool on) { while (RTC.PITSTATUS & RTC_CTRLBUSY_bm) { } RTC.PITCTRLA = on ? (RTC_PERIOD_CYC256_gc | RTC_PITEN_bm) : 0; } /** * @brief Debounce a fresh press: DEBOUNCE_TICKS consecutive pressed samples. * @return false on a glitch / immediate release */ static bool confirm_press(void) { for (uint8_t i = 0; i < DEBOUNCE_TICKS; i++) { wait_tick(); if (!pressed()) return false; } return true; } /** @brief Block until the button has been up for DEBOUNCE_TICKS. */ static void wait_release(void) { uint8_t stable = 0; while (stable < DEBOUNCE_TICKS) { wait_tick(); stable = pressed() ? 0 : stable + 1; } } /** * @brief Enable the boost, wait for it to settle, start the PWM. * * Needs the PIT running (settle delay) - always true on the off->on path. * 16-bit TCA registers must be written low byte first (TEMP register); * avr-gcc does that for plain 16-bit stores on this core. * * @param brightness compare value, MIN_BRIGHT..MAX_BRIGHT */ static void led_on(uint8_t brightness) { PORTA.OUTSET = PIN6_bm; for (uint8_t i = 0; i < BOOST_SETTLE_TICKS; i++) { wait_tick(); } TCA0.SINGLE.CTRLA = 0; TCA0.SINGLE.CNT = 0; TCA0.SINGLE.PER = PWM_TOP; TCA0.SINGLE.CMP2 = brightness; TCA0.SINGLE.CTRLB = TCA_SINGLE_WGMODE_SINGLESLOPE_gc | TCA_SINGLE_CMP2EN_bm; TCA0.SINGLE.CTRLA = TCA_SINGLE_ENABLE_bm; /* CLKSEL = DIV1 */ } /** @brief Glitch-free duty update via the buffered compare register. */ static void apply_pwm(uint8_t brightness) { TCA0.SINGLE.CMP2BUF = brightness; } /** @brief Stop the PWM (PORT takes PA2 back, low), disable the boost. */ static void led_off(void) { TCA0.SINGLE.CTRLA = 0; TCA0.SINGLE.CTRLB = 0; PORTA.OUTCLR = PIN2_bm | PIN6_bm; } /** * @brief Deal with a (already debounced) press while the LED is on. * * Counts hold time; past LONG_PRESS_TICKS it flips the ramp direction and * steps the brightness every DIM_STEP_TICKS until release. Hold time and ramp * freeze while the button reads released, otherwise the release debounce * would bleed into the long-press threshold. * * @param brightness current brightness, updated in place * @param dim_up ramp direction, flipped at long-press start * @return true on a short press (turn the LED off), false after a ramp */ static bool handle_on_press(uint8_t* brightness, bool* dim_up) { uint8_t held = 0; uint8_t released = 0; bool dimming = false; uint8_t step_acc = 0; for (;;) { wait_tick(); if (!pressed()) { released += 1; if (released >= DEBOUNCE_TICKS) break; continue; } released = 0; if (!dimming) { held += 1; if (held >= LONG_PRESS_TICKS) { dimming = true; *dim_up = !*dim_up; } } if (dimming && ++step_acc >= DIM_STEP_TICKS) { step_acc = 0; if (*dim_up) { if (*brightness < MAX_BRIGHT) *brightness += 1; } else { if (*brightness > MIN_BRIGHT) *brightness -= 1; } apply_pwm(*brightness); } } return !dimming; } /** @brief Clock, pins, PIT irq. */ static void init(void) { /* CLK_PER = 32.768 kHz ULP osc, no prescaler. Nothing requests the 20 MHz * osc after this, so it stays off. Both registers are CCP protected. */ CPU_CCP = CCP_IOREG_gc; CLKCTRL.MCLKCTRLA = CLKCTRL_CLKSEL_OSCULP32K_gc; CPU_CCP = CCP_IOREG_gc; CLKCTRL.MCLKCTRLB = 0; PORTA.DIRSET = PIN2_bm | PIN6_bm; PORTA.OUTCLR = PIN2_bm | PIN6_bm; pin_irq(true); /* Unused pins (PA0 is UPDI): PA3 has an external 180k pull-down, internal * pull-up would fight it (~15 uA) -> input buffer off only. PA7 floats -> * pull-up plus input buffer off. */ PORTA.PIN3CTRL = PORT_ISC_INPUT_DISABLE_gc; PORTA.PIN7CTRL = PORT_PULLUPEN_bm | PORT_ISC_INPUT_DISABLE_gc; /* PIT irq armed once, the timer itself is gated via PITEN. */ RTC.PITINTCTRL = RTC_PI_bm; } int main(void) { init(); sei(); uint8_t brightness = DEFAULT_BRIGHT; bool dim_up = true; /* first long press flips it -> first ramp goes down */ for (;;) { /* LED off, POWER-DOWN until a debounced press */ led_off(); pit_set(false); for (;;) { pin_irq(true); sleep_until_press(true); pin_irq(false); pit_set(true); if (confirm_press()) break; pit_set(false); /* glitch, back to sleep */ } led_on(brightness); wait_release(); /* however long the turn-on press is held */ pit_set(false); /* LED on, IDLE until a press, then short = off / long = ramp */ bool turn_off = false; while (!turn_off) { pin_irq(true); sleep_until_press(false); pin_irq(false); pit_set(true); if (confirm_press()) { turn_off = handle_on_press(&brightness, &dim_up); } else { wait_release(); } pit_set(false); } } } /* ISRs only clear their flag, they exist to wake the CPU. */ ISR(RTC_PIT_vect) { RTC.PITINTFLAGS = RTC_PI_bm; } ISR(PORTA_PORT_vect) { PORTA.INTFLAGS = PIN1_bm; }