diff --git a/CMakeLists.txt b/CMakeLists.txt index 90ca6433a..0f9f8d98d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,9 @@ else() # and that count may only fall, while each site stays a judgment (a driver may have to wait # on hardware), so failing the build on them would block every other gate. add_compile_options(-Wfunction-effects -Wno-error=function-effects) + # A stack frame over 512 bytes, counted the same way: a task's stack is sized for its deepest call, so one large frame costs every task that reaches it. + # Here rather than for every compiler: the code report reads this build's saved findings, and the frame a Release build lays out is the one a task pays for. + add_compile_options(-Wframe-larger-than=512 -Wno-error=frame-larger-than) # Each file's findings are kept beside its object, so the code report reads the whole tree after an incremental build instead of a clean one. find_package(Python3 COMPONENTS Interpreter QUIET) if(Python3_Interpreter_FOUND AND NOT CMAKE_CXX_COMPILER_LAUNCHER) @@ -278,9 +281,9 @@ file(CONFIGURE OUTPUT ${MOONLIVE_STAMP} CONTENT "${MOONLIVE_SCRIPT_LIST}\n") set(MOONLIVE_CATALOG_STAMP ${CMAKE_BINARY_DIR}/moonlive_catalog.stamp) add_custom_command( OUTPUT ${MOONLIVE_CATALOG_STAMP} - COMMAND ${CMAKE_COMMAND} -DSCRIPT_DIR=${CMAKE_SOURCE_DIR}/moonlive -DOUT=${CMAKE_SOURCE_DIR}/src/light/moonlive/script_catalog.h -DUV_EXECUTABLE=${UV_EXECUTABLE} -P ${CMAKE_SOURCE_DIR}/src/light/moonlive/catalog_scripts.cmake + COMMAND ${CMAKE_COMMAND} -DSCRIPT_DIR=${CMAKE_SOURCE_DIR}/moonlive -DOUT=${CMAKE_SOURCE_DIR}/src/core/moonlive/script_catalog.h -DUV_EXECUTABLE=${UV_EXECUTABLE} -P ${CMAKE_SOURCE_DIR}/src/core/moonlive/catalog_scripts.cmake COMMAND ${CMAKE_COMMAND} -E touch ${MOONLIVE_CATALOG_STAMP} - DEPENDS ${MOONLIVE_SCRIPTS} ${MOONLIVE_STAMP} ${CMAKE_SOURCE_DIR}/src/light/moonlive/catalog_scripts.cmake ${CMAKE_SOURCE_DIR}/src/light/moonlive/catalog_scripts.py + DEPENDS ${MOONLIVE_SCRIPTS} ${MOONLIVE_STAMP} ${CMAKE_SOURCE_DIR}/src/core/moonlive/catalog_scripts.cmake ${CMAKE_SOURCE_DIR}/src/core/moonlive/catalog_scripts.py COMMENT "Generating MoonLive script catalog" ) add_custom_target(moonlive_catalog DEPENDS ${MOONLIVE_CATALOG_STAMP}) diff --git a/README.md b/README.md index 1aef6156c..ccb94846b 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ MoonLight is a community project, shaped by the people who use it: - **Ideas and requests**: an effect, a layout, a driver, a fixture you want supported? [Open an issue](https://github.com/MoonModules/MoonLight/issues). - **Help build it**: pick something from the [issues](https://github.com/MoonModules/MoonLight/issues), or propose a module. The process is in [CLAUDE.md](CLAUDE.md). - **Test on hardware**: run it on your panels and fixtures, and report what works. -- **Talk to us**: [Discord](https://discord.gg/TC8NSUSCdV), [Reddit](https://reddit.com/r/moonmodules), [YouTube](https://www.youtube.com/@MoonModulesLighting), [GitHub](https://github.com/MoonModules). +- **Talk to us**: [Discord](https://discord.gg/TC8NSUSCdV), [Reddit](https://reddit.com/r/moonmodules), [YouTube](https://www.youtube.com/@MoonModulesLighting), [GitHub](https://github.com/MoonModules), or [leave a message](https://tally.so/r/68X5xO). ## License diff --git a/docs/assets/how-to/multi-board/autopilot-card.png b/docs/assets/how-to/multi-board/autopilot-card.png new file mode 100644 index 000000000..749268fab Binary files /dev/null and b/docs/assets/how-to/multi-board/autopilot-card.png differ diff --git a/docs/assets/how-to/multi-board/bulb-body.png b/docs/assets/how-to/multi-board/bulb-body.png new file mode 100644 index 000000000..b78dd83f2 Binary files /dev/null and b/docs/assets/how-to/multi-board/bulb-body.png differ diff --git a/docs/assets/how-to/multi-board/bulb-top.png b/docs/assets/how-to/multi-board/bulb-top.png new file mode 100644 index 000000000..e5c340abb Binary files /dev/null and b/docs/assets/how-to/multi-board/bulb-top.png differ diff --git a/docs/assets/how-to/multi-board/control-card.png b/docs/assets/how-to/multi-board/control-card.png new file mode 100644 index 000000000..019c0bcac Binary files /dev/null and b/docs/assets/how-to/multi-board/control-card.png differ diff --git a/docs/assets/how-to/multi-board/eye-card.png b/docs/assets/how-to/multi-board/eye-card.png new file mode 100644 index 000000000..462bebe3d Binary files /dev/null and b/docs/assets/how-to/multi-board/eye-card.png differ diff --git a/docs/assets/how-to/multi-board/eye.gif b/docs/assets/how-to/multi-board/eye.gif new file mode 100644 index 000000000..e7162db7a Binary files /dev/null and b/docs/assets/how-to/multi-board/eye.gif differ diff --git a/docs/assets/how-to/multi-board/leg-foot.png b/docs/assets/how-to/multi-board/leg-foot.png new file mode 100644 index 000000000..3967b16de Binary files /dev/null and b/docs/assets/how-to/multi-board/leg-foot.png differ diff --git a/docs/assets/how-to/multi-board/legs-card.png b/docs/assets/how-to/multi-board/legs-card.png new file mode 100644 index 000000000..aae6dc45f Binary files /dev/null and b/docs/assets/how-to/multi-board/legs-card.png differ diff --git a/docs/assets/how-to/multi-board/legs.gif b/docs/assets/how-to/multi-board/legs.gif new file mode 100644 index 000000000..f1ae92111 Binary files /dev/null and b/docs/assets/how-to/multi-board/legs.gif differ diff --git a/docs/assets/how-to/multi-board/osc-follower-card.png b/docs/assets/how-to/multi-board/osc-follower-card.png new file mode 100644 index 000000000..a19572428 Binary files /dev/null and b/docs/assets/how-to/multi-board/osc-follower-card.png differ diff --git a/docs/assets/how-to/multi-board/osc-leader-card.png b/docs/assets/how-to/multi-board/osc-leader-card.png new file mode 100644 index 000000000..daa924f19 Binary files /dev/null and b/docs/assets/how-to/multi-board/osc-leader-card.png differ diff --git a/docs/assets/how-to/multi-board/qr.png b/docs/assets/how-to/multi-board/qr.png new file mode 100644 index 000000000..51dbcf196 Binary files /dev/null and b/docs/assets/how-to/multi-board/qr.png differ diff --git a/docs/assets/how-to/multi-board/rings241.png b/docs/assets/how-to/multi-board/rings241.png new file mode 100644 index 000000000..1c0e20866 Binary files /dev/null and b/docs/assets/how-to/multi-board/rings241.png differ diff --git a/docs/assets/how-to/multi-board/sk6812rgbw.png b/docs/assets/how-to/multi-board/sk6812rgbw.png new file mode 100644 index 000000000..86ebbf158 Binary files /dev/null and b/docs/assets/how-to/multi-board/sk6812rgbw.png differ diff --git a/docs/assets/how-to/multi-board/tube-connector.png b/docs/assets/how-to/multi-board/tube-connector.png new file mode 100644 index 000000000..5f3e0a7ef Binary files /dev/null and b/docs/assets/how-to/multi-board/tube-connector.png differ diff --git a/docs/contributing/coding-standards.md b/docs/contributing/coding-standards.md index 677011088..6ba1ef97b 100644 --- a/docs/contributing/coding-standards.md +++ b/docs/contributing/coding-standards.md @@ -181,6 +181,9 @@ A finding is a named code smell. Every smell has a named, behavior-preserving mo | Platform code outside `src/platform` | Move Function into the platform layer; `if constexpr` on `platform_config.h` | a target's facts in one layer | | A blocking call on the render path | Move the work off the render thread: a worker, a cached value, a deferred apply | the tick only computes | | A name spread across files | Move Function, self-registration | a concept in one place | +| A module type named in the UI | Replace Conditional with Polymorphism: the module declares a control, a role or a flag | the UI knows no module | +| A stack frame over 512 bytes | Move the large local into a member or a `ScratchBuffer`, sized once | no task's stack pays for it | +| A buffer taken by hand in the light domain | Replace it with a `ScratchBuffer` member | owned, sized, reported and freed in one place | Cut first: a branch that chooses between equals is data, a guard is an early return, and nesting is what a reader trips on, more than branch count. The theory behind the moves is Ousterhout's: complexity is dependencies and obscurity, so a deep module with a small interface absorbs it and its callers stay simple. A function may stay over a limit when splitting it would hide a sequence that belongs together. It says so in one line, and its count stays rather than being forced. diff --git a/docs/contributing/documentation-standards.md b/docs/contributing/documentation-standards.md index e9e1e136c..c95cbc182 100644 --- a/docs/contributing/documentation-standards.md +++ b/docs/contributing/documentation-standards.md @@ -287,9 +287,9 @@ These are traps, not style: each one silently loses content from the generated p /// ## Terminology ``` - A renamed heading leaves the anchor pointing at nothing, and the generator drops the dead link rather than failing. So `check_docgen` verifies that every `@xref` names a heading in the same file. The reverse is not a rule: a `@moreinfo` section is an appendix a reader scrolls to, and most are reached that way rather than through a link. + A renamed heading leaves the anchor pointing at nothing, and the generator drops the dead link rather than failing. So `check_docgen` verifies that every `@xref` names a heading in the same file. The reverse is counted. A `@moreinfo` section that no `@xref` in its file and no link from a page or another source reaches is a section nothing refers to. It is a warning whose count only falls, since a section no reader is sent to is the first to cut. - **A section written to hold a comment's depth carries an `@xref` back from that comment.** Moving the reasoning out is what keeps the code readable; the anchor is what keeps it findable. The reader at the line that raises the question gets a link to the answer. Where no single line owns the section, the appendix stands alone and the reverse rule above applies. + **A section written to hold a comment's depth carries an `@xref` back from that comment.** Moving the reasoning out is what keeps the code readable; the anchor is what keeps it findable. The reader at the line that raises the question gets a link to the answer. Where no single line owns the section, a page that sends readers there links it as `moxygen/.md#`, or the section is a candidate to cut. - **Wrap any `` in backticks**, or it renders as a live element and swallows the page. - **Write "such as", not "e.g."** The brief ends at the first period. - **A weasel that CONTRASTS is information.** "bytes actually allocated" against requested, "the factor actually in use" against configured: the word carries the distinction the comment exists to draw. Vale flags the word rather than the use, which is why its Weasel rule is a suggestion. diff --git a/docs/explanation/architecture/mooncore.md b/docs/explanation/architecture/mooncore.md index bf17789e8..704c0a35e 100644 --- a/docs/explanation/architecture/mooncore.md +++ b/docs/explanation/architecture/mooncore.md @@ -111,7 +111,7 @@ Abstractions are added when a concrete implementation needs them, not pre-design **Platform boundary (hard rule).** All `#ifdef`, `#if defined`, platform-specific `#include`s, and hardware API calls live exclusively in `src/platform/`. Everything outside `src/platform/` compiles on every target without modification. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags, never a preprocessor `#ifdef`. **Domain boundary.** A file in `src/core` includes nothing from `src/light`: core declares an interface, and the light domain implements and registers it. -Both boundaries are rules in [the code report](../../reference/metrics/code.md), whose counts may only fall: the platform one at zero, the domain one at the crossings that remain. +Both boundaries are rules in [the code report](../../reference/metrics/code.md), each at zero, and a crossing raises the count the ratchet refuses. `LightOutput` is the domain boundary's shape: the light domain's `Drivers` implements it, and the WLED shim, MQTT and MoonStats read the device shape and the palettes through it. **The desktop build runs everything (hard rule).** Every module, effect and driver in the repo links and runs on the host, the platform layer has no silicon behind the call. Where a peripheral is absent the host *emulates* it rather than declaring itself incapable: the parallel WS2812 buses are backed by heap buffers, `lcdLanes` / `parlioLanes` / `rmtTxChannels` report a real chip's counts, and `hasLcdCam` is true. Code excluded from the host binary is code that cannot be unit-tested and cannot be seen by any AST-based check. It only ever runs where it is hardest to debug, which is what the LED drivers were until they were linked here. diff --git a/docs/explanation/architecture/moonlight.md b/docs/explanation/architecture/moonlight.md index f585e6c66..09cf6e42f 100644 --- a/docs/explanation/architecture/moonlight.md +++ b/docs/explanation/architecture/moonlight.md @@ -324,6 +324,7 @@ Non-negotiable: - Effects always write to their layer's logical buffer. Never to output, never to physical coordinates. - Drivers always own the output path (blending, mapping, brightness correction, channel reordering). - Layer buffer is mandatory: if it doesn't fit, reduce dimensions until it does ("at least see something"). +- A light module's buffer is a `ScratchBuffer` member, which sizes it, reports it in `dynamicBytes()` and frees it in `release()`, so no allocation is held unreported or leaked. The [code report](../../reference/metrics/code.md) counts each buffer still taken by hand, and that count only falls. ### Per-module reporting diff --git a/docs/how-to/multi-board-installation.md b/docs/how-to/multi-board-installation.md new file mode 100644 index 000000000..0be414209 --- /dev/null +++ b/docs/how-to/multi-board-installation.md @@ -0,0 +1,224 @@ +# Building a multi-board installation + +> Saw the StadBeest at the Museumnacht? [Leave a message](https://tally.so/r/68X5xO), or join us on [Discord](https://discord.gg/TC8NSUSCdV) or [Reddit](https://reddit.com/r/moonmodules). + +An installation is several MoonLight boards that act as one piece: one creature, one sculpture, one room. +The worked example is the StadBeest, a walking beast whose legs and eyes run on three boards. +Each section names a choice that makes it work; borrow what fits your own piece. + +> New here? Start with **[Install & first light](../gettingstarted.md)** and **[your first script](../tutorials/first-script.md)**. What follows assumes boards that run MoonLight on one network. + +--- + +## The StadBeest at a glance + +| part | board | lights | layout | effect | +|---|---|---|---|---| +| legs | LightCrafter 16 (ESP32-S3 N8R8) | 10 SK6812 RGBW strips of 144 | Tubes, `tubeDistance` 7 | `stadbeest-legs.mle` | +| left eye | ESP32-S3-Zero, 4 MB | a 241-light ring disc | Rings241 | `stadbeest-eyes.mle` | +| right eye | ESP32-S3-Zero, 4 MB | a 241-light ring disc | Rings241 | `stadbeest-eyes.mle` | + +See it at the [Museumnacht Den Haag](https://museumnachtdenhaag.nl/programma/het-stad-beest-ontwaakt/), where it runs through the night as *Het Stad Beest ontwaakt*. + +### The text next to the beast + +A QR code linking to the multi-board installation guide on moonmodules.org + +> **Het Stad Beest ontwaakt** +> +> Dit beest is gemaakt van wat de stad wegdoet. +> Zijn lijf is een kroonluchter, zijn nek zijn buigzame lamparmen uit de kringloopwinkel, en zijn ogen zitten in 3D-geprinte gloeilampen. +> Vanavond wordt het wakker. +> Het loopt op tien poten van licht, steeds een paar tegelijk, zoals een echt dier. +> Het hoort de muziek: op de beat zet het een stap, kijkt het om zich heen en flitst het. +> Hoe harder de muziek, hoe hoger het zijn poten optilt en hoe wijder zijn pupillen worden. +> +> Niemand bestuurt het. +> Elke paar minuten kiest het zelf nieuwe kleuren en een nieuwe stemming, van rustig tot wild. +> Drie kleine computers praten via wifi met elkaar, zodat poten en ogen samen één beest zijn. +> Ze draaien op MoonLight, open-source lichtsoftware van vrijwilligers: iedereen kan er zelf zo'n beest mee bouwen. +> +> Scan de code om te zien hoe het gemaakt is en om contact met ons op te nemen. +> +> *Door Ewoud Wijma - MoonModules* + +> **The City Beast awakens** +> +> This beast is made of what the city throws away. +> Its body is a chandelier, its neck is bendable lamp arms from a second-hand shop, and its eyes sit in 3D-printed light bulbs. +> Tonight it wakes up. +> It walks on ten legs of light, a few at a time, like a real animal. +> It hears the music: on the beat it takes a step, looks around and flashes. +> The louder the music, the higher it lifts its legs and the wider its pupils grow. +> +> Nobody steers it. +> Every few minutes it picks new colors and a new mood of its own, from calm to wild. +> Three small computers talk over WiFi, so its legs and eyes move as one beast. +> They run MoonLight, open-source lighting software made by volunteers: anyone can build a beast like this. +> +> Scan the code to see how it is made and to get in touch with us. +> +> *By Ewoud Wijma - MoonModules* + +The legs in the preview: ten tubes, one or two legs lifting at a time in a wave from back to front An eye in the preview: a dark pupil in a palette iris on the ring disc, darting and blinking + +The legs board leads: its control surface holds the shared settings, and the eyes follow it over OSC. +Every board hears the same music through WLED audio sync, so all three step and blink on the same beat. + +--- + +## How the StadBeest is made + +The body is built on a chandelier, and the eyes ride on bendable lamp arms, both found in a second-hand store. +Each eye sits in a 3D-printed bulb body, shaped like a lamp with a screw base. +A printed top plate screws onto the body and holds the 241-light ring disc. + +The 3D-printed eye bulb: a slotted, tapering body on a screw-thread base, open at the top The 3D-printed top plate: a flat disc with screw holes that mounts the ring disc onto the bulb body + +Each leg is a tube that holds one LED strip. +A printed connector joins the top of a tube to the chandelier, and a printed foot closes the bottom, with a slot for the strip's cable. + +The 3D-printed tube connector: a hollow sleeve that joins a leg tube to the chandelier The 3D-printed foot: a wide round base with a socket and a cable slot for the bottom of a leg tube + +--- + +## 1. One board per part + +Give each physical part its own board and its own layout, sized to that part. +A part then keeps its own frame rate, effect and brightness limits. +A failing board takes down one part rather than the piece. +The layout decides what an effect can show: the legs are ten vertical strips, the eyes are nine concentric rings, and each effect is written for exactly that shape. + +SK6812 RGBW strips, the lights of the legs The 241-light ring disc of an eye: nine concentric rings of LEDs + +The legs run on a LightCrafter 16, a 16-channel LED driver board with Ethernet, using ten of its channels. +Each eye runs on an ESP32-S3-Zero, small enough to fit inside the printed bulb. + +The LightCrafter 16 board: sixteen LED output channels around an ESP32-S3, with an Ethernet port The Waveshare ESP32-S3-Zero and its pin map + +--- + +## 2. Effects written for the hardware + +Both StadBeest effects are [MoonLive](../moonmodules/light/moonlive.md) scripts, and most of what makes them read well is design rather than code. + +The legs effect card: bpm, swing, lift, stand, sensitivity and pulse The eye effect card: bpm, sensitivity, pulse, pupil, look and blink + +**Draw for the medium.** A ring disc has nine rings, from one light in the center to sixty at the edge. +So the eye is built from ring zones: a dark pupil, a palette iris, a dim rim. Fine detail such as iris fibers or a thin slit pupil disappears at that resolution. + +**Move few things at once.** In a real walk most legs stand still while one or two lift. The legs effect keeps every leg lit and standing, and lifts each one briefly in a wave from back to front, the two sides half a cycle apart. A creature reads as alive when the eye can follow what moves. + +**Tie color to movement.** A standing leg holds its colors, a lifting leg carries them up with its foot, and each step moves the palette on a quarter. The eye's colors move on with each dart of its gaze. Color that drifts on its own competes with the motion instead of showing it. + +**Listen to the right signal.** + +- `audioBeat()` for timing: the legs step and the eyes dart on the beat. +- `audioSmooth()` for size: louder music lifts the legs higher and widens the pupil. +- A flash on the beat that fades over half a second, for brightness. + +The raw frequency bands change every frame, and driven straight into brightness or height they look random. + +**Let two boards agree without talking.** The eyes pick each dart's direction from `audioPeakHz()` at the beat, which both boards read the same. +So they look the same way at the same moment, with no message between them. + +--- + +## 3. Iterate on the board + +A script is a file, so the fastest loop is to edit it where it runs. + +1. Write it to the board: `curl -X POST -H "Expect:" --data-binary @legs.mle "http:///api/file?path=/moonlive/stadbeest-legs.mle"`. +2. Read the effect's status on its card or in `/api/state`: a compile error shows there with its position. +3. Judge it on the real lights, then change one thing. + +A file in `/moonlive` shadows the factory script of the same name, so the board runs your copy until you delete it ([scripts on a device](../reference/integrating.md)). +A script that does not compile leaves its effect dark, so keep the last working version at hand and upload it back before you walk away. + +--- + +## 4. One surface layout for every board + +Each board's [Control](../moonmodules/core/system.md#control) card is a desk of eight faders, eight encoders and eight switches, and each slot is assigned to one control by name, such as `stadbeest-legs.bpm`. +The legs board's Control card: each slot labeled after the control it drives, such as pulse, bpm, swing, lift and run + +Use one slot layout across the whole installation: a slot means the same thing on every board where it applies. +A board with nothing for a slot leaves it unassigned. + +| slot | legs board | each eye | +|---|---|---| +| `switch1` | `Drivers.on` | `Drivers.on` | +| `switch2` | legs `pulse` | eye `pulse` | +| `fader1` | `Drivers.brightness` | `Drivers.brightness` | +| `fader2` | legs `bpm` | eye `bpm` | +| `fader3` | legs `sensitivity` | eye `sensitivity` | +| `fader4` | legs `swing` | | +| `fader5` | legs `lift` | | +| `fader6` | legs `stand` | | +| `encoder1` | `Drivers.palette` | `Drivers.palette` | +| `encoder2` | | eye `pupil` | +| `encoder3` | | eye `look` | +| `encoder4` | | eye `blink` | +| `switch8` | autopilot `run` | | + +A slot holds the value of the control it drives, so `fader2` at 33 means `bpm` 33, and assigning a slot changes nothing until the slot moves. +Assign from the card, or with `POST /api/control` and `{"module":"Control","control":"fader2Target","value":"stadbeest-legs.bpm"}`. + +--- + +## 5. Linking the boards with OSC + +The [OSC](../moonmodules/core/services.md#osc) service mirrors a surface to the network, which is all the linking an installation needs. + +The leader's OSC card: listen, feedback on, multicast, group 239.255.77.78, feedbackPort 9001 A follower's OSC card: listen on port 9001, feedback off + +**The leader** (the legs board): OSC `feedback` on, `addressing` multicast, `group` the default `239.255.77.78`, `feedbackPort` 9001. +Every slot that changes on the leader, from its card, a preset, a script or a desk, goes out as `/mm/fader/N`, `/mm/encoder/N` or `/mm/switch/N`. + +**The followers** (the eyes): an OSC service with `listen` on and `port` 9001, the leader's `feedbackPort`, and `feedback` off. +A follower joins the group, writes each message onto its own slot N, and its assignments carry the value to its own controls. + +Three details matter: + +- The followers listen on 9001 and the leader on 9000, so the leader never hears its own feedback, and a follower never answers back. +- A follower takes a value when the leader's slot changes, and the leader sends every slot again every 30 seconds. A value lost on WiFi, or a follower that rebooted, is repaired within that time. +- WiFi multicast drops packets that arrive in a burst, so the leader sends that refresh one slot at a time. + A writer on the leader spreads its own changes the same way. + +--- + +## 6. Running unattended + +A [MoonLiveService](../moonmodules/core/services.md#moonliveservice) on the leader runs `stadbeest-autopilot.mls`, which plays the beast through a whole museum night without anyone at the desk. + +The autopilot service card: script, run and minutes + +- **Scenes:** every `minutes` it picks a new scene: a palette from the vivid ones, a mood from calm to wild, `pulse` on or off, and the eyes' pupil size and blink rate. +- **Breathing:** within a scene the energy rises and falls once a minute, spread over the legs' tempo, swing and lift and how far the eyes look. +- **Through the surface:** it writes the shared slots with `setControl("fader2", value)`, so the legs follow on the leader and the eyes over OSC, exactly as when a person moves a fader. +- **One slot at a time:** it writes one slot every 20 ms, every ten seconds, so no burst reaches the network. +- **Handing over:** its `run` switch sits on `switch8`; off gives the beast back to people and desks. + +## 7. One source of music + +Every board runs the [Audio](../moonmodules/core/services.md#audio) service in receive mode, and one machine sends WLED audio sync to all of them. +That machine is a board with a microphone, or a computer running MoonLight that captures what it plays. +One source means one beat: the legs step, the eyes dart and every flash lands on the same moment. + +--- + +## 8. Updating boards you cannot reach + +Boards inside a sculpture are hard to reach with a cable, so update them over the network and check before you send. + +- Compare the new image's size with the `partition` total on the board's Firmware card. +- A board with two app slots takes `POST /api/firmware/upload` directly. +- A 4 MB board has one app slot: `POST /api/firmware/moonbase` restarts it into MoonBase, its recovery image, which then takes the upload and restarts into the new firmware ([updating firmware](updating-firmware.md)). +- Update one board, see it run, then the next. + +--- + +## Driving it by hand + +Every slot is also reachable from a phone, a tablet or a hardware desk: [connecting a control surface](control-surface.md). +A desk on the leader moves the whole installation, since its changes go out over OSC like any other. diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md index 69f010c7f..b03159b2d 100644 --- a/docs/moonmodules/core/services.md +++ b/docs/moonmodules/core/services.md @@ -38,7 +38,7 @@ A user-added Service: the audio source the audio-reactive effects consume. `mode - `syncPort`: (network build) the UDP port, 11988 by default; `sync status` reports the state. - read-only: `level` (RMS), `peakHz` (the audio driving effects, from any source). -Detail: [technical](moxygen/AudioService.md) · [the sync packet](../light/moxygen/WLEDAudioSyncPacket.md) · [the lock-free ring](moxygen/SpscRing.md) +Detail: [technical](moxygen/AudioService.md) · [the sync packet](moxygen/WLEDAudioSyncPacket.md) · [the lock-free ring](moxygen/SpscRing.md) [Tests](../../reference/tests/unit-tests.md#audioservice) @@ -135,6 +135,8 @@ Faders move the surface's faders, the knobs turn its encoders, and each channel' Detail: [technical](moxygen/MidiService.md) + + ### MoonLiveService A Service added per board: **a MoonLive script that reads hardware and drives controls**. The flexible half of the input story, and the twin of a scripted effect. @@ -237,6 +239,7 @@ preset recall, an audio-reactive effect) is mirrored back to the surface, which A client learns the current state three ways: when it first writes to us from a new address, when its address changes, and whenever it sends **`/mm/hello`**. The last one exists because a client restarting on the SAME address is invisible to the other two, and most controllers send nothing of their own on load, so every widget would show its layout file's defaults until the user moved one. The shipped session has a `sync from device` button for exactly this. +Every value also goes out again every 30 seconds, one every 20 ms, so a datagram lost on WiFi, or a client that rebooted, is repaired within that time. **Setting one up**, from installing the app to using it from a phone, is its own page: [Connecting a control surface](../../how-to/control-surface.md). It needs no checkout and no tooling, just the app and the session file from the latest release. diff --git a/docs/moonmodules/core/supporting.md b/docs/moonmodules/core/supporting.md index 35925f245..4a1306c84 100644 --- a/docs/moonmodules/core/supporting.md +++ b/docs/moonmodules/core/supporting.md @@ -13,6 +13,7 @@ A row without a link carries free functions and constants rather than a class, w | [`ControlSurface`](moxygen/ControlSurface.md) | A transport that mirrors the control surface's state in both directions. The module owns the state and a surface is a view of it, which is what lets two attach at once. | | [`InputMapping`](moxygen/InputMapping.md) | The binding table behind a surface. A row says what an input does to a control: set it, step it, toggle it. A delta steps down as well as up, so one encoder covers a range without a second control to reverse it. | | [`ModuleFactory`](moxygen/ModuleFactory.md) | The registry mapping a type name to the function that builds one, which is what lets a preset file name a module this build has never instantiated. Storage grows on demand rather than reserving a fixed cap. | +| [`MoonLiveScript`](moxygen/MoonLiveScript.md) · [`MoonLiveScriptFile`](moxygen/MoonLiveScriptFile.md) · [`script_catalog`](moxygen/script_catalog.md) | One scripted module's script: the file it names, the compiled program and the content hash that decides when to recompile, shared by every scripted effect, layout, modifier, palette and service; and the factory scripts a device knows. | | [`ActiveInstance`](moxygen/ActiveInstance.md) | The one-active-instance election. Several instances of a type may exist, and exactly one is the one a consumer reaches. | ## Reading and writing JSON @@ -31,6 +32,7 @@ A flat parser returns zero for a missing key, which is indistinguishable from a | [`ScratchBuffer`](moxygen/ScratchBuffer.md) | One owned allocation with a typed view, so a module sizes its working memory once rather than per frame. The heavy body compiles once rather than per element type. | | [`SpscRing`](moxygen/SpscRing.md) | The textbook Lamport queue: one thread pushes, one pops, and each index is written by exactly one side. The release and acquire pair is what publishes the element data. | | [`TryLock`](moxygen/TryLock.md) | A non-blocking latch for a resource two threads reach. It never waits, so the render core is never held by a slower caller. | +| [`ThreadSlot`](moxygen/ThreadSlot.md) | A small table of slots a thread claims by its id, standing in for `thread_local` where an ESP32 task has no TLS. The script builtins reach their sinks through it. | | `Sort.h` | In-place insertion sort over a fixed array, for the small bounded collections the system holds. Every module sorts the same way and supplies only its comparator. | ## Wire formats and parsing @@ -42,6 +44,7 @@ A flat parser returns zero for a missing key, which is indistinguishable from a | [`FirmwareImage`](moxygen/FirmwareImage.md) | Reads an image's header without the vendor framework. The layout is a fixed on-disk format, so the same bytes parse on a device and in a test. | | [`PinList`](moxygen/util_PinList.md) · [`HostList`](moxygen/HostList.md) | The two list parsers a board configuration needs: a pin list and a host list of addresses and names, both typed by a human into one text control. | | [`Addressing`](moxygen/Addressing.md) · [`HostResolver`](moxygen/HostResolver.md) | One rule for sending to many receivers, unicast, multicast or broadcast, and the cache that turns a host name into an address off the render thread. | +| [`ConfigScrape`](moxygen/ConfigScrape.md) · [`Ipv4`](moxygen/Ipv4.md) | The keys MoonBase reads out of the app's saved config without a parser, and the IPv4 parse and static-address rule both images check by. | | [`sha256`](moxygen/sha256.md) · [`crc`](moxygen/crc.md) | A cryptographic digest for update verification and identity, and a cheap checksum used as a fingerprint of a block of state. | | [`Sha1`](moxygen/Sha1.md) · [`Base64`](moxygen/Base64.md) | The two codecs the WebSocket handshake needs, the digest that standard mandates and the encoding that carries it. | @@ -49,7 +52,7 @@ A flat parser returns zero for a missing key, which is indistinguishable from a | Header | What it is | |---|---| -| [`LightSummary`](moxygen/LightSummary.md) | A plain-data summary of the light pipeline's output, so the network shims report the real device shape without including anything from the light domain. | +| [`LightOutput`](moxygen/LightOutput.md) | The light pipeline as core reads it: the device's shape and its palettes as colors. The light domain implements it, so the network shims and MoonStats include nothing from the light domain. | | [`AudioFrame`](moxygen/AudioFrame.md) | One snapshot of analysed audio, produced once per render tick and consumed by audio-reactive effects. | | [`BinaryBroadcaster`](moxygen/BinaryBroadcaster.md) | The sink a producer holds instead of the concrete server, so a driver sends to every client without depending on the web server. | | `build_info.h` | The version and build identity, generated from the project manifest rather than edited by hand. | diff --git a/docs/moonmodules/light/supporting.md b/docs/moonmodules/light/supporting.md index 1a9bff2b3..53688ee7e 100644 --- a/docs/moonmodules/light/supporting.md +++ b/docs/moonmodules/light/supporting.md @@ -109,12 +109,6 @@ Detail: [technical](moxygen/MoonLiveParticles.md) [Tests](../../reference/tests/unit-tests.md#moonliveparticles) -### Script file - -One scripted module's script: the file it names, the compiled program, and the content hash that decides when to recompile. Shared by every scripted binding, effect, layout, modifier and palette alike. - -Detail: [technical](moxygen/MoonLiveScript.md) - ### Effect base The `EffectBase` class every effect derives from, the shared surface (buffer access, dimensions, the palette) an effect renders against. diff --git a/docs/reference/MIGRATING.md b/docs/reference/MIGRATING.md index 45dc57fdd..4ec38e576 100644 --- a/docs/reference/MIGRATING.md +++ b/docs/reference/MIGRATING.md @@ -34,6 +34,14 @@ For a device still on old firmware (no Backup button yet), the [installer page]( ## Unreleased +### The P4 and S31 Ethernet presets are named by their wiring + +**Action: *re-set a control*, only where a script, a document or an API call sets `ethBoard` to `P4-NANO` or `S31 CoreBoard`.** + +A preset is the wiring several boards follow, so `P4-NANO` is now `P4 RMII` and `S31 CoreBoard` is now `S31 RGMII`, beside `Classic RMII`. +A saved device opens on its chip's one preset as before: an unknown label leaves the selection at its default, which is that preset. +An old name sent to `ethBoard` matches no preset and changes nothing. + ### An audio codec is the device model's setting **Action: on an ESP32-S31 CoreBoard, apply its device model again, or set the I2C bus to SDA 51 / SCL 50 under System and AudioService's `codec` to `ES8311` with `mclkPin` 52.** diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index ef1fb0109..913740afb 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1497 finding(s)** across 310 file(s). 3.04% of all lines are duplicated, 3791 lines. +**1665 finding(s)** across 320 file(s). 3.00% of all lines are duplicated, 3791 lines. ## By rule @@ -14,10 +14,13 @@ Every function, block and file over a limit the [coding standards](../../contrib | complex function | 286 | | blocking call on the render path | 191 | | deeply nested | 114 | -| long function | 50 | +| module type in the UI | 69 | +| large stack frame | 61 | +| long function | 49 | | long parameter list | 34 | -| large file | 26 | -| light include in core | 10 | +| raw allocation in light | 28 | +| large file | 25 | +| name spread | 22 | ## How a finding is solved @@ -32,18 +35,22 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta - **blocking call on the render path** (a call from tick, tick20ms or tick1s that can block or allocate): Move the work off the render thread (a worker, a cached value, a deferred apply), or annotate a callee that cannot block. - **platform code outside src/platform** (a vendor header or a platform #ifdef outside src/platform): Move it behind a function in src/platform, and branch on platform_config.h with if constexpr. - **light include in core** (a src/core file including a light/ header): Dependency Inversion: core declares the interface, and the light domain implements and registers it. +- **module type in the UI** (a registered type's name as a string literal in src/ui): Replace Conditional with Polymorphism: the module declares what the UI needs (a control, a role, a flag) and the UI reads it. +- **name spread** (a type named in more than 10 files beyond its own): Move Function, self-registration: a concept in one place, which callers reach through its role or an interface. +- **large stack frame** (a function whose stack frame is over 512 bytes): Move the large local into a member or a ScratchBuffer, sized once, so no task's stack pays for it. +- **raw allocation in light** (an array new, malloc or platform::alloc in src/light outside ScratchBuffer): Replace the allocation with a ScratchBuffer member, which owns, sizes and frees it. ## By area | Area | Findings | |---|---:| | `test` | 584 | -| `light` | 387 | -| `core` | 324 | -| `platform` | 169 | +| `light` | 423 | +| `core` | 382 | +| `platform` | 173 | +| `ui` | 72 | | `moondeck` | 24 | -| `src` | 4 | -| `ui` | 3 | +| `src` | 5 | | `mooninstaller` | 2 | ## By file @@ -52,51 +59,56 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| -| `src/core/system/HttpServerModule.cpp` | 40 | `HttpServerModule::handleConnection`, complex function 119 | -| `src/platform/desktop/platform_desktop.cpp` | 37 | `platform::spawnEncoderProcess`, complex function 58 | +| `src/core/system/HttpServerModule.cpp` | 61 | `HttpServerModule::handleConnection`, complex function 119 | +| `src/platform/desktop/platform_desktop.cpp` | 41 | `platform::spawnEncoderProcess`, complex function 58 | +| `src/ui/migrate.js` | 41 | `line 29: 'I2cBusModule'`, module type in the UI 1 | | `test/unit/core/unit_FilesystemModule_persistence.cpp` | 34 | `lines 158-170, also in test/unit/core/unit_FilesystemModule_persistence.cpp:124`, duplicated block 13 | -| `src/core/moonlive/MoonLiveCompiler.cpp` | 26 | `moonlive::Parser::parseCall`, complex function 59 | -| `src/core/system/MqttModule.cpp` | 26 | `MqttModule::routePublish`, complex function 28 | -| `src/light/drivers/ParallelLedDriver.h` | 23 | `ParallelLedDriver::reinit`, complex function 36 | +| `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | +| `src/ui/app.js` | 29 | `app.js`, large file 8976 | +| `src/core/moonlive/MoonLiveCompiler.cpp` | 27 | `moonlive::Parser::parseCall`, complex function 59 | +| `src/light/drivers/ParallelLedDriver.h` | 27 | `ParallelLedDriver::reinit`, complex function 36 | +| `src/light/drivers/PreviewDriver.h` | 27 | `PreviewDriver::buildCoordTable`, complex function 37 | +| `src/light/drivers/RtspDriver.h` | 23 | `RtspDriver::serveControl, line 185`, large stack frame 1680 | | `test/unit/light/unit_I80Peripheral.cpp` | 23 | `lines 151-170, also in test/unit/light/unit_ParlioPeripheral.cpp:214`, duplicated block 20 | -| `src/light/drivers/RtspDriver.h` | 22 | `lines 92-104, also in src/light/drivers/HlsDriver.h:105`, duplicated block 13 | | `test/unit/light/unit_PanelCardDriver.cpp` | 22 | `lines 22-39, also in test/unit/light/unit_NdiDriver.cpp:29`, duplicated block 18 | | `test/unit/light/unit_RainbowEffect.cpp` | 22 | `lines 69-85, also in test/unit/light/unit_ParticlesEffect.cpp:32`, duplicated block 17 | -| `src/light/drivers/PreviewDriver.h` | 21 | `PreviewDriver::buildCoordTable`, complex function 37 | | `src/light/powerfunctions/draw.h` | 21 | `draw::scroll`, complex function 28 | -| `test/unit/core/unit_HttpServerModule_apply.cpp` | 21 | `lines 481-492, also in test/unit/core/unit_HttpServerModule_apply.cpp:113`, duplicated block 12 | +| `test/unit/core/unit_HttpServerModule_apply.cpp` | 21 | `lines 510-521, also in test/unit/core/unit_HttpServerModule_apply.cpp:116`, duplicated block 12 | +| `src/core/system/NetworkModule.h` | 20 | `NetworkModule in 27 files`, name spread 27 | | `test/unit/light/unit_ParallelLedDriver_ring.cpp` | 20 | `lines 486-503, also in test/unit/light/unit_ParallelLedDriver_ring.cpp:432`, duplicated block 18 | -| `src/core/system/NetworkModule.h` | 19 | `NetworkModule::syncAccessPoint`, complex function 18 | +| `src/light/drivers/HueDriver.h` | 19 | `HueDriver::pollPairing, line 297`, large stack frame 1184 | | `src/platform/esp32/platform_esp32.cpp` | 19 | `platform::httpRequest`, complex function 30 | | `src/platform/esp32/platform_esp32_moon_i80.cpp` | 18 | `platform::createRingState`, complex function 32 | | `test/unit/core/unit_PinsModule.cpp` | 18 | `lines 46-57, also in test/unit/light/unit_Hub75Driver_pins.cpp:66`, duplicated block 12 | -| `src/core/services/AudioService.h` | 17 | `AudioService::syncEnsureSocket`, complex function 17 | +| `src/core/services/AudioService.h` | 17 | `AudioService in 50 files`, name spread 50 | | `src/light/effects/PacmanEffect.h` | 16 | `lines 243-257, also in src/light/effects/FishTankEffect.h:244`, duplicated block 15 | -| `src/light/moonlive/MoonLiveBuiltins_light.h` | 15 | `moonlive::mm_light_escape`, complex function 13 | | `src/platform/esp32/platform_esp32_ota.cpp` | 15 | `platform::otaWriteMoonBase`, complex function 33 | | `test/unit/light/unit_PaintBrushEffect.cpp` | 15 | `lines 44-61, also in test/unit/light/unit_GEQ3DEffect.cpp:58`, duplicated block 18 | | `test/unit/light/unit_SolidEffect.cpp` | 15 | `lines 9-22, also in test/unit/light/unit_FixedRectangleEffect.cpp:90`, duplicated block 14 | +| `src/core/module/Control.cpp` | 14 | `applyControlValue`, complex function 74 | | `test/unit/light/unit_ParlioPeripheral.cpp` | 14 | `lines 214-233, also in test/unit/light/unit_I80Peripheral.cpp:151`, duplicated block 20 | -| `src/light/drivers/HueDriver.h` | 13 | `HueDriver::parseLights`, complex function 15 | +| `src/light/layers/Layer.h` | 13 | `Layer in 132 files`, name spread 132 | | `test/unit/light/unit_HlsDriver.cpp` | 13 | `lines 30-47, also in test/unit/light/unit_PanelCardDriver.cpp:22`, duplicated block 18 | | `test/unit/light/unit_RandomMapModifier.cpp` | 13 | `lines 116-132, also in test/unit/light/unit_BlurzEffect.cpp:49`, duplicated block 17 | -| `src/core/module/Control.cpp` | 12 | `applyControlValue`, complex function 74 | +| `src/core/system/ControlModule.h` | 12 | `ControlModule::storePreset, line 930`, large stack frame 1296 | +| `src/core/system/DevicesModule.h` | 12 | `lines 73-80, also in src/core/system/DevicesModule.h:59`, duplicated block 8 | +| `src/light/drivers/HlsDriver.h` | 12 | `lines 105-117, also in src/light/drivers/RtspDriver.h:92`, duplicated block 13 | +| `src/light/moonlive/MoonLiveBuiltins_light.h` | 12 | `moonlive::mm_light_escape`, complex function 13 | | `test/unit/light/unit_RubiksCubeEffect.cpp` | 12 | `lines 60-78, also in test/unit/light/unit_RubiksCubeEffect.cpp:28`, duplicated block 19 | -| `src/core/services/OscModule.h` | 11 | `OscModule::sendValue`, complex function 18 | -| `src/core/system/DevicesModule.h` | 11 | `lines 73-80, also in src/core/system/DevicesModule.h:59`, duplicated block 8 | | `src/platform/esp32/platform_esp32_i80.cpp` | 11 | `platform::createState`, complex function 27 | | `test/unit/light/unit_StarFieldEffect.cpp` | 11 | `lines 88-103, also in test/unit/light/unit_StarFieldEffect.cpp:13`, duplicated block 16 | -| `src/light/drivers/HlsDriver.h` | 10 | `lines 105-117, also in src/light/drivers/RtspDriver.h:92`, duplicated block 13 | +| `src/core/services/OscModule.h` | 10 | `OscModule::sendValue`, complex function 18 | +| `src/light/drivers/Drivers.h` | 10 | `Drivers in 63 files`, name spread 63 | | `src/light/effects/GEQ3DEffect.h` | 10 | `lines 140-150, also in src/light/effects/GEQ3DEffect.h:113`, duplicated block 11 | | `src/light/effects/GameOfLifeEffect.h` | 10 | `GameOfLifeEffect::evolveAutomaton`, complex function 67 | | `test/unit/light/unit_BlendMap.cpp` | 10 | `lines 283-289, also in test/unit/light/unit_BlendMap.cpp:212`, duplicated block 7 | | `test/unit/light/unit_Effects_container.cpp` | 10 | `lines 188-199, also in test/unit/light/unit_Effects_container.cpp:139`, duplicated block 12 | | `src/core/services/InfraredService.h` | 9 | `lines 113-135, also in src/core/services/GamepadService.h:108`, duplicated block 23 | -| `src/core/system/MoonStatsModule.h` | 9 | `buildMoonStatsReport`, long parameter list 11 | -| `src/light/drivers/Drivers.h` | 9 | `Drivers::prepare`, complex function 26 | +| `src/core/system/FilesystemModule.cpp` | 9 | `FilesystemModule::reapplyNode, line 177`, large stack frame 2368 | | `src/light/drivers/PanelCardDriver.h` | 9 | `lines 187-195, also in src/light/drivers/NetworkSendDriver.h:212`, duplicated block 9 | -| `src/light/layers/Layer.h` | 9 | `Layer::buildFoldedLUT`, complex function 25 | | `test/unit/light/unit_ParallelLedDriver_swap.cpp` | 9 | `lines 176-184, also in test/unit/light/unit_ParallelLedDriver_swap.cpp:112`, duplicated block 9 | +| `src/core/system/MoonStatsModule.h` | 8 | `MoonStatsModule::sendReport, line 330`, large stack frame 1712 | +| `src/core/system/WiFiModule.h` | 8 | `lines 352-359, also in src/light/drivers/FixtureProfilesModule.h:245`, duplicated block 8 | | `src/light/effects/FixedPointEffect.h` | 8 | `FixedPointEffect::drawBoids`, complex function 19 | | `src/light/effects/FlyingToastersEffect.h` | 8 | `lines 60-69, also in src/light/effects/FlyingToastersEffect.h:40`, duplicated block 10 | | `src/light/effects/SpaceInvadersEffect.h` | 8 | `lines 436-444, also in src/light/effects/SpriteCast.h:59`, duplicated block 9 | @@ -106,9 +118,8 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_MovingHeadEffect.cpp` | 8 | `lines 107-118, also in test/unit/light/unit_MovingHeadEffect.cpp:45`, duplicated block 12 | | `test/unit/light/unit_NoiseMeterEffect.cpp` | 8 | `lines 100-120, also in test/unit/light/unit_NoiseMeterEffect.cpp:49`, duplicated block 21 | | `test/unit/light/unit_draw.cpp` | 8 | `lines 352-358, also in test/unit/light/unit_draw.cpp:334`, duplicated block 7 | -| `src/core/system/ControlModule.h` | 7 | `ControlModule::driveSurface`, complex function 15 | -| `src/core/system/FilesystemModule.cpp` | 7 | `FilesystemModule::applyNode`, complex function 15 | -| `src/core/system/WiFiModule.h` | 7 | `lines 352-359, also in src/light/drivers/FixtureProfilesModule.h:245`, duplicated block 8 | +| `src/core/moonlive/MoonLiveSpill.cpp` | 7 | `moonlive::spillToBudget`, complex function 128 | +| `src/core/system/SystemModule.h` | 7 | `SystemModule in 31 files`, name spread 31 | | `src/core/util/JsonUtil.h` | 7 | `json::parseString`, complex function 34 | | `src/light/effects/FishTankEffect.h` | 7 | `lines 244-258, also in src/light/effects/PacmanEffect.h:243`, duplicated block 15 | | `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 32 | @@ -117,8 +128,11 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/core/unit_NetworkModule.cpp` | 7 | `lines 556-564, also in test/unit/core/unit_NetworkModule.cpp:190`, duplicated block 9 | | `test/unit/light/unit_NdiDriver.cpp` | 7 | `lines 29-46, also in test/unit/light/unit_PanelCardDriver.cpp:22`, duplicated block 18 | | `test/unit/light/unit_PlasmaEffect.cpp` | 7 | `lines 24-39, also in test/unit/light/unit_MetaballsEffect.cpp:22`, duplicated block 16 | -| `src/core/moonlive/MoonLiveSpill.cpp` | 6 | `moonlive::spillToBudget`, complex function 128 | -| `src/core/system/SystemModule.h` | 6 | `platform::getMacAddress in tick1s`, blocking call on the render path 1 | +| `src/core/moonlive/moonlive_lower.h` | 6 | `moonlive::lowerWith`, complex function 82 | +| `src/core/system/FirmwareUpdateModule.h` | 6 | `FirmwareUpdateModule in 13 files`, name spread 13 | +| `src/core/util/InputMapping.h` | 6 | `firePadRow, line 151`, large stack frame 1504 | +| `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 245-252, also in src/core/system/WiFiModule.h:352`, duplicated block 8 | +| `src/light/drivers/NetworkSendDriver.h` | 6 | `NetworkSendDriver::tick, line 192`, large stack frame 1760 | | `src/light/layers/BlendMap.h` | 6 | `blendMap`, complex function 35 | | `src/platform/esp32/platform_esp32_fs.cpp` | 6 | `lines 198-207, also in src/platform/esp32/platform_esp32_fs.cpp:149`, duplicated block 10 | | `src/platform/esp32/platform_esp32_improv.cpp` | 6 | `platform::improvTask`, complex function 22 | @@ -134,8 +148,6 @@ The next function worth simplifying is at the top of each file's list. | `src/core/module/Scheduler.cpp` | 5 | `Scheduler::getControlWide`, complex function 18 | | `src/core/services/AnalogService.h` | 5 | `lines 105-123, also in src/core/services/InfraredService.h:117`, duplicated block 19 | | `src/core/services/AudioBands.h` | 5 | `magnitudesToBands`, complex function 19 | -| `src/core/system/FirmwareUpdateModule.h` | 5 | `FirmwareUpdateModule::readMoonBaseVersion in tick1s`, blocking call on the render path 1 | -| `src/core/util/InputMapping.h` | 5 | `setInputActionField`, complex function 22 | | `src/core/util/JsonSink.h` | 5 | `JsonSink::writeJsonString`, deeply nested 8 | | `src/core/util/noise.h` | 5 | `lines 304-310, also in src/core/util/noise.h:288`, duplicated block 7 | | `src/light/drivers/PinList.h` | 5 | `assignCounts`, complex function 30 | @@ -145,7 +157,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layouts/CarLightsLayout.h` | 5 | `lines 49-65, also in src/light/layouts/TorontoBarGourdsLayout.h:50`, duplicated block 17 | | `src/light/layouts/RingLayout.h` | 5 | `lines 85-100, also in src/light/layouts/CarLightsLayout.h:95`, duplicated block 16 | | `src/light/layouts/TorontoBarGourdsLayout.h` | 5 | `lines 50-66, also in src/light/layouts/CarLightsLayout.h:49`, duplicated block 17 | -| `src/light/util/Palette.h` | 5 | `Palettes::rgbToHueSat`, complex function 12 | | `src/platform/desktop/moonlive_asm_x86_64.cpp` | 5 | `lines 570-577, also in src/platform/desktop/moonlive_asm_x86_64.cpp:528`, duplicated block 8 | | `src/platform/esp32/moonlive_asm_riscv.cpp` | 5 | `lines 40-47, also in src/platform/desktop/moonlive_asm_arm64.cpp:34`, duplicated block 8 | | `src/platform/esp32/platform_esp32_h264.cpp` | 5 | `platform::hlsSegment`, complex function 26 | @@ -153,21 +164,26 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_FireEffect.cpp` | 5 | `lines 29-42, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 14 | | `test/unit/light/unit_FreqSawsEffect.cpp` | 5 | `lines 19-29, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 11 | | `test/unit/light/unit_GEQ3DEffect.cpp` | 5 | `lines 58-75, also in test/unit/light/unit_PaintBrushEffect.cpp:44`, duplicated block 18 | -| `test/unit/light/unit_MoonLiveScripts.cpp` | 5 | `lines 286-294, also in test/unit/light/unit_MoonLiveScripts.cpp:258`, duplicated block 9 | +| `test/unit/light/unit_MoonLiveScripts.cpp` | 5 | `lines 287-295, also in test/unit/light/unit_MoonLiveScripts.cpp:259`, duplicated block 9 | | `test/unit/light/unit_RandomEffect.cpp` | 5 | `lines 10-22, also in test/unit/light/unit_RandomMapModifier.cpp:115`, duplicated block 13 | +| `src/core/moonlive/MoonLive.cpp` | 4 | `moonlive::MoonLive::ensureArena`, complex function 21 | +| `src/core/moonlive/MoonLiveScriptFile.h` | 4 | `moonlive::compileScriptFile`, complex function 19 | | `src/core/services/ButtonService.h` | 4 | `lines 96-118, also in src/core/services/InfraredService.h:113`, duplicated block 23 | +| `src/core/system/EthernetModule.h` | 4 | `EthernetModule::defineControls`, complex function 24 | | `src/core/system/ImprovProvisioningModule.h` | 4 | `NetworkModule::setTxPowerSetting in tick1s`, blocking call on the render path 1 | | `src/core/system/TasksModule.h` | 4 | `TasksModule::TaskListSource::writeListRowDetail`, complex function 11 | -| `src/light/drivers/NetworkSendDriver.h` | 4 | `lines 212-220, also in src/light/drivers/PanelCardDriver.h:187`, duplicated block 9 | | `src/light/drivers/ParallelSlots.h` | 4 | `encodeWs2812ShiftData`, complex function 19 | +| `src/light/drivers/RmtLedDriver.h` | 4 | `line 377: if (!frame_) frame_ = static_cast(platform::alloc(`, raw allocation in light 1 | | `src/light/effects/NebulaEffect.h` | 4 | `NebulaEffect::data`, complex function 27 | | `src/light/effects/NetworkReceiveEffect.h` | 4 | `platform::UdpSocket::recvFrom in tick`, blocking call on the render path 1 | | `src/light/effects/PolarNoiseEffect.h` | 4 | `lines 100-110, also in src/light/effects/TunnelEffect.h:96`, duplicated block 11 | | `src/light/effects/SpriteCast.h` | 4 | `lines 59-67, also in src/light/effects/SpaceInvadersEffect.h:436`, duplicated block 9 | | `src/light/effects/TrailsEffect.h` | 4 | `TrailsEffect::writeWide`, deeply nested 4 | -| `src/light/moonlive/MoonLiveScriptFile.h` | 4 | `moonlive::compileScriptFile`, complex function 19 | +| `src/light/layers/MappingLUT.h` | 4 | `MappingLUT::allocateDestinations`, complex function 12 | | `src/light/powerfunctions/particles.h` | 4 | `particles::Pool::collide`, complex function 16 | +| `src/light/util/Palette.h` | 4 | `lines 364-369, also in src/light/util/Palette.h:353`, duplicated block 6 | | `src/light/util/RtspSession.h` | 4 | `rtsp::parseTransport`, complex function 28 | +| `src/main.cpp` | 4 | `mm_main`, long function 179 | | `src/platform/desktop/main_desktop.cpp` | 4 | `main`, deeply nested 5 | | `test/js/ui-mooncloud.test.mjs` | 4 | `lines 6-17, also in test/js/installer-s31-webflash.test.mjs:11`, duplicated block 12 | | `test/unit/light/unit_BlurzEffect.cpp` | 4 | `lines 137-155, also in test/unit/light/unit_GEQEffect.cpp:156`, duplicated block 19 | @@ -181,21 +197,18 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_PraxisEffect.cpp` | 4 | `lines 9-21, also in test/unit/light/unit_RandomMapModifier.cpp:115`, duplicated block 13 | | `test/unit/light/unit_RmtLedDriver_lifecycle.cpp` | 4 | `lines 19-30, also in test/unit/light/unit_RmtLedDriver_pins.cpp:17`, duplicated block 12 | | `test/unit/light/unit_fluid.cpp` | 4 | `lines 160-169, also in test/unit/light/unit_TrailsEffect.cpp:54`, duplicated block 10 | -| `src/core/moonlive/MoonLive.cpp` | 3 | `moonlive::MoonLive::ensureArena`, complex function 21 | -| `src/core/moonlive/moonlive_lower.h` | 3 | `moonlive::lowerWith`, complex function 82 | +| `src/core/moonlive/MoonLiveBuiltins_common.h` | 3 | `moonlive::mm_ml_addControl`, complex function 12 | | `src/core/services/GamepadService.h` | 3 | `lines 108-130, also in src/core/services/InfraredService.h:113`, duplicated block 23 | | `src/core/system/AccessPointModule.h` | 3 | `platform::UdpSocket::recvFrom in tick20ms`, blocking call on the render path 1 | -| `src/core/system/EthernetModule.h` | 3 | `EthernetModule::defineControls`, complex function 24 | | `src/core/system/PinsModule.h` | 3 | `PinsModule::PinListSource::collect`, deeply nested 5 | | `src/core/util/HostList.h` | 3 | `parseHostList`, complex function 27 | | `src/core/util/HostResolver.h` | 3 | `HostResolver::pass`, deeply nested 4 | | `src/core/util/math16.h` | 3 | `lines 373-378, also in src/core/util/math8.h:94`, duplicated block 6 | | `src/light/drivers/Hub75Driver.h` | 3 | `Hub75Driver::prepare`, complex function 11 | -| `src/light/drivers/RmtLedDriver.h` | 3 | `platform::rmtWs2812Transmit in tick`, blocking call on the render path 1 | | `src/light/effects/AuroraEffect.h` | 3 | `lines 132-140, also in src/light/effects/TunnelEffect.h:92`, duplicated block 9 | | `src/light/effects/PongEffect.h` | 3 | `PongEffect::render`, complex function 15 | | `src/light/effects/SolidEffect.h` | 3 | `SolidEffect::static_cast`, complex function 36 | -| `src/main.cpp` | 3 | `mm_main`, long function 179 | +| `src/light/layouts/GridLayout.h` | 3 | `GridLayout in 84 files`, name spread 84 | | `src/platform/desktop/moonlive_asm_arm64.cpp` | 3 | `lines 34-41, also in src/platform/esp32/moonlive_asm_riscv.cpp:40`, duplicated block 8 | | `test/unit/core/unit_NetworkModule_ethernet.cpp` | 3 | `lines 276-285, also in test/unit/core/unit_NetworkModule_ethernet.cpp:193`, duplicated block 10 | | `test/unit/light/unit_AuroraEffect.cpp` | 3 | `lines 40-48, also in test/unit/light/unit_PolarLut_equivalence.cpp:40`, duplicated block 9 | @@ -214,13 +227,15 @@ The next function worth simplifying is at the top of each file's list. | `moondeck/moontube/reset_device.py` | 2 | `lines 241-246, also in moondeck/moontube/reset_device.py:219`, duplicated block 6 | | `moondeck/scenario/run_scenario.py` | 2 | `lines 33-39, also in moondeck/docs/build_docs.py:43`, duplicated block 7 | | `src/core/moonlive/MoonLiveBuiltins.h` | 2 | `lines 208-219, also in src/core/moonlive/MoonLiveBuiltins.h:111`, duplicated block 12 | -| `src/core/moonlive/MoonLiveBuiltins_common.h` | 2 | `lines 77-82, also in src/core/moonlive/MoonLiveBuiltins_common.h:65`, duplicated block 6 | | `src/core/services/MidiService.h` | 2 | `MidiService::decode`, complex function 18 | -| `src/core/services/MoonLiveService.h` | 2 | `line 6: #include "light/moonlive/MoonLiveScript.h"`, light include in core 1 | | `src/core/system/DevicePlugin.h` | 2 | `lines 94-100, also in src/core/system/DevicePlugin.h:75`, duplicated block 7 | +| `src/core/system/HttpServerModule.h` | 2 | `HttpServerModule in 21 files`, name spread 21 | +| `src/core/system/I2cBusModule.h` | 2 | `I2cBusModule::scan`, complex function 18 | | `src/core/system/MqttPacket.h` | 2 | `buildMqttConnect`, complex function 31 | +| `src/core/system/Sha1.h` | 2 | `sha1, line 21`, large stack frame 944 | | `src/core/util/ModuleFactory.h` | 2 | `ModuleFactory::create`, deeply nested 4 | | `src/core/util/PinList.h` | 2 | `parsePinList`, complex function 15 | +| `src/light/drivers/DriverBase.h` | 2 | `line 164: if (!wire_) wire_ = static_cast(platform::alloc(by`, raw allocation in light 1 | | `src/light/drivers/Hub75Slots.h` | 2 | `hub75Encode`, complex function 20 | | `src/light/drivers/MoonI80Peripheral.h` | 2 | `MoonI80Peripheral::busInitRing`, complex function 12 | | `src/light/effects/BeatRipplesEffect.h` | 2 | `BeatRipplesEffect::renderSurface`, complex function 12 | @@ -228,19 +243,21 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/FreqSawsEffect.h` | 2 | `FreqSawsEffect::for`, deeply nested 5 | | `src/light/effects/LinesEffect.h` | 2 | `LinesEffect::if`, deeply nested 5 | | `src/light/effects/MetaballsEffect.h` | 2 | `lines 73-84, also in src/light/effects/SpiralEffect.h:79`, duplicated block 12 | +| `src/light/effects/NoiseEffect.h` | 2 | `NoiseEffect in 25 files`, name spread 25 | | `src/light/effects/PlasmaEffect.h` | 2 | `lines 82-92, also in src/light/effects/SpiralEffect.h:78`, duplicated block 11 | | `src/light/effects/PulseEffect.h` | 2 | `PulseEffect::for`, deeply nested 5 | +| `src/light/effects/RainbowEffect.h` | 2 | `RainbowEffect in 18 files`, name spread 18 | | `src/light/effects/RubiksCubeEffect.h` | 2 | `RubiksCubeEffect::init in tick`, blocking call on the render path 1 | | `src/light/effects/StarFieldEffect.h` | 2 | `StarFieldEffect::for`, deeply nested 4 | | `src/light/effects/TetrixEffect.h` | 2 | `TetrixEffect::static_cast`, complex function 28 | | `src/light/layouts/GridBlacksLayout.h` | 2 | `GridBlacksLayout::placeLights`, deeply nested 4 | -| `src/light/layouts/GridLayout.h` | 2 | `lines 43-53, also in src/light/layouts/TubesLayout.h:45`, duplicated block 11 | +| `src/light/moonlive/MoonLiveEffect.h` | 2 | `MoonLiveEffect in 15 files`, name spread 15 | | `src/light/powerfunctions/polar.h` | 2 | `PolarLut::prepare`, complex function 41 | | `src/light/powerfunctions/shader.h` | 2 | `shader::cosPalette`, long parameter list 13 | | `src/light/util/MpegTs.h` | 2 | `ts::Writer::writeAccessUnit`, complex function 23 | | `src/light/util/RtpH264.h` | 2 | `rtp::Packetiser::writeNal`, long parameter list 8 | | `src/platform/esp32/moonlive_asm_riscv.h` | 2 | `lines 74-91, also in src/platform/esp32/moonlive_asm_xtensa.h:79`, duplicated block 18 | -| `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 470-476, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | +| `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 481-487, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | | `test/js/ui-midi-bridge.test.mjs` | 2 | `lines 4-16, also in test/js/ui-gamepad-bridge.test.mjs:4`, duplicated block 13 | | `test/unit/core/unit_AudioService_sync.cpp` | 2 | `lines 259-266, also in test/unit/core/unit_AudioService_sync.cpp:173`, duplicated block 8 | @@ -265,7 +282,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_SineEffect.cpp` | 2 | `lines 22-30, also in test/unit/light/unit_DistortionWavesEffect.cpp:26`, duplicated block 9 | | `test/unit/light/unit_SingleColumnLayout.cpp` | 2 | `lines 12-25, also in test/unit/light/unit_GridLayout.cpp:8`, duplicated block 14 | | `moondeck/check/check_clang_query.py` | 1 | `check_clang_query.py`, large file 1057 | -| `moondeck/check/check_docgen.py` | 1 | `check_docgen.py`, large file 1798 | +| `moondeck/check/check_docgen.py` | 1 | `check_docgen.py`, large file 1853 | | `moondeck/check/check_footprint.py` | 1 | `lines 31-41, also in moondeck/check/check_nonblocking.py:30`, duplicated block 11 | | `moondeck/check/check_nonblocking.py` | 1 | `lines 30-40, also in moondeck/check/check_footprint.py:31`, duplicated block 11 | | `moondeck/ci/verify_version.py` | 1 | `lines 39-45, also in moondeck/scenario/run_scenario.py:33`, duplicated block 7 | @@ -279,21 +296,23 @@ The next function worth simplifying is at the top of each file's list. | `mooninstaller/install-orchestrator.js` | 1 | `install-orchestrator.js`, large file 1284 | | `mooninstaller/install.js` | 1 | `install.js`, large file 1449 | | `src/core/module/Control.h` | 1 | `sanitizeHostname`, complex function 16 | +| `src/core/module/StateDocument.cpp` | 1 | `(anonymous namespace)::Applier::apply(mm::MoonModule*, mm::json::JsonNode const*), line 371`, large stack frame 1264 | | `src/core/moonlive/MoonLive.h` | 1 | `moonlive::MoonLive::addDeclaredControl`, complex function 14 | -| `src/core/moonlive/MoonLiveBuiltins_service.h` | 1 | `line 8: #include "light/moonlive/MoonLiveBuiltins_light.h"`, light include in core 1 | | `src/core/moonlive/MoonLiveIr.h` | 1 | `moonlive::IrProgram::push`, complex function 11 | +| `src/core/moonlive/MoonLiveScript.h` | 1 | `moonlive::MoonLiveScript::sync`, complex function 18 | | `src/core/services/AudioLevel.h` | 1 | `computeLevel`, complex function 14 | +| `src/core/services/MoonLiveService.h` | 1 | `moonlive::MoonLive::runValue in tick20ms`, blocking call on the render path 1 | | `src/core/services/OscPacket.h` | 1 | `osc::parse`, complex function 32 | +| `src/core/services/Services.h` | 1 | `Services in 11 files`, name spread 11 | | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | -| `src/core/system/HttpServerModule.h` | 1 | `HttpServerModule::cancelBufferedSend`, deeply nested 4 | -| `src/core/system/I2cBusModule.h` | 1 | `I2cBusModule::scan`, complex function 18 | -| `src/core/system/Sha1.h` | 1 | `sha1`, deeply nested 5 | +| `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 25 files`, name spread 25 | +| `src/core/system/MoonTalkModule.h` | 1 | `MoonTalkModule::send, line 103`, large stack frame 1440 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | | `src/core/util/ImprovFrame.h` | 1 | `ImprovFrameParser::feed`, complex function 22 | +| `src/core/util/color.h` | 1 | `rgbToHueSat`, complex function 12 | | `src/core/util/math8.h` | 1 | `lines 94-99, also in src/core/util/math16.h:373`, duplicated block 6 | | `src/light/drivers/Correction.h` | 1 | `Correction::apply`, complex function 30 | -| `src/light/drivers/FixtureProfilesModule.h` | 1 | `lines 245-252, also in src/core/system/WiFiModule.h:352`, duplicated block 8 | | `src/light/drivers/I80Peripheral.h` | 1 | `I80Peripheral::validateBusFatal`, complex function 14 | | `src/light/drivers/NdiDriver.h` | 1 | `platform::ndiSendFrame in tick`, blocking call on the render path 1 | | `src/light/effects/BallpitEffect.h` | 1 | `lines 80-85, also in src/light/effects/FireworksEffect.h:83`, duplicated block 6 | @@ -304,23 +323,22 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/GEQEffect.h` | 1 | `GEQEffect::for`, complex function 19 | | `src/light/effects/LavaLampEffect.h` | 1 | `lines 68-78, also in src/light/effects/SpiralEffect.h:79`, duplicated block 11 | | `src/light/effects/MovingHeadEffect.h` | 1 | `MovingHeadEffect::shape`, complex function 11 | -| `src/light/effects/NoiseEffect.h` | 1 | `lines 83-90, also in src/light/effects/RainbowEffect.h:47`, duplicated block 8 | | `src/light/effects/RadialSpectrumEffect.h` | 1 | `RadialSpectrumEffect::for`, deeply nested 4 | -| `src/light/effects/RainbowEffect.h` | 1 | `lines 47-54, also in src/light/effects/NoiseEffect.h:83`, duplicated block 8 | | `src/light/effects/SineEffect.h` | 1 | `SineEffect::for`, deeply nested 4 | | `src/light/effects/SphereMoveEffect.h` | 1 | `SphereMoveEffect::for`, deeply nested 4 | | `src/light/effects/WaterRippleEffect.h` | 1 | `WaterRippleEffect::if`, deeply nested 4 | -| `src/light/layers/MappingLUT.h` | 1 | `MappingLUT::allocateDestinations`, complex function 12 | +| `src/light/layers/Buffer.h` | 1 | `line 65: data_ = static_cast(platform::alloc(totalBytes));`, raw allocation in light 1 | +| `src/light/layers/Effects.h` | 1 | `Effects in 81 files`, name spread 81 | | `src/light/layouts/CubeLayout.h` | 1 | `lines 81-88, also in src/light/layouts/GridLayout.h:43`, duplicated block 8 | | `src/light/layouts/HumanSizedCubeLayout.h` | 1 | `HumanSizedCubeLayout::placeLights`, complex function 21 | +| `src/light/layouts/Layouts.h` | 1 | `Layouts in 109 files`, name spread 109 | | `src/light/layouts/PanelLayout.h` | 1 | `lines 54-69, also in src/light/layouts/PanelsLayout.h:64`, duplicated block 16 | | `src/light/layouts/PanelsLayout.h` | 1 | `lines 64-79, also in src/light/layouts/PanelLayout.h:54`, duplicated block 16 | | `src/light/layouts/SphereLayout.h` | 1 | `SphereLayout::forEachShellPoint`, deeply nested 4 | | `src/light/layouts/TubesLayout.h` | 1 | `lines 45-55, also in src/light/layouts/GridLayout.h:43`, duplicated block 11 | -| `src/light/moonlive/MoonLiveEffect.h` | 1 | `moonlive::MoonLive::run in tick`, blocking call on the render path 1 | +| `src/light/modifiers/RandomMapModifier.h` | 1 | `line 112: platform::alloc(static_cast(boxCount) * sizeof(nrOfL`, raw allocation in light 1 | | `src/light/moonlive/MoonLiveModifier.h` | 1 | `MoonLiveModifier::modifyLogical`, complex function 12 | | `src/light/moonlive/MoonLivePalette.h` | 1 | `moonlive::MoonLive::runValue in tick`, blocking call on the render path 1 | -| `src/light/moonlive/MoonLiveScript.h` | 1 | `moonlive::MoonLiveScript::sync`, complex function 18 | | `src/light/powerfunctions/fluid.h` | 1 | `Fluid::resize`, complex function 14 | | `src/light/util/E131Packet.h` | 1 | `parseE131Packet`, complex function 17 | | `src/module_types.cpp` | 1 | `registerModuleTypes`, long function 148 | @@ -331,7 +349,6 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | | `src/platform/platform.h` | 1 | `platform.h`, large file 1192 | -| `src/ui/app.js` | 1 | `app.js`, large file 8976 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | | `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 | | `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 | @@ -343,12 +360,12 @@ The next function worth simplifying is at the top of each file's list. | `test/js/ui-render-guard.test.mjs` | 1 | `lines 13-25, also in test/js/ui-ws-scheme.test.mjs:10`, duplicated block 13 | | `test/js/ui-script-picker-download.test.mjs` | 1 | `lines 12-22, also in test/js/ui-ws-scheme.test.mjs:10`, duplicated block 11 | | `test/js/ui-visibility.test.mjs` | 1 | `lines 7-16, also in test/js/ui-ws-scheme.test.mjs:13`, duplicated block 10 | -| `test/python/test_check_docgen.py` | 1 | `test_check_docgen.py`, large file 1551 | +| `test/python/test_check_docgen.py` | 1 | `test_check_docgen.py`, large file 1577 | | `test/scenario_runner.cpp` | 1 | `scenario_runner.cpp`, large file 1266 | | `test/unit/core/unit_ControlModule.cpp` | 1 | `unit_ControlModule.cpp`, large file 1574 | -| `test/unit/core/unit_Control_apply_absent_key.cpp` | 1 | `lines 92-103, also in test/unit/core/unit_HttpServerModule_apply.cpp:38`, duplicated block 12 | +| `test/unit/core/unit_Control_apply_absent_key.cpp` | 1 | `lines 92-103, also in test/unit/core/unit_HttpServerModule_apply.cpp:39`, duplicated block 12 | | `test/unit/core/unit_moonlive_compiler.cpp` | 1 | `unit_moonlive_compiler.cpp`, large file 1865 | -| `test/unit/core/unit_moonlive_fill.cpp` | 1 | `unit_moonlive_fill.cpp`, large file 1348 | +| `test/unit/core/unit_moonlive_fill.cpp` | 1 | `unit_moonlive_fill.cpp`, large file 1380 | | `test/unit/light/unit_BreakoutEffect.cpp` | 1 | `lines 18-27, also in test/unit/light/unit_TetrixEffect.cpp:20`, duplicated block 10 | | `test/unit/light/unit_Canvas.cpp` | 1 | `lines 21-26, also in test/unit/light/unit_Splat.cpp:15`, duplicated block 6 | | `test/unit/light/unit_ColorTrailsEffect.cpp` | 1 | `lines 43-49, also in test/unit/light/unit_fluid.cpp:159`, duplicated block 7 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index 96f43e408..47ebcb8ab 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,11 +4,11 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **2453 warning(s)** across 176 page(s). +**0 error(s)** and **2871 warning(s)** across 356 page(s). -**5633 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. +**5627 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. -An error is in a file that generates a documentation page, a header or a catalog page, so the finding is a defect in what gets published and it fails the gate. A warning is in an implementation file, which publishes nothing: its comments are a note to the next reader, worth fixing without being worth stopping a commit for. Both are counted here, because a warning nobody sees is a warning nobody fixes. +An error is in a file that generates a documentation page, a header or a catalog page, so the finding is a defect in what gets published and it fails the gate. A warning is worth fixing without being worth stopping a commit for: any finding in an implementation file, which publishes nothing, and the rules still staged as counts in a header too, the over-wide line and the appendix section nothing refers to. Both are counted here, because a warning nobody sees is a warning nobody fixes. The split is temporary. It stages the sweep rather than ranking the two kinds of comment, so when the warning column reaches zero it goes and every finding blocks. @@ -16,10 +16,11 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| over-wide comment lines | 0 | 1756 | +| over-wide comment lines | 0 | 1750 | | multi-line comment blocks | 0 | 447 | -| over-long sentences | 0 | 90 | -| over-wide doc lines | 0 | 85 | +| appendix sections nothing refers to | 0 | 427 | +| over-long sentences | 0 | 89 | +| over-wide doc lines | 0 | 83 | | member deep dives | 0 | 75 | ## By documentation area @@ -28,9 +29,18 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| -| `(tests, no card)` | 0 | 1868 | -| `platform/index.md` | 0 | 332 | -| `core/system.md` | 0 | 253 | +| `(tests, no card)` | 0 | 1870 | +| `platform/index.md` | 0 | 349 | +| `core/system.md` | 0 | 321 | +| `light/effects.md` | 0 | 81 | +| `light/drivers.md` | 0 | 56 | +| `core/supporting.md` | 0 | 50 | +| `light/power-functions.md` | 0 | 44 | +| `light/supporting.md` | 0 | 31 | +| `core/services.md` | 0 | 27 | +| `light/moonlive.md` | 0 | 22 | +| `light/modifiers.md` | 0 | 11 | +| `light/layouts.md` | 0 | 9 | ## Where the work is @@ -38,7 +48,7 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul ### (tests, no card) -**0 error(s)** and **1868 warning(s)** across 154 file(s). +**0 error(s)** and **1870 warning(s)** across 155 file(s). | Findings | File | | |---:|---|---| @@ -52,13 +62,13 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul | 44 | `test/unit/core/unit_moonlive_codegen_xtensa.cpp` | warning | | 42 | `test/unit/light/unit_ParallelSlots.cpp` | warning | | 38 | `test/unit/light/unit_Drivers_rendersplit.cpp` | warning | -| 1-36 each | *144 more warning files, 1205 findings* | | +| 1-36 each | *145 more warning files, 1207 findings* | | -By rule, warnings: 1418 over-wide comment lines, 205 multi-line comment blocks, 89 over-long sentences, 84 over-wide doc lines, 72 member deep dives. +By rule, warnings: 1414 over-wide comment lines, 205 multi-line comment blocks, 88 over-long sentences, 82 over-wide doc lines, 72 member deep dives, 9 appendix sections nothing refers to. ### platform/index.md -**0 error(s)** and **332 warning(s)** across 18 file(s). +**0 error(s)** and **349 warning(s)** across 24 file(s). | Findings | File | | |---:|---|---| @@ -72,20 +82,194 @@ By rule, warnings: 1418 over-wide comment lines, 205 multi-line comment blocks, | 19 | `src/platform/esp32/platform_esp32_parlio.cpp` | warning | | 10 | `src/platform/esp32/platform_esp32_i2s.cpp` | warning | | 10 | `src/platform/esp32/platform_esp32_improv.cpp` | warning | -| 1-9 each | *8 more warning files, 30 findings* | | +| 1-9 each | *14 more warning files, 47 findings* | | -By rule, warnings: 218 over-wide comment lines, 114 multi-line comment blocks. +By rule, warnings: 218 over-wide comment lines, 114 multi-line comment blocks, 17 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **253 warning(s)** across 4 file(s). +**0 error(s)** and **321 warning(s)** across 22 file(s). | Findings | File | | |---:|---|---| -| 188 | `src/core/system/HttpServerModule.cpp` | warning | +| 186 | `src/core/system/HttpServerModule.cpp` | warning | | 37 | `src/core/system/FilesystemModule.cpp` | warning | +| 25 | `src/core/system/HttpServerModule.h` | warning | | 19 | `src/core/module/Scheduler.cpp` | warning | | 9 | `src/core/system/FileManagerModule.cpp` | warning | +| 4 | `src/core/system/MqttPacket.h` | warning | +| 4 | `src/core/system/WledPacket.h` | warning | +| 3 | `src/core/module/MoonModule.h` | warning | +| 3 | `src/core/module/Scheduler.h` | warning | +| 3 | `src/core/system/DevicePlugin.h` | warning | +| 1-3 each | *12 more warning files, 28 findings* | | -By rule, warnings: 128 multi-line comment blocks, 120 over-wide comment lines, 3 member deep dives, 1 over-long sentences, 1 over-wide doc lines. +By rule, warnings: 128 multi-line comment blocks, 118 over-wide comment lines, 70 appendix sections nothing refers to, 3 member deep dives, 1 over-long sentences, 1 over-wide doc lines. + +### light/effects.md + +**0 error(s)** and **81 warning(s)** across 56 file(s). + +| Findings | File | | +|---:|---|---| +| 4 | `src/light/effects/EffectBase.h` | warning | +| 3 | `src/light/effects/ColorTrailsEffect.h` | warning | +| 3 | `src/light/effects/PulseEffect.h` | warning | +| 2 | `src/light/effects/AuroraEffect.h` | warning | +| 2 | `src/light/effects/BallpitEffect.h` | warning | +| 2 | `src/light/effects/DemoReelEffect.h` | warning | +| 2 | `src/light/effects/FireworksEffect.h` | warning | +| 2 | `src/light/effects/FluidEffect.h` | warning | +| 2 | `src/light/effects/FreqMatrixEffect.h` | warning | +| 2 | `src/light/effects/FreqSawsEffect.h` | warning | +| 1-2 each | *46 more warning files, 57 findings* | | + +By rule, warnings: 81 appendix sections nothing refers to. + +### light/drivers.md + +**0 error(s)** and **56 warning(s)** across 19 file(s). + +| Findings | File | | +|---:|---|---| +| 9 | `src/light/drivers/ColorLight5A75Packet.h` | warning | +| 8 | `src/light/drivers/ParallelSlots.h` | warning | +| 5 | `src/light/drivers/Hub75Slots.h` | warning | +| 3 | `src/light/drivers/Drivers.h` | warning | +| 3 | `src/light/drivers/FixtureProfilesModule.h` | warning | +| 3 | `src/light/drivers/HlsDriver.h` | warning | +| 3 | `src/light/drivers/PinList.h` | warning | +| 3 | `src/light/drivers/RtspDriver.h` | warning | +| 2 | `src/light/drivers/HueDriver.h` | warning | +| 2 | `src/light/drivers/MoonI80Peripheral.h` | warning | +| 1-2 each | *9 more warning files, 15 findings* | | + +By rule, warnings: 56 appendix sections nothing refers to. + +### core/supporting.md + +**0 error(s)** and **50 warning(s)** across 23 file(s). + +| Findings | File | | +|---:|---|---| +| 6 | `src/core/util/BinaryBroadcaster.h` | warning | +| 4 | `src/core/util/AudioFrame.h` | warning | +| 4 | `src/core/util/ControlSurface.h` | warning | +| 3 | `src/core/util/LightOutput.h` | warning | +| 3 | `src/core/util/PinList.h` | warning | +| 3 | `src/core/util/build_info.h` | warning | +| 3 | `src/core/util/sha256.h` | warning | +| 2 | `src/core/util/FirmwareImage.h` | warning | +| 2 | `src/core/util/HostList.h` | warning | +| 2 | `src/core/util/ImprovFrame.h` | warning | +| 1-2 each | *13 more warning files, 18 findings* | | + +By rule, warnings: 50 appendix sections nothing refers to. + +### light/power-functions.md + +**0 error(s)** and **44 warning(s)** across 10 file(s). + +| Findings | File | | +|---:|---|---| +| 11 | `src/core/util/math16.h` | warning | +| 11 | `src/core/util/noise.h` | warning | +| 4 | `src/core/util/oscillators.h` | warning | +| 4 | `src/light/powerfunctions/draw.h` | warning | +| 4 | `src/light/powerfunctions/polar.h` | warning | +| 3 | `src/light/powerfunctions/raymarch.h` | warning | +| 3 | `src/light/powerfunctions/shader.h` | warning | +| 2 | `src/light/powerfunctions/fonts.h` | warning | +| 1 | `src/core/util/math8.h` | warning | +| 1 | `src/light/powerfunctions/particles.h` | warning | + +By rule, warnings: 44 appendix sections nothing refers to. + +### light/supporting.md + +**0 error(s)** and **31 warning(s)** across 13 file(s). + +| Findings | File | | +|---:|---|---| +| 4 | `src/light/layers/BlendMap.h` | warning | +| 4 | `src/light/layers/Layer.h` | warning | +| 3 | `src/light/layers/MappingLUT.h` | warning | +| 3 | `src/light/util/DdpPacket.h` | warning | +| 3 | `src/light/util/FixtureChannels.h` | warning | +| 2 | `src/light/layers/Buffer.h` | warning | +| 2 | `src/light/layers/Effects.h` | warning | +| 2 | `src/light/util/ArtNetPacket.h` | warning | +| 2 | `src/light/util/Palette.h` | warning | +| 2 | `src/light/util/RtpH264.h` | warning | +| 1-2 each | *3 more warning files, 4 findings* | | + +By rule, warnings: 31 appendix sections nothing refers to. + +### core/services.md + +**0 error(s)** and **27 warning(s)** across 8 file(s). + +| Findings | File | | +|---:|---|---| +| 8 | `src/core/services/AudioBands.h` | warning | +| 5 | `src/core/services/AudioLevel.h` | warning | +| 3 | `src/core/services/AnalogService.h` | warning | +| 3 | `src/core/services/ButtonService.h` | warning | +| 3 | `src/core/services/OscPacket.h` | warning | +| 2 | `src/core/services/InfraredService.h` | warning | +| 2 | `src/core/services/MoonLiveService.h` | warning | +| 1 | `src/core/services/AudioService.h` | warning | + +By rule, warnings: 27 appendix sections nothing refers to. + +### light/moonlive.md + +**0 error(s)** and **22 warning(s)** across 13 file(s). + +| Findings | File | | +|---:|---|---| +| 3 | `src/core/moonlive/moonlive_lower.h` | warning | +| 3 | `src/light/moonlive/MoonLiveLayout.h` | warning | +| 3 | `src/light/moonlive/MoonLivePalette.h` | warning | +| 2 | `src/core/moonlive/MoonLiveBuiltins_common.h` | warning | +| 2 | `src/core/moonlive/MoonLiveIr.h` | warning | +| 2 | `src/core/moonlive/MoonLiveScript.h` | warning | +| 1 | `src/core/moonlive/MoonLiveBuiltins.h` | warning | +| 1 | `src/core/moonlive/MoonLiveBuiltins_service.h` | warning | +| 1 | `src/core/moonlive/MoonLiveScriptFile.h` | warning | +| 1 | `src/core/moonlive/MoonLiveSpill.h` | warning | +| 1 each | *3 more warning files, 3 findings* | | + +By rule, warnings: 22 appendix sections nothing refers to. + +### light/modifiers.md + +**0 error(s)** and **11 warning(s)** across 6 file(s). + +| Findings | File | | +|---:|---|---| +| 4 | `src/light/modifiers/ModifierBase.h` | warning | +| 2 | `src/light/modifiers/RegionModifier.h` | warning | +| 2 | `src/light/modifiers/RotateModifier.h` | warning | +| 1 | `src/light/modifiers/MirrorModifier.h` | warning | +| 1 | `src/light/modifiers/PinwheelModifier.h` | warning | +| 1 | `src/light/modifiers/RandomMapModifier.h` | warning | + +By rule, warnings: 11 appendix sections nothing refers to. + +### light/layouts.md + +**0 error(s)** and **9 warning(s)** across 7 file(s). + +| Findings | File | | +|---:|---|---| +| 3 | `src/light/layouts/Layouts.h` | warning | +| 1 | `src/light/layouts/CarLightsLayout.h` | warning | +| 1 | `src/light/layouts/CubeLayout.h` | warning | +| 1 | `src/light/layouts/PanelLayout.h` | warning | +| 1 | `src/light/layouts/PanelsLayout.h` | warning | +| 1 | `src/light/layouts/Rings241Layout.h` | warning | +| 1 | `src/light/layouts/TorontoBarGourdsLayout.h` | warning | + +By rule, warnings: 9 appendix sections nothing refers to. diff --git a/docs/reference/metrics/prose.md b/docs/reference/metrics/prose.md index b8c01c9a6..339951ee7 100644 --- a/docs/reference/metrics/prose.md +++ b/docs/reference/metrics/prose.md @@ -9,14 +9,14 @@ This number is the debt behind that gate, and it RATCHETS: a rise fails the chec docgen.md does. A comment is prose wherever it lives, so a `//` in the web interface and a `#` in a script are counted beside a `///` in a header. -**3185 finding(s)** across 377 file(s). +**3174 finding(s)** across 376 file(s). | rule | findings | |---|---| -| `MoonLight.EmDash` | 1636 | +| `MoonLight.EmDash` | 1626 | | `MoonLight.Weasel` | 738 | | `MoonLight.SentenceLength` | 474 | -| `MoonLight.Temporal` | 248 | +| `MoonLight.Temporal` | 247 | | `MoonLight.Spelling` | 43 | | `MoonLight.SelfReference` | 33 | | `MoonLight.NegatedHeading` | 13 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 874cfcfb4..9f15a373c 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,35 +1,35 @@ { - "commit": "35a2f0fb", + "commit": "9df5b93b", "flash": { - "esp32s3-n16r8": 2243984, - "esp32": 2192048, - "esp32p4rev1-eth": 2173984, + "esp32s3-n16r8": 2245600, + "esp32": 2194704, + "esp32p4rev1-eth": 2177776, "esp32p4rev1-eth-wifi": 2358896, - "esp32s3-n8r8": 2087168, - "esp32s31": 2545392, + "esp32s3-n8r8": 2245584, + "esp32s31": 2549760, "esp32-16mb": 2060368, "esp32-eth": 1681040, "esp32-wrover": 1843760, "qemu": 1383648, "esp32p4rev3-eth": 2073888, - "esp32s3-zero": 2024192, + "esp32s3-zero": 2181296, "esp32-pico": 2107168, - "desktop-macos": 2151832, + "desktop-macos": 2193864, "desktop-windows": 1925120 }, "measured": { - "esp32p4rev1-eth": "2026-10-06", - "esp32s31": "2026-10-06", - "esp32": "2026-10-06", + "esp32p4rev1-eth": "2026-10-07", + "esp32s31": "2026-10-07", + "esp32": "2026-10-07", "esp32-pico": "2026-09-09", - "esp32s3-n16r8": "2026-10-06", - "esp32s3-n8r8": "2026-09-08", - "esp32s3-zero": "2026-09-08", + "esp32s3-n16r8": "2026-10-07", + "esp32s3-n8r8": "2026-10-07", + "esp32s3-zero": "2026-10-07", "esp32-16mb": "2026-09-09", "esp32p4rev1-eth-wifi": "2026-09-30", "esp32-eth": "2026-09-11", "esp32p4rev3-eth": "2026-09-29", - "desktop-macos": "2026-10-06", + "desktop-macos": "2026-10-07", "desktop-windows": "2026-09-30" }, "perf": { @@ -42,8 +42,8 @@ "desktop-macos": { "p50": 5, "p95": 462, - "n": 16, - "last": "2026-10-06" + "n": 18, + "last": "2026-10-07" }, "esp32s3-n16r8": { "p50": 2073, @@ -108,8 +108,8 @@ "desktop-macos": { "p50": 1, "p95": 555, - "n": 17, - "last": "2026-10-06" + "n": 18, + "last": "2026-10-07" }, "esp32s3-n16r8": { "p50": 2505, @@ -485,7 +485,7 @@ "p50": 7, "p95": 15, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" }, "desktop-windows": { "p50": 4, @@ -511,7 +511,7 @@ "p50": 6, "p95": 11, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" }, "desktop-windows": { "p50": 4, @@ -525,7 +525,7 @@ "p50": 1, "p95": 2, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" }, "desktop-windows": { "p50": 4, @@ -551,7 +551,7 @@ "p50": 7, "p95": 18, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" }, "esp32s3-n16r8": { "p50": 9080, @@ -577,7 +577,7 @@ "p50": 267, "p95": 368, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" } }, "Layouts_resize_reallocates_live": { @@ -585,7 +585,7 @@ "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" }, "desktop-windows": { "p50": 93, @@ -611,7 +611,7 @@ "p50": 7, "p95": 11, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" }, "esp32s3-n16r8": { "p50": 10350, @@ -636,8 +636,8 @@ "desktop-macos": { "p50": 7, "p95": 122, - "n": 27, - "last": "2026-10-06" + "n": 29, + "last": "2026-10-07" } } }, @@ -649,13 +649,13 @@ "p50": 6, "p95": 11, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" }, "Layouts_resize_reallocates_live": { "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-06" + "last": "2026-10-07" } } }, @@ -679,24 +679,24 @@ } }, "loc": { - "core": 25734, - "light": 31845, - "platform": 17198, + "core": 27121, + "light": 30733, + "platform": 17209, "ui": 12227, - "test": 62726, - "moondeck": 30424 + "test": 63075, + "moondeck": 30598 }, "comments": { "core": { - "lines": 6191, - "ratio": 0.266 + "lines": 6491, + "ratio": 0.265 }, "light": { - "lines": 7649, - "ratio": 0.269 + "lines": 7410, + "ratio": 0.271 }, "platform": { - "lines": 3613, + "lines": 3614, "ratio": 0.234 }, "ui": { @@ -704,29 +704,29 @@ "ratio": 0.3 }, "test": { - "lines": 6923, + "lines": 6947, "ratio": 0.128 }, "moondeck": { - "lines": 4959, - "ratio": 0.186 + "lines": 4969, + "ratio": 0.185 } }, "tests": { - "cases": 2319, + "cases": 2326, "scenarios": 30 }, "docs": { "md_files": 142, - "md_lines": 30366, - "plans_files": 42, + "md_lines": 30771, + "plans_files": 41, "backlog_lines": 3247, "lessons_lines": 532, "claude_md_lines": 283 }, "complexity": { - "functions": 4143, - "over_threshold": 335, + "functions": 4162, + "over_threshold": 334, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 1c149ebac..e7381e81f 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `35a2f0fb`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `9df5b93b`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,20 +8,22 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | Metric | v6.0.0 | Now | Change | |---|---:|---:|---:| -| flash desktop-macos | 1,963 KB | 2,101 KB | +138.4 KB (+7.0%) | -| flash esp32 | 2,061 KB | 2,141 KB | +79.5 KB (+3.9%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,123 KB | +101.3 KB (+5.0%) | -| flash esp32s3-n16r8 | 2,111 KB | 2,191 KB | +79.9 KB (+3.8%) | -| flash esp32s31 | 2,395 KB | 2,486 KB | +90.3 KB (+3.8%) | +| flash desktop-macos | 1,963 KB | 2,142 KB | +179.4 KB (+9.1%) | +| flash esp32 | 2,061 KB | 2,143 KB | +82.1 KB (+4.0%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,127 KB | +105.0 KB (+5.2%) | +| flash esp32s3-n16r8 | 2,111 KB | 2,193 KB | +81.5 KB (+3.9%) | +| flash esp32s3-n8r8 | 2,038 KB | 2,193 KB | +154.7 KB (+7.6%) | +| flash esp32s3-zero | 1,977 KB | 2,130 KB | +153.4 KB (+7.8%) | +| flash esp32s31 | 2,395 KB | 2,490 KB | +94.6 KB (+3.9%) | | tick Effects_pipeline_builds_and_renders (desktop-macos) | 7 µs | 6 µs | -1 (-14.3%) | | tick Layouts_resize_reallocates_live (desktop-macos) | 12 µs | 10 µs | -2 (-16.7%) | -| lines core | 22,299 | 25,734 | +3,435 (+15.4%) | -| lines light | 31,054 | 31,845 | +791 (+2.5%) | -| lines platform | 16,798 | 17,198 | +400 (+2.4%) | +| lines core | 22,299 | 27,121 | +4,822 (+21.6%) | +| lines light | 31,054 | 30,733 | -321 (-1.0%) | +| lines platform | 16,798 | 17,209 | +411 (+2.4%) | | lines ui | 11,394 | 12,227 | +833 (+7.3%) | -| lines test | 57,759 | 62,726 | +4,967 (+8.6%) | -| lines moondeck | 29,325 | 30,424 | +1,099 (+3.7%) | -| unit tests | 2,120 | 2,319 | +199 (+9.4%) | +| lines test | 57,759 | 63,075 | +5,316 (+9.2%) | +| lines moondeck | 29,325 | 30,598 | +1,273 (+4.3%) | +| unit tests | 2,120 | 2,326 | +206 (+9.7%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -30,20 +32,20 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-macos | 2,101 KB (−16 KB) ✓ | - | - | yes | -| desktop-windows | 1,880 KB | - | - | carried 6d | -| esp32 | 2,141 KB (+208 B) ⚠ | 2,496 KB | 86% | yes | -| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 27d** | -| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 25d** | -| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 27d** | +| desktop-macos | 2,142 KB | - | - | yes | +| desktop-windows | 1,880 KB | - | - | **STALE 7d** | +| esp32 | 2,143 KB | 2,496 KB | 86% | carried 0d | +| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 28d** | +| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 26d** | +| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 28d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | -| esp32p4rev1-eth | 2,123 KB (+640 B) ⚠ | 4,096 KB | 52% | yes | -| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | carried 6d | -| esp32p4rev3-eth | 2,025 KB | 4,096 KB | 49% | **STALE 7d** | -| esp32s3-n16r8 | 2,191 KB (+416 B) ⚠ | 4,096 KB | 54% | yes | -| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | **STALE 28d** | -| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | **STALE 28d** | -| esp32s31 | 2,486 KB (+512 B) ⚠ | 4,096 KB | 61% | yes | +| esp32p4rev1-eth | 2,127 KB | 4,096 KB | 52% | carried 0d | +| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 7d** | +| esp32p4rev3-eth | 2,025 KB | 4,096 KB | 49% | **STALE 8d** | +| esp32s3-n16r8 | 2,193 KB (+272 B) ⚠ | 4,096 KB | 54% | yes | +| esp32s3-n8r8 | 2,193 KB | 3,072 KB | 71% | yes | +| esp32s3-zero | 2,130 KB | 2,496 KB | 85% | yes | +| esp32s31 | 2,490 KB | 4,096 KB | 61% | carried 0d | | qemu | 1,351 KB | - | - | carried (age?) | `Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table. @@ -52,17 +54,15 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32 | +0.2 KB | +0.0 KB | +0.0 KB | +0.3 KB | -0.1 KB | -0.0 KB | `35a2f0fb` | -| esp32p4rev1-eth | +0.6 KB | +0.0 KB | +0.0 KB | +0.9 KB | -0.4 KB | +0.0 KB | `35a2f0fb` | -| esp32s3-n16r8 | +0.4 KB | +0.0 KB | +0.0 KB | +0.5 KB | -0.1 KB | +0.0 KB | `35a2f0fb` | -| esp32s31 | +0.5 KB | +0.0 KB | +0.0 KB | +0.5 KB | -0.1 KB | +0.0 KB | `35a2f0fb` | +| esp32s3-n16r8 | +0.3 KB | +0.6 KB | -0.6 KB | +1.7 KB | -1.4 KB | +0.1 KB | `9df5b93b` | +| esp32s3-n8r8 | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `9df5b93b` | +| esp32s3-zero | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `9df5b93b` | Largest moves: -- **esp32**: `mm::ui::appJs` +137 B, `mm::HttpServerModule::streamFsFile(mm::platform::TcpConnecti` +65 B, `mm::HttpServerModule::handleListPatchRow(mm::platform::TcpCo` -45 B, `_ZN2mm13ModuleFactory12registerTypeINS_13NetworkModuleEEEbPK` -38 B, `mm::DriverBase::rebuildCorrection(unsigned char)` +32 B -- **esp32p4rev1-eth**: `mm::DriverBase::rebuildCorrection(unsigned char)` -348 B, `mm::TetrixEffect::tick()` +144 B, `mm::ui::appJs` +137 B, `mm::Hub75Driver::tick()` +106 B, `bool mm::ModuleFactory::registerType(c` +100 B -- **esp32s3-n16r8**: `mm::TetrixEffect::tick()` +144 B, `mm::ui::appJs` +137 B, `_ZN2mm13ModuleFactory12registerTypeINS_16RubiksCubeEffectEEE` -108 B, `mm::Hub75Driver::tick()` +96 B, `mm::HttpServerModule::handleListPatchRow(mm::platform::TcpCo` +45 B -- **esp32s31**: `mm::ui::appJs` +137 B, `mm::Hub75Driver::tick()` +106 B, `mm::platform::hub75Init(mm::platform::Hub75Handle&, mm::plat` +84 B, `mm::registerModuleTypes()` -68 B, `mm::RubiksCubeEffect::init(mm::draw::Canvas const&, short, s` +62 B +- **esp32s3-n16r8**: `mm::moonlive::compileSource(char const*, mm::moonlive::Built` +543 B, `mm::IpSettings::problem() const` +361 B, `mm::ipv4::staticProblem(unsigned char const*, unsigned char ` -337 B, `_ZN2mm8moonlive15XtensaAssembler8slotAddrENS0_3RegEh$part$0` +180 B, `mm::Drivers::firstOutputRgb(unsigned char*) const` -139 B +- **esp32s3-n8r8**: none +- **esp32s3-zero**: none What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -70,7 +70,7 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| -| desktop-macos | 1 µs (−1 µs) ✓ | 1,000,000 (+500,000) ✓ | +| desktop-macos | 1 µs | 1,000,000 | | desktop-windows | 4 µs | 250,000 | | esp32 | 8,354 µs | 119 | @@ -79,7 +79,7 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | esp32p4rev1-eth-wifi | |---|---|---|---|---|---|---|---|---|---|---| | Aurora_fps | 1,549 | 6,661 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | 6,343,087 ? | -| Control_a_palette_preset_changes_only_the_palette | 5 | - | - | 2,073 | - | - | - | - | - | - | +| Control_a_palette_preset_changes_only_the_palette | 5 (−1) ✓ | - | - | 2,073 | - | - | - | - | - | - | | Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | - | | Effects_pipeline_builds_and_renders | 6 | 4 ? | - | - | - | - | - | - | - | - | | Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | - | @@ -124,26 +124,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 25,734 | 6,191 | 26.6 % | -| light | 31,845 (+24) ⚠ | 7,649 | 26.9 % | -| platform | 17,198 (−1) ✓ | 3,613 | 23.4 % | -| ui | 12,227 (+7) ⚠ | 3,469 | 30.0 % | -| test | 62,726 (+88) ⚠ | 6,923 | 12.8 % | -| moondeck | 30,424 | 4,959 | 18.6 % | +| core | 27,121 (−1) ✓ | 6,491 | 26.5 % | +| light | 30,733 | 7,410 | 27.1 % | +| platform | 17,209 | 3,614 | 23.4 % | +| ui | 12,227 | 3,469 | 30.0 % | +| test | 63,075 | 6,947 | 12.8 % | +| moondeck | 30,598 | 4,969 | 18.5 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,319 (+6) ✓ | +| unit cases | 2,326 | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,143 (+4) ✓ | -| over threshold | 335 | +| functions | 4,162 | +| over threshold | 334 | | worst CCN | 128 | ## Documentation @@ -151,9 +151,9 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 142 | -| markdown lines | 30,366 (−10) ✓ | -| plan files | 42 | -| backlog lines | 3,247 (−25) ✓ | +| markdown lines | 30,771 (+1) ⚠ | +| plan files | 41 | +| backlog lines | 3,247 | | lessons lines | 532 | | CLAUDE.md lines | 283 | diff --git a/docs/work/future/generative-fields-analysis-top-down.md b/docs/work/future/generative-fields-analysis-top-down.md index 1295fd434..6efc96296 100644 --- a/docs/work/future/generative-fields-analysis-top-down.md +++ b/docs/work/future/generative-fields-analysis-top-down.md @@ -359,7 +359,7 @@ why it goes first. signatures would be a special case, and the language does not need another one. `Coord3D` is a class member, a local, a function argument and a return value, the same as `int`, `byte`, `bool` and `fixed`. Arguments and returns are roadmap § 6, which this step therefore depends on. Two -consequences worth stating: a member costs three of the 8 member records and 6 of the 64 arena bytes +consequences worth stating: a member costs three of the 16 member records and 6 of the 64 arena bytes unless the compiler packs it as one record, which is worth deciding when the type is added rather than after; and a local costs one frame slot per field under today's flat allocator, so a script holding several coordinates meets the 16-slot ceiling faster (§ 8b there). diff --git a/docs/work/future/moonlive-language-roadmap.md b/docs/work/future/moonlive-language-roadmap.md index 72969a05e..b73c75473 100644 --- a/docs/work/future/moonlive-language-roadmap.md +++ b/docs/work/future/moonlive-language-roadmap.md @@ -32,9 +32,9 @@ Five hard limits, all found by hitting them: | limit | value | where | |---|---|---| -| script state | **64 bytes** shared by all members | `kCtrlBytes`, `MoonLiveBuiltins.h:188` | -| distinct members | **8** | `kMaxCtrls`, same file | -| branch labels | **16** (an `if` or `for` takes up to 2) | `kIrLabels`, `MoonLiveIr.h:228` | +| script state | **64 bytes** shared by all members | `kCtrlBytes`, `MoonLiveBuiltins.h` | +| distinct members | **16** ✅ | `kMaxCtrls`, same file | +| branch labels | **40** in the IR, **48** labels and **96** fixups in the assembler, counted across the whole script (an `if` or `for` takes up to 2) | `kIrLabels`, `kAsmLabels`, `kAsmFixups`, `MoonLiveIr.h` | | frame slots | **32**, shared by live variables, loop counters and staged call arguments ✅ | `kMaxLocals`, `MoonLiveIr.h` | | ~~numeric types~~ | ~~`uint8_t`, `uint16_t`, `int16_t`~~ → **`int`, `byte`, `bool`, `fixed`, `string`** ✅ | still no float: `fixed` is Q16.16 | | ~~builtin table~~ | ~~16, and 16 used~~ → **96** ✅ | `BuiltinTable::kMax` — raised, with an overflow assert | @@ -54,7 +54,7 @@ Each row is a compromise the balls effect makes, and the language feature that w | forced to | because | wants | |---|---|---| -| 4 objects, not 25 | 64-byte arena, 8 members | a bigger arena, or a pool handle (shipped for particles) | +| 4 objects, not 25 | 64-byte arena | a bigger arena, or a pool handle (shipped for particles) | | whole-pixel motion | no fractional type | fixed-point or float | | one flat colour | no `hsv()` builtin | `hsv()` | | one array per field | no structs | structs | @@ -234,7 +234,9 @@ Doing #1 and #2 together is what actually opens the library; either alone leaves ### 3. A bigger arena and more members: *check the handle route first; storage rules now in the type-system design above* -64 bytes across 8 members is why an effect holds four objects rather than twenty-five. +64 bytes of member data is why an effect holds four objects rather than twenty-five. + +**More members: ✅ shipped (2026-10-07).** `kMaxCtrls` is 16, measured at +280 bytes per scripted module on the S3 (class size 1312 → 1592 B). The StadBeest legs need 11 members, 6 controls and 5 values kept between frames, and before the raise packed four of those values into one `int` by bit ranges. The arena stays at 64 bytes, and the rest of this section is about the arena. **The two limits bind at very different points, and it is the COUNT that bites first.** `fractal.mle` wanted 4 controls plus 4 scratch members plus a loop counter: 9 members costing @@ -242,8 +244,8 @@ Doing #1 and #2 together is what actually opens the library; either alone leaves have to be a member), so the script lost nothing, but the ceiling it hit was `kMaxCtrls` with 81% of the arena still free. Any script with a handful of controls and a handful of intermediates meets the same wall. If only one of the two moves, the count is the one worth moving: the four -tables it sizes are `DeclaredControl[8]` at 24 B each, so 8 -> 12 costs 96 B per engine and -roughly 600 B per device across three engines, against `sizeof(MoonLive)` at 864 B today. +tables it sizes were `DeclaredControl[8]` at 24 B each; the raise to 16 measured +280 B per +scripted module on the S3. **But check the handle route first.** The power-functions spec's item 5 — a particle pool as an arena-allocated HANDLE — means a simulation effect stops storing its own particle state entirely, @@ -258,8 +260,8 @@ Not purely a constants bump, and the blockers are known: (`MoonLive.h:355`) is the real gate. Past 64 bytes the seeded-member mask needs re-indexing — and there is a worked example, because it was widened 16 → 64 once already. The assert exists because the earlier `uint32_t` version silently aliased members mod 32. -- Watch `sizeof(MoonLive)`. It is held BY VALUE in every scripted module and constructed on the - main task's stack by `registerType`'s probe, which is what boot-looped the P4 at 1440 bytes. +- Watch `sizeof(MoonLive)`. It is held BY VALUE in every scripted module. `registerType`'s probe + builds one on the heap, since a probe on the boot stack is what boot-looped the P4 at 1440 bytes. Growing the arena grows every scripted module. ### 4. `setPaletteColor()` — ✅ *shipped* @@ -428,7 +430,7 @@ layers (2026-09-04). **The return value shipped the same day**, so two remain: | limit | what the compiler says | |---|---| -| no parameters | `a script function takes no arguments yet` | +| more than four parameters | `a function takes at most 4 parameters` | | no forward calls: a helper must be declared ABOVE its caller | `unknown function`, with the column but not the name | A function may now RETURN a value: `int f() { return ...; }` and the call is an expression. Each @@ -474,7 +476,7 @@ script value cannot express however signed it is. ### 8. More branch labels — *probably a constant, worth measuring first* -16 is tight enough that a straightforward nested draw does not fit. Raising `kIrLabels` costs +16 is tight enough that a straightforward nested draw does not fit. The limits are now 40 IR labels and 48 assembler labels with 96 fixups, and two scripts still hit them on 2026-10-07: the StadBeest legs with a bass envelope, and the StadBeest eye with darts, blinks and a ripple, each at roughly 20 to 25 `if`s across all its functions. Both fit only after branches were rewritten as arithmetic (§ 11). Raising `kIrLabels` costs compile-time table space and nothing at run time. Measure what a realistic effect needs before picking a number — the balls port wanted ~12 and had to be folded down. @@ -555,6 +557,21 @@ the parallel-array flattening this item exists to remove, and the element width already carries (`idxPack`) is the machinery it needs. An array of a USER struct needs the general declaration machinery above it. Worth building in that order if this is picked up. +### 11. What the StadBeest legs and eyes hit (2026-10-07) + +Two effects written in one session on real hardware: `stadbeest-legs.mle` (a walk cycle on 10 tubes, dancing to music) and `stadbeest-eyes.mle` (a frog's eye on a 241-light ring disc). Besides the member and label limits above, each of these cost a workaround: + +| limit | what it cost | wants | +|---|---|---| +| no `\|\|` or `&&` | every compound condition is nested `if`s, which spends labels twice; the eye writes `pulse * music == 1` and `(r2 - lo) * (hi - r2) > 0` instead | the two logical operators, short-circuit | +| no `abs`, `min`, `max`, `clamp` | every clamp is an `if`; the eye takes `abs` as `polarR(x, 0)` and `max(v, 0)` as `(v + polarR(v, 0)) / 2` | those four builtins, which also give back labels | +| "codegen failed: assembler overflow (branch range, slot, or immediate)" | names neither the limit nor the line; finding it took a desktop bisect with `disasm.py` | the message names the table that ran out and the function it was filling | +| "too many arguments to hold" | fires when the eye's `tick()` held too many live locals, so the wording sends the author to the calls | a message about live variables when that is the cause | +| a failed recompile leaves the effect dark | on a live installation every edit that does not compile switches the light off until a fixed one arrives; it happened three times in the session | keep running the last program that compiled, and show the error beside it | +| `setPaletteColor` clamps the palette index | a palette is circular, but `hue + offset` past 255 sticks at the last color unless the script writes `mod(..., 256)` | wrap the index, as the palette itself does | +| an effect visits every grid pixel | the eye computes 1600 pixels to light 241, and the legs' grid is mostly empty between 10 tubes | a way to iterate only the layout's lights, for sparse layouts | +| a `bool` control takes a min and a max | `addControl("pulse", pulse, 0, 1)` | `addControl(name, flag)` for a bool | + ## How to know a step landed Two measures, one local and one external. diff --git a/docs/work/present/OPEN-WORK.md b/docs/work/present/OPEN-WORK.md deleted file mode 100644 index 9ae2a5272..000000000 --- a/docs/work/present/OPEN-WORK.md +++ /dev/null @@ -1,38 +0,0 @@ -# Open work in the unarchived plans - -What is left in each plan, audited against the tree on 2026-10-02. One line per item, pointing rather than restating: the plan itself is the description, this is the worklist. A plan leaves this file when its last line here is struck. - -## Desktop: closable in a sitting - -| plan | what is left | -|---|---| -| **Input mapping and scripted sensors** | `addControl` still pulls the light header into the service builtins (`MoonLiveBuiltins_service.h`). | - -## Bench-gated: needs hardware, not keyboard time - -| plan | what is left | -|---|---| -| **One way to send to many receivers** | Step 7: discovery per mode, names, OSC, audio sync and the fallback with the S3 and the desktop; E1.31 multicast needs a second receiver. `run_network_live.py` writes `hosts` and reads each sender's `fixture` now, unrun since. | -| **Gamepads and MIDI desks** | The X-Touch check and the preset check of step 2; scribble strips and Learn need a desk. | -| **Input mapping and scripted sensors** | Step 3 (analog, the expression pedal) is host-verified only; the `.mls` picker and "new script" template are unconfirmed. | -| **RTSP video out** | Verification 2 to 5 on the P4, the glass-to-glass delay against HLS above all, since no factor is claimed until it is measured. | -| **MoonLight, v5.0.0 to the rename** | Verification 1, 2, 5 and 6 (upgrade, OTA across the move, the redirect, backup to restore), and the two fidelity bench questions: the audio level scale and the reconstructed logic. | - -## Larger, and its own effort - -| plan | what is left | -|---|---| -| **Gamepads and MIDI desks** | USB host on S3 and P4 (step 4), RTP-MIDI (step 5), a native Linux source (step 6). | -| **Input mapping and scripted sensors** | Steps 4, 4b, 5 and 6, none started: I2C sensors, the VL53L8CX zone grid, pulse timing with `EncoderService`, PIR. | -| **Documentation sweep** | The prose and docgen debt, now counted in [prose.md](../../reference/metrics/prose.md) and [docgen.md](../../reference/metrics/docgen.md) and ratcheted; the sweep itself is the backlog's [prose sweep](../future/backlog-core.md) item. | - -## Struck since 2026-09-07 - -- **MoonLive palettes**: `scenario_Palettes_a_script_drives_the_effects` writes a palette script, picks it by name, turns its control and removes it, in-process and live. -- **OSC control ingest**: the scenario promise is dropped, since a scenario drives the pipeline over HTTP; `unit_OscPacket`, `unit_OscModule` and the bench cover OSC. -- **Two-way control surfaces**: the surface tests live in `unit_ControlModule`, and the seam is documented in [supporting](../../moonmodules/core/supporting.md). -- **Config backup and restore**: the installer's erase note says to back up first. -- **MoonLight migration**: closed by the rename plan's decision that the migration is finished. -- **Input mapping, step 2 and the MoonLiveService card**: bench-verified on the Dig-2-Go on 2026-09-02, and the card is in [services](../../moonmodules/core/services.md). -- **Input mapping, continuous writes**: saving is debounced and bounded by a ceiling ([FilesystemModule](../../../src/core/system/FilesystemModule.h)). -- **Input mapping, modules exchanging values over the network**: OSC shared controls answer it ([multi-device runtime](../../explanation/architecture/mooncore.md#multi-device-runtime)). diff --git a/docs/work/present/Plan-20260901 - Input mapping and scripted sensors (partial).md b/docs/work/present/Plan-20260901 - Input mapping and scripted sensors (partial).md index 23df58af0..675a9b308 100644 --- a/docs/work/present/Plan-20260901 - Input mapping and scripted sensors (partial).md +++ b/docs/work/present/Plan-20260901 - Input mapping and scripted sensors (partial).md @@ -569,20 +569,9 @@ line: A user's own action already feels instant, since the UI updates locally on send. What this buys is the case where something ELSE moved the control: a script, OSC, a second browser. -### Open: `addControl` is still in the light header +### ✅ Resolved: `addControl` is in the neutral builtins -The domain-neutral builtins moved to `core/moonlive/MoonLiveBuiltins_common.h` (2026-09-02): the -math, the waveforms, noise, randomness and print. `addControl` did NOT, so the service table still -includes the light header for that one function, which leaves core depending on a domain for the -sake of declaring a setting. - -It stayed behind because it is not a pure function: it writes through an `AddControlSink`, a -per-thread slot table that also serves `addLight` and the draw canvas, and moving that machinery is -a bigger job than moving thirteen pure functions was. `smin` went back to the light table for the -same class of reason, and correctly: it wraps `draw::smin`, so it IS a light idea. - -Worth finishing when the sink table is touched for another reason. The include is honest about why -it is there. +Resolved 2026-10-06: `addControl`, its sink and `runDefineControls` live in `MoonLiveBuiltins_common.h`, registered once for both tables, and the per-thread slot claim is `core/util/ThreadSlot.h`, which the light sinks share. ### Open: a control written continuously should not be persisted at that rate diff --git a/docs/work/present/Plan-20261003 - Code ratchets, one report for simplicity (partial).md b/docs/work/present/Plan-20261003 - Code ratchets, one report for simplicity (partial).md index 7fcd5b5db..4ea7beb74 100644 --- a/docs/work/present/Plan-20261003 - Code ratchets, one report for simplicity (partial).md +++ b/docs/work/present/Plan-20261003 - Code ratchets, one report for simplicity (partial).md @@ -52,18 +52,18 @@ Each step ships with the tree better than before it, and the sweeps the numbers 3. ✅ **File size, nesting depth and parameter count.** Files over 1000 lines, control flow nested deeper than 3 and more than 7 parameters, all SonarQube's defaults; nesting is the cheap half of cognitive complexity, the measure built for how a reader experiences code. The baseline after this step is 487 findings: 295 complex functions, 118 deeply nested, 50 long, 24 large files, `app.js` (8494 lines), `platform_desktop.cpp` and `HttpServerModule.cpp` among them. 4. ✅ **Gains since a reference.** `repo-health.md` gains a table of the numbers a user feels, compared against a fixed reference rather than the previous commit: flash per target, free internal RAM and PSRAM per target (recorded per target with its date, as flash is; `collect_kpi` already reads the heap from a board and discards it), the tick per scenario, lines of code, and the code report's findings. A simplification then shows as a gain over weeks, where the per-commit delta shows only noise. The reference is a release tag, so the table says what the next release gives a user over the last one. 5. ✅ **Boundaries.** The core-to-light include rule, baseline 10 includes in 6 files, and the platform boundary folded in as a rule with baseline 0, so `check_platform_boundary.py` goes and `collect_kpi` stops running it. The rule's text joins [mooncore.md](../../explanation/architecture/mooncore.md) beside the platform abstraction. -6. 🚧 **The UI and name spread.** Type and module names as literals in `src/ui`, per file; and name spread per registered type, both from the type list in `module_types.cpp`. Control type names (`palette`, `list`) are the UI's contract and are not findings. +6. ✅ **The UI and name spread.** Type and module names as literals in `src/ui`, per file; and name spread per registered type, both from the type list in `module_types.cpp`. Control type names (`palette`, `list`) are the UI's contract and are not findings. Shipped 2026-10-06 as two rules: 69 type literals in `src/ui` (the containers `Layouts`, `Effects`, `Drivers` and `Services` allowed), and 22 types named in more than 10 files beyond their own, `Layer` (131) and `Layouts` (110) at the top. 7. ✅ **Duplication.** jscpd over `src`, `test`, `moondeck` and `mooninstaller`, with vendored files excluded, at jscpd's defaults of 5 lines and 50 tokens. A clone counts under both of its files. The first run found 412 clone pairs, 3.17% of all lines, most of them test fixtures copied between unit tests; the report's baseline is 1345 findings across 305 files. -8. 🚧 **Two rules from clang-query.** Its run on 2026-10-04 reports four inventories, and two of them are smells with a known move, which only the syntax tree answers. +8. ✅ **Two rules, from the compiler and the report rather than clang-query.** Shipped 2026-10-06: `-Wframe-larger-than=512` in the desktop build, read from its saved findings at no extra cost (92 frames, `registerType`'s probe at the top with 5.9 KB for AudioService), and a text rule for buffers taken by hand in `src/light` (24). clang-query stays the on-request inventory. The original plan follows. Its run on 2026-10-04 reports four inventories, and two of them are smells with a known move, which only the syntax tree answers. - **Large stack array**: a local array of 512 bytes or more, 18 today, 12 of them 1 KB or more, `HttpServerModule.cpp` and `HueDriver.h` among them. A task's stack is sized for its deepest call, so one large local costs every task that can reach it; the move is a member or a `ScratchBuffer`, sized once. - **Raw allocation in the light domain**: 70 raw `alloc`/`new` sites in 31 files, 16 of them light files, beside 113 `ScratchBuffer` members that already own most light memory. The move is a `ScratchBuffer` member. - The other two inventories stay as they are. Fixed-array member bytes (core 31 KB, light 11 KB) are a memory budget, which step 4's gains table reports. `ScratchBuffer` members are the managed heap working as intended. - clang-query counts the vendored `miniaudio.h` (69 arrays, 1857 comment rows), so it takes its list of files we own from `check_code.py`, one home for it. - Its comment inventory (52% of the public surface documented, 3338 declarations without a comment) is docgen's domain. Whether a public declaration without `///` becomes a docgen rule is an open decision below. 9. ✅ **The hot path in the report.** `check_nonblocking`'s blocking calls reachable from the render ticks become a rule, "blocking call on the render path", counted per file like the others: 159 today, 85 in core, 64 in light, 7 in the platform layer, 3 in tests. The measurement stays the compiler's; its own baseline, `hotpath-baseline.txt`, goes, a fourth copy of the one ratchet. The baseline flags each new call site where a per-file count sees only a file's total rise, the trade every rule in the report makes. -10. 🚧 **`@moreinfo` that nothing refers to.** A docgen rule: an appendix section no `@xref` and no card links to is a finding, so the appendix total falls by cutting what no reader reaches. +10. ✅ **`@moreinfo` that nothing refers to.** A docgen rule: an appendix section no `@xref` and no card links to is a finding, so the appendix total falls by cutting what no reader reaches. Shipped 2026-10-06 as a warning that ratchets, 432 sections at the start. 11. ✅ **The process.** CLAUDE.md's commit table lists `check_code` where it lists lizard and the platform boundary today; the Reviewer's scope gains the two judgment questions; coding-standards § Tooling describes the one report and drops the clang-format check that does not exist. `MoonDeck.md` and `testing.md` follow. -12. 🚧 **MoonCore as a library.** Once the core-to-light rule reads 0: a CMake target of `src/core` alone that compiles without `src/light`, so a crossing is a build error rather than a count. The end state of "core without light". +12. 🚧 **MoonCore as a library.** The core-to-light rule reads 0 since 2026-10-06, so this is unblocked: a CMake target of `src/core` alone that compiles without `src/light`, so a crossing is a build error rather than a count. The end state of "core without light". 13. ✅ **What each commit added and saved.** `collect_kpi --commit` prints, beside the KPI line, the commit's own account: lines added and removed in `src/` and `test/` (from `git diff --numstat`), and the change in duplicated lines and in code findings against the committed `code.md`. The commit message carries it, so a subtraction shows as a saving in the commit that made it rather than only in a later total. Step 4's gains table stays the release view; this is the per-commit view of the same numbers. ## Subtraction @@ -85,7 +85,7 @@ Each step ships with the tree better than before it, and the sweeps the numbers ## Open decisions - **Agentless development.** The gate lists in CLAUDE.md are run by an agent today; an agentless or local-model workflow wants one runnable command per event. `precommit.py` was removed on 2026-10-01. Whether a gate runner returns, and in what shape, is a decision for the product owner before step 11. -- **Thresholds.** Lizard keeps CCN 10 and NLOC 60 as today; nesting, parameters and file length took SonarQube's defaults in step 3, and duplication jscpd's in step 7. -- **Raw allocation in light.** Whether every light allocation goes through `ScratchBuffer` is a rule the architecture implies but does not state; it is stated first, then counted. +- **Thresholds.** Lizard keeps CCN 10 and NLOC 60 as today; nesting, parameters and file length took SonarQube's defaults in step 3, and duplication jscpd's in step 7. 🚧 Name spread took 10 files in step 6, which no tool defaults, for the product owner to confirm or move. +- ✅ **Raw allocation in light.** Decided 2026-10-06: every light buffer is a `ScratchBuffer` member, stated in the architecture's invariants and counted by the report. - **Undocumented public declarations.** Generated pages show an empty entry for each, which argues for a docgen rule; the cut-first rule argues against adding comments for their own sake. Counting only public declarations in headers that generate a page is the middle way. -- **Where the UI's type names go.** A few literals are the UI's contract (the containers `Layouts`, `Effects`, `Drivers` are pinned in `main.cpp`); step 6 lists them as the rule's allowed set, in the script, with the reason. +- ✅ **Where the UI's type names go.** A few literals are the UI's contract (the containers `Layouts`, `Effects`, `Drivers` are pinned in `main.cpp`); step 6 lists them as the rule's allowed set, in the script, with the reason. diff --git a/docs/work/present/Plan-20261003 - Ethernet, WiFi and access point as Network submodules (shipped, partly verified).md b/docs/work/present/Plan-20261003 - Ethernet, WiFi and access point as Network submodules (shipped, partly verified).md index 5aa91ba4d..9811b275c 100644 --- a/docs/work/present/Plan-20261003 - Ethernet, WiFi and access point as Network submodules (shipped, partly verified).md +++ b/docs/work/present/Plan-20261003 - Ethernet, WiFi and access point as Network submodules (shipped, partly verified).md @@ -121,16 +121,16 @@ Tested on the one board at hand, an S3 on a 2.4 GHz network, and pinned by tests Waiting for more devices and a cable: -- 🚧 **Memory on a board without PSRAM.** Build the classic ESP32 and the P4 with these changes, flash a board without PSRAM, and compare its free heap against main. -- 🚧 **Ethernet.** The Ethernet card on a wired board (P4, S31 or a classic Ethernet board): DHCP, a static address, and a cable plugged in while WiFi is joined. +- ✅ **Memory on a board without PSRAM.** The Olimex Gateway (classic ESP32, no PSRAM) on 2026-10-06: 137.5 KB free with a 108 KB largest block a minute after boot, the same as the firmware it ran before, which already carried these submodules. +- **Ethernet.** ✅ On the Olimex on 2026-10-06: DHCP, and a static address applied live (the device moved from .210 to .211 within seconds) that survives a reset. ✅ A cable plugged in while WiFi is joined: the device moved from WiFi (.207) to Ethernet (.210, 100 Mbit) live, with no restart. - 🚧 **Phones.** The full first setup on an iPhone, the sign-in screen opening on the WiFi card, and on an Android phone. -- 🚧 **Networks.** A hidden network joined as a station, a channel 13 router, and static addressing on a WiFi network. +- **Networks.** ✅ Static addressing on a WiFi network, on the Olimex on 2026-10-06: set on the known row, the device moved from .207 to .212 live and back on DHCP. 🚧 A hidden network joined as a station and a channel 13 router. - 🚧 **`opens: never`** on hardware, with USB at hand to recover. - ✅ **Static IP settings are checked before use:** a mask, address, gateway or DNS server a network cannot use leaves the interface on DHCP, with the reason on the Ethernet card or the WiFi row. - ✅ **A live address change asks first:** editing the addressing of the interface carrying the page names where the device will be, and an address is sent when committed rather than as it is typed. - ✅ **A recent scan orders the joining:** known networks a scan in the last five minutes saw are tried first, in list order, so the list stays the priority and no time goes to networks out of range. Signal strength does not reorder them, a choice made on 2026-10-05 to keep moving a row up meaning prefer it. Verified on an S3: with an absent network at the top and a scan that missed it, the round after a failed join tried the network in range first and rejoined in about a second. - ✅ **MoonBase** with the new layout, on a classic ESP32: it joins the app's WiFi, opens the device's own access point with the sign-in screen, and installs from a GitHub release. Two live scenarios pin the first two. -- 🚧 **MoonBase over Ethernet**, on an Olimex Gateway with the new scenario. +- **MoonBase over Ethernet**, on the Olimex on 2026-10-06. ✅ It comes up on Ethernet (the Ethernet MAC answers) and installs the app through `/api/firmware/upload`, 2.2 MB in 74 s, which then boots back on its address. ✅ MoonBase pins the app's static addressing, Ethernet's and each known network's, by the app's own rule (`core/util/Ipv4.h`, shared): with the app static on .211 it answers on .211, and on DHCP on its lease. With the cable out and the known WiFi network static on .212, MoonBase gave Ethernet its window, joined WiFi and answered on .212, and the app came back there. - ✅ **A staged install survives a reset in MoonBase.** It keeps the URL until an install ends and counts installs started, stopping after three that reset the device. On a D32 whose supply collapses whenever the radio starts, 3 of 3 hand-overs completed where 2 of 4 were lost before. - 🚧 **Two unexplained S3 failures, each without a serial log at the time.** A card join that did not start in one first-setup run. A freeze, silent on serial and off the network until reset, after the fallback scenario, after which the second known network was gone from the saved file. diff --git a/docs/work/present/Plan-20261005 - The audio codec as runtime configuration (in PR 129, M3 unverified).md b/docs/work/present/Plan-20261005 - The audio codec as runtime configuration (in PR 129, M3 unverified).md index d9edad6ca..da02abc9f 100644 --- a/docs/work/present/Plan-20261005 - The audio codec as runtime configuration (in PR 129, M3 unverified).md +++ b/docs/work/present/Plan-20261005 - The audio codec as runtime configuration (in PR 129, M3 unverified).md @@ -10,11 +10,11 @@ The two boards share their codec wiring, so a variant per board would multiply f ## Steps -1. **Take the two codec fixes from #128**, credited to TouchMyLight: pass the I2C address pre-shifted where `esp_codec_dev` expects the 8-bit form, and set the `data_if` that `esp_codec_dev_new` requires. Both affect the S31 as well. -2. **Build `esp_codec_dev` for the P4** as well as the S31, in `esp32/main/idf_component.yml`; measure the flash it adds on `esp32p4rev1-eth` with the flash split. -3. **Make the codec runtime configuration**: AudioService gains `codec` (none or ES8311) and `codecAddr`, the address shown only when a codec is chosen. The bus is the board's: the I2C scan module becomes I2cBusModule, which opens it once on its pins for every device on it. None is the default, so a P4 board without one never probes the bus. `audioCodecInit` takes them from AudioService; `audioCodecType` and `audioCodecPins` leave `platform_config.h`. -4. **The S31 CoreBoard entry** names its codec in `deviceModels.json` (SDA 51, SCL 50, MCLK 52, address 0x18), since the firmware leaves the codec to the device model. -5. **Two catalog entries** on `esp32p4rev1-eth`, as proposed on the PRs: Waveshare ESP32-P4-ETH and Guition JC-ESP32P4-M3-DEV, both SDA 7, SCL 8, 0x18, MCLK 13, BCLK 12, WS 10 and Ethernet `P4-NANO`, with mic data on 11 (Waveshare, its wiki and a bench test) and 48 (Guition, its schematic and #55's hardware test). +1. ✅ **Take the two codec fixes from #128**, credited to TouchMyLight: pass the I2C address pre-shifted where `esp_codec_dev` expects the 8-bit form, and set the `data_if` that `esp_codec_dev_new` requires. Both affect the S31 as well. +2. ✅ **Build `esp_codec_dev` for the P4** as well as the S31, in `esp32/main/idf_component.yml`; measure the flash it adds on `esp32p4rev1-eth` with the flash split. +3. ✅ **Make the codec runtime configuration**: AudioService gains `codec` (none or ES8311) and `codecAddr`, the address shown only when a codec is chosen. The bus is the board's: the I2C scan module becomes I2cBusModule, which opens it once on its pins for every device on it. None is the default, so a P4 board without one never probes the bus. `audioCodecInit` takes them from AudioService; `audioCodecType` and `audioCodecPins` leave `platform_config.h`. +4. ✅ **The S31 CoreBoard entry** names its codec in `deviceModels.json` (SDA 51, SCL 50, MCLK 52, address 0x18), since the firmware leaves the codec to the device model. +5. ✅ **Two catalog entries** on `esp32p4rev1-eth`, as proposed on the PRs: Waveshare ESP32-P4-ETH and Guition JC-ESP32P4-M3-DEV, both SDA 7, SCL 8, 0x18, MCLK 13, BCLK 12, WS 10 and Ethernet `P4-NANO`, with mic data on 11 (Waveshare, its wiki and a bench test) and 48 (Guition, its schematic and #55's hardware test). ## Tests @@ -24,13 +24,13 @@ The two boards share their codec wiring, so a variant per board would multiply f ## Verification -- S31 on the bench: the mic reports level and onsets through the codec, which also proves the two fixes. +- ✅ S31 on the bench: the mic reports level and onsets through the codec, which also proves the two fixes. - The MM testbench P4 (direct I2S mic, no codec) keeps its audio unchanged with `codec` at none. -- The two P4-ETH boards are verified by their PR authors on their hardware. +- The two P4-ETH boards: ✅ the Waveshare ESP32-P4-ETH on our bench (Ethernet, the codec at 0x18, the mic following sound); 🚧 the Guition M3, which #55's author is asked to confirm on the release. ## What comes out -The compile-time codec constants, and the need for #128's and #55's firmware variants; both PRs close or rebase onto this. +The compile-time codec constants, and the need for #128's and #55's firmware variants; both PRs close or rebase onto this. ✅ #128 and #55 closed in favor of #129 on 2026-10-06. ## Docs diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 16f975dfe..4039f8993 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -171,7 +171,7 @@ add_custom_target(ui_embed DEPENDS ${UI_DIR}/ui_embedded.h) # library while the device holds only what someone actually picked. CONFIGURE_DEPENDS re-globs when # a script is added or removed, so the catalog cannot go stale against the repo. set(MOONLIVE_DIR ${COMPONENT_DIR}/../../moonlive) -set(MOONLIVE_GEN ${COMPONENT_DIR}/../../src/light/moonlive) +set(MOONLIVE_GEN ${COMPONENT_DIR}/../../src/core/moonlive) file(GLOB MOONLIVE_SCRIPTS CONFIGURE_DEPENDS ${MOONLIVE_DIR}/effects/*.mle ${MOONLIVE_DIR}/layouts/*.mll ${MOONLIVE_DIR}/modifiers/*.mlm) # See the desktop CMakeLists: the file LIST is a dependency, so a DELETED script regenerates the diff --git a/mkdocs.yml b/mkdocs.yml index b13d16b40..104437cc9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -172,6 +172,7 @@ nav: - Driving LED panels with a receiving card: how-to/panel-cards.md - LED signal integrity: how-to/led-signal-integrity.md - Connecting a control surface: how-to/control-surface.md + - Building a multi-board installation: how-to/multi-board-installation.md - Home automation: how-to/home-automation.md - Save and recall presets: how-to/presets.md - Updating firmware: how-to/updating-firmware.md diff --git a/moonbase/main/CMakeLists.txt b/moonbase/main/CMakeLists.txt index 120591441..ce99b8f5f 100644 --- a/moonbase/main/CMakeLists.txt +++ b/moonbase/main/CMakeLists.txt @@ -1,8 +1,8 @@ idf_component_register( SRCS "moonbase_main.cpp" # MoonBase shares no source files with the application, the trade for an image that stays small and rarely changes. - # Three headers are the exceptions, each depending on nothing but the C library and core/util/hex.h, since a rule in two hand-written copies drifts: - # core/util/FirmwareImage.h (both images refuse to install the other), core/util/ConfigScrape.h (this image reads the app's config file) and core/util/CaptivePortal.h (both access points answer a phone the same way). + # Four headers are the exceptions, each depending on nothing but the C library, core/util/hex.h and the platform/nonblocking.h marker, since a rule in two hand-written copies drifts: + # core/util/FirmwareImage.h (both images refuse to install the other), core/util/ConfigScrape.h (this image reads the app's config file), core/util/Ipv4.h (both refuse the same static addresses) and core/util/CaptivePortal.h (both access points answer a phone the same way). INCLUDE_DIRS "../../src" # The app's own logo, embedded IDF-natively; served as /logo.png (header + favicon). EMBED_FILES "../../src/ui/moonmodules-logo.png" diff --git a/moonbase/main/moonbase_main.cpp b/moonbase/main/moonbase_main.cpp index d33aa792b..1488b32d2 100644 --- a/moonbase/main/moonbase_main.cpp +++ b/moonbase/main/moonbase_main.cpp @@ -73,7 +73,7 @@ constexpr int kHttpPort = 80; // The app's known networks in its priority order, tried as the app tries them. constexpr uint8_t kMaxNetworks = 8; -struct Network { char ssid[33]; char password[65]; }; +struct Network { char ssid[33]; char password[65]; mm::configscrape::SavedIp ip; }; Network networks_[kMaxNetworks] = {}; uint8_t networkCount_ = 0; // The app's own access point, named after the device and protected as the user set it, so a phone on it stays on through the restart into this image. @@ -102,6 +102,9 @@ struct { bool clockExtIn = true; } ethCfg_; +// The Ethernet card's addressing, so a board the app pins to a static address answers there in recovery too. +mm::configscrape::SavedIp ethIp_; + // The board's WiFi TX cap in dBm, 0 for none: a board that browns out at full power must not do so in recovery. int txPowerDbm_ = 0; @@ -150,6 +153,8 @@ void loadCredentials() { && mm::configscrape::findNetwork(buf, networkCount_, networks_[networkCount_].ssid, sizeof(Network::ssid), networks_[networkCount_].password, sizeof(Network::password))) networkCount_++; + for (uint8_t k = 0; k < networkCount_; k++) networks_[k].ip = mm::configscrape::findNetworkIp(buf, k); + ethIp_ = mm::configscrape::findChildIp(buf, "EthernetModule"); mm::configscrape::findInt(buf, "ethType", ðCfg_.type); mm::configscrape::findInt(buf, "ethPhyAddr", ðCfg_.phyAddr); mm::configscrape::findInt(buf, "ethRstGpio", ðCfg_.rstGpio); @@ -170,6 +175,30 @@ void loadCredentials() { // Network // --------------------------------------------------------------------------------------------- +// Pin a static address as the app does, once the link is up, when ESP-IDF lets DHCP stop; one the app refuses is refused here. +bool pinStatic(esp_netif_t* netif, const mm::configscrape::SavedIp& s) { + if (!netif || !s.usable()) return false; + esp_netif_dhcpc_stop(netif); + esp_netif_ip_info_t info = {}; + IP4_ADDR(&info.ip, s.ip[0], s.ip[1], s.ip[2], s.ip[3]); + IP4_ADDR(&info.gw, s.gateway[0], s.gateway[1], s.gateway[2], s.gateway[3]); + IP4_ADDR(&info.netmask, s.subnet[0], s.subnet[1], s.subnet[2], s.subnet[3]); + esp_netif_set_ip_info(netif, &info); + if (s.dns[0] || s.dns[1] || s.dns[2] || s.dns[3]) { + esp_netif_dns_info_t dns = {}; + dns.ip.type = ESP_IPADDR_TYPE_V4; + IP4_ADDR(&dns.ip.u_addr.ip4, s.dns[0], s.dns[1], s.dns[2], s.dns[3]); + esp_netif_set_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns); + } + return true; +} + +esp_netif_t* ethNetif_ = nullptr; +esp_netif_t* staNetif_ = nullptr; +// The known network being tried, whose addressing its connection applies, and whether the one before pinned a static address. +volatile int staCurrent_ = -1; +volatile bool staPinned_ = false; + void onGotIp(void*, esp_event_base_t, int32_t id, void*) { // Registered for every IP event; only an acquired STA address means online (IP_EVENT_STA_LOST_IP arrives on the same base and must not set the bit). if (id == IP_EVENT_STA_GOT_IP || id == IP_EVENT_ETH_GOT_IP) @@ -177,13 +206,23 @@ void onGotIp(void*, esp_event_base_t, int32_t id, void*) { } void onWifiEvent(void*, esp_event_base_t, int32_t id, void*) { + // Static has no lease to announce it, so the connection is the moment it is online. + if (id == WIFI_EVENT_STA_CONNECTED && staCurrent_ >= 0) { + const bool pinned = pinStatic(staNetif_, networks_[staCurrent_].ip); + if (!pinned && staPinned_) esp_netif_dhcpc_start(staNetif_); // a network after a static one leases again + staPinned_ = pinned; + if (pinned) xEventGroupSetBits(netEvents_, kNetGotIp); + } if (id == WIFI_EVENT_STA_DISCONNECTED) xEventGroupSetBits(netEvents_, kNetStaDown); if (switchingNetwork_) return; if (id == WIFI_EVENT_STA_START || id == WIFI_EVENT_STA_DISCONNECTED) esp_wifi_connect(); } +void onEthEvent(void*, esp_event_base_t, int32_t id, void*) { + if (id == ETHERNET_EVENT_CONNECTED && pinStatic(ethNetif_, ethIp_)) xEventGroupSetBits(netEvents_, kNetGotIp); +} + // Bring up the on-chip MAC as the app's config wires it, staying installed without a link so a later cable still gets an address. esp_eth_handle_t ethHandle_ = nullptr; -esp_netif_t* ethNetif_ = nullptr; bool ethStart() { #if !SOC_EMAC_SUPPORTED @@ -195,6 +234,7 @@ bool ethStart() { esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH(); esp_netif_t* netif = esp_netif_new(&netif_cfg); if (!netif) return false; + esp_event_handler_instance_register(ETH_EVENT, ETHERNET_EVENT_CONNECTED, &onEthEvent, nullptr, nullptr); eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); eth_esp32_emac_config_t emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); @@ -224,14 +264,15 @@ bool ethStart() { esp_netif_destroy(netif); return false; } + ethNetif_ = netif; // before the start, since the link can come up before this returns if (esp_netif_attach(netif, esp_eth_new_netif_glue(handle)) != ESP_OK || esp_eth_start(handle) != ESP_OK) { esp_eth_driver_uninstall(handle); // frees mac + phy esp_netif_destroy(netif); + ethNetif_ = nullptr; return false; } ethHandle_ = handle; - ethNetif_ = netif; return true; #endif // SOC_EMAC_SUPPORTED } @@ -250,7 +291,7 @@ void ethStop() { // Try each known network in turn for a bounded time, as the app does. Returns whether an address arrived. bool wifiStation(uint32_t waitMs) { if (!networkCount_) return false; - esp_netif_create_default_wifi_sta(); + staNetif_ = esp_netif_create_default_wifi_sta(); wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT(); if (esp_wifi_init(&init) != ESP_OK) return false; esp_wifi_set_mode(WIFI_MODE_STA); @@ -270,6 +311,7 @@ bool wifiStation(uint32_t waitMs) { std::strncpy(reinterpret_cast(cfg.sta.ssid), networks_[k].ssid, sizeof(cfg.sta.ssid) - 1); std::strncpy(reinterpret_cast(cfg.sta.password), networks_[k].password, sizeof(cfg.sta.password) - 1); const bool configured = esp_wifi_set_config(WIFI_IF_STA, &cfg) == ESP_OK; + staCurrent_ = k; // each known network keeps its own addressing, as in the app switchingNetwork_ = false; if (!configured) continue; // the next network, never the previous one under this one's turn // The first starts the radio, whose start event connects; each later one connects itself. diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 748398619..8d2fb9c20 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -748,6 +748,10 @@ The report, [`docs/reference/metrics/code.md`](../docs/reference/metrics/code.md **Platform code outside src/platform** and **light include in core** are the architecture's two boundaries, counted per line. The first is a vendor header or a platform `#ifdef` outside `src/platform/`, the second a `src/core` file including a `light/` header. +**Module type in the UI** is a registered type's name as a string literal in `src/ui`, one row per literal. The type list is read from `module_types.cpp` as `check_devices` reads it. The four containers `main.cpp` pins as the tree's roots (`Layouts`, `Effects`, `Drivers`, `Services`) are the UI's contract and not findings. **Name spread** is a type named in more than 10 files beyond its own: its header, its implementation and its tests. It is one row on the header that defines it, and it counts change amplification, since a rename of that type edits that many files. + +**Large stack frame** is a firmware function whose frame the compiler measures over 512 bytes, from `-Wframe-larger-than=512` in the desktop Release build. It is read from the same saved findings as the render path, so it costs no extra time, and each template instance counts as its own function. **Raw allocation in light** is a buffer taken by hand in `src/light`: an array `new`, a malloc-family call or `platform::alloc`. The architecture asks for a `ScratchBuffer` member there, and a factory creating an object is not one. + **Blocking call on the render path** is the last rule, one row per site [check_nonblocking](#check_nonblocking) finds, a float conversion at a `formatTo` site included. It reads that script's saved findings after an incremental desktop build, so a full run takes seconds; `--module` and `--account` skip it. A host without Clang 20 skips this rule with a SKIP line and leaves `code.md` as it is, while every other rule still ratchets. The account, `--account`, reads `code.md` as the last full run wrote it, so it follows a full run in the same gate pass. A saving shows in the commit that made it, rather than only in a later total. diff --git a/moondeck/check/check_code.py b/moondeck/check/check_code.py index b5b47608a..fb63deed2 100644 --- a/moondeck/check/check_code.py +++ b/moondeck/check/check_code.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """The code report: one page of counts that only fall, in the shape docgen.md and prose.md have. -Each rule is a textbook measure with a known tool: cyclomatic complexity, function length, nesting depth and parameter count from lizard, duplicated blocks from jscpd, file length and the two architecture boundaries counted here, and the blocking calls the render path reaches from Clang's `-Wfunction-effects` through check_nonblocking's build, a clean rebuild of the desktop that costs minutes. A finding is listed per file, so a touched file clears its own rows and leaves the list. The committed docs/reference/metrics/code.md is the number to beat, per rule and in total. +Each rule is a textbook measure with a known tool: cyclomatic complexity, function length, nesting depth and parameter count from lizard, duplicated blocks from jscpd, file length, the two architecture boundaries, module types named in the UI, name spread and buffers taken by hand in the light domain counted here, and the blocking calls the render path reaches and the stack frames over 512 bytes from the compiler's `-Wfunction-effects` and `-Wframe-larger-than` through check_nonblocking's build, a clean rebuild of the desktop that costs minutes. A finding is listed per file, so a touched file clears its own rows and leaves the list. The committed docs/reference/metrics/code.md is the number to beat, per rule and in total. Lizard tokenizes rather than parses, which is what lets it run in a second with no build; the cost is a mangled name on some template-dense bodies. Counting per FILE rather than whitelisting per function name is what makes that harmless: a file's count is right whatever lizard calls the function. @@ -39,6 +39,12 @@ # jscpd's defaults: a clone is this many lines sharing this many tokens, so renamed variables still match and a changed structure does not. MIN_CLONE_LINES = 5 MIN_CLONE_TOKENS = 50 +# Change amplification: a type more files name than this, beyond its own, costs that many edits when it changes. No tool sets a default, so the limit sits where the pipeline's spine (Layer, the containers, a fixture layout) stands out. +MAX_SPREAD = 10 +# The compiler's -Wframe-larger-than, set in CMakeLists.txt to this number: a task's stack is sized for its deepest call, so one large frame costs every task that reaches it. +MAX_FRAME = 512 +# The four containers main.cpp pins as the tree's roots, which the UI's tabs are built from: naming them is the UI's contract rather than knowledge of a module. +UI_CONTRACT = {"Layouts", "Effects", "Drivers", "Services"} # Firmware C++ only: src/ui is JavaScript served to the browser, and the vendor header is upstream's. -ENS adds the nesting depth column. LIZARD_ARGS = ["src/", "-l", "cpp", "-x", "src/ui/*", "-x", "src/platform/desktop/vendor/*", "-ENS"] @@ -49,6 +55,10 @@ HOT_PATH = "blocking call on the render path" PLATFORM = "platform code outside src/platform" CORE_LIGHT = "light include in core" +UI_TYPE = "module type in the UI" +SPREAD = "name spread" +STACK = "large stack frame" +RAW_ALLOC = "raw allocation in light" RULES = {"complex function": f"cyclomatic complexity > {MAX_CCN}", "long function": f"> {MAX_NLOC} lines of code", "deeply nested": f"control flow nested deeper than {MAX_NESTING}", @@ -57,10 +67,14 @@ "large file": f"> {MAX_FILE_LINES} lines", HOT_PATH: "a call from tick, tick20ms or tick1s that can block or allocate", PLATFORM: "a vendor header or a platform #ifdef outside src/platform", - CORE_LIGHT: "a src/core file including a light/ header"} + CORE_LIGHT: "a src/core file including a light/ header", + UI_TYPE: "a registered type's name as a string literal in src/ui", + SPREAD: f"a type named in more than {MAX_SPREAD} files beyond its own", + STACK: f"a function whose stack frame is over {MAX_FRAME} bytes", + RAW_ALLOC: "an array new, malloc or platform::alloc in src/light outside ScratchBuffer"} LIMITS = {"complex function": MAX_CCN, "long function": MAX_NLOC, "deeply nested": MAX_NESTING, "long parameter list": MAX_PARAMS, "duplicated block": MIN_CLONE_LINES, "large file": MAX_FILE_LINES, HOT_PATH: 1, - PLATFORM: 1, CORE_LIGHT: 1} + PLATFORM: 1, CORE_LIGHT: 1, UI_TYPE: 1, SPREAD: MAX_SPREAD, STACK: MAX_FRAME, RAW_ALLOC: 1} # The catalog move for each smell (Fowler, Refactoring), so a finding says how it is solved; the table is coding-standards § From a finding to a fix. FIXES = {"complex function": "a chain on one value becomes a table; a switch on a state becomes one method per state", "long function": "Extract Function: a named step a reader can skip", @@ -70,7 +84,11 @@ "large file": "Extract Class, Move Function: a module per concern", HOT_PATH: "Move the work off the render thread (a worker, a cached value, a deferred apply), or annotate a callee that cannot block", PLATFORM: "Move it behind a function in src/platform, and branch on platform_config.h with if constexpr", - CORE_LIGHT: "Dependency Inversion: core declares the interface, and the light domain implements and registers it"} + CORE_LIGHT: "Dependency Inversion: core declares the interface, and the light domain implements and registers it", + UI_TYPE: "Replace Conditional with Polymorphism: the module declares what the UI needs (a control, a role, a flag) and the UI reads it", + SPREAD: "Move Function, self-registration: a concept in one place, which callers reach through its role or an interface", + STACK: "Move the large local into a member or a ScratchBuffer, sized once, so no task's stack pays for it", + RAW_ALLOC: "Replace the allocation with a ScratchBuffer member, which owns, sizes and frees it"} def measure(extra=None): @@ -203,14 +221,62 @@ def boundary_rows() -> list: return rows +def registered_types() -> set: + """The factory's type names, from the one reader check_devices owns.""" + from check_devices import registered_types as read + return read() + + +_STRING_WORD_RE = re.compile(r"""(['"`])(\w+)\1""") + + +def ui_type_rows(types: set) -> list: + """Every registered type the browser code names in a string, less the containers the UI is built from.""" + rows = [] + for rel in owned_files(): + if not (rel.startswith("src/ui/") and rel.endswith(".js")): + continue + with open(ROOT / rel, encoding="utf-8", errors="replace") as f: + for n, line in enumerate(f, 1): + for m in _STRING_WORD_RE.finditer(line.split("//")[0]): + if m.group(2) in types and m.group(2) not in UI_CONTRACT: + rows.append((rel, UI_TYPE, f"line {n}: '{m.group(2)}'", 1)) + return rows + + +def spread_rows(types: set) -> list: + """Each type named in more than MAX_SPREAD files beyond its own, as a row on the header that declares it. + + A type's own files are the ones named for it (its header, its implementation, its tests), and the registry that lists every type is no one's. + """ + named = defaultdict(set) + home = {} + for rel in owned_files(): + text = (ROOT / rel).read_text(encoding="utf-8", errors="replace") + for word in set(re.findall(r"\b[A-Z]\w+\b", text)) & types: + named[word].add(rel) + # The header named for the type, else one defining it: a forward declaration ends in `;`. + if rel.startswith("src/") and rel.endswith(".h") and ( + Path(rel).stem == word or re.search(rf"\b(?:class|struct)\s+{word}\b(?!\s*;)", text)): + if Path(rel).stem == word or word not in home: + home[word] = rel + rows = [] + for t in sorted(named): + own = {f for f in named[t] if Path(f).stem == t or Path(f).stem.startswith(f"unit_{t}")} + others = named[t] - own - {"src/module_types.cpp"} + if len(others) > MAX_SPREAD: + rows.append((home.get(t, "src/module_types.cpp"), SPREAD, f"{t} in {len(others)} files", len(others))) + return rows + + class BuildMissing(Exception): """The desktop build the hot path is read from is absent or does not compile.""" -def hotpath_rows() -> list | None: - """One row per render-path call site that can block or allocate, a float conversion at a formatTo site included. +def build_warnings() -> str | None: + """The desktop build's saved compiler warnings, which the hot path and the stack frames are read from. - None when this host's compiler cannot measure it (no Clang 20 -Wfunction-effects), which skips the rule; BuildMissing when the build is absent or fails, which fails the run rather than reading as a clean tree. + None when this host's compiler cannot measure them (no Clang 20 -Wfunction-effects), which skips both rules; BuildMissing when the build is absent or fails, which fails the run rather than reading as a clean tree. """ import check_nonblocking as nb build_dir = nb.check_clang_tidy._host_build_dir() @@ -222,6 +288,12 @@ def hotpath_rows() -> list | None: out = nb.build_output(build_dir) if out is None: raise BuildMissing("The desktop build failed, so the hot path cannot be read.") + return out + + +def hotpath_rows(out: str) -> list: + """One row per render-path call site that can block or allocate, a float conversion at a formatTo site included.""" + import check_nonblocking as nb rows = [(r["file"], HOT_PATH, f"{r['callee']} in {r['fn'] or '?'}", 1) for r in nb.collect(out)] for site in nb.float_conversions_on_the_hot_path(): file, line = site.rsplit(":", 1) @@ -229,6 +301,47 @@ def hotpath_rows() -> list | None: return rows +_FRAME_RE = re.compile(r"^(?P[^:\n]+):(?P\d+):\d+: warning: stack frame size \((?P\d+)\) " + r"exceeds limit \(\d+\) in '(?P[^']*)' \[-Wframe-larger-than\]", re.M) + + +def stack_rows(out: str) -> list: + """One row per firmware function whose frame the compiler measured over MAX_FRAME, each template instance its own function.""" + owned = {r for r in owned_files() if r.startswith("src/")} + seen = {} + for m in _FRAME_RE.finditer(out): + path = m.group("path") + rel = os.path.relpath(path, ROOT) if os.path.isabs(path) else path + if rel not in owned: + continue # the SDK's headers and the vendored ones are not ours to shrink + fn = m.group("fn") + # Keyed on the whole signature, so two overloads of one name stay two functions. + name = fn.split("(")[0].split(" ")[-1].removeprefix("mm::") or fn or "?" + seen[(rel, fn)] = (rel, STACK, f"{name}, line {m.group('line')}", int(m.group("size"))) + return list(seen.values()) + + +# Buffer memory the light domain takes by hand: an array `new T[n]`, or a malloc-family or platform::alloc call. +# A factory's `new Peripheral()` is not one: it creates an object its owner holds, which is no buffer a ScratchBuffer could size. +# `new (std::nothrow) T[n]` is the same buffer; a placement `new (slot) T` constructs into memory someone else took. +_ALLOC_RE = re.compile(r"\bnew\s*(?:\(\s*std::nothrow\s*\)\s*)?[A-Za-z_][\w:<>]*\s*\[" + r"|\b(?:malloc|calloc|realloc|heap_caps_malloc|platform::alloc)\s*\(") + + +def raw_alloc_rows() -> list: + """Every hand allocation in src/light, which the architecture homes in a ScratchBuffer member; comments do not count.""" + rows = [] + for rel in owned_files(): + if not rel.startswith("src/light/") or rel.endswith(".js"): + continue + with open(ROOT / rel, encoding="utf-8", errors="replace") as f: + for n, line in enumerate(f, 1): + code = line.split("//")[0] + if _ALLOC_RE.search(code): + rows.append((rel, RAW_ALLOC, f"line {n}: {code.strip()[:60]}", 1)) + return rows + + def area(path: str) -> str: """The area a file counts under, the second directory level under src/.""" parts = path.split("/") @@ -366,15 +479,19 @@ def main() -> int: if report is None and not args.module: return 2 dup_rows, duplicated = clone_rows(report) if report else ([], None) - rows = findings(funcs) + dup_rows + large_files() + boundary_rows() + types = registered_types() + rows = (findings(funcs) + dup_rows + large_files() + boundary_rows() + ui_type_rows(types) + spread_rows(types) + + raw_alloc_rows()) # The hot path is a full rebuild, so the module view leaves it to the full report as it does clones; check_nonblocking --module is its detailed view. try: - hot = None if args.module else hotpath_rows() + warnings = None if args.module else build_warnings() except BuildMissing as e: print(e, file=sys.stderr) return 2 - if hot is not None: - rows += hot + hot = None + if warnings is not None: + hot = hotpath_rows(warnings) + rows += hot + stack_rows(warnings) if args.module: import check_clang_query @@ -391,7 +508,7 @@ def main() -> int: if hot is None: # A host without Clang 20's -Wfunction-effects cannot count the hot path: every other rule still ratchets, and the report keeps the last full count rather than losing that rule. print(f"SKIP {HOT_PATH}: not measurable on this host (it needs Clang 20+), so code.md is left as it is.") - base = {k: v for k, v in (base or {}).items() if k not in (HOT_PATH, "(total)")} or None + base = {k: v for k, v in (base or {}).items() if k not in (HOT_PATH, STACK, "(total)")} or None now = {k: v for k, v in counts(rows).items() if k != "(total)"} else: # The report is the artifact, written on every run: stdout scrolls away, the file is what the reviewer reads. diff --git a/moondeck/check/check_devices.py b/moondeck/check/check_devices.py index 017f59c16..4d034e0ae 100644 --- a/moondeck/check/check_devices.py +++ b/moondeck/check/check_devices.py @@ -91,7 +91,7 @@ def eth_preset_drift(): rows[m.group(1)] = [f.strip() for f in m.group(2).split(",")] # label -> the PHY constant naming that chip's arm of the ethConfigDefault ternary. - PAIRS = {"Classic RMII": "ethLan8720", "P4-NANO": "ethIp101", "S31 CoreBoard": "ethYt8531"} + PAIRS = {"Classic RMII": "ethLan8720", "P4 RMII": "ethIp101", "S31 RGMII": "ethYt8531"} # Every field `seedEthPresetFromPins` compares, or a drift it ignores reseeds a board to Custom. FIELDS = ["phyAddr", "mdc", "mdio", "rst", "rmiiClk", "rmiiClockExtIn"] # The preset table writes the PHY as its enum VALUE and the header names the constant. diff --git a/moondeck/check/check_docgen.py b/moondeck/check/check_docgen.py index c5c141a05..8f467f586 100755 --- a/moondeck/check/check_docgen.py +++ b/moondeck/check/check_docgen.py @@ -147,6 +147,8 @@ # (`lead N chars > M`), and the prefix alone also matched the word budget (`comment line N words`), # each a differently motivated rule silently demoted. _LINE_LENGTH_RULES = ("doc line ", "comment line ") +# Staged as the line-length cap is: a new rule over the sections written before it, so it counts and ratchets rather than stopping commits. +_UNREFERENCED_RULE = "unreferenced appendix section" # The one rule staged the OTHER way: an implementation file's hard wrap blocks too. The tree is at # zero, so nothing is held hostage, and a split sentence reflows every line it spans on the next # word change, which costs a reviewer the same in a `.cpp` as in a header. @@ -198,6 +200,8 @@ def _blocks(key: str, why: str = "") -> bool: return True if why.startswith(_LINE_LENGTH_RULES) and "chars >" in why: return False + if why.startswith(_UNREFERENCED_RULE): + return False return _generates_a_page(key.partition("::")[0]) @@ -404,7 +408,7 @@ def _details_tables(rel: str, text: str): "public function has no", "public variable has no", "second column", "file opens with //", "file lead missing", "@moreinfo on a member", "@xref", "code comment", "comment line", "doc line", "lead", - "appendix section", + "unreferenced appendix section", "appendix section", "class comment", "member comment", "no image", "image is", "one control", "description", "controls", "doc sentence", "hard wrap", ) @@ -427,6 +431,7 @@ def _details_tables(rel: str, text: str): "doc line": "over-wide doc lines", "lead": "over-long leads", "appendix section": "over-long appendix sections", + "unreferenced appendix section": "appendix sections nothing refers to", "@moreinfo on a member": "@moreinfo on a member", "@xref": "unresolved @xref", "no image": "cards with no image", @@ -1083,8 +1088,7 @@ def _header_rules(rel: str, text: str): # An `@xref{anchor}` names a heading on the SAME generated page. When the heading is renamed # the link resolves to nothing, and `_strip_unresolved_anchor_links` drops it silently: the # reader loses the cross-reference and no build says so. Checking it here is what makes the - # rename visible. The reverse (every section must be referenced) is NOT a rule: a `@moreinfo` - # section is an appendix a reader scrolls to, and 101 headers carry one that nothing links. + # rename visible. The reverse, a section nothing refers to, is `_unreferenced_sections`. _xref_resolves_rule(rel, lines, out) # PER SECTION, not per appendix. A whole-appendix cap punishes a file for having several @@ -1351,6 +1355,55 @@ def _orphan_pages(): for domain, name in sorted(pages - linked)] +def _appendix_sections(lines: list[str]) -> list[str]: + """The `## ` headings of a file's `@moreinfo` appendices, in order, skipping fenced examples as the section budget does.""" + out = [] + for i, ln in enumerate(lines): + if "@moreinfo" not in ln: + continue + fenced = False + for m in lines[i + 1:]: + if not m.lstrip().startswith("///"): + break + text = re.sub(r"^\s*///\s?", "", m).strip() + if text.startswith("```"): + fenced = not fenced + elif not fenced and text.startswith("## "): + out.append(text[3:].strip()) + return out + + +def _unreferenced_sections(): + """Appendix sections no `@xref` in their file and no link from a page or another source reaches. + + A section only a reader scrolling past finds is the first to cut: the appendix total falls by + removing what no reader is sent to. A link names the section as `moxygen/.md#`, + or as `.h#`, which the docs hook retargets at the generated page. + """ + link_re = re.compile(r"(?:moxygen/(\w+)\.md|\b(\w+)\.h)#([a-z0-9-]+)") + linked = set() + sources = [p for p in (ROOT / "docs").rglob("*.md") if "moxygen" not in p.parts] + sources += [ROOT / rel for rel in _headers()] + for path in sources: + for m in link_re.finditer(path.read_text(encoding="utf-8", errors="ignore")): + linked.add((m.group(1) or m.group(2), m.group(3))) + out = [] + for rel in _headers(): + # Only a page can be linked into; an implementation file's appendix is read in the file. + if _generates_a_page(rel.as_posix()): + out += _unreferenced_in(rel.as_posix(), (ROOT / rel).read_text(encoding="utf-8").split("\n"), linked) + return out + + +def _unreferenced_in(rel: str, lines: list[str], linked: set) -> list: + """One file's appendix sections that neither its own `@xref`s nor the `(stem, slug)` links in `linked` reach.""" + xrefs = {m.group(1).strip().rstrip("\\") for ln in lines for m in re.finditer(r"@xref\{([^}|]+)", ln)} + stem = Path(rel).stem + return [(f"{rel}::@moreinfo {section}", f"{_UNREFERENCED_RULE}: no @xref or link reaches it") + for section in _appendix_sections(lines) + if _slugify_heading(section) not in xrefs and (stem, _slugify_heading(section)) not in linked] + + def _duplicate_group_ids(): """Each `@defgroup` id is claimed by one header. @@ -1466,6 +1519,7 @@ def _violations(): out.extend(_header_rules(rel.as_posix(), (ROOT / rel).read_text(encoding="utf-8"))) out.extend(_duplicate_group_ids()) out.extend(_orphan_pages()) + out.extend(_unreferenced_sections()) return out @@ -1563,9 +1617,10 @@ def _write_report(found, appendix=None) -> None: if appendix is not None else []), "An error is in a file that generates a documentation page, a header or a catalog page, " "so the finding is a defect in what gets published and it fails the gate. A warning is " - "in an implementation file, which publishes nothing: its comments are a note to the " - "next reader, worth fixing without being worth stopping a commit for. Both are counted " - "here, because a warning nobody sees is a warning nobody fixes.", "", + "worth fixing without being worth stopping a commit for: any finding in an implementation " + "file, which publishes nothing, and the rules still staged as counts in a header too, the " + "over-wide line and the appendix section nothing refers to. Both are counted here, " + "because a warning nobody sees is a warning nobody fixes.", "", "The split is temporary. It stages the sweep rather than ranking the two kinds of " "comment, so when the warning column reaches zero it goes and every finding blocks.", "", "## By rule", "", "| Rule | Errors | Warnings |", "|---|---:|---:|"] @@ -1782,12 +1837,12 @@ def main() -> int: print(f" {rel}: {a} -> {b}") print(" Fix them, or say in the commit why the rule itself changed.\n") if errors: - _report(errors, "ERRORS, in files that generate a page. These fail the gate.") + _report(errors, "ERRORS. These fail the gate.") if warnings: if errors: print() - _report(warnings, "WARNINGS, in files that generate no page. Worth fixing, " - "not worth blocking a commit.") + # Not "files that generate no page": the staged rules (line length, unreferenced sections) warn in headers too. + _report(warnings, "WARNINGS. Worth fixing, not worth blocking a commit.") print("\nCut first. Move only what neither the code, the test name nor the spec says," "\nand prefer an existing home over a new appendix section." "\nRules: docs/contributing/documentation-standards.md § The card.") diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py index aeeea3f1e..480eb98f3 100644 --- a/moondeck/docs/screenshot_modules.py +++ b/moondeck/docs/screenshot_modules.py @@ -383,7 +383,7 @@ def set_control(host: str, module: str, control: str, value) -> bool: # Where each script extension lives in the repo, imported rather than restated: catalog_scripts.py # is the one home for the extension-to-role-to-folder mapping, and a second copy here would drift # the moment a role is added. -sys.path.insert(0, str(ROOT / "src" / "light" / "moonlive")) +sys.path.insert(0, str(ROOT / "src" / "core" / "moonlive")) from catalog_scripts import ROLE_BY_EXT, FOLDER_BY_ROLE # noqa: E402 SCRIPT_SRC = {ext: FOLDER_BY_ROLE[role] for ext, role in ROLE_BY_EXT.items()} diff --git a/moondeck/moonlive/check_encodings.py b/moondeck/moonlive/check_encodings.py index 2f3546b03..e81a0adfe 100644 --- a/moondeck/moonlive/check_encodings.py +++ b/moondeck/moonlive/check_encodings.py @@ -52,6 +52,8 @@ ("l32i a11, a1, 128", "b22120", "spillLoad: reload the arena pointer"), ("s32i a10, a1, 32", "a26108", "call: park the result"), ("addi a9, a1, 60", "92c13c", "slotAddr: the address of an argument block"), + ("addmi a9, a1, 256", "92d101", "slotAddr: a slot past byte 127 takes the 256-byte step first"), + ("addi a9, a9, -124", "92c984", "slotAddr: then the signed rest, so byte 132 is reached"), # Arithmetic, narrow and wide forms. ("add.n a2, a2, a13", "da22", "add: the narrow two-byte form"), ("mull a9, a2, a11", "b09282", "mul"), diff --git a/mooninstaller/deviceModels.json b/mooninstaller/deviceModels.json index 3f5dfd78a..91d71aaff 100644 --- a/mooninstaller/deviceModels.json +++ b/mooninstaller/deviceModels.json @@ -765,7 +765,7 @@ "id": "Ethernet", "parent_id": "Network", "controls": { - "ethBoard": "P4-NANO" + "ethBoard": "P4 RMII" } } ] @@ -1000,7 +1000,7 @@ "id": "Ethernet", "parent_id": "Network", "controls": { - "ethBoard": "P4-NANO" + "ethBoard": "P4 RMII" } }, { @@ -1068,7 +1068,7 @@ "id": "Ethernet", "parent_id": "Network", "controls": { - "ethBoard": "P4-NANO" + "ethBoard": "P4 RMII" } }, { @@ -1125,7 +1125,7 @@ "id": "Ethernet", "parent_id": "Network", "controls": { - "ethBoard": "P4-NANO" + "ethBoard": "P4 RMII" } }, { @@ -1215,7 +1215,7 @@ "id": "Ethernet", "parent_id": "Network", "controls": { - "ethBoard": "P4-NANO" + "ethBoard": "P4 RMII" } } ] @@ -1265,7 +1265,7 @@ "id": "Ethernet", "parent_id": "Network", "controls": { - "ethBoard": "S31 CoreBoard" + "ethBoard": "S31 RGMII" } }, { diff --git a/moonlive/effects/stadbeest-eyes.mle b/moonlive/effects/stadbeest-eyes.mle index 1d19ca100..213b47e84 100644 --- a/moonlive/effects/stadbeest-eyes.mle +++ b/moonlive/effects/stadbeest-eyes.mle @@ -1,51 +1,135 @@ -// StadBeestEyes: a frog's eye. Golden iris, black slit pupil, and it looks around. +// StadBeestEyes: a frog's eye on a ring disc. A wide black pupil in a palette iris; it darts its gaze, blinks, and with music darts on the beat, sends a ripple out from the pupil and dilates with the volume. class StadBeestEyesEffect { - byte hue = 30; - byte pupil = 70; - byte wander = 40; - byte bpm = 7; - byte audioReactive = 0; + byte bpm = 20; + byte sensitivity = 128; + bool pulse = true; + byte pupil = 100; + byte look = 100; + byte blink = 128; + + int lastBeat = -100000; // when the last beat started, in ms + int lastDart = 0; // when the gaze last darted, in ms + int blinkAt = 3000; // when the next blink starts, in ms + int steps = 0; // darts so far, each moving the colors on + int tx = 0; // where the gaze darts to, in lights from the center + int ty = 0; + bool wasOnBeat = false; // whether the last frame was on a beat, so one beat counts once + byte flash = 0; // the beat's flash, fading int dimensions() { return 2; } - string tags() { return "💫🎶"; } + string tags() { return "💫🎵🎶"; } void defineControls() { - addControl("hue", hue, 0, 255); // iris color: 30 is amber, 60 green, 180 blue - addControl("pupil", pupil, 10, 200); // how wide the slit opens - addControl("wander", wander, 0, 100); // how far the eye looks around - addControl("bpm", bpm, 1, 60); // how restless the gaze is - addControl("audioReactive", audioReactive, 0, 1); // the pupil dilates on sound + addControl("bpm", bpm, 1, 120); // darts a minute without music + addControl("sensitivity", sensitivity, 0, 255); // with music, dart on its beat and dilate with its loudness; 0 ignores the music + addControl("pulse", pulse, 0, 1); // with music, the eye flashes on its beat + addControl("pupil", pupil, 10, 200); // how wide the pupil is + addControl("look", look, 0, 200); // how far the gaze darts + addControl("blink", blink, 0, 255); // how often the eye blinks; 0 never } - void tick() { - int gazeX = div((beatsin(bpm, t, 255) - 128) * wander, 400); - int gazeY = div((beatsin(bpm + 3, t, 255) - 128) * wander, 400); + // A new gaze target: from the music's dominant pitch while it plays, so two eyes hearing the same music look the same way. + void dart(int music) { + lastDart = t; + steps = steps + 1; + int pick = random16(45); + if (music == 1) { + int hz = audioPeakHz(); + pick = mod(hz * 37, 45); + } + int col = mod(pick, 9) - 4; + int row = div(pick, 9) - 2; + tx = div(col * look, 100); + ty = div(row * look, 100); + } + + // How far the lids are closed, in lights from the middle: a blink closes and opens within 300 ms. + int lids() { + if (blink == 0) { return 0; } + if (t > blinkAt + 300) { + int wait = random16(6000); + blinkAt = t + 1500 + div(wait * (256 - blink), 128); + } + // A triangle over 300 ms, peaking at 150: the distance from the peak, turned into closing, and zero outside it. + int closing = 150 - polarR(t - blinkAt - 150, 0); + closing = div(closing + polarR(closing, 0), 2); + int half = div(height, 2); + return div(closing * half, 150); + } - int open = pupil; - if (audioReactive == 1) { open = pupil + div(audioLevel() * 90, 256); } + // Listens for the beat: it flashes, and with music darts the gaze; without music the gaze darts at bpm. Returns whether music plays. + int listen() { + int heard = audioBeat(); + if (sensitivity == 0) { heard = 0; } + flash = flash - div(flash + 15, 16); + // A rising edge is a beat, unless it follows the last one too closely. + if (heard > wasOnBeat) { + if (t - lastBeat > 250) { + lastBeat = t; + flash = 255; + dart(1); + } + } + wasOnBeat = heard; + int music = 0; + if (t - lastBeat < 2000) { music = 1; } + if (music == 0) { + if (t - lastDart > div(60000, bpm)) { dart(0); } + } + return music; + } - int cx = div(width, 2) + gazeX; - int cy = div(height, 2) + gazeY; + void tick() { + fill(0, 0, 0); + int cy = div(height, 2); + int music = listen(); + + int closed = lids(); + int heard = 0; + if (music == 1) { heard = audioSmooth() * sensitivity; } + // A wide pupil, half as high as it is wide, dilating with the volume: one division for both. + int a = 2 + div(pupil * 246 + heard, 8200); + int b = div(a + 1, 2); + int ab = a * a * b * b; + // A ripple runs from the pupil to the rim in 400 ms after each beat. + int lo = 0; + int hi = 0; + if (music == 1) { + int rip = div((t - lastBeat) * 20, 400); + lo = (rip - 2) * (rip - 2); + hi = (rip + 2) * (rip + 2); + } + int pul = 256; + if (pulse * music == 1) { pul = 64 + div(flash * 3, 4); } + // The colors move on a quarter palette with each dart, while the gaze travels. + int hue = div((t - lastDart) * 64, 150); + if (hue > 64) { hue = 64; } + hue = hue + steps * 64; + // The gaze jumps straight to its target: a dart rather than a drift. + int ox = div(width, 2) + tx; + int oy = cy + ty; for (int y = 0; y < height; y = y + 1) { - for (int x = 0; x < width; x = x + 1) { - int dx = x - cx; - int dy = y - cy; - int r = polarR(dx, dy); - - int slit = polarR(dx, dy * 5); - - if (slit < div(open, 16)) { - setRGB(y * width + x, 0, 0, 0); // the pupil: genuinely black - } else { - int a = polarA(dx, dy); - int fibre = div(sin(a * 12) + 32768, 512); - int bright = 255 - div(r * 9, 16) + div(fibre, 3); - if (bright < 30) { bright = 30; } - if (bright > 255) { bright = 255; } - setPaletteColor(x, y, hue + div(r, 3), bright); + if (polarR(y - cy, 0) < cy - closed) { + int dy = y - oy; + for (int x = 0; x < width; x = x + 1) { + int dx = x - ox; + int r2 = dx * dx + dy * dy; + if (dx * dx * b * b + dy * dy * a * a >= ab) { + int idx = hue + 128; + int bright = 50; + // The iris moves with the gaze; beyond it lies the dim white of the eye. + if (r2 < 169) { + idx = hue + div(r2 * 128, 169); + bright = 220; + } + if ((r2 - lo) * (hi - r2) > 0) { bright = 255; } + int c = mod(idx, 256); + int v = div(bright * pul, 256); + setPaletteColor(x, y, c, v); + } } } } diff --git a/moonlive/effects/stadbeest-legs.mle b/moonlive/effects/stadbeest-legs.mle index 3347888ca..c695985dc 100644 --- a/moonlive/effects/stadbeest-legs.mle +++ b/moonlive/effects/stadbeest-legs.mle @@ -1,50 +1,132 @@ -// StadBeestLegs: a crawl travelling down ten legs, each offset so the rig reads as walking. +// StadBeestLegs: a walk in waves, each side's legs lifting one after another from back to front, the two sides half a cycle apart; with music it steps on the beat and lifts with the volume. class StadBeestLegsEffect { - byte hue = 100; - byte bpm = 12; - byte spread = 60; - byte tail = 40; - byte audioReactive = 0; + byte bpm = 60; + byte swing = 77; + byte lift = 186; + byte stand = 64; + byte sensitivity = 128; + bool pulse = true; + + int lastBeat = -100000; // when the last beat started, in ms + int beatMs = 500; // the music's beat, measured + int steps = 0; // beats stepped so far + bool wasOnBeat = false; // whether the last frame was on a beat, so one beat steps once + byte flash = 0; // the beat's flash, fading int dimensions() { return 2; } - string tags() { return "💫🎶"; } + string tags() { return "💫🎵"; } void defineControls() { - addControl("hue", hue, 0, 255); // base color: 100 is a sickly green, 200 violet - addControl("bpm", bpm, 1, 60); // how fast the pulses travel down a leg - addControl("spread", spread, 0, 255); // how far apart the legs are in the gait - addControl("tail", tail, 5, 120); // how long a pulse's trail is - addControl("audioReactive", audioReactive, 0, 1); // each leg takes its own frequency band + addControl("bpm", bpm, 1, 255); // twice this many legs lift a minute on each side; with music, more or fewer to a beat + addControl("swing", swing, 1, 255); // how much of a cycle a leg is in the air + addControl("lift", lift, 0, 255); // how far a foot lifts, as a share of the leg + addControl("stand", stand, 0, 255); // the brightness of a leg on the ground + addControl("sensitivity", sensitivity, 0, 255); // with music, step on its beat and lift with its loudness; 0 ignores the music + addControl("pulse", pulse, 0, 1); // with music, the lights flash on its beat + } + + // One leg lit from its top down to the foot, `len` lights long, at `level` of 256, its colors anchored to the foot: `hue` there and half the palette up to the top. + void drawLeg(int leg, int len, int level, int hue) { + int n = 10; + if (n > width) { n = width; } + int gap = 1; + if (n > 1) { gap = div(width - 1, n - 1); } + int x = leg * gap; + for (int y = 0; y < len; y = y + 1) { + int d = len - y; + int bright = level - 1; + if (d < 8) { bright = div(d * bright, 8); } + setPaletteColor(x, y, mod(hue + div(d * 128, height), 256), bright); + } } void tick() { - for (int leg = 0; leg < width; leg = leg + 1) { - int phase = div(leg * spread, 4); + fill(0, 0, 0); + // Ten legs, right front to right back then left back to left front. + int n = 10; + if (n > width) { n = width; } + int half = div(n, 2); + if (half < 1) { half = 1; } + int per = div(256, half); - int drive = 255; - if (audioReactive == 1) { - drive = audioBand(div(leg * 16, width)) + 40; - if (drive > 255) { drive = 255; } + // A beat lifts the next leg, and the time between beats sets the tempo. + int onBeat = audioBeat(); + // The flash fades by a sixteenth each frame, out before the next beat. + flash = flash - div(flash + 15, 16); + if (onBeat == 1) { + if (wasOnBeat == 0) { + int gapMs = t - lastBeat; + if (gapMs > 250) { + if (gapMs < 1500) { beatMs = div(beatMs * 3 + gapMs, 4); } + lastBeat = t; + steps = steps + 1; + flash = 255; + } } + } + wasOnBeat = onBeat; - int head = div(beat(bpm, t + phase) * height, 65536); - int shimmer = div(noise(leg * 900, 0, t), 6); - - for (int y = 0; y < height; y = y + 1) { - int behind = y - head; - if (behind < 0) { behind = behind + height; } - - int bright = 0; - if (behind < tail) { bright = 255 - div(behind * 255, tail); } + // One clock, 256 to a full cycle in which every leg lifts once. + int stepMs = div(30000, bpm); + int clock = div(t, stepMs) * per + div(mod(t, stepMs) * per, stepMs); + int up = div(height * lift, 256); + int music = 0; + int loud = 0; + if (sensitivity > 0) { + if (t - lastBeat < 2000) { music = 1; } + } + if (music == 1) { + int into = div((t - lastBeat) * per, beatMs); + if (into > per) { into = per; } + // A leg a beat around bpm 60, halving or doubling from there, so the legs stay on the beat. + clock = steps * per + into; + if (bpm < 45) { + clock = div(clock, 2); + if (bpm < 23) { clock = div(clock, 2); } + } + if (bpm >= 90) { + clock = clock * 2; + if (bpm >= 180) { clock = clock * 2; } + } + // Louder lifts higher, from the set lift towards the whole leg. + loud = div(audioSmooth() * sensitivity, 64); + if (loud > 255) { loud = 255; } + up = up + div((height - up) * loud, 512); + } + int air = swing; - bright = bright + shimmer - div(y * 12, height); - if (bright > 255) { bright = 255; } - bright = div(bright * drive, 256); + for (int leg = 0; leg < n; leg = leg + 1) { + // Counted from the back of its side, so the wave runs back to front. + int side = 0; + int fromFront = leg; + if (leg >= half) { + side = 1; + fromFront = n - 1 - leg; + } + int back = half - 1 - fromFront; + int q = clock + 1024 - back * per - side * 128; + int p = mod(q, 256); - setPaletteColor(leg, y, hue + div(y * 40, height), bright); + // In the air the foot follows a half sine: lifting, then setting down, brighter than the legs it stands on. + int len = height; + int level = stand; + int ramp = 64; + if (p < air) { + ramp = div(p * 64, air); + int rise = sin(div(p * 32768, air)) - 32768; + len = height - div(up * rise, 32767); + if (len < 1) { len = 1; } + level = stand + div((256 - stand) * rise, 32767); + } + // Pulsing, a beat flashes to full brightness and fades to a quarter. + if (pulse == 1) { + if (music == 1) { level = div(level * (64 + div(flash * 3, 4)), 256); } } + // A leg's colors move only while it is in the air, a quarter palette a step, so every leg passes through the whole palette. + int hue = mod(div(q, 256) * 64 + ramp + 4096 - back * per * 2 - side * 64, 256); + drawLeg(leg, len, level, hue); } } } diff --git a/moonlive/services/stadbeest-autopilot.mls b/moonlive/services/stadbeest-autopilot.mls new file mode 100644 index 000000000..ce05eda73 --- /dev/null +++ b/moonlive/services/stadbeest-autopilot.mls @@ -0,0 +1,65 @@ +// StadBeestAutopilot: runs the StadBeest unattended. Every few minutes a new scene picks a palette, a mood and the eyes' character; within a scene the energy breathes once a minute. It moves the control surface, so the legs follow here and the eyes over OSC. + +class StadBeestAutopilot { + bool run = true; + byte minutes = 3; + + int scene = -1; // the scene running, counted from boot + int mood = 128; // the scene's energy, 0 calm to 255 wild + int palette = 0; // the scene's palette + int pulseOn = 0; // whether the scene flashes on the beat + int pupil = 100; // the eyes' pupil size this scene + int blinks = 128; // how often the eyes blink this scene + int lastGlide = -100000; // when the slots were last written, in ms + int nextSlot = 8; // the slot written next; 8 means all are written + + void defineControls() { + addControl("run", run, 0, 1); // off hands the beast back to people and desks + addControl("minutes", minutes, 1, 30); // how long a scene lasts + } + + // The energy around the scene's mood, breathing once a minute. + int energy() { + int phase = div(mod(t, 60000) * 1092, 1000); + int e = mood + div((sin(phase) - 32768) * 48, 32768); + if (e < 0) { e = 0; } + if (e > 255) { e = 255; } + return e; + } + + // One slot per call: the scene's choices, and the energy spread over tempo, swing, lift and how far the eyes look. + void write(int k) { + int e = energy(); + if (k == 0) { setControl("encoder1", palette); } + if (k == 1) { setControl("switch2", pulseOn); } + if (k == 2) { setControl("encoder2", pupil); } + if (k == 3) { setControl("encoder4", blinks); } + if (k == 4) { setControl("fader2", 20 + div(e * 70, 255)); } + if (k == 5) { setControl("fader4", 60 + div(e * 60, 255)); } + if (k == 6) { setControl("fader5", 120 + div(e * 100, 255)); } + if (k == 7) { setControl("encoder3", 50 + div(e, 2)); } + } + + void tick20ms() { + if (run == 0) { return; } + int now = div(t, minutes * 60000); + if (now != scene) { + scene = now; + palette = random16(13); + mood = random16(256); + pulseOn = random16(2); + pupil = 80 + random16(80); + blinks = 60 + random16(140); + lastGlide = -100000; + } + // Every ten seconds all eight slots, one per tick: the eyes hear them over WiFi multicast, which drops a burst. + if (t - lastGlide >= 10000) { + lastGlide = t; + nextSlot = 0; + } + if (nextSlot < 8) { + write(nextSlot); + nextSlot = nextSlot + 1; + } + } +} diff --git a/src/core/module/Control.h b/src/core/module/Control.h index a7f9f2d4d..81d60089e 100644 --- a/src/core/module/Control.h +++ b/src/core/module/Control.h @@ -1,6 +1,7 @@ #pragma once #include "core/util/format.h" // formatTo: nonblocking formatting into a fixed buffer +#include "core/util/Ipv4.h" // parseDottedQuad: an IPv4 control's text #include #include #include @@ -12,24 +13,6 @@ namespace mm { -/// Parse "A.B.C.D" into four octets, returning false on anything else; `out` is written only on success, as `inet_pton` does. -inline bool parseDottedQuad(const char* s, uint8_t out[4]) { - if (!s) return false; - uint8_t q[4]; - const char* p = s; - for (int idx = 0; idx < 4; idx++) { - char* end = nullptr; - long v = std::strtol(p, &end, 10); - if (end == p || v < 0 || v > 255) return false; - q[idx] = static_cast(v); - // Three dots between the octets, and nothing after the last (e.g. "1.2.3.4x" fails). - if (*end != (idx == 3 ? '\0' : '.')) return false; - p = end + 1; - } - std::memcpy(out, q, 4); - return true; -} - /// Format four octets as "A.B.C.D", which sixteen bytes always fits. inline void formatDottedQuad(char out[16], const uint8_t ip[4]) { mm::formatTo(out, 16, "%u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); diff --git a/src/core/module/MoonModule.h b/src/core/module/MoonModule.h index 86dd95b34..04fe19d31 100644 --- a/src/core/module/MoonModule.h +++ b/src/core/module/MoonModule.h @@ -13,6 +13,13 @@ namespace mm { /// A module's role, which identifies its type without RTTI and drives the UI's rendering. enum class ModuleRole : uint8_t { Generic, Effect, Modifier, Driver, Layout, Layer, Service }; +/// Which axes a module works in, so the layer can extrude lower-dimensional output across the rest. +enum class Dim : uint8_t { + D1 = 1, + D2 = 2, + D3 = 3, +}; + /// The lowercase role name, so the string cannot drift between one API route and another. inline const char* roleName(ModuleRole role) { switch (role) { @@ -56,8 +63,9 @@ inline const char* roleName(ModuleRole role) { /// Each module reports its instance size, its heap, and its tick time. class MoonModule { public: + // noexcept, so a failed allocation yields nullptr rather than a constructor running on null. /// Allocate in PSRAM where the platform offers it. - void* operator new(size_t size) { return platform::alloc(size); } + void* operator new(size_t size) noexcept { return platform::alloc(size); } /// Return the allocation above. void operator delete(void* ptr) noexcept { platform::free(ptr); } @@ -191,9 +199,6 @@ class MoonModule { /// Build this node's derived state for the current controls, the acquire half of the lifecycle. virtual void prepare() {} - /// Read the first output light as RGB, or false where this module has no output. - virtual bool firstOutputRgb(uint8_t /*out*/[3]) const { return false; } - /// A name's buffer, terminator included: the longest stripped type name with headroom; setName truncates past it. static constexpr uint8_t kNameLen = 16; /// This module's human label, which the user may rename. @@ -308,6 +313,9 @@ class MoonModule { /// A file changed through the API or a restore; a module that keeps something read from files updates what concerns it. virtual void onFileChanged(const char* /*path*/) {} + /// A list control's rows changed in `owner`; a module that resolves something from that list re-resolves it. + virtual void onListChanged(const MoonModule& /*owner*/) {} + /// Whether the user may delete or replace this module, which a load-bearing child declines. virtual bool userEditable() const { return true; } diff --git a/src/core/module/Scheduler.cpp b/src/core/module/Scheduler.cpp index e979b4efc..6734add44 100644 --- a/src/core/module/Scheduler.cpp +++ b/src/core/module/Scheduler.cpp @@ -165,16 +165,23 @@ void Scheduler::prepareTree() { } namespace { -void notifyTree(MoonModule* m, const char* path) { - m->onFileChanged(path); +// One walk for every tree-wide notification, so each hook reaches the same set of modules. +template +void forEachInTree(MoonModule* m, F&& f) { + f(*m); for (uint8_t i = 0; i < m->childCount(); i++) - if (MoonModule* c = m->child(i)) notifyTree(c, path); + if (MoonModule* c = m->child(i)) forEachInTree(c, f); } } // namespace void Scheduler::notifyFileChanged(const char* path) { for (uint8_t i = 0; i < moduleCount_; i++) - if (modules_[i]) notifyTree(modules_[i], path); + if (modules_[i]) forEachInTree(modules_[i], [path](MoonModule& m) { m.onFileChanged(path); }); +} + +void Scheduler::notifyListChanged(const MoonModule& owner) { + for (uint8_t i = 0; i < moduleCount_; i++) + if (modules_[i]) forEachInTree(modules_[i], [&owner](MoonModule& m) { m.onListChanged(owner); }); } void Scheduler::deleteTree(MoonModule* mod) { diff --git a/src/core/module/Scheduler.h b/src/core/module/Scheduler.h index 27460e7ee..f2c9a3544 100644 --- a/src/core/module/Scheduler.h +++ b/src/core/module/Scheduler.h @@ -54,6 +54,9 @@ class Scheduler { /// Tell every module in the tree that a file changed, so one that keeps something read from files updates it. void notifyFileChanged(const char* path); + /// Tell every module in the tree that `owner`'s list rows changed, so one resolving from that list re-resolves. + void notifyListChanged(const MoonModule& owner); + /// Register a top-level module, which the boot then walks in declared order. void addModule(MoonModule* mod); /// Run the boot phases over every registered module. diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index fb186008e..3f759b5d9 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -122,7 +122,7 @@ struct BuiltinTable { // The byte budget is what the arena allocates, where the count is what the record tables hold. /// Bytes the script's own members may occupy. static constexpr uint8_t kCtrlBytes = 64; // arena bytes the script's members share -static constexpr uint8_t kMaxCtrls = 8; // records: how many members/controls may exist +static constexpr uint8_t kMaxCtrls = 16; // records: how many members/controls may exist // The sanity bound, not the working limit: a compile sizes its staging from the token count. static constexpr size_t kCodeCap = 16384; diff --git a/src/core/moonlive/MoonLiveBuiltins_common.h b/src/core/moonlive/MoonLiveBuiltins_common.h index 2b9ca7571..228dd88cf 100644 --- a/src/core/moonlive/MoonLiveBuiltins_common.h +++ b/src/core/moonlive/MoonLiveBuiltins_common.h @@ -1,6 +1,8 @@ #pragma once +#include "core/moonlive/MoonLive.h" // runDefineControls drives the engine #include "core/moonlive/MoonLiveBuiltins.h" +#include "core/util/ThreadSlot.h" // the per-thread table the control sink lives in #include "core/util/math8.h" // beatsin16: the shared time vocabulary #include "core/util/math16.h" // beat16 / sin16 / cos16: full-range waveforms #include "core/util/noise.h" // inoise8: the shared gradient-noise field @@ -138,6 +140,57 @@ extern "C" inline uint32_t mm_ml_print(const uintptr_t* args, uint32_t, const ui return v; } +// A builtin has no receiver, so the binding installs one for the run. +/// Where a running `defineControls()` sends each `addControl`. +using AddControlFn = void (*)(void* ctx, const char* name, uint8_t offset, + int32_t lo, int32_t hi, CtrlType type); +/// The control sink one thread's run installed. +struct AddControlSink { AddControlFn fn = nullptr; void* ctx = nullptr; }; + +namespace detail { +/// One thread's control sink, in the slot it claims. +struct ControlSlot { std::atomic owner{0}; AddControlSink sink; }; +// constinit rather than a function-local static, whose thread-safe guard is a lock. +inline constinit ControlSlot gControlSlots[2]{}; +} // namespace detail + +/// The control sink for this thread, or an empty one; reading claims no slot. +inline const AddControlSink& addControlSink() { + detail::ControlSlot* s = ownedThreadSlot(detail::gControlSlots, false); + static constinit AddControlSink none{}; + return s ? s->sink : none; +} + +// False when the table is full, which the caller must not treat as an installed sink. +/// Point addControl at a consumer for one defineControls run; nullptr to detach. +inline bool setAddControlSink(AddControlFn fn, void* ctx) { + detail::ControlSlot* s = ownedThreadSlot(detail::gControlSlots, fn != nullptr); + if (!s) return false; + s->sink = {fn, ctx}; + if (!fn) releaseThreadSlot(s); + return true; +} + +// The compiler packs args[1]: the low byte is the arena offset, the next the type. +/// Surface a member as a control within a range; its declared type decides the kind. +extern "C" inline uint32_t mm_ml_addControl(const uintptr_t* args, uint32_t, const uint8_t*) { + // The name points into the compiled program's string pool, which outlives the run. + const char* name = reinterpret_cast(args[0]); + const CtrlType type = static_cast((args[1] >> 8) & 0xff); + const AddControlSink s = addControlSink(); + if (!name || !s.fn || !s.ctx) return 0; // no binding listening: the call is a no-op + // An arbitrary expression can exceed the member's type, and a wrapped slider top is invisible. + const int32_t lo = int32_t(args[2]), hi = int32_t(args[3]); + const int32_t limit = (type == CtrlType::Byte) ? 255 : (type == CtrlType::Bool) ? 1 : INT32_MAX; + if (lo > limit || hi > limit) return 0; + // A byte and a bool are unsigned, so a negative low bound became min 251 with max 100. + if ((type == CtrlType::Byte || type == CtrlType::Bool) && lo < 0) return 0; + // With min above max the write path rejects every value the slider could offer. + if (lo > hi) return 0; + s.fn(s.ctx, name, static_cast(args[1] & 0xff), lo, hi, type); + return 0; +} + // Both vocabularies call this first and then add their own, so a name means one thing everywhere. /// Register the neutral builtins into whatever table asks. inline void addCommonBuiltins(BuiltinTable& t) { @@ -159,6 +212,34 @@ inline void addCommonBuiltins(BuiltinTable& t) { t.add({"random16", 1, /*returns*/ true, BuiltinKind::Call, &mm_ml_random16, {}}); // print: the script author's only debugger. t.add({"print", 1, /*returns*/ true, BuiltinKind::Call, &mm_ml_print, {}}); + // addControl declares a setting; bit 1 of byRef passes the member's offset and type, not its value. + t.add({"addControl", 4, /*returns*/ false, BuiltinKind::Call, &mm_ml_addControl, {}, + /*byRef*/ 0x2, /*byStr*/ 0x1}); +} + +// Run once after a successful compile, where a compiled module's defineControls() sits. +/// The entry a binding runs so the controls a script declares exist. +inline constexpr const char* kEntryDefineControls = "defineControls"; + +/// What a binding attaches around a defineControls run, for the sizing its own builtins ask for. +using DefineHook = void (*)(void* ctx, bool attach); + +// Re-runnable like its compiled counterpart, since the declared list is cleared first. +/// Run a script's defineControls, so the controls it declares exist. +inline void runDefineControls(MoonLive& engine, DefineHook hook = nullptr, void* hookCtx = nullptr) { + if (!engine.hasEntry(kEntryDefineControls)) return; // nothing to clear and nothing to run + // Install before clearing, since a clear-then-run with the table full would drop every control. + if (!setAddControlSink([](void* ctx, const char* n, uint8_t off, + int32_t lo, int32_t hi, CtrlType type) { + static_cast(ctx)->addDeclaredControl(n, off, lo, hi, type); + }, &engine)) return; + if (hook) hook(hookCtx, true); + engine.clearDeclaredControls(); // re-runnable: rebuild rather than append + // This entry point writes no pixels, but `run` refuses a null or undersized buffer. + uint8_t scratch[3] = {}; + engine.run(scratch, 1, 3, 0, kEntryDefineControls); + if (hook) hook(hookCtx, false); + setAddControlSink(nullptr, nullptr); } /// @} diff --git a/src/core/moonlive/MoonLiveBuiltins_service.h b/src/core/moonlive/MoonLiveBuiltins_service.h index ed52520fe..bc3bf5ebf 100644 --- a/src/core/moonlive/MoonLiveBuiltins_service.h +++ b/src/core/moonlive/MoonLiveBuiltins_service.h @@ -5,7 +5,6 @@ #include "core/module/Scheduler.h" #include "platform/platform.h" // gpioInputBegin / gpioRead / gpioWrite #include "core/moonlive/MoonLiveBuiltins_common.h" // the neutral half: math, waveforms, print -#include "light/moonlive/MoonLiveBuiltins_light.h" // addControl, whose sinks stay there #include #include @@ -111,7 +110,7 @@ inline const BuiltinTable& serviceBuiltins() { // Built once and reused: the table is around 2 KB by value and never changes after registration. static const BuiltinTable table = [] { BuiltinTable t; - // The neutral half: a service gets sin, noise, beat and print, because none of it is about light. + // The neutral half: a service gets sin, noise, beat, print and addControl, because none of it is about light. addCommonBuiltins(t); // gpioRead(pin) -> 0/1. The input half: any switch, PIR, or level a pin can carry. t.add({"gpioRead", 1, /*returns*/ true, BuiltinKind::Call, &mm_service_gpioRead, {}}); @@ -126,9 +125,6 @@ inline const BuiltinTable& serviceBuiltins() { // setControl(name, v) -> 0/1, where byStr 0x1 marks the first argument as a quoted name. t.add({"setControl", 2, /*returns*/ true, BuiltinKind::Call, &mm_service_setControl, {}, /*byRef*/ 0, /*byStr*/ 0x1}); - // addControl(name, member, min, max) -> declare a setting, as an effect script does. - t.add({"addControl", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_addControl, {}, - /*byRef*/ 0x2, /*byStr*/ 0x1}); return t; }(); return table; diff --git a/src/light/moonlive/MoonLiveScript.h b/src/core/moonlive/MoonLiveScript.h similarity index 92% rename from src/light/moonlive/MoonLiveScript.h rename to src/core/moonlive/MoonLiveScript.h index bfbfeb48a..b36002b76 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/core/moonlive/MoonLiveScript.h @@ -1,8 +1,8 @@ #pragma once #include "core/module/MoonModule.h" -#include "light/moonlive/MoonLiveBuiltins_light.h" -#include "light/moonlive/MoonLiveScriptFile.h" +#include "core/moonlive/MoonLiveBuiltins_common.h" // runDefineControls +#include "core/moonlive/MoonLiveScriptFile.h" #include @@ -34,16 +34,12 @@ class MoonLiveScript { /// Said instead when the shipped copy has moved on since the fork. static constexpr const char* kStaleMark = "edited copy, shipped one updated"; - /// Let a binding that owns a particle pool size it from the script's defineControls(). - void setPoolSizer(PoolSizeFn fn, void* ctx) { sizePool_ = fn; poolCtx_ = ctx; } - - /// The same for a trail plane, which only a script asking with `trail(1)` pays for. - void setTrailSizer(TrailSizeFn fn, void* ctx) { sizeTrail_ = fn; trailCtx_ = ctx; } + /// Let a binding attach its own sinks around the script's defineControls(), such as a pool it sizes. + void setDefineHook(DefineHook fn, void* ctx) { defineHook_ = fn; defineCtx_ = ctx; } // True means a new program was installed, which a modifier turns into a Layer rebuild. /// Re-read the file and recompile when its content hash moved. - bool sync(const SysVarTable& sysvars, MoonModule& owner, - const BuiltinTable& builtins = lightBuiltins()) { + bool sync(const SysVarTable& sysvars, MoonModule& owner, const BuiltinTable& builtins) { // Cheapest question first, since this runs on every prepare sweep and must not compile. uint32_t fileHash = 0; const bool readable = scriptFileHash(name_, fileHash); @@ -59,7 +55,7 @@ class MoonLiveScript { uint32_t hash = 0; if (compileScriptFile(engine_, name_, builtins, sysvars, err, &hash)) { // By running defineControls(), the way a compiled module does. - runDefineControls(engine_, sizePool_, poolCtx_, sizeTrail_, trailCtx_); + runDefineControls(engine_, defineHook_, defineCtx_); // What the script says it is, read once per compile rather than per frame. readIdentity(); // How big the program is and which budget it is closest to, which the card cannot say. @@ -221,10 +217,8 @@ class MoonLiveScript { char failedScript_[kMaxScriptName + 1] = ""; size_t reportedBytes_ = 0; // what this script last added to the owner's total - PoolSizeFn sizePool_ = nullptr; - void* poolCtx_ = nullptr; - TrailSizeFn sizeTrail_ = nullptr; - void* trailCtx_ = nullptr; + DefineHook defineHook_ = nullptr; + void* defineCtx_ = nullptr; }; } // namespace mm::moonlive diff --git a/src/light/moonlive/MoonLiveScriptFile.h b/src/core/moonlive/MoonLiveScriptFile.h similarity index 79% rename from src/light/moonlive/MoonLiveScriptFile.h rename to src/core/moonlive/MoonLiveScriptFile.h index 206e2f908..1bb2ad2d8 100644 --- a/src/light/moonlive/MoonLiveScriptFile.h +++ b/src/core/moonlive/MoonLiveScriptFile.h @@ -1,7 +1,7 @@ #pragma once #include "core/moonlive/MoonLive.h" -#include "light/moonlive/script_catalog.h" +#include "core/moonlive/script_catalog.h" #include "core/util/fnv.h" // the shipped names, for isFactoryScript below #include "platform/platform.h" @@ -39,92 +39,6 @@ inline constexpr const char* kModifierExt = ".mlm"; inline constexpr const char* kServiceExt = ".mls"; inline constexpr const char* kPaletteExt = ".mlp"; -// A working example rather than an empty file, which would fail to parse the moment it is made. -/// What a new script starts out as, per role. -inline constexpr const char* kEffectTemplate = - "class NewEffect {\n" - " byte bpm = 60;\n" - "\n" - " void defineControls() {\n" - " addControl(\"bpm\", bpm, 1, 255);\n" - " }\n" - "\n" - " void tick() {\n" - " fill(scale(beat(bpm, t), 256), 0, 100);\n" - " }\n" - "}\n"; - -inline constexpr const char* kLayoutTemplate = - "class NewLayout {\n" - " byte cols = 16;\n" - " byte rows = 16;\n" - "\n" - " void defineControls() {\n" - " addControl(\"cols\", cols, 1, 64);\n" - " addControl(\"rows\", rows, 1, 64);\n" - " }\n" - "\n" - " void placeLights() {\n" - " for (y = 0; y < rows; y = y + 1) {\n" - " for (x = 0; x < cols; x = x + 1) {\n" - " addLight(x, y, 0);\n" - " }\n" - " }\n" - " }\n" - "}\n"; - -inline constexpr const char* kModifierTemplate = - "class NewModifier {\n" - " void modifyLogical() {\n" - " setXYZ(width - 1 - xPos, yPos, zPos);\n" - " }\n" - "}\n"; - -/// A service template: poll a pin on the 50 Hz tick, and write the control surface on a change. -inline constexpr const char* kServiceTemplate = - "class NewService {\n" - " int pin = 0;\n" - // 1 is the level an idle pull-up reads, so the first tick sees no change that never happened. - " int last = 1;\n" - "\n" - " void defineControls() {\n" - " addControl(\"pin\", pin, 0, 48);\n" - " }\n" - "\n" - " void tick20ms() {\n" - " int now = gpioRead(pin);\n" - " if (now != last) {\n" - " last = now;\n" - // Inverted, since active-low wiring means a pressed button reads 0. - " setControl(\"switch1\", 1 - now);\n" - " }\n" - " }\n" - "}\n"; - -/// The template a new palette script starts from. -inline constexpr const char* kPaletteTemplate = - "class NewPalette {\n" - " byte bpm = 20;\n" - "\n" - " void defineControls() {\n" - " addControl(\"bpm\", bpm, 1, 120); // how fast the colors move\n" - " }\n" - "\n" - " void tick() {\n" - " for (int i = 0; i < 16; i = i + 1) {\n" - " setPalEntryHSV(i, scale(beat(bpm, t), 256) + i * 16, 255, 255);\n" - " }\n" - " }\n" - "}\n"; - -// Beside the directory they name, rather than repeated in each binding. -/// What a `script` control tells the UI: the directory, the extension, and the new-file template. -inline constexpr const char* kEffectPick[3] = {kScriptDir, kEffectExt, kEffectTemplate}; -inline constexpr const char* kLayoutPick[3] = {kScriptDir, kLayoutExt, kLayoutTemplate}; -inline constexpr const char* kModifierPick[3] = {kScriptDir, kModifierExt, kModifierTemplate}; -inline constexpr const char* kServicePick[3] = {kScriptDir, kServiceExt, kServiceTemplate}; -inline constexpr const char* kPalettePick[3] = {kScriptDir, kPaletteExt, kPaletteTemplate}; - // One definition, because two copies drifted and the linker picked whichever it liked. /// Whether `ext` is one of the script extensions. inline bool isScriptExt(const char* ext) { diff --git a/src/light/moonlive/catalog_scripts.cmake b/src/core/moonlive/catalog_scripts.cmake similarity index 100% rename from src/light/moonlive/catalog_scripts.cmake rename to src/core/moonlive/catalog_scripts.cmake diff --git a/src/light/moonlive/catalog_scripts.py b/src/core/moonlive/catalog_scripts.py similarity index 100% rename from src/light/moonlive/catalog_scripts.py rename to src/core/moonlive/catalog_scripts.py diff --git a/src/light/moonlive/script_catalog.h b/src/core/moonlive/script_catalog.h similarity index 96% rename from src/light/moonlive/script_catalog.h rename to src/core/moonlive/script_catalog.h index b4e451b54..b6cba8803 100644 --- a/src/light/moonlive/script_catalog.h +++ b/src/core/moonlive/script_catalog.h @@ -122,8 +122,8 @@ constexpr const char* kEffectCatalogTags[] = { "💫", "💫", "💫🎶", - "💫🎶", - "💫🎶", + "💫🎵🎶", + "💫🎵", "💫🖌️", }; constexpr const char* kEffectFolder = "effects"; ///< its directory upstream @@ -199,15 +199,17 @@ constexpr const char* kServiceCatalog[] = { "button.mls", "chase.mls", "power.mls", + "stadbeest-autopilot.mls", "sweep.mls", }; -constexpr size_t kServiceCatalogCount = 4; +constexpr size_t kServiceCatalogCount = 5; /// What each service above declares about itself, in the same order, 0 meaning it says nothing and the device decides. constexpr unsigned char kServiceCatalogDim[] = { 0, 0, 0, 0, + 0, }; /// The emoji each declares, "" when it declares none. constexpr const char* kServiceCatalogTags[] = { @@ -215,6 +217,7 @@ constexpr const char* kServiceCatalogTags[] = { "", "", "", + "", }; constexpr const char* kServiceFolder = "services"; ///< its directory upstream @@ -245,7 +248,7 @@ constexpr const char* kPaletteCatalogTags[] = { }; constexpr const char* kPaletteFolder = "palettes"; ///< its directory upstream -constexpr size_t kCatalogCount = 56; ///< every factory script, all roles +constexpr size_t kCatalogCount = 57; ///< every factory script, all roles /// @} } // namespace mm::moonlive diff --git a/src/core/services/AudioService.h b/src/core/services/AudioService.h index bb3840f72..e3752d6fd 100644 --- a/src/core/services/AudioService.h +++ b/src/core/services/AudioService.h @@ -10,7 +10,7 @@ #include "core/services/AudioLevel.h" #include "core/services/AudioBands.h" #include "core/util/math8.h" // beatsin8 / sin8, the simulated-audio oscillators -#include "light/util/WLEDAudioSyncPacket.h" // WLED audio-sync wire format (send/receive) +#include "core/util/WLEDAudioSyncPacket.h" // WLED audio-sync wire format (send/receive) #include "platform/platform.h" #include @@ -485,7 +485,7 @@ class AudioService : public MoonModule { uint32_t micNonzero1s_ = 0; bool micStatusStale_ = false; - // WLED audio sync (light/WLEDAudioSyncPacket.h). One socket, bound only in Send/Receive. + // WLED audio sync (core/util/WLEDAudioSyncPacket.h). One socket, bound only in Send/Receive. platform::UdpSocket syncSock_; uint32_t lastSyncSend_ = 0; // millis of the last send (send throttle) uint32_t syncSendCount_ = 0; // sends made; test-visible so the throttle can be observed diff --git a/src/core/services/MoonLiveService.h b/src/core/services/MoonLiveService.h index 8997e34cd..fa771b312 100644 --- a/src/core/services/MoonLiveService.h +++ b/src/core/services/MoonLiveService.h @@ -3,12 +3,40 @@ #include "core/module/MoonModule.h" #include "core/moonlive/MoonLive.h" #include "core/moonlive/MoonLiveBuiltins_service.h" -#include "light/moonlive/MoonLiveScript.h" // the control seam it drives holds a draw::Canvas +#include "core/moonlive/MoonLiveScript.h" // the file, the compile and the declared controls #include namespace mm { +namespace moonlive { + +/// A service template: poll a pin on the 50 Hz tick, and write the control surface on a change. +inline constexpr const char* kServiceTemplate = + "class NewService {\n" + " int pin = 0;\n" + // 1 is the level an idle pull-up reads, so the first tick sees no change that never happened. + " int last = 1;\n" + "\n" + " void defineControls() {\n" + " addControl(\"pin\", pin, 0, 48);\n" + " }\n" + "\n" + " void tick20ms() {\n" + " int now = gpioRead(pin);\n" + " if (now != last) {\n" + " last = now;\n" + // Inverted, since active-low wiring means a pressed button reads 0. + " setControl(\"switch1\", 1 - now);\n" + " }\n" + " }\n" + "}\n"; + +/// What the `script` control tells the UI: the directory, the extension and the new-file template. +inline constexpr const char* kServicePick[3] = {kScriptDir, kServiceExt, kServiceTemplate}; + +} // namespace moonlive + /// A scripted service: the input twin of a scripted effect, and the flexible half of input. /// /// The compiled services are lists of mappings, wrong for anything with a condition in it. diff --git a/src/core/services/OscModule.h b/src/core/services/OscModule.h index 3dd879a38..91bddefb5 100644 --- a/src/core/services/OscModule.h +++ b/src/core/services/OscModule.h @@ -50,12 +50,12 @@ class OscModule : public MoonModule, public ControlSurface { /// A service, so the container accepts it as a child. ModuleRole role() const MM_NONBLOCKING override { return ModuleRole::Service; } - /// Whether to receive at all, off by default since the port is unauthenticated. + /// Whether to receive at all, off by default since the port is unauthenticated: @xref{what-it-refuses}. bool enabledOsc = false; /// The port we listen on. uint16_t port = osc::kDefaultPort; - /// Whether to mirror changes back, which is what makes this a surface rather than a remote. + /// Whether to mirror changes back, which is what makes this a surface rather than a remote: @xref{feedback}. bool feedback = false; /// How feedback travels, an Addressing value up to multicast + broadcast. uint8_t addressing = 0; @@ -136,6 +136,10 @@ class OscModule : public MoonModule, public ControlSurface { MoonModule::tick1s(); // Only when the answer changes, since the string is identical in between. if (!enabledOsc) return; + if (feedback && ++secondsSinceRefresh_ >= kRefreshSeconds) { + secondsSinceRefresh_ = 0; + if (auto* c = ControlModule::active()) c->resendPaced(this); + } refreshNamedHosts(); const bool fresh = peerFresh(); if (fresh != peerWasFresh_) { peerWasFresh_ = fresh; reportPeer(); } @@ -165,7 +169,7 @@ class OscModule : public MoonModule, public ControlSurface { // After the drain, so a burst re-seeds once rather than per packet. if (resendAll_) { resendAll_ = false; - if (auto* c = ControlModule::active()) c->resendTo(this); + if (auto* c = ControlModule::active()) c->resendPaced(this); } } @@ -239,7 +243,7 @@ class OscModule : public MoonModule, public ControlSurface { // Join the group a sender multicasts feedback to; a failed join leaves unicast working. if (groupValid_) sock_.joinMulticast(group_); if (!attached_) { - if (auto* c = ControlModule::active()) { c->addSurface(this); attached_ = true; } + if (auto* c = ControlModule::active()) { c->addSurface(this, /*paced=*/true); attached_ = true; } } lastFailMs_ = 0; reportPeer(); @@ -325,6 +329,9 @@ class OscModule : public MoonModule, public ControlSurface { bool peerWasFresh_ = false; ///< what the status last said, so it is rewritten only on a change bool attached_ = false; ///< whether we are on the surface list bool resendAll_ = false; ///< a new peer appeared, so push every value once + uint8_t secondsSinceRefresh_ = 0; ///< counts to kRefreshSeconds + /// How often every value goes out again, so a lost datagram or a rebooted follower catches up. + static constexpr uint8_t kRefreshSeconds = 30; platform::UdpSocket sock_; ///< the receive socket, which also sends feedback bool open_ = false; ///< whether it is bound uint32_t lastFailMs_ = 0; ///< when an open last failed, which throttles the retry diff --git a/src/core/system/ControlModule.h b/src/core/system/ControlModule.h index 4953aa825..efc656638 100644 --- a/src/core/system/ControlModule.h +++ b/src/core/system/ControlModule.h @@ -46,7 +46,7 @@ namespace mm { /// Applying one is applying a document, the engine every other writer shares. class ControlModule : public MoonModule, public ListSource { public: - /// Where the preset files live. + /// Where the preset files live, one per preset: @xref{why-files}. static constexpr const char* kPresetDir = "/.config/presets"; /// A preset file's pad, a root `$` key so the engine reads it as the file's own rather than a module. static constexpr const char* kSlotKey = "$slot"; @@ -58,7 +58,7 @@ class ControlModule : public MoonModule, public ListSource { static constexpr uint8_t kMaxPresets = kGridCols * kGridRows; /// The longest preset name, which becomes a file name. static constexpr uint8_t kMaxNameLen = 32; - /// The top-level subtrees a preset can carry. + /// The top-level subtrees a preset can carry: @xref{what-a-preset-holds}. static constexpr const char* kCapturable[] = {"Layouts", "Effects", "Drivers", "Services"}; /// What each capturable subtree covers, named after the CONTAINER rather than after a module. static constexpr const char* kCaptureRole[] = {"layout", "effects", "driver", "service"}; @@ -80,32 +80,51 @@ class ControlModule : public MoonModule, public ListSource { static constexpr uint8_t kSwitchCount = 8; static_assert(kTargetTypeMaxNumber[1] == kSwitchCount && kTargetTypeMaxNumber[2] == kEncoderCount && kTargetTypeMaxNumber[3] == kFaderCount, "an input row names exactly the surface's banks"); + /// Every surface value one surface can be sent, switches first, then faders, then encoders. + static constexpr uint8_t kSlotCount = kSwitchCount + kFaderCount + kEncoderCount; /// The boot ControlModule (exactly one exists). static ControlModule* active() { return ActiveInstance::active(); } // --- Control surfaces ------------------------------------------------------------------- A. - /// Attach a surface. - void addSurface(ControlSurface* s) { + /// Attach a surface, seeded with every value at once or, `paced`, one per tick. + void addSurface(ControlSurface* s, bool paced = false) { if (!s) return; for (uint8_t i = 0; i < surfaceCount_; i++) if (surfaces_[i] == s) return; if (surfaceCount_ >= kMaxSurfaces) return; touched_[surfaceCount_] = Touch{}; + resendNext_[surfaceCount_] = kSlotCount; surfaces_[surfaceCount_++] = s; // Read the targets BEFORE seeding: followTargets(); - resendTo(s); + if (paced) resendPaced(s); + else resendTo(s); } /// Push EVERY value to one surface, whatever the mirror last sent. void resendTo(ControlSurface* s) { - if (!s) return; - for (uint8_t i = 0; i < kSwitchCount; i++) - s->sendValue(SurfaceControl::Switch, i, switches_[i] ? 255 : 0); - for (uint8_t i = 0; i < kFaderCount; i++) s->sendValue(SurfaceControl::Fader, i, faders_[i]); - for (uint8_t i = 0; i < kEncoderCount; i++) s->sendValue(SurfaceControl::Encoder, i, encoders_[i]); + for (uint8_t slot = 0; slot < kSlotCount; slot++) resendOne(s, slot); + } + + // A network surface asks for this rather than resendTo, since a burst of datagrams is what WiFi drops. + /// Push every value to one attached surface, one per tick20ms, starting over if a resend is already running. + void resendPaced(ControlSurface* s) { + for (uint8_t i = 0; i < surfaceCount_; i++) + if (surfaces_[i] == s) resendNext_[i] = 0; + } + + /// Push the value at `slot`, counted as kSlotCount orders them, to one surface. + void resendOne(ControlSurface* s, uint8_t slot) { + if (!s || slot >= kSlotCount) return; + const SurfaceControl kind = slot < kSwitchCount ? SurfaceControl::Switch + : slot < kSwitchCount + kFaderCount ? SurfaceControl::Fader + : SurfaceControl::Encoder; + const uint8_t index = slot < kSwitchCount ? slot + : slot < kSwitchCount + kFaderCount ? slot - kSwitchCount + : slot - kSwitchCount - kFaderCount; + s->sendValue(kind, index, currentValue(kind, index)); } /// Detach. @@ -115,6 +134,7 @@ class ControlModule : public MoonModule, public ListSource { --surfaceCount_; surfaces_[i] = surfaces_[surfaceCount_]; touched_[i] = touched_[surfaceCount_]; + resendNext_[i] = resendNext_[surfaceCount_]; surfaces_[surfaceCount_] = nullptr; return; } @@ -161,6 +181,9 @@ class ControlModule : public MoonModule, public ListSource { void tick20ms() MM_NONBLOCKING override { MoonModule::tick20ms(); mirrorToSurfaces(); + // One value of each paced resend. + for (uint8_t i = 0; i < surfaceCount_; i++) + if (resendNext_[i] < kSlotCount) resendOne(surfaces_[i], resendNext_[i]++); } void tick1s() MM_NONBLOCKING override { @@ -1021,6 +1044,8 @@ class ControlModule : public MoonModule, public ListSource { static constexpr uint8_t kMaxSurfaces = 4; ControlSurface* surfaces_[kMaxSurfaces] = {}; uint8_t surfaceCount_ = 0; + /// Per surface, the value a paced resend sends next, kSlotCount when none is running. + uint8_t resendNext_[kMaxSurfaces] = {}; /// The last value KNOWN to a surface, so only changes go out. uint8_t sentSwitches_[kSwitchCount] = {}; uint8_t sentFaders_[kFaderCount] = {}; diff --git a/src/core/system/EthernetModule.h b/src/core/system/EthernetModule.h index 9399cef08..431625378 100644 --- a/src/core/system/EthernetModule.h +++ b/src/core/system/EthernetModule.h @@ -23,10 +23,10 @@ namespace mm { /// /// The preset list is filtered per chip, and the default is Custom rather than row 0. /// Where the filter leaves exactly ONE real preset that reasoning inverts, because the single survivor IS this chip's board. -/// A P4 offers P4-NANO and an S31 offers S31 CoreBoard, so Custom there ships an unconfigured interface on a board whose wiring is known. +/// A P4 offers P4 RMII and an S31 offers S31 RGMII, so Custom there ships an unconfigured interface on a board whose wiring is known. /// A classic keeps Custom, where several survive and only the catalog knows the board: an Olimex takes Classic RMII, a QuinLED Dig-Octa the no-reset variant. /// -/// The applied-tracker starts on a sentinel rather than row 0, or a virgin P4 would select P4-NANO and still boot with the interface at ethNone. +/// The applied-tracker starts on a sentinel rather than row 0, or a virgin P4 would select P4 RMII and still boot with the interface at ethNone. class EthernetModule : public MoonModule { public: /// Keep the interface configured whatever the toggle says, or the device drops off the network. @@ -277,17 +277,17 @@ class EthernetModule : public MoonModule { bool editable; // false on a soldered map: those lines are not the user's to set }; - // The presets this module knows, each a board family rather than one product; Custom keeps whatever is in the fields, for a hand-wired board. + // The presets this module knows, each a wiring that boards follow rather than one product; Custom keeps whatever is in the fields, for a hand-wired board. static constexpr EthPreset kEthPresets[] = { // The LAN8720 reference wiring most classic boards follow, which is also the chip default. {"Classic RMII", 1, 0, 23, 18, 5, 17, false, -1, -1, -1, -1, -1, false}, // The same wiring with no reset, for a board using GPIO 5 as an LED lane: the PHY resets by jumper. {"Classic RMII (no reset)", 1, 0, 23, 18, -1, 17, false, -1, -1, -1, -1, -1, false}, - // Waveshare P4-NANO and the boards following its shield pinout: IP101, the clock fed in. - {"P4-NANO", 2, 1, 31, 52, 51, 50, true, -1, -1, -1, -1, -1, false}, - // The S31's 1 Gb PHY, addressed by scan rather than by a fixed address. - {"S31 CoreBoard", 4, -1, 5, 6, 7, -1, false, -1, -1, -1, -1, -1, false}, - {"Custom", 0, -1, -1, -1, -1, -1, false, -1, -1, -1, -1, -1, true}, + // The IP101 wiring the Waveshare P4 boards and the shields on their pinout follow, the clock fed in. + {"P4 RMII", 2, 1, 31, 52, 51, 50, true, -1, -1, -1, -1, -1, false}, + // The S31's 1 Gb YT8531 PHY, addressed by scan rather than by a fixed address. + {"S31 RGMII", 4, -1, 5, 6, 7, -1, false, -1, -1, -1, -1, -1, false}, + {"Custom", 0, -1, -1, -1, -1, -1, false, -1, -1, -1, -1, -1, true}, }; static constexpr uint8_t kEthPresetCount = sizeof(kEthPresets) / sizeof(kEthPresets[0]); diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index cf2bea372..29c04111d 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -59,13 +59,10 @@ #include "core/system/FilesystemModule.h" #include "core/system/FirmwareUpdateModule.h" #include "core/system/SystemModule.h" // deviceName() for the WLED /json/info shim -#include "light/moonlive/MoonLiveScriptFile.h" // kFactoryScriptDir: where a download lands -#include "light/moonlive/script_catalog.h" // generated: which factory scripts exist +#include "core/moonlive/MoonLiveScriptFile.h" // kFactoryScriptDir: where a download lands +#include "core/moonlive/script_catalog.h" // generated: which factory scripts exist #include "core/util/build_info.h" // kVersion: the tag a script is fetched from -#include "light/util/Palette.h" // Palettes::nearestForHue: maps HA's RGB color picker onto our - // hue→palette convention (same core→light bridge MqttModule uses for hsv/set; see the note in MqttModule.cpp:7-14). -#include "light/drivers/Drivers.h" // Drivers::latestSummary(): the real light count/channels for - // the WLED /json shim (same one-narrow-reach as Palette above). +#include "core/util/LightOutput.h" // the device shape and the palettes as colors, for the WLED shim #include "platform/platform.h" #include "ui/ui_embedded.h" @@ -1610,8 +1607,8 @@ void HttpServerModule::writeWledName(JsonSink& sink, const char* name) { void HttpServerModule::writeWledInfoBody(JsonSink& sink, const char* name, const uint8_t mac[6]) { sink.appendf("{\"name\":"); writeWledName(sink, name); - // Real led count (the light domain's Drivers::latestSummary) + wifi rssi/signal, so the WLED app card and the WS push show the true device shape. signal maps rssi→0-100 like WLED. - const unsigned ledCount = Drivers::latestSummary()->lightCount; + // The real light count and the wifi signal, so the WLED app card shows the true device shape; signal maps rssi to 0-100 as WLED does. + const unsigned ledCount = lightSummary().lightCount; const int rssi = platform::wifiStaRssi(); int signal = (rssi == 0) ? 0 : (2 * (rssi + 100)); if (signal < 0) signal = 0; else if (signal > 100) signal = 100; @@ -1626,11 +1623,12 @@ void HttpServerModule::writeWledInfoBody(JsonSink& sink, const char* name, const // `on` + `bri` mirror Drivers on/brightness. // `seg[0].col[0]` reports the ACTIVE PALETTE's identity color, not the live first-LED: so every WLED consumer (the WLED native app's device card, HA's WLED integration color picker, Homebridge's HSV via the MQTT pair, the /ws push) sees the same stable palette-representative value and matches the palette-picker → RGB round-trip. // Live first-LED was tried first and dropped: it dimmed the picker under low master brightness (near-black) and jittered with the effect animation ("the picked color moves": user report). -// `Palettes::representativeRgb` returns V=255, so brightness stays HA's `state.bri × seg.bri` responsibility and doesn't double-dim. +// The palette color is at full value, so brightness stays HA's `state.bri × seg.bri` responsibility and doesn't double-dim. // Rationale for the seg[0].on / seg[0].bri fields lives inline below. void HttpServerModule::writeWledStateBody(JsonSink& sink) { const uint8_t bri = driversBrightness(scheduler_); - const RGB pc = Palettes::representativeRgb(driversPalette(scheduler_)); + const LightOutput* out = LightOutput::active(); + const RGB pc = out ? out->paletteRgb(driversPalette(scheduler_)) : RGB{0, 0, 0}; // nl/udpn/lor/transition/ps/pl/mainseg are additive to the Android-app minimum (Moshi ignores unknown/extra fields), and REQUIRED for HA's WLED integration. // `python-wled` parses the POST /json/state response through State.from_dict too: the same required-fields contract as /json. // Without them, HA `light.turn_on` succeeds on the device but the response parse raises, which HA wraps as HTTP 500 on `services/light/turn_on`. nl/udpn as empty objects satisfy the parser via their dataclass defaults; lor=0 is LiveDataOverride.OFF. seg[0].on MUST be present: HA WLED's is_on for a WLEDSegmentLight reads state.segments[].on (light.py:244), NOT top-level state.on. @@ -1638,7 +1636,7 @@ void HttpServerModule::writeWledStateBody(JsonSink& sink) { // The "brightness/color work but the toggle doesn't" symptom pinned on the bench. const char* onStr = driversOn(scheduler_) ? "true" : "false"; // seg[0].pal = the active palette index, so HA's WLED integration highlights the current entry in its palette dropdown (light.py reads state.segments[].palette). - // It shares the Drivers `palette` control with col[0] above (representativeRgb of the SAME index), so the HA palette dropdown and color picker stay two views of one value. + // It shares the Drivers `palette` control with col[0] above (the color of the SAME index), so the HA palette dropdown and color picker stay two views of one value. // Selecting a palette repaints the picker on HA's next poll, and picking a color snaps to the nearest palette (applyWledState). const uint8_t pal = driversPalette(scheduler_); // The applied look as a WLED preset slot (1-based, matching /presets.json), or -1 for none. Without this HA's preset dropdown reads "unknown" even while a preset is active, and never reflects a look chosen on the device itself. @@ -1705,11 +1703,12 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { const int channel = platform::wifiStaChannel(); int signal = (rssi == 0) ? 0 : (2 * (rssi + 100)); if (signal < 0) signal = 0; else if (signal > 100) signal = 100; - // Real pipeline shape from the light domain (Drivers::latestSummary) + render rate. - const LightSummary* ls = Drivers::latestSummary(); - const unsigned ledCount = ls->lightCount; + // The real pipeline shape and render rate. + const LightSummary& ls = lightSummary(); + const LightOutput* out = LightOutput::active(); + const unsigned ledCount = ls.lightCount; const unsigned renderFps = scheduler_ ? scheduler_->fps() : 0; - const char* rgbw = (ls->channelsPerLight >= 4) ? "true" : "false"; + const char* rgbw = (ls.channelsPerLight >= 4) ? "true" : "false"; sink.appendf(",\"info\":{\"ver\":\"99.0.0\",\"vid\":2410150,\"name\":"); writeWledName(sink, name); sink.appendf(",\"mac\":\"%02x%02x%02x%02x%02x%02x\"," @@ -1757,10 +1756,10 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { pmt, static_cast(platform::freeHeap() ? platform::freeHeap() : 32768u), static_cast(platform::millis() / 1000u), - static_cast(mm::paletteCount())); - // effects + palettes: python-wled's __pre_deserialize__ turns each array into an indexed dict. effects stays one real entry ("Solid"): this shim drives a single Layer, so a longer effect list would be a lie. palettes is the REAL built-in list (Palette.h paletteNames / kBuiltins) so HA's palette dropdown offers every palette the device has, indexed to match seg[0].pal and the Drivers `palette` control: the same one-narrow-reach into light/ that the representative color uses. + static_cast(out ? out->paletteCount() : 0)); + // effects + palettes: python-wled's __pre_deserialize__ turns each array into an indexed dict. effects stays one real entry ("Solid"): this shim drives a single Layer, so a longer effect list would be a lie. palettes is the REAL list, so HA's palette dropdown offers every palette the device has, indexed to match seg[0].pal and the Drivers `palette` control. sink.appendf(",\"effects\":[\"Solid\"],\"palettes\":["); - mm::paletteNames(sink); + if (out) out->writePaletteNames(sink); sink.appendf("]}"); sink.flush(); } @@ -1817,29 +1816,30 @@ void HttpServerModule::applyWledState(const char* body) { // It maps straight to the Drivers `palette` control: the direct-index counterpart to the col[] nearest-match below. // Both feed the same control, so the dropdown and the color picker stay one value. // Parsed from the segment object so a top-level stray "pal" can't hijack it. + const LightOutput* out = LightOutput::active(); const char* segStart = std::strstr(body, "\"seg\":"); const char* palStart = segStart ? std::strstr(segStart, "\"pal\":") : nullptr; - if (palStart) { + if (palStart && out) { int pal = mm::parseIntStr(palStart + 6); if (pal < 0) pal = 0; // Against the FULL count, built-ins plus the scripted tail, because that is exactly the list served as `palettes[]` above. Clamping to the built-ins rejected every scripted index this device had just offered, so picking one in Home Assistant silently snapped back to the last built-in. - if (pal >= mm::paletteCount()) pal = mm::paletteCount() - 1; + if (pal >= out->paletteCount()) pal = out->paletteCount() - 1; char valueJson[24]; std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%d}", pal); applySetControl("Drivers", "palette", valueJson); } // WLED color: seg[0].col[0] is [r,g,b]. // HA's WLED integration writes here when a user picks a color in the RGB picker. - // Palettes::nearestForRgb is the canonical RGB→palette entry (see the comment at its declaration): it applies the same RGB→(hue,sat) conversion representativeHueSat uses on the palette side, then runs the 2D-distance sweep. + // The nearest palette by the same RGB to hue-and-saturation conversion the palettes' own colors take. // Value channel is ignored: HA's own brightness slider handles bri via the `bri` field above. const char* colStart = std::strstr(body, "\"col\":[["); - if (colStart) { + if (colStart && out) { int r = 0, g = 0, b = 0; if (std::sscanf(colStart + 8, "%d,%d,%d", &r, &g, &b) == 3) { const uint8_t rc = static_cast(r < 0 ? 0 : (r > 255 ? 255 : r)); const uint8_t gc = static_cast(g < 0 ? 0 : (g > 255 ? 255 : g)); const uint8_t bc = static_cast(b < 0 ? 0 : (b > 255 ? 255 : b)); - const uint8_t idx = mm::Palettes::nearestForRgb(rc, gc, bc); + const uint8_t idx = out->nearestPalette(RGB{rc, gc, bc}); char valueJson[24]; std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%u}", static_cast(idx)); applySetControl("Drivers", "palette", valueJson); @@ -2458,11 +2458,9 @@ void HttpServerModule::afterListMutation() { // Mirrors the phase-2b tree-wide rebuild after persistence load. for (uint8_t i = 0; i < scheduler_->moduleCount(); i++) if (auto* m = scheduler_->module(i)) m->rebuildControls(); - // Re-resolve each driver's preset → correction so an EDIT flows to output immediately. - // This is a tier-1 correction refresh (rebuildCorrection → onCorrectionChanged), NOT a tier-3 prepareTree(): a preset edit changes correction data, not pipeline STRUCTURE, so it must not re-run prepare(): that reinits each driver's output peripheral (an RMT channel teardown blanks the strip for a tick, even on drivers not using the edited preset), which Live-reconfiguration forbids (a config change applies with no visible glitch). - // Drivers is the one container that owns driver corrections; core already couples to it (latestSummary). - if (auto* drivers = static_cast(findModuleByName("Drivers"))) - drivers->rebuildAllCorrections(); + // Each module re-resolves what it reads from the list, so an edit reaches the output at once. + // Not prepareTree(): re-running prepare reinitializes every driver's output, which blanks a strip for a tick. + if (listMutationModule_) scheduler_->notifyListChanged(*listMutationModule_); // The tree-wide rebuildControls() above changed visible SCHEMA (option sets, hidden flags); each of those rebuildControls() calls fires the schema-changed hook → requestFullResync(), so connected clients re-read the fresh schema. No explicit resync needed here. } } diff --git a/src/core/system/HttpServerModule.h b/src/core/system/HttpServerModule.h index 5d6c1a8a3..68a2194f3 100644 --- a/src/core/system/HttpServerModule.h +++ b/src/core/system/HttpServerModule.h @@ -19,11 +19,8 @@ class Scheduler; /// /// ## What it is allowed to include /// -/// This is core infrastructure held to a light-include-free contract, with one accepted exception. -/// The WLED-compatibility shim's color path uses the pure hue and palette-index conversions in `light/Palette.h`, `Palettes::nearestForRgb` and `Palettes::representativeRgb`. -/// `MqttModule` documents the same sanctioned exception at its own top. -/// Routing a HomeKit or Home Assistant WLED color to a MoonLight palette needs the palette set, which is inherently light-domain, and a format conversion is the least-coupling bridge. -/// This module still drives the palette through `Scheduler::setControl` rather than a light object, and no other light-domain include is permitted. +/// This is core infrastructure, and includes no light header. +/// The WLED-compatibility shim reads the device shape and the palettes as colors through `LightOutput`, which the light domain implements, and drives the palette through `Scheduler::setControl`. /// /// The implementation lives in the `.cpp`, and this header is the interface alone. /// The `port` control defaults to 8080 on the desktop and 80 on an ESP32. @@ -124,16 +121,11 @@ class Scheduler; /// The app keys on the brand to accept a device: we interoperate rather than impersonate, and this is no full WLED emulation. /// /// Live state is pushed over `/ws` as a state-and-info frame. -/// The state mirrors the Drivers brightness control and the live first-LED color, falling back to MoonLight purple when the first LED is off. +/// The state mirrors the Drivers brightness control, and its color is the active palette's, from `LightOutput::paletteRgb()`, or black on a device with no light output. /// /// Control is bidirectional over that same socket. /// The app's slider and toggle send a frame that is read and applied to Drivers brightness through the shared apply core, the path REST and Improv also use. /// -/// ## The one reach into output state -/// -/// The color read is the one place this core module reaches output state, through `MoonModule::firstOutputRgb`, a domain-neutral virtual the light-domain Drivers overrides. -/// That is what keeps this module free of a light-domain include. -/// /// ## Reading an uplink frame, and taking the lease /// /// The uplink parser handles RFC 6455 framing alone, and the payload's meaning belongs to the registered sink. diff --git a/src/core/system/IpSettings.h b/src/core/system/IpSettings.h index 14ff3af01..e002822f0 100644 --- a/src/core/system/IpSettings.h +++ b/src/core/system/IpSettings.h @@ -1,6 +1,7 @@ #pragma once #include "core/util/fnv.h" +#include "core/util/Ipv4.h" // the rule a static setting is checked by, shared with MoonBase #include "platform/platform.h" #include @@ -18,6 +19,20 @@ inline constexpr uint8_t kStatic = 1; ///< the address the user set inline constexpr const char* kOptions[] = {"DHCP", "Static"}; /// The four addresses a static setting holds, in the order they are written. inline constexpr const char* kFields[4] = {"ip", "gateway", "subnet", "dns"}; +// In ipv4::Fault's order, with no sentence for None; the app's alone, so MoonBase carries none of the text. +/// What the card says about each reason a static setting is not used. +inline constexpr const char* kWhy[] = { + nullptr, + "static IP not used: the subnet mask is not a valid mask", + "static IP not used: no address is set", + "static IP not used: the address is not a host address", + "static IP not used: the address is the subnet's network or broadcast address", + "static IP not used: the gateway is outside the subnet", + "static IP not used: the gateway is the device's own address", + "static IP not used: the gateway is the subnet's network or broadcast address", + "static IP not used: the DNS server is not a host address", +}; +static_assert(sizeof(kWhy) / sizeof(kWhy[0]) == static_cast(ipv4::Fault::Dns) + 1, "a sentence for every fault"); } // namespace mm::ipsettings @@ -53,14 +68,9 @@ struct IpSettings { return f.h; } - /// Why a Static setting cannot be used, a sentence for the card, or null when it can; gateway and DNS may be left at 0.0.0.0 for none. + /// Why a Static setting cannot be used, a sentence for the card, or null when it can. const char* problem() const MM_NONBLOCKING { - const uint32_t a = word(ip), m = word(subnet), d = word(dns); - if (!isMask(m)) return "static IP not used: the subnet mask is not a valid mask"; - if (const char* why = addressProblem(a, m)) return why; - if (const char* why = gatewayProblem(word(gateway), a, m)) return why; - if (d && !isHost(d)) return "static IP not used: the DNS server is not a host address"; - return nullptr; + return ipsettings::kWhy[static_cast(ipv4::staticFault(ip, gateway, subnet, dns))]; } /// Whether a Static setting is chosen and can be used; an unusable one leaves the DHCP client running. @@ -73,27 +83,27 @@ struct IpSettings { static IpSettings leased(platform::NetIface iface) { uint8_t lease[4][4]; platform::netGetIPv4(iface, lease[0], lease[1], lease[2], lease[3]); - if (!word(lease[3])) std::memcpy(lease[3], lease[1], 4); + if (!ipv4::word(lease[3])) std::memcpy(lease[3], lease[1], 4); IpSettings s; s.mode = ipsettings::kStatic; for (uint8_t k = 0; k < 4; k++) - if (word(lease[k])) std::memcpy(s.address(k), lease[k], 4); + if (ipv4::word(lease[k])) std::memcpy(s.address(k), lease[k], 4); return s; } /// Start a Static setting from what `iface` runs with now, when it has an address and no address is set yet. void prefillFrom(platform::NetIface iface) { - if (!isStatic() || word(ip)) return; + if (!isStatic() || ipv4::word(ip)) return; const IpSettings lease = leased(iface); - if (!word(lease.ip)) return; + if (!ipv4::word(lease.ip)) return; for (uint8_t k = 0; k < 4; k++) std::memcpy(address(k), lease.address(k), 4); } /// Start a Static setting's gateway, mask and DNS server from the network `iface` is on, leaving the address to choose, when no address is set yet. void prefillNetworkFrom(platform::NetIface iface) { - if (!isStatic() || word(ip)) return; + if (!isStatic() || ipv4::word(ip)) return; const IpSettings lease = leased(iface); - if (!word(lease.ip)) return; + if (!ipv4::word(lease.ip)) return; for (uint8_t k = 1; k < 4; k++) std::memcpy(address(k), lease.address(k), 4); } @@ -108,35 +118,6 @@ struct IpSettings { else platform::netSetDhcp(iface); } -private: - static uint32_t word(const uint8_t q[4]) MM_NONBLOCKING { - return (uint32_t(q[0]) << 24) | (uint32_t(q[1]) << 16) | (uint32_t(q[2]) << 8) | q[3]; - } - // Not 0.x, loopback 127.x, or multicast and reserved from 224.x on. - static bool isHost(uint32_t a) MM_NONBLOCKING { - const uint32_t first = a >> 24; - return first != 0 && first != 127 && first < 224; - } - // Ones then zeros, with at least one of each. - static bool isMask(uint32_t m) MM_NONBLOCKING { return m != 0 && m != 0xFFFFFFFFu && (~m & (~m + 1)) == 0; } - // A /31 has no network or broadcast address (RFC 3021), so both of its addresses are hosts. - static bool isNetworkOrBroadcast(uint32_t a, uint32_t m) MM_NONBLOCKING { - return ~m > 1 && ((a & ~m) == 0 || (a | m) == 0xFFFFFFFFu); - } - static const char* addressProblem(uint32_t a, uint32_t m) MM_NONBLOCKING { - if (a == 0) return "static IP not used: no address is set"; - if (!isHost(a)) return "static IP not used: the address is not a host address"; - if (isNetworkOrBroadcast(a, m)) return "static IP not used: the address is the subnet's network or broadcast address"; - return nullptr; - } - // A gateway of 0.0.0.0 is none, which a local-only device runs without. - static const char* gatewayProblem(uint32_t g, uint32_t a, uint32_t m) MM_NONBLOCKING { - if (!g) return nullptr; - if ((g & m) != (a & m)) return "static IP not used: the gateway is outside the subnet"; - if (g == a) return "static IP not used: the gateway is the device's own address"; - if (isNetworkOrBroadcast(g, m)) return "static IP not used: the gateway is the subnet's network or broadcast address"; - return nullptr; - } }; /// The IP settings an interface last applied, so only an edit applies again. diff --git a/src/core/system/MoonStatsModule.h b/src/core/system/MoonStatsModule.h index 6029ebe1e..735c89a5d 100644 --- a/src/core/system/MoonStatsModule.h +++ b/src/core/system/MoonStatsModule.h @@ -29,10 +29,9 @@ #include "core/module/Scheduler.h" #include "core/util/build_info.h" #include "platform/platform.h" -#include "core/util/LightSummary.h" // lightCount: the POD the light domain publishes -#include "light/drivers/Drivers.h" // Drivers::latestSummary(): the real light total +#include "core/util/LightOutput.h" // lightSummary: the real light total the light domain publishes #include "core/util/ModuleFactory.h" // displayNameFor: the TYPE label, not the instance name -#include "light/moonlive/MoonLiveScriptFile.h" // isFactoryScript: a shipped name is ours, not yours +#include "core/moonlive/MoonLiveScriptFile.h" // isFactoryScript: a shipped name is ours, not yours namespace mm { @@ -343,12 +342,11 @@ class MoonStatsModule : public MoonModule { } JsonSink body; - const LightSummary* lights = Drivers::latestSummary(); // Only on an upgrade: the server reads its presence as what makes a row one. const char* prev = (kind == MoonStatsEvent::Upgrade) ? previousVersion() : nullptr; buildMoonStatsReport(body, tree, count, kind, id, runningVersion_, prev, - lights ? lights->lightCount : 0, + lightSummary().lightCount, static_cast(platform::totalHeap()), static_cast(platform::freeHeap()), sched->fps()); diff --git a/src/core/system/MqttModule.cpp b/src/core/system/MqttModule.cpp index 6adc5a4c7..15e73beec 100644 --- a/src/core/system/MqttModule.cpp +++ b/src/core/system/MqttModule.cpp @@ -108,12 +108,10 @@ /// `hsv/get` publishes the chosen palette's representative hue, full sat, and value as brightness percent. /// Subscriptions otherwise happen only at CONNACK, so a mid-session discovery turn-on subscribes as well as announcing. /// -/// ## The one reach into the light domain +/// ## Palettes through the light output /// -/// `Palettes::nearestForHue` is a pure hue-and-saturation to index CONVERSION with no light state or objects, the one narrow reach this core module makes into the light domain. -/// PO-accepted: routing a HomeKit color to a palette needs the palette set, which is inherently light-domain. -/// A format conversion is the least-coupling way to bridge it, since the module still drives the palette via `Scheduler::setControl` rather than a light object. -/// This is a deliberate divergence from the plan's "no light include" line, made with the trade-off understood rather than by precedent. +/// A HomeKit color picks the nearest palette through `LightOutput`, which the light domain implements, so this module includes no light header. +/// It still drives the palette through `Scheduler::setControl` rather than a light object. /// #include "core/util/format.h" // formatTo: nonblocking formatting into a fixed buffer #include "core/system/MqttModule.h" @@ -125,7 +123,7 @@ #include "core/util/JsonSink.h" // jsonEscape: escape the editable deviceName into the discovery JSON #include "core/util/build_info.h" // kVersion / kFirmwareName: reported to HA's update entity #include "core/system/FirmwareUpdateModule.h" // g_otaStatus / g_otaBytesTotal / otaInFlight: shared with the OTA task -#include "light/util/Palette.h" // Palettes::nearestForHue: the one reach into the light domain, @xref{the-one-reach-into-the-light-domain} +#include "core/util/LightOutput.h" // the palettes as colors, for the HSV pair #include #include @@ -730,11 +728,13 @@ void MqttModule::routePublish(const char* topic, const uint8_t* payload, size_t // The mqttthing HSV triple: hue and sat pick the nearest palette, value maps to brightness. @xref{inbound-command-routing} int h = 0, s = 0, v = -1; std::sscanf(value, "%d,%d,%d", &h, &s, &v); - const uint8_t idx = Palettes::nearestForHue(static_cast(h < 0 ? 0 : h), - static_cast(s < 0 ? 0 : (s > 100 ? 255 : s * 255 / 100))); char json[24]; - std::snprintf(json, sizeof(json), "{\"value\":%u}", static_cast(idx)); - setControlValue("palette", json); + if (const LightOutput* out = LightOutput::active()) { + const uint8_t idx = out->nearestPalette(static_cast(h < 0 ? 0 : h), + static_cast(s < 0 ? 0 : (s > 100 ? 255 : s * 255 / 100))); + std::snprintf(json, sizeof(json), "{\"value\":%u}", static_cast(idx)); + setControlValue("palette", json); + } if (v >= 0) { int bri = (v > 100 ? 100 : v) * 255 / 100; std::snprintf(json, sizeof(json), "{\"value\":%d}", bri); @@ -796,9 +796,10 @@ void MqttModule::publishState(bool force) { char briStr[8]; std::snprintf(briStr, sizeof(briStr), "%d", (bri * 100) / 255); // hsv/get: the chosen palette's representative hue, full sat, value = brightness%. @xref{inbound-command-routing} + uint16_t hue = 0, sat = 0; + if (const LightOutput* out = LightOutput::active()) out->paletteHueSat(pal, hue, sat); char hsvStr[16]; - std::snprintf(hsvStr, sizeof(hsvStr), "%u,100,%d", - static_cast(Palettes::representativeHue(pal)), (bri * 100) / 255); + std::snprintf(hsvStr, sizeof(hsvStr), "%u,100,%d", static_cast(hue), (bri * 100) / 255); if (!publish("on/get", on ? "true" : "false") || !publish("brightness/get", briStr) || diff --git a/src/core/util/ConfigScrape.h b/src/core/util/ConfigScrape.h index 48a5108c5..b5492ca58 100644 --- a/src/core/util/ConfigScrape.h +++ b/src/core/util/ConfigScrape.h @@ -21,6 +21,7 @@ #include #include "core/util/hex.h" +#include "core/util/Ipv4.h" // the saved addresses, and the rule a static setting is checked by namespace mm::configscrape { @@ -101,17 +102,22 @@ inline bool childKey(const char* json, const char* typeKey, const char* key, cha return true; } -/// A string key of the child whose type is `childType`, the `"N.key":` beside its `"N.type":`, false when either is absent or the value empty. -inline bool findChildString(const char* json, const char* childType, const char* key, char* out, size_t outLen) { - if (!json || !childType || !key) return false; +/// The value of a key of the child whose type is `childType`, the `"N.key":` beside its `"N.type":`, or null when either is absent. +inline const char* findChildValue(const char* json, const char* childType, const char* key) { + if (!json || !childType || !key) return nullptr; const size_t tl = std::strlen(childType); for (const char* p = std::strstr(json, "type\":\""); p; p = std::strstr(p + 1, "type\":\"")) { char want[64]; if (std::strncmp(p + 7, childType, tl) != 0 || p[7 + tl] != '"' || !childKey(json, p, key, want, sizeof(want))) continue; const char* at = std::strstr(json, want); - return at && readString(at + std::strlen(want), out, outLen); + return at ? at + std::strlen(want) : nullptr; } - return false; + return nullptr; +} + +/// A string key of the child whose type is `childType`, false when either is absent or the value empty. +inline bool findChildString(const char* json, const char* childType, const char* key, char* out, size_t outLen) { + return readString(findChildValue(json, childType, key), out, outLen); } /// The known network at `index` in the app's priority order: its `ssid`, and the `password` that follows it in the same row. @@ -124,6 +130,79 @@ inline bool findNetwork(const char* json, uint8_t index, char* ssid, size_t ssid return true; } +/// One interface's addressing as the app saved it: DHCP or Static, and the four addresses Static pins. +struct SavedIp { + int mode = 0; ///< 0 DHCP, 1 Static, as the app's `ipSettings` select + uint8_t ip[4] = {}; ///< the address + uint8_t gateway[4] = {}; ///< the router + uint8_t subnet[4] = {255, 255, 255, 0}; ///< the netmask + uint8_t dns[4] = {}; ///< the name server + + /// Whether it pins a static address the app would also use, by the app's own rule. + bool usable() const { return mode == 1 && ipv4::staticFault(ip, gateway, subnet, dns) == ipv4::Fault::None; } +}; + +/// Where one key's value is, for the reader below: `ctx` carries what the lookup needs. +using ValueAt = const char* (*)(const void* ctx, const char* key); + +/// The five IP keys, each value found by `at`; a key absent or malformed keeps its default. +inline SavedIp readIp(ValueAt at, const void* ctx) { + SavedIp s; + if (const char* v = at(ctx, "ipSettings"); v && *v >= '0' && *v <= '9') s.mode = static_cast(std::strtol(v, nullptr, 10)); + uint8_t* const dst[4] = {s.ip, s.gateway, s.subnet, s.dns}; + const char* const names[4] = {"ip", "gateway", "subnet", "dns"}; + for (int k = 0; k < 4; k++) { + char text[16]; + if (readString(at(ctx, names[k]), text, sizeof(text))) parseDottedQuad(text, dst[k]); + } + return s; +} + +/// The IP settings of the child whose type is `childType`, the Ethernet card's. +inline SavedIp findChildIp(const char* json, const char* childType) { + struct Ctx { const char* json; const char* type; } ctx{json, childType}; + return readIp([](const void* c, const char* key) { + const auto* x = static_cast(c); + return findChildValue(x->json, x->type, key); + }, &ctx); +} + +/// The closing quote of the string opening at `p`, past its escapes, or null when it never closes. +inline const char* stringEnd(const char* p) { + for (p++; *p; p++) { + if (*p == '\\' && p[1]) p++; + else if (*p == '"') return p; + } + return nullptr; +} + +// String-aware, since a password may hold a brace; an unclosed object has no end. +/// The closing brace of the object `p` sits inside, or null when there is none. +inline const char* objectEnd(const char* p) { + int depth = 0; + for (; p && *p; p++) { + if (*p == '"' && !(p = stringEnd(p))) return nullptr; + if (*p == '{' || *p == '[') depth++; + else if ((*p == '}' || *p == ']') && depth-- == 0) return p; + } + return nullptr; +} + +/// The IP settings of the known network at `index`, read inside its own row. +inline SavedIp findNetworkIp(const char* json, uint8_t index) { + const char* row = findKey(json, "ssid"); + for (uint8_t k = 0; row && k < index; k++) row = findKey(row, "ssid"); + if (!row) return {}; + // The row's own closing brace ends it, so a row saved without a key reads neither its neighbor's nor a later module's. + const char* end = objectEnd(row); + struct Ctx { const char* row; const char* end; } ctx{row, end}; + return readIp([](const void* c, const char* key) -> const char* { + const auto* x = static_cast(c); + const char* v = findKey(x->row, key); + return (v && (!x->end || v < x->end)) ? v : nullptr; + }, &ctx); +} + } // namespace mm::configscrape /// @} diff --git a/src/core/util/Ipv4.h b/src/core/util/Ipv4.h new file mode 100644 index 000000000..447d08cbd --- /dev/null +++ b/src/core/util/Ipv4.h @@ -0,0 +1,95 @@ +#pragma once + +#include "platform/nonblocking.h" // MM_NONBLOCKING, a compiler marker with no platform contract + +#include +#include +#include + +namespace mm { + +/// @defgroup Ipv4 An IPv4 address as four octets +/// @{ +/// Reading one from text, and whether a static setting can be used. +/// +/// MoonBase includes it as well as the app, so the recovery image refuses the same static settings the app refuses and comes up where the app does. + +/// Parse "A.B.C.D" into four octets, returning false on anything else; `out` is written only on success, as `inet_pton` does. +inline bool parseDottedQuad(const char* s, uint8_t out[4]) { + if (!s) return false; + uint8_t q[4]; + const char* p = s; + for (int idx = 0; idx < 4; idx++) { + char* end = nullptr; + long v = std::strtol(p, &end, 10); + if (end == p || v < 0 || v > 255) return false; + q[idx] = static_cast(v); + // Three dots between the octets, and nothing after the last (e.g. "1.2.3.4x" fails). + if (*end != (idx == 3 ? '\0' : '.')) return false; + p = end + 1; + } + std::memcpy(out, q, 4); + return true; +} + +namespace ipv4 { + +/// Four octets as one number, the first octet highest. +inline uint32_t word(const uint8_t q[4]) MM_NONBLOCKING { + return (uint32_t(q[0]) << 24) | (uint32_t(q[1]) << 16) | (uint32_t(q[2]) << 8) | q[3]; +} + +// Not 0.x, loopback 127.x, or multicast and reserved from 224.x on. +/// Whether an address can name a host. +inline bool isHost(uint32_t a) MM_NONBLOCKING { + const uint32_t first = a >> 24; + return first != 0 && first != 127 && first < 224; +} + +/// Whether a netmask is ones then zeros, with at least one of each. +inline bool isMask(uint32_t m) MM_NONBLOCKING { return m != 0 && m != 0xFFFFFFFFu && (~m & (~m + 1)) == 0; } + +// A /31 has no network or broadcast address (RFC 3021), so both of its addresses are hosts. +/// Whether an address is its subnet's network or broadcast address. +inline bool isNetworkOrBroadcast(uint32_t a, uint32_t m) MM_NONBLOCKING { + return ~m > 1 && ((a & ~m) == 0 || (a | m) == 0xFFFFFFFFu); +} + +// A code rather than a sentence, so MoonBase, which only asks whether a setting is usable, carries none of the text. +/// Why a static setting cannot be used, or None when it can. +enum class Fault : uint8_t { None, Mask, NoAddress, AddressNotHost, AddressReserved, GatewayOutside, GatewaySelf, GatewayReserved, Dns }; + +/// Why a static address cannot be used in its subnet. +inline Fault addressFault(uint32_t a, uint32_t m) MM_NONBLOCKING { + if (a == 0) return Fault::NoAddress; + if (!isHost(a)) return Fault::AddressNotHost; + if (isNetworkOrBroadcast(a, m)) return Fault::AddressReserved; + return Fault::None; +} + +// A gateway of 0.0.0.0 is none, which a local-only device runs without. +/// Why a gateway cannot be used for an address. +inline Fault gatewayFault(uint32_t g, uint32_t a, uint32_t m) MM_NONBLOCKING { + if (!g) return Fault::None; + if ((g & m) != (a & m)) return Fault::GatewayOutside; + if (g == a) return Fault::GatewaySelf; + if (isNetworkOrBroadcast(g, m)) return Fault::GatewayReserved; + return Fault::None; +} + +/// Why a static setting cannot be used; gateway and DNS may be left at 0.0.0.0 for none. +inline Fault staticFault(const uint8_t ip[4], const uint8_t gateway[4], const uint8_t subnet[4], + const uint8_t dns[4]) MM_NONBLOCKING { + const uint32_t a = word(ip), m = word(subnet), d = word(dns); + if (!isMask(m)) return Fault::Mask; + if (const Fault f = addressFault(a, m); f != Fault::None) return f; + if (const Fault f = gatewayFault(word(gateway), a, m); f != Fault::None) return f; + if (d && !isHost(d)) return Fault::Dns; + return Fault::None; +} + +} // namespace ipv4 + +/// @} + +} // namespace mm diff --git a/src/core/util/LightOutput.h b/src/core/util/LightOutput.h new file mode 100644 index 000000000..e5795c381 --- /dev/null +++ b/src/core/util/LightOutput.h @@ -0,0 +1,85 @@ +#pragma once + +#include "core/util/ActiveInstance.h" // the seat the light domain claims +#include "core/util/color.h" // RGB and the hue conversions the palette helpers use + +#include + +namespace mm { + +class JsonSink; + +/// @defgroup LightOutput The light output, as core sees it +/// @{ +/// What the domain-neutral transports read from the light domain: the device's shape, and its palettes as colors. +/// +/// @moreinfo +/// +/// ## Core declares, light implements +/// +/// The light domain's `Drivers` container implements `LightOutput` and claims its seat from prepare, and the WLED shim, MQTT and MoonStats read through it. +/// So core reaches the light pipeline without including a light header: the dependency points inward, as the architecture's dependency rule asks. +/// On a device with no light output the seat is empty, and every reader degrades to the all-zero summary and palette 0. +/// +/// ## The summary is plain data +/// +/// `LightSummary` is a flat struct of small integers, overwritten in place on each rebuild, the same pull pattern as `AudioFrame`. +/// `lightCount` is a `uint32_t` to hold any count on any board, the light-side `nrOfLightsType` being a `uint16_t` without PSRAM and a `uint32_t` with it. +/// A new field means the producer fills it in one place and every consumer that wants it reads it, with no new seam. +/// +/// ## Palettes as colors +/// +/// A color wheel (Home Assistant, HomeKit through MQTT, the WLED app) picks a palette by color. +/// Matching happens in hue and saturation, so the interface carries those two, and the RGB forms are conversions on top. + +/// A small plain-data summary of the light pipeline's output. +struct LightSummary { + uint32_t lightCount = 0; ///< total physical lights driven (Layer::physicalLightCount()). + uint8_t channelsPerLight = 3; ///< 3 = RGB, 4 = RGBW, more = multi-channel DMX fixtures. +}; + +/// The light domain as the domain-neutral transports reach it. +class LightOutput { +public: + /// The live pipeline's shape. + virtual const LightSummary& summary() const = 0; + /// The palette whose representative color is nearest a hue (0..359) and saturation (0..255). + virtual uint8_t nearestPalette(uint16_t hue, uint8_t sat) const = 0; + /// A palette's representative hue (0..359) and saturation (0..255). + virtual void paletteHueSat(uint8_t index, uint16_t& hue, uint16_t& sat) const = 0; + /// How many palettes the picker offers, the built-ins and the scripted tail. + virtual uint8_t paletteCount() const = 0; + /// Every palette name as JSON strings, comma-separated, in picker order. + virtual void writePaletteNames(JsonSink& sink) const = 0; + + // Full value, so brightness stays the caller's concern and nothing dims twice. + /// A palette's representative color. + RGB paletteRgb(uint8_t index) const { + uint16_t hue = 0, sat = 0; + paletteHueSat(index, hue, sat); + return hsvToRgb(static_cast((hue * 256u) / 360u), static_cast(sat), 255); + } + /// The palette nearest an RGB color, through the same conversion the palettes' own colors take. + uint8_t nearestPalette(RGB c) const { + uint16_t hue = 0, sat = 0; + rgbToHueSat(c.r, c.g, c.b, hue, sat); + return nearestPalette(hue, static_cast(sat)); + } + + /// The live light output, or null on a device with none. + static const LightOutput* active() { return ActiveInstance::active(); } + +protected: + ~LightOutput() = default; +}; + +/// The live summary, or the all-zero one when no light output is running. +inline const LightSummary& lightSummary() { + static const LightSummary kNone{}; + const LightOutput* out = LightOutput::active(); + return out ? out->summary() : kNone; +} + +/// @} + +} // namespace mm diff --git a/src/core/util/LightSummary.h b/src/core/util/LightSummary.h deleted file mode 100644 index eb0659303..000000000 --- a/src/core/util/LightSummary.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -namespace mm { - -/// A small plain-data summary of the light pipeline's output, readable from core without any light-domain type. -/// -/// @moreinfo -/// -/// ## Who fills it and who reads it -/// -/// The light domain's `Drivers` container produces it, and domain-neutral consumers read it: the WLED `/json` shim and MQTT, which report the real device shape. -/// This is the shared-struct pull pattern the architecture page describes under data exchange, the same shape as `AudioFrame`. -/// The producer owns one plain struct overwritten in place on each rebuild, and a consumer holds a `const LightSummary*` and reads it, with no allocation and no event bus. -/// -/// It stays domain-neutral on purpose: plain `uint32_t` and `uint8_t` with no light typedefs, so it lives in core beside its consumers. -/// `lightCount` is a `uint32_t` to hold any count on any board, the light-side `nrOfLightsType` being a `uint16_t` without PSRAM and a `uint32_t` with it. -/// -/// ## Extendable by design -/// -/// Adding a field, say estimated power or a segment count, means the producer fills it in one place and every consumer that wants it reads it, with no new seam. -/// Keep it a flat struct of small integers. -struct LightSummary { - uint32_t lightCount = 0; ///< total physical lights driven (Layer::physicalLightCount()). - uint8_t channelsPerLight = 3; ///< 3 = RGB, 4 = RGBW, more = multi-channel DMX fixtures. -}; - -} // namespace mm diff --git a/src/core/util/ModuleFactory.h b/src/core/util/ModuleFactory.h index c0d88dd21..81f1bcf3e 100644 --- a/src/core/util/ModuleFactory.h +++ b/src/core/util/ModuleFactory.h @@ -17,7 +17,8 @@ using CreateModuleFn = MoonModule*(*)(); /// /// ## What a registration captures /// -/// A single stack probe yields the type's size, its role, its tags and its dimensionality, so registering is one line at boot. +/// A single probe instance yields the type's size, its role, its tags and its dimensionality, so registering is one line at boot. +/// It lives on the heap for the moment it is read, since a module can be kilobytes and the boot task's stack is not sized for one. /// The path to its documentation page rides along as a flash literal with no per-instance cost, and the interface builds a help link from it. /// /// Dimensionality is captured only when the type declares it, which the effect and modifier bases do and nothing else does. @@ -35,7 +36,7 @@ using CreateModuleFn = MoonModule*(*)(); /// A name without the suffix is returned unchanged, and the type name itself stays intact for persistence and lookup. class ModuleFactory { public: - /// Register a type, capturing everything from one stack probe: @xref{what-a-registration-captures|what is taken, and how}. + /// Register a type, capturing everything from one probe instance: @xref{what-a-registration-captures|what is taken, and how}. template static bool registerType(const char* typeName, const char* docPath = "") { if (!typeName) return false; @@ -43,18 +44,21 @@ class ModuleFactory { for (uint8_t i = 0; i < count_; i++) if (std::strcmp(types_[i].name, typeName) == 0) return true; if (!grow()) return false; - T probe; + // On the heap, as an instance can outgrow the boot stack; raw new, since make_unique inlines each type's constructor here, +13.4 KB on the S3. + T* probe = new T(); + if (!probe) return false; uint8_t dim = 0; if constexpr (requires(const T& t) { static_cast(t.dimensions()); }) { - dim = static_cast(probe.dimensions()); + dim = static_cast(probe->dimensions()); } types_[count_++] = {typeName, []() -> MoonModule* { return new T(); }, - sizeof(T), probe.role(), + sizeof(T), probe->role(), docPath ? docPath : "", - probe.tags() ? probe.tags() : "", + probe->tags() ? probe->tags() : "", dim, - probe.acceptsChildRoles() ? probe.acceptsChildRoles() : ""}; + probe->acceptsChildRoles() ? probe->acceptsChildRoles() : ""}; + delete probe; return true; } diff --git a/src/core/util/ThreadSlot.h b/src/core/util/ThreadSlot.h new file mode 100644 index 000000000..2d8155b8e --- /dev/null +++ b/src/core/util/ThreadSlot.h @@ -0,0 +1,43 @@ +#pragma once + +#include "platform/platform.h" // currentThreadId: the key a slot is claimed under + +#include +#include +#include + +namespace mm { + +/// @defgroup ThreadSlot Per-thread slots +/// @{ +/// A slot a thread claims by its id, for state a callback reaches without a receiver. +/// +/// `thread_local` dies on an ESP32 task without TLS, so a small fixed table keyed on the thread id stands in for it. +/// A slot type carries `std::atomic owner`, where zero means free. + +// `owner` is claimed with compare_exchange, since a load then a store let two threads share a slot. +/// The slot this thread owns in `slots`, taking a free one when `claim` is set; null when none is free. +template +inline Slot* ownedThreadSlot(Slot (&slots)[N], bool claim) MM_NONBLOCKING { + const uintptr_t me = platform::currentThreadId(); + for (size_t i = 0; i < N; i++) + if (slots[i].owner.load(std::memory_order_acquire) == me) return &slots[i]; + if (!claim) return nullptr; + for (size_t i = 0; i < N; i++) { + uintptr_t free = 0; + if (slots[i].owner.compare_exchange_strong(free, me, std::memory_order_acq_rel, + std::memory_order_relaxed)) + return &slots[i]; + } + return nullptr; +} + +/// Hand a slot back, once everything it carries is detached. +template +inline void releaseThreadSlot(Slot* s) MM_NONBLOCKING { + if (s) s->owner.store(0, std::memory_order_release); +} + +/// @} + +} // namespace mm diff --git a/src/light/util/WLEDAudioSyncPacket.h b/src/core/util/WLEDAudioSyncPacket.h similarity index 100% rename from src/light/util/WLEDAudioSyncPacket.h rename to src/core/util/WLEDAudioSyncPacket.h diff --git a/src/core/util/color.h b/src/core/util/color.h index c371a7935..a3e501760 100644 --- a/src/core/util/color.h +++ b/src/core/util/color.h @@ -35,6 +35,21 @@ constexpr RGB hsvToRgb(uint8_t h, uint8_t s, uint8_t v) { } } +/// Integer RGB → (hue 0..359, saturation 0..255). +constexpr void rgbToHueSat(uint8_t r, uint8_t g, uint8_t b, uint16_t& hue, uint16_t& sat) { + const uint8_t mx = r > g ? (r > b ? r : b) : (g > b ? g : b); + const uint8_t mn = r < g ? (r < b ? r : b) : (g < b ? g : b); + const uint8_t delta = static_cast(mx - mn); + if (delta == 0 || mx == 0) { hue = 0; sat = 0; return; } + sat = static_cast((static_cast(delta) * 255) / mx); + int32_t h; + if (mx == r) h = 60 * (static_cast(g) - b) / delta; + else if (mx == g) h = 120 + 60 * (static_cast(b) - r) / delta; + else h = 240 + 60 * (static_cast(r) - g) / delta; + if (h < 0) h += 360; + hue = static_cast(h % 360); +} + /// Scale a channel by a fraction, corrected so a full scale returns the value unchanged. constexpr uint8_t scale8(uint8_t val, uint8_t scale) { return static_cast(((static_cast(val) * static_cast(scale)) + 1 + ((static_cast(val) * static_cast(scale)) >> 8)) >> 8); diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h index 13448e26f..c23ff18ef 100644 --- a/src/light/drivers/DriverBase.h +++ b/src/light/drivers/DriverBase.h @@ -110,6 +110,13 @@ class DriverBase : public MoonModule { MoonModule::onControlChanged(name); } + // The correction alone rather than a prepare, which would reinitialize the output and blank it for a tick. + /// Re-resolve the fixture profile when the profile library's rows change. + void onListChanged(const MoonModule& owner) override { + if (&owner == static_cast(FixtureProfilesModule::active())) + rebuildCorrection(lastGlobalBrightness_); + } + /// Notified when the output channel count may have changed without a structural rebuild. virtual void onCorrectionChanged() {} diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 5426da850..cd6b59a5f 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -12,8 +12,8 @@ #include "light/drivers/Correction.h" #include "light/util/Palette.h" // the global active palette + its select control #include "light/moonlive/MoonLivePalette.h" // a palette computed per frame by a script -#include "light/moonlive/script_catalog.h" // the tags each factory palette declares -#include "core/util/LightSummary.h" // the POD published for the domain-neutral WLED/MQTT consumers +#include "core/moonlive/script_catalog.h" // the tags each factory palette declares +#include "core/util/LightOutput.h" // the seam the domain-neutral WLED, MQTT and MoonStats consumers read #include "platform/platform.h" #include // std::strcmp in onControlChanged @@ -42,19 +42,27 @@ namespace mm { /// Capital `Drivers` is this container; lowercase "driver" is one `DriverBase` child. /// /// @card Drivers.png -class Drivers : public MoonModule { +class Drivers : public MoonModule, public LightOutput { public: // Drivers only, so the picker is not buried under every generic system module. /// Which child roles the "+ add" picker offers under Drivers. const char* acceptsChildRoles() const override { return "driver"; } - // Defaulted to an all-zero summary, so a consumer always reads a valid POD rather than null. - /// The live light-pipeline summary, for the domain-neutral core consumers. - static const LightSummary* latestSummary() { - static const LightSummary kNone{}; - Drivers* a = ActiveInstance::active(); - return a ? &a->summary_ : &kNone; + /// The live light-pipeline summary. + const LightSummary& summary() const override { return summary_; } + /// The built-in palette nearest a color wheel's hue and saturation. + uint8_t nearestPalette(uint16_t hue, uint8_t sat) const override { return Palettes::nearestForHue(hue, sat); } + // A script computes its entries only while it runs, so a scripted index reads the active palette, which is the one a caller asks about. + /// A palette's representative hue and saturation, built-in or scripted. + void paletteHueSat(uint8_t index, uint16_t& hue, uint16_t& sat) const override { + if (LivePalettes::isLive(index)) Palettes::hueSatOf(*Palettes::active(), hue, sat); + else Palettes::representativeHueSat(index, hue, sat); } + /// The built-ins and the scripted tail. + uint8_t paletteCount() const override { return mm::paletteCount(); } + /// Every palette name, in picker order. + void writePaletteNames(JsonSink& sink) const override { mm::paletteNames(sink); } + using LightOutput::nearestPalette; // the RGB form, beside the override // Each seat REFERENCES this object's members, so leaving one published dangles a live reader. /// Release the children, then vacate every static seat this container published. @@ -63,6 +71,7 @@ class Drivers : public MoonModule { stopEncodeTask(); renderSplitActive_ = false; seat_.vacate(); + outputSeat_.vacate(); // The palette seam references this object's name arrays rather than copying them. LivePalettes::clear(livePtrs_); // Effects::tick runs this every frame, so a dead Drivers would keep executing its script. @@ -473,23 +482,11 @@ class Drivers : public MoonModule { // One POD, overwritten in place, pulled by the domain-neutral consumers. summary_.lightCount = out ? static_cast(out->physicalLightCount()) : 0; summary_.channelsPerLight = out ? out->channelsPerLight() : 3; - seat_.claim(); // first live Drivers wins the summary seat (claim-if-empty; one exists in practice) + seat_.claim(); // first live Drivers wins (claim-if-empty; one exists in practice) + outputSeat_.claim(); // and is the light output core reads passBufferToDrivers(); } - // Logical channel order: the per-strip wire reorder is applied later, by the drivers. - /// The first driven light's RGB, for a consumer that shows one color for the device. - bool firstOutputRgb(uint8_t out[3]) const override { - const Buffer* src = nullptr; - if (outputBuffer_.data()) src = &outputBuffer_; - else if (Layer* l = effects_ ? effects_->firstEnabledLayer() : layer_; l && l->buffer().data()) - src = &l->buffer(); - if (!src || src->count() == 0 || src->channelsPerLight() < 3) return false; - const uint8_t* p = src->data(); - out[0] = p[0]; out[1] = p[1]; out[2] = p[2]; - return true; - } - void tick() MM_NONBLOCKING override { // Core 1 is encoding the PREVIOUS frame, so wait it out before overwriting the buffer. if (renderSplitActive_) { @@ -543,6 +540,7 @@ class Drivers : public MoonModule { // The RAII vacate is the same dangling-static guard the mic and registry seats use. LightSummary summary_; ActiveInstance seat_{*this}; + ActiveInstance outputSeat_{*this}; // The boundary is one shared buffer: core 0 waits on encodeDone_ before overwriting it. platform::WorkerTask encodeTask_{}; diff --git a/src/light/drivers/Hub75Slots.h b/src/light/drivers/Hub75Slots.h index 9b1e0bdde..46b91019f 100644 --- a/src/light/drivers/Hub75Slots.h +++ b/src/light/drivers/Hub75Slots.h @@ -146,8 +146,7 @@ inline size_t hub75Encode(const uint8_t* rgb, uint8_t* out, const uint16_t darkWord = addrMask(latched); const uint16_t litWord = static_cast(darkWord | (1u << lay.oe)); const uint16_t latchWord = static_cast(addrMask(r) | (1u << lay.lat)); - // This plane's lit window, in words: the top plane fills the row less the latch word, each lower plane half the one above, all scaled by brightness. - // 32-bit throughout: a row of 2,048 words at full brightness and 6 bits stays under 17 million. + // Lit words: the top plane fills the row less its latch, each lower plane half the one above; 32 bits hold 2,048 words at 6 bits. const uint32_t span = 255u << (geo.bitDepth - 1); const uint32_t litWords = (static_cast(rowWords - 1) * geo.brightness * (1u << plane) + span / 2) / span; diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index a4496425d..f3dd18fc6 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -4,7 +4,7 @@ #include "core/moonlive/MoonLiveBuiltins.h" #include "core/moonlive/MoonLiveBuiltins_common.h" // the neutral half: math, waveforms, noise, print -#include "core/moonlive/MoonLive.h" // runDefineControls drives the engine +#include "core/moonlive/MoonLive.h" #include "core/moonlive/MoonLiveIr.h" // kArg3: the register `t` is passed in #include @@ -15,6 +15,7 @@ #include "core/util/math16.h" // beat16 / triwave16: full-range waveforms #include "light/powerfunctions/shader.h" // shader::smoothstep, the GLSL vocabulary, already in fixed point #include "core/util/noise.h" // inoise8: the shared gradient-noise field +#include "core/util/ThreadSlot.h" // the per-thread table the sinks live in #include #include "core/services/AudioService.h" // the audio vocabulary reads the latest frame #include "light/powerfunctions/draw.h" // draw::line, the shared 3D Bresenham a script draws with @@ -181,12 +182,6 @@ using AddLightFn = void (*)(void* ctx, uint16_t x, uint16_t y, uint16_t z); /// The addLight sink for one thread, installed by the binding around each run. struct AddLightSink { AddLightFn fn = nullptr; void* ctx = nullptr; }; -// A builtin has no receiver, so the binding installs one for the run. -/// Where a running `defineControls()` sends each `addControl`. -using AddControlFn = void (*)(void* ctx, const char* name, uint8_t offset, - int32_t lo, int32_t hi, CtrlType type); -struct AddControlSink { AddControlFn fn = nullptr; void* ctx = nullptr; }; - // Forwarded to the layer, which applies one amount per frame, so the longest trail wins. /// Where fade(amt) sends its request. using FadeFn = void (*)(void* ctx, uint8_t amt); @@ -237,9 +232,8 @@ struct FlowSink { }; namespace detail { -// `owner` is claimed with compare_exchange, since a load then a store let two threads share a slot. +/// Every sink one run installs, in the slot its thread claims (ThreadSlot.h). struct SinkSlot { std::atomic owner{0}; AddLightSink sink; draw::Canvas canvas; - AddControlSink controls; ///< where a defineControls run sends addControl FadeSink fade; ///< where fade sends its request MotionSink motion; ///< where the motion setters send their writes CoordSink coord; ///< where setXYZ sends a folded coordinate @@ -251,7 +245,6 @@ struct SinkSlot { std::atomic owner{0}; AddLightSink sink; draw::Canv }; // Two slots, constinit rather than function-local statics, whose thread-safe guard is a lock. inline constinit SinkSlot gSinkSlots[2]{}; -inline SinkSlot* sinkSlots() MM_NONBLOCKING { return gSinkSlots; } // Never installed into, since a shared sink would let two overflow threads alias each other. /// Permanently empty, so a third concurrent runner's addLight calls no-op. inline const AddLightSink& sinkOverflow() { static const AddLightSink s; return s; } @@ -259,27 +252,15 @@ inline const AddLightSink& sinkOverflow() { static const AddLightSink s; return inline const draw::Canvas& canvasOverflow() { static const draw::Canvas c{}; return c; } /// The slot this thread owns, taking a free one when `claim` is set. -inline SinkSlot* ownedSlot(bool claim) MM_NONBLOCKING { - const uintptr_t me = platform::currentThreadId(); - SinkSlot* slots = sinkSlots(); - for (uint8_t i = 0; i < 2; i++) - if (slots[i].owner.load(std::memory_order_acquire) == me) return &slots[i]; - if (!claim) return nullptr; - for (uint8_t i = 0; i < 2; i++) { - uintptr_t free = 0; - if (slots[i].owner.compare_exchange_strong(free, me, std::memory_order_acq_rel, - std::memory_order_relaxed)) - return &slots[i]; - } - return nullptr; -} +inline SinkSlot* ownedSlot(bool claim) MM_NONBLOCKING { return ownedThreadSlot(gSinkSlots, claim); } // The halves detach independently, so an early release hands a live context to the next claimer. /// Release the slot once every sink it carries is detached. inline void releaseIfEmpty(SinkSlot* s) MM_NONBLOCKING { // Every sink the slot carries, since one unnamed here lets a claimer reach an ended run. - if (s && !s->sink.fn && !s->sink.ctx && !s->canvas.data && !s->controls.fn && !s->fade.fn && - !s->motion.fn && !s->coord.fn && !s->poolSize.fn && !s->pool.pool) - s->owner.store(0, std::memory_order_release); + if (s && !s->sink.fn && !s->sink.ctx && !s->canvas.data && !s->fade.fn && + !s->motion.fn && !s->coord.fn && !s->pal.fn && !s->poolSize.fn && !s->pool.pool && + !s->flow.a && !s->trailSize.fn) + releaseThreadSlot(s); } } // namespace detail @@ -290,13 +271,6 @@ inline const AddLightSink& addLightSink() { return s ? s->sink : detail::sinkOverflow(); } -/// The control sink for this thread, or an empty one; reading claims no slot. -inline const AddControlSink& addControlSink() { - detail::SinkSlot* s = detail::ownedSlot(false); - static constinit AddControlSink none{}; - return s ? s->controls : none; -} - /// The fade sink for this thread, or an empty one; reading claims no slot. inline const FadeSink& fadeSink() MM_NONBLOCKING { detail::SinkSlot* s = detail::ownedSlot(false); @@ -423,16 +397,6 @@ inline void setFlowSink(const FlowSink& f) MM_NONBLOCKING { if (!f.a) detail::releaseIfEmpty(s); } -// False when the table is full, which the caller must not treat as an installed sink. -/// Point addControl at a consumer for one defineControls run; nullptr to detach. -inline bool setAddControlSink(AddControlFn fn, void* ctx) { - detail::SinkSlot* s = detail::ownedSlot(fn != nullptr); - if (!s) return false; - s->controls = {fn, ctx}; - if (!fn) detail::releaseIfEmpty(s); - return true; -} - // Detaching releases the slot unless another half is live, so passing tasks do not exhaust it. /// Point addLight at a consumer for one run; nullptr to detach. inline void setAddLightSink(AddLightFn fn, void* ctx) { @@ -447,31 +411,6 @@ inline void setAddLightSink(AddLightFn fn, void* ctx) { if (s) s->sink = {fn, ctx}; } -// The compiler builds the record, so nothing travels through a buffer freed by the time this runs. -/// The one control declaration: args are name, member offset, min and max. -inline uint32_t addControlDecl(const uintptr_t* args, CtrlType type) { - // The name points into the compiled program's string pool, which outlives the run. - const char* name = reinterpret_cast(args[0]); - const AddControlSink s = addControlSink(); - if (!name || !s.fn || !s.ctx) return 0; // no binding listening: the call is a no-op - // An arbitrary expression can exceed the member's type, and a wrapped slider top is invisible. - const int32_t lo = int32_t(args[2]), hi = int32_t(args[3]); - const int32_t limit = (type == CtrlType::Byte) ? 255 : (type == CtrlType::Bool) ? 1 : INT32_MAX; - if (lo > limit || hi > limit) return 0; - // A byte and a bool are unsigned, so a negative low bound became min 251 with max 100. - if ((type == CtrlType::Byte || type == CtrlType::Bool) && lo < 0) return 0; - // With min above max the write path rejects every value the slider could offer. - if (lo > hi) return 0; - s.fn(s.ctx, name, static_cast(args[1] & 0xff), lo, hi, type); - return 0; -} - -// The compiler packs both into args[1]: the low byte is the arena offset, the next the type. -/// Surface a member as a control within a range; its declared type decides the kind. -extern "C" inline uint32_t mm_light_addControl(const uintptr_t* args, uint32_t, const uint8_t*) { - return addControlDecl(args, static_cast((args[1] >> 8) & 0xff)); -} - /// Place one light at the script's coordinates through this thread's layout sink. extern "C" inline uint32_t mm_light_addLight(const uintptr_t* args, uint32_t, const uint8_t*) { const uint32_t x = uint32_t(args[0]), y = uint32_t(args[1]), z = uint32_t(args[2]); @@ -567,8 +506,10 @@ extern "C" inline uint32_t mm_light_fieldRate(const uintptr_t* args, uint32_t, c return (*f.frame % n) == 0 ? 1u : 0u; } -/// Advect the trail along a noise field: two decoupled samples, one per axis. -extern "C" inline uint32_t mm_light_flowNoise(const uintptr_t* args, uint32_t, const uint8_t*) { +// One home for the two flow builtins' shape: cell scale, strength and time from the arguments, one advect, then the planes swap. +/// Advect the trail by a velocity field sampled at each light's scaled, time-drifted position. +template +inline uint32_t advectTrail(const uintptr_t* args, Field&& field) { FlowSink& f = flowSink(); if (!f.live() || !f.spare()) return 0; const uint32_t cells = (uint32_t(args[0]) ? uint32_t(args[0]) : 1u) * 256u; @@ -576,35 +517,34 @@ extern "C" inline uint32_t mm_light_flowNoise(const uintptr_t* args, uint32_t, c const uint32_t t = platform::millis(); draw::advect16(f.spare(), f.live(), f.w, f.h, f.d, [&](lengthType x, lengthType y, lengthType z, draw::pos_t& vx, draw::pos_t& vy) { - const uint32_t fx = uint32_t(x) * cells, fy = uint32_t(y) * cells; - const uint32_t fz = uint32_t(z) * cells + t / 4u; - const int32_t nx = int32_t(inoise16(fx, fy, fz)) - 32768; - const int32_t ny = int32_t(inoise16(fx + 0x9E37u, fy + 0x7C15u, fz)) - 32768; - // 64-bit, since an unbounded `strength` wraps a 32-bit product. - vx = draw::pos_t((static_cast(nx) * strength) >> 15); - vy = draw::pos_t((static_cast(ny) * strength) >> 15); + field(uint32_t(x) * cells, uint32_t(y) * cells, uint32_t(z) * cells + t / 4u, + strength, vx, vy); }, draw::Edge::Clamp); *f.front = !*f.front; // the destination now holds the trail return 0; } +/// Advect the trail along a noise field: two decoupled samples, one per axis. +extern "C" inline uint32_t mm_light_flowNoise(const uintptr_t* args, uint32_t, const uint8_t*) { + return advectTrail(args, [](uint32_t fx, uint32_t fy, uint32_t fz, int32_t strength, + draw::pos_t& vx, draw::pos_t& vy) { + const int32_t nx = int32_t(inoise16(fx, fy, fz)) - 32768; + const int32_t ny = int32_t(inoise16(fx + 0x9E37u, fy + 0x7C15u, fz)) - 32768; + // 64-bit, since an unbounded `strength` wraps a 32-bit product. + vx = draw::pos_t((static_cast(nx) * strength) >> 15); + vy = draw::pos_t((static_cast(ny) * strength) >> 15); + }); +} + /// Advect the trail along a curl field: the same, but divergence-free, so nothing clumps. extern "C" inline uint32_t mm_light_flowCurl(const uintptr_t* args, uint32_t, const uint8_t*) { - FlowSink& f = flowSink(); - if (!f.live() || !f.spare()) return 0; - const uint32_t cells = (uint32_t(args[0]) ? uint32_t(args[0]) : 1u) * 256u; - const int32_t strength = signedArg(args[1]); - const uint32_t t = platform::millis(); - draw::advect16(f.spare(), f.live(), f.w, f.h, f.d, - [&](lengthType x, lengthType y, lengthType z, draw::pos_t& vx, draw::pos_t& vy) { - int32_t cx = 0, cy = 0; - curl16(uint32_t(x) * cells, uint32_t(y) * cells, - uint32_t(z) * cells + t / 4u, strength, cx, cy); - vx = draw::pos_t(cx); - vy = draw::pos_t(cy); - }, draw::Edge::Clamp); - *f.front = !*f.front; - return 0; + return advectTrail(args, [](uint32_t fx, uint32_t fy, uint32_t fz, int32_t strength, + draw::pos_t& vx, draw::pos_t& vy) { + int32_t cx = 0, cy = 0; + curl16(fx, fy, fz, strength, cx, cy); + vx = draw::pos_t(cx); + vy = draw::pos_t(cy); + }); } // Named `trailDecay`, since a builtin reserves the name and scripts declare their own `decay`. @@ -884,8 +824,6 @@ inline void writeSysVarSlot(uint8_t* arenaSlot, uint32_t value) MM_NONBLOCKING { // A name is a moment rather than a role, so an entry a script did not define is not called. /// The entry points the light domain calls, looked up by name in the emitted block. inline constexpr const char* kEntryTick = "tick"; // an effect, per frame -// Run once after a successful compile, where a compiled module's defineControls() sits. -inline constexpr const char* kEntryDefineControls = "defineControls"; inline constexpr const char* kEntryPlaceLights = "placeLights"; // a layout, placing lights inline constexpr const char* kEntryModify = "modifyLogical"; // a modifier, folding one light @@ -996,9 +934,6 @@ inline const BuiltinTable& lightBuiltins() { t.add({"line", 7, /*returns*/ false, BuiltinKind::Call, &mm_light_line, {}}); // circle draws an outline of a given stroke width, the shape a script cannot build from line alone. Named for draw::circle, and NOT `ring`, which a shipped layout already defines as its own function. t.add({"circle", 7, /*returns*/ false, BuiltinKind::Call, &mm_light_circle, {}}); - // Bit 1 of byRef marks the member, so the compiler passes its offset and type, not its value. - t.add({"addControl", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_addControl, {}, - /*byRef*/ 0x2, /*byStr*/ 0x1}); // setPaletteColor writes one palette-colored pixel, with one brightness evaluation. t.add({"setPaletteColor", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPaletteColor, {}}); // The volumetric write, so a script paints a cube rather than its first slice. @@ -1017,27 +952,6 @@ inline const BuiltinTable& lightBuiltins() { return table; } -// Re-runnable like its compiled counterpart, since the declared list is cleared first. -/// Run a script's defineControls, so the controls it declares exist. -inline void runDefineControls(MoonLive& engine, PoolSizeFn sizePool = nullptr, void* poolCtx = nullptr, - TrailSizeFn sizeTrail = nullptr, void* trailCtx = nullptr) { - if (!engine.hasEntry(kEntryDefineControls)) return; // nothing to clear and nothing to run - // Install before clearing, since a clear-then-run with the table full would drop every control. - if (!setAddControlSink([](void* ctx, const char* n, uint8_t off, - int32_t lo, int32_t hi, CtrlType type) { - static_cast(ctx)->addDeclaredControl(n, off, lo, hi, type); - }, &engine)) return; - if (sizePool) setPoolSizeSink(sizePool, poolCtx); - if (sizeTrail) setTrailSizeSink(sizeTrail, trailCtx); - engine.clearDeclaredControls(); // re-runnable: rebuild rather than append - // This entry point writes no pixels, but `run` refuses a null or undersized buffer. - uint8_t scratch[3] = {}; - engine.run(scratch, 1, 3, 0, kEntryDefineControls); - if (sizePool) setPoolSizeSink(nullptr, nullptr); - if (sizeTrail) setTrailSizeSink(nullptr, nullptr); - setAddControlSink(nullptr, nullptr); -} - /// @} } // namespace mm::moonlive diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index fc5210960..72a4c3f90 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -2,7 +2,7 @@ #include "light/effects/EffectBase.h" #include "core/moonlive/MoonLive.h" -#include "light/moonlive/MoonLiveScript.h" +#include "core/moonlive/MoonLiveScript.h" #include "light/moonlive/MoonLiveParticles.h" #include "light/moonlive/MoonLiveBuiltins_light.h" #include @@ -10,6 +10,27 @@ namespace mm { +namespace moonlive { + +/// What a new effect script starts out as: a working example, since an empty file fails to parse the moment it is made. +inline constexpr const char* kEffectTemplate = + "class NewEffect {\n" + " byte bpm = 60;\n" + "\n" + " void defineControls() {\n" + " addControl(\"bpm\", bpm, 1, 255);\n" + " }\n" + "\n" + " void tick() {\n" + " fill(scale(beat(bpm, t), 256), 0, 100);\n" + " }\n" + "}\n"; + +/// What the `script` control tells the UI: the directory, the extension and the new-file template. +inline constexpr const char* kEffectPick[3] = {kScriptDir, kEffectExt, kEffectTemplate}; + +} // namespace moonlive + /// The thin binding between the MoonLive engine and a first-class `EffectBase`. /// /// @moreinfo @@ -53,15 +74,9 @@ class MoonLiveEffect : public EffectBase { void prepare() override { // The next tick is the first, so the idle interval is not handed to the flow and the decay. tickStarted_ = false; - // The script sizes its own pool from defineControls(), which sync() runs after a compile. - script_.setPoolSizer([](void* ctx, uint16_t n) -> uint16_t { - return static_cast(ctx)->particles_.resize(n); - }, this); - // Two 16-bit planes are 96 KB on a 20-cube, so only a script that advects pays for them. - script_.setTrailSizer([](void* ctx, bool want) -> bool { - return static_cast(ctx)->resizeTrail(want); - }, this); - script_.sync(moonlive::effectSysVars(), *this); + // The script sizes its own pool and trail from defineControls(), which sync() runs after a compile. + script_.setDefineHook(&MoonLiveEffect::attachSizers, this); + script_.sync(moonlive::effectSysVars(), *this, moonlive::lightBuiltins()); // The planes follow the fixture, so a resize re-sizes them though the script did not. if (trailWanted_) resizeTrail(true); // Unconditional, since a walk costs little and keeps the card correct after any prepare. @@ -152,6 +167,21 @@ class MoonLiveEffect : public EffectBase { moonlive::writeSysVarSlot(script_.engine().controlSlot(offset), value); } + // Two 16-bit planes are 96 KB on a 20-cube, so only a script that advects pays for them. + /// Attach pool(n) and trail(1) to this effect for one defineControls run, or detach them. + static void attachSizers(void* ctx, bool attach) { + if (!attach) { + moonlive::setPoolSizeSink(nullptr, nullptr); + moonlive::setTrailSizeSink(nullptr, nullptr); + return; + } + moonlive::setPoolSizeSink([](void* c, uint16_t n) -> uint16_t { + return static_cast(c)->particles_.resize(n); + }, ctx); + moonlive::setTrailSizeSink([](void* c, bool want) -> bool { + return static_cast(c)->resizeTrail(want); + }, ctx); + } // A fresh card starts with no script and renders nothing until one is named. moonlive::MoonLiveScript script_; diff --git a/src/light/moonlive/MoonLiveLayout.h b/src/light/moonlive/MoonLiveLayout.h index f8476f024..210a1b306 100644 --- a/src/light/moonlive/MoonLiveLayout.h +++ b/src/light/moonlive/MoonLiveLayout.h @@ -1,7 +1,7 @@ #pragma once #include "core/moonlive/MoonLive.h" -#include "light/moonlive/MoonLiveScript.h" +#include "core/moonlive/MoonLiveScript.h" #include "light/layouts/LayoutBase.h" #include "light/moonlive/MoonLiveBuiltins_light.h" #include @@ -9,6 +9,33 @@ namespace mm { +namespace moonlive { + +/// What a new layout script starts out as. +inline constexpr const char* kLayoutTemplate = + "class NewLayout {\n" + " byte cols = 16;\n" + " byte rows = 16;\n" + "\n" + " void defineControls() {\n" + " addControl(\"cols\", cols, 1, 64);\n" + " addControl(\"rows\", rows, 1, 64);\n" + " }\n" + "\n" + " void placeLights() {\n" + " for (y = 0; y < rows; y = y + 1) {\n" + " for (x = 0; x < cols; x = x + 1) {\n" + " addLight(x, y, 0);\n" + " }\n" + " }\n" + " }\n" + "}\n"; + +/// What the `script` control tells the UI: the directory, the extension and the new-file template. +inline constexpr const char* kLayoutPick[3] = {kScriptDir, kLayoutExt, kLayoutTemplate}; + +} // namespace moonlive + /// Where the lights physically are, written as text on a running device. /// /// @moreinfo @@ -99,7 +126,7 @@ class MoonLiveLayout : public LayoutBase { void compile() const { // A content hash makes an unchanged call cost a read rather than a recompile. auto* self = const_cast(this); - self->script_.sync(moonlive::layoutSysVars(), *self); + self->script_.sync(moonlive::layoutSysVars(), *self, moonlive::lightBuiltins()); } struct Counter { nrOfLightsType n; }; diff --git a/src/light/moonlive/MoonLiveModifier.h b/src/light/moonlive/MoonLiveModifier.h index e4c229166..51508cc32 100644 --- a/src/light/moonlive/MoonLiveModifier.h +++ b/src/light/moonlive/MoonLiveModifier.h @@ -1,7 +1,7 @@ #pragma once #include "core/moonlive/MoonLive.h" -#include "light/moonlive/MoonLiveScript.h" +#include "core/moonlive/MoonLiveScript.h" #include "light/modifiers/ModifierBase.h" #include "light/moonlive/MoonLiveBuiltins_light.h" #include @@ -9,6 +9,21 @@ namespace mm { +namespace moonlive { + +/// What a new modifier script starts out as. +inline constexpr const char* kModifierTemplate = + "class NewModifier {\n" + " void modifyLogical() {\n" + " setXYZ(width - 1 - xPos, yPos, zPos);\n" + " }\n" + "}\n"; + +/// What the `script` control tells the UI: the directory, the extension and the new-file template. +inline constexpr const char* kModifierPick[3] = {kScriptDir, kModifierExt, kModifierTemplate}; + +} // namespace moonlive + /// A coordinate transform authored live rather than compiled in. /// /// @moreinfo @@ -51,7 +66,7 @@ class MoonLiveModifier : public ModifierBase { /// Compile the script as written. void prepare() override { // Only when a new program was installed, since the Layer's rebuild calls prepare() again. - if (script_.sync(moonlive::modifierSysVars(), *this)) needsRebuild_ = true; + if (script_.sync(moonlive::modifierSysVars(), *this, moonlive::lightBuiltins())) needsRebuild_ = true; rebuildControls(); } diff --git a/src/light/moonlive/MoonLivePalette.h b/src/light/moonlive/MoonLivePalette.h index 8d2275601..a470517aa 100644 --- a/src/light/moonlive/MoonLivePalette.h +++ b/src/light/moonlive/MoonLivePalette.h @@ -1,12 +1,35 @@ #pragma once #include "core/moonlive/MoonLive.h" -#include "light/moonlive/MoonLiveScript.h" +#include "core/moonlive/MoonLiveScript.h" #include "light/moonlive/MoonLiveBuiltins_light.h" #include "light/util/Palette.h" namespace mm { +namespace moonlive { + +/// What a new palette script starts out as. +inline constexpr const char* kPaletteTemplate = + "class NewPalette {\n" + " byte bpm = 20;\n" + "\n" + " void defineControls() {\n" + " addControl(\"bpm\", bpm, 1, 120); // how fast the colors move\n" + " }\n" + "\n" + " void tick() {\n" + " for (int i = 0; i < 16; i = i + 1) {\n" + " setPalEntryHSV(i, scale(beat(bpm, t), 256) + i * 16, 255, 255);\n" + " }\n" + " }\n" + "}\n"; + +/// What the `script` control tells the UI: the directory, the extension and the new-file template. +inline constexpr const char* kPalettePick[3] = {kScriptDir, kPaletteExt, kPaletteTemplate}; + +} // namespace moonlive + /// A palette authored as code rather than stored as data. /// /// @moreinfo diff --git a/src/light/util/Palette.h b/src/light/util/Palette.h index 64448d590..068043084 100644 --- a/src/light/util/Palette.h +++ b/src/light/util/Palette.h @@ -238,30 +238,7 @@ class Palettes { return p; } - // --- Representative color, for mapping an external color wheel onto a palette --- - - /// Representative hue (0..359) of built-in `index`. - static uint16_t representativeHue(uint8_t index) { - uint16_t hue = 0, sat = 0; - representativeHueSat(index, hue, sat); - return hue; - } - - /// Representative RGB color of built-in `index`: the palette's identity color, for any external - static RGB representativeRgb(uint8_t index) { - uint16_t hue = 0, sat = 0; - representativeHueSat(index, hue, sat); - // hue 0..359 → h 0..255 for hsvToRgb's 6-sector integer map (Palette.h convention. - return hsvToRgb(static_cast((hue * 256u) / 360u), - static_cast(sat), 255); - } - - /// RGB to the nearest palette: the one call every RGB-input consumer should reach for rather - static uint8_t nearestForRgb(uint8_t r, uint8_t g, uint8_t b) { - uint16_t hue = 0, sat = 0; - rgbToHueSat(r, g, b, hue, sat); - return nearestForHue(hue, static_cast(sat)); - } + // --- Representative color, for mapping an external color wheel onto a palette --- /// The palette whose representative (hue, sat) is closest to the target, by 2D distance in (circular-hue, saturation) space. static uint8_t nearestForHue(uint16_t hue, uint8_t sat) { @@ -285,7 +262,11 @@ class Palettes { /// Compute a palette's representative hue (0..359) + saturation (0..255) from the average of its expanded RGB entries. static void representativeHueSat(uint8_t index, uint16_t& hueOut, uint16_t& satOut) { - const Palette p = fromBuiltin(index); + hueSatOf(fromBuiltin(index), hueOut, satOut); + } + + /// A palette's representative hue (0..359) and saturation (0..255), from the average of its entries. + static void hueSatOf(const Palette& p, uint16_t& hueOut, uint16_t& satOut) { uint32_t rs = 0, gs = 0, bs = 0; for (uint8_t i = 0; i < Palette::kEntries; i++) { rs += p.entry[i].r; gs += p.entry[i].g; bs += p.entry[i].b; @@ -297,21 +278,6 @@ class Palettes { } private: - /// Integer RGB → (hue 0..359, saturation 0..255). - static void rgbToHueSat(uint8_t r, uint8_t g, uint8_t b, uint16_t& hue, uint16_t& sat) { - const uint8_t mx = r > g ? (r > b ? r : b) : (g > b ? g : b); - const uint8_t mn = r < g ? (r < b ? r : b) : (g < b ? g : b); - const uint8_t delta = static_cast(mx - mn); - if (delta == 0 || mx == 0) { hue = 0; sat = 0; return; } - sat = static_cast((static_cast(delta) * 255) / mx); - int32_t h; - if (mx == r) h = 60 * (static_cast(g) - b) / delta; - else if (mx == g) h = 120 + 60 * (static_cast(b) - r) / delta; - else h = 240 + 60 * (static_cast(r) - g) / delta; - if (h < 0) h += 360; - hue = static_cast(h % 360); - } - /// Default to a full rainbow (index 0). static inline Palette active_ = fromBuiltin(0); }; diff --git a/src/light/util/light_types.h b/src/light/util/light_types.h index 5732a6a38..2e4fb7919 100644 --- a/src/light/util/light_types.h +++ b/src/light/util/light_types.h @@ -48,13 +48,6 @@ struct Coord3D { /// A count of lights, and the index the mapping stores: wider with PSRAM, half the size without. using nrOfLightsType = std::conditional_t; -/// Which axes a module works in, so the layer can extrude lower-dimensional output across the rest. -enum class Dim : uint8_t { - D1 = 1, - D2 = 2, - D3 = 3, -}; - /// @} } // namespace mm diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp index 09f9fd26a..bab475b5a 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.cpp +++ b/src/platform/esp32/moonlive_asm_xtensa.cpp @@ -221,11 +221,22 @@ void XtensaAssembler::callLabel(Label l, Reg d, bool take) { // A single instruction serves the common small case; without it a constant above a byte truncates. // The address form below is for a host call that reads its arguments from the frame. // The slots already hold them and the call passes where they start, which makes the argument count a memory question rather than a register one. +// addi carries a signed byte, so a slot past byte 127 takes addmi's 256-byte step first, as a compiler builds any large frame offset. void XtensaAssembler::slotAddr(Reg d, uint8_t slot) { if (slot >= kMaxSpillSlots) { overflow_ = true; return; } - const uint32_t off = kFrameBase + uint32_t(slot) * kSlotStride; - const uint8_t b[3] = {uint8_t((ar(d) << 4) | 0x2), uint8_t(0xc0 | 1), uint8_t(off)}; - emit(b, 3); // addi aD, a1, #off + const int32_t off = static_cast(kFrameBase + uint32_t(slot) * kSlotStride); + const uint8_t dr = ar(d); + uint8_t base = 1; // a1 + int32_t lo = off; + if (off > 127) { + const int32_t hi = (off + 128) >> 8; // leaves lo in -128..127 + const uint8_t m[3] = {uint8_t((dr << 4) | 0x2), uint8_t(0xd0 | 1), uint8_t(hi)}; + emit(m, 3); // addmi aD, a1, #hi*256 + lo = off - hi * 256; + base = dr; + } + const uint8_t b[3] = {uint8_t((dr << 4) | 0x2), uint8_t(0xc0 | base), uint8_t(static_cast(lo))}; + emit(b, 3); // addi aD, aBase, #lo } void XtensaAssembler::movImm(Reg d, int32_t imm) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a6b379de8..e7e22116a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -21,6 +21,8 @@ add_executable(mm_tests unit/core/unit_AudioService_codec.cpp unit/core/unit_I2cBusModule.cpp unit/core/unit_AudioService_sync.cpp + unit/core/unit_WledAudioSyncPacket.cpp + unit/core/unit_ThreadSlot.cpp unit/core/unit_ButtonService.cpp unit/core/unit_GamepadService.cpp unit/core/unit_MoonLiveService.cpp @@ -108,7 +110,6 @@ add_executable(mm_tests unit/light/unit_RtspSession.cpp unit/light/unit_PanelCardDriver.cpp unit/light/unit_PanelCardDriver_packet.cpp - unit/light/unit_WledAudioSyncPacket.cpp unit/light/unit_BlendMap.cpp unit/light/unit_BeatRipples.cpp unit/light/unit_ColorTrailsEffect.cpp @@ -174,7 +175,6 @@ add_executable(mm_tests unit/light/unit_Drivers_rendersplit.cpp unit/light/unit_FixtureProfilesModule.cpp unit/light/unit_DriverBase_fixture_persistence.cpp - unit/light/unit_Drivers_firstOutputRgb.cpp unit/light/unit_HueDriver.cpp unit/light/unit_Palette.cpp unit/light/unit_WaveEffect.cpp diff --git a/test/python/test_check_code.py b/test/python/test_check_code.py index db0a00a66..897789d29 100644 --- a/test/python/test_check_code.py +++ b/test/python/test_check_code.py @@ -117,16 +117,16 @@ def test_a_blocking_call_on_the_render_path_is_a_row_per_site(monkeypatch): monkeypatch.setattr(nb, "float_conversions_on_the_hot_path", lambda: ["src/light/b.h:42"]) monkeypatch.setattr(nb.check_clang_tidy, "_host_build_dir", lambda: _ratchet.ROOT) monkeypatch.setattr(check_code.Path, "exists", lambda self: True) - assert check_code.hotpath_rows() == [ + assert check_code.hotpath_rows(check_code.build_warnings()) == [ ("src/core/a.h", check_code.HOT_PATH, "mm::platform::millis in tick1s", 1), ("src/core/a.h", check_code.HOT_PATH, "printf in ?", 1), ("src/light/b.h", check_code.HOT_PATH, "a float conversion at formatTo, line 42", 1)] # A host whose compiler cannot measure it skips the rule; a build that fails raises, never a clean zero. monkeypatch.setattr(nb, "build_output", lambda _d: None) with pytest.raises(check_code.BuildMissing): - check_code.hotpath_rows() + check_code.build_warnings() monkeypatch.setattr(nb, "function_effects_enabled", lambda _d: False) - assert check_code.hotpath_rows() is None + assert check_code.build_warnings() is None def test_the_two_boundaries_are_counted_where_the_architecture_draws_them(): @@ -218,3 +218,67 @@ def test_the_launcher_keeps_what_the_compiler_said_beside_the_object(tmp_path): def test_the_baseline_is_the_committed_report_and_a_report_never_committed_is_a_first_run(): assert _ratchet.committed(_ratchet.ROOT / "docs/reference/metrics/docgen.md") assert _ratchet.committed(_ratchet.ROOT / "docs/reference/metrics/no-such-report.md") is None + + +def _tree(tmp_path, monkeypatch, files: dict): + """A miniature repository the file rules read, in place of the real one.""" + for rel, text in files.items(): + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text(text) + monkeypatch.setattr(check_code, "ROOT", tmp_path) + monkeypatch.setattr(check_code, "owned_files", lambda: list(files)) + + +def test_a_module_type_named_in_the_ui_is_a_finding_and_a_container_is_not(tmp_path, monkeypatch): + _tree(tmp_path, monkeypatch, {"src/ui/app.js": "if (m.type === 'NoiseEffect') x('Drivers'); // 'RainbowEffect'\n", + "src/core/x.h": "const char* a = \"NoiseEffect\";\n"}) + rows = check_code.ui_type_rows({"NoiseEffect", "RainbowEffect", "Drivers"}) + assert rows == [("src/ui/app.js", check_code.UI_TYPE, "line 1: 'NoiseEffect'", 1)] + + +def test_a_type_named_beyond_its_own_files_spreads_onto_its_header(tmp_path, monkeypatch): + monkeypatch.setattr(check_code, "MAX_SPREAD", 2) + files = {"src/light/Foo.h": "class Foo {};\n", "test/unit/unit_Foo.cpp": "Foo f;\n", + "src/module_types.cpp": "registerType(\"Foo\");\n"} + files.update({f"src/a{i}.h": "Foo* p;\n" for i in range(3)}) + _tree(tmp_path, monkeypatch, files) + assert check_code.spread_rows({"Foo"}) == [("src/light/Foo.h", check_code.SPREAD, "Foo in 3 files", 3)] + monkeypatch.setattr(check_code, "MAX_SPREAD", 3) + assert check_code.spread_rows({"Foo"}) == [] + + +def test_a_large_frame_is_a_row_per_function_and_only_ours_count(tmp_path, monkeypatch): + _tree(tmp_path, monkeypatch, {"src/core/a.h": "", "src/platform/desktop/vendor/v.h": ""}) + monkeypatch.setattr(check_code, "owned_files", lambda: ["src/core/a.h"]) + out = "\n".join([ + f"{tmp_path}/src/core/a.h:40:17: warning: stack frame size (1056) exceeds limit (512) in 'bool mm::F::reg(char const*)' [-Wframe-larger-than]", + f"{tmp_path}/src/core/a.h:40:17: warning: stack frame size (1056) exceeds limit (512) in 'bool mm::F::reg(char const*)' [-Wframe-larger-than]", + f"{tmp_path}/src/core/a.h:40:17: warning: stack frame size (600) exceeds limit (512) in 'bool mm::F::reg(char const*)' [-Wframe-larger-than]", + f"{tmp_path}/src/platform/desktop/vendor/v.h:9:1: warning: stack frame size (4000) exceeds limit (512) in 'f()' [-Wframe-larger-than]", + "/Library/SDK/thread.h:9:1: warning: stack frame size (700) exceeds limit (512) in 'g()' [-Wframe-larger-than]"]) + rows = sorted(check_code.stack_rows(out)) + assert rows == [("src/core/a.h", check_code.STACK, "F::reg, line 40", 1056), + ("src/core/a.h", check_code.STACK, "F::reg, line 40", 600)] + + +def test_a_hand_buffer_in_light_is_a_finding_and_a_factory_or_a_comment_is_not(tmp_path, monkeypatch): + _tree(tmp_path, monkeypatch, { + "src/light/a.h": "auto* p = new uint8_t[n];\nbuf = static_cast(platform::alloc(n));\n// malloc(n) in a comment\n" + "reg([]() -> P* { return new Peripheral(); });\nnew (slot) T();\n", + "src/core/b.h": "auto* p = new uint8_t[n];\n"}) + assert [r[2] for r in check_code.raw_alloc_rows()] == ["line 1: auto* p = new uint8_t[n];", + "line 2: buf = static_cast(platform::alloc(n));"] + + +def test_a_nothrow_array_new_in_light_is_a_hand_buffer_too(tmp_path, monkeypatch): + _tree(tmp_path, monkeypatch, {"src/light/a.h": "auto* p = new (std::nothrow) uint8_t[n];\nnew (slot) T[n];\n"}) + assert [r[2] for r in check_code.raw_alloc_rows()] == ["line 1: auto* p = new (std::nothrow) uint8_t[n];"] + + +def test_two_overloads_with_large_frames_stay_two_findings(tmp_path, monkeypatch): + _tree(tmp_path, monkeypatch, {"src/core/a.h": ""}) + monkeypatch.setattr(check_code, "owned_files", lambda: ["src/core/a.h"]) + out = "\n".join([ + f"{tmp_path}/src/core/a.h:10:1: warning: stack frame size (700) exceeds limit (512) in 'void mm::f(int)' [-Wframe-larger-than]", + f"{tmp_path}/src/core/a.h:20:1: warning: stack frame size (800) exceeds limit (512) in 'void mm::f(float)' [-Wframe-larger-than]"]) + assert sorted(r[3] for r in check_code.stack_rows(out)) == [700, 800] diff --git a/test/python/test_check_docgen.py b/test/python/test_check_docgen.py index 6f1c8e021..7771b2658 100644 --- a/test/python/test_check_docgen.py +++ b/test/python/test_check_docgen.py @@ -1549,3 +1549,29 @@ def compound(kind, name, inners="", brief=""): pages = gen_api._class_to_header(tmp_path) assert "mm-Outer" in pages assert "mm-Outer-Hidden" not in pages and "mm-Outer-Hidden-Deeper" not in pages + + +# ---- appendix sections nothing refers to ---- + +_APPENDIX = ["/// The thing.", "///", "/// @moreinfo", "///", "/// ## Why it waits", + "///", "/// Because.", "///", "/// ## The wire layout", "///", "/// Bytes."] + + +def test_an_appendix_section_nothing_refers_to_is_a_finding(): + from check_docgen import _unreferenced_in + found = _unreferenced_in("src/core/x.h", _APPENDIX, set()) + assert [k for k, _ in found] == ["src/core/x.h::@moreinfo Why it waits", "src/core/x.h::@moreinfo The wire layout"] + + +def test_an_xref_or_a_page_link_reaches_a_section(): + from check_docgen import _unreferenced_in + lines = _APPENDIX + ["/// See @xref{why-it-waits}."] + found = _unreferenced_in("src/core/x.h", lines, {("x", "the-wire-layout")}) + assert found == [] + + +def test_a_heading_inside_a_fenced_example_is_no_section(): + from check_docgen import _unreferenced_in + lines = ["/// The thing.", "///", "/// @moreinfo", "///", "/// ## Real", "///", "/// ```text", + "/// ## not a section", "/// ```"] + assert [k for k, _ in _unreferenced_in("src/core/x.h", lines, set())] == ["src/core/x.h::@moreinfo Real"] diff --git a/test/python/test_moonlive_catalog_scripts.py b/test/python/test_moonlive_catalog_scripts.py index 391dcc28f..bcba58884 100644 --- a/test/python/test_moonlive_catalog_scripts.py +++ b/test/python/test_moonlive_catalog_scripts.py @@ -18,7 +18,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "src" / "light" / "moonlive")) +sys.path.insert(0, str(ROOT / "src" / "core" / "moonlive")) from catalog_scripts import declared # noqa: E402 diff --git a/test/python/test_scripts_are_cpp.py b/test/python/test_scripts_are_cpp.py index 8d2ea412d..13b842f2c 100644 --- a/test/python/test_scripts_are_cpp.py +++ b/test/python/test_scripts_are_cpp.py @@ -91,7 +91,7 @@ def prelude() -> str: lines.append("int setControl(string, int);") lines.append("void addControl(string, int&, int, int);") lines.append("void addControl(string, byte&, int, int);") - lines.append("void addControl(string, bool&);") + lines.append("void addControl(string, bool&, int, int);") # one arity for every type, as the builtin table registers it return "\n".join(lines) + "\n" diff --git a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json index cc0730ab5..d8ccb3eaf 100644 --- a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json +++ b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json @@ -193,10 +193,10 @@ "p95": 462, "min": 5, "max": 462, - "n": 16, - "samples": [5, 462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13] + "n": 18, + "samples": [5, 462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json index be2dda740..61cefefdd 100644 --- a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json +++ b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json @@ -89,10 +89,10 @@ "p95": 555, "min": 1, "max": 555, - "n": 17, - "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4] + "n": 18, + "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Drivers_output_and_brightness.json b/test/scenarios/light/scenario_Drivers_output_and_brightness.json index 15af7b764..8bdf3f468 100644 --- a/test/scenarios/light/scenario_Drivers_output_and_brightness.json +++ b/test/scenarios/light/scenario_Drivers_output_and_brightness.json @@ -122,9 +122,9 @@ "min": 6, "max": 17, "n": 32, - "samples": [7, 8, 9, 6, 6, 7, 7, 7, 6, 6, 7, 6, 6, 7, 7, 7, 7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15] + "samples": [9, 6, 6, 7, 7, 7, 6, 6, 7, 6, 6, 7, 7, 7, 7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json index c46f483d7..24db4cdf0 100644 --- a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json +++ b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json @@ -80,9 +80,9 @@ "min": 1, "max": 2, "n": 32, - "samples": [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "desktop-windows": { "tick_us": { @@ -128,9 +128,9 @@ "min": 6, "max": 46, "n": 32, - "samples": [7, 6, 7, 11, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10] + "samples": [11, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_swap_while_running.json b/test/scenarios/light/scenario_Effects_swap_while_running.json index c49fb1faf..fbc749b7f 100644 --- a/test/scenarios/light/scenario_Effects_swap_while_running.json +++ b/test/scenarios/light/scenario_Effects_swap_while_running.json @@ -78,9 +78,9 @@ "min": 1, "max": 2, "n": 32, - "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "desktop-windows": { "tick_us": { @@ -153,9 +153,9 @@ "min": 1, "max": 4, "n": 32, - "samples": [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2] + "samples": [1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json index b3963bdc4..7e691667a 100644 --- a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json +++ b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json @@ -84,9 +84,9 @@ "min": 6, "max": 81, "n": 32, - "samples": [7, 6, 6, 6, 7, 10, 6, 6, 7, 6, 6, 7, 7, 6, 7, 7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12] + "samples": [6, 6, 6, 7, 10, 6, 6, 7, 6, 6, 7, 7, 6, 7, 7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -167,9 +167,9 @@ "min": 4, "max": 32, "n": 32, - "samples": [5, 5, 5, 5, 5, 7, 5, 4, 5, 4, 4, 4, 4, 4, 7, 4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7] + "samples": [5, 5, 5, 5, 7, 5, 4, 5, 4, 4, 4, 4, 4, 7, 4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -240,9 +240,9 @@ "min": 6, "max": 19, "n": 32, - "samples": [6, 7, 7, 6, 8, 9, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15] + "samples": [7, 7, 6, 8, 9, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -321,9 +321,9 @@ "min": 6, "max": 18, "n": 32, - "samples": [6, 7, 6, 6, 7, 9, 7, 6, 7, 7, 6, 7, 7, 6, 7, 7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14] + "samples": [7, 6, 6, 7, 9, 7, 6, 7, 7, 6, 7, 7, 6, 7, 7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -402,9 +402,9 @@ "min": 6, "max": 31, "n": 32, - "samples": [7, 7, 7, 6, 7, 9, 6, 6, 6, 7, 6, 6, 7, 6, 7, 7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13] + "samples": [7, 7, 6, 7, 9, 6, 6, 6, 7, 6, 6, 7, 6, 7, 7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -485,9 +485,9 @@ "min": 3, "max": 25, "n": 32, - "samples": [6, 5, 4, 5, 6, 6, 4, 3, 3, 3, 4, 3, 3, 3, 4, 4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9] + "samples": [5, 4, 5, 6, 6, 4, 3, 3, 3, 4, 3, 3, 3, 4, 4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -582,9 +582,9 @@ "min": 3, "max": 20, "n": 32, - "samples": [5, 5, 4, 5, 6, 6, 4, 4, 3, 3, 4, 4, 3, 3, 4, 4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7] + "samples": [5, 4, 5, 6, 6, 4, 4, 3, 3, 4, 4, 3, 3, 4, 4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json index c12ccb188..78205d1d1 100644 --- a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json +++ b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json @@ -104,9 +104,9 @@ "min": 7, "max": 24, "n": 32, - "samples": [9, 8, 8, 8, 9, 11, 7, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12] + "samples": [8, 8, 8, 9, 11, 7, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } }, @@ -138,9 +138,9 @@ "min": 13, "max": 37, "n": 32, - "samples": [15, 14, 13, 18, 15, 18, 13, 15, 15, 15, 13, 15, 15, 15, 15, 15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19] + "samples": [14, 13, 18, 15, 18, 13, 15, 15, 15, 13, 15, 15, 15, 15, 15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } }, @@ -210,9 +210,9 @@ "min": 17, "max": 46, "n": 32, - "samples": [21, 19, 17, 20, 20, 25, 20, 20, 20, 20, 19, 21, 20, 20, 20, 21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24] + "samples": [19, 17, 20, 20, 25, 20, 20, 20, 20, 19, 21, 20, 20, 20, 21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } }, @@ -243,9 +243,9 @@ "min": 119, "max": 225, "n": 32, - "samples": [143, 124, 119, 123, 132, 171, 122, 122, 123, 123, 137, 124, 123, 122, 123, 124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162] + "samples": [124, 119, 123, 132, 171, 122, 122, 123, 123, 137, 124, 123, 122, 123, 124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } }, @@ -283,9 +283,9 @@ "min": 262, "max": 454, "n": 32, - "samples": [293, 270, 264, 268, 287, 368, 263, 264, 266, 264, 265, 275, 270, 264, 271, 267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355] + "samples": [270, 264, 268, 287, 368, 263, 264, 266, 264, 265, 275, 270, 264, 271, 267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } }, @@ -315,9 +315,9 @@ "min": 49, "max": 100, "n": 32, - "samples": [51, 50, 56, 49, 53, 62, 55, 55, 56, 55, 58, 56, 56, 56, 56, 58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100] + "samples": [50, 56, 49, 53, 62, 55, 55, 56, 55, 58, 56, 56, 56, 56, 58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } }, @@ -341,9 +341,9 @@ "min": 45, "max": 103, "n": 32, - "samples": [46, 46, 52, 45, 49, 57, 51, 52, 52, 52, 50, 52, 52, 52, 53, 53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76] + "samples": [46, 52, 45, 49, 57, 51, 52, 52, 52, 50, 52, 52, 52, 53, 53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } }, @@ -373,9 +373,9 @@ "min": 44, "max": 281, "n": 32, - "samples": [45, 45, 51, 44, 48, 56, 51, 51, 52, 51, 50, 51, 51, 51, 52, 52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281] + "samples": [45, 51, 44, 48, 56, 51, 51, 52, 51, 50, 51, 51, 51, 52, 52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } } diff --git a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json index 4bf1d7a2e..b2b0610e9 100644 --- a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json +++ b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json @@ -92,9 +92,9 @@ "min": 6, "max": 23, "n": 32, - "samples": [10, 6, 7, 6, 7, 9, 6, 6, 7, 7, 6, 7, 6, 6, 7, 6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22] + "samples": [6, 7, 6, 7, 9, 6, 6, 7, 7, 6, 7, 6, 6, 7, 6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "desktop-windows": { "tick_us": { @@ -174,9 +174,9 @@ "min": 8, "max": 22, "n": 32, - "samples": [10, 10, 8, 10, 11, 12, 10, 10, 10, 10, 9, 10, 10, 10, 10, 11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19] + "samples": [10, 8, 10, 11, 12, 10, 10, 10, 10, 9, 10, 10, 10, 10, 11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "desktop-windows": { "tick_us": { @@ -263,9 +263,9 @@ "min": 5, "max": 14, "n": 32, - "samples": [6, 6, 6, 6, 7, 9, 6, 6, 7, 6, 5, 6, 7, 6, 6, 6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14] + "samples": [6, 6, 6, 7, 9, 6, 6, 7, 6, 5, 6, 7, 6, 6, 6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json index 669810f7a..8e193127a 100644 --- a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json +++ b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json @@ -77,9 +77,9 @@ "min": 6, "max": 19, "n": 32, - "samples": [6, 6, 6, 6, 7, 9, 7, 6, 7, 7, 6, 6, 6, 6, 7, 7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10] + "samples": [6, 6, 6, 7, 9, 7, 6, 7, 7, 6, 6, 6, 6, 7, 7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -160,9 +160,9 @@ "min": 3, "max": 11, "n": 32, - "samples": [4, 4, 4, 4, 4, 5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9] + "samples": [4, 4, 4, 4, 5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -242,9 +242,9 @@ "min": 3, "max": 9, "n": 32, - "samples": [4, 4, 4, 4, 4, 5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9] + "samples": [4, 4, 4, 4, 5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -317,9 +317,9 @@ "min": 2, "max": 6, "n": 32, - "samples": [3, 3, 3, 3, 3, 4, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5] + "samples": [3, 3, 3, 3, 4, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -398,9 +398,9 @@ "min": 3, "max": 11, "n": 32, - "samples": [4, 4, 4, 4, 4, 6, 4, 3, 3, 3, 4, 3, 3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7] + "samples": [4, 4, 4, 4, 6, 4, 3, 3, 3, 4, 3, 3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -480,9 +480,9 @@ "min": 3, "max": 9, "n": 32, - "samples": [4, 4, 4, 4, 4, 5, 4, 3, 3, 3, 3, 3, 4, 3, 3, 3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6] + "samples": [4, 4, 4, 4, 5, 4, 3, 3, 3, 3, 3, 4, 3, 3, 3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { @@ -561,9 +561,9 @@ "min": 5, "max": 20, "n": 32, - "samples": [6, 6, 6, 6, 7, 9, 5, 6, 7, 6, 5, 6, 6, 6, 6, 7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12] + "samples": [6, 6, 6, 7, 9, 5, 6, 7, 6, 5, 6, 6, 6, 6, 7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json index e248320b5..6e0706f8e 100644 --- a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json +++ b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json @@ -115,10 +115,10 @@ "p95": 122, "min": 6, "max": 202, - "n": 27, - "samples": [6, 122, 6, 6, 53, 36, 6, 6, 6, 202, 11, 6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9] + "n": 29, + "samples": [6, 122, 6, 6, 53, 36, 6, 6, 6, 202, 11, 6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } }, @@ -183,10 +183,10 @@ "p95": 204, "min": 6, "max": 205, - "n": 27, - "samples": [6, 204, 6, 6, 49, 37, 6, 6, 7, 205, 7, 7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11] + "n": 29, + "samples": [6, 204, 6, 6, 49, 37, 6, 6, 7, 205, 7, 7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7] }, - "last_updated": "2026-10-06" + "last_updated": "2026-10-07" } } } diff --git a/test/unit/core/fake_light_output.h b/test/unit/core/fake_light_output.h new file mode 100644 index 000000000..b62873774 --- /dev/null +++ b/test/unit/core/fake_light_output.h @@ -0,0 +1,34 @@ +#pragma once + +#include "core/util/LightOutput.h" +#include "core/util/JsonSink.h" + +#include + +namespace mm::test { + +/// A light output with twelve palettes thirty degrees of hue apart, so a core test's color mapping is exact. +/// A fake Drivers derives from it beside MoonModule and claims the seat, as the real one does from prepare. +struct FakeLightOutput : public LightOutput { + /// The summary a test sets directly. + const LightSummary& summary() const override { return summary_; } + /// The palette whose hue is nearest, ignoring saturation. + uint8_t nearestPalette(uint16_t hue, uint8_t) const override { return static_cast(((hue + 15) / 30) % 12); } + /// Palette `index` sits at `index` times thirty degrees, fully saturated. + void paletteHueSat(uint8_t index, uint16_t& hue, uint16_t& sat) const override { hue = index * 30u; sat = 255; } + /// Twelve palettes. + uint8_t paletteCount() const override { return 12; } + /// One name, which is all a shape test reads. + void writePaletteNames(JsonSink& sink) const override { sink.append("\"P0\""); } + using LightOutput::nearestPalette; + + /// What summary() answers. + LightSummary summary_; + /// The seat a test claims, as the real Drivers does from prepare. + ActiveInstance outputSeat_{*this}; + +protected: + ~FakeLightOutput() = default; +}; + +} // namespace mm::test diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc index 616990476..7288dbeda 100644 --- a/test/unit/core/moonlive_device_codegen.inc +++ b/test/unit/core/moonlive_device_codegen.inc @@ -19,7 +19,7 @@ // included in the enclosing namespace. #include "light/moonlive/MoonLiveBuiltins_light.h" -#include "light/moonlive/MoonLiveScriptFile.h" // the role extensions the sweep filters on +#include "core/moonlive/MoonLiveScriptFile.h" // the role extensions the sweep filters on #include #include diff --git a/test/unit/core/unit_AudioService_sync.cpp b/test/unit/core/unit_AudioService_sync.cpp index 5a55f3b22..eab041f9b 100644 --- a/test/unit/core/unit_AudioService_sync.cpp +++ b/test/unit/core/unit_AudioService_sync.cpp @@ -39,7 +39,7 @@ #include "doctest.h" #include "core/services/AudioService.h" -#include "light/util/WLEDAudioSyncPacket.h" +#include "core/util/WLEDAudioSyncPacket.h" #include "platform/platform.h" #include diff --git a/test/unit/core/unit_ControlModule.cpp b/test/unit/core/unit_ControlModule.cpp index f5f7777ae..220eb85a4 100644 --- a/test/unit/core/unit_ControlModule.cpp +++ b/test/unit/core/unit_ControlModule.cpp @@ -61,8 +61,8 @@ struct Device { scheduler.setup(); } - // The surface seat is static: freeing this ControlModule vacates it, or the first test's would answer active() for every later one. - ~Device() { delete control; std::filesystem::remove_all(root_); } + // Every seat is static: releasing and freeing the tree vacates them, or the first test's modules would answer active() for every later one. + ~Device() { scheduler.release(); std::filesystem::remove_all(root_); } mm::MoonModule* add(mm::MoonModule* parent, const char* type) { auto* m = mm::ModuleFactory::create(type); diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp index 14ab6e257..e8979f280 100644 --- a/test/unit/core/unit_HttpServerModule_apply.cpp +++ b/test/unit/core/unit_HttpServerModule_apply.cpp @@ -6,6 +6,7 @@ #include "core/util/ModuleFactory.h" #include "core/module/MoonModule.h" #include "core/util/JsonSink.h" +#include "fake_light_output.h" // the fake Drivers is the light output too #include #include // std::string: named explicitly, GCC does not pull it in transitively @@ -50,13 +51,15 @@ struct Tag : public mm::MoonModule { } }; -// A stand-in for the real Drivers module the WLED shim targets: an `on` Bool + a `brightness` Uint8, the two controls applyWledState drives. Named "Drivers" so findModuleByName resolves it. -struct FakeDrivers : public mm::MoonModule { +// Stands in for the Drivers the WLED shim drives, by name, and is the light output a test claims when it reaches the palettes. +struct FakeDrivers : public mm::MoonModule, public mm::test::FakeLightOutput { bool on = true; uint8_t brightness = 20; + uint8_t palette = 0; void defineControls() override { controls_.addControl("on", on); controls_.addControl("brightness", brightness, 0, 255); + controls_.addControl("palette", palette, 0, 255); } }; @@ -324,6 +327,32 @@ TEST_CASE("apply-core: applyWledState sets on + bri independently (no brightness s.deleteTree(drivers); } +// The WLED app and Home Assistant pick a palette by index or by color, and both reach the palette through the light output. +TEST_CASE("apply-core: applyWledState picks a palette by index and by color through the light output") { + registerTestTypes(); + mm::Scheduler s; + auto* drivers = new FakeDrivers(); + drivers->setName("Drivers"); + drivers->defineControls(); + s.addModule(drivers); + mm::HttpServerModule http; + http.setScheduler(&s); + + // No light output running: neither form moves the palette. + http.applyWledState("{\"seg\":[{\"pal\":3,\"col\":[[0,0,255]]}]}"); + CHECK(drivers->palette == 0); + + drivers->outputSeat_.claim(); + http.applyWledState("{\"seg\":[{\"pal\":3}]}"); + CHECK(drivers->palette == 3); + http.applyWledState("{\"seg\":[{\"pal\":40}]}"); + CHECK(drivers->palette == 11); // clamped to the last palette the output offers + http.applyWledState("{\"seg\":[{\"col\":[[0,0,255]]}]}"); + CHECK(drivers->palette == 8); // pure blue is 240 degrees, the fake's palette 8 + + s.deleteTree(drivers); +} + // Diff-on-the-wire (the 1 Hz-stutter fix): the periodic WS push sends only CHANGED control values, not the whole ~34 KB tree every second. buildStatePatch value-hashes each leaf against a baseline and emits only the ones that differ. These pin the core guarantees: an unchanged tree → EMPTY patch (the whole point, no per-second re-serialize of static config), and a single value change → a one-entry patch addressed by "/". TEST_CASE("buildStatePatch: unchanged tree yields an empty patch") { registerTestTypes(); diff --git a/test/unit/core/unit_ModuleFactory.cpp b/test/unit/core/unit_ModuleFactory.cpp index 919848182..dbc617c3b 100644 --- a/test/unit/core/unit_ModuleFactory.cpp +++ b/test/unit/core/unit_ModuleFactory.cpp @@ -108,3 +108,20 @@ TEST_CASE("ModuleFactory: dynamic capacity grows past initial size") { delete m; } } + +namespace { +// Counts the instances built through operator new, so a test sees where the probe lived. +class HeapCountedStub : public mm::MoonModule { +public: + static inline int heapBuilt = 0; + void* operator new(size_t size) noexcept { heapBuilt++; return mm::MoonModule::operator new(size); } + uint8_t big[8192] = {}; ///< larger than a classic ESP32's boot-task stack can spare +}; +} // namespace + +// Regression: the probe was a stack local, so registering a large module boot-looped the P4 at registration. +TEST_CASE("ModuleFactory: the registration probe lives on the heap, however large the module") { + HeapCountedStub::heapBuilt = 0; + CHECK(mm::ModuleFactory::registerType("HeapCountedStub")); + CHECK(HeapCountedStub::heapBuilt == 1); +} diff --git a/test/unit/core/unit_MoonBaseContract.cpp b/test/unit/core/unit_MoonBaseContract.cpp index 5f339a877..39b6fcb0c 100644 --- a/test/unit/core/unit_MoonBaseContract.cpp +++ b/test/unit/core/unit_MoonBaseContract.cpp @@ -47,6 +47,20 @@ TEST_CASE("the app's saved config carries every key MoonBase reads") { } net->setTxPowerSetting(8); net->setWifiCredentials("bench-ssid", "bench-password"); // the first known network + // A static address on the first known network and on Ethernet, which MoonBase must answer on too. + auto* wifi = d.wifi; + const uint32_t first = wifi->idAt(0); + REQUIRE(wifi->setListRowField(first, "ipSettings", "{\"value\":1}")); + REQUIRE(wifi->setListRowField(first, "ip", "{\"value\":\"192.168.1.77\"}")); + REQUIRE(wifi->setListRowField(first, "gateway", "{\"value\":\"192.168.1.1\"}")); + if constexpr (mm::platform::hasEthernet || mm::platform::previewsEthernetControls) { + for (uint8_t i = 0; i < d.eth->controls().count(); i++) { + auto& c = d.eth->controls()[i]; + if (std::strcmp(c.name, "ipSettings") == 0) *static_cast(c.ptr) = 1; + if (std::strcmp(c.name, "ip") == 0) std::memcpy(c.ptr, "\xC0\xA8\x01\xD3", 4); // 192.168.1.211 + if (std::strcmp(c.name, "gateway") == 0) std::memcpy(c.ptr, "\xC0\xA8\x01\x01", 4); + } + } sys->markDirty(); net->markDirty(); fs->flush(); @@ -71,6 +85,16 @@ TEST_CASE("the app's saved config carries every key MoonBase reads") { mm::configscrape::findInt(content.c_str(), "ethType", ðType); CHECK(ethType != -2); } + // Each interface's addressing, read where the app wrote it. + const mm::configscrape::SavedIp row = mm::configscrape::findNetworkIp(content.c_str(), 0); + CHECK(row.usable()); + CHECK(row.ip[3] == 77); + CHECK_FALSE(mm::configscrape::findNetworkIp(content.c_str(), 1).usable()); + if constexpr (mm::platform::hasEthernet || mm::platform::previewsEthernetControls) { + const mm::configscrape::SavedIp eth = mm::configscrape::findChildIp(content.c_str(), "EthernetModule"); + CHECK(eth.usable()); + CHECK(eth.ip[3] == 211); + } // The access point's own password, not a known network's or another child's. char apPassword[64] = {}; REQUIRE(mm::configscrape::findChildString(content.c_str(), "AccessPointModule", "password", apPassword, sizeof(apPassword))); @@ -128,6 +152,26 @@ TEST_CASE("the config scraper reads a child's key by the child's type") { CHECK_FALSE(mm::configscrape::findChildString(R"({"2.type":"AccessPointModule","2.password":""})", "AccessPointModule", "password", out, sizeof(out))); } +// A row's addressing is its own: one saved without a static address never reads the next row's, and one the app would refuse is not used. +TEST_CASE("the config scraper reads each interface's IP settings, by the app's rule") { + const char* json = R"({"0.type":"EthernetModule","0.ipSettings":1,"0.ip":"10.0.0.5","0.subnet":"255.255.255.0",)" + R"("1.known":[{"id":1,"ssid":"home","password":"h"},)" + R"({"id":2,"ssid":"site","password":"s","ipSettings":1,"ip":"10.1.0.9","gateway":"10.1.0.1"},)" + R"({"id":3,"ssid":"bad","password":"b","ipSettings":1,"ip":"10.2.0.0","subnet":"255.255.255.0"}]})"; + CHECK(mm::configscrape::findChildIp(json, "EthernetModule").usable()); + CHECK_FALSE(mm::configscrape::findNetworkIp(json, 0).usable()); // no keys of its own: DHCP + const mm::configscrape::SavedIp site = mm::configscrape::findNetworkIp(json, 1); + CHECK(site.usable()); + CHECK(site.gateway[3] == 1); + CHECK_FALSE(mm::configscrape::findNetworkIp(json, 2).usable()); // the network address, which the app refuses too + CHECK_FALSE(mm::configscrape::findNetworkIp(json, 3).usable()); // past the last row + // The last row ends at its own brace, so a later module's address is not read as its own, and a brace in a password does not end it early. + const char* tail = R"({"1.known":[{"id":1,"ssid":"home","password":"p}w"}],"0.type":"EthernetModule","0.ipSettings":1,"0.ip":"10.0.0.5"})"; + CHECK(mm::configscrape::findNetworkIp(tail, 0).mode == 0); + const char* braced = R"({"1.known":[{"id":1,"ssid":"home","password":"p}w","ipSettings":1,"ip":"10.1.0.9"}]})"; + CHECK(mm::configscrape::findNetworkIp(braced, 0).usable()); +} + // The page keeps calling the same paths across the hand-over to MoonBase, which shares no sources, so a renamed route fails only on a device mid-update. TEST_CASE("the two boot images serve the OTA routes under the same names") { // Resolved from this file rather than the working directory: ctest runs the binary from the build tree, where a relative path finds nothing. diff --git a/test/unit/core/unit_MoonStatsReport.cpp b/test/unit/core/unit_MoonStatsReport.cpp index 978c07223..81525d6ba 100644 --- a/test/unit/core/unit_MoonStatsReport.cpp +++ b/test/unit/core/unit_MoonStatsReport.cpp @@ -9,6 +9,7 @@ #include "core/system/SystemModule.h" #include "light/drivers/PreviewDriver.h" // the one type the report excludes as boot wiring +#include "light/moonlive/MoonLiveEffect.h" // kEffectPick: the picker the scripted fake offers namespace { diff --git a/test/unit/core/unit_MqttModule.cpp b/test/unit/core/unit_MqttModule.cpp index 0c392cdbf..bba286789 100644 --- a/test/unit/core/unit_MqttModule.cpp +++ b/test/unit/core/unit_MqttModule.cpp @@ -60,6 +60,7 @@ #include "core/system/ControlModule.h" #include "core/system/FilesystemModule.h" #include "core/util/ModuleFactory.h" +#include "fake_light_output.h" // the fake Drivers is the light output too #include "light/effects/NoiseEffect.h" #include "light/layers/Layer.h" #include "light/layers/Effects.h" @@ -76,8 +77,8 @@ using namespace mm; namespace { -// Stands in for Drivers: on (Bool), brightness (Uint8 0-255), palette (Select). Named "Drivers" so MqttModule's setControl("Drivers", …) resolves to it. -struct FakeDrivers : public MoonModule { +// Stands in for Drivers, named so setControl("Drivers", …) resolves to it, and is the light output so the color mapping is exact. +struct FakeDrivers : public MoonModule, public mm::test::FakeLightOutput { bool on = true; uint8_t brightness = 100; uint8_t palette = 0; @@ -87,6 +88,8 @@ struct FakeDrivers : public MoonModule { // The real 0..255 range, so a nearest-palette index is not clamped away by an artificially small Select: @xref{the-fake-carries-the-real-ranges}. controls_.addControl("palette", palette, 0, 255); } + void setup() override { outputSeat_.claim(); MoonModule::setup(); } + void release() override { outputSeat_.vacate(); MoonModule::release(); } }; // A scheduler with FakeDrivers, a SystemModule and an MqttModule, set up so controls are bound: @xref{the-mac-is-derived-never-written-out}. @@ -199,10 +202,10 @@ TEST_CASE("MqttModule: brightness/set rescales 0-100 to 0-255") { TEST_CASE("MqttModule: hsv/set maps a hue to the nearest palette + value to brightness") { Rig r; - // A blue-ish hue at full saturation should pick a blue-family palette (a non-zero index, not Rainbow at 0). We assert it moved off the default and that value drove brightness. + // The light output answers which palette is nearest; MQTT routes the hue there and the value to brightness. r.drivers->palette = 0; r.publish("hsv/set", "210,100,40"); // blue, sat 100%, value 40% - CHECK(r.drivers->palette != 0); // snapped to some blue-family palette + CHECK(r.drivers->palette == 7); // the fake's palette at 210 degrees CHECK(r.drivers->brightness == (40 * 255) / 100); // value → brightness } diff --git a/test/unit/core/unit_OscModule.cpp b/test/unit/core/unit_OscModule.cpp index 1082fa362..1dadc9610 100644 --- a/test/unit/core/unit_OscModule.cpp +++ b/test/unit/core/unit_OscModule.cpp @@ -1,11 +1,12 @@ /// @module OscModule /// @also Scheduler -/// OSC feedback settings that need no socket: a bad host list and a missing group are reported rather than sending nowhere. +/// OSC feedback: its settings are validated without a socket, and every value is sent again every 30 seconds, one per tick. #include "doctest.h" #include "core/services/OscModule.h" #include "core/module/Scheduler.h" +#include "core/system/ControlModule.h" #include @@ -41,3 +42,45 @@ TEST_CASE("OSC multicast feedback without a group says so, and the default group REQUIRE(rig.scheduler.setControl("Osc", "group", "{\"value\":\"\"}") == Scheduler::SetControlResult::Ok); CHECK(std::strstr(rig.osc->status(), "needs a group") != nullptr); } + +namespace { +// The OSC module with its datagrams counted instead of sent, so the test needs no socket and no clock. +struct CountingOsc : OscModule { + int sent = 0; + void sendValue(SurfaceControl, uint8_t, uint8_t) override { sent++; } +}; +} // namespace + +// Regression: a follower that missed one multicast datagram kept the wrong value until that slot changed again, which for a palette was a whole scene. +TEST_CASE("OSC feedback asks for every surface value again every 30 seconds, and gets one per tick") { + Scheduler scheduler; + auto* control = new ControlModule(); + control->setName("Control"); + auto* osc = new CountingOsc(); + osc->setName("Osc"); + scheduler.addModule(control); + scheduler.addModule(osc); + scheduler.setup(); + REQUIRE(scheduler.setControl("Osc", "feedback", "{\"value\":true}") == Scheduler::SetControlResult::Ok); + REQUIRE(scheduler.setControl("Osc", "listen", "{\"value\":true}") == Scheduler::SetControlResult::Ok); + + // Attached as the module attaches itself: seeded one value per tick, not in one burst. + control->addSurface(osc, /*paced=*/true); + CHECK(osc->sent == 0); + for (int i = 0; i < ControlModule::kSlotCount; i++) { + const int before = osc->sent; + control->tick20ms(); + CHECK(osc->sent - before == 1); + } + control->tick20ms(); + CHECK(osc->sent == ControlModule::kSlotCount); // every value once, then quiet + + for (int s = 0; s < 29; s++) osc->tick1s(); + control->tick20ms(); + CHECK(osc->sent == ControlModule::kSlotCount); // nothing before the 30 seconds are up + osc->tick1s(); + for (int i = 0; i < ControlModule::kSlotCount + 2; i++) control->tick20ms(); + CHECK(osc->sent == 2 * ControlModule::kSlotCount); // and every value once more + control->removeSurface(osc); + scheduler.release(); +} diff --git a/test/unit/core/unit_ThreadSlot.cpp b/test/unit/core/unit_ThreadSlot.cpp new file mode 100644 index 000000000..1e760d7cd --- /dev/null +++ b/test/unit/core/unit_ThreadSlot.cpp @@ -0,0 +1,48 @@ +/// @module ThreadSlot + +#include "doctest.h" +#include "core/util/ThreadSlot.h" + +#include +#include +#include + +namespace { +struct TestSlot { std::atomic owner{0}; int value = 0; }; +} // namespace + +TEST_CASE("a thread finds the slot it claimed, and a read alone claims none") { + TestSlot slots[2]{}; + CHECK(mm::ownedThreadSlot(slots, false) == nullptr); + TestSlot* mine = mm::ownedThreadSlot(slots, true); + REQUIRE(mine != nullptr); + CHECK(mm::ownedThreadSlot(slots, false) == mine); + CHECK(mm::ownedThreadSlot(slots, true) == mine); // claiming again is the same slot + mm::releaseThreadSlot(mine); + CHECK(mm::ownedThreadSlot(slots, false) == nullptr); +} + +TEST_CASE("two threads hold two slots, and a third finds the table full") { + TestSlot slots[2]{}; + TestSlot* mine = mm::ownedThreadSlot(slots, true); + TestSlot* theirs = nullptr; + TestSlot* third = &slots[0]; // overwritten with what the third thread gets + // The second thread holds its slot while the third claims, since an ended thread's id is reused. + std::atomic claimed{false}, done{false}; + std::thread second([&] { + theirs = mm::ownedThreadSlot(slots, true); + claimed = true; + while (!done) std::this_thread::yield(); + }); + while (!claimed) std::this_thread::yield(); + std::thread([&] { third = mm::ownedThreadSlot(slots, true); }).join(); + done = true; + second.join(); + REQUIRE(mine != nullptr); + REQUIRE(theirs != nullptr); + CHECK(mine != theirs); + CHECK(third == nullptr); + mm::releaseThreadSlot(theirs); + std::thread([&] { third = mm::ownedThreadSlot(slots, true); }).join(); + CHECK(third == theirs); // a released slot is free for the next claimer +} diff --git a/test/unit/light/unit_WledAudioSyncPacket.cpp b/test/unit/core/unit_WledAudioSyncPacket.cpp similarity index 85% rename from test/unit/light/unit_WledAudioSyncPacket.cpp rename to test/unit/core/unit_WledAudioSyncPacket.cpp index 2a6c9c475..c9f5941eb 100644 --- a/test/unit/light/unit_WledAudioSyncPacket.cpp +++ b/test/unit/core/unit_WledAudioSyncPacket.cpp @@ -1,9 +1,9 @@ /// @module WledAudioSyncPacket -/// Pins the WLED audio-sync wire format, the 44-byte v2 packet MoonLight broadcasts on UDP 11988 and that WLED / MoonLight (D_WLEDAudio.h) receive. A wire format breaks silently, so build → parse is round-tripped AND a golden byte vector fixes the exact offsets: the packet is a fixed compatibility contract (netmindz/WLED-sync), not ours to drift. (Same rigor as the Improv frame golden vector.) +/// Pins the WLED audio-sync wire format, the 44-byte v2 packet on UDP 11988, with a round trip and a golden byte vector, since a wire format breaks silently. #include "doctest.h" -#include "light/util/WLEDAudioSyncPacket.h" +#include "core/util/WLEDAudioSyncPacket.h" #include #include @@ -39,7 +39,7 @@ TEST_CASE("build produces a 44-byte v2 packet with the exact WLED layout") { CHECK(wledGetFloatLE(pkt + 8) == doctest::Approx(200.0f)); // sampleSmth = levelSmoothed at offset 12 CHECK(wledGetFloatLE(pkt + 12) == doctest::Approx(150.0f)); - // samplePeak at 16; byte 17 is WLED's `reserved2` ("not used yet") and MUST be zero, not a counter of ours: WLED transmits 0 there and may claim the byte in a later version. + // samplePeak at 16; byte 17 is WLED's `reserved2` and stays zero, since WLED may claim it in a later version. CHECK(pkt[16] == 1); CHECK(pkt[17] == 0); // fftResult[16] = bands at offset 18 @@ -51,7 +51,7 @@ TEST_CASE("build produces a 44-byte v2 packet with the exact WLED layout") { CHECK(wledGetFloatLE(pkt + 40) == doctest::Approx(440.0f)); } -// WLED clamps every band to 254 on send (constrain(fftResult[i], 0, 254)), so 255 never appears on the wire. A receiver written against WLED may treat 255 as a value real data cannot carry. The magnitude crosses in WLED's units and comes back in ours, so a MoonLight pair round-trips exactly while a WLED peer reads the value its own effects expect. +// WLED clamps bands to 254 and sends WLED units, so a MoonLight pair round-trips exactly and a WLED peer reads what its effects expect. TEST_CASE("FFT_Magnitude carries WLED units and round-trips back to ours") { AudioFrame f{}; f.peakMag = 144; // WLED's "full brightness" threshold after its /16 @@ -119,7 +119,7 @@ TEST_CASE("parse rejects wrong length, wrong header, v1, and null") { } TEST_CASE("parse clamps NaN / out-of-range floats instead of undefined casts") { - // A foreign packet passes the header check but carries hostile float payloads. wledFloatToU16 must bound them to [0, 65535] rather than let static_cast of a NaN / huge / negative float produce an undefined result. + // A foreign packet can pass the header check with hostile floats, so wledFloatToU16 bounds NaN, huge and negative values to 0..65535. AudioFrame f = sampleFrame(); uint8_t pkt[WLED_SYNC_PACKET_SIZE]; buildWledAudioSync(pkt, f, false); diff --git a/test/unit/core/unit_moonlive_codegen_xtensa.cpp b/test/unit/core/unit_moonlive_codegen_xtensa.cpp index 7ff4d940c..89cb50a5f 100644 --- a/test/unit/core/unit_moonlive_codegen_xtensa.cpp +++ b/test/unit/core/unit_moonlive_codegen_xtensa.cpp @@ -249,6 +249,33 @@ TEST_CASE("Xtensa load32/store32 reach the WHOLE arena, not just the first 60 by CHECK(top.size() == 3); // the wide form, never the 2-byte narrow one } +// Regression: addi's immediate is a signed byte, so arguments staged at byte 132 were addressed at -124, and the script drew nothing; every slot's sum is decoded. +TEST_CASE("Xtensa slotAddr reaches every frame slot, past the byte addi carries") { + using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; + using mm_xtensa_backend::mm::moonlive::R0; + bool reachedPast127 = false; + for (uint8_t slot = 0; slot < Asm::kMaxSpillSlots; slot++) { + Asm a(64); + a.slotAddr(R0, slot); + REQUIRE_FALSE(a.overflowed()); + const uint8_t* b = a.bytes(); + int32_t sum = 0; + if (a.size() == 3) { + CHECK(b[1] == 0xc1); // addi a2, a1 + sum = static_cast(b[2]); + } else { + REQUIRE(a.size() == 6); + CHECK(b[1] == 0xd1); // addmi a2, a1 + CHECK(b[4] == 0xc2); // then addi a2, a2 + sum = static_cast(b[2]) * 256 + static_cast(b[5]); + reachedPast127 = true; + } + INFO("slot " << int(slot)); + CHECK(sum == 48 + slot * 4); // the frame base plus the slot's word + } + CHECK(reachedPast127); // the frame does reach past the byte, or this tests nothing +} + TEST_CASE("Xtensa shlImm encodes 32-n where sarImm encodes n") { using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; using mm_xtensa_backend::mm::moonlive::R0; diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index 1a2372495..b6bd0064b 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -68,7 +68,7 @@ TEST_CASE("MoonLive run is a no-op on sub-RGB buffers (cpl 1 and 2)") { moonlive::MoonLive engine; REQUIRE(engine.compile(255, 255, 255)); for (uint8_t cpl : {uint8_t(1), uint8_t(2)}) { - std::vector buf(8 * cpl, 0xAB); // exact size — an RGB write WOULD overrun + std::vector buf(8 * cpl, 0xAB); // exact size: an RGB write WOULD overrun engine.run(buf.data(), 8, cpl, 0); for (auto v : buf) CHECK(v == 0xAB); // every byte untouched, no out-of-bounds } @@ -130,7 +130,7 @@ TEST_CASE("platform allocExec returns usable executable memory, freeExec release CHECK(buf[0] == 7); platform::freeExec(blk, 64); } -#endif // MM_MOONLIVE_HAS_HOST_JIT — the JIT-dependent block ends here; the STAGE 1 CONTROLS below +#endif // MM_MOONLIVE_HAS_HOST_JIT: the JIT-dependent block ends here; the STAGE 1 CONTROLS below // exercise the parser/arena, which also depend on compile() succeeding, so they gate too. #if MM_MOONLIVE_HAS_HOST_JIT @@ -169,7 +169,7 @@ TEST_CASE("elapsed time survives a call that happens before it is read") { "setRGB(1, mod(t, 200), 0, 0);"), kCtrlTable, kSys)); uint8_t buf[2 * 3] = {}; eng.run(buf, 2, 3, 12345); - CHECK(buf[3] == 12345 % 200); // 145 — the elapsed value the host passed, not a clobbered one + CHECK(buf[3] == 12345 % 200); // 145, the elapsed value the host passed, not a clobbered one eng.free(); } #endif @@ -195,21 +195,23 @@ TEST_CASE("a compiled script reports its size, and its tightest budget only when eng.describe(buf, sizeof(buf)); INFO("described: " << buf); CHECK(std::strstr(buf, " B") != nullptr); // a byte count - CHECK(std::strstr(buf, "/") == nullptr); // and no budget: 1 of 8 controls is not news + CHECK(std::strstr(buf, "/") == nullptr); // and no budget: one control is not news CHECK(eng.codeLen() > 0); // A script using every control slot is one edit from failing, so the card says which wall. - REQUIRE(eng.compile("class T {\n" - " byte a=1; byte b=1; byte c=1; byte d=1;\n" - " byte e=1; byte f=1; byte g=1; byte h=1;\n" - " void defineControls() { addControl(\"a\",a,0,9); addControl(\"b\",b,0,9);\n" - " addControl(\"c\",c,0,9); addControl(\"d\",d,0,9); addControl(\"e\",e,0,9);\n" - " addControl(\"f\",f,0,9); addControl(\"g\",g,0,9); addControl(\"h\",h,0,9); }\n" - " void tick() { setRGB(0, a, b, c); }\n}\n", kCtrlTable, kSys)); + std::string full = "class T {", adds; + for (int i = 0; i < moonlive::kMaxCtrls; i++) { + const std::string m = "m" + std::to_string(i); + full += " byte " + m + " = 1;"; + adds += " addControl(\"" + m + "\", " + m + ", 0, 9);"; + } + full += " void defineControls() {" + adds + " } void tick() { setRGB(0, m0, m1, m2); } }"; + REQUIRE(eng.compile(full.c_str(), kCtrlTable, kSys)); moonlive::runDefineControls(eng); eng.describe(buf, sizeof(buf)); INFO("described: " << buf); - CHECK(std::strstr(buf, "controls 8/8") != nullptr); + const std::string wall = "controls " + std::to_string(moonlive::kMaxCtrls) + "/" + std::to_string(moonlive::kMaxCtrls); + CHECK(std::strstr(buf, wall.c_str()) != nullptr); eng.free(); } @@ -284,12 +286,12 @@ TEST_CASE("MoonLive controls: arena address is STABLE across a recompile and the REQUIRE(eng.compile(mmScript("byte speed = 7;\nsetRGB(speed, 0, 0, 255);"), kCtrlTable, kSys)); uint8_t* before = eng.controlSlot(0); REQUIRE(before != nullptr); - *before = 12; // a "slider move" — write the live value + *before = 12; // a "slider move": write the live value // Edit the source (recompile) but KEEP the control. The grow-only arena must not move, and the live value must survive (a kept control keeps its slider position across a source edit). REQUIRE(eng.compile(mmScript("byte speed = 7;\nsetRGB(speed, 255, 0, 0);"), kCtrlTable, kSys)); uint8_t* after = eng.controlSlot(0); - CHECK(after == before); // STABLE address — no dangling bound pointer + CHECK(after == before); // STABLE address, no dangling bound pointer CHECK(*after == 12); // value preserved across the recompile // Adding a SECOND control keeps the first's value and seeds the new slot from its default. @@ -341,7 +343,7 @@ TEST_CASE("MoonLive controls: free() releases the arena (no stale slot after rel REQUIRE(eng.controlSlot(0) != nullptr); eng.free(); CHECK_FALSE(eng.ok()); - CHECK(eng.controlSlot(0) == nullptr); // arena gone — no dangling pointer handed out + CHECK(eng.controlSlot(0) == nullptr); // arena gone, no dangling pointer handed out // Recompiling after a full free re-acquires cleanly (add/remove robustness). REQUIRE(eng.compile(mmScript("byte a = 5;\nfill(0, 0, a);"), kCtrlTable, kSys)); REQUIRE(eng.controlSlot(0) != nullptr); @@ -727,6 +729,25 @@ TEST_CASE("a class declaring more member data than the arena holds is refused") eng.free(); } +// A script that walks, keeps a beat and flashes needs more than eight members, and packing them into one int was the workaround the limit forced. +TEST_CASE("a class holds kMaxCtrls members, and one more is refused by name") { + std::string ok = "class T {"; + for (int i = 0; i < moonlive::kMaxCtrls; i++) ok += " byte m" + std::to_string(i) + " = " + std::to_string(i) + ";"; + ok += " void tick() { setRGB(0, m" + std::to_string(moonlive::kMaxCtrls - 1) + ", 0, 0); } }"; + moonlive::MoonLive eng; + REQUIRE(eng.compile(ok.c_str(), kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == moonlive::kMaxCtrls - 1); // the last member is seeded and read like the first + eng.free(); + + const std::string over = ok.substr(0, ok.find(" void")) + " byte extra = 1;" + ok.substr(ok.find(" void")); + moonlive::MoonLive refused; + CHECK_FALSE(refused.compile(over.c_str(), kCtrlTable, kSys)); + CHECK(std::string(refused.error()).find("too many members") != std::string::npos); + refused.free(); +} + // A uint16_t member holds a value a byte cannot. // This is the correctness wall on a 256-wide wall. // Every arena slot was 8-bit, so a coordinate clamped at 255 and a modifier could not walk a light off a large grid. @@ -1335,8 +1356,19 @@ TEST_CASE("a coordinate far outside the grid saturates at that edge, not the opp CHECK(px[6] == 7); // same on the other axis } -#endif // MM_MOONLIVE_HAS_HOST_JIT — every case above needs compile() to SUCCEED, so - // they all gate on the JIT: on a target with no backend (x86-64 desktop today) the helpers they call are compiled out with it. +#endif // MM_MOONLIVE_HAS_HOST_JIT: every case above needs compile() to succeed, and on a target with no backend (the x86-64 desktop) the helpers they call are compiled out with it. + +// Regression: the slot release checked only some of its sinks, so detaching one released the slot while the palette, flow or trail sink was still installed. +TEST_CASE("a sink slot stays owned while any of its sinks is still installed") { + using namespace mm::moonlive; + static int ctx = 0; + setPalSink([](void*, uint8_t, uint8_t, uint8_t, uint8_t) {}, &ctx); + setFadeSink([](void*, uint8_t) {}, &ctx); + setFadeSink(nullptr, nullptr); // one sink leaves + CHECK(palSink().fn != nullptr); // the palette sink is still this thread's + setPalSink(nullptr, nullptr); + CHECK(palSink().fn == nullptr); // and the slot goes once nothing is left +} // The table was FULL at 16 entries and add() failed silently, so the next builtin registered would have vanished and surfaced as "unknown function" in a script. TEST_CASE("the builtin table has room and reports an overflow") { diff --git a/test/unit/light/MoonLiveScriptFixture.h b/test/unit/light/MoonLiveScriptFixture.h index 76de86633..023174075 100644 --- a/test/unit/light/MoonLiveScriptFixture.h +++ b/test/unit/light/MoonLiveScriptFixture.h @@ -1,7 +1,7 @@ #pragma once #include "doctest.h" -#include "light/moonlive/MoonLiveScriptFile.h" +#include "core/moonlive/MoonLiveScriptFile.h" #include "platform/platform.h" #include diff --git a/test/unit/light/unit_Drivers_container.cpp b/test/unit/light/unit_Drivers_container.cpp index 9de1af0ec..0679bec03 100644 --- a/test/unit/light/unit_Drivers_container.cpp +++ b/test/unit/light/unit_Drivers_container.cpp @@ -50,23 +50,43 @@ class RebuildTrackingDriver : public mm::DriverBase { int correctionCalls = 0; }; -// Regression (the profile-edit LED-blank bug): editing a live fixture profile blanked the strip for ~½s, even on drivers NOT using that profile. Cause: the list-mutation handler re-ran a whole-tree prepareTree(), and a physical driver's prepare() reinits its output peripheral (an RMT channel teardown → dark for a tick). A preset edit changes correction DATA, not pipeline STRUCTURE, so the fix routes it through rebuildAllCorrections(), the tier-1 correction refresh, which must re-resolve each driver's correction WITHOUT calling its prepare(). This pins that split so the blank can't return: rebuildAllCorrections() bumps the correction path, never prepare(). -TEST_CASE("Drivers::rebuildAllCorrections re-resolves corrections without re-preparing drivers") { +// Regression: a profile edit re-ran prepare on the whole tree, whose output reinit blanked every strip for half a second; the list-changed hook re-resolves the correction alone. +TEST_CASE("A fixture-profile list edit re-resolves each driver's correction without re-preparing it") { + mm::FixtureProfilesModule lib; // construction claims the library seat mm::Drivers drivers; RebuildTrackingDriver drv; drivers.addChild(&drv); drivers.on = true; drivers.brightness = 200; drv.defineControls(); - drivers.setup(); // setup() seeds via passBufferToDrivers → rebuildCorrection - const int baselineCorrection = drv.correctionCalls; // whatever setup did — we measure the delta + drivers.setup(); // seeds each driver's correction + const int baselineCorrection = drv.correctionCalls; const int baselinePrepare = drv.prepareCalls; - // The correction-only refresh the list-mutation handler now calls instead of prepareTree(). - drivers.rebuildAllCorrections(); + drv.onListChanged(lib); + CHECK(drv.correctionCalls > baselineCorrection); // the edit reaches the output + CHECK(drv.prepareCalls == baselinePrepare); // with no peripheral reinit, so no blank - CHECK(drv.correctionCalls > baselineCorrection); // correction WAS re-resolved (edit reaches output) - CHECK(drv.prepareCalls == baselinePrepare); // but prepare() was NOT called — no peripheral reinit, no blank + // Another module's list is not one a driver resolves from. + const int afterLib = drv.correctionCalls; + drv.onListChanged(drivers); + CHECK(drv.correctionCalls == afterLib); +} + +// Core reads the light pipeline through LightOutput, so the seat must hold only a live, prepared Drivers. +TEST_CASE("Drivers is the light output core reads, from prepare until release") { + mm::Drivers drivers; + CHECK(mm::LightOutput::active() != &drivers); // a probe that never prepares never publishes + drivers.prepare(); + CHECK(mm::LightOutput::active() == &drivers); + CHECK(&mm::lightSummary() == &drivers.summary()); + // A palette's own color finds a palette of the same color. + const mm::LightOutput& out = drivers; + const mm::RGB c = out.paletteRgb(out.nearestPalette(mm::RGB{255, 0, 0})); + CHECK(c.r == 255); + drivers.release(); + CHECK(mm::LightOutput::active() == nullptr); + CHECK(mm::lightSummary().lightCount == 0); // the all-zero summary, never a dangling one } // The `on` control is master power: on=false scales the correction LUT to zero (output black) while PRESERVING the brightness value, so on=true restores the exact level. It rides the same cheap LUT rebuild as brightness (no pipeline realloc). This pins the shared power control IR/MQTT/WLED drive. @@ -301,3 +321,22 @@ TEST_CASE("Drivers: relay pins and double buffering are expert settings, and the for (const char* name : {"loopbackTest", "loopbackTxPin", "loopbackRxPin", "loopbackFrame"}) CHECK_MESSAGE(modeOf(rmt, name) == mm::kModeDeveloper, name); } + +// A scripted palette has no stored stops, so its color comes from the entries it computes now, not from the built-in its index would wrap onto. +TEST_CASE("Drivers gives a scripted palette's color from its live entries") { + mm::Drivers drivers; + const mm::Palette saved = *mm::Palettes::active(); + mm::Palette blue; + for (auto& e : blue.entry) e = mm::RGB{0, 0, 255}; + mm::Palettes::setActiveDirect(blue); // what a running palette script wrote + uint16_t hue = 0, sat = 0; + drivers.paletteHueSat(mm::palettes::kCount, hue, sat); // the first scripted index + CHECK(hue == 240); + CHECK(sat == 255); + uint16_t builtinHue = 0, builtinSat = 0; + mm::Palettes::representativeHueSat(0, builtinHue, builtinSat); + drivers.paletteHueSat(0, hue, sat); // a built-in still reads its own stops + CHECK(hue == builtinHue); + CHECK(sat == builtinSat); + mm::Palettes::setActiveDirect(saved); +} diff --git a/test/unit/light/unit_Drivers_firstOutputRgb.cpp b/test/unit/light/unit_Drivers_firstOutputRgb.cpp deleted file mode 100644 index 5bcf5b4e1..000000000 --- a/test/unit/light/unit_Drivers_firstOutputRgb.cpp +++ /dev/null @@ -1,73 +0,0 @@ -/// @module Drivers - -/// Pins Drivers::firstOutputRgb, the domain-neutral seam the WLED-compatibility shim uses to tint the app's device card with the live first-LED color. It reads pixel 0 of whichever buffer Drivers is driving (the single-layer fast path here: the layer's own buffer). - -#include "doctest.h" -#include "light/drivers/Drivers.h" -#include "light/layers/Layer.h" -#include "light/layouts/Layouts.h" -#include "light/layouts/GridLayout.h" - -#include - -namespace { - -// A 4×4 grid, one layer, pinned directly to a Drivers (the test-rig path). Returns the layer so the caller can write pixel 0. -struct Rig { - mm::Layouts layouts; - mm::GridLayout grid; - mm::Layer layer; - mm::Drivers drivers; - - Rig() { - grid.width = 4; grid.height = 4; grid.depth = 1; - layouts.addChild(&grid); - layer.setLayouts(&layouts); - layer.setChannelsPerLight(3); - layouts.applyState(); - layer.applyState(); - drivers.setLayer(&layer); // pin: Drivers reads this layer's buffer - } -}; - -} // namespace - -TEST_CASE("Drivers::firstOutputRgb reads pixel 0 of the driven buffer") { - Rig r; - uint8_t* buf = r.layer.buffer().data(); - REQUIRE(buf != nullptr); - buf[0] = 10; buf[1] = 200; buf[2] = 60; // pixel 0 = R,G,B (logical channel order) - - uint8_t rgb[3] = {0, 0, 0}; - REQUIRE(r.drivers.firstOutputRgb(rgb)); - CHECK(rgb[0] == 10); - CHECK(rgb[1] == 200); - CHECK(rgb[2] == 60); -} - -TEST_CASE("Drivers::firstOutputRgb reports black pixel 0 as-is (caller substitutes the default)") { - Rig r; - uint8_t* buf = r.layer.buffer().data(); - REQUIRE(buf != nullptr); - buf[0] = 0; buf[1] = 0; buf[2] = 0; // black — firstOutputRgb returns true with 0,0,0 - - uint8_t rgb[3] = {1, 2, 3}; - REQUIRE(r.drivers.firstOutputRgb(rgb)); // true: there IS an output, and it is black - CHECK(rgb[0] == 0); - CHECK(rgb[1] == 0); - CHECK(rgb[2] == 0); - // (The WLED shim is what maps an all-black read to MoonLight purple, pinned in the HTTP shim's own logic, not here; this seam reports the raw pixel.) -} - -TEST_CASE("Drivers::firstOutputRgb returns false when there is no driven buffer") { - mm::Drivers drivers; // no layer pinned, no buffer - uint8_t rgb[3] = {9, 9, 9}; - CHECK_FALSE(drivers.firstOutputRgb(rgb)); -} - -TEST_CASE("MoonModule::firstOutputRgb defaults to false (no output module)") { - // A plain module that doesn't drive output never claims a first LED, the seam's safe default, so the shim falls back to purple for a device with no Drivers. - mm::MoonModule m; - uint8_t rgb[3] = {}; - CHECK_FALSE(m.firstOutputRgb(rgb)); -} diff --git a/test/unit/light/unit_Drivers_livePalettes.cpp b/test/unit/light/unit_Drivers_livePalettes.cpp index 6ec0d4e8f..d214411f0 100644 --- a/test/unit/light/unit_Drivers_livePalettes.cpp +++ b/test/unit/light/unit_Drivers_livePalettes.cpp @@ -11,7 +11,7 @@ #include "light/drivers/Drivers.h" #include "light/util/Palette.h" #include "core/util/JsonSink.h" -#include "light/moonlive/MoonLiveScriptFile.h" +#include "core/moonlive/MoonLiveScriptFile.h" #include "platform/platform.h" #include "../core/conditional_controls.h" // mm::test::controlIndex diff --git a/test/unit/light/unit_MoonLiveScriptResolve.cpp b/test/unit/light/unit_MoonLiveScriptResolve.cpp index a32dedd01..c73f58e85 100644 --- a/test/unit/light/unit_MoonLiveScriptResolve.cpp +++ b/test/unit/light/unit_MoonLiveScriptResolve.cpp @@ -10,7 +10,7 @@ /// These pin the three cases plus the one that used to be a bug waiting to happen: both readers (the compiler and the change-detector) must resolve to the SAME file, or a fork would be compiled from one and hashed from the other and recompile on every prepare sweep forever. #include "doctest.h" -#include "light/moonlive/MoonLiveScriptFile.h" +#include "core/moonlive/MoonLiveScriptFile.h" #include "platform/platform.h" #include diff --git a/test/unit/light/unit_MoonLiveScripts.cpp b/test/unit/light/unit_MoonLiveScripts.cpp index f2763df87..fffa2e8e6 100644 --- a/test/unit/light/unit_MoonLiveScripts.cpp +++ b/test/unit/light/unit_MoonLiveScripts.cpp @@ -17,9 +17,10 @@ #include "core/moonlive/moonlive_emit.h" #include "light/moonlive/MoonLiveBuiltins_light.h" #include "core/moonlive/MoonLiveBuiltins_service.h" // the service vocabulary a .mls compiles against -#include "light/moonlive/MoonLiveScriptFile.h" // the role extensions the sweep filters on -#include "light/moonlive/MoonLiveScript.h" // kMaxStatus: the status line a failure reports through -#include "light/moonlive/script_catalog.h" // generated: what the device offers +#include "core/moonlive/MoonLiveScriptFile.h" // the role extensions the sweep filters on +#include "core/moonlive/MoonLiveScript.h" // kMaxStatus: the status line a failure reports through +#include "core/moonlive/script_catalog.h" // generated: what the device offers +#include "core/services/MoonLiveService.h" // kServiceTemplate: what a new service script starts as #include #include