Skip to main content

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 TypeDisplayNet
Minimum Version5.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

ModeRotationPress
notify[=<name>]Broadcasts streamdeck_notify carrying the signed tick count. Routes nothing.Broadcasts streamdeck_notify carrying Pressed. Routes nothing.
adjustRuns rotate=<action> once per input event with {delta} = the signed tick count.Runs the optional press=<action> as a reset or toggle.
selectMoves 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.

ProviderCandidatesPress
@sourcesTransmitters in the device catalog.Whatever press= specifies, typically a connect.
@multiviewsMultiview configurations.Typically command mtv connect {item} <seat>.
@mtv-windowsControllable windows in the seat's current MultiView layout.Typically builtin focus {index}.
@statusRead-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.

The two positions of notify

notify 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.7 on a range=0-100:5 dial 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.
External writes and commits

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:

ReasonWhen
startupEngine start, once bindings are compiled
mountA deck with dial bindings mounts at a seat
bindA dial binding is created or changed
requestdial 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:

PlaceholderModeValue
{delta}adjustSigned tick count for this input event, not one per tick. A fast spin reports several ticks in one event.
{item}selectThe highlighted candidate's display name. For a range, the number.
{itemid}selectThe highlighted candidate's stable id. For a static list or a range this equals {item}.
{index}selectThe 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 select dial shows its position in the list (3/8), except on a range, where the value already is the position.

Arguments

ArgumentDescription
remote_idThe device name or ID of the remote HID extender, the RX seat where the deck is plugged in. Matched case-insensitively.
dialA 0-based dial index. Dial 0 is the leftmost.
itemsA 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 / pressActions, each in the same <builtin|command|script|sendkeys> <payload> form as a button action. Which applies depends on the mode.
notifyAnnounce the committed value. Bare, or notify=<name> to carry a label a client keys on.
labelThe dial's label on the touch strip. Underscores render as spaces.
valueFor 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 modes
kvm 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 lists
kvm 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 value
kvm streamdeck dial bind 6cdffb00f077 0 select range=0-100:5 notify=Volume label=Volume
Announce a value AND route it
kvm 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 providers
kvm streamdeck dial list 6cdffb00f077
kvm streamdeck dial providers
A controller writing its level in, and asking both ends to resync
kvm 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, or select
  • An invalid or negative dial index
  • A select dial with neither items= nor range=, 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 select dial with no press= action, no notify, and a provider that is not read-only
  • A select dial given a rotate= action. Rotation browses automatically
  • An adjust dial with no rotate= action
  • dial set on a dial with no binding, or on an adjust or notify dial. Only select dials hold a value
  • dial set given 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:

KindValue typeSource
rangeJSON numberrange=<lo>-<hi>[:<step>]
listJSON stringitems=<a,b,c>
providerJSON stringitems=@<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.

ReasonWhen
startupEngine start, once bindings are compiled
mountA deck with dial bindings mounts at a seat
bindA dial binding is created or changed
requestdial 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.

Payload shapes

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:

SourceDiscriminatorFull payload
Dial rotationhas Dial + TicksRemoteId, Dial, Name, Ticks
Dial presshas Dial + PressedRemoteId, Dial, Name, Pressed
Button presshas ButtonRemoteId, 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 typeDisplayNet API command
AddressPOST /api/displaynet/<operation>, or name the operation in the body of a POST /api/displaynet. See Sending commands.
RoleUser
SubcommandOperationParameters
streamdeckkvm_streamdeckaction [args]*
streamdeck_dial_bindkvm_streamdeck_dial_bindremote_id dial mode [rotate] [press] [items] [range] [notify] [label] [model] [config]
streamdeck_dial_listkvm_streamdeck_dial_list[remote_id]
streamdeck_dial_setkvm_streamdeck_dial_setremote_id dial value
streamdeck_dial_synckvm_streamdeck_dial_syncremote_id [dial]
streamdeck_dial_unbindkvm_streamdeck_dial_unbindremote_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