SolarOS

ESP32 operating environment · 4.16.18

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
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   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

Display Link Protocol (DLP)

Connect unusual displays through a peripheral MCU that handles the panel. SolarOS discovers resolution and pixel format over SPI, with optional READY and RESET lines. Retained-frame updates send changed regions and use RLE compression when supported; the reusable protocol also supports ordered byte streams.

Connect a DLP display →
02

PCA9685 PWM expansion

Attach a PCA9685 controller to a named I2C bus for 16 PWM outputs. Address channels by device name from the shell, Python, or Lua for general PWM or calibrated servo control. All channels on one controller share a frequency; the driver is included in full builds with expansion I2C support.

Connect a PCA9685 controller →
03

Calibrated servo control

Control positional servos from the shell, Python, or Lua with explicit angle and pulse limits, optional inversion, and named native or expansion PWM outputs. Opening reserves an output without pulses; disable pauses the signal and close releases it. Scripts automatically close their servos on exit.

Control servo outputs →
04

Elecrow ThinkNode M9 support

Run SolarOS on the ThinkNode M9 with a 320x240 color display, backlit QWERTY keyboard, microSD, RTC, GNSS, battery monitoring, and a buzzer. Use the keyboard compose layer for shell symbols and scrollback navigation, with Wi-Fi and BLE on ESP32-S3 with 8 MB PSRAM. The onboard LR1110 radio is not yet supported.

Explore ThinkNode M9 support →
05

BLE HID host management

Manage saved Bluetooth HID hosts from Python and Lua. List paired hosts, offer a connection to a selected host, discover host names when available, and explicitly pair or forget hosts while preserving the local input keyboard pairing.

Manage BLE HID hosts →
06

Tab5 keyboard support

Attach the M5Stack Tab5 70-key keyboard through a named I2C bus, with an optional interrupt. Use key repeat, held Shift, Ctrl and Alt, the printed Sym layer, and runtime JSON keymaps on ESP32 and ESP32-S3 builds with the driver enabled.

Connect a Tab5 keyboard →
07

Generic TCA8418 keyboard

Build your own I2C matrix keyboard with configurable dimensions up to 8 rows and 10 columns. Create custom keymaps with the desktop tool, load them at runtime, or compile the same JSON into firmware defaults.

Define your own keyboard →
08

Model Zoo browser

Browse and install model bundles on SD or flash storage from the Zoo text interface. On ESP32-S3 with PSRAM, inspect model requirements, download with hash verification, and use the installed bundle path in shell commands or scripts.

Explore Model Zoo →

SolarOS documentation

Display Link Protocol over SPI

spi-display attaches a Display Link Protocol (DLP) peripheral under the supplied display name. The peripheral owns its physical panel. SolarOS reads its native dimensions and accepted pixel formats, selects a transfer format, and registers a display target for text, graphics, and foreground applications. A receive-capable SPI bus with MISO is required. The spi_display firmware group supplies the driver and is selected by the full flavor on compatible boards.

expansion attach spi-display lcd1 spi=<bus> cs=<allowed-cs-gpio>
expansion attach spi-display lcd1 spi=<bus> cs=<allowed-cs-gpio> ready=<allowed-input-gpio> reset=<allowed-output-gpio>

READY and RESET are independently optional. Without READY, transfers use polling and the peripheral's minimum spacing. RESET targets the peripheral controller, and defaults to active low with a 10 ms pulse and a 5 s boot deadline. Optional parameters are clock=<kHz>, reset-ms=<ms>, boot-ms=<ms>, and reset-active=0|1. The clock ceiling defaults to 10000 kHz and is limited by the peripheral's reported maximum. Width and height are discovered rather than supplied as bindings.

The host accepts dimensions up to 2040 pixels per axis and 1,048,576 total pixels, subject to available memory. It keeps a monochrome text buffer, an RGB565 composition buffer, and one conversion row. When memory permits, two additional native-format buffers (each at most 256 KiB) track changes. The driver uploads changed rectangles, uses optional byte RLE when useful, and checks retained state before skipping an unchanged frame. It sends a full replacement after content loss or when partial updates cost more. Larger images and cache allocation failures use bounded full-frame uploads. Color and indexed graphics are converted to the negotiated format. Changing geometry requires detaching and recreating the target after releasing display claims.

The DLP SPI profile retains SDLP v1 mode 1 and 256-byte full-duplex cells. The portable protocol also supports ordered byte streams; this driver uses SPI. Its negotiated format describes wire pixels; it does not require any particular MCU or panel bus. See the driver reference for bindings.

SolarOS documentation

PCA9685 expansion controller

The optional pca9685 package group adds an I2C controller with 16 PWM outputs. It is enabled in full on boards with expansion I2C. The expansion device name becomes the PWM provider name, and output numbers run from 0 through 15. With an existing i2c0 bus and a controller at address 0x40:

expansion attach pca9685 servos0 i2c=i2c0 addr=0x40
pwm providers
servo open arm servos0:0 1000 2000 -45 45
servo set arm 0
servo close arm
expansion detach servos0

pwm set and pwm pulse also accept these outputs. Python and Lua use the same names through solaros.pwm and solaros.servo. Attachment initializes every channel to full-off; opening a servo does not start pulses. Closing every servo and stopping every general PWM output permits detach. Disabled servos still reserve their channels and the shared frequency.

All 16 channels share one prescaler. A frequency change is rejected while another channel is active or reserved, unless both requests select the same prescaler. Different PCA9685 devices have independent frequencies. With the nominal 25 MHz internal oscillator, requests from 24 through 1526 Hz are supported; the servo abstraction additionally limits requests to 1000 Hz. The hardware has 12-bit resolution, approximately 4.88 microseconds per tick at a requested 50 Hz. PWM status reports quantized timing.

Optional oe=<gpio-or-controller-line> controls the active-low output-enable input. The driver holds it high during initialization and low after all outputs are off. Detach raises it before shutting down the chip. Without this binding, wire OE low. osc_hz=<20000000..30000000> changes the timing calculation to match a measured internal oscillator frequency; the default is 25000000. External-clock mode is not supported. Use an individual 7-bit address from 0x40 through 0x77, excluding the shared All Call address 0x70.

Failed transfers attempt to restore the previous frequency and output values. If restoration also fails, further commands are rejected until outputs are closed and the controller is detached and reattached. Disable/close remain available for cleanup. If detach cannot complete its hardware shutdown, its context and expansion resources remain held; retry detach after restoring I2C.

The controller supplies control signals. Supply servo power from a suitable rail and connect its ground to the controller and MCU ground.

SolarOS documentation

Servo outputs

The servo service binds calibrated positional servos to named PWM outputs. It is included in the full flavor and can be selected through the servo package group in other flavors. Use pwm providers to discover controllers and pwm status to discover their outputs.

Outputs use provider:number. The built-in ESP32 provider is native; its output numbers are runtime-safe GPIO numbers, so native:1 selects GPIO1. The pwm group includes this backend on boards with PWM and GPIO support. Expansion drivers can register additional PWM providers. Their output numbers identify controller channels rather than ESP32 pins. A bare GPIO number is accepted as shorthand for a native output.

Each named servo has explicit pulse and angle endpoints. Choose these values from the servo's specification and the mounted mechanism's usable range. Angles are in degrees, pulse widths are in microseconds, and frequency is in hertz. The default frequency is 50 Hz; supported configuration frequencies are 1 through 1000 Hz, subject to the hardware timer and servo's requirements. Pulse endpoints must be positive, ordered, and shorter than the PWM period. Angle endpoints must be finite and ordered. Commands outside either calibrated range are rejected.

Shell

servo list
servo status [name]
servo open <name> <output> <min-us> <max-us> <min-deg> <max-deg> [freq-hz [inverted:0|1]]
servo set <name> <angle-deg>
servo pulse <name> <pulse-us>
servo disable <name>
servo close <name>

servo list (or bare servo) shows one line per open servo: its name, output, enabled state, and owner. servo status adds calibration, frequency, inversion, and the last accepted command for all open servos. servo status <name> shows those details for one servo. Tab completes open servo names after set, status, pulse, disable, and close.

Names contain 1 through 15 letters, digits, underscores, or hyphens. The following example assumes an available GPIO1 and a servo calibrated for 1000 through 2000 microseconds over -45 through 45 degrees:

servo open arm native:1 1000 2000 -45 45
servo set arm 0
servo status arm
servo disable arm
servo close arm

open reserves the output with no pulses. The first set or pulse command starts output. set linearly maps the angle endpoints to the pulse endpoints; inversion reverses this mapping. pulse commands a pulse within the same calibrated limits.

disable stops pulses while keeping the pin and timer reservation. Another set or pulse resumes output. close stops pulses and releases the output. These operations control the signal; they do not switch the servo's power rail. Shell-opened servos remain reserved until explicitly closed or the OS restarts.

Python and Lua

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

  • list(): return all open servo records.
  • status(name): return one record; fail if the name is not open.
  • open(name, output, min_us, max_us, min_deg, max_deg[, freq_hz[, inverted]]): reserve a calibrated output. Frequency defaults to 50; inversion defaults to false. Use a boolean for inversion.
  • set(name, angle_deg): command an angle within the calibrated range.
  • pulse(name, pulse_us): command an integer pulse width within that range.
  • disable(name): stop pulses and retain the reservation.
  • close(name): stop pulses and release the reservation.

Records contain name, owner, output, freq_hz, pulse_min_us, pulse_max_us, angle_min_deg, angle_max_deg, inverted, enabled, has_command, commanded_pulse_us, and commanded_angle_deg. Command fields are meaningful after has_command becomes true. They describe the last accepted command and remain available while disabled. pwm status reports the quantized hardware pulse width separately. Neither interface measures the servo's physical position.

Python example using the calibration assumed above:

import solaros

solaros.servo.open("arm", "native:1", 1000, 2000, -45, 45)
try:
    solaros.servo.set("arm", 0)
    print(solaros.servo.status("arm"))
finally:
    solaros.servo.close("arm")

Lua uses the same argument order:

solaros.servo.open("arm", "native:1", 1000, 2000, -45, 45)
solaros.servo.set("arm", 0)
solaros.servo.close("arm")

Each output belongs to the shell, Python, or Lua runtime that opened it. Other runtimes can inspect it but cannot command or close it. Python and Lua close their servo outputs when the interpreter exits, including after an error or interruption. A hardware close failure is logged and retains the reservation for retry by the owning runtime.

PCA9685 expansion controller

The optional pca9685 package group adds an I2C controller with 16 PWM outputs. It is enabled in full on boards with expansion I2C. The expansion device name becomes the PWM provider name, and output numbers run from 0 through 15. With an existing i2c0 bus and a controller at address 0x40:

expansion attach pca9685 servos0 i2c=i2c0 addr=0x40
pwm providers
servo open arm servos0:0 1000 2000 -45 45
servo set arm 0
servo close arm
expansion detach servos0

pwm set and pwm pulse also accept these outputs. Python and Lua use the same names through solaros.pwm and solaros.servo. Attachment initializes every channel to full-off; opening a servo does not start pulses. Closing every servo and stopping every general PWM output permits detach. Disabled servos still reserve their channels and the shared frequency.

All 16 channels share one prescaler. A frequency change is rejected while another channel is active or reserved, unless both requests select the same prescaler. Different PCA9685 devices have independent frequencies. With the nominal 25 MHz internal oscillator, requests from 24 through 1526 Hz are supported; the servo abstraction additionally limits requests to 1000 Hz. The hardware has 12-bit resolution, approximately 4.88 microseconds per tick at a requested 50 Hz. PWM status reports quantized timing.

Optional oe=<gpio-or-controller-line> controls the active-low output-enable input. The driver holds it high during initialization and low after all outputs are off. Detach raises it before shutting down the chip. Without this binding, wire OE low. osc_hz=<20000000..30000000> changes the timing calculation to match a measured internal oscillator frequency; the default is 25000000. External-clock mode is not supported. Use an individual 7-bit address from 0x40 through 0x77, excluding the shared All Call address 0x70.

Failed transfers attempt to restore the previous frequency and output values. If restoration also fails, further commands are rejected until outputs are closed and the controller is detached and reattached. Disable/close remain available for cleanup. If detach cannot complete its hardware shutdown, its context and expansion resources remain held; retry detach after restoring I2C.

The controller supplies control signals. Supply servo power from a suitable rail and connect its ground to the controller and MCU ground.

PWM resources

The service has a bounded pool of 16 named servos, independent of the backend's channel capacity. PWM supports four registered providers and 32 active or reserved outputs across those providers. Limits do not preallocate servo objects for every controller channel.

Each provider manages its shared frequency groups. Outputs in the same group must use compatible frequencies, including while disabled. A conflicting request is rejected. Independent providers can use different frequencies. An expansion provider cannot be detached while any output is active, reserved, or executing a command. Close servo objects and stop general PWM outputs first.

The native backend has four hardware channels shared with general PWM outputs and board peripherals using the same driver. They share one timer. A board peripheral already using that timer at another frequency can prevent opening a native servo.

Pulse commands update hardware duty without creating a background task. Hardware PWM continues independently of script scheduling. Commands to an external controller may require a bus transfer. Calls to one provider are serialized; a transfer to one provider does not hold the global registry lock or prevent commands to another servo on an independent provider.

Native timer resolution is 14 bits at servo frequencies: at 50 Hz, one tick is approximately 1.22 microseconds. Other providers report their own resolution and frequency group in pwm status. Pulse widths are rounded to hardware ticks.

See GPIO and analog APIs, Python hardware API, and Lua hardware API.

Quick reference

Use servo open name output min-us max-us min-deg max-deg [freq-hz [inverted]] with explicit calibration and a named PWM output such as native:1. Open reserves without pulses; set and pulse start output, disable retains the reservation, close releases it. Python and Lua expose the same operations under solaros.servo, plus list and status, and close their outputs on interpreter exit. Status describes commanded position. Providers enforce their own shared-frequency constraints. The service supports 16 servos; native PWM has four hardware channels.

SolarOS documentation

Built-In Targets

The current tree includes these board targets:

TargetPlatformIO envHardwareHighlights
solar_termsolar_termSolarTerm, built from the Waveshare ESP32-S3-RLCD-4.2Primary ST7305 reflective display target with SDMMC, CDC, UART, RTC, SHTC3, battery ADC, ES8311/ES7210 audio, expansion I2C/SPI/UART/GPIO/ADC/PWM, and runtime-routable SPI3 on GPIO1/GPIO2/GPIO3/GPIO17. SolarOS refers to this hardware configuration as SolarTerm.
solar_writersolar_writerSolarWriter, built from the Elecrow CrowPanel 5.79-inch792x272 dual-SSD1683 display, INMP441 microphone, shared PCM5102 line output and MAX98357 speaker output, LC709203F fuel gauge, DS3231 RTC, microSD, CH340/UART console, and GPIO21 reserved for jack detect.
freenove_esp32_s3_display_4_0freenove_esp32_s3_display_4_0Freenove ESP32-S3 Display 4.0-inch (FNK0104S)Integrated 480x320 ST7796 display, FT6336 capacitive pointer, ES8311 speaker and microphone, four-bit SDMMC, battery ADC, native USB CDC, and UART/I2C/GPIO expansion connectors.
qdtech_es3c28pqdtech_es3c28pQDtech ES3C28P 2.8-inch ESP32-S3 Touch DisplayCapacitive-touch ESP32-S3R8 target with 16 MB flash, 8 MB PSRAM, a 320x240 landscape ILI9341V display, FT6336 pointer, ES8311 speaker and microphone, four-bit SDMMC, battery ADC, one WS2812B, native USB CDC, and UART/I2C/GPIO expansion connectors.
qdtech_es3n28pqdtech_es3n28pQDtech ES3N28P 2.8-inch ESP32-S3 DisplayNon-touch ESP32-S3R8 target with 16 MB flash, 8 MB PSRAM, a 320x240 landscape ILI9341V display, ES8311 speaker and microphone, four-bit SDMMC, battery ADC, one WS2812B, native USB CDC, and UART/I2C/GPIO expansion connectors.
elecrow_crowpanel_esp32_s3_4_2_epaperelecrow_crowpanel_esp32_s3_4_2_epaperElecrow CrowPanel ESP32-S3 4.2-inch E-paperESP32-S3-WROOM-1-N8R8 target with a 400x300 SSD1683 e-paper display, microSD over SDSPI, CH340C/UART console, rotary/menu/exit controls, status LED, Wi-Fi, BLE, and expansion I2C/SPI/UART/1-Wire/GPIO/ADC/PWM.
elecrow_crowpanel_esp32_s3_5_79_epaperelecrow_crowpanel_esp32_s3_5_79_epaperElecrow CrowPanel ESP32-S3 5.79-inch E-paperESP32-S3-WROOM-1-N8R8 target with a 792x272 dual-SSD1683 e-paper display, microSD over SDSPI, CH340C/UART console, rotary/menu/exit controls, status LED, Wi-Fi, BLE, and expansion I2C/SPI/UART/1-Wire/GPIO/ADC/PWM.
waveshare_esp32_s3_epaper_3_97waveshare_esp32_s3_epaper_3_97Waveshare ESP32-S3-ePaper-3.97ESP32-S3-WROOM-1-N16R8 target with an SSD1677 e-paper display that defaults to 800x480 landscape, AXP2101 battery/charger monitoring, QMI8658 six-axis IMU, four-bit SDMMC, native USB CDC, UART, PCF85063 RTC, SHTC3 temperature/humidity sensor, ES8311 speaker/microphone audio, rotary navigation, Wi-Fi, BLE, and expansion I2C/UART.
xteink_x4_proxteink_x4_proXteink X4 ProESP32-S3 target with 16 MB flash, 8 MB OPI PSRAM, an 800x480 landscape e-paper display with cached SSD1677/UC8179/UC8279 identification, fixed-mix frontlight brightness, GT911 touch and Home pad, one-bit SDMMC, BM8563-compatible RTC, CW2017 gauge, native USB CDC, UART0, navigation buttons, Wi-Fi, and BLE.
cl_32cl_32CL-32ESP32-S3-WROOM-1-N16R8 target with a 384x168 ST7305 reflective LCD, an ATmega808-backed keyboard and battery monitor, native USB CDC, UART, microSD over SDSPI, PCF85063 RTC, onboard PWM buzzer, Wi-Fi, BLE, and expansion I2C/SPI/UART/GPIO/ADC/PWM/I2S.
odroid_goodroid_goHardkernel ODROID-GOClassic ESP32 target with ILI9341 display, SD over VSPI/SDSPI, battery ADC, ESP32 DAC speaker, buttons, ADC D-pad, status LED, display brightness, expansion I2C/SPI/UART/GPIO/PWM, and runtime GPIO4/GPIO15.
freenove_esp32_wrover_v3freenove_esp32_wrover_v3Freenove ESP32-WROVER v3.0 (FNK0060)Classic ESP32 target with 8 MB PSRAM, CH340/UART console, one-bit SDMMC, Wi-Fi, BLE, a GPIO0 BOOT/KEY button, and a 384x288 monochrome PAL composite display on GPIO25.
ttgo_vga32_v14ttgo_vga32_v14LilyGO TTGO VGA32 v1.4ESP32-PICO-D4 desktop target with 8 MB external PSRAM, build-selectable 320x200@70Hz, 320x240@60Hz, 640x400@70Hz, or 640x480@60Hz VGA output through the onboard RGB222 resistor DAC, GPIO25 mono DAC audio, a default-attached PS/2 keyboard, v1.4 microSD wiring over HSPI, USB-UART, Wi-Fi, BLE disabled by default, and two input-only expansion GPIOs.
esp32_s3_devkitc1_n16r8esp32_s3_devkitc1_n16r8Espressif ESP32-S3-DevKitC-1-N16R8Headless ESP32-S3 target with CDC, UART, Wi-Fi, BLE, a GPIO0 BOOT/KEY button, expansion I2C/SPI/UART/GPIO/ADC/PWM, graphics through attachable display targets, and no primary display or onboard sensors.
goouuu_esp32_s3camgoouuu_esp32_s3camGOOUUU ESP32-S3CAMHeadless ESP32-S3 camera target with 16 MB flash, 8 MB PSRAM, native USB CDC, CH340/UART console, one-bit SDMMC, an onboard WS2812, Wi-Fi, BLE, and fixed DVP/SCCB camera wiring. Its required driver.camera-esp32 package exposes OV2640 probing and bounded QVGA/VGA JPEG capture through service.camera; cam-webd adds a single-client MJPEG stream and JPEG snapshots with optional authentication over the shared HTTP server.
esp32_devkitc_v4_wroveresp32_devkitc_v4_wroverEspressif ESP32-DevKitC V4 with ESP32-WROVER-EHeadless classic ESP32 target with PSRAM, UART, Wi-Fi, BLE, a GPIO0 BOOT/KEY button, expansion I2C/SPI/UART/GPIO/ADC/PWM/I2S, graphics through attachable display targets, and no built-in peripherals.
devkitc1_epaper_workbenchesp32_s3_devkitc1_n16r8 with SOLAR_OS_BOARD=devkitc1_epaper_workbenchESP32-S3 DevKitC-1 E-paper WorkbenchManifest-generated development target with fixed CardKB, 400x300 SSD1683 display, and SDSPI storage attachments.
t_lora_pagert_lora_pagerLilyGO T-LoRa-Pager (SX1262)ESP32-S3-WROOM-1-N16R8 target with a 480x222 ST7796 display, a TCA8418 4x10 matrix keyboard, an SX1262 LoRa radio, PCF85063 RTC, microSD over SDSPI, ES8311 audio, a BQ27220 battery gauge, a BQ25896 charger, a rotary encoder, an XL9555 I2C GPIO controller for integrated power rails, a MIA-M10Q GNSS receiver, an ST25R3916 NFC-A reader, a BHI260AP IMU, a DRV2605 haptic controller, native USB CDC, Wi-Fi, BLE, and expansion I2C/SPI/UART/GPIO/ADC/PWM. SD power and its external pull-up start enabled; GNSS and NFC power start disabled and are enabled with gnss power on and nfc power on. Use haptic play EFFECT for DRV2605 ROM effects 1 through 117. The Pager fixes BQ25896 fast-charge current at 704 mA during attachment; inspect or explicitly change charger settings with charger.
thinknode_m9thinknode_m9Elecrow ThinkNode M9ESP32-S3R8 target with 16 MB flash, 8 MB PSRAM, a 320x240 ST7789 display, a 37-key STC8H-scanned QWERTY keypad with backlight, microSD over SDSPI, PCF8563 RTC, ATGM336H or BM72A GNSS over NMEA, battery ADC, passive buzzer, CH340C-class (CH340K) USB/UART console, Wi-Fi, and BLE. The keypad I2C address identifies the board revision (0x6c is v1.0, 0x6d is v1.1). The GNSS enable rail is active-low on v1.0, which the manifest declares; a v1.1 board, active-high, sets active = 1 on gnss0. GNSS power starts disabled and is enabled with gnss power on. The keypad's Home key exits the foreground app, Back is escape, Messages/Maps/Pin are F1/F2/F3, long-press Pin is F4, and the Message key arms the one-shot compose layer that supplies tab, pipe, backslash, brackets, and the other characters the keypad cannot type directly. Composed up and down page through the scrollback and composed right is tab (see the stc8h-keyboard driver reference). The onboard LR1110 LoRa radio has no SolarOS driver yet; its pins are reserved.
t_deck_plust_deck_plusLilyGO T-Deck PlusESP32-S3FN16R8 target with a 320x240 ST7789 display, GT911 touch, I2C keyboard, five-way trackball, separate I2S speaker output and ES7210 microphone capture, SX1262 LoRa radio, microSD over SDSPI, battery ADC, internal GPS UART, native USB CDC, Wi-Fi, and BLE.
waveshare_esp32_s3_sim7670g_4gwaveshare_esp32_s3_sim7670g_4gWaveshare ESP32-S3-SIM7670G-4G V2.0Headless ESP32-S3R8 target with 16 MB flash, 8 MB PSRAM, CH343/UART console, SIM7670G AT, GNSS, and IPv4 PPP access over UART1, MAX17048 battery gauge, one-bit SDMMC, one WS2812B, Wi-Fi, BLE, and expansion GPIO/ADC/PWM/SPI/I2S. Set the DIP switches to CAM OFF, HUB ON, 4G OFF, USB OFF. With CAM off, the unused camera signal pins are available as expansion GPIOs and can form runtime SPI or I2S buses; GPIO15/GPIO16 remain the shared MAX17048 I2C bus and GPIO46 remains blocked as an input-only strapping pin. SolarOS drives GPIO21 active-high to control the modem VBAT rail; leave the 4G switch OFF to avoid contention and permit software power-off/reset. The modem UART belongs exclusively to PPP while connected; disconnect it before AT or GNSS use. Camera support is not included.
waveshare_esp32_s3_sim7670g_4g_epaperwaveshare_esp32_s3_sim7670g_4g with SOLAR_OS_BOARD=waveshare_esp32_s3_sim7670g_4g_epaperWaveshare ESP32-S3-SIM7670G-4G E-paper workbenchExpansion-export reference target with a fixed M5Stack TAB5 keyboard on the board-owned I2C bus at address 0x6d, INT on GPIO41, and a fixed primary 400x300 SSD1683 display on SPI2 using GPIO3/GPIO7 through GPIO12. GPIO40 remains available for expansion.

SolarOS documentation

Native BLE HID peripheral

solaros.ble.hid mirrors the Python BLE HID API. start(name) publishes a fixed encrypted and bonded composite keyboard, mouse, and gamepad service for one host. start(name, true) starts a manual host-selection lease, initially idle. hosts() lists saved HID hosts with address, addr_type, connected, and name. For manual leases, the current encrypted, bonded host's GAP Device Name is read asynchronously, without delaying reports. name and status().host_name are empty until available or when the host does not permit the read. Names are UTF-8 prefixes of at most 63 bytes, kept only for the current connection. Applications can cache them on mounted storage. For manual leases, connect(address, addr_type) advertises only to the selected saved host, forget(address, addr_type) deletes that host pairing, and disconnect() returns to idle while keeping pairings. Address type defaults to zero. The remote host initiates the BLE connection. Reconnect requests use the saved bond's authentication and Secure Connections settings; selecting a saved host does not require enabling pairing again on the host. Manual pair() drops the selected connection and advertises a fresh peripheral identity for a new host, preserving other saved hosts and the local keyboard. The default lease's pair() resets the previous host pairing on the public identity. Call it only for a user-requested new-host pairing flow. When bond storage is full, this explicit pairing flow may replace an unused bond, preserving the remembered input keyboard and all connected peers. status() returns connection, security, subscription, keyboard LED, and queue state. poll() returns nil or a connected, secured, disconnected, keyboard-leds, or passkey event. Render a passkey as six digits and enter it on the remote host.

local hid = solaros.ble.hid
hid.start("SolarOS Controls")
hid.pair()

while not solaros.should_exit() do
    local event = hid.poll()
    if event then
        print(event.type, event.status)
        if event.type == "passkey" then
            print(string.format("Enter %06d on the remote host", event.passkey))
        end
    end
    if hid.status().gamepad_subscribed then
        hid.gamepad.axis(hid.AXIS_X, -12000)
        hid.gamepad.button(1, true)
        hid.gamepad.hat(hid.HAT_UP)
        hid.gamepad.send()
        break
    end
end

Keyboard calls are keyboard.press, keyboard.release, and keyboard.release_all. Mouse calls are mouse.move and mouse.button. Gamepad setters accept axes in -32768..32767, buttons 1..32, and HAT_* directions, then gamepad.send() publishes the combined state. Input operations require the corresponding encrypted host subscription. The event queue holds 16 entries; inspect events_dropped. stop() neutralizes reports and retires the service. Runtime teardown does the same automatically.

The native HID service shares the single application-peripheral lease with solaros.ble.server; the two cannot be active at once. It is separate from the dormant USB-only solaros.hid package. Scripts select behavior through typed operations and constants; report descriptors and raw report bytes stay native.

solaros.ble.server mirrors the application peripheral API: create, service, characteristic, start, stop, close, status, peers, set, send, disconnect, and poll. Values and event data use binary Lua strings; poll() returns nil when empty. Properties combine solaros.ble.READ, WRITE, WRITE_NO_RESPONSE, NOTIFY, and INDICATE. The server belongs to this runtime and is cleaned up alongside its outgoing peers on interpreter exit.

local gatt = solaros.ble.gatt
local peer = gatt.connect("aa:bb:cc:dd:ee:ff", 1) -- replace address and type
for _, service in ipairs(gatt.services(peer)) do
    print(service.uuid, service.index)
    for _, characteristic in ipairs(gatt.characteristics(peer, service.index)) do
        print(characteristic.uuid, characteristic.handle)
    end
end
-- Use a discovered handle: local data = gatt.read(peer, handle)
-- Binary write: gatt.write(peer, handle, string.char(0, 255), true)
gatt.disconnect(peer)

For an already connected peer and a discovered notification-capable handle:

gatt.configure_queue(peer, 32)
gatt.subscribe(peer, handle) -- third argument true selects indications
local event = gatt.poll(peer) -- call regularly from the application's loop
if event then print(event.handle, event.data, event.indication) end
print(gatt.status(peer).events_dropped)
-- When finished: gatt.unsubscribe(peer, handle)

SolarOS documentation

M5Stack Tab5 Keyboard

The Tab5 Keyboard (A164) is a 3.3 V, 70-key I2C keyboard with a default address of 0x6d. Connect 3.3 V, GND, SDA, and SCL to the corresponding board supply and named bus. INT is optional; connect it to a free, input-capable GPIO and pass irq=<pin>. Use expansion status and expansion layout to find the board's buses and pins.

The waveshare_esp32_s3_sim7670g_4g_epaper profile attaches this keyboard automatically as fixed keyboard0 on i2c0 at 0x6d, with INT on GPIO41. GPIO40 is available for expansion. Use input test keyboard0 to inspect its event count and last event. Fixed board attachments cannot be detached or attached a second time from the shell.

On boards without a fixed Tab5 attachment, attach a connected keyboard with:

expansion attach tab5-keyboard keyboard0 i2c=i2c0 addr=0x6d
input sources
input keymap keyboard0 show
expansion detach keyboard0

For interrupt operation, append an available GPIO binding, for example irq=gpio1 when GPIO1 is free on the board. Falling INT wakes the keyboard worker. It also polls every 50 ms to cover missed edges; without INT it polls every 10 ms. These are worker intervals, not guaranteed input latency.

The driver selects Normal mode and maps both press and release events into the common input service, including host key repeat. Aa acts as held Shift, Ctrl and Alt are held modifiers, and Sym selects the printed symbol layer while held. Character-mode latch and double-click behavior is not used. Letters and navigation keys carry HID usages. Printed punctuation uses logical character mappings without HID usages; raw HID forwarding requires a mapping that supplies the appropriate usages and modifiers.

Physical IDs are 1 + row * 14 + column, with rows 0..4 and columns 0..13. Use input keymap keyboard0 load <path> and input keymap keyboard0 reset to customize or restore the built-in map. This driver uses runtime keymaps; it is not a SOLAR_OS_KEYMAP_PROFILE desktop/build profile.

The firmware has a 32-event FIFO and no overflow status flag. A detected full queue, malformed event, input queue failure, or I2C error releases tracked keys and triggers reconfiguration with a cleared FIFO. Release and press keys again after recovery. A controller reset or an undetected dropped event cannot be reconstructed from the event-only protocol.

Detach stops the worker and removes its GPIO handler, releases keys, and restores the previous keyboard mode and interrupt configuration. It leaves the named I2C bus available to other devices. If restoring configuration fails, ownership is retained so detach can be retried. Attaching clears old queued events; RGB settings and the persistent I2C address are not changed.

Custom flavors can enable tab5_keyboard = true in [groups] or select expansion_tab5_keyboard in [packages]. The full flavor includes it on ESP32 and ESP32-S3 boards with expansion I2C support. addr selects the current device address; it does not reprogram the keyboard.

SolarOS documentation

zoo

Browse the hosted model zoo and install complete model bundles on mounted SD or flash storage. The text interface works on the display and VT100 port shells. It requires the Zoo package, ESP32-S3, PSRAM, and networking.

zoo
zoo refresh
zoo source [HTTPS-CATALOG-URL|reset]
zoo storage [sd|flash]
zoo install ID VERSION

The default source is https://solar-os.eu/zoo/catalog.json. The first launch fetches it; later launches use the cached catalog. r refreshes the cache. Up/Down and Page Up/Page Down select entries, Enter opens details, Alt+S searches, and d downloads the selected version. Arrows and page keys scroll long details. * marks an installed bundle and ! marks an incompatible entry. c or Esc cancels a running operation. q or Esc returns from details or exits the browser; the app-exit key exits from either view.

Source and storage preferences persist. Without an explicit storage preference, Zoo uses mounted SD, then flash. Files are stored under dl/zoo/ on that volume; each installed bundle is in models/ID/VERSION/. Downloads are streamed to storage, checked against archive, manifest, model, and asset hashes, and installed through a staged directory swap. Interrupted downloads preserve the previous installation.

The browser shows task, modality, result adapter, license, validation status, and reported free-memory requirements. Runtime compatibility is not a device inference test, and unreported memory remains unknown. The device accepts catalogs up to 512 KiB and 256 entries; larger catalogs fail without replacing the cache. Installation also needs enough storage for the ZIP and extracted files.

Installation does not load a model. Use the displayed bundle.json path with model bundle PATH or the native, Python, or Lua inference APIs. Loaded models remain resident until explicitly unloaded or the device reboots. See Model bundles for inputs, results, and execution, and Native image pipelines for a Zoo-to-camera workflow and a Python detection example. Catalog task names describe the model; check its result adapter before selecting a script. Raw tensor bundles need model-specific interpretation.