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.