Gold Knight Tech

Open Backend · Bring Your Own Frontend

Relay Server

A small Node.js server that bridges an RTI XP Series processor to any number of browsers or apps over WebSocket - buttons, sliders, live feedback, plus optional chat, WebRTC calling, AI voice control, and push notifications. It has no opinion about your frontend: build your own and point it here.

Node.jsMac / Windows / Linux
MIT-styleBring your own frontend
CORSCross-origin by default
🔌

Relay Server only talks to your XP Series processor through the companion RelayServer driver (formerly named WebPanelBridge) - you need that driver installed and running on the processor first. It's listed in the Drivers catalog; find "RelayServer" there for the download and licensing details. Without it, this server has nothing to bridge to.

1What it is

Relay Server sits between your XP Series processor (running the RelayServer driver) and everything else. Any number of driver connections and any number of client connections can be open at once:

XP Series
RelayServer driver
Relay Server
this backend
Browsers / apps
your frontend(s)
📱

Your custom frontend doesn't have to live only in a separate browser tab or app - Integration Designer's Web Object can embed any URL directly inside an RTI touchpanel/remote UI page. Point a Web Object at your own frontend (built against this Relay Server's API) to show it as a native-feeling part of the panel's own interface, alongside your regular RTI pages, instead of switching to a separate app.

2Install

Plain Node.js, no native modules to compile - runs the same way on macOS, Windows, and Linux. Requires Node.js 22.5 or newer (it uses Node's built-in node:sqlite module for chat history).

⬇ Download relay-server.zip Source only - no license or authorization code needed to run this server itself.

Quick start

1. Download and unzip. 2. Copy/rename settings.example.json to settings.json and edit it in a text editor - it must be named exactly settings.json, the .example file itself is never read. 3. Run:

npm install
npm start

Prefer environment variables instead of a settings file? They still work as a fallback (only used when settings.json doesn't exist):

npm install
XP8_TOKEN=<random-string> CLIENT_TOKEN=<a-different-random-string> ADMIN_PASSWORD=<pick-one> npm start
settings.json keyEnv varRequiredMeaning
xp8TokenXP8_TOKENRequiredShared secret the XP Series driver authenticates with on /xp8.
clientTokenCLIENT_TOKENRequiredShared secret browsers/apps authenticate with on /client. Must differ from xp8Token.
adminPasswordADMIN_PASSWORDRequiredProtects config writes and admin endpoints (HTTP Basic Auth, username ignored).
portPORTOptionalDefaults to 8181.
webappDirWEBAPP_DIROptionalServe a built frontend's static files from here. Unset = no bundled frontend; every API route and both WebSocket endpoints still work.
turnHostTURN_HOSTOptionalAdds a TURN server to the ICE-servers response (with turnSecret/TURN_SECRET). Without it, only public STUN is returned - fine on most networks, calls across strict NATs may fail to connect.
🔒

Plain HTTP/WS only - no built-in TLS. Browsers require a secure context (HTTPS) for camera/microphone access, so put a reverse proxy (Caddy, nginx, etc.) in front for anything beyond same-machine testing. The XP Series driver's own connection has no such requirement and works fine over plain ws://.

3WebSocket API

/xp8 - the driver side

The RelayServer driver connects here first:

→ driver sends
{"type":"hello","token":"<XP8_TOKEN>","panelId":"xp8-main"}
← server replies
{"type":"hello_ok"}                    // or {"type":"hello_failed","reason":"..."}
DirectionMessageMeaning
driver → server{"type":"feedback","key":"...","value":"..."}Push a state update out to every connected client.
driver → server{"type":"ping"}Keepalive; server replies {"type":"pong"}.
server → driver{"type":"button","slot":N}A client pressed button slot N.
server → driver{"type":"slider","slot":N,"value":V}A client set slider slot N to V (0-100).
server → driver{"type":"runmacro","name":"..."}A client asked to run a named macro directly.
server → driver{"type":"clients","count":N}How many clients are currently connected.

/client - the browser/app side (this is what your frontend uses)

→ client sends
{"type":"auth","token":"<CLIENT_TOKEN>"}
← server replies
{"type":"auth_ok","feedback":{...current state...},"xp8Connected":true}
DirectionMessageMeaning
client → server{"type":"button","slot":N}Press button slot N.
client → server{"type":"slider","slot":N,"value":V}Set slider slot N (0-100).
client → server{"type":"runmacro","name":"..."}Run a named macro.
server → client{"type":"feedback","key":"...","value":"..."}A state update pushed from a driver.
server → client{"type":"xp8status","connected":true|false}At least one XP Series driver is/isn't connected.

4Getting feedback from your XP Series processor

Feedback (a light that's on, a sensor value, anything state-related) takes two hops: XP Series driver → Relay Server → your client. Each hop uses a different mechanism.

🔌

Step 1 below only works if the RelayServer driver is installed and running on your XP Series processor - see the Drivers catalog. Relay Server has nothing to relay without it.

1. Driver side - call "Push Feedback" from Integration Designer

The RelayServer driver exports a function in the "Feedback to Web App" category:

Push Feedback(Key, Value)

Both parameters are plain strings. Bind a call to this into whatever macro/event already fires when the state you care about changes - the same macro that already updates a touchpanel's feedback is usually the right place. Key is any identifier you choose (it isn't predefined anywhere); Value is whatever string representation makes sense for that key ("true"/"false" for a toggle, a number-as-string for a level).

// Example macro step:
Push Feedback("LivingRoomLight", "true")

2. Client side - handle it over /client

Once connected and authed, every Push Feedback call arrives as a live message, and a client that just connected gets the current value of every key ever pushed so far in auth_ok's snapshot:

socket.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "auth_ok") {
    // msg.feedback = { "LivingRoomLight": "true", ... } - snapshot of every
    // key pushed so far, so a client connecting late still sees current state
    console.log(msg.feedback);
  } else if (msg.type === "feedback") {
    // fires every time the driver calls Push Feedback from now on
    console.log(msg.key, "=", msg.value);
  }
};

There's nothing to register or declare on the relay-server side - it just relays whatever key/value pairs the driver sends, and remembers the latest value per key so new connections start with current state instead of nothing.

5Try it live

This connects over a real WebSocket to our actual, running Relay Server - the exact same auth / button / slider / feedback messages documented above, aimed at a small sandboxed fake device (DemoLight, DemoVolume) instead of a real XP Series processor, so it's safe to leave connected. Open this page in two tabs and toggle the light in one - the other updates live via the same feedback broadcast a real driver would trigger.

Connecting…

6REST API

Config

GET /api/configReturns config.yaml's raw text. Open read.
PUT /api/configReplaces it (validated as YAML first). Admin-protected.
POST /api/modelMultipart file - uploads a .glb for an optional 3D view. Admin-protected.

WebRTC calling + chat

GET /api/webrtc/ice-serversSTUN (+ TURN if configured).
Socket.IO @ /api/comm/socketregister, chat_message, signal (offer/answer/ice), join_group_call / leave_group_call, push_call.
GET /api/comm/history?limit=100Chat history (sqlite-backed).
POST /api/comm/voice-messageMultipart audio + fields - upload a voice note.
POST /api/comm/mediaMultipart file - image/video/file sharing.

Push notifications

GET /api/push/vapid-public-keyPublic key for the browser's Push subscription call.
POST /api/push/subscribe{deviceId, endpoint, keys}
DELETE /api/push/subscribe{deviceId}

AI voice control

GET/PUT /api/admin/ai-configSet OpenAI + Anthropic API keys, stored server-side only, never sent to a browser. Admin-protected.
POST /api/ai/voiceMultipart audio - transcribes via Whisper, asks Claude to translate the request into button/slider actions, dispatches them exactly like a real client press. Costs real money against your own OpenAI/Anthropic accounts.

7Example code - connect, send, receive

Works from a browser or from Node.js (with the ws package). Replace RELAY_URL and CLIENT_TOKEN with your own.

Connect + authenticate

const RELAY_URL = "wss://your-server.com:8443";
const socket = new WebSocket(RELAY_URL + "/client");

socket.onopen = () => {
  socket.send(JSON.stringify({ type: "auth", token: "<CLIENT_TOKEN>" }));
};

Receive - feedback and connection state

socket.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  switch (msg.type) {
    case "auth_ok":
      console.log("Connected. XP Series processor online:", msg.xp8Connected);
      console.log("Current feedback state:", msg.feedback);
      break;
    case "feedback":
      // e.g. { key: "LivingRoomLight", value: "true" }
      console.log(msg.key, "changed to", msg.value);
      break;
    case "xp8status":
      console.log("XP Series connection is now:", msg.connected);
      break;
  }
};

Send - press a button, move a slider

function pressButton(slot) {
  socket.send(JSON.stringify({ type: "button", slot }));
}

function setSlider(slot, value) {
  // value is 0-100
  socket.send(JSON.stringify({ type: "slider", slot, value }));
}

// e.g. turn on "Living Room Light" (bound to button slot 1)
pressButton(1);

// e.g. set "Music Volume" (bound to slider slot 1) to 60%
setSlider(1, 60);

REST example - read the current config

const res = await fetch(RELAY_URL.replace("wss:", "https:") + "/api/config");
const yamlText = await res.text();
console.log(yamlText);