Your own apps

Everything you add to a Spangap device is a straddle: a directory with a straddle.yaml manifest at its root and, under it, whatever halves your feature has. The firmware half is an ESP-IDF component. The browser half is a Vue module that gets folded into the device’s web interface. The name is because one straddle spans both sides of the platform, the device and the browser, as one versioned thing with its own dependencies. You tell the build which straddles you want and it assembles them into one firmware image and one web app.

There are three ways your code can show up, and a straddle can have any one of them or all three.

The plainest is a service: a C++ object the boot code constructs for you, with an onInit that runs once the platform is up. That is the whole of a headless feature, something that watches a sensor, talks to a network, or reacts to a setting, with no user interface of its own. Everything else below is built on it.

A browser app is a Vue component in a floating window, with an icon in the dock at the bottom of the web interface. The shell gives you the window, dragging, resizing, stacking, phone layout, and a live mirror of the device’s settings store, so your component reads a key and it just updates.

An LCD app is an object with a tile in the launcher on the device’s own screen. When somebody taps the tile, the shell creates an LVGL object the size of the screen and hands it to you: onCreate(lv_obj_t* root). Everything you draw goes into root. The shell hides your window when another app opens, brings it back untouched when yours is reopened, keeps your keyboard focus, puts you in the recents switcher and frees the whole tree when the app is closed. You never write a main loop and never touch LVGL from the wrong thread, because every method of your app runs on the one task LVGL lives on.

What ties the three together is the settings store. A key you declare in your manifest exists on the device with its default, shows up in the Settings app on the screen and in the browser, and can be read and set from the command line. Your service, your browser window and your LCD app all read it and subscribe to it the same way. That is how the Hello World below has one toggle that works from three places.

What a straddle looks like

hello/
├── straddle.yaml
├── esp-idf/
│   ├── CMakeLists.txt
│   ├── idf_component.yml
│   ├── include/
│   │   └── hello_service.h
│   ├── src/
│   │   └── hello_service.cpp
│   ├── conditional/
│   │   └── spangap-lcd/
│   │       ├── include/
│   │       │   └── hello_app.h
│   │       └── src/
│   │           └── hello_app.cpp
│   └── assets/
│       └── lcd-icons/
│           └── hello.svg
└── browser/
    ├── package.json
    └── src/
        ├── modules/
        │   └── hello.ts
        └── panels/
            └── HelloWindow.vue

esp-idf/ is an ordinary ESP-IDF component. The build stages it, with every other straddle in the image, into one flat components directory and generates the glue: a spangap_requires.cmake that turns your manifest’s dependencies into the component’s REQUIRES, and the boot code that constructs your services.

conditional/ is how a straddle carries code for a straddle that may not be in the build. Anything under conditional/<name>/ is compiled only when <name> is staged. The LCD app lives under conditional/spangap-lcd/, so the same straddle builds for a board without a screen and simply has no tile there.

browser/ is a small npm package. The build links it into the web interface and generates one dispatcher that calls every straddle’s registration function, so staging the straddle is all it takes for its window to appear. Leave the directory out and the straddle has no browser half, which is fine.

The icon is one SVG file in assets/lcd-icons/, and it serves both surfaces: every staged straddle’s icon directory is collected into the LCD launcher and into the browser dock.

straddle.yaml

The manifest is the only thing that makes a directory a straddle. For the Hello World it is this:

schema_version: 1
name: yourname/hello
prefix: hello
version: 0.1.0
firmware: esp-idf
browser: browser

services:
  - { class: HelloService, header: hello_service.h }
  - { class: HelloApp, header: hello_app.h, when: spangap/spangap-lcd }

browser_register:
  - { call: registerHello }

settings:
  - at:
      - { id: apps }
      - { id: hello, label: "Hello" }
    rows:
      - switch: { label: "Shout", key: s.hello.shout, default: 0 }

Line by line:

  • name is the <org>/<repo> identity, matching where it lives on GitHub. The build looks for the straddle in your workspace by the repo part of the name, hello/, and clones it from GitHub only if it isn’t there. A straddle you are writing locally doesn’t need to be published anywhere.
  • prefix is your short identity, the prefix for your symbols. It is often not the repo name.
  • version is yours to bump.
  • firmware and browser are the paths to the two halves. These are the conventional names.
  • services declares the objects the boot code constructs. Each entry names a class and the header that declares it, as you would write it in an #include. The second entry carries a when:, so it is only constructed, and its header only needed, when the screen straddle is in the build. That matches the conditional/spangap-lcd/ directory the class lives in.
  • browser_register names the function the web interface calls at startup to register your window. By convention it is exported from modules/<repo>.ts in your browser package, so there is nothing else to say.
  • settings declares rows in the shared settings tree. at: is a path of nodes: apps is the top-level Apps menu, which the platform names and which appears once somebody contributes to it, and hello is a page under it. The one row is a switch bound to the key s.hello.shout, with a default of 0. From this the build generates the row in the LCD Settings app, the same row in the browser’s Settings window, and the code that seeds the default into the store on first boot.

There is no requires: here because nothing in this straddle needs anything beyond the core runtime, which is always there. A straddle that calls into another one lists it under requires:, and the build turns the list into the component’s REQUIRES, which is why the CMake file below doesn’t name any.

There is more the manifest can say: additional_installs for soft dependencies that are pulled in by default but can be dropped, start: for bare-hardware bring-up, and a lot more about settings rows. The schema in build-system/schemas/straddle.schema.json is the full list and the build system’s README explains each.

Variables

The settings store is one tree of dot-separated keys, readable from any task on the device and mirrored live to every browser that has the web interface open. The s. prefix on a key is what makes it a persisted setting, saved to flash and shown in the browser. A key without it is in-RAM only, which is what you use for status your code publishes rather than settings it keeps.

On the device, the calls you will use most:

int         storageGetInt(const char* key, int def = 0);
std::string storageGetStr(const char* key, const char* def = "");
void        storageSet(const char* key, int val);
void        storageSet(const char* key, const char* val);

void storageSubscribeChanges(const char* scope, storage_change_cb_t cb);
void storageUnsubscribeCb(const char* scope, storage_change_cb_t cb);

A subscription delivers changes to the task that subscribed, which is the thing to understand about it. Subscribe from an LCD app and your callback runs on the lcd task, where it may touch widgets. Subscribe from a service’s onInit, though, and it would die with the boot task, so a service with no task of its own uses the NOW_AND_ON_CHANGE_DIRECT macro instead, which runs the callback on the storage machinery and also runs it once right away. Keep such a callback quick.

In the browser, the device store is the same tree as a reactive object:

import { useDeviceStore } from 'spangap-browser/stores/device'
const device = useDeviceStore()

device.get('s.hello.shout')       // reactive: a computed over it updates live
device.set('s.hello.shout', 1)    // a patch goes to the device

Because the default for s.hello.shout was declared in the manifest, none of the code below seeds anything. And because the Settings app and the browser got the same row, the same value can be flipped from several places and everything follows.

Headless code: the Service class

A service is a class with two optional overrides. onStart runs on bare hardware before anything else, and is only for board bring-up. onInit runs after the whole platform is up, which is where nearly everything goes: registering a command, starting a task of your own, subscribing to a key.

class Service {
public:
    virtual void onStart() {}   // bare hardware; raw peripherals only
    virtual void onInit() {}    // storage, network, web and screen are up
};

The constructor runs at the very top of boot before anything exists, so it may only initialize members. The class has to be global, not in a namespace. Services are constructed in dependency order, so the straddles yours requires are up before onInit runs.

The Hello World’s service does one thing: it says in the device log whenever the toggle changes.

// esp-idf/include/hello_service.h
#pragma once
#include "service.h"

class HelloService : public Service {
public:
    void onInit() override;
};
// esp-idf/src/hello_service.cpp
#include "hello_service.h"
#include "storage.h"
#include "log.h"

void HelloService::onInit() {
    NOW_AND_ON_CHANGE_DIRECT("s.hello.shout", {
        info("hello: shout is %s", storageGetInt("s.hello.shout", 0) ? "on" : "off");
    });
}

This builds on every board, screen or not, web interface or not. It is the part of the straddle that is always there.

In the browser

The web interface is a Vue 3 and Quasar app, and a straddle’s browser half is an npm package the build links into it. The registration function the manifest named does two things: it puts an icon in the dock, and it mounts the window component. Both are driven by two refs you own, one for whether the window is visible and one that is bumped to raise it.

// browser/src/modules/hello.ts
import { ref } from 'vue'
import { registerApp } from 'spangap-browser/lib/apps'
import { registerWindowMount } from 'spangap-browser/lib/windowMounts'
import HelloWindow from '../panels/HelloWindow.vue'

export const helloVisible = ref(false)
export const helloFocus = ref(0)

export function registerHello() {
  registerApp({
    id: 'hello', label: 'Hello', icon: 'hello',
    open: () => { helloVisible.value = true; helloFocus.value++ },
    isOpen: () => helloVisible.value,
  })
  registerWindowMount({
    id: 'hello', title: 'Hello', component: HelloWindow,
    visible: helloVisible, focusToken: helloFocus,
  })
}

icon is the basename of the SVG in assets/lcd-icons/. The window component wraps the shell’s FloatingWindow, which takes the title, visibility and focus token as props and emits update:visible when the user closes it. Everything inside the window is yours.

<!-- browser/src/panels/HelloWindow.vue -->
<template>
  <FloatingWindow
    id="hello" :title="title" :visible="visible" :focus-token="focusToken"
    :default-geom="{ x: 30, y: 20, w: 40, h: 40 }" auto-height
    @update:visible="v => emit('update:visible', v)"
  >
    <div class="hello">
      <h2></h2>
      <SettingToggle label="Shout" k="s.hello.shout" />
      <q-btn flat label="Close" @click="emit('update:visible', false)" />
    </div>
  </FloatingWindow>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import FloatingWindow from 'spangap-browser/components/FloatingWindow.vue'
import SettingToggle from 'spangap-browser/components/SettingToggle.vue'
import { useDeviceStore } from 'spangap-browser/stores/device'

defineProps<{ title: string; visible: boolean; focusToken: number }>()
const emit = defineEmits<{ (e: 'update:visible', v: boolean): void }>()

const device = useDeviceStore()
const greeting = computed(() =>
  device.get('s.hello.shout') == 1 ? 'HELLO, WORLD!' : 'Hello, world')
</script>

<style scoped>
.hello { padding: 16px; text-align: center; }
</style>

SettingToggle is one of the shell’s config-bound controls. Give it a key and it reads the value, writes it back when flipped, and handles the moment before the store has synced. There are matching ones for sliders, selects and text fields. Where you need something those don’t cover, read and write the device store directly, as the greeting does.

The package manifest says where the module and the panel are, and that the shell’s packages come from the app being built:

{
  "name": "hello",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    "./modules/*": "./src/modules/*.ts",
    "./panels/*.vue": "./src/panels/*.vue"
  },
  "peerDependencies": {
    "vue": "^3.5.0",
    "quasar": "^2.17.0",
    "pinia": "^3.0.4",
    "spangap-browser": "*"
  }
}

While you work on the browser half, spangap dev serves the web interface from Vite with hot reload against a running device, so an edit to the window shows up without a firmware build.

On the LCD

LcdApp is itself a Service, so an on-device app is just another entry in services:. It is a base class with a handful of virtual methods, and the one you must override is onCreate.

class LcdApp : public Service {
public:
    explicit LcdApp(const Config& cfg);

    virtual void onCreate(lv_obj_t* root) = 0;  // build your UI into root, once
    virtual void onShow() {}                     // brought to the foreground
    virtual void onHide() {}                     // sent to the background
    virtual bool onBack() { return false; }      // true = handled; false = go Home
    virtual void onClose() {}                    // being stopped or evicted

    lv_obj_t*   root();                          // the same widget onCreate got
    lv_group_t* inputGroup();                    // keypad focus group
    void        goHome();                        // back to the launcher
    void        stop();                          // close this app
    void        setFullscreen(bool on);
    lv_timer_t* timer(lv_timer_cb_t cb, uint32_t period_ms, void* user = nullptr);

protected:
    virtual void appInit() {}                    // boot-time wiring, off the UI
    virtual bool available() const { return true; }
};

Config, passed to the constructor, carries the launcher label (name), the icon’s basename (iconBasename) and whether to keep the status bar (statusBar, on by default).

The lifecycle is what you would expect from a phone. onCreate runs the first time the app is opened and builds the whole tree. The tree then persists: opening another app hides yours, and reopening reveals it exactly as it was, without onCreate running again. onShow and onHide bracket those transitions. onBack lets you take over the Back gesture, for instance to go up a level in your own navigation; return false and Back means Home. onClose fires when the app is stopped, by a swipe-up in the recents switcher or your own call to stop(), and also when the shell evicts it to get memory back. Either way the root tree is deleted for you afterwards and the next open starts again from onCreate. Release anything that isn’t an LVGL object in onClose: a connection, a file, a subscription.

Timers and animations are the one thing LVGL doesn’t tie to an object, so create them through timer() and anim() and the shell frees them when the app closes.

Two things about threads. Every method above runs on the lcd task, so you can call LVGL directly in all of them. appInit is the exception: it runs on the boot task, once, right after your tile has been installed, which is where you register a command-line command or start a worker task of your own. Code on another task that wants to touch your widgets hops over with lcdRun().

And available(). Return false and the app is in the image but not in the launcher. It is for an app that exists to show hardware this particular board hasn’t got.

The Hello World’s app builds the same three things the browser window has: a greeting, a switch that follows s.hello.shout, and a button that hides the window.

// esp-idf/conditional/spangap-lcd/include/hello_app.h
#pragma once
#include "lcd_app.h"

class HelloApp : public LcdApp {
public:
    HelloApp() : LcdApp({ .name = "Hello", .iconBasename = "hello" }) {}
    void onCreate(lv_obj_t* root) override;
    void onClose() override;
};
// esp-idf/conditional/spangap-lcd/src/hello_app.cpp
#include "hello_app.h"
#include "storage.h"

static lv_obj_t* s_greeting;
static lv_obj_t* s_switch;

/* Read the setting and make the widgets agree with it. */
static void refresh() {
    bool shout = storageGetInt("s.hello.shout", 0);
    lv_label_set_text(s_greeting, shout ? "HELLO, WORLD!" : "Hello, world");
    if (shout) lv_obj_add_state(s_switch, LV_STATE_CHECKED);
    else       lv_obj_remove_state(s_switch, LV_STATE_CHECKED);
}

static void onShoutChanged(const char*, const char*) { refresh(); }

void HelloApp::onCreate(lv_obj_t* root) {
    lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_flex_align(root, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
                          LV_FLEX_ALIGN_CENTER);
    lv_obj_set_style_pad_row(root, lcdPx(16), 0);

    s_greeting = lv_label_create(root);

    lv_obj_t* caption = lv_label_create(root);
    lv_label_set_text(caption, "Shout");

    /* The switch writes the setting; the subscription below writes the switch.
     * So a flip here, in the browser, in Settings or on the command line all
     * end up in the same place. */
    s_switch = lv_switch_create(root);
    lv_obj_add_event_cb(s_switch, [](lv_event_t* e) {
        lv_obj_t* sw = lv_event_get_target_obj(e);
        storageSet("s.hello.shout", lv_obj_has_state(sw, LV_STATE_CHECKED) ? 1 : 0);
    }, LV_EVENT_VALUE_CHANGED, nullptr);

    lv_obj_t* button = lv_button_create(root);
    lv_obj_t* label  = lv_label_create(button);
    lv_label_set_text(label, "Close");
    lv_obj_add_event_cb(button, [](lv_event_t* e) {
        static_cast<HelloApp*>(lv_event_get_user_data(e))->goHome();
    }, LV_EVENT_CLICKED, this);

    storageSubscribeChanges("s.hello.shout", onShoutChanged);
    refresh();
}

void HelloApp::onClose() {
    storageUnsubscribeCb("s.hello.shout", onShoutChanged);
}

lcdPx() scales a pixel length by the display’s zoom setting, so the gap looks the same on a small screen and a large one. Labels take the platform’s font from the theme, so nothing needs a font call. The subscription is dropped in onClose with the same callback, because the lcd task is shared by every app and the wholesale unsubscribe would take theirs too.

The rest of the firmware half

The CMake file is short because the build generates the interesting part:

# esp-idf/CMakeLists.txt
file(GLOB HELLO_SRCS "src/*.cpp")
include(${CMAKE_CURRENT_LIST_DIR}/spangap_requires.cmake)
idf_component_register(
    SRCS ${HELLO_SRCS} ${SPANGAP_CONDITIONAL_SRCS}
    INCLUDE_DIRS "include" ${SPANGAP_CONDITIONAL_INCLUDE_DIRS}
    REQUIRES ${SPANGAP_REQUIRES}
)

SPANGAP_REQUIRES comes from your manifest, plus the screen straddle when the conditional directory for it is in play. SPANGAP_CONDITIONAL_SRCS and SPANGAP_CONDITIONAL_INCLUDE_DIRS are the conditional/ slices that apply to this build, and also where the generated code that constructs your services lands, so they have to be there even in a straddle with no conditional/. The component manifest beside it is the usual ESP-IDF one:

# esp-idf/idf_component.yml
version: "0.1.0"
dependencies:
  idf:
    version: ">=5.5"

And the icon. The house style is a single line drawing in a 64-unit box, light strokes on a transparent background, so it sits next to the other tiles without looking like a sticker:

<!-- esp-idf/assets/lcd-icons/hello.svg -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"
     stroke="#eef3f8" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
  <path d="M10 14h44v30H28l-12 10V44h-6z"/>
  <path d="M22 29h20"/>
</svg>

Build it

Put the hello/ directory in your workspace next to the other straddles, then build the bare platform with your board and your app:

spangap build spangap/spangap-demo --with spangap/hw-lilygo-tdeck --with yourname/hello && \
spangap flash

On the T-Deck that gives you all three: the launcher has a Hello tile, the dock in the web interface has a Hello icon, and the log says what the toggle is doing. Open the window in the browser and the app on the screen side by side, flip either switch, and the other follows along with both greetings. Then try it from the command line, in the serial monitor or over SSH:

show s.hello.shout
set s.hello.shout=1

Both switches flip, both greetings shout, the log gets a line, and so does the row under Settings, Apps, Hello. That is the settings store doing what it is for, and nothing in the straddle did anything special to take part in it.

Build with --with spangap/hw-heltecv4 instead and there is no screen, so the straddle is the service and the browser window. Build with --no-web-ui and it is the service alone. Nothing in the straddle changes between the three.

Where to go from here

The full description of the on-device app model, including the keyboard, fonts and the recents switcher, is in spangap-lcd’s apps.md, and the browser shell’s in spangap-web’s browser-shell.md. The settings block can describe a whole pane, with text fields, dropdowns, lists and forms, and the build system’s README covers all of it. Adding a command-line command, a background task, or a stream between the device and the browser are each a page in the platform’s docs, starting from the spangap-core README. For a real straddle laid out exactly this way, loramon is a small one to read.