Ulanzi Studio plugin that drives a FlexRadio FLEX-6000/8000 directly, from a Ulanzi macro keypad or dial. Bridges the Ulanzi SDK (which talks to the physical device over Bluetooth) to the radio's own SmartSDR TCP/IP API on port 4992 — no TCI bridge, no desktop app in the middle.
Fork of nigelfenton/aethersdr-ulanzi-plugin, which drives AetherSDR over TCI.
TCI has no verb for the RIT offset. It can switch RIT on and off (rit_enable:0,true;) and that is all — so a dial physically cannot walk RIT up and down through a TCI link. The radio's own API does expose it, as an ordinary slice property:
slice s 0 rit_on=1
slice s 0 rit_freq=-250 # offset in Hz
Everything else in the plugin follows from having swapped the transport: the same actions the TCI version had, re-expressed as SmartSDR commands, plus the RIT actions that were impossible before.
One thing worth knowing about rit_freq: it is absolute, not incremental. There is no "RIT +1" command. So "dial one click up" means read the offset the radio last reported, add the step, write the sum back. The plugin therefore keeps a live mirror of the radio's state, fed from the status stream it subscribes to at connect. That mirror is what makes the RIT dial — and every toggle and ± step in the plugin — work at all. It also means the plugin stays in sync when you drag RIT around in SmartSDR at the same time: the radio broadcasts the change, the mirror updates, and your next click builds on the new value rather than a stale one.
24 actions. The bundled D100H profiles use 8 of them (one dial + 7 keys); larger devices can fan out to the full set.
| Action | Controller | SmartSDR command |
|---|---|---|
| VFO Tune | Encoder | slice t <n> <MHz> — rotate = step, press+rotate = coarse, press = switch the dial to RIT and back |
| RIT Tune | Encoder | slice s <n> rit_on=1 rit_freq=<Hz> — rotate = RIT up/down, press = clear |
| RIT Up / RIT Down | Keypad | same, one step per press |
| RIT Clear | Keypad | slice s <n> rit_freq=0 (leaves RIT enabled) |
| RIT Toggle | Keypad | slice s <n> rit_on=0|1 |
| Dial Mode | Keypad | none — shows whether the dial is on VFO or RIT plus the live offset, and switches it |
| MOX Toggle | Keypad | xmit 0|1 |
| TUNE (carrier) | Keypad | transmit tune on|off |
| ATU Tune | Keypad | atu start |
| Mode Cycle | Keypad | slice s <n> mode=… — USB → LSB → CW → DIGU → DIGL → AM → FM |
| Mode USB / LSB / CW / DIGU | Keypad | slice s <n> mode=<MODE> |
| Band Up / Band Down | Keypad | slice t <n> <MHz> to the next band centre |
| Slice Cycle | Keypad | slice s <n> active=1 — moves the radio's real slice selection |
| AF Gain Up / Down | Keypad | slice s <n> audio_level=<0-100> |
| RF Power Up / Down | Keypad | transmit set rfpower=<0-100> |
| Mic Gain Up / Down | Keypad | transmit set miclevel=<0-100> |
Every action targets the radio's active slice, not a hardcoded slice 0 — select a different slice in SmartSDR (or with the Slice Cycle key) and the dial follows.
Mic gain is worth a note: the upstream TCI plugin ships those two keys marked non-functional, because mic_level isn't in the TCI spec and AetherSDR ignores it. Over the radio's own API it is a real, supported parameter, so here the keys actually do something.
Devices like the D100H have exactly one encoder, and you want it on the VFO and on RIT. So a short press on the VFO Tune dial flips what rotation controls — VFO, RIT, VFO. Press-and-rotate is still the coarse step.
Which leaves the obvious question: how do you know which mode you're in? The D100H's encoder has no screen of its own, so the plugin ships a Dial Mode key for exactly this. Put it next to the dial and it shows DIAL / VFO in blue or DIAL / RIT in amber, captioned with the live RIT offset — and pressing it switches the dial too, so it's an indicator and a second switch in one key. The bundled D100H profile places it at the key beside the encoder.
The caption tracks the offset whenever RIT is on, whichever mode the dial is in, and it follows the radio: drag RIT around in SmartSDR and the key keeps up. On Ulanzi devices that do have a display behind the dial, the VFO tile itself switches icons the same way; Studio ignores the call harmlessly when there's no screen there.
That last sentence is the awkward part, and it's why the press is not handled the way you might expect. Coarse tuning is press-and-hold-while-rotating, so Studio emits a dialdown before every coarse step. Acting on that dialdown — which is what the upstream plugin does — flips the mode every time you reach for a coarse move. (On the upstream default binding it's worse than annoying: the press is MOX, so a coarse tuning move keys the transmitter.) So dialGesture.js defers the press until it knows which gesture it was:
- tap → fires on release, immediately
- press, hold, no rotation → fires on a 350 ms timer, so it still works if the device never sends a dialup event
- press + rotate → never fires
If you'd rather have the old behaviour, the press is still configurable: MOX, next slice, cycle mode, or nothing.
Per-action settings: radio address and port, VFO step size and coarse multiplier, RIT step size / coarse multiplier / limit, dial-press behaviour, gain step. The plugin holds one shared connection to the radio, so changing the address on any action reconnects all of them.
Leave the address blank and the plugin finds the radio by itself. A FLEX broadcasts a discovery packet to 255.255.255.255:4992 about once a second; the plugin listens for it, reads the IP out of the payload, and connects. Set an explicit IP when you have more than one radio on the LAN, or when the radio is on another subnet where broadcasts don't reach you.
Two deliberate choices in how it connects:
- It does not send
client gui. That claims the sole-GUI role on v1/v2 firmware and would boot SmartSDR off the radio. The plugin connects as a plain control client and rides alongside whatever GUI you're running — SmartSDR, AetherSDR, Maestro. - It does not enable the API's
keepalive. That mode obliges the client to ping at least once a second and drops the socket after 15 seconds of silence. TCP-level keepalive plus reconnect-with-backoff gives the same liveness guarantee without the per-second traffic.
If the radio goes away, the plugin retries with exponential backoff (1s → 30s) and re-subscribes on reconnect.
┌─────────────────────┐ BLE ┌─────────────────────┐ WebSocket ┌─────────────────────┐
│ Ulanzi D100H / │ ──────────► │ Ulanzi Studio │ ─────────────► │ This plugin │
│ LCD keypad │ │ (Windows / macOS) │ 127.0.0.1:3906 │ (Node.js) │
└─────────────────────┘ └─────────────────────┘ └──────────┬──────────┘
│ TCP 4992
│ (+ UDP 4992
│ discovery)
▼
┌─────────────────────┐
│ FLEX-6000/8000 │
│ SmartSDR API │
└─────────────────────┘
The plugin runs as a Node.js process inside Ulanzi Studio, loaded via CodePath in manifest.json.
Source layout:
| File | Responsibility |
|---|---|
plugin/app.js |
Glue: Studio events, per-action settings, connection management |
plugin/radio.js |
Radio model + every command builder — pure logic, no I/O, fully unit-tested |
plugin/flexClient.js |
The TCP client: C|/R|/S| framing, sequence numbers, reconnect |
plugin/discovery.js |
UDP discovery listener |
plugin/dialGesture.js |
Telling a dial press apart from a press-and-rotate |
The radio link itself needs no third-party packages — Node's built-in net and dgram. The one npm dependency, ws, belongs to the Ulanzi SDK's own link back to Studio.
- Download the release ZIP, or clone this repo.
- Quit Ulanzi Studio fully (system tray → Quit) — Studio only scans its plugin directory at startup.
- Extract the
com.ec5w.flexradio.ulanziPluginfolder into Ulanzi Studio's plugin directory:- Windows:
%APPDATA%\Ulanzi\UlanziDeck\Plugins\ - macOS:
~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/
- Windows:
- Launch Ulanzi Studio — the FlexRadio category appears in the action picker.
- Drag actions onto your device's keys, or import a bundled D100H layout — Studio Profile menu → Import → pick one from
profiles/. - Leave "Radio IP address" blank unless you need to pin it.
:: Windows — symlink so edits go live without re-copying
mklink /J "%APPDATA%\Ulanzi\UlanziDeck\Plugins\com.ec5w.flexradio.ulanziPlugin" ^
"<path-to-repo>\com.ec5w.flexradio.ulanziPlugin"# macOS
ln -s "<path-to-repo>/com.ec5w.flexradio.ulanziPlugin" \
"$HOME/Library/Application Support/Ulanzi/UlanziDeck/Plugins/com.ec5w.flexradio.ulanziPlugin"Then cd com.ec5w.flexradio.ulanziPlugin && npm install, edit, restart Studio.
| Command | What it does |
|---|---|
node scripts/test-protocol.mjs |
43 offline checks — RIT arithmetic, status parsing, dial-gesture handling, and the wire client spoken against a fake radio over a real socket. No hardware needed. |
node scripts/probe-radio.mjs [ip] [seconds] |
Read-only. Connects, subscribes, dumps the raw status stream next to the state the plugin decodes from it. The tool to reach for when a key "doesn't work". |
pwsh scripts/Generate-Icons.ps1 |
Regenerates all 25 action PNGs (196×196, GDI+, Windows-only). |
pwsh scripts/Build-Profiles.ps1 |
Rebuilds the bundled .ulanziDeckProfile files from the layout table in the script. |
pwsh scripts/Build-Release.ps1 |
Builds the operator-facing release ZIP; reads the version from manifest.json. |
probe-radio.mjs sends sub and slice list and nothing else — it cannot change a radio setting.
All action icons are generated programmatically by Generate-Icons.ps1 — pure PowerShell + System.Drawing, no npm. 196×196 PNGs with text baked into the pixel data, because the Ulanzi device LCD renderer strips <text> from SVGs. The RIT cluster gets its own amber tile colour so it reads as a group on the device. Operators can still override any key with a custom image from Studio's right-click menu.
Version 0.1.0.
Verified live against a FLEX-8400 running SmartSDR v4.2.20:
- UDP discovery — radio found and auto-selected without configuration
- TCP connect,
V/Hprologue, subscriptions,slice list - Status decoding — slice (frequency, mode,
rit_on/rit_freq/xit_on/xit_freq,audio_level,active),interlock,transmit,atu. Captured lines from that radio are pinned as test fixtures.
That read-only pass caught a real bug: the radio reports mic gain as mic_level, while the API docs give miclevel for the setter. The parser now accepts both, and the mic-gain action retries with the other spelling if the radio rejects the first.
Reported working on a D100H against that radio. Everything still unconfirmed on hardware:
- First light — the operator reports the plugin driving the radio correctly from the dial and keys
- Confirm the
miclevelvsmic_levelsetter spelling (the fallback handles either, but it's untested) - Confirm the Dial Mode key repaints on the device (
setStateIcon) — the D100H encoder has no screen, so this key is the only visual cue for VFO-vs-RIT - LCD feedback for the other keys — TX colour, current mode
- macOS testing
Plugin doesn't appear in Studio's plugin list. Restart Studio fully — system tray → Quit, then relaunch. Closing the window leaves the cached list in place.
Actions don't fire. Run the plugin with a visible stdout instead of Studio's spawned process:
node plugin/app.js 127.0.0.1 3906 en-USLook for [flex] ready. [flex] DROPPED (not connected) means the radio link is down; [flex] !! "<cmd>" rejected: <code> means the radio refused a command and the hex code says why.
Nothing found on the LAN. [discovery] udp/4991 unavailable: bind EACCES is harmless — 4992 is the port discovery actually uses and 4991 is only bound as a fallback. If 4992 also fails, another client already holds it; set the radio IP explicitly instead.
Reconnecting a D100H after a Bluetooth flap. Removing and re-pairing from Windows Settings does not help — the OS still thinks the device is paired. Reset it device-side: unplug the USB lead (it must be on battery for the pairing UI to return), power off, wait 10–15 seconds, power on, then pick the right BT channel with the three indicator LEDs on the underside (it multi-pairs to three hosts). It reconnects within a few seconds.
Apache-2.0 — matching the Ulanzi SDK and the upstream plugin. See NOTICE for attribution.