Skip to content

About

devOS: keyboard-first firmware for the M5Stack Tab5 (ESP32-P4) with drop-in apps. Browser installer + OTA via GitHub Pages.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

devOS for the M5Stack Tab5

devOS is a keyboard-first firmware for the M5Stack Tab5 (ESP32-P4, 5" 1280×720 display) with its 70-key keyboard. It turns the Tab5 into a small cyberdeck for sysadmin, network and developer work: SSH terminal, Markdown editor, Tailscale and WireGuard VPNs, MQTT, REST, Docker, Proxmox, network diagnostics (ping, DNS, port scan, Wi-Fi survey, mDNS, Wake-on-LAN), a Coder's Toolkit (Base64, hashes, JWT, UUID, subnet, cron, regex, …), an ADS-B radar, a TOTP authenticator, and a web page for getting files on and off the SD card. Each of these is an app that plugs into the core the same way, so you can add your own without touching the Home Screen.

Install it from your browser: https://domgrimm.github.io/tab5-devos/ (Chrome or Edge, USB-C cable). After that the Tab5 updates itself over Wi-Fi.


Contents

  1. What's on it
  2. How it works
  3. Drop-in apps
  4. Building
  5. Releasing
  6. Repository layout

Jobs has its own guide: JOBS.md — how it works, the language and its DSL, every action with its parameters and outputs, events, credentials, limits and troubleshooting.


What's on it

App What it does
Terminal Multi-session SSH client (libssh2 over mbedTLS), VT100/xterm emulation, 10,000-line scrollback, bookmarks from the SD card
Editor SD-card file browser, Markdown / text editor with live preview, a scratchpad and voice memos
Tailscale Joins your tailnet (MicroLink: ts2021, DERP, DISCO) and lists peers with latency
WireGuard Brings up tunnels from standard wg-quick config files
MQTT Broker monitor and publisher (MQTT 3.1.1) with JSON pretty-printing
Network Ping, DNS lookup, port scan, Wi-Fi survey, mDNS browser and Wake-on-LAN (magic packets to a sleeping machine, broadcast or routed over the VPN, with a remembered list)
REST REST and webhook client with saved requests and {{variables}}; Sym+J turns the request on screen into a Jobs job
Docker Docker Engine / Portainer console: containers, logs, start / stop, plus background Jobs docker.inspect / start / stop / restart actions
Proxmox Proxmox VE console: the cluster's VMs and containers with CPU / memory / disk / uptime, start / shutdown / stop / reboot (with confirmation), a console link into REST and SSH into the host, plus background Jobs proxmox.guest_status / guest_start / guest_stop / guest_shutdown / guest_reboot actions
Coder Offline developer toolkit: Base64 / Base64 URL / Hex / URL / Base58 encode & decode, SHA-1/256/384/512 and HMAC hashes, CRC-32, a JWT decoder with optional HS256/384/512 signature verification, random UUID v4, Unix-time conversion both ways, an IPv4 subnet calculator, a cron explainer (with the next runs), a regex tester, and hashing a file off the SD card
ADS-B Radar view of aircraft from a dump1090 / readsb / tar1090 aircraft.json feed, over an OpenStreetMap underlay cached on the SD card
Authenticator Offline TOTP codes from an encrypted vault; add accounts by scanning a QR code with the camera
Jobs Persistent automation: a keyboard-first job list and Text editor over a small language (manual / interval / daily / weekdays / event triggers, typed actions, if/wait/repeat, json_get, reusable jobs with typed inputs), Validate/Apply/Enable/Run now/Cancel, diagnostics and run history. Keeps running while the app is hidden or the screen is off
Settings Wi-Fi, file sharing, display, power, time zone, Jobs secrets, updates, and switching apps on and off

Global keys, from any app: Sym + Space command palette (type part of an app or command, Enter runs it), Sym + I system info (power, memory, network, CPU), Sym + S keyboard shortcuts (everywhere, and for the app you're in), Sym + V paste (one clipboard for every app: Ctrl + C in the Editor or C in the Authenticator copies, Sym + V pastes into any text field or the Terminal), Sym + H Home Screen, Sym + T dark / light theme, Sym + − / + brightness, Sym + P screen off (sleep), Sym + Shift + R restart (asks first), Sym + Shift + Q shut down (asks first), Sym + 1…6 built-in apps, Alt + Tab previous app, Esc back out (and to the Home Screen when nothing else wants it). The Tab5 keyboard has no Fn key; Sym is the system modifier, and Aa is Shift. In the top bar, a tap on the Wi-Fi name, the battery, the clock or the Shared mark opens that part of Settings.

On first boot with a MicroSD card inserted, devOS creates the folders and starter files it needs (/notes/, /.ssh/, /wireguard/, /.devos/, a welcome note that lists the keys). You never have to prepare the card on a computer.

File sharing

Settings > File Sharing turns the SD card into a web page. Switch it on and the panel shows an address (http://192.168.1.50) and a password. Open the address in a browser on any computer or phone on the same network (or over Tailscale / WireGuard) to browse the card, upload files and whole folders (button or drag and drop), download, rename, make folders and delete. The Editor sees the changes the next time you open it. The page also has a Send text to the devOS clipboard box: paste text there and press Copy to devOS clipboard to put it on the Tab5's one Universal Clipboard, ready to paste into the Editor, Terminal or any text field (it is also kept in /.devos/clipboard.txt and can be loaded back into the box).

  • A new password every time sharing starts: eight random characters, any user name. Sharing is always off after a restart, and an amber Shared mark sits in the top bar while it's on.
  • Plain HTTP. The password keeps others out, but the traffic isn't encrypted: use it on a network you trust, or over Tailscale / WireGuard, which are.
  • Scriptable. Everything the page does is a small HTTP API (Basic auth; changes also need an X-Devos: 1 header, which stops other web pages from using your logged-in browser):
    curl -u devos:PASSWORD 'http://192.168.1.50/api/list?path=/notes'
    curl -u devos:PASSWORD -H 'X-Devos: 1' -T notes.md 'http://192.168.1.50/api/file?path=/notes/notes.md'
    curl -u devos:PASSWORD -o notes.md 'http://192.168.1.50/api/file?path=/notes/notes.md'
    curl -u devos:PASSWORD -H 'X-Devos: 1' -X DELETE 'http://192.168.1.50/api/file?path=/notes/notes.md'
    curl -u devos:PASSWORD -H 'X-Devos: 1' --data-binary 'text for the Tab5' 'http://192.168.1.50/api/clipboard'
    POST /api/mkdir?path=, POST /api/rename?path=&to= and DELETE ...&recursive=1 (a folder and its contents) cover the rest; POST /api/clipboard puts the body on the Tab5's clipboard (GET reads it back); components/devos_fileshare/devos_fileshare.h lists it all.

Jobs

Jobs is a small automation app built around a dashboard of jobs first: a 260 px job list on the left, a detail area with Overview / Builder / Text / Runs tabs, a Problems strip and a state-aware toolbar.

Full guide: JOBS.md — the language and DSL, every action with its parameters and outputs, events, credentials, limits and troubleshooting. What follows is the short version.

version 1;
job "NAS health check" {
    trigger every 5m;
    network.ping(host: "nas.local", timeout: 3s) as nas;
    if !nas.ok {
        system.notify(message: "NAS offline", level: "warning");
    } else {
        system.log(message: "NAS responded in ${nas.latency_ms} ms");
    }
}

Keyboard first: the job list takes Up/Down to pick, Enter to open, Space to enable/disable and R to run. Sym+B / Sym+M / Sym+O / Sym+Y switch to Builder / Text / Overview / Runs, Sym+L shows or hides the job list (hiding it reclaims the 260 px), Sym+C validates, Sym+A applies (transactionally, with a conflict check), Sym+G enables, Sym+R runs now, Sym+X cancels and Sym+D deletes (after a confirmation). Sym+W dry-runs the draft, Sym+Shift+Y shows the last run's step trace, Sym+Z opens Revisions (view, diff, roll back) and Sym+Shift+R reloads the saved revision (discarding unsaved changes, after a prompt) - which is the way out of a stale-apply conflict. Esc steps field → view → list → Home. Unsaved edits are kept as a draft (shown as (unsaved) in the header), restored on the next visit and protected from a restart; a draft only counts as unsaved when the text actually differs from the applied revision. The Builder can add actions and control steps (if, wait, repeat, set, run job); Enter commits the field being edited.

Dry run is a real run. Sym+W executes the draft through the same scheduler path as Run now - it is not simulated, it just leaves no revision or history behind. Steps with an effect outside the device are listed in a confirmation first (a publish publishes, a restart restarts); a draft whose steps are all read-only or on-device sinks (system.log, system.notify) starts immediately.

A job is disabled until you enable it, and an interval job first runs one interval after it is enabled. The engine keeps running while you are in another app or the screen is off, and is off entirely when Jobs is switched off in Settings > Apps. A failed automatic run raises a rate-limited toast. Sources live in /sdcard/jobs/<id>.job; the active revision and history are kept under /sdcard/.devos/jobs/, and starter examples are in /sdcard/jobs/examples/.

A job has exactly one trigger: manual, every <dur> (phase-anchored, missed periods skipped), daily "HH:MM" or weekdays "HH:MM" (device-local time; one run per local date, DST-safe, and blocked while the clock is not set; weekdays accepts an optional day list, weekdays "08:00" days "Mon,Wed,Fri" defaulting to Mon-Fri), or event "topic" (with an optional where filter over event.topic, event.source, event.payload, event.seq, event.retain and event.truncated, and a bounded debounce). The system events are system.boot (once per normal boot), network.wifi_connected / network.wifi_disconnected, network.tailscale_connected / network.tailscale_disconnected, network.wireguard_up / network.wireguard_down (all transition-only), system.battery_below (a valid, present pack crossing the low threshold; an absent or invalid battery never fires) and mqtt.message (from the configured broker). An optional policy(overlap: "skip" | "queue_one", cooldown: 5m) controls automatic admission; Run now bypasses the cooldown.

Actions are http.request, network.ping, network.wol, network.dns, system.log, system.notify, mqtt.publish, docker.inspect / docker.start / docker.stop / docker.restart, and proxmox.guest_status / proxmox.guest_start / proxmox.guest_stop / proxmox.guest_shutdown / proxmox.guest_reboot. system.notify raises a toast (level: "info" by default, or "success" for the green tick, "warning", "error"), shown whatever app is on screen, so a background job can tell you something happened. An mqtt.message trigger declares the broker subscription it needs - event "mqtt.message"(topic: "home/doorbell") - so the broker sends only those topics; retained messages are ignored unless include_retained: true is set. MQTT is one configured broker over plain TCP (no TLS), and a job never carries the broker password: it stays in the MQTT app's settings.

The body can loop, read JSON and call other jobs: repeat <1..32> as i { ... } runs the block with a read-only integer index, json_get(body, "a.b[1].c") pulls one value out of a JSON string (a dot-separated path with optional array indexes; a missing path is null, and an object or array comes back as its own JSON text so you can log it or drill into it), and one job can call another with run "name"(input: value) as result (see Reusable jobs below). Loops stop at the 256-step budget or the run timeout, so repeated network work can't run away:

version 1;
job "Wait for the NAS" {
    trigger every 1m;
    set up = false;
    repeat 5 as i {
        if !up {
            network.ping(host: "nas.local", timeout: 2s) as p;
            if p.ok { set up = true; system.log(message: "NAS up after ${i} tries"); }
            else { wait 2s; }
        }
    }
    if !up { system.notify(message: "NAS still offline", level: "warning"); }
}

Reusable jobs. A job can declare typed inputs and return a value; another job calls it. This keeps a check in one place and reuses it from several automations:

version 1;
job "Check site"(url: string) {
    trigger manual;
    http.request(method: "GET", url: url, timeout: 5s) as r;
    return r.ok;
}
version 1;
job "Two sites" {
    trigger every 10m;
    run "Check site"(url: "https://a.example/health") as a;
    run "Check site"(url: "https://b.example/health") as b;
    if !a || !b { system.notify(message: "A site is down", level: "error"); }
}

Inputs are checked for type at the call, a call may nest (up to four deep), a call that would run the same job again is refused as a cycle, and a called job runs inside the caller's time and step budget so it stops with it. A returned null (a job with no return) is false in an if.

network.wol(mac: "aa:bb:cc:dd:ee:ff", target: "192.168.1.255") sends a magic packet (empty target = broadcast; a host name, IPv4 address or directed broadcast all work, resolved on a background worker) and reports sent/target/error. network.dns(name: "nas.local", type: "A") does one DNS query (server empty = the DHCP server; ip:port is accepted) and reports ok, rcode, count, first and error; it runs in its own lookup context, so it never disturbs a lookup in the Network app, and Jobs runs one at a time.

Credentials. A job never stores a password or token. A credential-capable field takes secret("name"), e.g. http.request(..., bearer_token: secret("health-token")). Values live in an encrypted store sealed with ChaCha20-Poly1305 under a device key; the engine resolves one only for that field and wipes the copy as soon as the action starts. Add one in Settings > Jobs Secrets (a reference name and a hidden value; the list shows names and versions, and Delete asks twice), or from code (devos_secrets_set), or by dropping /sdcard/.devos/secrets.import with name=value lines - it is sealed in, wiped and removed the next time Jobs starts (you can upload it from Settings > File Sharing). The value is never shown again after saving. devos_secrets_security_note() states the actual protection: the blob on the card is genuinely encrypted, but the device key lives in plain NVS until NVS encryption is enabled. An nvs_keys partition is reserved; enabling CONFIG_NVS_ENCRYPTION and flashing the keys is the production hardening step, and is not yet verified on hardware.

Docker actions run in the background, whether or not the Docker screen is open. docker.inspect(container: "web") reads the daemon directly by id or name (ok, status, state, health, id, name, updated); docker.start / stop / restart return status, accepted and outcome_unknown. accepted means the daemon took the request (HTTP 204/304), not that the service recovered - follow a restart with a wait and a health check:

version 1;
job "Recover web service" {
    trigger every 5m;
    policy(timeout: 90s, overlap: "skip", cooldown: 15m);
    http.request(method: "GET", url: "http://web.local/health", timeout: 5s) as before;
    if !before.ok || before.status != 200 {
        docker.restart(container: "web", timeout: 15s) as restart;
        wait 10s;
        http.request(method: "GET", url: "http://web.local/health", timeout: 5s) as after;
        if !after.ok || after.status != 200 {
            system.notify(message: "Web recovery failed", level: "error");
        }
    }
}

A transport error on a mutation reports outcome_unknown and is never retried automatically; Docker not being configured (or switched off in Settings > Apps) blocks the run with a diagnostic. The Docker app's live list, stats and logs keep working while a job acts, and a job's commands never overwrite each other.

Proxmox actions work the same way. A guest is addressed by its vmid alone - the engine resolves its node and type (QEMU VM or LXC container) from the cluster resources, so a job never has to know which node a guest lives on. proxmox.guest_status(vmid: 100) reads the guest directly (ok, status, state, node, name, type, cpu, mem, maxmem, uptime, error); proxmox.guest_start / guest_stop (hard) / guest_shutdown (clean) / guest_reboot return status, accepted, task (the UPID Proxmox returned) and outcome_unknown - accepted means Proxmox took the task, not that the guest reached the state, so follow a reboot with a wait and a guest_status poll:

version 1;
job "Reboot the web VM" {
    trigger manual;
    proxmox.guest_reboot(vmid: 100, timeout: 15s) as reboot;
    if reboot.accepted {
        wait 30s;
        proxmox.guest_status(vmid: 100) as after;
        system.notify(message: "web VM is ${after.state}", level: "success");
    }
}

A guest that is not in the cluster is a real failure (nothing was sent); a transport error on a mutation is outcome_unknown. Proxmox not being configured (or switched off in Settings > Apps) blocks the run with a diagnostic. Settings live in the Proxmox app (C): the server URL, an API token id (user@realm!tokenid) and its secret (kept in NVS), and "accept any certificate" for Proxmox's self-signed one.

Builder is the default view (press Sym+M for Text). It has a trigger card (Manual / Every / Daily / Weekdays / Event, with the event topic and an optional where filter), a step tree you can tap to select and drag to reorder, and a settings inspector generated from each action's schema; conditions, set values and expression-capable parameters can hold full expressions. A repeat block appears as one card ("count as index") whose body is edited in Text; steps around it can still be added, edited, moved and deleted without changing it. The commands are on Sym+key so they work while typing in a field: Sym+B Builder, Sym+M Text, Sym+C validate, Sym+A apply, Sym+G enable, Sym+R run, Sym+X cancel, Sym+Y history, Sym+N new, Sym+D delete, Sym+U add a step, Sym+K/Sym+J move a step.

Wake-on-LAN

Network > Wake-on-LAN wakes a sleeping machine with a magic packet. Type its MAC address (any of aa:bb:cc:dd:ee:ff, aa-bb-cc-dd-ee-ff, aabb.ccdd.eeff or plain aabbccddeeff) and press Wake. Leave the "send to" box empty on the same Wi-Fi network (the packet is broadcast); type a host or IP (or a directed broadcast like 192.168.1.255) to reach a machine on another subnet, or across Tailscale / WireGuard - the socket is routed through the tunnel like every other. Machines you have woken are remembered in /.devos/wol.json: pick one and Enter re-wakes it.


How it works

The two cores

The ESP32-P4 has two RISC-V cores, and devOS gives each a job:

 Core 0 — system, network, crypto            Core 1 — presentation
 ┌────────────────────────────────────┐      ┌─────────────────────────────────┐
 │ Wi-Fi (ESP-Hosted → ESP32-C6)       │      │ LVGL v9 GUI loop + PPA blits    │
 │ lwIP, DNS, mDNS                     │ ◄──► │ touch (GT911)                   │
 │ Tailscale / WireGuard tunnels       │queues│ keyboard polling → key dispatch │
 │ TLS, SSH I/O, HTTP, MQTT            │events│ app screens                     │
 │ power management, telemetry         │      │ MicroSD I/O                     │
 └────────────────────────────────────┘      └─────────────────────────────────┘

The UI core never waits on the network. An app starts work on an engine (a component with no LVGL in it, running on core 0) and reads results back through queues, task notifications or small status getters. A slow TLS handshake can't freeze the screen.

Memory follows the same split. The 32 MB PSRAM holds big things: LVGL draw buffers, terminal scrollback, file and diff buffers, parsed Markdown. The ~768 KB of internal SRAM is kept for what needs it: TLS / SSH handshakes, Wi-Fi DMA and task stacks. At least 120 KB of it stays free.

Boot

main/main.c brings the system up in order:

  1. Board (bsp_tab5): I2C buses, MIPI-DSI display, touch, battery monitor, RTC, camera.
  2. Storage (devos_storage): mount the MicroSD card and create any missing folders and templates.
  3. App switches: read which apps are switched off (Settings > Apps) before anything else starts.
  4. Keyboard, theme, core, power, OTA.
  5. Network (devos_net), then each app's engine, started only if its app is switched on.
  6. Apps: each app's descriptor is handed to devos_core_register_app(). The Home Screen registers last and builds its tiles from whatever is registered.

The core (devos_core)

devos_core is the small kernel every app talks to:

  • App registry: up to 32 apps, each identified by a stable string uid ("terminal", "adsb"). Registration calls the app's init() once and measures the memory it took.
  • App switcher: shows one app's screen at a time and calls hide() on the old app, then show() on the new one.
  • Key dispatcher: every key goes first to the system overlays (the command palette and the system info panel, which take the keyboard while they're open), then to the global shortcuts, then to the active app's handle_key(). An unhandled Esc goes to the Home Screen.
  • Intents: one app can ask another to do something. devos_core_open_with("terminal", "ssh", "pi@host") switches to the Terminal, whose show() picks the request up with devos_core_take_intent(). The Network app uses this to SSH or ping a host it found, Docker to SSH into a container or open its web port in REST, and the palette to open a file in the Editor ("open", "notes/welcome.md").
  • Clipboard: one piece of text shared by every app and the File Sharing web page (devos_clipboard_set / _get, in PSRAM; the API is in devos_clipboard.h, which has no LVGL, so non-UI engines can use it too).
  • Telemetry: a 1 Hz snapshot (battery, Wi-Fi, IP, VPN state, SD, heap, CPU, clock) fed by devos_sysmon and drawn by the top bar and tiles.
  • App switches: an app switched off in Settings is never initialised and its engine never starts, so it costs no RAM. Changes apply on restart. If a new set of switches fails to boot twice, devOS reverts to the last set that worked. Holding a finger on the screen at power-on switches every app back on.

Shared building blocks

Apps don't carry their own renderers, parsers or network code. Each of these exists once, so a fix lands everywhere:

Component Provides
devos_ui Theme engine (dark / high-contrast light, live switching), top bar, devos_widgets (buttons, fields, dialogs, virtual lists), devos_focus (keyboard focus and focus ring), devos_icons (vector icons), devos_cmdpal (the Sym + Space command palette), devos_hud (the Sym + I system info panel), devos_shortcuts (the Sym + S keyboard sheet), devos_powerdlg (the restart / shutdown confirm dialog), devos_toast (short notices below the top bar, from any task)
devos_net Wi-Fi manager, DNS + mDNS resolver, and the socket layer every connection goes through (outgoing, and listening for the file-sharing server). That layer is where VPN routing applies, so a socket opened any other way would bypass the tunnel
devos_http HTTP/1.1 + HTTPS client on top of the socket layer
devos_json Small JSON reader and pretty-printer
devos_mdview The CommonMark-subset renderer used by the editor preview
devos_crypto SHA-1/256/384/512, HMAC, PBKDF2, ChaCha20-Poly1305, base32, and Base64 / Base64 URL / Hex / URL / Base58 / CRC-32 with a streaming hash API
devos_hashfile Streams a file off the SD card through a hash on core 0, with progress and result getters
devos_vterm VT100 / xterm terminal emulator

The feature engines (devos_mqtt, devos_docker, devos_proxmox, devos_adsb, devos_maptiles, devos_netdiag, devos_hashfile, devos_totp, devos_wireguard, devos_tailnet, devos_audio, devos_qr, devos_fileshare) follow the same rule: no LVGL, a small C API, and status getters that report "off" if their app is switched off.

Keyboard first

Every screen works from the keyboard alone; touch is a second way in, never the only one. The same keys mean the same thing everywhere:

Keys Meaning
Arrows Move the selection or focus
Tab / Aa + Tab Next / previous field or region
Enter Activate: open, connect, confirm, press the focused button
Space Toggle a checkbox or switch; pause live views
Left / Right Change the focused value (slider, dropdown, switch)
Esc Back out one level: dialog, then field or panel, then Home Screen
Letters Frequent actions, shown in each screen's key hint line
Sym + <key> System shortcuts; Sym + L shows or hides an app's side panel

The focused control always has a visible accent ring, a selection border or a text cursor. On-screen keyboards only appear when no hardware keyboard is attached.

Updates

devos_ota reads a manifest (version, url, size, sha256) from the feed, which by default is this repo's GitHub Pages site. If the version is newer than the running one, it downloads the image into the spare OTA slot over HTTPS (certificates verified), checks the hash and restarts into it. Rollback is on: an image that doesn't boot properly is replaced by the previous one. You can point the feed at your own server in Settings > System.


Drop-in apps

An app is a descriptor: a struct of names and callbacks that it hands to the core. The core, the Home Screen, Settings > Apps and the top bar only ever see descriptors. They have no list of apps and no switch statement over app ids, so a new app appears everywhere without them changing.

The descriptor

typedef struct {
    devos_app_id_t id;          /* leave 0: the core assigns one */
    const char *uid;            /* stable id, e.g. "weather" - used for layout, switches, intents */
    const char *name;           /* short tile name */
    const char *title;          /* screen / tile title */
    const char *subtitle;       /* tile subtitle when there are no live lines */
    const char *icon;           /* LVGL symbol, e.g. LV_SYMBOL_GPS (fallback icon) */
    const char *category;       /* "tools", "network", "system", ... */
    lv_obj_t *screen;           /* the app's root object, set in init() */
    void (*init)(void);         /* once, at boot (skipped if switched off) */
    void (*show)(void);         /* the app comes to the front */
    void (*hide)(void);         /* the app goes to the back */
    bool (*handle_key)(uint32_t key, uint8_t modifiers);   /* true = handled */
    int  (*get_telemetry_lines)(char lines[3][64]);        /* live tile text */
    void (*draw_icon)(lv_layer_t *layer, const lv_area_t *area, lv_color_t color);
} devos_app_descriptor_t;

(The full definition is in components/devos_core/devos_core.h.)

What the core does with it

Callback Called when The app should
init Once at boot, from devos_core_register_app() Build its screen hidden, set screen, register its controls with devos_theme / devos_focus
show The user opens the app (tile, hotkey, intent) Refresh, take keyboard focus, collect any intent with devos_core_take_intent()
hide Another app comes to the front Pause timers and live views
handle_key Every key, after the global shortcuts Return true if it used the key. Leave Esc unhandled at the top level and the core goes Home
get_telemetry_lines About once a second while the Home Screen is up Write up to 3 short lines for its tile ("3 aircraft", "Tunnel up")
draw_icon Whenever an icon is drawn Draw a vector icon on a 20×20 grid (see devos_icons.c); NULL falls back to icon
get_shortcuts When the Sym + S sheet opens over it Return its keys, one "keys\twhat they do" per line (a line without a tab is a heading), for its current state

Once registered, an app automatically gets:

  • A Home Screen tile with its icon, title and live lines. Tiles go 8 per page; users rearrange or hide them (E on the Home Screen) and the layout is saved by uid.
  • A row in Settings > Apps with an on/off switch and the RAM it costs, measured when it started.
  • Theming. Widgets built with devos_widgets recolour when the theme changes.
  • Keyboard focus through devos_focus: rings, Tab order, Enter / Space / arrows on buttons, switches, sliders and fields.
  • Intents. Other apps can open it with devos_core_open_with("<uid>", action, arg). open_with() returns false if the app isn't there (e.g. switched off), so callers handle that rather than assuming an app exists.
  • A command palette entry. Sym + Space finds it by title, uid, name or subtitle. An app can add commands of its own with devos_cmdpal_add() from its init() (the Editor adds "Open the scratchpad", Settings one per section), so they disappear with the app when it's switched off.

Writing one

main/apps/app_template/ is a complete, working example that follows every rule below; copy it. Its README goes step by step. In short:

1. Create main/apps/app_weather/app_weather.c (and a header declaring app_weather_get_descriptor()):

#include "devos_core.h"
#include "devos_widgets.h"
#include "devos_focus.h"

static devos_app_descriptor_t s_desc;
static devos_focus_t s_focus;
static lv_obj_t *s_temp;

static void refresh_cb(lv_event_t *e) { /* ask the engine for new data */ }

static void weather_init(void)
{
    lv_obj_t *scr = devos_w_screen(&s_desc);           /* themed, hidden, sets s_desc.screen */
    devos_w_bar(scr, "Weather", NULL);
    s_temp = devos_w_label(scr, &lv_font_montserrat_28, DEVOS_W_TEXT, "--");
    lv_obj_t *btn = devos_w_btn(scr, "Refresh", 160, refresh_cb, NULL, NULL);
    devos_w_keys(scr);                                  /* key hint footer */

    devos_focus_init(&s_focus);
    devos_focus_add(&s_focus, btn);
}

static void weather_show(void) { devos_focus_first(&s_focus); }

static bool weather_key(uint32_t key, uint8_t mods)
{
    if (devos_focus_key(&s_focus, key, mods)) return true;   /* arrows, Enter, Tab ... */
    if (!mods && (key == 'r' || key == 'R')) { refresh_cb(NULL); return true; }
    return false;                                             /* Esc -> Home Screen */
}

static int weather_tile(char lines[3][64])
{
    snprintf(lines[0], 64, "21 C, clear");
    return 1;
}

devos_app_descriptor_t *app_weather_get_descriptor(void)
{
    s_desc.uid = "weather";
    s_desc.name = "Weather";
    s_desc.title = "Weather";
    s_desc.subtitle = "Local forecast";
    s_desc.icon = LV_SYMBOL_GPS;
    s_desc.category = "tools";
    s_desc.init = weather_init;
    s_desc.show = weather_show;
    s_desc.handle_key = weather_key;
    s_desc.get_telemetry_lines = weather_tile;
    return &s_desc;
}

(Check devos_widgets.h for the exact helper signatures; the template uses all of them.)

2. Register it in devos_system_bringup() in main/main.c, before the launcher:

devos_core_register_app(app_weather_get_descriptor());

If it has a background engine, start that with START_ENGINE("weather", weather_engine_init()); so it's skipped when the app is switched off.

3. Add the source to main/CMakeLists.txt (firmware: APP_SRCS and INCLUDE_DIRS) and to the root CMakeLists.txt (simulator: DEVOS_SOURCES and the include list).

That's all. You don't edit the launcher, Settings, the top bar or any enum.

Rules for apps

  • Never block the UI core. Network, TLS, crypto and slow file work go in an engine on core 0; the app polls its status or gets a queue message.
  • Use the shared pieces. Sockets through devos_net_socket_* (so VPN routing works), HTTP through devos_http, JSON through devos_json, Markdown through devos_mdview. Extend them rather than copying them into your app.
  • Big buffers go in PSRAM (heap_caps_malloc(n, MALLOC_CAP_SPIRAM)); keep internal SRAM for the network stack.
  • Keyboard first: every control reachable by key, focus always visible, keys shown on screen, Esc backs out one level.
  • Theme everything: build with devos_widgets or register custom widgets with devos_theme so both palettes work and switch instantly.
  • Clean up: timers, callbacks and allocations belong to the app's context struct, not loose globals.
  • Refer to other apps by uid, never by id, and handle devos_core_open_with() returning false.
  • Secrets (keys, tokens, passwords) go in NVS or encrypted SD storage, never in source.

AGENTS.md has the full rulebook.


Building

Prerequisites

  • ESP-IDF v5.4 (target esp32p4). Espressif's container image works as-is: docker.io/espressif/idf:v5.4. Build with v5.4 as the releases do: the second-stage bootloader grew enough in v5.5 that it no longer fits the 0x6000 bytes before the 0x8000 partition-table offset, and the build stops with "Bootloader binary size ... is too large". Either stay on v5.4, or shrink the bootloader (CONFIG_BOOTLOADER_LOG_LEVEL_WARN) / move the partition table before switching versions.
  • LVGL isn't vendored. Fetch the pinned version once:
    tools/fetch_lvgl.sh

Firmware

idf.py set-target esp32p4
idf.py build
idf.py -p /dev/ttyACM0 flash monitor

To hand a build to another machine (or to a MacBook with no toolchain), bundle it:

tools/package_fw.sh                       # -> dist/devos-<version>-<build>.zip
scp dist/devos-*.zip macbook:~/
# on the MacBook:
unzip devos-*.zip && cd devos-* && ./install.sh

The zip carries the four flash images, the offsets that build used and a self-contained install.sh (needs only python3 + esptool — no repo, no ESP-IDF). It picks the Tab5's serial port automatically — the P4's USB-Serial/JTAG is a CDC-ACM device (/dev/cu.usbmodem* on macOS, /dev/ttyACM* on Linux), so it can tell it apart from a USB-UART bridge; --list shows what it found, -p overrides it. install.command double-clicks on macOS, --dry-run prints the esptool command instead of running it, and --erase wipes the chip first (that loses NVS: Wi-Fi, Tailscale state and the secrets device key). For a device already running devOS, updating over the air is faster than a cable — see tools/make_ota_manifest.py.

Or with the container, without installing IDF:

podman run --rm -v "$PWD":/project:Z -w /project docker.io/espressif/idf:v5.4 idf.py build

sdkconfig is generated from sdkconfig.defaults and isn't committed. After changing the defaults, delete sdkconfig and run idf.py fullclean.

The Tab5's ESP32-C6 runs the ESP-Hosted Wi-Fi firmware. It ships on the Tab5; tools/flash_c6_slave.sh reflashes it if needed.

Simulator

The whole UI also runs on a Linux or macOS desktop (SDL2), with simulated networking and telemetry. It's the quickest way to work on an app:

cmake -B build_sim -S . -DDEVOS_SIMULATOR=ON -G Ninja
ninja -C build_sim
./build_sim/devos_sim

Your keyboard stands in for the Tab5's; as a PC keyboard has no Sym key, Ctrl + 1…8 are Sym + 1…8, F1 is Sym + T, F6 (or Ctrl + Space) is Sym + Space, F7 is Sym + I, F8 is Sym + S and F9 is Sym + V; Super (Windows / Cmd) + any letter, digit or Space is Sym + it. tools/sim/run_web_sim.sh runs it headless behind noVNC so you can use it from a browser on another machine. The simulator also shows some placeholder demo tiles to exercise Home Screen paging; they're never built into the firmware.

Tests

Host-side unit tests live in tools/*_test.c (Markdown renderer, launcher, app switches, command palette search, OTA, terminal emulator, crypto and the Coder's Toolkit core, Wake-on-LAN packets, and an end-to-end run of the file-sharing server over real sockets). Each file's header has its exact gcc line. Run them from an empty directory: some write config files relative to the current directory.


Releasing

The GitHub Pages site in docs/ is the browser installer and the OTA feed. To release:

  1. Bump DEVOS_VERSION_MAJOR/MINOR/PATCH in components/devos_config/include/devos_config.h. Devices only take newer versions.
  2. Build the firmware.
  3. Regenerate the site:
    tools/publish_pages.py --build build --out docs --notes "What changed"
    This writes the flasher page, the ESP Web Tools manifest, the four flash images and the OTA manifest. It refuses to write anything if the images contain home-directory paths, e-mail addresses, or any string listed in ~/.config/devos/publish-deny.txt (one per line; your own names, networks and addresses).
  4. Commit and push. Pages serves docs/ at https://<owner>.github.io/<repo>/.

If you fork this, change DEVOS_OTA_DEFAULT_FEED in components/devos_ota/devos_ota.c to your own Pages URL.


Repository layout

components/
  bsp_tab5/          board drivers: display, touch, battery monitor, RTC, camera, I2C
  tab5_keyboard/     70-key keyboard driver (I2C, Sym / Aa / Ctrl / Alt)
  devos_config/      pins, sizes, version
  devos_core/        app registry, switcher, key dispatch, intents, app switches, telemetry
  devos_ui/          theme, top bar, widgets, focus, icons, command palette, system info, shortcuts, restart/shutdown dialog
  devos_net/         Wi-Fi, DNS/mDNS, socket layer + VPN routing
  devos_http/        HTTP(S) client
  devos_storage/     MicroSD mount and scaffolding
  devos_fileshare/   the SD card as a web page (HTTP server + the page itself; also pastes text into the clipboard)
  devos_power/       active / dim / sleep
  devos_ota/         update check and install
  devos_sysmon/      1 Hz telemetry, clock, time zones
  devos_json/  devos_mdview/  devos_crypto/  devos_vterm/          shared engines
  devos_mqtt/  devos_docker/  devos_proxmox/  devos_adsb/         feature engines
  devos_netdiag/     ping, DNS, port scan, mDNS, Wi-Fi survey, Wake-on-LAN
  devos_maptiles/    OpenStreetMap tiles: fetch one at a time, cache on SD
  devos_hashfile/    stream a file off the SD card through a hash (core 0)
  devos_totp/  devos_wireguard/  devos_tailnet/  devos_audio/  devos_qr/
  libssh2_port/      SSH client glue (libssh2 from the component registry)
  microlink/         Tailscale client (third party, MIT)
  wireguard_lwip/    WireGuard for lwIP (third party, BSD)
  quirc/             QR decoder (third party, ISC)
main/
  main.c             bring-up and app registration
  apps/app_*/        one folder per app; app_template/ is the starting point
tools/
  *_test.c           host unit tests
  sim/               web simulator scripts
  fetch_lvgl.sh      fetch the pinned LVGL
  publish_pages.py   build the Pages site (installer + OTA feed)
  flash_c6_slave.sh  reflash the ESP32-C6 Wi-Fi firmware
docs/                GitHub Pages: browser installer and OTA feed (generated)

PLAN.md is the design document and roadmap.

Licence

devOS is released under the MIT licence. Third-party components keep their own licences: components/microlink (MIT), components/wireguard_lwip (BSD-3-Clause), components/quirc (ISC), and LVGL (MIT, fetched by tools/fetch_lvgl.sh).

About

devOS: keyboard-first firmware for the M5Stack Tab5 (ESP32-P4) with drop-in apps. Browser installer + OTA via GitHub Pages.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages