Skip to content

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:

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

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:

  1. Group your desired lighting functionality into modes and name them well.
  2. Define presets with the state of any psyical lights for any combination of modes.
  3. Define the psysical output channels and assign the previously defined presets to the right channels.
  4. 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 modeIDs 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.

Cascading in-depth

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 first BlinkL preset.
  • the Parking preset has a HTP priority, which only applies the preset when the calculated channel intensity up to that point is lower than the preset intensity;
    The intensity of the Parking preset is overlaid over the 0 intensity of the first BlinkL preset.

(How to combine this example with the sequential blinkers is left as an exercise for the reader.)