SolarOS

ESP32 operating environment · 4.17.7

Small system.
Real tools.

SolarOS is an ESP32 operating environment that provides a shell, foreground applications, background jobs, storage, networking, hardware services, and scripting surfaces.

solar@pocket:/ apps
messages   dlp   rtsp
agent   aplay   arecord
recorder   funcgen   player
calc   chat   clock
com   curl   webradio
help   edit   hexedit
launcher   files   ftp
sftp   flash   contacts
inbox   email   io
invaders   less   ltop
rfscan   logic   lua
notes   plot   playground
python   reader   writer
scp   sftpsync   sheet
ssh   synth   telnet
view   vplay   sketch
web   zoo

solar@pocket:/ _

Everything you need. Nothing you don't.

Homage to a simpler life

SolarOS tries to bring back the spirit of the golden era of home computing: turn on a computer, arrive at a shell prompt, and start doing something. Explore, customize, write a program, make a tool, share it with someone.

  • Modern software accumulates layers of convenience that obscure how it works and what it costs to run. We become accustomed to using - and now generating - software without understanding it.
  • SolarOS encourages a more deliberate approach: understand the machine, question the complexity, and take responsibility for the resources your software uses. Scripting makes experimentation approachable; native services provide an efficient foundation. The constraints give us a reason to pay attention.
  • The scripting surfaces let users express their ideas, while native services handle demanding work, making efficient execution a shared foundation that users can build on.

Wastefulness for the sake of convenience is not sustainable.

It is a continuous exploration of how much useful, understandable, general-purpose computing can be built on deliberately constrained hardware. We already have an abundance of computing power; we can and should make better use of it.

SolarOS is an argument made executable.

SolarOS argues that useful, general-purpose computing can be built on modest hardware - and that efficiency, understanding, and the ability to shape your own tools should guide its design.

  • The Solar in the name reflects the goal of building an efficient operating environment that can run on solar power. Energy consumption is a design concern alongside memory, storage, and processing time.
  • That commitment also extends to the project’s infrastructure: all our servers ( website, HIL pipeline, local expert systems ) are powered by 100% renewable energy.

Inside the system

A compact, capable runtime.

The same shell, apps, and jobs work across handheld and headless boards.

01

Local shell

History, aliases, scripts, completion, storage, and resumable sessions.

02

Foreground apps

Edit, read, inspect data, connect remotely, browse, and capture logic.

03

Background jobs

Logging, acquisition, NTP, HTTP, SLIP, bridges, and battery monitoring.

04

Python & Lua

Automate services and hardware with embedded scripting APIs.

05

Connected hardware

Wi-Fi, BLE, SD, RTC, GPIO, ADC, PWM, I²C, SPI, audio, and sensors.

06

Firmware flavors

Build focused or full images from capability-aware packages.

What's new

Recently added.

01

Screen keyboard

Type above the shell, TUIs and graphical apps with the screen-keyboard job. Choose a full keyboard, symbol palette or compact T-Deck punctuation layout; toggle it with Ctrl+Space or a bottom-edge swipe, then select keys by touch or physical arrows and Enter.

Use the screen keyboard →
02

reTerminal Sticky

Run SolarOS on Seeed Studio reTerminal Sticky, with an 800×480 e-paper touchscreen, Fastwave refresh, 8 MB PSRAM and 32 MB flash. Built-in sensors, microSD, microphone, buzzer and battery monitoring use the shared shell and scripting APIs.

Explore Sticky support →
03

Sealed Chat (SC2)

Exchange end-to-end encrypted direct messages and chat in named private rooms through the Chat app. Connect to a relay, verify a peer with safety numbers, and keep messages moving in the background with sc2-sync on firmware with SC2 support.

Follow the Sealed Chat tutorial →
04

Graphical radio scanner

Monitor live RSSI, sweep frequency bands, and view peak traces and waterfalls on color or monochrome displays. The optional rfscan app supports attached radios with live RSSI, adjustable tuning and bandwidth, and audible threshold alerts.

Explore the radio scanner →
05

Touchscreen gestures

Use tap, double-tap, hold, and directional swipe gestures from registered touchscreens in native apps, Python, and Lua. Bind gestures to shell commands through the gestures job while ordinary pointer events remain available to apps.

Use touch gestures →
06

Jet 3D rendering

Build 3D scenes from Python and Lua with textured meshes, lighting, sprites, particles, shaders, and asset loading. Install the Jet library on supported ESP32-S3 boards with PSRAM; render to color or dithered monochrome displays.

Create a Jet scene →
07

Named motor control

Control DRV8830 motors on named I2C buses from the shell, Python, or Lua. Use normalized forward and reverse drive commands, coast and brake controls, and fault diagnostics through the shared motor service.

Connect and control a motor →
08

Hardware encoder counts

Attach named A/B quadrature encoders with explicit x1, x2, or x4 counting on ESP32 and ESP32-S3. Read signed hardware counts, inspect status, and reset counters from the shell, Python, or Lua.

Attach a quadrature encoder →

SolarOS documentation

screen-keyboard

The screen keyboard supplies touch and keyboard input above the shell, TUIs, and graphical applications on a selected display. Start the background job to arm its activation actions:

job start screen-keyboard mode=full
job start screen-keyboard mode=symbols display=display0
job start screen-keyboard mode=deck
job status screen-keyboard
screen-keyboard show
screen-keyboard hide
screen-keyboard toggle
screen-keyboard mode full
screen-keyboard mode symbols
screen-keyboard mode deck
job stop screen-keyboard

Start the job once, choosing one mode. By default, Ctrl+Space or an inward upward swipe from the bottom edge toggles visibility. Tap a key to enter it, or use the physical arrows to move the selection and Enter to activate it. Escape hides the keyboard. A downward swipe that starts inside its panel also hides it. Touch strokes reserved for activation or keyboard selection do not reach the application underneath; touches outside these regions remain normal application input.

Full mode provides letters, numbers, one-shot Shift/Ctrl/Alt, Caps Lock, a symbol table, and a navigation page with Tab, Escape, arrows, Home/End, Page Up/Down, Delete, and Backspace. Physical Tab cycles the full-mode pages. Symbols mode opens the punctuation palette directly, with = selected first. Deck mode provides two rows containing only punctuation absent from T-Deck's normal and Sym layers: = < > [ ] { } % & plus backslash, vertical bar, caret, tilde, and backtick. It has no control-key row; use the configured key or gesture, or Escape, to hide it. Closing the keyboard clears its modifiers and Caps Lock. Physical typing can continue while it is visible; unmodified arrows, Enter, Tab, and Escape control the keyboard. Desktop modifier shortcuts retain their normal behavior.

Start arguments:

ArgumentMeaning
mode=full|symbols|deckInitial mode; default full.
layout=gridLayout provider.
display=<target>Display and input destination; default the focused display.
trigger=<key-or-chord>Visibility toggle; default ctrl+space. Use none to disable it.
source=<keyboard>Restrict the key trigger to one registered input source.
key-id=<id>Use a source-local physical key ID instead of a translated key. Requires source=; without trigger=, no modifiers are required.
gesture=edge-swipe|noneEdge activation; default edge-swipe.
direction=north|south|east|westInward edge-swipe motion; default north from the bottom edge.
touch-source=<source>Restrict touch interaction to one registered touch source.
auto-hide=on|offHide after emitting a key; default off.
visible=on|offShow immediately on start; default off.

For a keyboard without a convenient activation chord, select a key ID from input test <source> and bind it with source= and key-id=. No long-press activation is used. A display must be ready and at least 80 by 64 logical pixels. The keyboard hides on session/focus changes, display geometry changes, and sleep. Status reports visibility, layout, mode, emitted-key count, and the last error. Configuration is volatile; use the selected startup script to start the job automatically after boot.

SolarOS documentation

Seeed Studio reTerminal Sticky

The seeed_reterminal_sticky target uses an ESP32-S3R8 with 32 MB flash and 8 MB PSRAM. It defaults to the full flavor. Its 3.97-inch 800x480 e-paper panel defaults to native landscape orientation. The sticky-display device identifies SSD1677 and SSD2677 panels at startup, then registers the selected controller and driver under display0. Detection checks command 0x70 and stable post-reset BUSY polarity twice. BUSY polarity also supports panels without working SPI readback; floating or inconsistent BUSY signals fail attachment. The result is checked on every attachment rather than cached in NVS. Pins follow the Seeed hardware overview:

  • E-paper: SCK GPIO13, MOSI GPIO14, CS GPIO15, DC GPIO16, RST GPIO17, BUSY GPIO18, EN GPIO47
  • Touch (GT911): SCL GPIO2, SDA GPIO3, INT GPIO21, RST GPIO41, EN GPIO42; 180-degree panel alignment before display rotation.
  • Buttons: AI/Power GPIO4 (tap: light sleep/wake, hold: shutdown), Up GPIO5 (tap: Up arrow, hold for two seconds: BLE pairing), Down GPIO6 (tap: Down arrow, hold for two seconds: app exit); all active-low.
  • Power: PWR_HOLD GPIO45, PWR_LOCK GPIO46; BQ25616 charger enable GPIO39 is configured as an output and driven LOW at boot to enable charging.
  • Console: UART0 GPIO43/44 (CH343 USB-C bridge), DIO flash
  • Sensor I2C: SCL GPIO0, SDA GPIO1; PCF8563 RTC at 0x51, SHT40 at 0x44, BQ27220 at 0x55
  • microSD: shared display SPI bus, MISO GPIO12, CS GPIO8, power GPIO10
  • Buzzer: PWM GPIO48, attached as audio0 through audio-pwm.

The default devices are display0, touch0, rtc0, environment0, battery0, imu0, storage0, audio0, and mic0. The console uses the USB-UART bridge; GPIO19/20 carry the PDM microphone clock/data. The 32 MiB partition table keeps two 7 MiB OTA application slots and reserves nearly 18 MiB for FAT storage at /flash, mounted alongside /sdcard. The serial-only layout uses one 14 MiB application slot and the same storage region. Updating from the former 16 MiB layout requires flashing the partition table over USB; an OTA image cannot change it. Back up flash files before changing layouts or formatting storage.

The SD rail stays powered while the shared display SPI bus is active. Use expansion attach storage0 after insertion or reinsertion to attach the slot or recover its mounted card.

An SSD1677 display starts with asynchronous refresh=pulse / waveform=fastwave at 20 MHz. The Sticky panel profile (panel=1) uses matching black/white data in both RAM planes for its factory full-refresh waveform. Pulse mode uses short monochrome drives with per-pixel settling budgets. Defaults are 100 ms between new images, five drive cycles, eight scan frames, frame-rate code 8, retained supplies during settling, and adaptive cleanup with baseline 64. refresh=fast aliases native refresh=partial; waveform=otp restores auto. If pulse resources cannot be allocated at startup, native auto mode remains available. Pulse buffers are allocated in PSRAM when enabled (about 2.75 MiB including worker snapshots). Optical contrast and ghosting depend on the panel. Returning to a native mode releases the pulse buffers, snapshots, and worker. pulse-scan=sync releases the asynchronous worker and its snapshots. The native framebuffer prefers PSRAM; SPI DMA staging stays in internal SRAM. Touch swaps the GT911 axes before its 180-degree board rotation to match landscape. The direct MONO1 frame presenter supports scaled animation and games at a preferred 10 presentations per second on SSD1677. Pulse settling continues asynchronously.

SSD2677 uses its own Sticky 3D97 V2 monochrome profile at 20 MHz. Sticky's panel=1 starts in asynchronous refresh=pulse / waveform=fastwave. The driver uploads an SSD2677-specific 535-byte RAM waveform and uses the same pixel-budget and scan worker as SSD1677. Defaults are drive-cycles=8, pulse-frames=4, waveform-fr=5 (85 Hz), pulse-period=100, pulse-power=hold, and adaptive cleanup every 64 changed targets. Static pixels continue settling after the application stops painting; newer frames replace queued targets without waiting for every preceding frame to settle. drive-cycles and pulse-frames accept 1..32. SSD2677 waveform-fr accepts 1..5, corresponding to 25, 50, 65, 75, and 85 Hz. Other pulse tuning, cleanup, inversion, synchronous scan, and SPI-rate controls follow SSD1677 semantics. The panel's own OTP supplies analog voltage settings. Full cleanup, recovery, and final power-state images use factory waveforms. Pulse mode uses aligned RAM windows around active pixels, including the preceding mask so settled pixels are cleared to HOLD. The first mask initializes the full visible RAM; subsequent transfers cover only that window.

Both Sticky controller revisions default to display0 scale 2 (a 400x240 logical canvas on the 800x480 panel). A saved per-display scale takes precedence.

Native refresh=auto|fast|partial|full modes remain available. The panel=0 alias starts in auto mode. refresh=partial selects factory partial refresh; refresh=fast selects partial refresh with a fixed 85 Hz scan rate. refresh=full forces factory full refresh. Auto and fast modes perform a full cleanup after 19 changed partial updates; explicit partial mode bypasses periodic cleanup. Full refreshes always use factory scan timing. Unchanged frames are skipped. This profile transmits full-frame old/new two-bit transition pairs. The panel scans 680 gate lines while accepting the 480 visible image rows. Temperature readings select the vendor waveform bucket, with a 25 C fallback for unavailable readback. Partial updates reuse that selection and resample temperature every five seconds; full refreshes always resample temperature and reload the factory waveform. The MONO1 raster presenter supports all four rotations, palette inversion, scaled animation and games, with a preferred rate of ten presentations per second. Actual scan time depends on the waveform, transfer cost, and temperature. display mode display0 reports scan/submission/coalescing statistics, pending pixels, the configured SPI rate, and the last native refresh, transfer, and panel drive times in microseconds. The driver reverses the V2 gate-row order while preserving the source X direction so the default orientation and touch calibration match SSD1677 units. Recovery (display relax), final power-state presentation, and sleep/resume use native full refreshes. The driver restores its short RAM waveform after the next pulse baseline. The SSD1677 LUT/register format cannot be used directly on SSD2677. This profile does not support generic SSD2677 color panels.

The LSM6DS3TR-C at 0x6a supplies acceleration and angular velocity through imu0. Its GPIO7 interrupt also supplies hardware tap, double-tap, tilt, wake, and free-fall input gestures. With gestures enabled, acceleration runs at 416 Hz and angular velocity at 104 Hz. Single taps wait 650 ms for a possible double tap. Use input test imu0 to inspect events and gesture bind source=imu0 to assign actions. wake reports motion; it does not automatically wake the OS. Fused orientation is not provided. The microphone provides mic0.capture: 16 kHz, signed 16-bit mono PCM through I2S0's PDM receiver. GPIO38 and capture DMA are enabled only while its stream is open; capture can run alongside the PWM buzzer output. Use audio mic 1000 for a level check, or select mic0.capture in arecord or Recorder. Native four-level grayscale and full peripheral power optimization remain unimplemented for this target. Battery percentage comes from the BQ27220 state-of-charge register; charging state is inferred from its discharge flag.

Power-key shutdown runs after release and releases the hardware power latch after jobs, storage, and display shut down. With USB power connected, the shutdown path falls back to deep sleep. Light sleep retains the latch and resumes the current session. An Up hold starts pairing once and does not send an arrow on release.

Build and flash:

pio run -e seeed_reterminal_sticky pio run -e seeed_reterminal_sticky -t upload

SolarOS documentation

rfscan

Graphical receiver-energy monitor for attached radios that report the live_rssi capability. Requires a graphical display and the radio_scanner group. Radio drivers provide live energy readings and validate tuning and bandwidth settings through the common radio API; the app does not select support by radio model.

rfscan
rfscan radio0
radio scan radio0

Select the radio at launch with rfscan <name>; it remains selected for that app session. With no argument the app selects the first attached radio. The radio configuration TUI also opens the scanner with s on a graphical display. From a text-only shell, use session create rfscan display0 radio0 to open it on the device display.

Channel plots live RSSI against time. Time cycles through 10 seconds, 1 minute, 10 minutes and 1 hour. Sampling intervals increase with the time window; short bursts can be missed. The graph shows the latest level and the minimum and peak since starting or clearing. When another service is already receiving, the scanner observes without retuning, consuming packets or taking its radio claim. Transmit and sleep intervals produce no energy samples. An idle radio is claimed and placed in receive mode until scanning stops.

PgUp zooms into the RSSI plot's Y axis and PgDown zooms out. Available spans are 5, 10, 25, 50, 100 and 200 dB; the default is 100 dB. In Channel, the plot centers on the average RSSI of samples inside the displayed time window. In Band, it centers on the average of the current frequency-bin readings. Empty bins and held peaks do not affect the average. Each zoom key centers the axis on that average, then holds the axis steady until the next zoom key. The Avg readout continues updating; values outside the selected span are clipped to the plot edges. Zoom leaves scanning and the alert threshold unchanged and does not change waterfall shading. A threshold outside the visible Y range keeps working but its horizontal marker is hidden.

Channel controls are Start/Stop, Time, Step, Freq and Thresh. Step selects a frequency increment of 25, 62.5, 125 or 250 kHz without interrupting monitoring. With Freq selected, Up/Down increases/decreases the requested frequency by that increment; the frequency is shown to 1 Hz. Changing frequency applies it and resumes acquisition automatically when monitoring was running. Tuning requires an unclaimed radio and preserves the current receiver bandwidth. A busy radio reports its owner. Stop, suspension or exit restores the previous frequency and operating state.

Enter on a setting opens a prefilled input popup. Type to replace the current value, use Left/Right, Home/End, Backspace or Delete to edit, or Ctrl+U to clear the field. Enter applies and Esc cancels. Frequency uses MHz; Step and BW use kHz, including decimal values. A BW of 0 keeps the current receiver filter. Range accepts start and end frequencies in MHz separated by a space; Time accepts 1–3600 seconds. Values that would exceed the supported number of scan bins are rejected or require a larger step. Radio drivers validate frequency and bandwidth when scanning starts. Opening or cancelling a popup leaves the current scan running.

Thresh controls an audible energy alert in both tabs. The default is −90 dBm; Up/Down changes it by 1, 1, 1, 2, 5 or 10 dB for Y spans of 5, 10, 25, 50, 100 or 200 dB, respectively, and cycles through Off at the ends of the −130 to −30 dBm range. Enter accepts an exact dBm value or off. Changing the threshold leaves scanning running. A dashed horizontal line marks the threshold; fresh samples at or above it request a short beep at most once per second, including brief hits between graphical refreshes. Alerts use the selected audio output and global volume/mute, skip busy audio, and are cancelled on Stop, suspension or exit. Audio failures are reported without ending the scan. The center frequency of the strongest sample responsible for the last alert is shown beside the threshold as Threshold: >=-90dBm Triggered: 868.375000MHz. Before the first alert, Triggered shows --. The frequency remains visible while scanning moves on; the next alert updates it.

Band shows received energy against frequency, a dashed peak trace and a waterfall with the newest completed sweep at the top. Darker pixels mean stronger energy. The waterfall uses dithering on 1-bit displays; buttons use solid backgrounds, outlines and inverse selection. Range cycles through ±250 kHz, ±1 MHz, ±2 MHz and a preset band: 433.05–434.79, 863–870 or 902–928 MHz, selected from the current frequency. Step cycles through 25, 62.5, 125 and 250 kHz; wide ranges increase the step when necessary to stay within 129 frequency bins. BW selects the current receiver bandwidth or 62.5, 125 or 250 kHz. Drivers can round the requested bandwidth up to a supported filter; the running scan shows the applied bandwidth. Unsupported settings are rejected by the radio driver. The final frequency shown is the last sampled bin.

Three status lines above the graph show tuning settings, RSSI readings and the alert threshold with the last alert frequency. Errors replace the RSSI readings. The top header shows LIVE with owns RX, observe RX or RX paused for a band sweep; stopped acquisition shows STOPPED. In Channel, the header also shows the time window. The tuning row shows the frequency or band range, with BW: and Step: labels. Sampling runs independently of graphical refreshes. Radios advertising fast_tune use lightweight frequency changes and driver-defined settling times; other live_rssi radios remain supported through normal configuration. Sweep speed depends on the driver, bandwidth, range and frequency step.

Band scans require exclusive ownership of the selected radio. A busy radio shows its owner; stop that owner explicitly before scanning. Normal packet reception on that radio is paused during a sweep. Band scanning has no idle timeout. Stop, app suspension, app exit or a scan error ends the sweep. The scanner restores the previous configuration and operating state and releases its claim; a restoration failure is reported. Changing settings or switching tabs automatically resumes acquisition if it was running; a stopped scan stays stopped. Rejected settings restore the previous selection and resume it when radio ownership permits. Returning to a suspended app leaves it stopped until Start is selected.

ControlAction
Tab, or tap a tabSwitch between Channel and Band
Left/RightSelect a bottom function
PgUp/PgDownZoom the RSSI Y axis in/out, centered on the displayed average
Up/DownNext/previous value of the selected function; Up starts and Down stops when Start/Stop is selected
Enter, or tap a bottom buttonToggle Start/Stop, or open the selected setting's input popup
SpaceStart or stop
r, s, tTime/range, step, threshold
f in Channel, b in BandIncrease frequency by Step, or advance bandwidth
cClear history and peak hold
Ctrl+Alt+Del, q or EscExit

RSSI measures energy inside the receiver filter without decoding its source. Frequency bins are sampled sequentially; receiver bandwidth limits frequency detail. Scan only within the attached module's supported RF band and antenna range. This display does not provide raw I/Q samples.

SolarOS documentation

Pointers and axes

Use input to inspect semantic sources independently of their transport:

input touch
input mouse
input joystick
input gesture
input test touch0

Touch and other absolute pointers report positions; mice report relative deltas; analog joysticks report normalized X/Y axes; gesture sensors report recognized motions and taps. input test shows counters and the latest event while the shell is active.

The input service recognizes single-finger tap, double-tap, hold, swipe, and edge-swipe gestures from any registered absolute touch source. Producers submit ordinary pointer samples; the common input pump handles gesture timing. gesture status lists touch sources and their supported gestures. Recognition preserves ordinary pointer events and pauses while an exclusive pointer filter or capture owns the source, including handwriting input.

A tap lasts at most 250 ms with no more than 12 pixels of movement on either axis. A single tap waits 300 ms after release so a second tap can form one double-tap event instead of two taps. The second press must start within that interval and within 24 pixels on each axis of the first release. A stationary contact produces one hold after 600 ms; release does not produce another gesture. A swipe completes within 1000 ms, moves at least 40 pixels, and has a dominant axis at least 1.5 times the other axis. Its direction is west, east, north, or south relative to the target display's orientation when the contact starts. Without a target display, calibrated source directions apply. IMU directions remain board-relative. Other touch gestures use direction none.

An edge-swipe starts at a screen edge and moves inward using the same distance, duration, and dominant-axis thresholds as a swipe. The edge band is 5 percent of the shorter native display side, rounded up and limited to 8 through 24 pixels. Its direction describes motion: north is an upward swipe from the bottom edge, south is downward from the top, east is rightward from the left, and west is leftward from the right, relative to display orientation. Display bounds and orientation are fixed when contact starts. Pointer calibration bounds take precedence over display bounds. Without either set of bounds, recognition produces ordinary swipes only. An edge swipe produces one edge-swipe event instead of an additional ordinary swipe, so those bindings can be configured independently. Raw pointer events remain available; a gesture binding does not reserve or consume the touch stroke.

Touch gesture value is 1 for tap, 2 for double-tap, 600 milliseconds for hold, or the dominant-axis distance in pixels for either swipe kind, capped at 32767. raw is zero; double-tap sets the double flag and edge-swipe sets the edge flag. Pinch and rotation are not supported.

Native foreground applications opt in to structured pointer input with SOLAR_OS_APP_FLAG_POINTER_EVENTS and to axis input with SOLAR_OS_APP_FLAG_AXIS_EVENTS. Gesture-aware applications opt in with SOLAR_OS_APP_FLAG_GESTURE_EVENTS. Their event callback then receives SOLAR_OS_EVENT_POINTER in event.data.pointer or SOLAR_OS_EVENT_AXIS in event.data.axis, or SOLAR_OS_EVENT_GESTURE in event.data.gesture. Pointer events contain the source, pointer ID, absolute or relative mode, action, coordinates, deltas, buttons, and optional display target. A non-empty target routes to the active opted-in application on that display. Its absolute coordinates and deltas follow the target's current display orientation; orientation 0 is the device driver's normal mounting. An empty target follows local input focus. Axis events contain the source, X/Y/Z/RX/RY/RZ axis, normalized value, and delta and follow local input focus. Gesture events contain the recognized kind, direction, flags, optional value, and original sensor word and also follow local input focus. Applications without the matching flag do not receive those structured events.

Gesture bindings observe the same events without taking them away from the foreground application. A binding can match one source or every source, one gesture kind, and optionally one direction. Commands run one at a time. The cooldown suppresses repeated reports and defaults to 250 ms. The command after -- accepts separate arguments or one quoted command line.

An LSM6DS3TR-C attached with irq=<pin> registers a gesture source alongside its IMU measurements. It reports tap, double-tap, tilt, wake, and free-fall, with direction none. Its hardware 4D detector also reports tilt with directions north, east, south, and west and values 0, 90, 180, and 270 degrees. The optional rotation=0..3 binding corrects sensor mounting in quarter-turns; these directions stay fixed when the display rotates. Sticky uses rotation=1. Hold the screen upright and turn it; flat poses do not select an orientation. The detector uses a filtered 60-degree threshold and does not repeat an unchanged orientation. Orientation state is checked every 50 ms independently of the interrupt pin. Single taps wait 650 ms so a double tap produces one double-tap event. Values are 1, or 2 for double-tap. The raw word contains WAKE_UP_SRC in bits 0..7, TAP_SRC in bits 8..15, and FUNC_SRC1 in bits 16..23, and D6D_SRC in bits 24..31. wake is a motion event; power behavior requires an explicit binding. No gesture commands are assigned automatically.

gesture bind source=gesture0 gesture=flick direction=east -- input emit ALT+RIGHT
gesture bind source=gesture0 gesture=flick direction=west cooldown=400 -- input emit ALT+LEFT
gesture bind source=* gesture=double-tap -- /flash/bin/toggle-light.sh
job start gestures
gesture bindings
job stop gestures
gesture unbind 2

To rotate Sticky's display when held in each of its four orientations:

gesture bind source=imu0 gesture=tilt direction=north -- display orientation display0 0
gesture bind source=imu0 gesture=tilt direction=east -- display orientation display0 90
gesture bind source=imu0 gesture=tilt direction=south -- display orientation display0 180
gesture bind source=imu0 gesture=tilt direction=west -- display orientation display0 270
job start gestures

To use horizontal swipes for previous/next foreground sessions and vertical swipes for page navigation in the focused app:

gesture bind source=* gesture=swipe direction=west -- input emit ALT+LEFT
gesture bind source=* gesture=swipe direction=east -- input emit ALT+RIGHT
gesture bind source=* gesture=swipe direction=north -- input emit PAGE_UP
gesture bind source=* gesture=swipe direction=south -- input emit PAGE_DOWN
job start gestures

An upward swipe from the bottom edge can have its own action, for example:

gesture bind source=* gesture=edge-swipe direction=north -- input emit PAGE_UP

input emit creates a virtual local keyboard on first use and injects a key tap into the normal input-focus path. Modifier chords use CTRL, SHIFT, ALT, GUI, or their left/right forms, for example ALT+RIGHT. It does not send USB or BLE HID reports. A chord tap releases its modifiers together with the named key. Bindings are deliberately volatile and remain configured when the gestures job stops. Put the required gesture bind commands followed by job start gestures in the selected startup shell script to recreate and activate them after boot. gesture unbind all removes every rule and resets the next binding ID to 1. Background commands may use shell built-ins or invoke scripts, but they cannot launch a foreground application. Stopping the job prevents new gesture actions and discards queued actions; a command already executing is allowed to finish. Use an explicit display target for display actions, for example display orientation display0 90.

Foreground Python and Lua scripts receive the same structured pointer, axis, and gesture events through solaros.input.read([timeout_ms]). Touch events expose absolute x/y coordinates and press/move/release actions; relative mice expose delta_x/delta_y and button bits; joystick events expose their named axis, normalized value, and delta; gesture events expose names, direction, flags, value, and raw sensor data. solaros.input.sources() lists the registered semantic sources. Each runtime keeps a bounded 16-event foreground queue and reports overwritten events through solaros.input.status().dropped. Keyboard characters remain on solaros.tui.getch().

Absolute-pointer calibration maps a source's raw logical coordinates into a target extent and stores the mapping in NVS under that source name:

input calibrate touch0
input calibrate touch0 set 0 479 0 319 480 320
input calibrate touch0 reset

The device driver still owns physical orientation. Calibration only clamps and scales the already oriented X/Y values. Mouse deltas and joystick axes do not use pointer calibration.

Attach a standard relative PS/2 mouse to a named bus with:

expansion bus create ps2 ps2mouse clock=gpio17 data=gpio18
expansion attach ps2-mouse mouse0 ps2=ps2mouse
input test mouse0

The mouse attachment enables standard three-byte reporting and publishes relative motion plus the primary, secondary, and middle buttons. Confirm the connector's pinout, supply, and signal voltage before wiring it; ESP32 GPIOs are not 5 V tolerant.

An analog joystick consumes two existing scalar streams. Use stream list and adc status to find the actual stream names, then bind the measured range:

expansion attach analog-joystick joystick0 x=adc2 y=adc4 min=0 center=1650 max=3300 deadzone=100
input test joystick0

The attachment normalizes both streams as axes. Applications decide what those axes mean; SolarOS does not turn them into arrows or other keys.

The screen-keyboard background job provides a full virtual keyboard or a symbol palette above display applications. Start it with job start screen-keyboard mode=full or mode=symbols, then toggle it with Ctrl+Space or an upward swipe from the bottom edge. See screen-keyboard for controls and bindings.

SolarOS documentation

Jet 3D renderer

solaros.jet renders script-defined scenes using the installed jet resident library. The firmware includes the service.jet frontend, not the renderer. The Python and Lua package groups include this frontend on supported boards; custom flavors can select the jet group separately. It requires ESP32-S3, PSRAM and a display with RGB565 or MONO1 presentation.

Install a compatible library from your configured signed package catalog:

pkg install jet

Installation and rendering are separate operations. jet.open() uses the installed file; it does not download code. A missing or incompatible library produces an error. Only one scene may be open across all scripts and native consumers. Close it before pkg unload jet or pkg remove jet.

Python

import solaros

with solaros.jet.open() as scene:
    scene.background(0xffffff)
    scene.camera(z=-500, fov=60)
    cube = scene.cube(150, 150, 150, color=0x2192ff)
    for angle in range(360):
        if solaros.should_exit():
            break
        scene.transform(cube, z=100, rx=angle, ry=angle * 2)
        scene.render()
        solaros.time.sleep_ms(1)

open(display=None, fps=0) returns a scene. The default target is the calling foreground display. From a port shell, provide a ready target name from display list, for example solaros.jet.open("display0"). fps is 0 through 30; zero uses the target's preferred stream rate on monochrome targets and 30 on color targets. Presentation may coalesce queued frames. Jet keeps its current orientation while active. Display and gesture rotation changes are rejected until the scene is suspended or closed.

Scene methodArguments and result
background(color)Set RGB888 clear color; default white.
camera(x=0, y=0, z=-500, rx=0, ry=0, rz=0, fov=60)Camera position, integer-degree rotation and horizontal field of view, 10–150 degrees. Default near/far planes are 16/4096.
light(ambient=0x5a5a5a, color=0xfff5e6, intensity=220, azimuth=35, elevation=40)Ambient RGB888 and directional light; intensity is 0–255, direction is in integer degrees.
cube(width, height, depth, color=0xffffff)Create a shaded cube; each dimension is 1–4096. Return an integer object handle.
mesh(vertices, triangles, color=0xffffff)Copy vertices and indexed triangles; return an object handle. Vertices are (x,y,z) sequences. Python indices are zero-based. Normals are generated separately for each triangle.
mesh_data(vertices, triangles, material=0)Copy XYZ/UV/normal vertices and optional per-triangle material handles; see below.
material(...), material_set(handle, ...), material_info(handle)Create, partially update, or inspect a reusable material.
texture(width, height, pixels, ...), texture_set(handle, ...), texture_info(handle)Copy, update, or inspect a texture.
assign(object, material)Replace every triangle's material; zero restores the object's default material.
capabilities()Query available provider features, geometry counts and resource limits.
transform(object, x=0, y=0, z=0, rx=0, ry=0, rz=0)Replace an object's position and rotation. Omitted fields reset to zero.
remove(object)Remove an object. Its handle becomes invalid.
render()Render synchronously, then transfer the completed color buffer to the shared presenter without copying.
status()Return frames, presented, dropped, render_us, present_us, width, height, and error. Times are cumulative microseconds. presented counts successful presentation calls, not optical refreshes.
close()Release objects, buffers, display ownership and the library lease; repeated close is harmless.

Colors are 0x000000 through 0xffffff. Positions and vertex coordinates are integer design units in -4096 through 4096; rotations are integer degrees. Objects use back-face culling and Gouraud shading, with 16-bit per-pixel depth. A scene supports 32 objects. Each mesh accepts up to 1024 input vertices and 2048 triangles. Vertex winding determines the visible side.

Use a with block or try/finally for prompt cleanup. Interpreter teardown also closes retained scenes after their provider calls finish. Closed scene objects cannot access a later scene. Foreground session switching joins presentation and releases its display claim before another session draws; rendering resumes on return. End a graphics/TUI session or close Emotion faces before opening Jet. The claimed target defers terminal and status-bar painting until the scene closes or suspends, including when a port-shell script targets the display shell's screen.

Lua

Lua exposes the same operations as colon methods. solaros.jet.open(display, fps) defaults to nil, 0; method arguments follow the Python table's order. Lua mesh tables and triangle indices are one-based. Object handles are opaque integers, not array indices. New resource constructors and setters use an options table in Lua, for example scene:material{color=0xffffff, shading="phong"} and scene:material_set(handle, {alpha=160}). Texture creation is scene:texture(width, height, pixels, options); mesh data uses scene:mesh_data(vertices, triangles, material).

local solaros = require("solaros")
local scene <close> = solaros.jet.open(nil)
local cube = scene:cube(150, 150, 150, 0x2192ff)
for angle = 0, 359 do
    if solaros.should_exit() then break end
    scene:transform(cube, 0, 0, 100, angle, angle * 2, 0)
    scene:render()
    solaros.time.sleep_ms(1)
end

The <close> declaration releases the scene on normal completion and errors. Explicit scene:close() is also available. Use Ctrl+] to stop either runtime. Complete two-cube examples are supplied as examples/python/jet.py and examples/lua/jet.lua in the firmware source tree.

Materials, textures and mesh attributes

These operations require Jet library 1.1 or later. The original library still supports the basic methods; capabilities() reports unavailable extensions as false. Resource handles belong to their scene and become invalid when removed or when the scene closes. remove() refuses a material referenced by geometry or a texture referenced by a material. Detach or remove its consumers first.

texture = scene.texture(2, 2, b"\x00\x01\x01\x00", format="index8",
                        palette=(0xffffff, 0x2192ff))
material = scene.material(texture=texture, shading="phong", specular=80, gloss=32)
scene.assign(cube, material)
scene.material_set(material, alpha=180)

material() accepts these keyword options, or positional values in this order. material_set(handle, ...) changes only supplied options.

OptionDefault and range
colorWhite RGB888 tint.
shading"gouraud"; also "flat", "phong", "wireframe", "unlit", "water", "additive".
textureTexture handle, or zero for a solid color.
alpha, diffuse, specular, gloss255, 255, 0, 0 respectively; each 0–255. Gloss sets the Phong specular exponent.
emissive, perspectiveFalse, true; bypass lighting and enable perspective-correct UV interpolation respectively.
water_bias, reflection_max_y0 and -1; water reflection row offset 0–255 and maximum reflection row -1–1023. Water shading needs a scene background gradient.
nameOptional unique material name, fewer than 64 UTF-8 bytes.

texture(width, height, pixels, ...) requires dimensions and pixel bytes as positional arguments. Width and height are 1–1024. All pixel and palette data is copied into provider-owned PSRAM; the script may release its input buffers.

Texture optionDefault and meaning
format"rgb565": two bytes per pixel, most significant byte first. "rgb888" uses R,G,B byte triples; "index8" uses one-byte palette indices.
address"wrap"; also "clamp" and "zero" for out-of-range UVs.
alpha_key-1 disables color-key transparency; otherwise an RGB888 color.
screen_space, reflectionFalse; sample using screen coordinates or reflected view direction.
bilinearFalse; true enables bilinear sampling for RGB textures. Indexed textures use nearest sampling.
offset0–255; palette offset, wrapped by palette length.
paletteRequired for index8: 1–256 RGB888 colors. Every input index must fit this palette.
pixelsReplacement byte buffer for texture_set(); supply creation pixels positionally.
nameOptional unique texture name, fewer than 64 UTF-8 bytes.

texture_set(handle, ...) retains dimensions and format. It can replace pixels or palette and change sampling options. Allocation or validation failure leaves the previous texture usable. texture_info() includes width, height, format, palette size and sampling settings. material_info() returns material settings. A scene allows 16 textures and 32 reusable materials, plus each object's default material.

mesh_data() accepts vertices consistently shaped as (x,y,z), (x,y,z,u,v), or (x,y,z,u,v,nx,ny,nz). UVs are normalized numeric values in -32–32; normals are numeric components in -1–1. Without explicit normals, per-face normals are generated. Supply unit-length normals for smooth lighting. Each triangle is (a,b,c) or (a,b,c,material), with the usual language-specific index base. The optional fourth element overrides the default material argument for that triangle. Zero selects the object's default material.

Complete textured examples are examples/python/jet_materials.py and examples/lua/jet_materials.lua.

Geometry and scene controls

These operations require Jet library 1.2 or later. Python setters accept keyword options; Lua setters take an options table. Setters change only supplied fields. capabilities() reports geometry, scene, max_objects, and max_lights.

MethodContract
primitive(...)Create a primitive and return its object handle.
object_set(object, ...), object_info(object)Update or inspect object properties. Info also includes vertex/triangle counts, six local-space bounds (min XYZ, max XYZ), and lod_levels.
object_action(object, ...)Run a geometry or movement action; return the object handle, or the new handle for clone.
mesh_update(object, vertices, triangles, material=0)Replace geometry using the mesh_data() format while retaining transforms, flags and LOD links. Failed validation or allocation leaves the previous geometry intact.
camera_set(...), camera_info(), camera_action(...)Partially update, inspect, or move the camera.
point_light(...), point_light_set(light, ...), point_light_info(light)Create, update or inspect one of up to eight point lights; remove(light) releases it.
gradient(colors)Copy a sequence of up to 1024 RGB888 color stops, interpolated over the display height. Empty sequence or Python None / Lua nil restores the solid background.
lod(object, levels)Set up to eight alternative object handles, ordered from highest to lowest detail. Empty sequence clears the list. Cycles and removal of referenced objects are rejected.
settings(...), settings_info()Update or inspect scene-wide controls.

Primitive options:

OptionDefault and meaning
kind"cube"; also "grid", "plane", "pyramid", "sphere", "capsule", "cylinder", "billboard", "quad", "debug_cube".
width, height, depth, radius150, 150, 150, 75; each 1–4096. Capsule height is total height and must be at least twice its radius.
segments, rows, cols12, 4, 4; segments 3–32, grid vertex rows/columns 2–32. Generated geometry must fit 2048 triangles and 8192 vertices.
caps, u_scale, v_scale, per_cell_uvTrue, 1, 1, false. Cylinder caps, sphere UV repetition (1–32), and grid UV mode.
material, material2, colorZero, zero, white. Zero selects the object's default material; grid material2 defaults to material. Debug cubes own six colored face materials.
nameOptional unique object name, fewer than 64 UTF-8 bytes.

Object properties are x/y/z, rx/ry/rz, scale_x/scale_y/scale_z (1/64–16), enabled, culling ("back", "front", "none"), blend ("replace", "add", "subtract", "scale", "average", "xor"), billboard, depth_test, depth_write, depth_bias (-128–127), precise_sort, fade_near/fade_far, appear_near/appear_far (0–16384), lod_bias (-8–8), lod_persist, and name. Positions and rotations default to zero, scales to one, culling to back, blend to replace, depth test/write and LOD persistence to true. Fade bands default to disabled (both zero); otherwise far must exceed near. Enabled defaults to true; billboard defaults to false except billboard primitives.

object_action() takes action plus optional x/y/z (default zero) and numerator/denominator (default one, 1–16384). Actions are "translate", "rotate", "look_at", "bake_rotation", "bake_scale", "cache_positions", "invalidate_positions", "clone", "bake_lighting", and "unbake". Bake scale uses the supplied ratio and retains object scale; bake rotation moves rotation into the vertices and resets it. Clones have independent geometry and default materials, and share named materials and LOD references. Position caching is optional and reports an allocation error if its main buffer cannot be created. Baking lighting stores flat directional and ambient lighting per triangle; unbake restores live lighting.

Camera properties are x/y/z, rx/ry/rz, fov, near (1–4095), and far (2–4096), with near less than far. camera_action() accepts action and x/y/z: "translate", "translate_local", "translate_local_y", "rotate", "rotate_local", "look_at", "look_at_x", or "look_at_y". Look-at values are target coordinates.

Point-light options are x/y/z, color (white), range_min (0), range_max (255), enabled (true), and a unique name. Range values are 0–16384 with minimum less than maximum. Scene settings are clear (true), lod_scale (0 disables distance LOD; otherwise 1–16384 design units), lod_bias (-8–8, default zero), and water_time (numeric animation time, 0–1000000). Objects used only as LOD alternatives should be disabled as standalone objects with object_set(alternative, enabled=False).

Complete examples are examples/python/jet_scene.py and examples/lua/jet_scene.lua.

Sprites and picking

Jet library 1.3 or later adds sprite(...), sprite_set(handle, ...), sprite_info(handle), pick(points), and picks(). Python uses keyword options and Lua uses options tables. capabilities() includes sprites, picking, max_sprites (32), and max_picks (32).

Sprites draw after 3D geometry, in ascending z_order, without depth testing. sprite() returns a handle; remove(sprite) unregisters it. An attached material cannot be removed until its sprites and geometry release it.

Sprite optionDefault and meaning
x, yZero; top-left screen pixel, -4096–4096.
width, height32; solid rectangle dimensions, 0–4096. Textured sprites use texture dimensions.
material, colorZero and white. Zero uses the sprite's own solid-color material; color changes this fallback.
alpha, enabled255 and true. Alpha multiplies material alpha.
z_orderZero, -32768–32767; equal orders retain their current relative order.
blend"replace" uses normal alpha compositing; "add" adds source channels with saturation. For additive sprites, nonzero alpha enables the full source contribution, so put falloff into the texture.
scaleOne; integer nearest-neighbor upscale, 1–16.
flip_x, flip_yFalse; reverse texture sampling.
mirror_x, mirror_yFalse; append reflected halves to double the texture extent along that axis. Flips apply to the expanded image.
nameOptional unique sprite name, fewer than 64 UTF-8 bytes.

pick(points) copies up to 32 (x,y) pairs in screen pixels. Coordinates must be inside the display, or -1 to disable a query. Empty sequence or Python None / Lua nil disables picking. New queries clear the previous results; call render() before picks().

picks() returns a list/table of result dictionaries/tables, one per query: hit, object (handle, zero on a miss), triangle, depth, x, and y. Python triangle indices start at zero and use -1 on a miss; Lua indices start at one and use zero on a miss. Results describe the last completed render and retain those handle values even if an object is subsequently removed. The index addresses the selected mesh, including any LOD alternative.

Picking tests filled triangle coverage. Sprites, wireframe outlines, texture color-key holes and per-pixel transparency do not participate. Overlapping triangles are compared using mean camera-space triangle depth rather than an exact ray intersection. Picking is disabled by default; leaving the query list empty skips all query iterations.

cursor = scene.sprite(x=width // 2 - 2, y=height // 2 - 2,
                      width=5, height=5, color=0xff0000)
scene.pick(((width // 2, height // 2),))
scene.render()
selection = scene.picks()[0]
if selection["hit"]:
    selected_object = selection["object"]

Complete examples are examples/python/jet_picking.py and examples/lua/jet_picking.lua.

Asset loading

Jet library 1.4 or later reports assets=True in capabilities() and adds three loaders. They accept file contents, copy retained data into owned storage, and leave the scene unchanged if validation or allocation fails. Read files with the language's normal file APIs; the provider does not open paths or fetch dependencies.

MethodInput and result
load_obj(data, scale=1, material=0, color=0xffffff, name="")OBJ text or bytes, up to 1 MiB; return an object handle. Python accepts keyword options; Lua takes an options table after the data string.
load_mtl(data)MTL text or bytes, up to 256 KiB; return material handles in declaration order. The complete material set is published together.
load_bmp(data, ...)BMP bytes, up to 8 MiB; return a texture handle. Accepts the texture sampling and name options. Format is always "rgb565"; pixels and palette options are not accepted.

OBJ loading supports v, vt, vn, usemtl, positive and negative face indices, and v, v/vt, v//vn, or v/vt/vn face elements. Convex polygons of up to 16 vertices are triangulated as a fan. Missing normals are generated per triangle; supplied normals are retained. An optional fourth position component is the homogeneous divisor. OBJ units are multiplied by 128 * scale, matching Jet's loader convention; scale is 1/1024–32 and final coordinates must fit -4096–4096. A file may define up to 1024 positions, 8192 combined UV/normal records, and 2048 generated triangles. Lines may contain at most 4096 bytes.

usemtl names must match materials already in the scene. Load MTL data first; mtllib, object/group names and smoothing declarations do not load files or split the object. Jet's mt HEX565 [alpha] extension creates an object-owned face material; up to 32 are allowed. md or mr restores the default material.

MTL loading supports newmtl, Kd, Ks, Ns, d, Tr, illum, and map_Kd. Diffuse color and opacity values are 0–1. Specular color is averaged into Jet's scalar specular coefficient; gloss is clamped to 255. illum 0 selects unlit shading, 1 selects Gouraud, and 2–10 select Phong. Other MTL properties are ignored. Texture-map names, including spaces, must exactly match textures loaded or created beforehand; map options are not supported. Material names must be unique and fit the normal 63-byte limit.

BMP loading accepts Windows DIB headers of at least 40 bytes, dimensions 1–1024, top-down or bottom-up rows, and uncompressed 1-, 4-, 8-, 16-, 24-, or 32-bit pixels. Paletted files are expanded to RGB565. Uncompressed 16-bit files use RGB555; 16-bit bit-field files support RGB555 and RGB565 masks. Standard BGR masks are also accepted for 32-bit bit-field files. RLE and other compression are rejected. The 32-bit alpha byte is ignored; use the texture's alpha_key option when color-key transparency is needed.

with open("/sdcard/assets/checker.bmp", "rb") as source:
    texture = scene.load_bmp(source.read(), name="checker.bmp")
with open("/sdcard/assets/model.mtl") as source:
    materials = scene.load_mtl(source.read())
with open("/sdcard/assets/model.obj") as source:
    model = scene.load_obj(source.read(), scale=0.7)

Self-contained examples that build a tiny BMP and import a textured OBJ cube are examples/python/jet_assets.py and examples/lua/jet_assets.lua.

Shared mesh instancing

Jet library 1.5 or later reports instancing=True. A prototype is an immutable copy of an object's local geometry. Its default and object-owned face materials are copied; named materials stay shared and remain editable. The source object can be changed or removed afterward. Its position, rotation, scale and LOD links are not part of the prototype; bake any wanted geometry changes first. Objects that already contain instances cannot become prototypes.

MethodContract
prototype(object, name="")Freeze local geometry and return a prototype handle. Python accepts the name keyword; Lua takes an options table.
prototype_info(prototype)Return id, name, vertex/triangle counts and six local-space bounds.
instance(prototype, ...)Create an object containing one instance; return its object handle.
instance_add(object, prototype, ...)Append an instance to an existing object; return its index. The object may also have its own geometry.
instance_set(object, index, ...)Partially update an instance's local pose and material overrides.
instance_info(object, index)Return local pose/options, prototype handle, per-face material handles, row-major 3×3 basis, and baked.
instance_remove(object, index), instance_clear(object)Remove one or all instances, preserving the object's own geometry. Later indices shift down after removal.
remove(prototype)Release a prototype after all of its instances and clones are removed.

Instance indices are zero-based in Python and one-based in Lua. Options are x/y/z, rx/ry/rz, material (all default zero), and materials (optional per-triangle material handles). Python uses keywords; Lua uses an options table. Zero material handles retain the prototype's original face materials. A nonzero uniform material takes precedence over the per-face table. A supplied table must match the prototype's triangle count; an empty sequence clears it. Material references reject removal while in use.

Instances share prototype geometry and inherit their containing object's transform, culling, fade, LOD and depth flags. Their local bounds must fit -4096–4096. A scene supports 16 prototypes and up to 32 instances per object; the object's own geometry plus all instances must fit 2048 triangles. capabilities() reports max_prototypes and max_instances. object_info() includes the instance count and total geometry counts.

clone copies the batch placements while retaining shared prototypes. bake_rotation moves the object's rotation into its geometry and instance placements. bake_scale creates shared scaled mesh copies within that batch; instance_info().baked then becomes true while prototype identifies the original prototype whose materials remain retained. Later partial instance updates preserve that baked geometry. Pose angles are rounded integer Euler angles; basis exposes the exact retained matrix. assign(object, material) updates both the object's own faces and all instance overrides, clearing per-face override tables.

Picking results additionally include mesh, batch, and instance. object identifies the owning scene object, batch identifies the object whose geometry was selected by LOD, and mesh is the actual object/prototype handle. instance uses the language's index base, or Python -1 / Lua 0 for ordinary geometry. For older providers these extra handles are zero and the instance index indicates ordinary geometry. Results retain the last completed frame's values even after resources are removed.

Complete examples are examples/python/jet_instances.py and examples/lua/jet_instances.lua.

Particles and lens flares

Jet library 1.6 or later reports particles and flares in capabilities(). Both use scene-owned resources released by remove() or close(). They are drawn after opaque geometry; sprites retain their normal overlay ordering. Neither particles nor flare sprites participate in picking. Flares reserve separate occlusion queries without replacing the script's queries.

step(dt) advances enabled particle pools, sets the next flare integration interval, and advances water animation time. Delta time is 0–1 seconds. Rendering does not advance particle physics by itself; flare integration defaults to 1/30 second until step() is called.

Particle methodContract
particles(...)Create a fixed pool of 200 particles and return its handle. Up to four pools may exist.
particles_set(handle, ...)Partially update pool options.
particles_emit(handle, ...)Emit sparks or water splash; return the number spawned. A full pool spawns fewer particles.
particles_info(handle)Return options, active, triangles drawn last frame, capacity, and current random_state.
particles_clear(handle)Clear particles without resetting the random state.

Pool options are world_scale (1/64–16, default one), additive (false), seed (1–4294967295, default one), enabled (true), and unique name. Each pool has an independent deterministic random generator. Setting its seed affects subsequent emissions. Disabled pools pause physics and skip drawing. Particles that expire or leave the -4096–4096 world-coordinate range are removed.

Emitter options are kind ("sparks" or "splash"), x/y/z origin, nx/ny/nz spark normal (default 0,1,0), speed (default 100, 0–4096), count (default 24, 0–200), and inherited velocity vx/vy/vz (default zero). Splash emitters also accept travel direction tx/ty/tz (0,0,1), side direction sx/sy/sz (1,0,0), and up direction ux/uy/uz (0,1,0). Origins and velocity components are numeric values in -4096–4096; normal/direction components are -1–1. Particle colors and lifetimes follow Jet's spark and splash profiles.

Flare methodContract
flare(elements, ...)Create a lens flare from 1–12 element option dictionaries/tables. Up to four flares may exist.
flare_set(handle, ...), flare_info(handle)Update or inspect flare options. Info adds last computed visibility, in_front, on_screen, projected_x/y, and element count.
flare_element_set(handle, index, ...), flare_element_info(handle, index)Update or inspect an element; Python indices start at zero and Lua indices at one.

Flare options are world-space light direction x/y/z (default 0,0,1), fade_speed (default 4, 0–1024 visibility units/second), enabled (true), and unique name. Direction components are -1–1 and the vector must be nonzero; it is normalized before use. Disabled flares hide their sprites and retain their last visibility state. Visibility describes geometry occlusion; sprite alpha also includes the screen-edge fade and element alpha.

Element options are material (zero hides the element), width/height (32, 1–1024), axis (0, -2–2), alpha (1, 0–1), blend ("add" or "replace"), scale (1, 1–16), flip_x/y, mirror_x/y (false), and z_order (100 plus element index by default, -32768–32767). Axis zero pins an element to the light; positive/negative values place it along the light-to-screen-center line. Textured elements use texture dimensions for drawing and width/height for placement. Referenced materials cannot be removed. Additive elements retain the sprite blend contract: zero alpha skips them; nonzero alpha adds the full source. Use replace blending when alpha fading is needed.

Python uses keyword options and Lua uses options tables. Capability limits are max_particle_systems, max_flares, and max_flare_elements. Complete examples are examples/python/jet_particles.py and examples/lua/jet_particles.lua.

Optional effects

Jet library 1.7 or later reports postfx=True. postfx(...) partially updates global effect options; Python uses keywords and Lua an options table. All effects default to disabled. Geometry keeps the direct RGB565 presentation path, and disabled effects allocate no image scratch buffers.

OptionDefault and meaning
fxaaFalse; five-tap luminance edge smoothing.
bloomFalse; thresholded, separable radius-three bloom. RGB565 channel sum above 75 seeds the blur.
crtZero disables; 1–255 darkens odd display rows, with 255 making them black.
motion_blurZero disables; 1–100 is the previous filtered frame's percentage. The first frame initializes history; 100 retains that frame.
chromaticZero disables; 1–32 shifts red and blue sampling in opposite horizontal directions, clamping at edges.
pixelateOne disables; 2–32 fills square blocks from their top-left sample.
cel_bitsZero disables; 1–8 discards low bits of scalar diffuse lighting in the existing raster pass. Ambient and gloss remain continuous.

Image effects run after geometry and particles, before all sprites, in this order: FXAA, bloom, CRT, pixelation, chromatic shift, motion blur. Cel shading acts during lighting. Picking continues to describe geometry before filtering. Filters read and write the configured RGB565 byte order within their work; presentation does not add a conversion pass.

postfx_info() returns settings, image scratch_bytes, and history_valid. postfx_reset() invalidates temporal history without changing settings. postfx_clear() disables all effects and frees their scratch buffers. Disabling every image effect also frees scratch, even if cel shading remains enabled. Newly enabling motion blur resets its history. Failed allocation retains the previous effect configuration.

Scratch is reused between frames: FXAA needs width * 8 bytes, bloom width * height * 4, motion blur width * height * 2, and chromatic shift width * 2. CRT, pixelation and cel shading need no image scratch. Buffers for individually disabled filters remain reusable until all image effects are disabled or postfx_clear() is called. These optional passes add render work; enable only the effects the scene needs.

Complete examples are examples/python/jet_effects.py and examples/lua/jet_effects.lua.

Tiled indexed textures

Jet library 1.8 or later reports tiled_textures=True. Existing texture constructors and setters accept three additional options:

OptionDefault and meaning
tiledFalse; true adds owned 32×32 indexed tile storage. Requires format="index8".
filter"nearest"; also "bilinear", "three_point", and "cached_bilinear". Controls tiled sampling; the ordinary bilinear option still applies to RGB textures.
mip_levelZero; 1–5 selects an owned nearest-downsampled mip at 1/2 through 1/32 dimensions. Tiled 3D sampling uses this mip; sprite images retain their original dimensions and pixels.

Bilinear and three-point filtering decode palette colors before interpolation. Cached bilinear precomputes the exact 1024×1024 integer-UV lookup into 2 MiB of RGB565 storage. It produces the same samples as bilinear filtering. Changes to pixels, palette, offset or mip rebuild this cache before publishing the update; rebuilding can temporarily require another cache's worth of memory. Uncached palette-offset animation does not allocate or copy image pixels.

Tile pages, mip data and caches are owned by the provider. The original row-major indices remain available for sprites. Texture handles and all material references stay stable across updates. Turning off tiled releases tile storage. texture_info() adds tile_bytes, filter_bytes, and sampler_width/height. load_bmp() produces RGB textures and does not accept tiled mode.

sample_texture(texture, u, v) samples any texture and returns its quantized RGB888 color. Coordinates are normalized numeric values in -32–32; wrap, clamp and zero addressing follow the texture settings.

Complete examples are examples/python/jet_tiles.py and examples/lua/jet_tiles.lua.

Procedural shaders

Jet 1.9 adds capabilities().shaders and up to 16 scene-owned shaders. Python uses shader(callback=None, **options) and shader_set(handle, callback=None, **options). Lua uses shader(callback, options) and shader_set(handle, callback, options); pass nil for a built-in shader. shader_info(handle) reports options, calls and failed for the last render. remove(handle) releases an unused shader; materials referring to it prevent removal.

OptionDefaultMeaning
kind"checker"checker, uv, depth, callback
color, secondarywhite, blackChecker colors in RGB888
scale8Checks per UV unit; screen-space cell size in pixels; depth shader white point in depth-buffer units; 1–4096
screen_spacefalseUse pixel X/Y instead of normalized UV coordinates
alpha_key-1Transparent RGB888 color, compared after RGB565 quantization; -1 disables
nameemptyResource name

Bind with material(shader=handle, shading="unlit") in Python or material{shader=handle, shading="unlit"} in Lua. Shaders override texture sampling; the material's lighting, alpha and blend behavior still apply. Use shader=0 to detach. Shader materials support filled 3D geometry, including shared instances and LOD. Wireframe, water reflection, HUD sprites and flare elements reject shader materials. Built-in uv visualizes wrapped U/V as red/green; depth maps depth linearly to grayscale.

Passing a function selects kind="callback". It receives (u, v, params) and returns an integer RGB888 color. UV coordinates are normalized, can extend outside 0–1, and use the material's perspective setting. With screen_space, u and v are pixel coordinates. params contains x, y, depth, frame (starting at zero), local and world. The last two are integer XYZ tuples/tables reconstructed on the source face, including transforms and near/far clipping. Lua accesses params["local"] because local is a keyword.

Callbacks run synchronously for visible fragments after coverage and depth tests. Their functions stay alive with the scene, including through garbage collection; close/remove releases them. Calling Jet from a callback raises an error. A callback exception, invalid result, allocation failure or attempted Lua yield rejects that render without presenting its partial frame. The original script error is raised after the renderer returns. A later render can recover after replacing the callback. Set kind to a built-in to release a callback; omitting it in a partial update preserves the current kind.

Built-in shaders execute natively. Script callbacks allocate a parameter dictionary/table per fragment and can be much slower; use small surfaces or textures for complex patterns. See examples/python/jet_shaders.py and examples/lua/jet_shaders.lua.

Renderer controls

Jet 1.10 reports capabilities().renderer_controls. The existing settings() method additionally accepts texture_lod (false), texture_near (0), texture_far (0), and wireframe (false). settings_info() reports them. Distances are camera-space Z values in 0–16384; enabling texture LOD requires texture_far > texture_near. Invalid updates preserve all previous settings.

With texture LOD enabled, textures retain full detail through texture_near, fade toward the material's base color up to texture_far, then use that color alone. It does not fade procedural shaders. This is independent of mesh LOD. Global wireframe draws every object's outlines against a black background, skipping lighting, textures, shaders and depth. Turn it off to restore material shading; use clear=True for a clean transition. Examples are examples/python/jet_controls.py and examples/lua/jet_controls.lua.

Presentation and memory

Jet library 1.11 adds settings(background_cache=True) (Lua: scene:settings{background_cache=true}). It captures the next successful geometry frame's color and depth before particles, post-processing and sprites. Keep only the static background geometry visible for that frame, render once, then disable or remove it. Later frames restore the cached pixels and depth before drawing moving objects, preserving occlusion without rendering the static geometry again. capabilities() reports background_cache support.

The cache requires clear=True and wireframe=False. It retains a fixed view; camera, lighting, material and geometry changes do not update it automatically. Set background_cache=False to release it, then capture again for a new view. A failed or cancelled capture preserves the previous cache. Captured geometry does not participate in picking after it is disabled or removed.

The cache uses an additional width × height × 4 bytes of PSRAM. Recapture temporarily holds both the old cache and a new pair of buffers. Scenes release all cache storage on close.

Rendering uses the display's full dimensions. Width must be a multiple of eight; both dimensions must be at most 1024 pixels. The host owns three RGB565 color buffers and one depth buffer, each width × height × 2 bytes of PSRAM, in addition to loaded code, geometry and temporary render queues.

RGB565 targets receive wire-order pixels directly. MONO1 targets use luminance and 4×4 ordered dithering, with one additional ceil(width / 8) × height byte buffer. Terminal palette changes do not invert the scene. Transport may still rotate, scale or DMA-stage frames. E-paper refresh policy and settling determine visible motion independently of successful presentation calls.

Scripts provide asset contents to the loaders; the provider does not open paths. Indexed-only display output is not supported. The provider uses one fixed full-width, full-frame renderer profile. Scripts control scene behavior and optional effects; buffer layout, interlace, checkerboard reconstruction, overdraw diagnostics and parallel raster workers are build-time choices. Tiled storage, mip selection and filter caches are managed resources; upstream worker feedback/residency pointers are not script objects. Display size follows the selected target; close and reopen the scene to change targets. Native consumers can use the C provider table in solar_os_jet_abi.h through the resident library service.

Quick reference

OperationPython / Lua
Install providerpkg install jet
Open scenesolaros.jet.open(display, fps); defaults None/nil, 0
Geometryprimitive(...); mesh_data(vertices,triangles,material); mesh_update(object,...)
Surfaces and assetsmaterial(...); texture(...); load_obj(data,...); load_mtl(data); load_bmp(data,...)
Update viewcamera(x,y,z,rx,ry,rz,fov); light(ambient,color,intensity,azimuth,elevation)
Update objecttransform(object,x,y,z,rx,ry,rz); remove(object)
Shared geometryprototype(object,...); instance(prototype,...); instance_set(object,index,...)
Overlays and selectionsprite(...); pick(points); picks()
Animation and effectsparticles(...); flare(elements,...); step(dt); postfx(...)
Procedural surfacesshader(...); shader_set(handle,...); shader_info(handle)
Draw and inspectrender(); status()
Releaseclose(), Python with, Lua <close>
LimitsOne scene, 32 objects, 1024 input vertices and 2048 triangles per mesh; RGB888 colors; design coordinates -4096..4096

SolarOS documentation

Motor control

The motor service exposes named motor endpoints through the shell, Python, Lua, and C. Drivers register endpoints with this service, so applications use the same API across controllers. The current hardware backend is DRV8830.

Select the motor package group for the common service and the drv8830 group for that controller. Selecting a driver also includes its required service. Both groups are included in the full flavor where supported. motor list reports each endpoint's driver and drive, brake, and clear capabilities. Optional operations return an unsupported-operation error when the provider does not implement them.

Quick reference

An active native consumer can claim a motor output exclusively. While claimed, ordinary drive, coast, brake and clear commands and device detach are rejected. Status remains available. The owning consumer must stop and release the output; failed cleanup retains the claim for retry. See Robot axes.

expansion attach drv8830 motor0 i2c=i2c0 addr=0x60 max_mv=3000
motor list
motor status motor0
motor drive motor0 0.5
motor drive motor0 -0.5
motor coast motor0
motor brake motor0
motor clear motor0
expansion detach motor0

Normalized control

drive takes a finite value from -1.0 through 1.0. The sign selects direction; the magnitude requests a fraction of the backend's configured full-scale output. Zero coasts. This value is a control demand, not a measured speed, torque, voltage, or universal PWM duty cycle. Each driver maps it to its hardware and reports the quantized applied command when available.

Values outside the range, NaN, and infinity are rejected. brake requests active braking when supported. coast releases the bridge output. clear is an explicit fault-recovery operation that leaves the motor coasting. Commands remain active after the shell or API call returns. Neither coast nor brake switches off the motor supply.

Parameters and command stream

Attachment publishes the exclusive scalar sink mot.<name> and parameters mot.<name>.polarity, .max_drive and .brake. Configure these through motor configure <name> <key> <value> or the parameter service while unclaimed. Defaults are polarity +1, maximum drive 1 and coast on stream close. Configuration changes first coast the motor; a failed stop leaves configuration unchanged. Polarity maps logical drive to physical direction. Maximum drive limits accepted demand before backend quantization.

The sink accepts normalized signed drive, not velocity. Opening it claims the motor and locks configuration until a successful close. Direct commands and competing opens are rejected while claimed. Stream zero/close requests use the configured coast/brake policy; raw motor drive ... 0 continues to mean coast. Scalar payloads are native float; OS feedback controllers also supply a local monotonic deadline, checked after status acquisition and before actuation. See Feedback controllers for binding and tuning.

Python and Lua

Both runtimes expose solaros.motor when the motor service is compiled:

  • list(): return motor metadata records.
  • status(name): read one named motor's state and diagnostics.
  • drive(name, value): apply a normalized signed command.
  • coast(name): release the motor output.
  • brake(name): request active braking, when supported.
  • configure(name, key, value): set service polarity, maximum drive or stop policy.
  • clear(name): coast and clear driver-supported fault latches.

Names are required, including when only one motor is present. Successful control calls return no value; service errors raise runtime errors.

Metadata records contain name, driver, can_drive, can_brake, and can_clear. Status records also contain:

FieldMeaning
modeunknown, coast, forward, reverse, or brake.
drive_valid, driveWhether the applied normalized command is known, and its value. This is not measured shaft motion.
faultsNormalized present or recorded fault conditions.
blocking_faultsConditions that currently prevent drive. Recorded history can remain after recovery.
fault_input_present, fault_input_assertedAvailability and sampled assertion state of an optional hardware fault input.
voltage_valid, voltage_mvOptional nominal voltage-setting diagnostic, in millivolts. This is not a measured output voltage.
raw_valid, raw_control, raw_faultOptional driver-specific register diagnostics. Their bit layout is not part of the common motor contract.

Both modules export normalized fault-mask constants FAULT_UNKNOWN, FAULT_OVERCURRENT, FAULT_UNDERVOLTAGE, FAULT_OVERTEMPERATURE, and FAULT_CURRENT_LIMIT. Test these against faults or blocking_faults; do not interpret driver register bits as common fault masks.

Python, after attachment:

import solaros

print(solaros.motor.list())
try:
    solaros.motor.drive("motor0", 0.5)
    print(solaros.motor.status("motor0"))
finally:
    solaros.motor.coast("motor0")

Lua, after attachment:

local ok, err = pcall(function()
    solaros.motor.drive("motor0", -0.5)
    local state = solaros.motor.status("motor0")
    print(state.mode, state.drive, state.blocking_faults)
end)
solaros.motor.coast("motor0")
if not ok then error(err) end

DRV8830 backend

One controller provides one motor endpoint. Multiple controllers can share a named I2C bus at distinct seven-bit addresses, 0x60 through 0x68. Connect SDA/SCL to that bus, share ground, and supply the controller and motor according to their ratings. The bus must run at 400 kHz or less.

Optional max_mv=482..5060 sets the backend's full-scale voltage limit; the default is 5060 mV. It is rounded down to a supported VSET code. A normalized magnitude of one selects that code. Other magnitudes select the nearest fraction of that code. Codes below the minimum valid VSET code 6 map to coast. With the default full scale, commands whose magnitude is below approximately 0.0873 coast; the first nonzero applied command is 6/63, approximately 0.0952. Quantization and this deadband are backend-specific.

The chip internally regulates the average voltage across the motor winding. If the target exceeds the available supply, it saturates at full duty. SolarOS reads the programmed setting, not a motor or supply-voltage measurement. Current limiting is configured by the external sense resistor.

FAULTn is optional. Add int=<available-native-gpio> when wired; this is an active-low, open-drain signal. Use a pull-up compatible with the ESP32 input; the driver also enables the native input pull-up. SolarOS claims the address and optional GPIO; the named bus owns SDA/SCL. The fault register also works without the interrupt wire. The input is sampled on status and drive calls; there is no asynchronous callback.

Python and Lua attachment dictionaries/tables accept i2c, addr, optional integer int, and optional integer max_mv. For example, Python can attach with:

solaros.expansion.attach("drv8830", "motor0",
                        {"i2c": "i2c0", "addr": 0x60, "max_mv": 3000})

Attach and successful detach coast the bridge. Reversal passes through standby. Active FAULT, a latched overcurrent or extended-current-limit condition, or an asserted fault input blocks drive. Recovered undervoltage and overtemperature history remains visible without requiring clear. A failed control write attempts to coast. A communication failure cannot guarantee a physical stop. Failed detach retains the service registration and expansion resource claims for retry.

Electrical behavior and register diagnostics follow the TI DRV8830 datasheet.

C providers and consumers

Applications include solar_os_motor.h and use solar_os_motor_get(), solar_os_motor_status(), solar_os_motor_drive(), solar_os_motor_coast(), solar_os_motor_brake(), and solar_os_motor_clear().

Drivers register one endpoint per motor channel using solar_os_motor_register(). Status and coast callbacks are required; normalized drive, brake, clear, and shutdown callbacks are optional. Callbacks are serialized per endpoint and execute outside the registry lock. Providers own their hardware mapping, initial coast state, and fault policy. Successful unregister stops the motor before allowing the driver to release its context. In-flight operations and failed shutdown prevent unregister and preserve the endpoint for retry.

SolarOS documentation

Encoder counts

The encoder service exposes named counters through the shell, Python, Lua, and C. The pcnt-encoder expansion driver uses the ESP32 pulse-counter peripheral to count A/B signals in hardware. Multiple encoders can run independently, subject to available GPIOs and PCNT units.

Select the encoder package group for the common service and pcnt_encoder for the hardware driver. Selecting the driver also includes the service. Both are included in the full flavor on supported boards. The provider supports ESP32 and ESP32-S3; it requires no Arduino or external encoder library.

Quick reference

Use available native GPIOs for A and B. These example pins must be checked against the board's pin table and current resource claims:

expansion attach pcnt-encoder left a=1 b=2 mode=4
expansion attach pcnt-encoder right a=3 b=4 mode=2 invert=1
encoder list
encoder status left
encoder read left
encoder reset left
expansion detach left
expansion detach right

Counting modes

mode is required at attachment. There is no implicit counting mode.

ArgumentCounting behavior
mode=1x1: count rising edges of A; B determines direction.
mode=2x2, half quadrature: count both edges of A; B determines direction.
mode=4x4, full quadrature: count both edges of A and B.

All modes require both A and B. For a complete cycle starting at A=B=0, the sequence A rises, B rises, A falls, B falls produces a positive count. The reverse sequence produces a negative count. invert=1 reverses the sign. One signal cycle produces 1, 2, or 4 counts according to the selected mode; counts are not automatically converted into detents, revolutions, or distance. Changing mode requires detaching and attaching again.

The current PCNT provider returns signed 32-bit accumulated counts. ESP-IDF compensates for the 16-bit hardware counter rolling over at its configured limits. Keep the total between -2147483648 and 2147483647; reset before exceeding that range. Reading does not clear the count. Attachment starts from zero; reset stops, clears, and restarts the counter, so edges during that brief interval are not retained.

Wiring and options

Connect A/B to distinct, available native GPIOs and share ground. Input levels must be compatible with the ESP32. GPIO-expander lines cannot serve as PCNT inputs. Both GPIOs are exclusively claimed for the attachment.

Optional argumentMeaning and default
invert=0|1Reverse counting direction when 1; default 0.
pull=0|1|2Internal pulls: 0 none, 1 up, 2 down; default 0. Pin support depends on the chip.
filter_ns=<integer>Reject pulses shorter than this duration in nanoseconds; default 1000. Zero disables the filter. Values beyond hardware support are rejected.

Choose a filter shorter than the valid signal pulses at the highest expected speed. The hardware glitch filter does not replace suitable electrical debouncing for mechanical contacts. The existing rotary-encoder driver provides debounced knob navigation events instead of this raw count API.

Each attachment uses one PCNT unit: x1/x2 use one channel, x4 uses two channels within that unit. Resource limits come from the chip; exhausting the pool returns an error. Detach stops counting, releases the PCNT unit, and resets the pins before releasing their claims. Failed detach retains the endpoint and claims for retry; if teardown partially completed, reads can return a not-ready error. Counting across explicit sleep is not guaranteed.

Python and Lua

Both runtimes expose solaros.encoder when the common service is compiled:

CallResult
list()Metadata records containing name, driver, integer mode, and can_reset.
status(name)The same fields plus signed integer count.
read(name)The signed count.
reset(name)Set count to zero when supported; no return value.

Names are always required. Service errors raise runtime errors. The constants MODE_X1, MODE_X2, and MODE_X4 equal 1, 2, and 4. All providers use this API; a provider without reset support reports can_reset=false and rejects reset requests.

Python:

import solaros

solaros.expansion.attach("pcnt-encoder", "left",
                        {"a": 1, "b": 2, "mode": solaros.encoder.MODE_X4})
print(solaros.encoder.status("left"))
print(solaros.encoder.read("left"))
solaros.encoder.reset("left")
solaros.expansion.detach("left")

Lua:

solaros.expansion.attach("pcnt-encoder", "left", {
    a = 1, b = 2, mode = solaros.encoder.MODE_X4,
    invert = 0, pull = 0, filter_ns = 1000,
})
print(solaros.encoder.read("left"))
solaros.encoder.reset("left")
solaros.expansion.detach("left")

An encoder measures motion independently of the motor service. Attaching one does not enable automatic speed or position control.

Calibrated feedback

Attachment publishes enc.<name>, a timestamped scalar source in rad/s, and parameters enc.<name>.ppr, .gear_ratio, .polarity, .encoder_on_output, .velocity_tau and .position_zero. Use encoder configure <name> <key> <value> or the parameter service to configure them while unclaimed. PPR defaults to zero (uncalibrated), gear ratio to 1, polarity to +1, velocity filter time constant to 0.1 s, and output-shaft placement to false.

A positive PPR is required for velocity control. Counts per output revolution are derived from PPR, the actual driver decoding mode, and gear ratio for a motor-shaft encoder. The same service accumulates signed count wrap into relative position and filters velocity using acquisition intervals. Position starts at position_zero when the stream opens; it is not an absolute reference.

The source permits one open handle, retains its acquisition history while open, and prevents calibration changes, reset and detach until close. Raw reads and encoder status remain available. With positive PPR, status reports fresh calibrated feedback even when no controller or stream reader is running. An idle query measures velocity over a 20 ms window; its first query is valid without an earlier status call. Position accumulates from the first observation after configuration/reset, or from the current stream reference. Raw read remains an immediate count acquisition. Without calibration, feedback remains invalid.

When a stream is active, status previews the current count using the owner's estimator history without changing its filter, position reference or acquisition timestamp. The returned status has its own fresh acquisition time.

Use stream echo enc.<name> [--once] [--rate 1..100] to inspect the velocity source. An idle echo opens the calibrated source and maintains its filter history until it stops. Alongside a controller, echo observes the controller's existing timestamped samples without taking its claim or advancing its estimator. See Feedback controllers for a standalone encoded-motor loop and tuning example.

C providers

Consumers include solar_os_encoder.h and use solar_os_encoder_get(), solar_os_encoder_status(), solar_os_encoder_read(), and solar_os_encoder_reset(). solar_os_encoder_configure() configures the service calibration. Providers register a name, driver, counting mode, context, and read callback. Reset and shutdown callbacks are optional.

Native reference claims prevent reset and detach while allowing shared reads. Claimed sampling returns a fresh count, the x1/x2/x4 decoding factor and a monotonic acquisition timestamp. Release the claim after dependent control has stopped. Tokens are invalid after release. See Robot axes. Callbacks are serialized per endpoint outside the registry lock. In-flight operations prevent unregister; failed shutdown keeps the registration for retry. The provider owns its context until unregister succeeds.

The configure(name, key, value) function is also available in Python and Lua.