diff --git a/pio-scripts/load_usermods.py b/pio-scripts/load_usermods.py index 38a08401e6..c2411ffb27 100644 --- a/pio-scripts/load_usermods.py +++ b/pio-scripts/load_usermods.py @@ -5,7 +5,8 @@ from SCons.Script import Exit from platformio.builder.tools.piolib import LibBuilderBase -usermod_dir = Path(env["PROJECT_DIR"]).resolve() / "usermods" +project_dir = Path(env["PROJECT_DIR"]).resolve() +usermod_dir = (project_dir / "usermods").resolve() # Utility functions def find_usermod(mod: str) -> Path: @@ -28,7 +29,8 @@ def find_usermod(mod: str) -> Path: def is_wled_module(dep: LibBuilderBase) -> bool: """Returns true if the specified library is a wled module """ - return usermod_dir in Path(dep.src_dir).parents or str(dep.name).startswith("wled-") + dep_root = Path(dep.src_dir).resolve() + return usermod_dir in dep_root.parents or str(dep.name).startswith("wled-") ## Script starts here # Process usermod option diff --git a/usermods/chase_race/chase_race_usermod.cpp b/usermods/chase_race/chase_race_usermod.cpp new file mode 100644 index 0000000000..28f2c02f30 --- /dev/null +++ b/usermods/chase_race/chase_race_usermod.cpp @@ -0,0 +1,107 @@ +#include "wled.h" + +#ifdef USERMOD_CHASE_RACE + +/* + * Chase Race Usermod + * ------------------ + * Registers a dedicated usermod so we can keep the custom "Chase Race" effect + * separate from WLED core code. The usermod exists purely to register the + * effect with strip.addEffect(); all race logic lives in mode_chase_race(). + * + * Slider/Color mapping: + * Speed -> Pace (advance interval) + * Intensity -> Car length (LEDs per car) + * Custom1 -> Gap size (blank pixels between cars) + * Colors -> Car 1 / Car 2 / Car 3 body colors + */ + +namespace { + +// Metadata string that drives the UI and JSON description of the new effect. +// Layout: +// Speed slider -> "Pace" (updates per second) +// Intensity -> "Car length" +// Custom1 -> "Gap size" +// Custom2 -> unused (reserved) +// Colors -> Car 1, Car 2, Car 3 +static const char _data_FX_MODE_CHASE_RACE[] PROGMEM = + "Chase Race@Pace,Car length,Gap size,,,;Car 1,Car 2,Car 3;;"; + +static uint16_t mode_chase_race(void) { + const uint16_t segLen = SEGLEN; + if (!segLen) return FRAMETIME; + + if (SEGENV.call == 0) { + SEGENV.step = strip.now; + SEGENV.aux0 = 0; // head position + } + + const uint16_t minInterval = 10; + const uint16_t maxInterval = 180; + const uint16_t interval = maxInterval - ((maxInterval - minInterval) * SEGMENT.speed / 255); + + if (strip.now - SEGENV.step >= interval) { + SEGENV.step = strip.now; + SEGENV.aux0 = (SEGENV.aux0 + 1) % segLen; + } + + // Ensure there is room for each car plus at least 1 blank LED when possible. + uint16_t carLenMax = 1; + if (segLen > 3) { + carLenMax = max(1, (segLen - 3) / 3); + } + carLenMax = max(1, min(carLenMax, segLen)); // safety for tiny segments + + uint16_t carLen = map(SEGMENT.intensity, 0, 255, 1, carLenMax); + carLen = max(1, min(carLen, carLenMax)); + + uint16_t maxGap = 0; + if (segLen > carLen * 3) { + maxGap = (segLen - (carLen * 3)) / 3; + } + + uint16_t desiredGap = map(SEGMENT.custom1, 0, 255, 1, max(1, segLen / 3)); + uint16_t gapLen = (maxGap == 0) + ? 0 + : max(1, min(desiredGap, maxGap)); + + const uint16_t spacing = carLen + gapLen; + const uint16_t origin = SEGENV.aux0 % segLen; + + SEGMENT.fill(BLACK); + + auto drawCar = [&](uint16_t start, uint32_t color) { + if (!color) return; + for (uint16_t i = 0; i < carLen && i < segLen; i++) { + uint16_t idx = (start + i) % segLen; + SEGMENT.setPixelColor(idx, color); + } + }; + + // SEGCOLOR indices are reversed relative to the UI (color slot 1 -> index 2) + drawCar(origin, SEGCOLOR(2)); // Car 1 color slot + drawCar((origin + spacing) % segLen, SEGCOLOR(1)); // Car 2 + drawCar((origin + (2 * spacing)) % segLen, SEGCOLOR(0)); // Car 3 + + return FRAMETIME; +} + +} // namespace + +class ChaseRaceUsermod : public Usermod { + public: + void setup() override { + strip.addEffect(255, &mode_chase_race, _data_FX_MODE_CHASE_RACE); + } + + void loop() override {} + + uint16_t getId() override { return USERMOD_ID_CHASE_RACE; } + +}; + +static ChaseRaceUsermod chaseRace; +REGISTER_USERMOD(chaseRace); + +#endif // USERMOD_CHASE_RACE diff --git a/usermods/chase_race/library.json b/usermods/chase_race/library.json new file mode 100644 index 0000000000..e46a4d9929 --- /dev/null +++ b/usermods/chase_race/library.json @@ -0,0 +1,7 @@ +{ + "name": "chase_race", + "version": "0.1.0", + "description": "Scaffolding for the Chase Race WLED effect usermod.", + "build": { "libArchive": false }, + "dependencies": {} +} diff --git a/usermods/chase_race/load_usermods.py b/usermods/chase_race/load_usermods.py new file mode 100644 index 0000000000..c2411ffb27 --- /dev/null +++ b/usermods/chase_race/load_usermods.py @@ -0,0 +1,109 @@ +Import('env') +from collections import deque +from pathlib import Path # For OS-agnostic path manipulation +from click import secho +from SCons.Script import Exit +from platformio.builder.tools.piolib import LibBuilderBase + +project_dir = Path(env["PROJECT_DIR"]).resolve() +usermod_dir = (project_dir / "usermods").resolve() + +# Utility functions +def find_usermod(mod: str) -> Path: + """Locate this library in the usermods folder. + We do this to avoid needing to rename a bunch of folders; + this could be removed later + """ + # Check name match + mp = usermod_dir / mod + if mp.exists(): + return mp + mp = usermod_dir / f"{mod}_v2" + if mp.exists(): + return mp + mp = usermod_dir / f"usermod_v2_{mod}" + if mp.exists(): + return mp + raise RuntimeError(f"Couldn't locate module {mod} in usermods directory!") + +def is_wled_module(dep: LibBuilderBase) -> bool: + """Returns true if the specified library is a wled module + """ + dep_root = Path(dep.src_dir).resolve() + return usermod_dir in dep_root.parents or str(dep.name).startswith("wled-") + +## Script starts here +# Process usermod option +usermods = env.GetProjectOption("custom_usermods","") + +# Handle "all usermods" case +if usermods == '*': + usermods = [f.name for f in usermod_dir.iterdir() if f.is_dir() and f.joinpath('library.json').exists()] +else: + usermods = usermods.split() + +if usermods: + # Inject usermods in to project lib_deps + symlinks = [f"symlink://{find_usermod(mod).resolve()}" for mod in usermods] + env.GetProjectConfig().set("env:" + env['PIOENV'], 'lib_deps', env.GetProjectOption('lib_deps') + symlinks) + +# Utility function for assembling usermod include paths +def cached_add_includes(dep, dep_cache: set, includes: deque): + """ Add dep's include paths to includes if it's not in the cache """ + if dep not in dep_cache: + dep_cache.add(dep) + for include in dep.get_include_dirs(): + if include not in includes: + includes.appendleft(include) + if usermod_dir not in Path(dep.src_dir).parents: + # Recurse, but only for NON-usermods + for subdep in dep.depbuilders: + cached_add_includes(subdep, dep_cache, includes) + +# Monkey-patch ConfigureProjectLibBuilder to mark up the dependencies +# Save the old value +old_ConfigureProjectLibBuilder = env.ConfigureProjectLibBuilder + +# Our new wrapper +def wrapped_ConfigureProjectLibBuilder(xenv): + # Call the wrapped function + result = old_ConfigureProjectLibBuilder.clone(xenv)() + + # Fix up include paths + # In PlatformIO >=6.1.17, this could be done prior to ConfigureProjectLibBuilder + wled_dir = xenv["PROJECT_SRC_DIR"] + # Build a list of dependency include dirs + # TODO: Find out if this is the order that PlatformIO/SCons puts them in?? + processed_deps = set() + extra_include_dirs = deque() # Deque used for fast prepend + for dep in result.depbuilders: + cached_add_includes(dep, processed_deps, extra_include_dirs) + + wled_deps = [dep for dep in result.depbuilders if is_wled_module(dep)] + + broken_usermods = [] + for dep in wled_deps: + # Add the wled folder to the include path + dep.env.PrependUnique(CPPPATH=str(wled_dir)) + # Add WLED's own dependencies + for dir in extra_include_dirs: + dep.env.PrependUnique(CPPPATH=str(dir)) + # Enforce that libArchive is not set; we must link them directly to the executable + if dep.lib_archive: + broken_usermods.append(dep) + + if broken_usermods: + broken_usermods = [usermod.name for usermod in broken_usermods] + secho( + f"ERROR: libArchive=false is missing on usermod(s) {' '.join(broken_usermods)} -- modules will not compile in correctly", + fg="red", + err=True) + Exit(1) + + # Save the depbuilders list for later validation + xenv.Replace(WLED_MODULES=wled_deps) + + return result + +# Apply the wrapper +env.AddMethod(wrapped_ConfigureProjectLibBuilder, "ConfigureProjectLibBuilder") diff --git a/usermods/chase_race/readme.md b/usermods/chase_race/readme.md new file mode 100644 index 0000000000..5a12271cfe --- /dev/null +++ b/usermods/chase_race/readme.md @@ -0,0 +1,36 @@ +# Chase Race Usermod + +This folder hosts the dedicated usermod for the **Chase Race** effect. The +effect renders three "cars" (solid color bars) that run nose-to-tail along the +active segment. Blank pixels between cars simulate the open road, and once the +lead car crosses the finish line the entire pack immediately wraps to the +start, creating an endless race loop. + +## Files + +- `chase_race_usermod.cpp` registers the usermod/effect and implements the race + logic. +- `library.json` keeps PlatformIO happy (`libArchive=false` ensures the code is + linked directly into the firmware). + +## Building + +1. Select the `chase_race` PlatformIO environment (already the default in the + workspace-level `platformio.ini`). +2. Compile/upload with `pio run -e chase_race` or from the IDE. + +The environment enables the `USERMOD_CHASE_RACE` build flag and instructs +`pio-scripts/load_usermods.py` to include this folder automatically. + +## Effect controls + +- **Speed slider ("Pace")** - how fast the convoy advances. +- **Intensity slider ("Car length")** - LED length of each car. +- **Custom 1 slider ("Gap size")** - blank spacing between cars. If there + aren't enough pixels to honor the requested spacing, the usermod shrinks the + gaps automatically (down to zero when physically necessary). +- **Color slots ("Car 1 / Car 2 / Car 3")** - individual body colors. Using the + same color for multiple cars is perfectly fine. + +Enable the Chase Race effect from the WLED UI or JSON API and it will animate +the currently selected segment using these controls. diff --git a/wled00/const.h b/wled00/const.h index 8891dfcaee..5a97fa3dbc 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -207,6 +207,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_CHASE_RACE 59 //Usermod "chase_race_usermod.cpp" //Access point behavior #define AP_BEHAVIOR_BOOT_NO_CONN 0 //Open AP when no connection after boot