Plugins come in two formats:
Create /plugins/hello.lua on the SD card:
plugin = {
name = "Hello World",
id = "hello",
type = "activity",
menuEntry = "Hello",
}
function plugin.onEnter()
local fonts = require("lib.fonts")
fonts.init()
display.clear()
display.drawText(fonts.ui, 50, 100, "Hello from Lua!")
display.refresh()
end
function plugin.loop()
if input.wasPressed(input.BACK) then
plugin.goHome()
end
end
function plugin.onExit()
require("lib.fonts").cleanup()
endCreate /plugins/hello/main.lua:
/plugins/hello/
main.lua ← entry point (required, contains plugin table)
helpers.lua ← additional modules
data/ ← assets, configs, images
main.lua uses the same format as a single-file plugin. The folder can contain any additional files the plugin needs — helper modules, data files, assets, etc.
Reboot the device (or long-press power to reload). The plugin appears in the home menu.
Ready-to-use templates are in /templates/ on the SD card:
| Template | Description |
|---|---|
plugin_single_file.lua |
Single-file plugin starter |
community_plugin/ |
Folder plugin starter with comments and structure |
home_lyra.lua |
Lyra-style home screen |
home_classic.lua |
Classic home screen |
Copy a template to /plugins/ and edit to create your plugin.
A full-screen interactive plugin (browser, settings, tools):
plugin = {
type = "activity",
menuEntry = "My Tool", -- shown in home menu, nil = hidden
}A file format reader. Registers file extensions it can open:
plugin = {
type = "reader",
fileExtensions = { "epub", "epub3" },
}
function plugin.canOpen(path)
return true
end
function plugin.open(path)
-- parse file, build pages
end
function plugin.renderPage(pageNum)
-- draw current page
end
function plugin.getPageCount()
return totalPages
endBackground functionality (sync, auto-download). No UI:
plugin = {
type = "service",
menuEntry = nil,
}
function plugin.onEvent(event)
if event == "wifi_connected" then
-- sync progress, check updates, etc.
end
endMost plugins only need the core Lua API (display, input, storage, system, font, layout) — no declaration required. If your plugin uses an opt-in capability — text for word-wrap and pagination helpers, or any future module like zip, xml, epub, css, image — declare it in your manifest's requires field. Capabilities are only registered into Lua states for plugins that need them, so plugins that don't list a capability pay zero RAM cost for it.
plugin = {
name = "TXT Reader",
id = "txt_reader",
type = "reader",
fileExtensions = {"txt"},
requires = {"text"}, -- text.indexPages, text.getPageLines, text.wrapString
system = true,
}Recognized capabilities (current):
| Capability | What it adds | Notes |
|---|---|---|
text |
text.indexPages, text.getPageLines, text.indexMarkdownPages, text.renderMarkdownPage, text.wrapString |
Streaming word-wrap and pagination. Required by TXT and MD readers. |
Future capabilities (planned for the EPUB reader phases — not yet implemented):
zip— ZIP archive entry readsxml— XML/HTML SAX parserepub— high-level EPUB book object (manifest, spine, TOC, metadata)css— CSS subset parser + selector matcherimage— JPEG / PNG decoders for inline<img>and cover thumbnails
If your plugin declares an unknown capability, the registration is skipped with a log entry — older firmware can still boot a plugin written for newer firmware (the unrecognized capability is just unavailable). Recognized capabilities go up over time; old plugins keep working.
Limits: up to PLUGIN_REQ_MAX (6) capabilities per plugin, each up to PLUGIN_REQ_LEN (12) characters including NUL.
The plugin manager scans /plugins/ on boot (or on SD reload via power long-press). Discovery is done entirely in C by reading the first 1KB of each plugin file and extracting manifest fields with string matching — no Lua state is created during discovery, making it fast (~12ms for 3 plugins) and zero heap overhead.
- Single-file plugins: Any
.luafile directly in/plugins/(e.g.,/plugins/my_tool.lua) - Folder plugins: Any subdirectory containing
main.lua(e.g.,/plugins/my_tool/main.lua)
Plugins are only loaded into a Lua state when the user navigates to them. Discovery just reads the manifest.
Skipped automatically:
/plugins/lib/— reserved for shared Lua modules- Dotfiles and dot-directories (
.hidden) - Directories without a
main.lua - Non-
.luafiles
Both formats use the same manifest and lifecycle — the only difference is where the entry point file lives.
Important: The plugin = { ... } table must be in the first 1KB of the file for discovery to find it. Keep the manifest near the top.
- Your plugin has multiple
.luafiles (helpers, modules, screens) - Your plugin ships with data files (configs, assets, templates)
- You're distributing your plugin for others to install (drop one folder on SD)
- Simple plugins with all logic in one file
- Core system plugins (home, settings, file_browser)
Every plugin must define a plugin table (in main.lua for folder plugins, or in the .lua file for single-file plugins):
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Human-readable name |
| id | string | Yes | Unique identifier (lowercase, no spaces) |
| version | string | No | Semantic version |
| author | string | No | Author name |
| type | string | Yes | "activity", "reader", or "service" |
| menuEntry | string | No | Text shown in home menu (nil = hidden) |
| fileExtensions | table | Readers only | File extensions this plugin handles |
onEnter(arg) → Called when plugin becomes active
arg is optional (e.g. file path for readers)
loop() → Called every frame (~60Hz when awake)
Handle input, update display
onExit() → Called when leaving plugin
Free resources, save state
-- Switch to another plugin
plugin.navigate("settings")
-- Switch to a reader with a file
plugin.navigate("epub_reader", "/books/my_book.epub")
-- Go back to previous plugin / home
plugin.finish()
-- Go directly home
plugin.goHome()See build_spec.md for the complete API documentation for:
display.*— renderinginput.*— button handlingstorage.*— file I/Owifi.*— networkfont.*— font managementzip.*— archive accessxml.*— XML/HTML parsingjson.*— JSON parsingsystem.*— device infoi18n.*— translations
CrossLua Reader ships shared modules in /plugins/lib/ that plugins can require:
Persistent settings. All plugins should use this instead of reading/writing JSON directly.
local settings = require("lib.settings")
settings.load() -- read from SD (once per plugin)
local val = settings.get("orientation", 0) -- get with default
settings.set("orientation", 1) -- change in memory
settings.save() -- persist to SDSee docs/settings-schema.md for all available keys, types, and defaults.
System font manager. Loads UI font, reader font, and optional fallback font based on settings and language.
local fonts = require("lib.fonts")
fonts.init() -- load fonts + auto-load fallback (call in onEnter)
display.drawText(fonts.ui, x, y, "Menu") -- Ubuntu 12 UI font
display.drawText(fonts.reader, x, y, "Book") -- user's reader font (with fallback if set)
fonts.reload_reader() -- reload after font/language settings change
fonts.cleanup() -- unload all (call in onExit)
-- Discover available font packs from /fonts/ subdirectories:
local families = fonts.discover_families() -- returns e.g. {"Bookerly", "NotoSans"}
-- Script detection for reader plugins:
local scripts = fonts.detect_scripts(text) -- returns e.g. {"hebrew"}
fonts.detect_fallbacks(text) -- auto-load fallback from text contentSee docs/font-packs.md for how to create and install font packs.
Reading progress persistence. Stores progress alongside book files.
local progress = require("lib.progress")
progress.save("/books/my_book.epub", {page=42, totalPages=210, offset=34567})
local p = progress.load("/books/my_book.epub") -- returns table or nil
progress.clear("/books/my_book.epub") -- delete saved progressTheme metrics for consistent layout. Returns Lyra (modern) or Classic constants.
local theme = require("lib.theme")
local t = theme.get() -- { menu_row_height=64, corner_radius=6, side_padding=20, ... }
-- Switch theme:
theme.set("classic") -- or "lyra" (default)Shared UI drawing helpers. Uses theme metrics automatically. Selection highlights use dithered gray (readable text on gray background, matching CrossPoint's Lyra theme).
local ui = require("lib.ui")
ui.draw_header(font_id, "My Plugin") -- top bar with battery
ui.draw_menu(font_id, items, selected, y) -- scrollable menu with gray selection
ui.draw_list(font_id, items, selected, y, max_visible, scroll_offset) -- file/item list
ui.draw_button_hints(font_id, {"Back", "Select", "", ""}) -- CrossPoint-style 4-button barMenu items format: { {label="Browse Files"}, {label="Settings"} }
Button hints: pass a table of 4 strings for the 4 front buttons {Back, Confirm, Left, Right}. Use "" for empty buttons. Rendered as bordered cells matching CrossPoint's button hint bar.
Orientation-aware button mapping and hint labels. Maps logical actions (up, down, left, right, back, confirm) to physical buttons per orientation.
local buttons = require("lib.buttons")
-- Get hint labels for the 4 front buttons (orientation-aware):
ui.draw_button_hints(font_id, buttons.get("home", orientation))
ui.draw_button_hints(font_id, buttons.get("browser", orientation))
ui.draw_button_hints(font_id, buttons.get("settings", orientation))
ui.draw_button_hints(font_id, buttons.get("reader", orientation))
-- Apply button remap for an orientation (call on boot and orientation change):
input.setMapping(buttons.get_mapping(orientation))Format in buttons.lua is logical_action = "physical_button" — read as "to perform [action], press [button]". Edit /plugins/lib/buttons.lua to customize mappings or labels.
Language pack discovery and UI translation. Falls back to English for missing keys.
local lang = require("lib.lang")
lang.load("he") -- load Hebrew language pack
local label = lang.tr("settings") -- → "הגדרות" (or "Settings" if key missing)
local dir = lang.get_direction() -- → "rtl"
local family = lang.get_font_family() -- → "NotoSansHebrew"
local packs = lang.discover() -- scan /languages/ for available packsShared recursive JSON parser. Handles objects, arrays, strings, numbers, booleans, null.
local json = require("lib.json")
local data = json.decode('{"name": "test", "count": 42}')
-- data.name == "test", data.count == 42Reader status bar for book progress.
local status_bar = require("lib.status_bar")
status_bar.draw(font_id, progress_pct, current_page, total_pages, "Book Title")If your plugin's loop() function throws a Lua error, the device shows a crash screen with the error message and waits for the user to press any button, then returns to home. Your plugin is stopped but not disabled — it can be launched again.
Users can drop BMP images into /wallpapers/ on the SD card. The settings plugin lets them choose the sleep screen mode (blank, single wallpaper, cycle, random, or stay-on-page).
Plugins can register a callback to draw custom content on the sleep screen:
system.setSleepHook(function()
-- Draw after the base sleep screen (wallpaper/blank/clear) renders,
-- before the display refresh. Use any display.* API.
display.fillRect(20, 600, 440, 120)
display.drawRect(20, 600, 440, 120)
display.drawText(fonts.reader, 30, 610, quote_text)
end)The hook is cleared automatically on plugin exit. Errors in the hook are caught and logged — they don't prevent sleep.
See docs/sleep-screen.md for wallpaper formats, modes, and full API reference.
- Short press (0.5-2s): Manual sleep
- Long press (>2s): SD card reload — re-mounts SD and restarts plugins from home. Useful after editing Lua files without reflashing.
system.reload() -- reinit SD card and restart from homeTemplates are in the templates/ directory of the repository:
| Template | Type | Description |
|---|---|---|
plugin_single_file.lua |
File | Minimal single-file plugin starter |
community_plugin/ |
Folder | Full folder plugin with comments and structure |
home_lyra.lua |
File | Lyra-style home screen |
home_classic.lua |
File | Classic home screen |
Single-file:
- Copy
plugin_single_file.luafromtemplates/to/plugins/my_plugin.luaon your SD card - Edit the
plugintable (change name, id, menuEntry) - Reboot or long-press power to reload
Folder plugin (community):
- Copy
community_plugin/fromtemplates/to/plugins/my_plugin/on your SD card - Edit
main.lua— change the manifest, add your logic - Add helper files, data, assets as needed
- Reboot or long-press power to reload
Custom home screen:
- Copy
home_lyra.luafromtemplates/to/plugins/home.luaon your SD card - Edit to customize — must keep
id = "home" - Reboot
To share a plugin with others:
- Single-file: share the
.luafile — user drops it in/plugins/ - Folder plugin: share the folder as a zip — user extracts to
/plugins/
Folder plugins are self-contained: all files live in one directory, nothing to configure.
- The device has ~89KB free heap available for plugin data
- The Lua state itself uses ~80-90KB — this is the largest single consumer
- Fonts use on-demand glyph loading (~2-8KB RAM per font instead of ~25-31KB)
- Use
{skip_reader = true}infonts.init()if your plugin only needs the UI font - Avoid loading entire files into memory — use
storage.readBytes()for streaming - Release large tables when done:
myTable = nil; collectgarbage() - Check available memory:
system.freeHeap()returns bytes free
- E-ink refresh takes 400ms-1.5s — Lua computation is rarely the bottleneck
- For heavy loops, call
system.delay(1)periodically to yield to the watchdog - Text measurement (
display.getTextWidth) is a native call — fast
- Always call
display.refresh()after drawing — nothing appears until refresh - Use
display.refreshFull()every 10-15 pages to clear ghosting - Get screen dimensions with
display.width()/display.height()— don't hardcode 480/800
- Plugin state/cache goes in
/plugins/.cache/<plugin_id>/ - Books are in
/books/or user-chosen directories - Font files are in
/fonts/