kvm streamdeck dial
The kvm streamdeck dial command binds the rotary encoders on a Stream Deck + family deck (the + and the + XL), and drives the touch strip above them.
Each dial is one composite binding whose <mode> decides how rotation and press behave. Storing both halves together is deliberate: in select mode turning and pressing are two halves of a single gesture, not two independent bindings.
Dials are addressed by seat and a 0-based dial index, exactly like buttons. A deck with no dials rejects the bind.
| Command Type | DisplayNet |
| Minimum Version | 5.0 |
Usage
kvm streamdeck dial bind <remote_id> <dial> <notify[=<name>]|adjust|select>
[rotate=<action...>] [press=<action...>]
[items=<a,b,c>|@<provider>] [range=<lo>-<hi>[:<step>]]
[notify[=<name>]] [label=<text...>] [config=<name>]
kvm streamdeck dial unbind <remote_id> <dial> [config=<name>]
kvm streamdeck dial list [<remote_id>]
kvm streamdeck dial providers
kvm streamdeck dial set <remote_id> <dial> <value>
kvm streamdeck dial sync <remote_id> [<dial>]
Binding scope
A dial binding lives in the configuration its seat resolves, alongside that seat's buttons and keyboard macros. See Scope. Add config=<name> to write into a configuration the seat does not currently resolve; on unbind it names the configuration to remove from. config= is not valid on kvm config dial add, where the configuration is already the first argument.
Modes
| Mode | Rotation | Press |
|---|---|---|
notify[=<name>] | Broadcasts streamdeck_notify carrying the signed tick count. Routes nothing. | Broadcasts streamdeck_notify carrying Pressed. Routes nothing. |
adjust | Runs rotate=<action> once per input event with {delta} = the signed tick count. | Runs the optional press=<action> as a reset or toggle. |
select | Moves a local highlight through the candidate list and broadcasts streamdeck_dial_browse. Routes nothing. | Commits the highlighted candidate: runs press=<action>, announces the value, or both. |
select is browse-and-commit: turning the dial only moves a highlight, and the action fires on press. A routed command never fires per rotation tick, so spinning a dial cannot flood the system with switching commands.
Candidates
A select dial browses one of three things:
items=Cam1,Cam2,Cam3 # static list, fixed at bind time
items=@sources # provider, resolved live every time the dial is turned
range=0-100 # numeric range
range=0-100:5 # numeric range with a step (0, 5, 10 … 100)
items= and range= are mutually exclusive.
Providers
A provider keeps the list correct as the system changes, with sources appearing and multiview configs being renamed, without touching the binding. An unknown @name is rejected at bind time, listing the valid providers.
| Provider | Candidates | Press |
|---|---|---|
@sources | Transmitters in the device catalog. | Whatever press= specifies, typically a connect. |
@multiviews | Multiview configurations. | Typically command mtv connect {item} <seat>. |
@mtv-windows | Controllable windows in the seat's current MultiView layout. | Typically builtin focus {index}. |
@status | Read-only seat readouts. Rotation only changes which reading is shown; press does nothing, so press= is not required. | — |
Use dial providers to list them at runtime, including which are read-only.
Starting position
A provider dial's highlight starts on the candidate that reflects the seat's current state, and follows it when that state changes elsewhere: the routed source for @sources, the focused window for @mtv-windows, the connected configuration for @multiviews. Each engine-made move is announced as streamdeck_dial_browse with External: true.
A highlight the operator has rotated to holds until the next press. Committing returns the dial to following the seat, since the committed candidate is then the seat's state.
Static lists and ranges have no system state to reflect; they start on their first value.
Ranges
range=<lo>-<hi>[:<step>] generates its candidates rather than storing them, which is how a dial covers 0 to 100 without a hundred-item list. Bounds are integers and inclusive; the step defaults to 1. A range is rejected at bind time if the high bound does not exceed the low one, the step is not at least 1 or is wider than the range itself, or the result would exceed 1000 values.
Browsing past the end of a list returns to the beginning.
A range stops at its bounds. This is deliberate and is not a rounding of the same behaviour: a volume that jumps from 100 to 0 on one extra detent is a hazard to someone's ears or someone's speakers.
Announcing the value
Add notify (or notify=<name>) to a select or adjust dial and its press broadcasts streamdeck_dial_commit carrying the value the operator settled on. This is what a third-party control system, a Q-SYS plugin for example, binds to when it needs an explicit value rather than a stream of tick deltas.
When notify is present, press= becomes optional: announcing the value is the commit. A dial with both an action and a notify does both on the same press. This mirrors how the notify modifier already works on buttons.
notifynotify in the mode word position makes the dial a pure announcer that routes nothing and reports tick deltas. notify= in the trailing modifier position adds a value announcement to a select or adjust dial. The two are distinguished by position, and existing notify-mode bindings are unaffected.
Two-way control
notify makes a dial readable from outside. dial set makes it writable, so a controller that owns the underlying value, a Q-SYS audio level for example, can keep the dial in agreement with reality rather than only hearing about it after the fact.
kvm streamdeck dial set <remote_id> <dial> <value>
This moves the dial's highlight and repaints the touch strip. It is a state update, not a commit: the dial's press= action does not run, so adopting a controller's level never routes anything.
- Range dials snap. A controller sends real-world values that need not land on a step.
42.7on arange=0-100:5dial applies 45, and the response reports the value actually applied rather than the one requested. Values outside the bounds clamp to the nearest endpoint. - List and provider dials match by identity, against either the candidate's id or its display name. A value matching nothing is an error, and the highlight does not move.
dial set deliberately does not emit streamdeck_dial_commit. That event means an operator decided. If an external write echoed back as a decision, the controller that made the write would see its own echo and write again: set, commit, set, commit. Against a fader being turned by hand at the same time, that loop oscillates audibly.
An external write announces itself as streamdeck_dial_browse carrying External: true instead, so other clients mirroring the dial still update and the originating controller can recognise and discard its own echo.
Keeping both ends in agreement
kvm streamdeck dial sync <remote_id> [<dial>]
Asks the engine to re-announce its dial values as streamdeck_dial_sync. Only dials carrying a notify name participate. The engine also emits the event unprompted:
Reason | When |
|---|---|
startup | Engine start, once bindings are compiled |
mount | A deck with dial bindings mounts at a seat |
bind | A dial binding is created or changed |
request | dial sync was called |
The event carries the engine's current value rather than a bare request for the controller's, so a controller that already agrees can stay quiet instead of writing back on every restart.
A dial's position lives in memory and is lost on restart. A provider dial re-seeds from the seat's current state (see Starting position), so its sync reports reality. A list or range dial has no state to read: a restored value would be a stale value asserted confidently, so it reports its starting value and invites correction.
When the two ends disagree, the controller wins for dials it owns. It holds the real state, where the deck holds only a knob position. A controller that disagrees with a streamdeck_dial_sync calls dial set, and the engine adopts it silently. The engine never overrides a controller on its own initiative; the only thing that does is an operator physically turning the dial, which is exactly the input that should win.
Substitutions
A dial action's payload may contain placeholders, filled in when the action runs:
| Placeholder | Mode | Value |
|---|---|---|
{delta} | adjust | Signed tick count for this input event, not one per tick. A fast spin reports several ticks in one event. |
{item} | select | The highlighted candidate's display name. For a range, the number. |
{itemid} | select | The highlighted candidate's stable id. For a static list or a range this equals {item}. |
{index} | select | The highlighted candidate's 1-based position at the moment of the press, which is what index-taking builtins such as focus consume. |
A live candidate list can change between browsing and pressing, as a source drops off or a window closes. The press re-finds the highlighted candidate by its identity, and if it has disappeared the press does nothing rather than acting on whatever moved into that position. {index} is therefore resolved at press time, not stored when browsing.
Touch strip
On a deck with a touch strip, the strip renders one column per dial showing each dial's label and current value, repainted as a dial is turned.
- Dials with no binding show a dim placeholder rather than blank space, so an unconfigured strip is distinguishable from a failed one.
- Columns whose candidate maps to a source device show that source's live thumbnail; everything else renders as text.
- A
selectdial shows its position in the list (3/8), except on a range, where the value already is the position.
Arguments
| Argument | Description |
|---|---|
remote_id | The device name or ID of the remote HID extender, the RX seat where the deck is plugged in. Matched case-insensitively. |
dial | A 0-based dial index. Dial 0 is the leftmost. |
items | A static comma-separated list, or @<provider> for a live list. Mutually exclusive with range=. |
range | <lo>-<hi>[:<step>], integers, bounds inclusive. Mutually exclusive with items=. |
rotate / press | Actions, each in the same <builtin|command|script|sendkeys> <payload> form as a button action. Which applies depends on the mode. |
notify | Announce the committed value. Bare, or notify=<name> to carry a label a client keys on. |
label | The dial's label on the touch strip. Underscores render as spaces. |
value | For dial set: the value to adopt. A number on a range dial (snapped to the step, clamped to the bounds); a candidate's id or display name otherwise. |
Examples
The three modeskvm streamdeck dial bind 6cdffb00f077 0 notify=VolumeKnob
kvm streamdeck dial bind 6cdffb00f077 1 adjust rotate=command volume set {delta} press=command volume mute label=Volume
kvm streamdeck dial bind 6cdffb00f077 2 select items=Cam1,Cam2,Cam3 press=command connect HDMI {item} 6cdffb00f077 label=Source
Live provider listskvm streamdeck dial bind 6cdffb00f077 3 select items=@sources press=command connect HDMI {item} 6cdffb00f077 label=Source
kvm streamdeck dial bind 6cdffb00f077 4 select items=@mtv-windows press=builtin focus {index} label=Window
kvm streamdeck dial bind 6cdffb00f077 5 select items=@status
A volume dial for a third-party controller — no DisplayNet action, just the valuekvm streamdeck dial bind 6cdffb00f077 0 select range=0-100:5 notify=Volume label=Volume
Announce a value AND route itkvm streamdeck dial bind 6cdffb00f077 1 select range=1-10 notify=Zone press=command preset run Zone{item}
List a seat's dials, and the available providerskvm streamdeck dial list 6cdffb00f077
kvm streamdeck dial providers
A controller writing its level in, and asking both ends to resynckvm streamdeck dial set 6cdffb00f077 0 42.7 # a 0-100:5 dial adopts 45
kvm streamdeck dial set 6cdffb00f077 3 Podium PC # a list dial, matched by name
kvm streamdeck dial sync 6cdffb00f077 # re-announce every notify dial
kvm streamdeck dial sync 6cdffb00f077 0 # just dial 0
Return value
kvm streamdeck dial list
Each entry is one dial binding. RemoteId carries the seat id, as it does for buttons. Candidates holds the static comma list, the @provider reference, or a range in its stored #lo-hi[:step] form. RotateActionType / PressActionType and their payloads appear only for the halves the mode uses, and NotifyName only when the dial announces.
kvm streamdeck dial list 6cdffb01f5ba{
"status": "SUCCESS",
"request_id": null,
"result": {
"streamdeck_dial_list": [
{
"RemoteId": "6CDFFB01F5BA",
"Dial": 0,
"Mode": "select",
"Enabled": true,
"PressActionType": "command",
"PressActionPayload": "connect HDMI {item} 6cdffb01f5ba",
"Candidates": "@sources",
"StripLabel": "Source"
},
{
"RemoteId": "6CDFFB01F5BA",
"Dial": 4,
"Mode": "select",
"Enabled": true,
"NotifyName": "Volume",
"Candidates": "#0-100:5",
"StripLabel": "Volume"
}
]
},
"error": null
}
kvm streamdeck dial providers
The candidate providers usable as items=@<name>. read_only marks a provider whose press does nothing, and for which bind therefore does not require a press= action.
kvm streamdeck dial providers{
"status": "SUCCESS",
"request_id": null,
"result": {
"streamdeck_dial_providers": [
{
"name": "@sources",
"description": "All transmitters, newest catalog state",
"read_only": false
},
{
"name": "@status",
"description": "Read-only seat readouts; rotate to cycle, press does nothing",
"read_only": true
}
]
},
"error": null
}
Binding subcommands
bind and unbind both return the same envelope.
kvm streamdeck dial bind 6cdffb00f077 0 select range=0-100 notify=Volume{
"status": "SUCCESS",
"request_id": null,
"result": null,
"error": null
}
kvm streamdeck dial set
Reports the value actually applied, which on a stepped range is the snapped one rather than what was requested. Index and Count place it in the candidate list, and Kind matches streamdeck_dial_commit.
kvm streamdeck dial set 6cdffb01f5ba 0 42.7 — a range=0-100:5 dial{
"status": "SUCCESS",
"request_id": null,
"result": {
"streamdeck_dial_set": [
{
"RemoteId": "6CDFFB01F5BA",
"Dial": 0,
"Value": 45,
"Index": 9,
"Count": 21,
"Kind": "range"
}
]
},
"error": null
}
Value is typed by Kind, exactly as on streamdeck_dial_commit: a JSON number for a range, a string for a list or provider dial.
kvm streamdeck dial sync
Dials is how many streamdeck_dial_sync entries were emitted. Zero is meaningful: it says no dial at that seat carries a notify name, which is the difference between a working sync and a silent no-op a caller would otherwise wait on.
kvm streamdeck dial sync 6cdffb01f5ba{
"status": "SUCCESS",
"request_id": null,
"result": {
"streamdeck_dial_sync": [
{ "RemoteId": "6CDFFB01F5BA", "Dials": 2 }
]
},
"error": null
}
Errors
Common error conditions:
- Unknown dial mode. Expected
notify,adjust, orselect - An invalid or negative dial index
- A
selectdial with neitheritems=norrange=, or with both - An unknown
items=@<provider>; the error lists the valid providers - A malformed range, a step of less than 1 or wider than the range, or a range yielding more than 1000 values
- A
selectdial with nopress=action, nonotify, and a provider that is not read-only - A
selectdial given arotate=action. Rotation browses automatically - An
adjustdial with norotate=action dial seton a dial with no binding, or on anadjustornotifydial. Onlyselectdials hold a valuedial setgiven a non-numeric value on a range dial, or a value matching no candidate on a list or provider dial
Notifications
streamdeck_dial_browse
Sent whenever a select dial's highlight moves: on each rotation, on a dial set, and when the engine aligns a provider dial with the seat's state (Starting position). It carries the highlighted candidate so a client can mirror what the operator sees on the touch strip. Rotation routes nothing; this event and the strip repaint are its only effects.
Item is the candidate's display name, ItemId its stable identity, Index its 0-based position and Count the list length.
External distinguishes the source: false when an operator turned the dial, true when the engine wrote the value (a dial set, or a provider dial aligning with the seat's state). A controller uses it to recognise and discard the echo of its own write; a client that only mirrors the dial can ignore it and treat both identically.
streamdeck_dial_browse notification{
"status": "DN_NOTIFICATION",
"request_id": null,
"error": null,
"result": {
"streamdeck_dial_browse": [
{
"RemoteId": "6CDFFB01F5BA",
"Dial": 0,
"Item": "Podium PC",
"ItemId": "6cdffb00f1b7",
"Index": 2,
"Count": 8,
"External": false
}
]
}
}
streamdeck_dial_commit
Sent on press of a select dial carrying a notify, with the value the operator settled on. This is the event a third-party control system binds to.
Kind is always present and declares how to read Value:
Kind | Value type | Source |
|---|---|---|
range | JSON number | range=<lo>-<hi>[:<step>] |
list | JSON string | items=<a,b,c> |
provider | JSON string | items=@<provider> |
streamdeck_dial_commit — a range dial{
"status": "DN_NOTIFICATION",
"request_id": null,
"error": null,
"result": {
"streamdeck_dial_commit": [
{
"RemoteId": "6CDFFB01F5BA",
"Dial": 0,
"Name": "Volume",
"Kind": "range",
"Value": 45,
"Index": 9,
"Count": 21
}
]
}
}
Rotation does not fire this event. The operator has not decided yet, and streamdeck_dial_browse already carries the live highlight for a client that wants to follow the dial mid-turn.
Neither does dial set. A value written in from outside is a state update, not a decision, and echoing it back as one creates a feedback loop.
A press whose highlighted candidate has disappeared fires streamdeck_dial_stale instead.
streamdeck_dial_stale
Sent when a press finds that the highlighted candidate has disappeared from a live list. Nothing is routed, no streamdeck_dial_commit follows, and ItemId names the vanished candidate. The highlight is cleared. On a provider dial the engine then re-seeds it from the seat's current state; on a static list a following press commits the first candidate.
streamdeck_dial_stale notification{
"status": "DN_NOTIFICATION",
"request_id": null,
"error": null,
"result": {
"streamdeck_dial_stale": [
{
"RemoteId": "6CDFFB01F5BA",
"Dial": 0,
"ItemId": "6cdffb00f1b7"
}
]
}
}
streamdeck_dial_sync
Announces what the engine currently holds for a seat's notify-carrying select dials, so a controller can compare against its own state and correct the engine if the two disagree. Sent on request via dial sync, and unprompted whenever dial state may have gone stale.
All of a seat's dials arrive in one notification, one entry each. RemoteId, Dial, Name, Kind, Value, Index and Count mean exactly what they do on streamdeck_dial_commit, including Value being a JSON number for a range and a string otherwise, plus Reason.
Reason | When |
|---|---|
startup | Engine start, once bindings are compiled |
mount | A deck with dial bindings mounts at a seat |
bind | A dial binding is created or changed |
request | dial sync was called |
streamdeck_dial_sync — a deck just mounted at the seat{
"status": "DN_NOTIFICATION",
"request_id": null,
"error": null,
"result": {
"streamdeck_dial_sync": [
{
"RemoteId": "6CDFFB01F5BA",
"Dial": 0,
"Name": "Volume",
"Kind": "range",
"Value": 45,
"Index": 9,
"Count": 21,
"Reason": "mount"
}
]
}
}
Dial position is not persisted. After a restart a provider dial re-seeds from the seat's state, so this event carries reality; a list or range dial reports its starting value, and the controller, which holds the real state, corrects it with dial set.
streamdeck_notify
A dial in notify mode broadcasts this on each rotation and each press. The same event name is also used by buttons, with a different payload.
streamdeck_notify does not have a single schema. A client cannot switch on the event name alone. It must discriminate on which fields are present:
| Source | Discriminator | Full payload |
|---|---|---|
| Dial rotation | has Dial + Ticks | RemoteId, Dial, Name, Ticks |
| Dial press | has Dial + Pressed | RemoteId, Dial, Name, Pressed |
| Button press | has Button | RemoteId, Button, Page, Name |
Every shape carries RemoteId (the seat id) and Name. What differs is whether the event names a Button or a Dial, and which value field accompanies it. For a committed value, streamdeck_dial_commit has one shape and declares its own type.
streamdeck_notify — dial rotation (Ticks is signed; magnitude grows with spin speed){
"status": "DN_NOTIFICATION",
"request_id": null,
"error": null,
"result": {
"streamdeck_notify": [
{ "RemoteId": "6CDFFB01F5BA", "Dial": 2, "Name": "VolumeKnob", "Ticks": -3 }
]
}
}
streamdeck_notify — dial press{
"status": "DN_NOTIFICATION",
"request_id": null,
"error": null,
"result": {
"streamdeck_notify": [
{ "RemoteId": "6CDFFB01F5BA", "Dial": 0, "Name": "Mute", "Pressed": true }
]
}
}
REST API
| Endpoint type | DisplayNet API command |
|---|---|
| Address | POST /api/displaynet/<operation>, or name the operation in the body of a POST /api/displaynet. See Sending commands. |
| Role | User |
| Subcommand | Operation | Parameters |
|---|---|---|
streamdeck | kvm_streamdeck | action [args]* |
streamdeck_dial_bind | kvm_streamdeck_dial_bind | remote_id dial mode [rotate] [press] [items] [range] [notify] [label] [model] [config] |
streamdeck_dial_list | kvm_streamdeck_dial_list | [remote_id] |
streamdeck_dial_set | kvm_streamdeck_dial_set | remote_id dial value |
streamdeck_dial_sync | kvm_streamdeck_dial_sync | remote_id [dial] |
streamdeck_dial_unbind | kvm_streamdeck_dial_unbind | remote_id dial [model] [config] |
A parameter in brackets is optional. A parameter marked * takes the remainder of the command line as one string. Pass the text exactly as you would type it on the TCI interface.
Every dial action except providers has a typed operation taking named fields. rotate, press, items, range, label and model are separate fields rather than key=value tokens, and a missing or unknown field is rejected before the command runs.
providers has no typed operation. It runs through kvm_streamdeck, which takes a raw grammar line: action and args, spelled as under Usage above.
See also
kvm streamdeck: buttons, tile styling, deck status, and the seat/scope model dials share- Workstations: what a workstation is and how its pieces fit together
kvm config: the seat-configuration scope model bindings resolve through