Minimal HTTP + Web UI bridge for a Vantage QLink (Q‑Link) controller over TCP, designed to play nicely with Homebridge polling. It exposes a small REST API, serves a lightweight console UI, serializes on‑wire traffic, and coalesces status polls so multiple clients don’t stampede the controller.
Host target: Raspberry Pi (tested), Node.js v22.20.0 (nvm), runs under PM2.
- What it does
- Architecture
- Requirements
- Install
- Configure
- Run (PM2)
- Security
- HTTP API
- Web UI
- Screenshots
- Homebridge integration
- Timing, caching & coalescing
- Troubleshooting
- Roadmap
- License
-
Maintains one TCP connection to a Vantage controller and writes commands with a global send gap to avoid flooding.
-
Provides
/status/vgsfor fast switch state reads usingVGS# <m> <s> <b>and parsingRGS# <m> <s> <b> <v>(orVGS# … <v>/ bare0|1). -
Coalesces parallel polls for the same (m,s,b) so only one on‑wire read occurs.
-
Parses VOS push lines like
SW m s b v, does a one‑shotVGS#confirm, and updates a state cache that can short‑circuit polling for a short window. -
Serves a simple Web UI for sending commands, viewing logs, and browsing available commands.
-
VERY IMPORTANT! The back-end app does auto-connect to the Vantage Qlink IP Enabler. Connect/Disconnect in the web UI (i.e. you can manually disconnect and connect) because the Qlink application which lets you program the system connects directly to the same IP/port, so I wanted to be able to disconnect and/or troubleshoot the Vantage system easily. Mine, every time there's a daylight savings time change, gets its timers messed up and I have to reload the programming.
Homebridge (HTTP‑SWITCH) ─┐ ┌─> /public/index.html (UI)
├─> REST API│
Other clients (curl etc.) ┘ └─> TCP bridge ──> Vantage IP interface --> Vantage serial interface
Key pieces:
- Serialized TCP queue; configurable
MIN_GAP_MSbetween sends - Awaiters map to match replies to in‑flight
VGS#requests - State cache fed by push + confirm and by recent polls
- Homebridge‑driven whitelist of allowed (m,s,b)
- Vantage Qlink master controller with an IP interface
- Raspberry Pi (Linux) with network access to the Vantage controller
- Node.js v22.20.0 (via nvm is fine)
- PM2 for process management (optional but recommended)
# as the service user (e.g., homeauto)
# 1) Node via nvm
export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
nvm install 22.20.0
nvm use 22.20.0
# 2) Clone & install
git clone https://github.com/imfinlay/vantage-qlink-api.git
cd vantage-qlink-api
npm installConfiguration lives in config.js (a sample is checked into the repo).
Servers (multiple supported):
module.exports = {
SERVERS: [
{ name: 'Vantage', host: '<IP address>', port: 3040 }
],
// Optional TCP handshake string sent on connect - depending on your Qlink config you may need to send VOS 0 1/n to enable reporting of switch presses. This is persistent in the Qlink master, so you don't actually need to send it every time, but it doesn't hurt! You may also need to change the CRLF behaviour in your environment with VCL 1. If you have the Qlink config program, you can set all this in the RS-232 station config.
HANDSHAKE: '',
// Timing & behavior
MIN_GAP_MS: 120, // global on‑wire gap between sends
PUSH_FRESH_MS: 10000, // how long push‑confirmed state satisfies /status/vgs
HANDSHAKE: 'VCL 1 0\r\n', // Optional, set CRLF at startup
HANDSHAKE_RETRY_MS: 0, // retry handshake once after N ms (0 = disabled)
// Direct load dimming
DEFAULT_LOAD_FADE_SECONDS: 3, // fallback fade when /dim POST omits fade
LOAD_AWAITERS_MAX_PER_KEY: 200, // concurrent awaiters allowed per load key
// Whitelist behavior (derived from Homebridge config)
HB_WHITELIST_STRICT: true // true: empty whitelist denies all; false: allow all when empty
// Configuratble config path in case you moved it
HB_CONFIG_PATH: process.env.HB_CONFIG_PATH || null,
HB_CONFIG_CANDIDATES: [
'/var/lib/homebridge/config.json'
],
// Auto-connect on startup
// You can also set AUTO_CONNECT=1, AUTO_CONNECT_INDEX=0, AUTO_CONNECT_RETRY_MS=5000 in the environment.
AUTO_CONNECT: /^(1|true|yes)$/i.test(String(process.env.AUTO_CONNECT || 1)),
AUTO_CONNECT_INDEX: Number(process.env.AUTO_CONNECT_INDEX ?? 0),
AUTO_CONNECT_RETRY_MS: Number(process.env.AUTO_CONNECT_RETRY_MS ?? 5000),
// --- optional debug ---
debug: { push: false }
};-
Set
debug: trueinconfig.jsto enable verboseVGS RESP …entries for switch polls. This shows whether a response came from the cache or from the Vantage, which is useful to identify whether you have set the cache timeout (either globally or per device) high enough relative to the load on your system. For example:[2025-10-27T01:21:29.190Z] CMD/API -> VGS# 1 9 23 [2025-10-27T01:21:29.234Z] RX <- RGS# 1 9 23 0 [2025-10-27T01:21:29.235Z] VGS RESP 1-9-23 [tcp:await, stream, age=0ms] RGS# 1 9 23 0 [2025-10-27T01:21:33.077Z] VGS RESP 1-9-23 [tcp:await, cache-hit, age=3842ms] RGS# 1 9 23 0
The app also reads the Homebridge
config.json(path usually/var/lib/homebridge/config.json) to build a whitelist of allowed (master, station, button). You can refresh it via the UI or a server restart.
Simple start:
pm2 start app.js --name vantage-qlink-api
pm2 saveEcosystem file (optional ecosystem.config.js):
module.exports = {
apps: [{
name: 'vantage-qlink-api',
script: './app.js',
env: {
PORT: 3000,
HOST: '0.0.0.0'
}
}]
}Then:
pm2 start ecosystem.config.js
pm2 saveAdvanced ecosystem with environment & clustering
Keep
instances: 1(one TCP session to the controller)
module.exports = {
apps: [{
name: 'vantage-qlink-api',
script: './app.js',
instances: 1,
exec_mode: 'fork',
watch: false,
env: {
PORT: 3000,
HOST: '0.0.0.0',
MIN_GAP_MS: 120,
PUSH_DEBUG: '0',
HB_CONFIG_PATH: '/var/lib/homebridge/config.json',
LOG_FILE_PATH: '/home/homeauto/apps/vantage-qlink-api/app.log'
},
error_file: './logs/err.log',
out_file: './logs/out.log',
merge_logs: true,
max_restarts: 10,
restart_delay: 2000
}]
}-
Bind address: For LAN‑only use, set
HOST=127.0.0.1(and reverse‑proxy if you want a UI from another host). Otherwise, firewall port 3000 to your subnet only. -
Firewall (UFW example):
sudo ufw allow from 192.168.1.0/24 to any port 3000 proto tcp
-
Reverse proxy (Nginx snippet):
server { listen 80; server_name yourhost; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }
Add HTTPS (e.g., via certbot) and optional Basic Auth when exposing beyond your LAN.
-
Whitelist: The app builds an allow‑list from your Homebridge
config.json. WithHB_WHITELIST_STRICT: true, an empty list denies all. -
Single TCP session: Run one instance only. Multiple instances could compete for the controller RS‑232/TCP port.
All endpoints are GET unless noted.
POST /connect→{ serverIndex }POST /disconnectGET /status→{ connected: boolean, server?: { name, host, port } }
-
GET /servers→{ servers: [{ index, name, host, port }...] } -
GET /commands→{ commands, count, items: [{ command, description, params }] } -
GET /logs?limit=200&format=txt→ plain text (newline‑separated)- default (no
format): JSON{ file, count, lines }
- default (no
POST /sendwith JSON body:
{
"command": "VGS# 1 9 34",
"quietMs": 300,
"maxMs": 2000
}-
GET /status/vgs?m=<master>&s=<station>&b=<button>&format=<raw|bool>&quietMs=&maxMs=&cacheMs=&jitterMs=format=raw→"0"or"1"(plain text)format=bool→"true"or"false"- default JSON:
{ ok, sent, state, raw, bytes, cached }
Protocol details
- Sends
VGS# m s b- this is the 'Get Switch' v-command, detailed response (the #) sending master, station and switch (button, if you like) - Accepts
RGS# m s b vorVGS# m s b v(or bare0|1) - this is the detailed response to VGS# - The last field is treated as the boolean state (non‑zero =
1)
-
POST /dim(JSON){ "master": 3, "enclosure": 4, "module": 1, "load": 2, "level": 75, "fade": 3.5, "maxMs": 2200 }- Sends
VLB# m enclosure module load level [fade]to set the dim level - Omitting
fadeusesDEFAULT_LOAD_FADE_SECONDSfromconfig.js 504on timeout,429when awaiters are saturated,400on validation failure- Successful responses include
{ ok, level, fade, raw, source, ts, ageMs, cached, command, requested }
- Sends
-
GET /dim?m=<master>&e=<enclosure>&module=<module>&load=<load>&format=<json|raw|level>&cacheMs=&maxMs=- Issues
VGB# m enclosure module loadand waits forRGBfeedback - Cache hits return immediately with
X-Load-Cache: hit; misses trigger a new poll format=rawemits the rawRGB/RLBline,format=levelemits only the numeric level, default JSON matches the POST bodycacheMs(defaultMIN_POLL_INTERVAL_MS) controls cache reuse;maxMscaps how long the awaiter waits
- Issues
Both endpoints attach X-Load-Command with the dispatched line plus headers (X-Load-Level, X-Load-Fade, X-Load-Source) for quick introspection.
GET /recv?format=utf8|hex|base64&start=&end=POST /recv/reset
Open http://<pi>:3000/:
-
Server: pick target and Connect/Disconnect
-
Send Command: enter a line (e.g.
VGS 2 20 7) and choose response modifier:$→ short form (adds$after the command token)#→ detailed (adds#after the command token, e.g.VGS# 2 20 7)
-
Wait/Collect: optionally set
quietMsandmaxMsfor/send -
Commands: searchable table from
commands.csv; click to copy into the input -
Logs Tail: live log viewer with adjustable interval; supports auto‑scroll, stop/start logging to file, view filtering
The UI persists preferences (log limit/interval, auto‑scroll, selected server, modifier) in
localStorage.
TODO
Using the community HTTP‑SWITCH plugin:
- Get master, station and switch/button IDs from the Qlink program or by pressing buttons and watching the logs in the HTML front end
- Create an accessory with the following config. Be aware that the plugin someties creates a unique ID, so don't just copy/paste and edit the JSON from another device
- Use the ./scripts/add_hb_switch to generate the JSON or add directly to the Homebridge config if you're working on the API server.
{
"accessory": "HTTP-SWITCH",
"name": "Hall Eyeball",
"switchType": "stateful",
"method": "GET",
"onUrl": "http://127.0.0.1:3000/test/vsw?m=2&s=20&b=7&state=1&waitMs=800",
"offUrl": "http://127.0.0.1:3000/test/vsw?m=2&s=20&b=7&state=0&waitMs=500",
"statusUrl": "http://127.0.0.1:3000/status/vgs?m=2&s=20&b=7&format=bool&quietMs=300&maxMs=2200&cacheMs=800&jitterMs=300",
"statusMethod": "GET",
"statusPattern": "^true$",
"pullInterval": 3500,
"timeout": 3000
}For "one shot" or momentary buttons (i.e. where it's not on or off, but just a single push to execute a switch function) you can use the HTTP-DUMMY Homebridge plugin.
The /dim endpoints expose load-level control. Configure the plugin so brightness writes POST /dim with JSON containing your load address and the desired level (0‑100), and poll GET /dim for status. Example using homebridge-http-lightbulb:
{
"accessory": "HTTP-LIGHTBULB",
"name": "Kitchen Pendants",
"debug": false,
"onUrl": {
"url": "http://127.0.0.1:3000/dim",
"method": "POST",
"headers": { "Content-Type": "application/json" },
"body": "{\"master\":3,\"enclosure\":1,\"module\":1,\"load\":2,\"level\":90}"
},
"offUrl": {
"url": "http://127.0.0.1:3000/dim",
"method": "POST",
"headers": { "Content-Type": "application/json" },
"body": "{\"master\":3,\"enclosure\":1,\"module\":1,\"load\":2,\"level\":0}"
},
"brightness": {
"setUrl": {
"url": "http://127.0.0.1:3000/dim",
"method": "POST",
"headers": { "Content-Type": "application/json" },
"body": "{\"master\":3,\"enclosure\":1,\"module\":1,\"load\":2,\"level\":%s}"
},
"statusUrl": {
"url": "http://127.0.0.1:3000/dim?m=3&e=1&module=1&load=2&format=level&cacheMs=100&maxMs=2800",
"method": "GET",
"statusPattern": "^(\\d{1,3})$"
}
},
"statusUrl": {
"url": "http://127.0.0.1:3000/dim?m=3&e=1&module=1&load=2&format=level&cacheMs=100&maxMs=2800",
"method": "GET"
},
"statusPattern": "^\\s*(?:[1-9]\\d?|100)\\s*$",
"pullInterval": 2153,
"timeout": 3000
}Replace %s (or {{BRIGHTNESS}} if your plugin uses handlebars-style templating) with whatever token your plugin exposes. Omitting fade falls back to DEFAULT_LOAD_FADE_SECONDS.
Notes
- The top-level
statusPatterntreats any value above0astrueso HomeKit reports the load as Off when the level is zero. - Both status URLs use
format=levelso the body is a plain0-100string. cacheMslets the server satisfy polls from its cache briefly.quietMs/maxMstune when a status response is considered complete.- Use
pullInterval≥ 3.5s and add jitter to avoid alignment across many accessories.
-
GET
/status/vgsParam Alias(es) Purpose m– Required master index (integer) s– Required station/controller index (integer) b– Required button address within the station (integer) format– Response shape: json(default),bool, orrawquietMs– Optional wait hint (currently parsed but unused) maxMs– Deadline for awaiting the TCP reply (falls back to 1200 ms) cacheMs– Cache freshness window in milliseconds (default MIN_POLL_INTERVAL_MS)jitterMs– Optional random delay before polling (default 0) -
POST
/dimField Alias(es) Purpose / accepted values mmasterRequired master index eenclosureRequired enclosure ID (1‑4) modulemod,modulePosRequired module position (1‑4) loadlRequired load number (1‑8) levelvalue,levelPercentLoad level 0‑100 fadefadeSeconds,speedFade duration in seconds (0‑6553); defaults to config fallback maxMstimeoutMsOptional command timeout (min 50 ms, default 2000 ms) -
GET
/dimParam Alias(es) Purpose mmasterRequired master index eenclosureRequired enclosure ID modulemod,modulePosRequired module position loadlRequired load number format– Response shape: json(default),level, orrawcacheMs– Cache freshness window (defaults to MIN_POLL_INTERVAL_MS)maxMstimeoutMsOptional command timeout (min 50 ms, default 2000 ms)
MIN_GAP_MS: enforced between all sends to the controller (avoid bursty traffic)- Coalescing: multiple concurrent
/status/vgsfor the same (m,s,b) share one on‑wire request - Push + confirm: on receiving a
VOSSW m s b v, the app does a singleVGS#confirm and updates the cache PUSH_FRESH_MS: window where push‑confirmed state can short‑circuit/status/vgs- Whitelist: built from Homebridge config;
HB_WHITELIST_STRICT: truemeans empty → deny all
In ./scripts, there's add_hb_switch.sh which will either create JSON for a switch and print to STDOUT or modify the homebridge config.json directly. You should check in the homebridge config and restart homebridge for changes to take effect. Note that it randomizes the pullInterval to reduce load on the Vantage master.
Usage:
./add_hb_switch.sh [options] "<name>" <m> <s> <b>
Options:
-c, --config PATH Path to Homebridge config.json (default: /var/lib/homebridge/config.json or ~/.homebridge/config.json)
-a, --apply Write directly into config.json (otherwise prints JSON to stdout)
-r, --replace If an accessory with the same name exists, replace it (default: update/insert by name)
--host HOST API host for URLs (default: 127.0.0.1)
--port PORT API port for URLs (default: 3000)
# Timings
--wait-on N waitMs for ON (default: 800)
--wait-off N waitMs for OFF (default: 500)
--quiet N quietMs for status (default: 300)
--max N maxMs for status (default: 2200)
--cache N cacheMs for status (default: 800)
--jitter N jitterMs for status (default: 300)
--timeout N timeout for the accessory (default: 3000)
Notes:
- pullInterval is randomized between 3800–4500 ms on each run.
- Requires 'jq'.
- Examples:
./add_hb_switch.sh "Hall Light" 2 20 7
./add_hb_switch.sh -a -c /var/lib/homebridge/config.json "Dining room" 1 9 48-
HTTP 500on/status/vgs→ controller didn’t answer in time- Increase
maxMs, reduce concurrency (raisepullInterval, addjitterMs), or raiseMIN_GAP_MS
- Increase
-
ESOCKETTIMEDOUTin Homebridge → the accessory’stimeoutis too low for the chosenstatusUrltimings -
UI doesn’t show servers/commands/logs
- Check browser console for syntax errors
- Verify
/servers,/commands,/logsreturn 200 (curlthem) - For logs: use
?format=txtor parse JSON{ lines }
-
RGS# shows 0 but Homebridge thinks ON
- Ensure
statusPatternis^true$andformat=boolonstatusUrl
- Ensure
-
No push confirms in log
- Ensure VOS push is enabled in the controller; confirm app logs include
PUSHlines
- Ensure VOS push is enabled in the controller; confirm app logs include
-
git branch -m work load-dimmingsaysNo branch named 'work'- Run the command from the repository root (where
package.jsonlives) so Git can see the branches - If you are already on
load-dimming, drop the old name from the command:git branch -m load-dimming - List local branches with
git branchto confirm the current name before pushing withgit push -u origin load-dimming
- Run the command from the repository root (where
- Server‑Sent Events / WebSocket log streaming (replace polling)
- Optional per‑key rate limits / circuit breaker when a device flaps
- Built‑in health endpoint with queue depth and awaiter counts
MIT — see LICENSE.
# after pm2 start …
curl -sS http://127.0.0.1:3000/servers | jq .
curl -sS http://127.0.0.1:3000/commands | jq '.count, .items[0]'
curl -sS "http://127.0.0.1:3000/status/vgs?m=1&s=9&b=34&format=raw&quietMs=300&maxMs=2200&cacheMs=800&jitterMs=300"
curl -sS "http://127.0.0.1:3000/logs?limit=10&format=txt"