English · 简体中文
Official development kit for UlanziStudio programmable macro keypad platform
SDK Version: Based on Ulanzi JS Plugin Development Protocol V2.1.2 Compatible: Ulanzi Studio 3.0.11
Create a new folder with the naming convention com.ulanzi.{pluginName}.ulanziPlugin:
com.ulanzi.myplugin.ulanziPlugin/
├── manifest.json # Plugin configuration (required)
├── libs/ # SDK libraries
├── resources/ # Icons and assets
├── plugin/
│ └── app.js # Main service entry
└── property-inspector/
└── inspector.html # Configuration UINaming Convention:
- Plugin folder:
com.ulanzi.{pluginName}.ulanziPlugin - Main service UUID:
com.ulanzi.ulanzistudio.{pluginName}(4 segments) - Action UUID:
com.ulanzi.ulanzistudio.{pluginName}.{actionName}(5+ segments)
Create manifest.json in the plugin root directory:
{
"Author": "Your Name",
"Name": "My Plugin",
"Description": "Plugin description",
"Icon": "resources/icon.png",
"Version": "1.0.0",
"UUID": "com.ulanzi.ulanzistudio.myplugin",
"Type": "JavaScript",
"CodePath": "plugin/app.js",
"Actions": [
{
"Name": "My Action",
"Icon": "resources/action-icon.png",
"UUID": "com.ulanzi.ulanzistudio.myplugin.action",
"PropertyInspectorPath": "property-inspector/inspector.html",
"States": [
{ "Name": "Default", "Image": "resources/action-icon.png" }
],
"Controllers": ["Keypad"]
}
],
"OS": [
{ "Platform": "windows", "MinimumVersion": "10" },
{ "Platform": "mac", "MinimumVersion": "10.11" }
]
}Required Fields:
UUID— Plugin unique identifier (exactly 4 segments)CodePath— Main entry point (.htmlfor WebView,.jsfor Node.js)Actions— Array of action definitionsType— Fixed value"JavaScript"
Action Fields:
UUID— Action unique identifier (5+ segments)Controllers—["Keypad"],["Encoder"], or bothDevices— Target devices (empty array[]= all devices)
Copy the SDK libraries to your plugin project:
# For HTML Property Inspectors
cp -r common-html/libs ./libs/
# For Node.js Main Services
cp -r common-node ./plugin-common-node/
# Install WebSocket dependency
npm install wsSDK Structure:
common-html/libs/— WebSocket client, UI utilities, i18n for Property Inspectorscommon-node/— WebSocket server, system APIs for Main Services
Plugins can use either HTML or Node.js as the main service. Choose based on your needs:
- HTML — Best for UI-rich plugins, Canvas drawing, simpler logic
- Node.js — Best for system integration, file access, complex logic
HTML Main Service (plugin/app.html):
<!DOCTYPE html>
<html>
<head>
<script src="../libs/js/constants.js"></script>
<script src="../libs/js/eventEmitter.js"></script>
<script src="../libs/js/timers.js"></script>
<script src="../libs/js/utils.js"></script>
<script src="../libs/js/ulanziApi.js"></script>
</head>
<body>
<script>
$UD.connect('com.ulanzi.ulanzistudio.myplugin');
$UD.onConnected(() => {
console.log('Connected to UlanziStudio');
});
$UD.onAdd((message) => {
console.log('Action added:', message.context);
});
$UD.onRun((message) => {
// Main action trigger logic
console.log('Action triggered:', message.context);
$UD.toast('Hello from plugin!');
});
</script>
</body>
</html>Node.js Main Service (plugin/app.js):
import UlanziApi from './plugin-common-node/index.js';
const $UD = new UlanziApi();
// Connect to UlanziStudio
$UD.connect('com.ulanzi.ulanzistudio.myplugin');
$UD.onConnected(() => {
console.log('Connected to UlanziStudio');
});
$UD.onAdd((message) => {
console.log('Action added:', message.context);
});
$UD.onRun((message) => {
// Main action trigger logic
console.log('Action triggered:', message.context);
$UD.toast('Hello from plugin!');
});Key Differences:
- HTML uses
<script>tags to load SDK; Node.js uses ES modules (import) RandomPortis only needed when a Property Inspector page bypasses UlanziStudio and connects directly to a self-hosted Node.js main service- Both use the same
$UDAPI for events and commands
Create property-inspector/inspector.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../../libs/css/uspi.css">
<script src="../../libs/js/constants.js"></script>
<script src="../../libs/js/eventEmitter.js"></script>
<script src="../../libs/js/timers.js"></script>
<script src="../../libs/js/utils.js"></script>
<script src="../../libs/js/ulanziApi.js"></script>
</head>
<body>
<div class="uspi-wrapper">
<form id="property-inspector">
<div class="uspi-item">
<div class="uspi-item-label" data-localize>Message</div>
<input type="text" class="uspi-item-value" name="message" value="">
</div>
</form>
</div>
<script>
$UD.connect('com.ulanzi.ulanzistudio.myplugin.action');
// Load saved settings when action is added
$UD.onAdd((message) => {
Utils.setFormValue(message.param, '#property-inspector');
});
// Send settings to main service when form changes
document.getElementById('property-inspector').addEventListener('change', () => {
const params = Utils.getFormValue('#property-inspector');
$UD.sendParamFromPlugin(params);
});
</script>
</body>
</html>Key Points:
- Wrap content in
.uspi-wrapperfor automatic i18n and styling - Use
data-localizeattribute for automatic translation - Use
Utils.getFormValue()andUtils.setFormValue()for form handling
Create localization JSON files in the plugin root directory:
Localization JSON files can include two kinds of entries:
- Top-level manifest fields: use
Name,Description, and anActionsarray aligned withmanifest.jsonto localize how the plugin and its actions appear in the app. Localizationobject: use it for Property Inspector or page text. Each key matches the text or attribute value referenced bydata-localize.
zh_CN.json:
{
"Name": "我的插件",
"Description": "我的插件描述",
"Actions": [
{
"Name": "插件操作",
"Tooltip": "执行插件操作"
}
],
"Localization": {
"Message": "消息",
"Save": "保存",
"Hello": "你好"
}
}en.json:
{
"Name": "My Plugin",
"Description": "My plugin description",
"Actions": [
{
"Name": "Plugin Action",
"Tooltip": "Run plugin action"
}
],
"Localization": {
"Message": "Message",
"Save": "Save",
"Hello": "Hello"
}
}Supported Languages:
zh_CN— Simplified Chinesezh_HK— Traditional Chinese (HK)en— Englishja_JP— Japanesede_DE— Germanko_KR— Koreanpt_PT— Portuguesees_ES— Spanish
Usage in HTML:
<!-- Automatic translation by element text content -->
<div class="uspi-item-label" data-localize>Message</div>
<!-- Automatic translation by attribute value -->
<option value="save" data-localize="Save">Save</option>
<!-- Manual translation in JavaScript -->
<script>
const label = $UD.t('Message'); // Returns localized string
</script>The UlanziDeck Simulator allows testing plugins without the desktop application:
cd UlanziDeckSimulator
npm install
npm startTesting Steps:
-
Copy plugin — Copy your plugin folder to
UlanziDeckSimulator/plugins/ -
Open simulator — Open http://127.0.0.1:39069 in your browser
-
Refresh plugin list — Click "Refresh Plugin List" button to load your plugin
-
Start main service — For Node.js plugins, start the main service manually:
node path/to/your/plugin/app.js
-
Add action to key — Drag your action from the plugin list to a key
-
Open property inspector — Click the key to open the configuration UI
-
Test events — Use right-click menu to manually trigger events for testing
Simulator Limitations:
openviewandopenurlcannot open local files (browser restriction)- Main service must be started separately for Node.js plugins
- Actions are not auto-loaded by default
Launch UlanziStudio with debugging flags enabled:
Available Flags:
| Flag | Description |
|---|---|
--log |
Write logs to file |
--logLevel |
Set log verbosity |
--webRemoteDebug |
Enable HTML plugin debugging at port 9292 |
--nodeRemoteDebug |
Enable Node.js plugin debugging |
Windows:
Right-click UlanziStudio shortcut → Properties → append to Target:
"C:\...\Ulanzi Studio.exe" --log --webRemoteDebug --nodeRemoteDebug
macOS:
open /Applications/Ulanzi\ Studio.app --args --log --webRemoteDebugAccess Debug Tools:
- HTML Plugins: Open
localhost:9292in browser to debug all loaded HTML plugins - Node.js Plugins: Open
chrome://inspectin Chrome, find your plugin and click "inspect"
Important Notes:
- Using
opencommand on macOS may prevent Accessibility permissions, affecting hotkey functionality - Use
./UlanziStudiodirectly if hotkeys are not working
| Package | Purpose |
|---|---|
common-html |
HTML plugins (WebView) — WebSocket client, UI utilities, i18n, Canvas helpers |
common-node |
Node.js plugins — WebSocket server, system APIs, file system access |
UlanziDeckSimulator |
Browser simulator for testing |
demo |
Example plugins |
Plugins can use either HTML or Node.js as the main service:
| Type | Entry | Loading | SDK | Use Case |
|---|---|---|---|---|
| HTML Plugin | .html |
WebView | common-html |
UI-rich plugins, Canvas drawing, simple logic |
| Node.js Plugin | .js |
Node.js v20 | common-node |
System integration, file access, complex logic |
Property Inspector (optional) is always HTML using common-html, opened when user configures an action.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Property │ WebSocket│ UlanziStudio │ WebSocket│ Main │
│ Inspector │ ←──────→│ (Bridge) │ ←──────→│ Service │
│ (HTML) │ │ │ │ (Node.js/HTML)│
└─────────────────┘ └──────────────────┘ └─────────────────┘
Plugin Lifecycle:
- Main Service — Long-running background process (Node.js or HTML)
- Property Inspector — HTML page created when user configures a key, destroyed on navigate away
Main Service: com.ulanzi.ulanzistudio.{plugin} → 4 segments
Action: com.ulanzi.ulanzistudio.{plugin}.{action} → 5+ segments
Package: com.ulanzi.{plugin}.ulanziPlugin
Each key instance has a unique context string:
// Format: uuid + '___' + key + '___' + actionid
const context = $UD.encodeContext(msg) // Encode to context string
const decoded = $UD.decodeContext(context) // Decode → { uuid, key, actionid }Important: The same action can be assigned to multiple keys. The context parameter uniquely identifies each instance.
- D200
- D200H
- Dial
- D200X
Actions specify which controller types they support:
| Controller | Description | Events |
|---|---|---|
Keypad |
Standard button press | onRun, onKeyDown, onKeyUp |
Encoder |
Rotary dial | onDialDown, onDialUp, onDialRotate* |
Example Configuration:
{
"Controllers": ["Keypad", "Encoder"],
"Devices": ["D200X"]
}This action supports both keypad buttons and rotary dial on the D200X device.
The D200X dial area has an independent layout canvas for customizing display content:
"Encoder": {
"layout": "$UA1" // Built-in layout: icon + text
// or "layout": "layout.json" // Custom layout file
}Built-in Layouts:
$UA1— Icon + Text layout$UA2— Text + Text layout
// Use state index from manifest.json States array
$UD.setStateIcon(context, stateIndex, text)
// Use local image file path (relative to plugin root)
$UD.setPathIcon(context, 'resources/icon.png', text)
// Use base64-encoded image data
$UD.setBaseDataIcon(context, base64Data, text)
// Use local GIF file for animation
$UD.setGifPathIcon(context, 'anim.gif', text)
// Use base64-encoded GIF data
$UD.setGifDataIcon(context, base64GifData, text)// Save action-specific settings (only saves when action is active)
$UD.setSettings(data, context)
// Request saved action settings (response via onDidReceiveSettings)
$UD.getSettings(context)
// Save plugin-wide global settings
$UD.setGlobalSettings(data)
// Request global settings (response via onDidReceiveGlobalSettings)
$UD.getGlobalSettings()Important: Settings are NOT saved when the action is inactive.
// Show toast notification on UlanziStudio
$UD.toast('Hello World')
// Trigger system-level hotkey
$UD.hotkey('Ctrl+C') // Windows
$UD.hotkey('⌘C') // macOS
// Open URL in system browser
$UD.openUrl('https://example.com')
// Open local HTML file in popup window
$UD.openView('popup.html', 400, 300)
// Open file picker dialog
$UD.selectFileDialog('image(*.png *.jpg)')
// Open folder picker dialog
$UD.selectFolderDialog()
// Write to plugin log file
$UD.logMessage('Debug info', 'debug')
// Show error indicator on button
$UD.showAlert(context)// Action added to key (drag-and-drop)
$UD.onAdd((message) => {
console.log('Action added:', message.context);
})
// Key triggered (single click confirmed) — main entry point
$UD.onRun((message) => {
console.log('Action triggered:', message.context);
})
// Key press started (fires before run, useful for long-press detection)
$UD.onKeyDown((message) => {})
// Key released
$UD.onKeyUp((message) => {})
// Action active state changed
$UD.onSetActive((message) => {
console.log('Active:', message.active); // true/false
})
// Action removed from one or more keys
$UD.onClear((message) => {
// message.param is array, each item has .context
})// Dial pressed
$UD.onDialDown((message) => {})
// Dial released
$UD.onDialUp((message) => {})
// Dial rotated (any direction)
$UD.onDialRotate((message) => {
// message.rotateEvent: 'left' | 'right' | 'hold-left' | 'hold-right'
})
// Rotated left (not held)
$UD.onDialRotateLeft((message) => {})
// Rotated right (not held)
$UD.onDialRotateRight((message) => {})
// Rotated left while pressed
$UD.onDialRotateHoldLeft((message) => {})
// Rotated right while pressed
$UD.onDialRotateHoldRight((message) => {})// Main Service → Property Inspector (pass-through, not saved by host)
$UD.sendToPropertyInspector(data, context)
// Property Inspector → Main Service (pass-through, not saved by host)
$UD.sendToPlugin(data)Event Handlers:
// In Main Service: receive from Property Inspector
$UD.onSendToPlugin((message) => {})
// In Property Inspector: receive from Main Service
$UD.onSendToPropertyInspector((message) => {})-
Settings Not Saved When Inactive Settings are NOT saved when the action is inactive. Check
onSetActivebefore relying onsetSettings. -
Simulator Limitations
openviewandopenurlcannot open local files (browser restriction)- Main service must be started separately for Node.js plugins
- Actions are not auto-loaded by default
-
Path Handling Use
Utils.getPluginPath()for cross-platform path handling:const pluginPath = Utils.getPluginPath(); const configPath = `${pluginPath}/config.json`;
-
Port Management Normal plugins pass parameters between the Property Inspector page and the main service through UlanziStudio's standard events, so they do not need to manage ports. Use
RandomPortonly when the Property Inspector page needs to bypass UlanziStudio and connect directly to a WebSocket / HTTP service hosted by the plugin's own Node.js main service. It generates a random listening port and writes it tows-port.jsfor the Property Inspector page to read:import { RandomPort } from './plugin-common-node/index.js'; const port = new RandomPort().getPort(); // Writes ws-port.js
-
Context in clear Event For the
clearevent,contextis in each item of theparamarray:$UD.onClear((message) => { if (message.param) { for (const item of message.param) { console.log('Removed:', item.context); } } });
| Topic | Link |
|---|---|
| manifest.json Full Reference | manifest.md |
| common-html SDK (Property Inspector) | common-html/README.md |
| common-node SDK (Main Service) | common-node/README.md |
| Simulator Guide | UlanziDeckSimulator/README.md |
| Localization (i18n) | See common-html/README.md » Localization |
| Debugging Guide | See Step 8 above or common-html/README.md » Debugging |
| Field | Required | Description |
|---|---|---|
UUID |
✅ | Plugin ID (exactly 4 segments) |
CodePath |
✅ | Entry point (.html or .js) |
Actions |
✅ | Action definitions array |
Type |
✅ | Fixed value "JavaScript" |
Author |
✅ | Developer name |
Name |
✅ | Plugin name |
Icon |
✅ | Plugin icon path |
Version |
✅ | Plugin version |
Controllers |
["Keypad"], ["Encoder"], or both |
|
Devices |
[] = all devices, ["D200X"] = specific |
|
OS |
Supported operating systems |
Full reference: manifest.md
| Resource | Link |
|---|---|
| Download | https://www.ulanzi.com/pages/ulanzi-app |
| Community Forum | https://bbs.ulanzistudio.com |
Apache License 2.0 - Developers may use this SDK to build and distribute plugins, including commercial or closed-source plugins, subject to the license terms and required notices.
Plugin development inquiries: ustudioservice@ulanzi.com