Skip to content

Pads and LEDs

SDK 1.xWritten for core 1.53+ and gui 1.23+.

Simulator
Part of it Pad pressure and the Rainbow frame (PadAnimator) show on the CrossPad; a click hits at full velocity.
CrossPad
Needed for pressure and the Rainbow frame.
Example
examples/02-pads-leds in crosspad-examples, which is not public yet

Goal: an app that takes over the 16 pads while it is open, lights each pad in its own colour (harder hits brighter), draws the grid on the screen, and hands the pads back cleanly when it closes.

You need: Start here done, or only the PC simulator; the examples cloned; Hello screen read, because this recipe builds on its create, destroy and encoder group. Pressure and the Rainbow frame show on a CrossPad.

The app registers a pad logic, an IPadLogicHandler that gets every press, pressure change and release while the app is open. The logic lights the pads and records a level per pad; it never touches the screen. A timer on the LVGL task reads those levels and paints a 4 × 4 grid laid out like the pads (pad 1 bottom-left), plus a label naming the last pad hit. A Rainbow button shows a full 16-pad frame with PadAnimator on the board.

The app on the simulator: a 4 by 4 grid of dim coloured squares with the second square of the second row from the bottom lit bright green, the label "Pad 6  level 110" at the top right and a blue Rainbow button at the bottom right
PadsLeds in the simulator, just after pad 6 was hit at velocity 110.

The picture is the simulator, a moment after pad 6 was hit at velocity 110 through its remote-control port (Softer hits on the simulator). A mouse click hits at 127.

The code is in the example folder (examples/02-pads-leds in crosspad-examples, which is not public yet): src/PadsLedsApp.cpp (the screen), src/PadsLedsPadLogic.cpp with its header (the pads), and include/example-pads-leds/PadsModel.hpp (the colours, plain C++ with a host test in tests/).

  1. Copy the example into your simulator checkout. Run this from your crosspad-examples clone.

    Terminal window
    python3 tools/install.py --target pc ~/crosspad-pc pads-leds

    You should see: installed example-pads-leds -> …/src/apps/crosspad-example-pads-leds.

  2. Rebuild the simulator. The build looks for apps while CMake configures, so configure again before you build. cmake -B build reuses the settings of your first build, which CMake keeps in build/.

    Terminal window
    cd ~/crosspad-pc
    cmake -B build
    cmake --build build

    You should see: a line starting -- App submodule: crosspad-example-pads-leds while CMake configures, and a build that ends without error.

    If not: a new app does not show up.

  3. Start the simulator, from the same folder.

    Terminal window
    ./bin/CrossPad

    You should see: the CrossPad window with the launcher, and a tile labelled PadsLeds among the apps.

  4. Click the PadsLeds tile.

    You should see: a 4 × 4 grid of dim squares in sixteen colours on the screen, Hit a pad at the top right and a Rainbow button at the bottom right. The simulator’s pads glow in the same colours: pad 1 (bottom-left) red, then round the colour wheel through yellow, green, cyan, blue and magenta to pad 16.

  5. Click a pad in the simulator window and hold the mouse button.

    You should see: the pad and its square on the screen light at full brightness, and the label reads Pad N level 127 (N is the pad’s number). Release, and both go back to the dim glow; the label keeps the pad and reads Pad N level 0, because 0 means released. A mouse click, or a keyboard key mapped to a pad, always hits at velocity 127, so every hit from the simulator window is a full-brightness one.

  6. Close the app with a short press of the power button (Space or Ctrl in the simulator).

    You should see: the launcher again, and the pads lose the app’s colours.

Pad pressure and the Rainbow frame show on the CrossPad; in the simulator, Rainbow changes its label to Stop and back. Simulator vs CrossPad lists what differs.

The simulator’s remote-control port (TCP on 127.0.0.1:19840, one JSON command per line) takes a velocity, so you can see a soft hit without a board. With the app open, save this as soft_hit.py and run it with python3 soft_hit.py (Windows: python soft_hit.py). Pads count from 0 here, so 5 is pad 6.

import socket, time
s = socket.create_connection(("127.0.0.1", 19840))
for cmd in ('{"cmd":"pad_press","pad":5,"velocity":40}', '{"cmd":"pad_release","pad":5}'):
s.sendall((cmd + "\n").encode())
print(s.recv(256).decode().strip())
time.sleep(1)

You should see: {"ok":true,"pad":5,"velocity":40} in the terminal, pad 6 and its square lit at about a third of full brightness for a second, and the label Pad 6 level 40.

Step 1 runs from your crosspad-examples clone. The steps after it run in the firmware project (~/CrossPad, Windows C:\CrossPad\firmware) in a terminal with ESP-IDF loaded, as in 2. Build the firmware. Used the one-line installer? Use crosspad-idf in place of idf.py.

  1. Copy the example into the firmware project.

    Terminal window
    python3 tools/install.py --target idf ~/CrossPad pads-leds

    You should see: installed example-pads-leds -> …/components/crosspad-example-pads-leds.

  2. Clean the build folder and build. The firmware looks for apps only when it configures from scratch, hence the fullclean. The lines are for a v2 board (PCB r20).

    Terminal window
    cd ~/CrossPad
    idf.py -B build_v2 fullclean
    idf.py -B build_v2 -DSDKCONFIG=sdkconfig.v2 build

    For a v1 board (PCB r16 / r19), use build_v1 in both commands and sdkconfig.v1 in the second.

    You should see: Generated app_registry_init.cpp with N apps: …;PadsLeds;… while CMake configures, and Project build complete. To flash, run: at the end.

    If not: build stops before Project build complete. If the build completes but PadsLeds is not in the list: a new app does not show up.

  3. Flash the firmware, then the assets, which hold the app's icon and go to the board separately. Answer the board as in 3. Flash the board: Allow, then Update, then leave it alone for 10 seconds.

    Terminal window
    python3 tools/ota_flash.py
    python3 tools/fs_transfer.py assets

    You should see: OTA complete! from the first command and Assets written and verified. The device is restarting. from the second.

    If not: OTA flash cannot find the board, or OTA times out waiting for confirmation.

  4. Open the app. On the board, hit pad 1 on the MODE map to open the Sampler. Then, with the board on USB, start the app with the crosspad-hil command-line tool, from the venv you made in 1. Set up the toolchain. Used the one-line installer? Skip the first line: crosspad-hil already works.

    Terminal window
    . ~/CrossPad/.venv/bin/activate
    crosspad-hil app start PadsLeds

    You should see: running=PadsLeds in the terminal.

    If not: the command needs the board in MIDI + Serial mode: run crosspad-hil usb-mode set default, press Allow on the board, and start the app again. unknown app: the firmware on the board has no app of that name; crosspad-hil app list lists the ones it has, and step 2's configure line shows whether the build found it. No board found: OTA flash cannot find the board.

    You should see: the grid on the screen, Hit a pad, the Rainbow button, and all 16 pads glowing dimly in their colours, pad 1 red.

  5. Hit pad 5 softly, then hard. Then press and hold it, and push harder.

    You should see: pad 5 lights yellow-green, and its square on the screen follows; the label reads Pad 5 level and a number. A quick tap shows the hit’s velocity, so a harder tap is brighter. A held pad streams its pressure, which replaces the velocity almost at once: pushing harder or softer changes the brightness and the level. Release, and the pad drops back to the dim glow and the label reads Pad 5 level 0.

  6. Tap Rainbow (or press the encoder while it is focused).

    You should see: all 16 pads at full brightness in their colours, and the button reads Stop. Hit a pad: its square and the label change, the LEDs do not. Tap Stop: the pads go back to the app’s colours.

  7. Close the app with a short press of the power button.

    You should see: the MODE map again, and the pads lose the app’s colours.

PadsModel.hpp (the colour of each pad and how a level scales its brightness) has a host test, tests/test_pads_leds.cpp: plain C++ and Catch2, no board and no LVGL.

The test build needs a crosspad-core checkout: your simulator clone has one in lib/crosspad-core. The first configure downloads Catch2. Run it from your crosspad-examples clone.

Terminal window
cmake -S tests -B tests/build -G Ninja -DCROSSPAD_CORE_DIR="$(cd ~/crosspad-pc/lib/crosspad-core && pwd)"
cmake --build tests/build
ctest --test-dir tests/build -R pads-leds --output-on-failure

You should see: 100% tests passed, 0 tests failed out of 5. The build compiles the tests of every example in the clone; -R pads-leds runs only this recipe's.

02-pads-leds/src/PadsLedsApp.cpp
std::shared_ptr<PadsLedsPadLogic> s_logic; // kept for the life of the program (see create)
lv_obj_t* s_cells[16] = {}; // one square per pad
lv_obj_t* s_status = nullptr; // "Pad N level L"
lv_timer_t* s_timer = nullptr; // redraws the cells from the recorded levels
lv_group_t* s_group = nullptr; // what the encoder walks
lv_obj_t* s_rainbow_label = nullptr; // "Rainbow" / "Stop"
bool s_rainbow = false; // this app started the PadAnimator frame

Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 25–31 (repository opens soon)

Everything the screen needs lives at file scope, so the timer and the button callback can reach it. These variables keep their values between visits, so create sets every one back to null or false first, except s_logic, which is meant to live as long as the program (What outlives the screen). s_rainbow remembers that this app, not someone else, started the animation.

02-pads-leds/src/PadsLedsApp.cpp
// Registered once and kept: REGISTER_APP_PL below makes the framework
// push it when the app opens and pop it when the app closes.
if (!s_logic) {
s_logic = std::make_shared<PadsLedsPadLogic>();
crosspad::getPadManager().registerPadLogic("PadsLeds", s_logic);
}

Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 92–97 (repository opens soon)

registerPadLogic on getPadManager() stores the handler under a name; the last argument of REGISTER_APP_PL (below) is that same name. The framework gives the pads to that handler right after create returns, so the registration has to be done by then. The handler is created once and kept for the life of the program, so nothing the pad callbacks use is ever freed. Keeping it also means one registration for every visit, and the timer can read s_logic without a null check.

02-pads-leds/include/example-pads-leds/PadsLedsPadLogic.hpp
/// Pad callbacks run on the pad task, not the LVGL task: this class lights
/// the pads and records levels, and touches no LVGL object.
class PadsLedsPadLogic : public crosspad::IPadLogicHandler {
public:
void onActivate(crosspad::PadManager& pm) override;
void onDeactivate(crosspad::PadManager& pm) override;
void onPadPress(crosspad::PadManager& pm, uint8_t pad, uint8_t velocity) override;
void onPadRelease(crosspad::PadManager& pm, uint8_t pad) override;
void onPadPressure(crosspad::PadManager& pm, uint8_t pad, uint8_t pressure) override;
/// 0 = released; otherwise the latest velocity or pressure (1..127).
uint8_t level(uint8_t pad) const { return pad < 16 ? levels_[pad].load() : 0; }
int lastPad() const { return lastPad_.load(); }
private:
std::atomic<uint8_t> levels_[16]{};
std::atomic<int> lastPad_{-1};
};

Source: examples/02-pads-leds/include/example-pads-leds/PadsLedsPadLogic.hpp, lines 8–25 (repository opens soon)

The handler implements the five callbacks of IPadLogicHandler it needs and keeps two things the screen reads: a level per pad (0 when released, else the latest velocity or pressure) and the last pad hit (−1 before the first hit). Both are std::atomic, because the pad task writes them while the LVGL task reads them. level() and lastPad() are the only way the screen sees them.

02-pads-leds/src/PadsLedsPadLogic.cpp
void light(crosspad::PadManager& pm, uint8_t pad, uint8_t level)
{
const auto c = padColor(pad, level);
pm.setPadColor(pad, c.r, c.g, c.b);
}

Source: examples/02-pads-leds/src/PadsLedsPadLogic.cpp, lines 10–14 (repository opens soon)

light is a file-local helper in PadsLedsPadLogic.cpp: it turns a pad and a level into the pad’s colour and sets it with the (pad, r, g, b) form of setPadColor. Every callback below lights the pads through it.

02-pads-leds/src/PadsLedsPadLogic.cpp
void PadsLedsPadLogic::onActivate(crosspad::PadManager& pm)
{
for (uint8_t p = 0; p < 16; ++p) {
levels_[p].store(0);
light(pm, p, example_pads::kRestLevel);
}
lastPad_.store(-1); // a new visit starts with no "last pad"
}
void PadsLedsPadLogic::onDeactivate(crosspad::PadManager& pm)
{
// Hand the LEDs back: the next pad logic paints its own colours.
for (uint8_t p = 0; p < 16; ++p) pm.clearPadColor(p);
}

Source: examples/02-pads-leds/src/PadsLedsPadLogic.cpp, lines 19–32 (repository opens soon)

onActivate runs each time the app opens and gets the pads. It clears the recorded levels and paints every pad at the rest level with light, so the colours show before the first hit. It also resets the last pad to −1: without that, a second visit shows the previous visit’s Pad N level L before you touch anything. onDeactivate runs when the app closes and calls clearPadColor on every pad; a colour you set stays on the pad until you clear it, even after your app is gone.

Pad callbacks: light and record, nothing else

Section titled “Pad callbacks: light and record, nothing else”
02-pads-leds/src/PadsLedsPadLogic.cpp
void PadsLedsPadLogic::onPadPress(crosspad::PadManager& pm, uint8_t pad, uint8_t velocity)
{
if (pad >= 16) return;
const uint8_t v = velocity ? velocity : 1;
light(pm, pad, v);
levels_[pad].store(v); // the screen reads these on the LVGL task
lastPad_.store(pad);
}
void PadsLedsPadLogic::onPadPressure(crosspad::PadManager& pm, uint8_t pad, uint8_t pressure)
{
if (pad >= 16 || levels_[pad].load() == 0) return; // aftertouch only while held
const uint8_t v = pressure ? pressure : 1;
light(pm, pad, v);
levels_[pad].store(v);
}
void PadsLedsPadLogic::onPadRelease(crosspad::PadManager& pm, uint8_t pad)
{
if (pad >= 16) return;
light(pm, pad, example_pads::kRestLevel);
levels_[pad].store(0);
}

Source: examples/02-pads-leds/src/PadsLedsPadLogic.cpp, lines 36–58 (repository opens soon)

These run on a pad task, not the LVGL task, and two of them can run at the same time (Threads). So they light the LED through the PadManager they are handed, store the level in a std::atomic, and touch no LVGL object. While this logic has the pads, PadManager does nothing of its own for a press or a release (no default light, no MIDI note, no bus event): what the pads show is what this code sets.

onPadPress turns a velocity of 0 into 1, because a level of 0 means “released” to the timer. onPadPressure (aftertouch) ignores a pad whose level is 0, so a pressure value that arrives after the release cannot light a pad that is no longer held. onPadRelease goes back to the rest level. The colour itself comes from padColor(pad, level) in PadsModel.hpp: the pad’s hue, scaled by the level from 0 to 127.

02-pads-leds/src/PadsLedsApp.cpp
// The screen is a picture of the pads: row 0 on screen is pads 13-16.
const int32_t cell = 36, gap = 4;
for (int row = 0; row < 4; ++row) {
for (int col = 0; col < 4; ++col) {
const int pad = (3 - row) * 4 + col;
lv_obj_t* c = lv_obj_create(root);
lv_obj_remove_style_all(c);
lv_obj_set_size(c, cell, cell);
lv_obj_set_pos(c, col * (cell + gap), row * (cell + gap));
lv_obj_set_style_radius(c, 4, 0);
lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0);
s_cells[pad] = c;
}
}

Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 110–123 (repository opens soon)

create lays the cells out the way the player sees the pads: pad 1 bottom-left, so the top row on the screen is pads 13 to 16 (The screen). Row 0 is the top of the screen, and the index in s_cells is the pad index, counted from 0, so the timer can walk the cells in pad order:

col 0 col 1 col 2 col 3
row 0 12 13 14 15 (pads 13-16)
row 1 8 9 10 11
row 2 4 5 6 7
row 3 0 1 2 3 (pads 1-4)
index = (3 - row) * 4 + col pad number = index + 1

Flip the rows and pad 1 lights the top-left square instead: the one mistake every pad-mirroring screen makes once.

02-pads-leds/src/PadsLedsApp.cpp
// Runs on the LVGL task every 33 ms: reads what the pad task recorded, draws it.
void tick(lv_timer_t*)
{
for (uint8_t p = 0; p < 16; ++p) {
const uint8_t lvl = s_logic->level(p);
const auto c = example_pads::padColor(p, lvl ? lvl : example_pads::kRestLevel);
lv_obj_set_style_bg_color(s_cells[p], lv_color_make(c.r, c.g, c.b), 0);
}
const int last = s_logic->lastPad();
if (last >= 0) {
lv_label_set_text_fmt(s_status, "Pad %d level %u", last + 1,
static_cast<unsigned>(s_logic->level(static_cast<uint8_t>(last))));
}
}

Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 35–48 (repository opens soon)

create starts this with lv_timer_create(tick, 33, nullptr). LVGL runs its timers on the LVGL task, so this is the one place that turns the recorded levels into pixels. Reading atomics from a timer needs no lock and no queue between the two tasks. The label shows the last pad hit and that pad’s level now, so after a release it reads Pad N level 0; before the first hit of a visit lastPad() is −1 and the label keeps Hit a pad.

02-pads-leds/src/PadsLedsApp.cpp
// PadAnimator holds a whole frame until stop(): while it shows, pad presses
// do not repaint the LEDs. Good for a moment, not for playing.
// The rainbow starts only while isActive() is false, so it never paints over
// an animation of the board's own.
void on_rainbow(lv_event_t*)
{
auto& anim = crosspad::getPadAnimator();
if (s_rainbow && anim.isActive()) {
anim.stop();
s_rainbow = false;
lv_label_set_text(s_rainbow_label, "Rainbow");
return;
}
s_rainbow = false;
lv_label_set_text(s_rainbow_label, "Rainbow");
if (anim.isActive()) return;
crosspad::RgbColor frame[crosspad::PadAnimator::kPadCount];
for (uint8_t p = 0; p < 16; ++p) {
const auto c = example_pads::wheel(p);
frame[p] = crosspad::RgbColor(c.r, c.g, c.b);
}
anim.showFrame(frame);
s_rainbow = true;
lv_label_set_text(s_rainbow_label, "Stop");
}

Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 52–76 (repository opens soon)

getPadAnimator() returns the board’s one animator. showFrame puts one RgbColor per pad on the LEDs and holds it until stop(), another frame or another animation. While it shows, the LED controller ignores every other colour change, so pad hits do not light the LEDs. The colours the pad logic sets in the meantime are still recorded, and stop() repaints them: that is why the pads come back in the app’s colours after Stop.

The callback starts a frame only when isActive() is false, so it never paints over an animation the board is showing, such as a firmware update’s progress bar. It stops only a frame it started (s_rainbow), and checks isActive() first because something else can have ended the frame since. In that case the button still reads Stop, and tapping it starts a new rainbow.

02-pads-leds/src/PadsLedsApp.cpp
void PadsLeds_destroy(lv_obj_t* root)
{
// root is null when the app's screen was already wiped: free what is ours, delete nothing.
if (s_rainbow && crosspad::getPadAnimator().isActive()) crosspad::getPadAnimator().stop();
s_rainbow = false;
s_rainbow_label = nullptr;
lv_timer_delete(s_timer);
s_timer = nullptr;
if (s_group) { lv_group_delete_deferred(s_group); s_group = nullptr; }
for (auto& c : s_cells) c = nullptr;
s_status = nullptr;
if (root) lv_obj_delete_async(root);
}

Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 142–154 (repository opens soon)

By the time destroy runs, the framework has already taken the pads back (onDeactivate has run). Destroy stops the rainbow if this app’s frame is still showing: left running, the frame would stay on the LEDs after the app closed, and no pad would light for anyone. It deletes the timer before the root goes, so tick never runs on deleted cells, deletes the encoder group with lv_group_delete_deferred because the encoder may still be reading it, sets every widget pointer back to null, and deletes the root last, asynchronously. It does not unregister the pad logic: that stays registered for the next visit.

02-pads-leds/src/PadsLedsApp.cpp
REGISTER_APP_PL(PadsLeds, nullptr, "example-pads-leds.png",
PadsLeds_create, PadsLeds_destroy,
nullptr, nullptr, nullptr, 0, "PadsLeds")

Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 158–160 (repository opens soon)

REGISTER_APP_PL is REGISTER_APP with a tenth argument: the name of the pad logic to give the pads to while the app is open. It must match the string passed to registerPadLogic exactly. How an app works explains the other nine arguments.

Rule this example followsWhat happens if you break it
The pad logic touches no LVGL object; it writes atomics that an lv_timer readsLVGL state gets out of step, and the screen misbehaves now and then, usually only on the board
onActivate resets everything a visit shows, including the last padA second visit opens with the previous visit’s label (Pad N level L) before any hit
create resets every file-scope variable firstThe second visit uses pointers to widgets the first visit deleted
onDeactivate calls clearPadColor on every pad it colouredThe pads keep the app’s colours at rest after it closes, and ignore the colours the next app sets for every pad (setDefaultColor, setPressedColor, setPlayingColor)
The pad logic is registered once, before create returns, under the name in argument 10; the app never calls setActivePadLogic or pushPadLogicThe console prints PadManager: App '<name>' not found, using default pad logic and the handler never sees a pad; switching the logic yourself fights the framework’s push and pop
Destroy deletes the timer, and stops a frame this app startedtick runs on deleted cells after the app closes (LoadProhibited); a frame left on the animator freezes every pad’s LEDs
The rainbow starts only when isActive() is false, and stops only its own frameWithout the first check, the button paints over an update’s progress bar; without s_rainbow, Stop ends animations the app never started
  • Play a sample: the same pad logic pattern, playing the current kit while the app has the pads.
  • How an app works: the lifecycle and the thread rules this recipe follows.
  • The API pages for IPadLogicHandler, PadManager and PadAnimator.
  • The example’s folder (examples/02-pads-leds in crosspad-examples, which is not public yet), with its host test.