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:
nameis 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.prefixis your short identity, the prefix for your symbols. It is often not the repo name.versionis yours to bump.firmwareandbrowserare the paths to the two halves. These are the conventional names.servicesdeclares 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 awhen:, so it is only constructed, and its header only needed, when the screen straddle is in the build. That matches theconditional/spangap-lcd/directory the class lives in.browser_registernames the function the web interface calls at startup to register your window. By convention it is exported frommodules/<repo>.tsin your browser package, so there is nothing else to say.settingsdeclares rows in the shared settings tree.at:is a path of nodes:appsis the top-level Apps menu, which the platform names and which appears once somebody contributes to it, andhellois a page under it. The one row is a switch bound to the keys.hello.shout, with a default of0. 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.