Adding a device
A driver teaches sub-screen-player one family of displays. Everything else (finding the
device, pacing frames, encoding, keep-alives, the API, the CLI) is shared, so a driver is
usually a few hundred lines. Use crates/drivers/d92 as the reference.
1. Learn how the device talks
Section titled “1. Learn how the device talks”Collect, and write down in docs/devices/<model>.md (and its Japanese version
docs/devices/<model>.ja.md):
- USB ids (vendor:product) and the interface class. On macOS:
ioreg -p IOUSB -l; Linux:lsusb -v; Windows: Device Manager → Details → Hardware Ids. - Panel size and orientation: the size callers should draw (landscape), and whether images must be rotated before sending.
- Image format: JPEG, raw RGB565, … and any size limit.
- Commands: how to show a frame, and if available brightness, power, clear, keep-alive.
- Timing: how fast frames can go, and whether the device needs keep-alives.
If there is no documentation, capture the vendor app’s traffic. Wireshark with USBPcap works
on x86 Windows. On machines where that is not possible (e.g. Apple Silicon), running the
vendor app in a VMware VM with usb.analyzer.enable = "TRUE" logs the USB traffic in
vmware.log. See the reverse engineering etiquette.
Today ssp-core supports HID devices. If your display uses USB bulk transfers or a
serial port, open an issue first: it needs a new Transport in crates/core, which other
drivers will share.
2. Create the crate
Section titled “2. Create the crate”crates/drivers/<model>/├── Cargo.toml└── src/ ├── lib.rs Driver + Display implementation └── protocol.rs pure functions that build reportsCargo.toml:
[package]name = "ssp-driver-<model>"description = "sub-screen-player driver for the <Vendor Model>"version.workspace = trueedition.workspace = truerust-version.workspace = truelicense.workspace = trueauthors.workspace = true
[dependencies]ssp-core.workspace = truetracing.workspace = true
[lints]workspace = true3. Write the protocol
Section titled “3. Write the protocol”protocol.rs holds constants (USB ids, panel size, report length) and functions that turn
commands and images into bytes. Keep it free of I/O so it can be tested exhaustively.
Test it against captured bytes. For example, the D92 driver checks that a 14547-byte JPEG gets exactly the header the vendor app sends:
#[test]fn live_frame_header_matches_capture() { let out = live_frame(&vec![0xAB; 14547]); assert_eq!(&out[..13], &[0x43, 0x52, 0x54, 0, 0, 0x44, 0x52, 0x41, 0, 0, 0x38, 0xf3, 0xb1]);}If a command is known to break the device, write a test that the driver never sends it.
4. Implement Display
Section titled “4. Implement Display”pub struct MyDisplay { transport: Box<dyn Transport>, info: DisplayInfo,}
impl MyDisplay { /// Takes a `Transport` (not a HID handle) so tests can pass a `RecordingTransport`. pub fn open(transport: Box<dyn Transport>, serial: &str) -> Result<Self> { ... }}
impl Display for MyDisplay { fn info(&self) -> &DisplayInfo { &self.info } fn show(&mut self, image: &EncodedImage) -> Result<()> { ... } // Implement only what the device supports; the rest keeps the defaults.}Fill in DisplayInfo honestly:
driver: a short, stable id ("d92"). It becomes part of display ids and log lines.panel: the landscape size, theRotationneeded on the wire and theImageFormat.capabilities: setmax_fpsto what you measured,keep_alive_intervalif the device needs one, andmax_image_bytesto what the device accepts.
Return Error::Transport or Error::Disconnected when the device is gone; the daemon then
reopens it automatically. Do not retry or sleep in loops inside the driver.
5. Implement Driver
Section titled “5. Implement Driver”pub struct MyDriver;
impl Driver for MyDriver { fn id(&self) -> &'static str { "mymodel" } fn name(&self) -> &'static str { "Vendor Model" } fn usb_matches(&self) -> &'static [UsbMatch] { &[UsbMatch { vendor_id: 0x1234, product_id: 0x5678, usage_page: Some(0xFF00) }] } fn open(&self, candidate: &Candidate) -> Result<Box<dyn Display>> { let transport = ssp_core::hid::open(candidate)?; Ok(Box::new(MyDisplay::open(Box::new(transport), &candidate.serial)?)) }}Match as narrowly as you can (add the usage page if the device has several HID interfaces), so the driver never claims somebody’s keyboard.
While the driver is being developed, mark it experimental. It is then only used when it is
named (--driver mymodel, or enable in the [drivers] section of the config), so a
half-finished driver never takes over a display on its own, not even if it gets released:
fn experimental(&self) -> bool { true // remove once it is verified on hardware }6. Register it
Section titled “6. Register it”- Root
Cargo.toml: add the crate tomembersand to[workspace.dependencies]. crates/server/Cargo.toml: addssp-driver-<model>.workspace = true.crates/server/src/drivers.rs:registry.register(ssp_driver_<model>::MyDriver);contrib/linux/70-sub-screen-player.rules: add a line with your vendor and product id.README.mdandREADME.ja.md: add the device to the “Supported displays” table.docs/devices/<model>.mdanddocs/devices/<model>.ja.md: the protocol notes from step 1.
7. Test on hardware
Section titled “7. Test on hardware”mise run ci must pass. Then run the automatic check on the device and paste its output
into your pull request:
ssp selftest --driver mymodel # every check should PASSA running daemon only blocks the test if it uses your driver. To keep another display running
meanwhile, start the daemon with just its driver, e.g. ssp serve --driver d92.
Then try it by hand and look at the screen:
mise run serve # the clock should appearssp devices # your display is listedssp brightness 30 && ssp brightness 100ssp off && ssp onssp show some-photo.jpg --fit cover # check the orientationssp status # check frames shown / dropped and send timeTo measure the frame rate, stream frames over the WebSocket API (see api.md) and
watch ssp status. Unplug and replug the display while the clock runs: it should come back
on its own.