Creating DUI Packages¶
A .dui package is a self-contained directory that declaratively defines a Stream Deck UI — either a touchscreen card or a physical key — using an SVG layout, a YAML manifest, and optional image assets. No Python rendering code required.
Looking for the packages bundled with DeUX (
IconKey,PictureKey,DashboardCard) or for howDuiKey("name")resolves to a package? See the DUI Repository & Built-in Packages guide.
Package Structure¶
MyPackage.dui/
manifest.yaml # Required – metadata, bindings, events, regions
layout.svg # Required – SVG layout template
assets/ # Optional – static images (PNG, JPEG, etc.)
icon.png
The Manifest¶
manifest.yaml is the heart of every package. It has four required top-level fields, optional metadata for repository publishing, and three optional sections for bindings, events, and regions.
Required Fields¶
| Field | Type | Description |
|---|---|---|
name |
str |
Package name (non-empty) |
type |
str |
"TouchStripCard" (touchscreen panel) or "Key" (physical key) |
version |
int |
Positive integer (>= 1) |
layout |
str |
Relative path to the SVG file (e.g. layout.svg) |
Metadata Fields¶
These fields are optional for local use but required when publishing to a DUI package repository. The verify --strict tool enforces description and author.
| Field | Type | Required for repo | Description |
|---|---|---|---|
description |
str |
yes | One-line summary for search and display |
author |
str |
yes | Author name and optional email ("Jane Doe <jane@example.com>") |
license |
str |
no | SPDX license identifier (MIT, Apache-2.0, CC-BY-4.0) |
tags |
list[str] |
no | Free-form lowercase labels for search ([music, media, spotify]) |
category |
str |
no | Primary category from a controlled vocabulary (see below) |
url |
str |
no | Project or source URL |
icon |
str |
no | Path to a thumbnail in assets/ for repository listings |
min_deux |
str |
no | Minimum DeUX version required (e.g. "0.5.0") — see below |
device |
list[str] |
no | Explicit device compatibility — see valid values |
Valid Categories¶
The category field is validated against a fixed controlled vocabulary at load time. Passing any other value raises a PackageError (Invalid category '<value>'. Valid categories: [...]). The accepted values are:
media·productivity·system·gaming·social·development·utilities·streaming·home-automation·communication
The authoritative list lives in VALID_CATEGORIES in src/deux/dui/schema.py and is re-exported from deux.dui.
min_deux¶
Declares the minimum DeUX runtime version a package needs (for example "0.5.0"). The loader validates that the value is a string and stores it on the resulting PackageSpec, but it does not compare it against the installed DeUX version. The field is purely declarative metadata used by repository tooling, package browsers, and verify reports to surface compatibility expectations.
Best practice:
- Pin to the lowest DeUX version that actually exposes every feature your package relies on.
- Use a standard
MAJOR.MINOR.PATCHstring so downstream tools can sort and compare it. - Bump the value whenever you adopt a manifest field or behavior introduced in a newer DeUX release.
If you omit min_deux, the package is assumed to work with any DeUX version that can load its manifest schema.
device¶
Optional list of Stream Deck families the package is designed for. The loader validates that each entry is a non-empty string, but does not currently restrict the values to a fixed whitelist — empty/missing means "no explicit restriction".
By convention, packages and the bundled tooling use these PascalCase identifiers, which map to the hardware families enumerated in src/deux/runtime/hid/device.py:
| Identifier | Hardware family |
|---|---|
StreamDeckClassic |
Stream Deck Classic (original, MK.2) |
StreamDeckXL |
Stream Deck XL |
StreamDeckNeo |
Stream Deck Neo |
StreamDeckPlus |
Stream Deck + |
StreamDeckPlusXL |
Stream Deck + XL |
Example:
Use this field to signal which devices a package was authored and tested against. Repository tooling can filter listings by device, and verify reports include the declared compatibility set.
Optional Sections¶
| Section | Description |
|---|---|
bindings |
Data bindings that connect values to SVG elements |
events |
Map physical inputs to named semantic events |
regions |
Touchscreen hit-test areas (TouchStripCard only) |
Minimal Example¶
name: HelloKey
type: Key
version: 1
layout: layout.svg
bindings:
label:
type: text
node: label
default: "Hello"
Repository-Ready Example¶
name: NowPlaying
type: TouchStripCard
version: 2
layout: layout.svg
description: "Media player card with album art, progress bar, and transport controls"
author: "Jane Doe <jane@example.com>"
license: MIT
category: media
tags: [music, spotify, media-player]
url: https://github.com/jane/nowplaying-dui
icon: assets/icon.png
min_deux: "0.5.0"
bindings:
title:
type: text
node: title
default: "No Track"
max_width: 90
overflow: ellipsis
SVG Layout¶
The SVG file is a standard SVG document. Key rules:
- The root
<svg>must have explicitwidthandheightattributes. - Every element referenced by a binding must have a unique
idattribute. - Standard dimensions:
- Touchscreen card: 197 × 98 px
- Key: 120 × 120 px
Example Key SVG¶
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120">
<rect width="120" height="120" fill="#1a1a2e"/>
<text id="label" x="60" y="68" text-anchor="middle"
font-size="16" fill="white">Hello</text>
<rect id="indicator" x="10" y="100" width="100" height="6"
rx="3" fill="#333333"/>
</svg>
Assets¶
Files in the assets/ directory are loaded into memory and inlined as base64 data URIs at render time. Reference them in your SVG with href="assets/filename.png" on <image> elements.
Bindings¶
Bindings connect runtime data to SVG elements. Each binding has a name (the key in the bindings map), a type, and a node (the SVG element ID to target).
text¶
Bind a string value to a <text> element.
title:
type: text
node: title
default: "Untitled"
max_width: 90 # optional – truncate beyond this pixel width
overflow: ellipsis # "ellipsis" (default) or "clip"
wrap: false # optional – enable word-wrapping
max_height: 40 # optional – vertical budget for wrapped text (requires wrap)
line_height: 14.0 # optional – line spacing in px (requires wrap)
If wrap: true, then max_width is required. Text is wrapped into <tspan> elements using pixel-accurate font metrics.
image¶
Bind a PIL Image or raw bytes to an <image> element.
cover:
type: image
node: cover
fit: cover # "cover" (default), "contain", or "fill"
placeholder_node: placeholder # optional – shown when image is None
visibility¶
Toggle an element's display.
color¶
Set a color attribute on an element.
accent_color:
type: color
node: accent
attribute: fill # "fill" (default), "stroke", or "color"
default: "#ff0000"
range¶
Scale an element's width or height proportionally to a 0–1 value. Useful for progress bars.
progress:
type: range
node: progress_bar
default: 0.0
direction: horizontal # "horizontal" (default) or "vertical"
slider¶
Translate an element's position proportionally to a 0–1 value.
knob:
type: slider
node: knob
default: 0.5
direction: horizontal
min_pos: 10.0 # required – position at value 0.0
max_pos: 180.0 # required – position at value 1.0
toggle¶
Switch visibility between two elements based on a boolean.
play_pause:
type: toggle
node_on: icon_pause # shown when truthy
node_off: icon_play # shown when falsy
default: false
Note:
toggleusesnode_on/node_offinstead ofnode.
iconify¶
Embed an Iconify icon into a <g> element.
status_icon:
type: iconify
node: icon_group
size: 24 # required – icon size in px
default: "line-md:home"
Icons are fetched from the Iconify API and cached in-process.
list¶
Render a dynamic list of items as repeated child elements of a parent SVG node (typically a <text> element).
Each item is either a plain text label or an Iconify icon reference (prefix with icon:).
The item at default_index receives active_attrs; all others receive inactive_attrs.
nav:
type: list
node: pager # ID of the parent SVG element
child_tag: tspan # SVG element generated per item (default "tspan")
default_items: # initial list of labels (default [])
- Main
- Settings
- "icon:mdi:home" # prefix with "icon:" to render an Iconify icon
default_index: 0 # active item index; use null or -1 for "no active item"
active_attrs: # attributes applied to the active item
fill: "#ffffff"
font-weight: bold
inactive_attrs: # attributes applied to all inactive items
fill: "#888888"
separator: " · " # inserted between items; empty string disables (default)
icon_size: 14 # pixel size for "icon:" items (default 16)
Runtime updates may provide a partial payload ({"items": [...]} or {"index": N}); the other half is preserved.
An index of -1 is normalised to None (no active item).
Setting items without index clamps the existing index to the new bounds.
transform¶
Apply one or more SVG transforms to a node proportional to a 0–1 value.
The only currently supported kind is rotate, which interpolates linearly between two angles.
Multiple transforms in the list are composed (space-separated) in order.
gauge:
type: transform
node: needle
default: 0.0 # initial normalised value (0.0–1.0)
transforms:
- kind: rotate
from: -90 # angle in degrees when value is 0.0 (default 0)
to: 90 # angle in degrees when value is 1.0 (default 360)
origin: center # "center" (default) or an explicit "x y" pair
origin: center resolves to the element's bounding box center at render time.
Providing an explicit "x y" string (e.g. "60 60") sets a fixed origin in SVG user-space coordinates.
The transforms list must be non-empty.
css_class¶
Bind a CSS class string to an SVG element's class attribute.
The binding replaces the entire class attribute on the target node; setting an empty string removes the attribute.
This is useful when the layout SVG embeds a <style> block and you want to switch between named visual states (e.g. default: "muted" then update to "active" at runtime).
Events¶
Events map physical hardware inputs to named semantic actions. Define them as a list under the events key.
events:
- name: activate
source: key_press_release
max_duration_ms: 300
- name: hold
source: key_hold
hold_ms: 500
Event Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
name |
str |
yes | Unique semantic name |
source |
str |
yes | Physical input source (see table below) |
direction |
str |
no | "left" or "right" (turn events only) |
max_duration_ms |
int |
no | Max press duration for *_press_release (default 500) |
hold_ms |
int |
no | Hold threshold for *_hold (default 500) |
accumulate |
bool |
no | Debounce rapid ticks via DialAccumulator (turn events only, default false) |
accumulate_delay |
float |
no | Seconds to wait after last tick before flushing (default 0.25, requires accumulate: true) |
accumulate_max_steps |
int |
no | Cap on accumulated ticks (default 10, requires accumulate: true) |
busy |
bool |
no | Enable spinner animation and event suppression (default false) |
Sources¶
Key sources (for type: Key):
| Source | Fires when |
|---|---|
key_press |
Key pressed down |
key_release |
Key released (always) |
key_press_release |
Released within max_duration_ms (suppressed if hold fired) |
key_hold |
Held for hold_ms |
Encoder sources (for type: TouchStripCard):
| Source | Fires when |
|---|---|
encoder_press |
Encoder pressed down |
encoder_release |
Encoder released (always) |
encoder_press_release |
Released within max_duration_ms (suppressed if hold fired) |
encoder_turn |
Encoder rotated while not pressed; filter with direction |
encoder_press_turn |
Rotated while pressed; filter with direction |
encoder_hold |
Held for hold_ms |
encoder_turn and encoder_press_turn are mutually exclusive at runtime. While the encoder is pressed, only encoder_press_turn mappings are eligible — turning a pressed encoder will never fire encoder_turn, even if no encoder_press_turn is declared (or its direction filter doesn't match). Any turn while pressed cancels a pending encoder_hold.
After releasing the encoder following a press cycle that included at least one turn, plain encoder_turn events are suppressed for a short grace window (150 ms by default). This debounces the very common ergonomic mistake of letting a finger continue to nudge the dial while lifting off, so a press_turn gesture cannot accidentally bleed into an unrelated encoder_turn handler. Releases without an intervening turn — and encoder_press_turn events themselves — are never suppressed.
Touch sources (for regions):
| Source | Fires when |
|---|---|
tap |
Touch tap on a region |
long_press |
Long press on a region |
Accumulated Turns¶
By default, each encoder tick fires the handler once. For continuous controls like volume or brightness, rapid ticks produce many individual calls. The accumulate option debounces rapid ticks and flushes them as a single callback with the net step count.
events:
- name: brightness_up
source: encoder_turn
direction: right
accumulate: true
accumulate_delay: 0.2 # optional – seconds before flush (default 0.25)
accumulate_max_steps: 5 # optional – cap on net ticks (default 10)
- name: brightness_down
source: encoder_turn
direction: left
accumulate: true
- name: kelvin_up
source: encoder_press_turn
direction: right
accumulate: true
accumulate_delay: 0.1
accumulate_max_steps: 5
The handler receives a single int argument — the net accumulated steps (positive for right, negative for left):
@card.on("brightness_up")
async def handle(steps: int):
# steps is e.g. 3 if the user turned right 3 ticks quickly
new_val = card.adjust_range("brightness", steps * 5, min_val=0, max_val=100)
Validation rules:
accumulateis only valid onencoder_turnandencoder_press_turnsourcesaccumulate_delayandaccumulate_max_stepsrequireaccumulate: trueaccumulate_delaymust be a positive numberaccumulate_max_stepsmust be a positive integer
Under the hood: accumulated turns are powered by
DialAccumulator, a small asyncio debounce
primitive. accumulate_delay maps to its delay constructor argument
and accumulate_max_steps maps to max_steps. You can use
DialAccumulator directly from Python code (outside of .dui packages)
when you need the same debounce behaviour on an
EncoderSlot:
from deux.ui import DialAccumulator
acc = DialAccumulator(my_handler, delay=0.2, max_steps=5)
@encoder.on_turn
async def on_turn(direction: int) -> None:
acc.tick(direction)
Regions (TouchStripCard Only)¶
Regions define rectangular touch-sensitive areas on the touchscreen.
regions:
album_art:
x: 0
y: 0
width: 98
height: 98
events: [tap, long_press]
controls:
x: 98
y: 0
width: 99
height: 98
events: [tap]
All coordinates are non-negative integers. The events list is optional and restricts which touch gestures the region responds to.
Spinner & Busy State¶
When an event handler takes time to complete (e.g. making an API call), users get no immediate visual feedback and may press the button again. The spinner system solves this by letting your application explicitly enter and exit a busy state with a visual animation.
Defining a Spinner¶
Add a spinner section to your manifest:
spinner:
type: rotation # "rotation", "pulse", or "custom"
node: spinner_icon # SVG element ID to animate (required for rotation/pulse)
frames: 12 # frames per cycle (default 12, minimum 2)
interval_ms: 80 # ms between frames (default 80, minimum 10)
Your SVG must include an element with the spinner node ID. It's typically hidden by default and made visible during animation:
Spinner Types¶
rotation¶
Rotates the SVG node by 360/frames degrees each frame around its centre. Good for loading spinners.
pulse¶
Cycles the node's opacity between 0.2 and 1.0 in a triangle wave. Good for subtle "working" indicators.
custom¶
Use your own pre-rendered frames. Place them in one of two formats:
Numbered PNGs in assets/spinner/:
Animated GIF at assets/spinner.gif:
For custom spinners, the node field is optional (ignored).
How It Works¶
The busy state is entirely controlled by your application via two methods:
await card.start_busy()/await key.start_busy()— enters the busy state and starts the spinner animationawait card.finish_busy()/await key.finish_busy()— stops the spinner and exits the busy state
When busy:
- Spinner frames are rendered on top of the current UI state — all bindings (text, colors, images, etc.) are preserved. Only the spinner node is animated; the rest of the key/card looks exactly as it did before.
- For touchscreen cards, only the affected panel region is updated (not the entire strip)
- The normal refresh cycle skips animating slots to avoid overwriting frames
- Frames are re-generated each time the spinner starts so they always reflect the latest binding values
Duplicate start_busy() calls while already busy are no-ops.
finish_busy() when not busy is also a no-op.
Controlling the Busy Lifecycle¶
Your application decides when to start and stop the spinner. This decouples the spinner from the event handler — the handler can return immediately while the spinner keeps running until an external signal arrives.
@key.on("toggle")
async def handle():
await key.start_busy()
# Fire the API call. The spinner keeps running
# after this handler returns.
await smart_home_api.toggle_light()
# Later, when the external system confirms the new state:
async def on_state_update(new_state):
key.set("status_color", "#00ff00" if new_state else "#333333")
await key.finish_busy() # stops the spinner and re-renders
If the work completes within the handler, call both in the same handler:
@key.on("toggle")
async def handle():
await key.start_busy()
new_state = await smart_home_api.toggle_light()
key.set("status_color", "#00ff00" if new_state else "#333333")
await key.finish_busy()
Sometimes you don't need a spinner at all — the task resolves instantly.
Since the application controls the busy state, you simply don't call
start_busy() for fast operations.
Validation rules:
- For rotation and pulse types, the node must exist in the SVG
- For custom type, either assets/spinner.gif or assets/spinner/frame_*.png files must exist
Complete Example¶
name: SmartLight
type: Key
version: 1
layout: layout.svg
spinner:
type: rotation
node: loading_ring
frames: 8
interval_ms: 100
bindings:
label:
type: text
node: label
default: "Light"
status_color:
type: color
node: indicator
attribute: fill
default: "#333333"
events:
- name: toggle
source: key_press_release
max_duration_ms: 300
from deux.dui import DuiKey, load_package
spec = load_package("./SmartLight.dui")
key = DuiKey(spec)
@key.on("toggle")
async def handle():
await key.start_busy()
# Spinner starts — the key still shows the current
# label and status_color while the spinner animates in the corner.
await smart_home_api.toggle_light()
# Don't call finish_busy() here — wait for the state update callback.
async def on_light_state_changed(new_state):
"""Called by your integration when the light confirms its new state."""
key.set("status_color", "#00ff00" if new_state else "#333333")
await key.finish_busy() # stops spinner, re-renders the key
Complete Examples¶
Touchscreen Card (Audio Player)¶
name: AudioPlayer
type: TouchStripCard
version: 1
layout: layout.svg
bindings:
title:
type: text
node: title
default: "No Track"
max_width: 90
overflow: ellipsis
artist:
type: text
node: artist
default: ""
cover:
type: image
node: cover
fit: cover
placeholder_node: cover_placeholder
playing:
type: toggle
node_on: icon_pause
node_off: icon_play
default: false
progress:
type: range
node: progress_bar
default: 0.0
direction: horizontal
events:
- name: toggle_play
source: encoder_press_release
max_duration_ms: 250
- name: next
source: encoder_turn
direction: right
- name: previous
source: encoder_turn
direction: left
- name: seek_forward
source: encoder_press_turn
direction: right
accumulate: true
accumulate_delay: 0.15
- name: seek_backward
source: encoder_press_turn
direction: left
accumulate: true
accumulate_delay: 0.15
regions:
card:
x: 0
y: 0
width: 197
height: 98
events: [tap, long_press]
Physical Key (Status Indicator)¶
name: StatusKey
type: Key
version: 1
layout: layout.svg
bindings:
label:
type: text
node: label
default: "Status"
indicator_color:
type: color
node: indicator
attribute: fill
default: "#333333"
icon:
type: iconify
node: icon_group
size: 32
default: "line-md:check-all"
events:
- name: activate
source: key_press_release
max_duration_ms: 300
- name: hold
source: key_hold
hold_ms: 500
Using DUI Packages in Python¶
Loading a Package¶
from deux.dui import load_package, load_all_packages
# Load a single package
spec = load_package("path/to/AudioPlayer.dui")
# Load all packages from a directory
packages = load_all_packages("path/to/packages/")
Touchscreen Card¶
from deux.dui import DuiCard
card = DuiCard(spec, screen)
# Set binding values
card.set("title", "Bohemian Rhapsody")
card.set("artist", "Queen")
card.set("playing", True)
card.set("progress", 0.42)
# Set multiple at once
card.set_many(title="New Song", artist="Artist", progress=0.0)
# Range helpers
card.set_range("progress", 0.75)
card.adjust_range("progress", 0.01) # increment by 0.01
# Register event handlers
@card.on("toggle_play")
async def on_toggle():
...
@card.on("next")
async def on_next():
...
# Render to a PIL Image (panel-sized, sized from the SVG's intrinsic dimensions)
image = card.render()
# Or render directly to encoded device-ready bytes
panel_bytes = card.render_bytes(panel_width=800, panel_height=100)
Physical Key¶
from deux.dui import DuiKey
key = DuiKey(spec, slot)
key.set("label", "Deploy")
key.set("indicator_color", "#00ff00")
@key.on("activate")
async def on_activate():
...
# Render to encoded image bytes. key_size is REQUIRED — pass the
# target key dimensions (width, height) in pixels.
image_bytes = key.render_image(key_size=(120, 120))
Render API note.
DuiCardandDuiKeydeliberately expose different render entry points:
DuiCard.render()returns a PILImage. For encoded bytes, useDuiCard.render_bytes(panel_width=..., panel_height=...)or, when composing a touch strip,DuiCard.render_panel_bytes(...).DuiKey.render_image(key_size, image_format="JPEG")returns encoded device-readybytesdirectly; there is noDuiKey.render()method.
Validation¶
Packages are validated on load. Common errors:
- Missing required manifest fields (
name,type,version,layout) - Unknown binding type
- Binding references a
nodeID that doesn't exist in the SVG togglebinding missingnode_onornode_offsliderbinding missingmin_posormax_pos- Duplicate event names
hold_msused on a non-hold source- Invalid region coordinates (negative values)
- Invalid
categoryvalue (must be from the controlled vocabulary) - Invalid
tagsentries (must be non-empty strings)
All validation errors raise PackageError with a descriptive message.
Package Verification Tool¶
Use the verify tool to check packages before publishing to a repository. For
a full reference of all DeUX CLI utilities — including the live preview
and splash tools — see the CLI Tools guide.
Basic verification¶
Checks that the package loads correctly and reports warnings for missing metadata, oversized assets, uppercase tags, and unknown manifest keys.
Strict mode (for repository submission)¶
Promotes all warnings to errors. Use this as a gate for repository submissions — packages must have description and author to pass.
Build a repository index¶
Verifies all .dui packages in a directory and emits a repository.json to stdout containing metadata from every valid package. This JSON index is all a repository needs — no external database required.
Verification checks¶
| Check | Severity | Description |
|---|---|---|
| Package loads | error | All load_package validation passes |
description present |
warning | Non-empty string |
author present |
warning | Non-empty string |
category valid |
error | Must be from the controlled vocabulary (if set) |
| Tags are lowercase | warning | Each tag should be lowercase |
icon exists |
warning | If declared, file must exist in assets/ |
| Unknown manifest keys | warning | Catches typos like desciption |
| Package size | warning | Total assets + SVG under 2 MB |
license is SPDX |
warning | No spaces in the license identifier |