Displays and transports · Tutorial
Connect displays with DLP and byte streams
Start with SolarOS byte streams
A byte stream is a named endpoint that lets a SolarOS application read and write bytes without handling the underlying connection itself. For example, cdc0 can carry bytes over USB, tcp0 over a network, and ble0 over Bluetooth. The consumer uses the endpoint's name; its provider handles the transport.
SolarOS also has scalar, event, audio, and video streams. A temperature sample, a key event, and a camera frame have their own data contracts. For this tutorial we need exclusive duplex byte streams: an ordered sequence of bytes in both directions, owned by one consumer at a time. DLP sends commands and pixels in one direction and replies in the other.
Inspect the endpoints on your device from the SolarOS shell:
stream list
port list
stream status cdc0
port status cdc0
Use an ID actually listed on your device if cdc0 is absent. stream status shows the type, direction, sharing policy, owner, and counters. port status shows the port capabilities and owner. Registering a port also exposes it as a byte stream with the same name.
Creating an endpoint and opening it are separate steps. TCP, BLE, Link, and MeshCore endpoints can remain registered while idle. A consumer such as dlp, stream-dlp, com, a shell, or a bridge claims the endpoint when it uses it. Release that consumer before reusing or removing the stream. There is no general stream create or stream open shell command; each provider supplies its own setup commands.
All built-in ways to obtain a byte stream
| Provider | How the endpoint appears | Example stream ID |
|---|---|---|
| Board UART | A board-defined UART registers its port when attached | uart0 |
| USB CDC | Firmware with USB CDC support registers the native USB port | cdc0 |
| Runtime UART | expansion bus create uart ... creates a named UART port | dlp-uart |
| MIDI UART | expansion bus create midi ... creates a UART-backed MIDI bus and port | midi0 |
| Raw TCP | stream tcp listen ... or stream tcp connect ... | tcp0 |
| Native BLE CoC | stream ble listen ... or stream ble connect ... | ble0 |
| SolarOS Link | link stream create ... over an ESP-NOW or packet-radio Link | vser0 |
| MeshCore | meshcore stream create ... for an exact trusted peer | mser0 |
The Telnet server also registers telnet0 for its client connection. Start it with job start telnetd when you need a management shell. That port is owned by the Telnet shell and carries Telnet protocol processing; use a raw TCP stream for the DLP network connection below.
Availability depends on your board and firmware packages. Use a current full image for these examples, with a graphics-capable target for the receiving display. The dlp group supplies the receiver app, stream_dlp supplies the remote-display driver, and dlp_client supplies private scripting connections. TCP, BLE, ESP-NOW, and MeshCore also need their respective support. Check apps, expansion drivers, and the relevant command help on each device. The quick start covers installation and shell access.
Board UART and USB CDC
These ports already exist when their drivers are active. Select a free port from port list; no stream creation command is needed. USB CDC requires native USB support and the correct connector. A USB-to-UART adapter exposes a UART such as uart0, even though the desktop sees a USB serial device.
Keep another console available while assigning a serial port to DLP. From that other console, use session list and close any shell on the selected port:
session list
session close <PORT_SHELL_ID>
log sink cdc off
port status cdc0
Replace <PORT_SHELL_ID> with the actual session ID. SolarOS keeps at least one interactive shell, so create or retain another shell first. Stop any logger, bridge, file transfer, or other consumer that owns the selected port as well. The CDC log setting above prevents log mirroring from mixing text with DLP.
An existing board UART uses its configured pins and baud. Match the peer's baud; DLP does not negotiate serial settings. The expansion reference lists board buses and pins, and serial ports describes the inspection commands.
Create a runtime UART
Use an unused UART controller and GPIOs approved by your board's resource profile. This example uses uart1; replace both GPIO placeholders:
expansion bus create uart dlp-uart port=uart1 tx=<TX_GPIO> rx=<RX_GPIO> baud=921600
uart status dlp-uart
stream status dlp-uart
The stream ID is dlp-uart, the chosen bus name. Wire host TX to peer RX, host RX to peer TX, and connect grounds using compatible logic levels. Both ends must use 921600 baud for this example. DLP over UART needs no READY wire; hardware reset is separate from the stream driver.
Runtime UARTs may also be defined in a board manifest or saved expansion configuration. That changes when the port is created, not how DLP uses it. See runtime buses for the bus lifecycle.
The specialized expansion bus create midi midi0 tx=<TX_GPIO> rx=<RX_GPIO> form also registers a UART-backed byte port. It selects an unused controller automatically and defaults to 31250 baud. That bus is intended for MIDI wiring and the midi job, which claims its port. midi stream add creates parsed scalar CC streams. For the DLP UART examples here, use the explicit uart form and the baud required by the display endpoint.
Create raw TCP streams
On the device accepting a connection:
stream tcp listen tcp0 7777
stream status tcp0
On another SolarOS device initiating the connection:
stream tcp connect tcp0 <RECEIVER_IP> 7777
stream status tcp0
Replace <RECEIVER_IP> with the listener's reachable IPv4 address or hostname. The stream name is local to each device; the names need not match. Start the listening consumer before the connecting consumer. Outbound TCP connects on first I/O, and hostnames resolve at endpoint registration. Recreate the endpoint if its hostname must resolve again.
Up to four TCP endpoints may be registered. Listeners bind to all network interfaces. These connections carry raw bytes without Telnet negotiation or newline translation, and provide no encryption or authentication; use a trusted network. Endpoint configurations last until removal or reboot.
Create native BLE streams
On the listening device:
stream ble listen ble0
The listener advertises as SolarOS ble0 when a consumer claims it. Start dlp serve as shown below before scanning from the connecting device:
ble scan
stream ble connect ble0 <LISTENER_ADDRESS> <ADDRESS_TYPE>
Copy both values from the scan. Address types are public, random, rpa_public, and rpa_random. For example, the form for a public address is stream ble connect ble0 aa:bb:cc:dd:ee:ff public; replace the address with the actual scan result. The connecting endpoint also waits for a consumer before opening its connection.
These are native L2CAP Connection-Oriented Channels (CoC), with ordered bytes and backpressure. A phone's GATT UART application does not provide this transport. Up to four configurations share at most two active channels, subject to the Bluetooth connection budget; SolarOS reserves a connection for its BLE keyboard. Only one generic BLE listener can be claimed at a time, and it shares its peripheral role with generic GATT/HID peripheral services. The keyboard input client can coexist with the stream.
BLE central/peripheral roles determine who opens the connection. Either device can send pixels or serve a display. Streams currently accept open connections without requiring pairing or peer authorization. Inspect an active connection with stream ble status ble0. See the BLE stream reference for details.
Create a SolarOS Link stream over ESP-NOW or packet radio
Link streams add ordering, acknowledgements, retries, and flow control to a packet transport. Configure both devices for the same medium. For ESP-NOW, start this on both devices:
job start espnow-link link0 channel=6 phy=normal
link status link0
Both devices must use the same channel and compatible PHY. If Wi-Fi is also in use, choose its channel instead of blindly using 6. Fixed-channel ESP-NOW prevents Wi-Fi connections on another channel. Exchange initial broadcast messages on both sides to learn the peer mappings:
link send link0 broadcast "hello"
Read each device's local ID from link status link0. Then configure the stream on device A with B's ID, and on B with A's ID:
link stream create link0 vser0 <OTHER_DEVICE_LINK_ID>
link stream status vser0
Use an actual decimal or 0x peer ID. For reliable cold-start initiation, configure espnow peer add <PEER_ID> <PEER_MAC> on each side instead of relying on learned mappings.
For packet radio, attach compatible radios and use the same complete radio profile on both sides. Replace the ESP-NOW job with:
job start radio-link link0 radio0 lora-eu868
link status link0
link stream create link0 vser0 <OTHER_DEVICE_LINK_ID>
radio0 must already exist, and the profile must suit your radios and region. Choose one adapter for link0. A raw link send-binary message or best-effort bridge ... link0 job does not supply the reliable byte-stream contract. DLP uses the peer-bound vser0 port instead. Both peers need compatible Link stream protocol versions. Link traffic is unencrypted and peer IDs are not cryptographic authentication.
The Link manual covers radio attachment, ESP-NOW peer mapping, and stream diagnostics.
Create a MeshCore stream
MeshCore streams carry reliable bytes in encrypted direct-request packets between two SolarOS endpoints. Standard MeshCore repeaters can route those packets. On each device, start MeshCore on an attached compatible radio, discover the other endpoint, and review its identity:
job start meshcore radio0 meshcore-eu868
meshcore advert flood
contacts list
contacts show <CONTACT_ID>
contacts trust <CONTACT_ID> <ENDPOINT_ID>
meshcore stream create mser0 <ENDPOINT_ID>
meshcore stream status mser0
Use the contact and exact peer endpoint IDs reported locally. They can differ between devices. Each side must trust and bind the other device's MeshCore identity; only one stream may bind a given peer. Choose a radio profile for your hardware and region, and release other jobs owning that radio first. See MeshCore virtual serial ports.
Create endpoints from Python or Lua
With the corresponding bindings enabled, Python and Lua also expose solaros.streams.tcp.listen(name, port), connect(name, host, port), and remove(name), plus solaros.streams.ble.listen(name), connect(name, address, addr_type), and remove(name).
For example, this is a SolarOS Python alternative to the TCP shell command:
import solaros
solaros.streams.tcp.connect("tcp0", "192.0.2.10", 7777)
Replace the example IP. Run this only if tcp0 has not already been registered. For BLE, the numeric address types are 0 public, 1 random, 2 public identity, and 3 random identity; pass the type positionally. Configurations remain after the creating script exits. Close the consumer and remove endpoints when done. UART and radio endpoints use their provider configuration. The Python and Lua references describe the APIs.
Put DLP on top of the stream
Display Link Protocol (DLP) carries display discovery, image uploads, and presentation commands. The remote endpoint advertises its dimensions and pixel formats. Uploads prepare an image; PRESENT makes it visible. Replies let the sender wait for presentation completion. Negotiated features include compact framing, upload windows, changed rectangles, and RLE compression.
SolarOS can take either side:
| Goal | Command | Result |
|---|---|---|
| Let another device draw on a SolarOS display | dlp serve display0 stream=tcp0 | A receiver session uses the existing display |
| Let SolarOS apps draw on a remote DLP endpoint | expansion attach stream-dlp remote stream=tcp0 | A new named display target, remote, appears locally |
SolarOS apps -> remote display target -> stream-dlp -> byte stream
|
DLP receiver -> physical panel
The receiver can be another SolarOS device or another compatible DLP implementation. TCP listener/client and BLE central/peripheral roles are independent of DLP sender/receiver roles. DLP carries display updates and replies; keyboard input and input focus remain managed by each SolarOS device.
Connect two SolarOS devices over TCP
This is the quickest complete example without extra wiring. Device B has the panel; device A will run applications on it. Both need reachable network connections and the relevant packages. In B's SolarOS shell:
wifi connect "YOUR_SSID" "YOUR_PASSWORD"
wifi status
display list
stream tcp listen tcp0 7777
dlp serve display0 stream=tcp0
dlp status
Use B's actual display name if it differs from display0. Note B's IP from wifi status. The receiver runs on that display and suspends its previous session. Only one DLP receiver app may run per device. Its waiting state is expected until a sender connects. Keep a separate console for status and stop commands if needed.
In A's shell, join the reachable network and use B's address:
stream tcp connect tcp0 <DEVICE_B_IP> 7777
expansion attach stream-dlp remote stream=tcp0
display list
display test remote
Attachment discovers the remote geometry and formats, claims tcp0, and registers remote as a display target on A. The test pattern should appear on B's panel. To run an application from A's management shell:
session create shell remote
session focus remote
A's local keyboard now addresses the shell on remote. Start an app there as you would on a built-in display. Alternatively, from A's management console:
session create files remote
Applications remain single-instance, so close an existing Files instance before creating another. session list shows the sessions and their displays. Input connected to B does not become A's keyboard through DLP.
Use the same display connection over BLE
Stop and remove the TCP setup using the cleanup steps below before switching this example to BLE. On B:
stream ble listen ble0
dlp serve display0 stream=ble0
On A, scan after B has started serving. Substitute the scan address and type:
ble scan
stream ble connect ble0 <DEVICE_B_ADDRESS> <ADDRESS_TYPE>
expansion attach stream-dlp remote stream=ble0 timeout-ms=8000
display test remote
stream ble status ble0
The same session create commands now work through BLE. To reverse the display direction, release both consumers and swap dlp serve and stream-dlp between the devices. The existing BLE listener can stay on B.
For UART, Link, or MeshCore, create both endpoints as described above, then use their local stream names in place of ble0: run dlp serve on the panel side first and attach stream-dlp on the app side. For a UART connection, a native USB CDC device requires a USB host peer; two USB device connectors alone do not make a connection.
Send desktop images to a SolarOS display
The SolarOS checkout includes a desktop image/demo sender. On the desktop, run from the SolarOS project directory:
python3 -m pip install Pillow
python3 scripts/dlp_image.py --tcp <DEVICE_B_IP>:7777 image.png
python3 scripts/dlp_image.py --tcp <DEVICE_B_IP>:7777 --demo --fps 30 --frames 100 --stats
B must be running the TCP listener and dlp serve from the TCP example. Release A's stream-dlp connection before using the desktop sender. The script discovers the display size, fits the image on white, and sends MONO1 pixels. The FPS option sets a pacing ceiling, not a promised display rate.
For USB serial, first free cdc0 as described above, then run on B:
dlp serve display0 stream=cdc0
On the desktop, replace /dev/ttyACM0 with that device's actual serial path:
python3 -m pip install pyserial Pillow
python3 scripts/dlp_image.py --port /dev/ttyACM0 image.png
For a UART connection, also match the script's --baud setting to the UART's configured baud. Stop any desktop serial monitor that has the same port open. The desktop sender is an image test tool; desktop virtual-monitor integration is a separate host application. See the DLP app reference for sender options and timing statistics.
Send a frame directly from a script
Use stream-dlp when you want a named display for shells, apps, and graphics. For a script that already has packed pixel data, a private DLP connection can send a frame without registering a display target. This Python example runs on SolarOS A, while B serves its TCP endpoint:
import solaros
# Use a fresh local name, and replace the address with B's IP.
solaros.streams.tcp.connect("image0", "192.0.2.10", 7777)
try:
with solaros.dlp.connect("image0", format=solaros.dlp.MONO1) as link:
info = link.info()
# Zero bits are white. Use the discovered packed frame size.
link.present(bytes(info["frame_bytes"]))
finally:
# The context manager releases the claim before removing the endpoint.
solaros.streams.tcp.remove("image0")
Release any previous sender first. present requires exactly frame_bytes bytes and waits for completion. MONO1 packs horizontal pixels least-significant bit first, black=1, with each row padded to a whole byte. Use info() to read the geometry, stride, and accepted formats. Lua provides the same connection API with positional options and scoped cleanup. See the Python and Lua examples.
Recover, measure, and clean up
After a TCP or BLE disconnect, dlp serve waits for another connection while retaining the last completed image. An attached stream-dlp display retries on its next presentation after an error, discovers a new protocol session, and sends a full frame. A new session must repaint fully before partial updates. If the remote geometry or formats change, detach and attach again.
timeout-ms=100..60000 sets the discovery deadline and minimum command timeout; the default is 5000 ms. A peripheral's presentation limits can extend command waits. For slow radio links, begin with small static displays and allow longer timeouts. Full-frame size, transport rate, round trips, and panel refresh all affect completion time; large images over LoRa may exceed the available timeouts. Compression and changed regions help only when the content allows them.
Run dlp status on the receiving device to inspect frames, byte counts, and presentation timings. The desktop sender's --stats measures host work and end-to-end waits. Receiver presentation timing excludes wire upload and is not a measurement of physical panel scanout.
| Symptom | What to check |
|---|---|
| Stream missing | Board capability, enabled packages, provider setup, and the exact ID in stream list |
| Stream busy | stream status, port status, and session list; release the reported owner |
| BLE listener absent from scan | Start its consumer so it advertises, and check generic BLE peripheral-role ownership |
| Discovery times out | Receiver started first, correct address/type/port or UART wiring/baud, both radio peers configured |
| Corrupt serial exchange | CDC log mirroring, another shell/logger/monitor on the port, and matching UART settings |
| Cannot detach the display | Close apps and shells that still claim that named display |
To dismantle the TCP example, first use session list on A and close the actual app/shell session IDs on remote. Keep another management shell open:
session close <REMOTE_SESSION_ID>
expansion detach remote
stream tcp remove tcp0
On B:
dlp stop display0
stream tcp remove tcp0
Stopping the receiver restores B's previous display session. Escape or Ctrl+Alt+Delete on its display also closes it. The commands above allow cleanup from a separate console.
For other providers, release the DLP consumer first, then use the matching removal command on each side:
| Provider | Remove the unclaimed endpoint |
|---|---|
| Runtime UART | expansion bus remove dlp-uart |
| BLE | stream ble remove ble0 |
| Link | link stream remove vser0 |
| MeshCore | meshcore stream remove mser0 |
Board-defined UART and USB ports remain available for reuse. Stop transport jobs only when no other application needs them. Dynamically registered TCP, BLE, Link, and MeshCore stream configurations must be recreated after reboot.
For a physical SPI connection to a DLP peripheral, SolarOS also provides