Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ulanzi-plugin-FlexradioAPI

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.

Why the fork: RIT

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.

Actions

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.

One dial, both jobs

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.

Property inspector

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.

Connecting to the radio

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.

Architecture

┌─────────────────────┐    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.

Quickstart

  1. Download the release ZIP, or clone this repo.
  2. Quit Ulanzi Studio fully (system tray → Quit) — Studio only scans its plugin directory at startup.
  3. Extract the com.ec5w.flexradio.ulanziPlugin folder into Ulanzi Studio's plugin directory:
    • Windows: %APPDATA%\Ulanzi\UlanziDeck\Plugins\
    • macOS: ~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/
  4. Launch Ulanzi Studio — the FlexRadio category appears in the action picker.
  5. Drag actions onto your device's keys, or import a bundled D100H layout — Studio Profile menu → Import → pick one from profiles/.
  6. Leave "Radio IP address" blank unless you need to pin it.

Developer setup

:: 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.

Tooling

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.

Button icons

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.

Status

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/H prologue, 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 miclevel vs mic_level setter 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

Troubleshooting

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-US

Look 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.

License

Apache-2.0 — matching the Ulanzi SDK and the upstream plugin. See NOTICE for attribution.

About

Ulanzi plugin for Flexradio API with RIT wheel support. Works on SmartSDR and AetherSDR

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages