Summary: JLed is an embedded C++ library to control LEDs. It uses a non-blocking approach and can control LEDs in simple (on/off) and complex (blinking, breathing and more) ways in a time-driven manner. LEDs can be grouped and controlled in parallel or sequentially.
- Language: C++14 or later, embedded-friendly (no exceptions, RTTI, or dynamic allocation)
- Build System: PlatformIO + Make, managed via devbox
- Key Constraint: Non-blocking, time-driven approach for resource-constrained MCUs
README.md- User-facing documentationdoc/- Images for documentationexamples/- End-to-end examples for MCU (.inosketches)src/- Library source code (.hand.cppfiles)test/- Host-based unit tests with separate Makefile.tools/- Development tools (doc site generator).github/workflows/- CI/CD configurationdevbox.json- Development environment configurationplatformio.ini- PlatformIO project configurationMakefile- Top-level build orchestration
Prerequisites:
- Devbox for dependency management
Initial setup:
devbox shell # Sets up all dependencies: Python 3.13, lcov 1.16, cpplint 2.0.0, pip, platformio 6.1.18All development tools are configured in devbox.json and activated automatically.
Formatting:
- Code formatting is defined in
.clang-format(based on Google style) - Validate with cpplint:
make lint
Naming Conventions:
- Classes/Types:
PascalCase(e.g.,BrightnessEvaluator,JLed) - Public methods:
PascalCase(e.g.,Breathe(),Update(),FadeOn()) - Private members:
snake_case_with trailing underscore (e.g.,val_,duration_on_) - Constants:
kPascalCase(e.g.,kFullBrightness,kRepeatForever) - Template parameters: Single uppercase letter (e.g.,
T,B) or descriptivePascalCase
C++ Guidelines:
- Use C++14 features
- Prefer
constexprover#definefor constants - Use
= deletefor disabled constructors - Use
overridekeyword for virtual method overrides - No floating-point in core logic (expensive on MCUs without FPU)
- Prefer templates over virtual functions for performance-critical code
During development we use PlatformIO. All tasks are orchestrated by Makefiles:
make lint- Lint all C++ files using cpplintmake ci- Compile all examples for different target platforms (usually run in CI, takes ~10 minutes)make envdump- Runpio run --target envdumpto dump detailed PlatformIO environment informationmake clean- Clean up all build artifactsmake upload- Upload the active sketch inplatformio.inito the configured MCUmake monitor- Start serial monitoring for the configured MCU (for debugging)make test- Run all unit tests in thetest/directory and calculate coveragemake tags- Create ctags for all files in the project
The tests in test/ are orchestrated by a separate Makefile, which is called by the top-level
Makefile. test/Makefile has the following targets:
make test- Build and run all testsmake clean- Delete all intermediary files (e.g., object files)make clobber- Likemake cleanplus delete all created test binariesmake coverage- Run tests and calculate coverage (generates HTML report intest/report/)
Testing Framework:
- Uses Catch2 (amalgamated version in
test/catch2/) - Test files:
test/test_*.cpp - Common main:
test/test_main.cpp(containsCATCH_CONFIG_MAIN)
Test Structure:
- Test naming:
TEST_CASE("description of what is tested", "[tag]") - Use
SECTION()for related test variations within a TEST_CASE - Tags help organize tests:
[jled],[sequence],[hal], etc.
Writing Tests:
- All core logic should have unit tests
- Aim for high coverage (check with
make coverage) - Test effect evaluators by calling
Eval(t)at different time points - Use mocks for HAL testing (see
test/Arduino.h,test/esp-idf/for examples) - HAL code is primarily tested via integration examples on real hardware
Adding Tests:
- When adding a new effect: Add tests in
test/test_jled.cpp - When adding HAL support: Add tests in
test/test_[platform]_hal.cpp - When fixing a bug: Add a test that reproduces the bug first
- Update
test/Makefileif adding new test files
Supported Platforms:
- Arduino (AVR, SAMD, etc.)
- ESP32 (with Arduino framework)
- ESP8266 (with Arduino framework)
- ARM mbed (Cortex-M)
- Raspberry Pi Pico
Platform detection is automatic via preprocessor macros in src/jled.h.
Core Components:
src/jled_base.h- Platform-agnostic core logicTJLed<HalType, Clock, B>template class - Main LED controller with state machineBrightnessEvaluator- Abstract base for all effects- Effect implementations:
ConstantBrightnessEvaluator,BlinkBrightnessEvaluator,BreatheBrightnessEvaluator,CandleBrightnessEvaluator TJLedSequence<JLed, Clock, B>template - Controls multiple LEDs in parallel or sequence
src/jled.h- Platform-specific convenience layer- Detects platform via preprocessor macros
- Selects appropriate HAL and Clock implementations
- Provides
JLedandJLedSequenceclasses
src/*_hal.h- Hardware abstraction layers for PWM and time (ESP32, ESP8266, Arduino, mbed, Pico)
Core principle: JLed core logic (i.e. the state machine), the effects calculation and the
access to the hardware are strictly separated. This keeps complexity low and allows us to a) easily
extend JLed and b) effectively unit test almost all parts of JLed
- JLed logic/effects and hardware code are strictly separated
- Each platform has two separate abstractions in
src/*_hal.h:- PWM HAL - Controls GPIO pins (e.g.,
ArduinoHal,Esp32Hal)analogWrite(uint8_t val)- Write PWM value to GPIO pin
- Clock - Provides time (e.g.,
ArduinoClock,Esp32Clock)static uint32_t millis()- Return current MCU time in milliseconds
- PWM HAL - Controls GPIO pins (e.g.,
- Separation rationale: A platform can have multiple PWM HALs (e.g., native platform PWM and external ICs like PCA9685), but only one time provider
- Uses compile-time polymorphism (templates, not virtual functions) for performance
- Avoids virtual function table overhead in hot paths
- Zero-cost abstraction
- An effect is simply a function that calculates a brightness over time
- Each effect implements the abstract
BrightnessEvaluatorclass - The
BrightnessEvaluatorclass has two abstract methods that must be overridden:Period()- Returns the period of the effect in millisecondsEval(t)- Calculates the brightness [0-255] for the given point in timet(0 ≤ t < Period())
- Effects must be stateless - all state is managed by the
TJLedclass - Effects should be copyable (used with placement new)
- Uses C++ placement new to avoid dynamic allocation
- Each
JLedinstance has a fixed buffer:alignas(...) char brightness_eval_buf_[MAX_SIZE] MAX_SIZEis compile-time calculated as max size of all effect evaluator types:__max(sizeof(CandleBrightnessEvaluator), __max(sizeof(BreatheBrightnessEvaluator), __max(sizeof(ConstantBrightnessEvaluator), sizeof(BlinkBrightnessEvaluator))))
- When effect changes (e.g.,
.Fade()), old evaluator is destroyed, new one constructed in same buffer - Copy constructor clones evaluator into new
JLedinstance's buffer
- JLed uses a fluent interface in the public API that allows configuring LEDs in an
intuitive way, e.g.:
JLed led = JLed(21).DelayBefore(1500).Breathe(500).Repeat(5).MaxBrightness(150);
- Methods return
B&(reference to derived type) using CRTP pattern for chainability - This approach allows readable, self-documenting LED configurations
Embedded System Constraints:
- No dynamic allocation: MCUs have limited heap; avoid new/delete in library code
- No exceptions: Not available on many embedded platforms
- No RTTI: Keep binary size small
- Timing critical:
Update()must be fast - called frequently in main loop - Memory footprint: Keep instance size small (users may control many LEDs)
- Non-blocking: Never use
delay()- always time-driven approach
Design Philosophy:
- Simplicity over features: Don't add complexity for edge cases
- Testability: If it can't be unit tested, reconsider the design
- Portability: Core logic must be platform-agnostic
- Performance: Critical path (
Update()/Eval()) must be optimized - Separation of concerns: Logic, effects, and hardware access are strictly separated
-
Create the evaluator class in
src/jled_base.h:class MyEffectBrightnessEvaluator : public CloneableBrightnessEvaluator { uint16_t period_; public: explicit MyEffectBrightnessEvaluator(uint16_t period) : period_(period) {} BrightnessEvaluator* clone(void* ptr) const override { return new (ptr) MyEffectBrightnessEvaluator(*this); } uint16_t Period() const override { return period_; } uint8_t Eval(uint32_t t) const override { // Calculate brightness [0-255] based on time t return /* your calculation */; } };
-
Update MAX_SIZE calculation if your evaluator is larger than existing ones (in
TJLedclass) -
Add fluent interface method in
TJLedtemplate class:B& MyEffect(uint16_t period) { return SetBrightnessEval( new (brightness_eval_buf_) MyEffectBrightnessEvaluator(period)); }
-
Add unit tests in
test/test_jled.cpp:TEST_CASE("MyEffect works correctly", "[jled]") { // Test the effect evaluator and integration with JLed }
-
Add an example in
examples/my_effect/my_effect.ino -
Update README.md with documentation and example
-
Create HAL header
src/[platform]_hal.h:// PWM HAL for GPIO control class MyPlatformHal { public: using PinType = uint8_t; // or appropriate type MyPlatformHal() = delete; explicit MyPlatformHal(PinType pin) : pin_(pin) { // Initialize pin for PWM output } void analogWrite(uint8_t val) { // Write PWM value to pin } private: PinType pin_; }; // Clock for time tracking class MyPlatformClock { public: static uint32_t millis() { // Return current time in milliseconds } };
-
Add platform detection in
src/jled.h:#elif defined(MY_PLATFORM_MACRO) #include "my_platform_hal.h" namespace jled { using JLedHalType = MyPlatformHal; using JLedClockType = MyPlatformClock; }
-
Add unit tests in
test/test_my_platform_hal.cppwith appropriate mocks -
Test on actual hardware using existing examples
-
Update CI (if possible) in
.github/workflows/test.ymlto include your platform -
Update README.md and this file to document the new platform
-
Create directory
examples/my_example/ -
Create sketch
examples/my_example/my_example.ino:#include <jled.h> // Keep it simple and focused on one concept // Add comments explaining what the example demonstrates void setup() { } void loop() { }
-
Test on hardware - ensure it works on at least one platform
-
Add to platformio.ini if needed (comment out by default)
-
Update README.md if the example demonstrates a new concept
-
Write a failing test that reproduces the bug in
test/test_*.cpp -
Fix the bug in the appropriate source file
-
Verify test passes with
make test -
Check coverage with
make coverage- ensure the fix is covered -
Run lint with
make lint -
Test on hardware if it's a platform-specific issue
DO:
- ✓ Preserve backwards compatibility in public API
- ✓ Add tests for all bug fixes and new features
- ✓ Run
make lintandmake testbefore committing - ✓ Test on actual hardware when possible
- ✓ Document public API in code comments
DON'T:
- ✗ Add platform-specific code to
jled_base.h- use HAL instead - ✗ Use floating-point in core logic - slow on MCUs without FPU
- ✗ Make breaking changes without major version bump
- ✗ Add features requiring dynamic allocation, exceptions, or RTTI
- ✗ Use
delay()or blocking operations - ✗ Over-engineer for hypothetical future use cases
- ✗ Add external library dependencies
GitHub Actions (.github/workflows/test.yml) runs on push/PR to master:
- Lint Job: Runs
make lint- must pass for merge - Test Job:
- Runs
make test- unit tests - Generates coverage - uploads to Coveralls
- Runs
make ci- builds all examples for all platforms (~10 minutes) - Must pass for merge
- Runs
When CI Fails:
- Lint errors: Check
make lintoutput - Test failures: Review test logs
- Build errors: Check platform-specific compilation issues
JLed has an automated documentation microsite at https://jandelgado.github.io/jled/ that provides version-aware documentation with easy navigation between different versions.
The documentation site:
- Auto-generates from git tags and master branch
- Provides version switching between all stable releases
- Includes README content, images, and examples for each version
- Deploys automatically to GitHub Pages on every push to
master
Located in .tools/doc-site/, this Python tool generates the static site:
Key Files:
generate_site.py- Main site generator scripttemplates/base.html- Page template with navigationtemplates/redirect.html- Root redirect to latest versionrequirements.txt- Python dependencies (markdown, Jinja2, packaging)README.md- Detailed tool documentation
How It Works:
- Discovers all stable git tags (matching
v*.*.*, excluding pre-releases) - Sorts versions using semantic versioning
- For each version: checks out code, parses README, extracts navigation, copies assets
- Generates HTML pages with version selector, page nav, and examples list
- Creates root redirect to latest stable version
Local Usage:
# Generate site to .doc-site/
make docs
# Serve locally (always use port 9000)
cd .doc-site && python -m http.server 9000The web server only needs to be started once. After re-running make docs, the regenerated
files are served immediately — no need to restart the server.
Workflow: .github/workflows/deploy-docs.yml
Trigger: Push to master branch (or manual dispatch)
Steps:
- Checkout repository with full history (
fetch-depth: 0) - Setup Python 3.13
- Install dependencies from
requirements.txt - Generate site to
./site-build - Deploy to
gh-pagesbranch usingpeaceiris/actions-gh-pages@v4
First-Time Setup: After initial workflow run, enable GitHub Pages in repository settings:
- Settings → Pages → Source: Deploy from branch
gh-pages
/index.html # Redirects to latest stable
/versions.json # Metadata: versions, latest stable
/v2.0.0/
│ ├── index.html # Version page with README + nav
│ ├── doc/ # Images and assets
│ └── examples/ # Example folders
│ ├── hello/
│ │ ├── index.html # Example page with syntax-highlighted code
│ │ └── hello.ino
│ └── morse/
│ ├── index.html
│ ├── morse.ino
│ └── README.md
/master/
├── index.html
├── doc/
└── examples/
Individual pages are generated for each example showing syntax-highlighted code:
Implementation:
generate_example_page()ingenerate_site.pyprocesses each exampletemplates/example.htmlprovides consistent styling with main docs- File filtering excludes backups (*~) and build artifacts
- Language detection maps extensions to Pygments lexers (.ino → C++, etc.)
- README.md files in examples are rendered at the bottom
File Processing:
- Include: source (.ino, .cpp, .h), build (CMakeLists.txt), scripts (.sh, .py)
- Exclude: backups (*~), build artifacts (.o, .bin), large files (>500KB)
- Order: main source first, README last
Examples with README.md: morse, multiled, multiled_mbed, raspi_pico
To update site styling/layout:
- Edit
.tools/doc-site/templates/base.html - Test locally before committing
- Push to
masterto deploy
To modify content generation:
- Edit
.tools/doc-site/generate_site.py - Update logic for version filtering, README parsing, or asset copying
- Test locally, then commit and push
To update dependencies:
- Modify
.tools/doc-site/requirements.txt - Test locally:
pip install -r .tools/doc-site/requirements.txt --upgrade
See .tools/doc-site/README.md for complete documentation.
On Target Hardware:
- Flash with
make upload, monitor withmake monitor - Add
Serial.print()statements for debugging - Verify
Update()is called regularly inloop()(every few milliseconds) - Check pin numbers match your board's pinout
Development:
- Check HAL implementation if behavior differs between platforms
- For HAL issues: test simple on/off before complex effects
- Use unit tests to isolate core logic issues
- Check PlatformIO environment with
make envdump