import dgram from "node:dgram"; import { WebSocket } from "ws"; const API_BASE = process.env.DATANET_API_URL ?? process.env.API_BASE ?? "http://localhost:8080"; const WS_BASE = process.env.DATANET_WS_URL ?? process.env.WS_URL ?? "ws://localhost:8080"; const API_KEY = process.env.DATANET_API_KEY ?? process.env.API_KEY ?? "ak_dev_12345"; const CHANNEL = process.env.DATANET_CHANNEL ?? process.env.TOPIC ?? "demo.lighting.dmx"; const PAYLOAD_MODE = (process.env.DATANET_BINARY_MODE ?? "dmx").toLowerCase(); const ARTNET_HOST = process.env.ARTNET_HOST ?? "127.0.0.1"; const ARTNET_PORT = Number.parseInt(process.env.ARTNET_PORT ?? "6454", 10); const ARTNET_UNIVERSE = Number.parseInt(process.env.ARTNET_UNIVERSE ?? "0", 10); const ARTNET_NET = Number.parseInt(process.env.ARTNET_NET ?? "0", 10); const ARTNET_SUBNET = Number.parseInt(process.env.ARTNET_SUBNET ?? "0", 10); const ENABLE_BROADCAST = process.env.ARTNET_BROADCAST === "1"; if (process.argv.includes("--help")) { console.log(`DataNet Art-Net Bridge Subscribes to a DataNet binary channel and forwards frames to an Art-Net node. Environment variables: DATANET_API_KEY API key used to fetch a JWT DATANET_API_URL HTTP API base URL (default: http://localhost:8080) DATANET_WS_URL WebSocket base URL (default: ws://localhost:8080) DATANET_CHANNEL Binary channel to subscribe to (default: demo.lighting.dmx) DATANET_BINARY_MODE dmx | artnet (default: dmx) ARTNET_HOST Target Art-Net host (default: 127.0.0.1) ARTNET_PORT UDP port (default: 6454) ARTNET_UNIVERSE Universe 0-15 within subnet (default: 0) ARTNET_NET Net 0-255 (default: 0) ARTNET_SUBNET Subnet 0-15 (default: 0) ARTNET_BROADCAST Set to 1 to enable UDP broadcast `); process.exit(0); } function clampByte(value, fallback = 0) { if (!Number.isFinite(value)) return fallback; return Math.max(0, Math.min(255, Math.trunc(value))); } function computePortAddress(universe, subnet) { const uni = Math.max(0, Math.min(15, universe)); const sub = Math.max(0, Math.min(15, subnet)); return (sub << 4) | uni; } function buildArtDmxPacket(dmxData) { const frameLength = Math.max(2, Math.min(512, dmxData.length || 2)); const data = Buffer.alloc(frameLength, 0); dmxData.copy(data, 0, 0, Math.min(frameLength, dmxData.length)); const packet = Buffer.alloc(18 + frameLength); packet.write("Art-Net", 0, "ascii"); packet[7] = 0x00; packet[8] = 0x00; packet[9] = 0x50; packet[10] = 0x00; packet[11] = 14; packet[12] = 0; packet[13] = 0; const portAddress = computePortAddress(ARTNET_UNIVERSE, ARTNET_SUBNET); packet[14] = portAddress & 0xff; packet[15] = clampByte(ARTNET_NET); packet[16] = (frameLength >> 8) & 0xff; packet[17] = frameLength & 0xff; data.copy(packet, 18); return packet; } function normalizeBinaryPayload(data, isBinary) { if (Buffer.isBuffer(data)) return data; if (data instanceof ArrayBuffer) return Buffer.from(data); if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength); if (typeof data === "string") return isBinary ? Buffer.from(data, "binary") : Buffer.from(data, "utf8"); return Buffer.alloc(0); } async function fetchToken() { const response = await fetch(`${API_BASE}/auth/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ apiKey: API_KEY }), }); if (!response.ok) { throw new Error(`/auth/token failed: ${response.status} ${await response.text()}`); } const body = await response.json(); if (!body.token) { throw new Error("auth response did not include a token"); } return body.token; } async function main() { const token = await fetchToken(); const udp = dgram.createSocket("udp4"); if (ENABLE_BROADCAST) udp.setBroadcast(true); let forwardedFrames = 0; let lastFrameBytes = 0; const ws = new WebSocket(`${WS_BASE}/ws`, ["bearer", token]); ws.binaryType = "arraybuffer"; ws.on("open", () => { console.log(`[artnet-bridge] connected to ${WS_BASE}/ws`); console.log(`[artnet-bridge] subscribing to ${CHANNEL}`); console.log( `[artnet-bridge] forwarding ${PAYLOAD_MODE === "artnet" ? "Art-Net packets" : "DMX payloads"} to ${ARTNET_HOST}:${ARTNET_PORT}`, ); ws.send(JSON.stringify({ op: "sub", ch: CHANNEL })); }); ws.on("message", (message, isBinary) => { if (!isBinary) { try { const envelope = JSON.parse(message.toString()); if (envelope.type === "error") { console.error(`[artnet-bridge] gateway error: ${envelope.error}`); } } catch { console.log(`[artnet-bridge] text: ${message.toString()}`); } return; } const incoming = normalizeBinaryPayload(message, isBinary); const packet = PAYLOAD_MODE === "artnet" ? incoming : buildArtDmxPacket(incoming); udp.send(packet, ARTNET_PORT, ARTNET_HOST, (error) => { if (error) { console.error(`[artnet-bridge] udp send failed: ${error.message}`); return; } forwardedFrames += 1; lastFrameBytes = packet.byteLength; console.log( `[artnet-bridge] frame ${forwardedFrames} forwarded (${incoming.byteLength} incoming bytes -> ${lastFrameBytes} udp bytes)`, ); }); }); ws.on("close", (code) => { console.log(`[artnet-bridge] websocket closed (${code})`); udp.close(); }); ws.on("error", (error) => { console.error(`[artnet-bridge] websocket error: ${error.message}`); }); process.on("SIGINT", () => { console.log("\n[artnet-bridge] shutting down"); ws.close(); udp.close(); process.exit(0); }); } main().catch((error) => { console.error(`[artnet-bridge] failed: ${error.message}`); process.exit(1); });