This build was inspired by the flickering candelabras in the haunted house attraction at Disneyland. It's an interesting effect, but I thought I could do better. Unlike most lamp flickering designs, this design features a fully programmable table driven dimming sequence. This revised version supports sixteen brightness levels (the previous had only eight). It can optionally suport multiple dimming channels. The final product is used every Halloween, wired into my porch lights.
The engine for this circuit is an Arduino Uno. It is used to drive an optically isolated triac dimming circuit. All of the phase shift timing is done in software.
You will have to build some interface circuitry. You need two circuits (see schematic below):
The zero crossing circuit takes the AC power line signal and applies it to the LED diodes of an dual-diode opto-coupler. Resistor R1 limits the peak LED current. The AC voltage keep the LEDs lit except at waveform zero crossing. The output of the opto-coupler keeps the output signal low except at zero crossing time. Resistor R2 provides the pull-up voltage for the output signal. This signal feeds the input of the Arduino.
The lamp driver circuit is a basic phase modulated dimmer circuit. A pulse signal from the Arduino turns on the LED inside the opto-triac chip. Resistor R3 limits the LED current. This signal triggers the internal triac, which then passes 120 vac voltage, current limited by resistor R4, to the gate of a power triac, turning it on. This sends power to the attached light bulb. Note: you may also use a random turn-on type SSR relay module in place of this circuit, but at added cost.
While this build shows only one dimming channel, there is no reason why you couldn't add more channels by adding more lamp driver circuits and expanding the code.
Click on the figure to get a large version.
The Arduino sketch software works as follows:
/*--------------------------------------------- title: HwMachArduino.ino Halloween Machine revisions: 4/08/08 - init release 10/30/08 - port to lugger 8/14/13 - port to Arduino 10/01/17 - use PROGMEM 10/02/17 - 4 bit brightness 10/08/20 - limit moc3020m drive time (c) Telford Dorr 2008-2020 Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------- This sketch uses an Arduino to flash and dim a standard 120 vac incandescent lamp for a flickering candle Halloween effect. The entire sequence is approximately 56 seconds long, and repeats endlessly. The connecting hardware has one controllable dimming channel and one zero crossing input channel. The input port is low except during zero cross. The output port is wired up inverted polarity - pull down (output = LOW) to activate. This software will support multiple lighting channels, each with its own brightness sequence table. The only caveat is that all sequence tables need to be the same length. Add additional I/O and channel processing where indicated. Eash channel can be controlled to one of sixteen brightness levels. This is an all-software bit-banging method of controlling the channel brightness. The main loop controls the basic dimming sequence. There is a 'brightness' table for each controlled lighting channel. Each table value is repeated a specified number of line half-cycles (should be an even number) before advancing to the next value. Table values are fetched, and then the routine waits for line voltage zero crossing. Then the table values are compared against a downcounting counter. When the value matches, the appropriate channel is activated, and stays activated for the remainder of the line half-cycle. After each lighting channel is processed, a delay routine is called (500 usec). This controls the portion of 8.33 msec line half-cycle the channel is active, and thus its brightness. ---------------------------------------------*/ #include <avr/pgmspace.h> #define ON LOW #define OFF HIGH // Global constants const uint8_t OUTPORT1 = 13; // Add more channel port defs here. const uint8_t STATPORT = 12; const uint8_t REPEATS = 8; /*--------------------------------------------- HW Machine data tables Each number represents a 'brightness' value. 15 = brightest, 0 = off Because this table is large and constant, and because we could expand to multiple channels, needing miltiple tables, the tables will be stored in program memory (hence PROGMEM). ---------------------------------------------*/ const uint8_t CHANNEL_1_TABLE[] PROGMEM = { 15, 9,15, 9,13, 9,13, 9, 11, 7,11, 7, 9, 5, 9, 5, // 1 7, 3, 7, 3, 7, 3, 7, 3, 9, 5,11, 7,13, 9,15, 9, 15, 9,13, 9,11, 7, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, // 2 7, 3, 7, 3, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5, 9, 5,11, 7,11, 7, 12, 9,12, 9,15, 9,15, 9, 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, // 3 13, 9,11, 7, 9, 5, 7, 2, 9, 5,11, 7,13, 9,15, 9, 15, 9,15, 9,13, 9,13, 9, 11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, 15, 9,13, 9,11, 7, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, // 4 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, // 5 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, 13, 9,11, 7, 9, 5, 7, 2, 9, 5,11, 7,13, 5,15, 9, 15, 9,15, 9,13, 9,13, 9, 11, 7,11, 7, 9, 5, 9, 5, // 6 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, 15, 9,13, 9,11, 7, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, 13, 9,13, 9,15, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, // 7 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 5, 7, 5, 11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, // 8 13, 9,11, 7, 9, 5, 7, 2, 9, 5,11, 7,13, 9,15, 9, 15, 9,15, 9,13, 9,13, 9, 11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, 15, 9,13, 9,11, 7, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, // 9 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, // 10 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, 13, 9,11, 7, 9, 5, 7, 2, 9, 5,11, 7,13, 9,15, 9, 15, 9,15, 9,13, 9,13, 9, 11, 7,11, 7, 9, 5, 9, 5, // 11 7, 2, 7, 2, 7, 2, 7, 2, 9, 4,11, 7,13, 9,15, 9, 15, 9,13, 9,11, 7, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, // 12 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, // 13 13, 9,11, 7, 9, 5, 7, 2, 9, 5,11, 7,13, 9,15, 9, 15, 9,15, 9,13, 9,13, 9, 11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, 9, 5,11, 7,13, 9,15, 9, 15, 9,13, 9,15, 7, 9, 5, 7, 2, 7, 2, 7, 2, 7, 2, // 14 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9, 13, 9,13, 9,11, 7,11, 7, 9, 5, 9, 5, 7, 2, 7, 2, 9, 5, 9, 5,11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9 }; // Add more channel tables here, if desired. // All must be the same length. #define SIZE_TABLE ((uint16_t)(sizeof CHANNEL_1_TABLE / sizeof CHANNEL_1_TABLE[ 0 ] )) // Global vars uint16_t table_index; uint8_t half_cycle, brightness; uint8_t table_1_value; // Add more channel table value vars here. // --------------------------------------------- // Initialze hardware void setup() { // The input port detects power line zero-crossing pinMode( STATPORT, INPUT ); // The output port is wired up inverted polarity - // pull down an opto-coupler to activate. pinMode( OUTPORT1, OUTPUT ); // Add more channel outputs here. digitalWrite( OUTPORT1, OFF ); // Add more channel resets here. } // --------------------------------------------- // Wait for line zero crossing // (the input port is low except during zero cross) void sync() { // wait for zero-cross start (pin to go high) while (!digitalRead( STATPORT )) ; // cancel all output drive digitalWrite( OUTPORT1, OFF ); // Add more channel resets here. // wait for xero-cross end (pin to go low) while (digitalRead( STATPORT )) ; } // --------------------------------------------- void loop() { // Table loop for (table_index = 0; table_index < SIZE_TABLE; ++table_index) { table_1_value = pgm_read_byte(CHANNEL_1_TABLE + table_index); // Add more channel table value loads here. // All use same table index. // Number of power line half-cycles loop for (half_cycle = 0; half_cycle < REPEATS; ++half_cycle) // repeat 'N' half-cycles { // wait for line zero-cross sync(); // Trigger loop for (brightness = 15; brightness > 0; --brightness ) // 15 = bright, 0 = off { // Process channel trigger if (table_1_value == brightness) digitalWrite( OUTPORT1, ON ); // Add more channel trigger processing here. delayMicroseconds( 500 ); // waste time (usec) } // Brightness == 0 - kill drive // This kills drive well before zero crossing, which fixes issue // with current generation of MOC3010M devices. digitalWrite( OUTPORT1, OFF ); } } }
Here's an alternate method using interrupts to capture zero crossing. It still polls for a flag set by the interrupt handler to start the cycle timing, so there's no real advantage, unless you have an extremely narrow zero crossing pulse, in which case this method won't miss it. The wiring is slightly different: the zero cross pulse moves from D13 to D3, which supports interrupts.