FireFly Solar / 日輪蛍 – Nichirin Hotaru

太陽を宿し、夜にモールスを灯す蛍。
Taiyō o yadoshi, yoru ni Mōrusu o tomosu hotaru.
A firefly carrying the sun, lighting Morse in the night.

FireFly Solar is my small solar-powered Morse blinker for outdoors. Think of it as a solar-punk style LED throwie, but without the disposable coin cell.

Some years ago, when I started playing with AVR and ATtiny programming, I built a very simple Morse “throwie”: one ATtiny, one LED, one resistor, and some power-friendly firmware. The goal was to use as few parts as possible while still doing something useful.

The basic behaviour is simple: detect daylight, sleep when it is bright, blink when it is dark. The LED can even be used as a light sensor, which keeps the hardware nicely minimal.

This new version keeps the spirit of that old project, but moves it closer to a small outdoor object. It uses a solar cell and supercapacitor instead of a disposable battery. During the day it charges. At night it wakes up and sends a Morse-style light signal.

So it is no longer really a throwie. It is more like a small pendant or garden signal creature. Hang it somewhere with daylight, keep the electronics protected from direct weather, and let it blink when the sun is gone.

Why this Name?

Why Japanese? Because I want to do so, but the repository name stays simple: FireFly Solar

For blog posts and documentation I use the Japanese name:

FireFly Solar / 日輪蛍 – Nichirin Hotaru

Roughly translated, this means something like sun-disc firefly. This will keep the solar idea for the new design.

About this Project

This is partly a small hardware project and partly a workflow exercise. I want to improve my practical design skills in a few areas:

  • low-power electronics
  • solar charging with supercapacitors
  • simple outdoor mechanical design
  • 3D-printed parts
  • documentation for beginner-friendly DIY kits
  • repeatable project workflow from idea to small kit

The hardware itself should stay simple. The larger goal is to define a clean path from prototype to PCB, enclosure, documentation, BOM, and maybe a small DIY kit later.

Maybe this will end up on Tindie one day. Maybe it will only become a useful internal reference project for future ToGo-Lab hardware. Both outcomes are fine.

Idea behind

The target is a beginner-friendly soldering kit with clear documentation and hackable firmware.

Current idea:

  • MCU: ATtiny45/85, through-hole
  • Power: solar cell plus supercapacitor
  • Behaviour: night-only Morse-style blink
  • Light detection: preferably minimal hardware, using the LED as in the original morse throwie
  • Firmware: editable with Arduino IDE
  • Mechanics: small outdoor-friendly holder or pendant structure

Where to find the Details

The source files and releases live in the ToGo-Lab Gitea repository, https://gitea.togo-lab.io/tgohle/0001-FireFly

Larger internal notes and working documents may live in Nextcloud during development, but the public-facing documentation should stay in the repository as much as possible.

The default licence target for documentation and design files is:

CC-BY-NC-4.0

Firmware licensing will be handled separately if needed.

電車ベクトル (Train Vector): The Software

Short overview

This is the software description of the 電車ベクトル (Train Vector) project (link to overview). The hardware side (link to description) isolates and prepares the motor signal, but the real decision how to light the LEDs is done in software.

Because the signal comes from a noisy H-bridge motor controller, the software has to do more than just one raw read and one if statement.

The actual Arduino sketch is here in the git subfolder: https://gitea.togo-lab.io/tgohle/0004-DenshaBekutoru/src/branch/master/firmware/ArduinoTest/DenshaBekutoru-0004_Version-0-2_ProMini_PCB2026-2_V01

In Summary

The software is the part that turns the noisy input from the hardware into a stable direction decision. It does that by:

  • averaging the analogue inputs,
  • calibrating itself at startup,
  • building a threshold from the real idle behaviour,
  • using hysteresis,
  • remembering the last valid direction,
  • driving the outputs accordingly,

Basic idea

The software reads two analogue voltages from the optocoupler output stage:

  • ADC_PIN_1 = A0
  • ADC_PIN_2 = A1

These two signals represent the processed motor direction information. If the motor is driven one way, one side will dominate. If the motor is driven the other way, than the other side will dominate. So the basic software idea is to compare both voltages and evaluate the difference:

  • diff = v1 - v2

If diff is clearly positive, the software decides for direction 1.
If diff is clearly negative, the software decides for direction 2.
If the difference is too small, the software does not change direction and simply keeps the last valid state.

That last point is the important one. It means the headlights do not start flickering just because the motor is stopped, the signal is weak, or the input is sitting in some noisy neutral area.

First step: averaging the analogue inputs

The first thing, after power on: The sketch averages the inputs over a time window. This is done in:

  • measureAveragedVoltages()

The code samples both analogue channels repeatedly over:

  • SAMPLE_WINDOW_MS = 100
  • SAMPLE_DELAY_US = 500

So instead of reacting to one random noisy measurement, the software builds an average value for both channels:

  • v1
  • v2

This is one of the most important parts of the program, because the motor signal is not clean. Without averaging, the later direction decision would be much more unstable.

Second step: calibration when nothing happens

At startup, the software assumes there is no real movement yet. That is the moment it uses to adapt itself to the actual build, controller, and wiring. This is done in:

  • calibrateVdiffThreshold()

The sketch performs multiple averaged measurements at boot:

  • AverageRepeat = 100

For each of these measurements it calculates:

  • |v1 - v2|

This gives the natural mismatch between both channels when no real direction decision should happen yet. Because even in the idle case both channels are usually not perfectly identical, the software does not assume zero. Instead, it measures the real baseline. The measured values are sorted, and then the median is taken:

  • VdiffBaseline = median(|V1-V2|)

I think this is a good choice, because the median is less sensitive to single bad spikes than a simple average. From this baseline, the code builds the real decision threshold:

  • VdiffThreshold = max(VdiffBaseline * 1.5, 0.03V)

So the threshold is:

  • adapted to the real hardware setup
  • increased by a margin
  • never allowed to go below a minimum floor

This is exactly the part that should help the board work with different train builds and different controllers without having to hard-code one fixed voltage value.

Third step: threshold and hysteresis

Once the baseline threshold is known, the software creates two limits:

  • T_hold
  • T_enter

These are derived from the calibrated threshold:

  • T_hold = 1.0 * VdiffThreshold
  • T_enter = 2.0 * VdiffThreshold

This creates a small hysteresis system. The idea is:

  • if the signal difference is very small, hold the old direction
  • if the signal difference is clearly strong, allow a direction change
  • in between, still hold the old direction

So the software does not switch direction the moment the signal only barely crosses one border. That makes the behaviour much more stable.

The state machine

The actual state machine is very simple and handled in:

  • updateDirectionFromVoltages()

mainly State machine (direction memory) + Hysteresis (Version A)

I use two thresholds:

  • T_hold (smaller): below -> treat as neutral and HOLD state
  • T_enter (larger) : above -> allow direction change (set by sign)

Behavior:

  • If |diff| = T_enter : set direction by sign
  • Else (between) : hold direction

Presets (implemented as multipliers of the calibrated VdiffThreshold):

  • T_hold = 1.0 * VdiffThreshold
  • T_enter = 2.0 * VdiffThreshold
  • Inputs derived from v1, v2: diff = v1 – v2

This means the software remembers the last valid direction and does not jump around in the weak or noisy range.

Output logic

Once the state machine has decided the direction, the outputs are updated in:

  • applyDirectionOutputs()

The output pins are:

  • LED_DIR1_PIN = 3
  • LED_DIR2_PIN = 9

The code first switches both outputs off and then activates only the matching one:

  • direction 2 -> D3 on
  • direction 3 -> D9 on

So at the moment the output stage is simple:

  • one direction = one output active
  • the other direction = the other output active

This is enough for the basic headlight function.

The sketch also includes a small startup blink sequence in:

  • initBlinkSequence()
  • blinkLED()

That is mainly useful to see directly that the board is alive after power-up.

Main loop behaviour

After setup and calibration, the loop is very simple:

  1. measure both averaged input voltages
  2. update the direction state
  3. apply the outputs
  4. print debug values to serial
  5. wait a short time

The delay at the end is:

  • delay(300)

So this is not written as a super-fast control loop. It is written as a stable and easy-to-observe detection loop, which makes sense for this kind of project stage.

Serial output for debugging

For debugging, the code can print all important values to serial output.

This is controlled by:

  • SerialOutputAllow

The function is:

  • serialPrintAll()

The output includes:

  • v1
  • v2
  • |v1-v2|
  • VdiffBaseline
  • VdiffThreshold
  • T_hold
  • T_enter
  • current direction

For development this is useful, because it makes it much easier to see why the software made a certain decision and how well the calibration worked.

Overview of the used functions

Here is a short overview of the most important functions in the sketch:

  • blinkLED()
    Small helper to blink one output LED

  • initBlinkSequence()
    Startup blink pattern to show the board is alive

  • applyDirectionOutputs()
    Switches the output pins according to the current direction state

  • measureAveragedVoltages()
    Reads both analogue inputs over a time window and calculates averaged voltages

  • sortFloatArray()
    Sort helper used during calibration

  • medianOfSortedFloatArray()
    Returns the median value from the sorted calibration data

  • calibrateVdiffThreshold()
    Measures the idle mismatch at startup and creates the calibrated threshold values

  • updateDirectionFromVoltages()
    The actual direction decision logic with hysteresis and direction memory

  • serialPrintAll()
    Prints all relevant measured and calculated values for debugging

  • setup()
    Initialises serial, pins, calibration, startup blink, first direction update

  • loop()
    Repeats measurement, state update, output update, and debugging output