Appearance
Guide
Sparkplug is an Arduino boilerplate to add extendible and high-detailed lighting to scale model vehicles.
WARNING
Note: this software is currently under active development: anything can change at any time, API and UI must be considered unstable until we release version 1.0.0.
Getting started
Sparkplug requires some programming, but is designed to be extendible without having to jump through lots of hoops.
What is Sparkplug?
Sparkplug is an Arduino boilerplate designed to run on an ESP8266 to add extendible and high-detailed lighting to scale model vehicles.
While being designed to run on the ESP8266, because of the Arduino platform, it can be ported to run on different architectures.
The combination of cheap and well-known hardware and Wifi connectivity for controlling the lights using a web interface made the esp8266 a fitting solution.
Installing dependencies
To build and upload the sketch you need either:
- Arduino IDE 1.8.15
- Arduino CLI 0.20
NOTE
To upload any data to the LittleFS file system, the Arduino IDE is required.
Core
Using Arduino CLI:
shell
arduino-cli core install esp8266:esp8266@3.0.2 --additional-urls https://arduino.esp8266.com/stable/package_esp8266com_index.json
Using the Arduino IDE board manager you can follow these instructions: https://github.com/esp8266/Arduino#installing-with-boards-manager
Libraries
- SparkFun LP55231 LED Driver Breakout Arduino Library
- Adafruit PCA9685 PWM Servo Driver Library
- WebSocket Server and Client for Arduino
- PrintCharArray
Installing these libraries using Arduino CLI:
shell
arduino-cli lib install "SparkFun LP55231 Breakout" "Adafruit PWM Servo Driver Library" "WebSockets" "PrintCharArray"
Adding config
Sparkplug uses header files in order to separate the configuration for different models from eachother.
To add a new config, create a new directory in the ./configs/
directory, and add a header file. Below is an example config with two modes and a single channel using the built-in LED as an output device:
cpp
#pragma once
#include "../../src/spark.h"
enum ModeIDs
{
LowBeams
Hazards,
};
const int modesCount = 2;
LightingMode modes[modesCount];
PCA9685 pcaDevices[] =
{
{ 0x40, false, 0, 1, false }
};
size_t pcaDevicesCount = COUNT_OF(pcaDevices);
const PROGMEM Preset presetsMainBeams[] =
{
{ LowBeams, PresetModes::Normal, SwopModes::LTP, 0x3333, 100, 100 },
{ HighBeams, PresetModes::Normal, SwopModes::LTP, 0x7777, 200, 200 },
};
Channel channels[] =
{
{ COUNT_OF(presetsMainBeams), presetsMainBeams },
};
const int channelsCount = COUNT_OF(channels);
Then include this header in the sketch file after spark.h
:
cpp
#include "src/spark.h"
#include "configs/myconfig/myconfig.h"
This config contains definitions of:
- Lighting modes.
- Lichting channels.
- Lighting presets.
- I²C output devices.
The config is also the place to hook into any events to run custom code specific to that config.
Build and upload
Lighting
Sparkplug organises physical lights — output devices with channels containing state — and functionality of those lights — presets applied when a mode is active — into a readable and extendible syntax:
- Modes - Contain state for lighting modes e.g. low beams, left blinker.
- Presets - Define the state of a channel per mode or combination of modes.
- Channels - Contain lighting state and assigned presets.
- Devices - I²C output devices with an assigned range of channels.
In short:
- Group your desired lighting functionality into modes and name them well.
- Define presets with the state of any psyical lights for any combination of modes.
- Define the psysical output channels and assign the previously defined presets to the right channels.
- Define output devices and assign ranges of channels to the right devices.
Modes
To group lighting functionality into groups of light with a shared functionality that can be turned on and off with only the relevant lights changing, sparkplug groups those functionalities into "modes".
Modes serve as IDs for presets, so only the relevant presets are applied when a mode changes state.
Modes are defined in your config. Sparkplug does not come with a default set of modes, but you can use the following as an example:
cpp
enum ModeIDs
{
DaytimeRunningLights,
Parking,
LowBeams,
HighBeams,
BlinkL,
BlinkR,
};
const size_t modesCount = BlinkR + 1;
LightingMode modes[modesCount];
The enum
values should be used to define modeID
s of presets in a human-readable way.
KEEP IN MIND
The array of LightingMode
contains state of all modes and should therefore be the same size as the ModeIDs
enum.
Presets
A Preset defines the state of a channel that is applied when the mode set in the presets modeID
is active.
The most basic presets contain a valid modeID
and an intensity
as a 16-bit unsigned value:
cpp
const PROGMEM Preset examplePreset =
{
.modeID = HighBeams,
.intensity = 0xFFFF
};
If you omit the intensity
property, it will be treated as full intensity.
By default, presets make channels 'act' like incandescent bulbs; when a preset is applied, the channel fades in to the desired intensity
, and fades out slower when the preset is no longer applied.
If you want to emulate LED-based modern vehicle lighting you can set those fade times to different values:
cpp
const PROGMEM Preset exampleLEDPreset =
{
.modeID = DaytimeRunningLights,
.fadeSpeedRising = 0,
.fadeSpeedFalling = 0,
};
In most real-world applications, a single vehicle light fixture can serve more than one purpose, so it is likely that a channel has multiple presets defined for each desired state of the bulb. Presets are cascading; they can take or give precedence over other presets.
This allows you to define intricate lighting that takes priority over other modes when needed, but not interfere with other modes when that is not desirable. (e.g. US tail lights where the blinker takes priority over the parking lights and brake light)
Presets also support basic blinking.
Channels
A channel represets a single physical light source in your hardware setup.Previously defined presets are assigned to channels so the output intensity of a channel can be calculated correctly for each combination of active modes.
The number of channels your config requires depends on the number of physical light sources and the output devices that are driving those. If you have two LED drivers, one with 16 and the other with 9 outputs, you must define 15 channels in your config.
The following example defines a single channel with two basic presets:
cpp
const PROGMEM Preset presetsHeadlightLeft[] =
{
{ .modeID = LowBeams, .intensity = 0x1111 },
{ .modeID = HighBeams, .intensity = 0x7777 },
};
Channel channels[] =
{
{ COUNT_OF(presetsHeadlightLeft), presetsHeadlightLeft }
};
const size_t channelsCount = COUNT_OF(channels);
KEEP IN MIND
All channels should be defined in a global channels[]
array. In this example, this array only contains a single channel.
How many channels you need depends on the amount of physical lights you plan to include in your project, and the amount of output channels your hardware is able to support.
Devices
Devices represent external or built-in means to output lighting. Most devices have multiple channels (e.g. LED-driver ICs, or pins on a microcontroller). Every device requires its own driver with a setup()
and, in the case of an output device, an output()
.
Devices using the I²C protocol are "hot-swappable"; A setup routine runs when the device is connected, after which the current lighting state is sent to the device.
It comes built-in with support for these LED diver ICs:
Example definition of devices in a config:
cpp
#pragma once
builtinLED builtin(0);
PCA9685 dashboard(0x40, 0, 4);
WireDevice *wireDevices[] =
{
&dashboard
};
size_t wireDevicesCount = COUNT_OF(wireDevices);
OutputDevice *outputDevices[] =
{
&dashboard,
&builtin,
};
size_t outputDevicesCount = COUNT_OF(outputDevices);
- Devices in
wireDevices[]
will be checked for (dis-)connection and initialized when needed. - Devices in
outputDevices[]
will be updated with changed lighting channels for every frame.
Presets in-depth
Presets are cascading; they can take or give precedence over other presets:
- Latest takes priority (
LTP
)
A later preset takes priority over previous presets. - Lowest takes priority (
LoTP
)
When a preset with a lower value than the current preset is already active, that preset will take priority, even if it comes before the current preset. - Highest takes priority (
HTP
)
When a preset with a higher value than the current preset is already active, that preset will take priority, even if it comes before the current preset.
This allows you to define intricate lighting that takes priority over other modes when needed, but not interfere with other modes when that is not desirable.
Since what you can do with cascading can be a bit hard to grasp, the following examples will make use of different kinds of cascading to hopefully make it understandable as a useful tool.
Blinkers
Channels support basic blinking functionality that can be used for blinkers, alarm lights etc.
KEEP IN MIND
Channels calculate what preset is applied for any given combination of active modes. Therefore, only a single preset is applied each time. This also means that only a single blinking preset can be applied at the time.
The following example describes a basic blinking preset:
cpp
const PROGMEM Preset exampleBlinkPreset =
{
.modeID = BlinkL,
.mode = PresetModes::Blink,
.timeOff = 400,
.timeOn = 200
};
This preset is applied and removed when the blink cycle swaps phase. The timeOff
and timeOn
properties determine how long the preset is applied and defines the blinks duty cycle.
Blinkers with a default state
Most of the time, blinking presets are defined in sets:
- A normal preset
acting as the default preset that is applied when the blinking preset is in the off-phase. You can use this preset to turn off - A blinking preset that is applied when the blink phase is in the on-state.
The following example describes a set of blinking presets alternating between half intensity and full intensity:
cpp
const PROGMEM Preset exampleBlinkPresets[] =
{
{ .modeID = BlinkL, .intensity = 0x7777 },
{ .modeID = BlinkL, .mode = PresetModes::Blink, .intensity = 0xFFFF },
};
Sequential blinkers
When you define multiple channels with blink presets that have the same period (the total of timeOn
and timeOff
is equal between them), you can easily configure those channels as a squential blinking pattern.
Example:
cpp
const PROGMEM Preset presetBlinkerFirst[] =
{
{ .modeID = BlinkL, .mode = PresetModes::Blink, .timeOff = 200, .timeOn = 1000 }
};
const PROGMEM Preset presetBlinkerSecond[] =
{
{ .modeID = BlinkL, .mode = PresetModes::Blink, .timeOff = 400, .timeOn = 800 }
};
const PROGMEM Preset presetBlinkerThird[] =
{
{ .modeID = BlinkL, .mode = PresetModes::Blink, .timeOff = 600, .timeOn = 600 }
};
Channel channels[] =
{
{ COUNT_OF(presetBlinkerFirst), presetBlinkerFirst },
{ COUNT_OF(presetBlinkerSecond), presetBlinkerSecond },
{ COUNT_OF(presetBlinkerThird), presetBlinkerThird },
};
const size_t channelsCount = COUNT_OF(channels);
US tail lights
...with combined brake lights and blinkers.
The way most old North-American cars use a single bulb for tail lights, brake lights and turn signals is fascinating.
cpp
const PROGMEM Preset USTaillightPresets[] =
{
{ .modeID = Parking, .intensity = 0x7777 },
{ .modeID = Brake },
{ .modeID = BlinkL, .intensity = 0 },
{ .modeID = BlinkL, .mode = PresetModes::Blink },
};
In this example, the blinking light always alternates between fully off, and fully on, regardless of if the Brake
or Parking
modes are applied.
Thunderbird
In some older models, the tail lights are bit more complicated: The blinkers and brake lights are 'overlaid' over the parking lights.
The hard part is letting the blinker take priority over the brake lights, while still taking the state of the parking lights into consideration. The following example achieves that effect:
cpp
const PROGMEM Preset thunderbirdPresets[] =
{
{ .modeID = Brake },
{ .modeID = BlinkL, .intensity = 0 },
{ .modeID = BlinkL, .mode = PresetModes::Blink },
{ .modeID = Parking, .priorityMode = SwopModes::HTP, .intensity = 0x7777 },
};
At first glance, the order of the presets might be a little unconventional but here is how it works:
In the example;
- the
Brake
preset is overwritten by the firstBlinkL
preset. - the
Parking
preset has aHTP
priority, which only applies the preset when the calculated channel intensity up to that point is lower than the preset intensity;
The intensity of theParking
preset is overlaid over the0
intensity of the firstBlinkL
preset.
(How to combine this example with the sequential blinkers is left as an exercise for the reader.)