const { contextBridge, ipcRenderer } = require("electron"); const crypto = require("node:crypto"); const dgram = require("node:dgram"); const mqtt = require("mqtt"); const { SerialPort } = require("serialport"); const ModbusRTU = require("modbus-serial"); const knx = require("knx"); const ARTNET_PORT = 6454; const SACN_PORT = 5568; const KNX_PORT = 3671; const ARTNET_HEADER = Buffer.from("Art-Net\0", "ascii"); const ARTNET_OPCODE_DMX = 0x5000; const ARTNET_OPCODE_TIMECODE = 0x9700; function createStatusEmitter() { return { statusListeners: new Set(), lastError: "" }; } function emitStatus(target, message) { target.lastError = message || ""; const payload = { ok: !target.lastError, error: target.lastError }; for (const listener of target.statusListeners) { listener(payload); } } function addStatusApi(target, handler) { target.statusListeners.add(handler); handler({ ok: !target.lastError, error: target.lastError }); return () => { target.statusListeners.delete(handler); }; } const artnet = { ...createStatusEmitter(), sendSocket: dgram.createSocket("udp4"), listenSocket: null, listeners: new Set(), config: { enabled: true, host: "255.255.255.255", port: ARTNET_PORT, broadcast: true, timecodeListenPort: ARTNET_PORT }, sequence: 1 }; const sacn = { ...createStatusEmitter(), socket: dgram.createSocket("udp4"), config: { enabled: false, host: "", port: SACN_PORT, multicast: true, sourceName: "Time4Midi" }, sequence: 1, cid: crypto.randomBytes(16) }; const osc = { ...createStatusEmitter(), socket: dgram.createSocket("udp4"), config: { enabled: false, host: "127.0.0.1", port: 8000 } }; const mqttState = { ...createStatusEmitter(), client: null, connected: false, config: { enabled: false, brokerUrl: "mqtt://127.0.0.1:1883", username: "", password: "", clientId: "" } }; const serialState = { ...createStatusEmitter(), port: null, config: { enabled: false, path: "", baudRate: 9600, dataBits: 8, stopBits: 1, parity: "none", mode: "rs232" } }; const modbusTcpState = { ...createStatusEmitter(), client: new ModbusRTU(), connected: false, signature: "", config: { enabled: false, host: "127.0.0.1", port: 502, timeout: 1000 } }; const modbusRtuState = { ...createStatusEmitter(), client: new ModbusRTU(), connected: false, signature: "", config: { enabled: false, path: "", baudRate: 9600, dataBits: 8, stopBits: 1, parity: "none", timeout: 1000 } }; const knxState = { ...createStatusEmitter(), connection: null, connecting: null, connected: false, config: { enabled: false, ipAddr: "224.0.23.12", ipPort: KNX_PORT, physAddr: "15.15.15", forceTunneling: false } }; function normalizeArtNetConfig(nextConfig = {}) { return { enabled: nextConfig.enabled !== false, host: nextConfig.host || "255.255.255.255", port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : ARTNET_PORT, broadcast: Boolean(nextConfig.broadcast), timecodeListenPort: Number.isFinite(Number(nextConfig.timecodeListenPort)) ? Number(nextConfig.timecodeListenPort) : ARTNET_PORT }; } function normalizeSacnConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), host: String(nextConfig.host || ""), port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : SACN_PORT, multicast: nextConfig.multicast !== false, sourceName: String(nextConfig.sourceName || "Time4Midi").slice(0, 63) }; } function normalizeOscConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), host: nextConfig.host || "127.0.0.1", port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : 8000 }; } function normalizeMqttConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), brokerUrl: String(nextConfig.brokerUrl || "").trim() || "mqtt://127.0.0.1:1883", username: String(nextConfig.username || ""), password: String(nextConfig.password || ""), clientId: String(nextConfig.clientId || "") }; } function normalizeSerialConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), path: String(nextConfig.path || ""), baudRate: Number.isFinite(Number(nextConfig.baudRate)) ? Number(nextConfig.baudRate) : 9600, dataBits: [7, 8].includes(Number(nextConfig.dataBits)) ? Number(nextConfig.dataBits) : 8, stopBits: [1, 2].includes(Number(nextConfig.stopBits)) ? Number(nextConfig.stopBits) : 1, parity: ["none", "even", "odd"].includes(String(nextConfig.parity || "none")) ? String(nextConfig.parity || "none") : "none", mode: String(nextConfig.mode || "rs232") }; } function normalizeModbusTcpConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), host: String(nextConfig.host || "127.0.0.1"), port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : 502, timeout: Number.isFinite(Number(nextConfig.timeout)) ? Number(nextConfig.timeout) : 1000 }; } function normalizeModbusRtuConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), path: String(nextConfig.path || ""), baudRate: Number.isFinite(Number(nextConfig.baudRate)) ? Number(nextConfig.baudRate) : 9600, dataBits: [7, 8].includes(Number(nextConfig.dataBits)) ? Number(nextConfig.dataBits) : 8, stopBits: [1, 2].includes(Number(nextConfig.stopBits)) ? Number(nextConfig.stopBits) : 1, parity: ["none", "even", "odd"].includes(String(nextConfig.parity || "none")) ? String(nextConfig.parity || "none") : "none", timeout: Number.isFinite(Number(nextConfig.timeout)) ? Number(nextConfig.timeout) : 1000 }; } function normalizeKnxConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), ipAddr: String(nextConfig.ipAddr || "224.0.23.12"), ipPort: Number.isFinite(Number(nextConfig.ipPort)) ? Number(nextConfig.ipPort) : KNX_PORT, physAddr: String(nextConfig.physAddr || "15.15.15"), forceTunneling: Boolean(nextConfig.forceTunneling) }; } function closeArtNetListener() { if (artnet.listenSocket) { try { artnet.listenSocket.close(); } catch (_error) { // Ignore listener close races. } artnet.listenSocket = null; } } function parseArtNetTimecode(message) { if (message.length < 19) { return null; } if (!message.subarray(0, 8).equals(ARTNET_HEADER)) { return null; } const opcode = message.readUInt16LE(8); if (opcode !== ARTNET_OPCODE_TIMECODE) { return null; } const frames = message[14]; const seconds = message[15]; const minutes = message[16]; const hours = message[17]; const frameType = message[18] & 0x03; const fpsMap = [24, 25, 29.97, 30]; const fps = fpsMap[frameType] || 30; const totalSeconds = (hours * 3600) + (minutes * 60) + seconds + (frames / fps); return { hours, minutes, seconds, frames, fps, totalSeconds }; } function ensureArtNetListener() { if (!artnet.config.enabled || artnet.listenSocket) { return; } artnet.listenSocket = dgram.createSocket("udp4"); artnet.listenSocket.on("error", (error) => { emitStatus(artnet, `Art-Net Listen Fehler: ${error.message}`); }); artnet.listenSocket.on("listening", () => { emitStatus(artnet, ""); }); artnet.listenSocket.on("message", (message) => { const parsed = parseArtNetTimecode(message); if (!parsed) return; for (const listener of artnet.listeners) { listener(parsed); } }); try { artnet.listenSocket.bind(artnet.config.timecodeListenPort); } catch (error) { emitStatus(artnet, `Art-Net Listen Fehler: ${error.message}`); } } function configureArtNet(nextConfig) { const previousPort = artnet.config.timecodeListenPort; artnet.config = normalizeArtNetConfig(nextConfig); try { artnet.sendSocket.setBroadcast(artnet.config.broadcast); } catch (error) { emitStatus(artnet, `Art-Net Konfig Fehler: ${error.message}`); } if (!artnet.config.enabled) { closeArtNetListener(); emitStatus(artnet, ""); return artnet.config; } if (previousPort !== artnet.config.timecodeListenPort) { closeArtNetListener(); } if (artnet.listeners.size > 0) { ensureArtNetListener(); } return artnet.config; } function sendArtNetDmx(payload) { if (!artnet.config.enabled) { return Promise.resolve(false); } const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0)); const values = Array.isArray(payload.values) ? payload.values : []; const length = Math.max(2, Math.min(512, values.length || 512)); const evenLength = length % 2 === 0 ? length : length + 1; const packet = Buffer.alloc(18 + evenLength); ARTNET_HEADER.copy(packet, 0); packet.writeUInt16LE(ARTNET_OPCODE_DMX, 8); packet[10] = 0x00; packet[11] = 0x0e; packet[12] = artnet.sequence; packet[13] = 0x00; packet.writeUInt16LE(universe, 14); packet.writeUInt16BE(evenLength, 16); for (let index = 0; index < evenLength; index += 1) { packet[18 + index] = Math.max(0, Math.min(255, Math.round(values[index] || 0))); } artnet.sequence = artnet.sequence >= 255 ? 1 : artnet.sequence + 1; return new Promise((resolve, reject) => { artnet.sendSocket.send(packet, 0, packet.length, artnet.config.port, artnet.config.host, (error) => { if (error) { emitStatus(artnet, `Art-Net Send Fehler: ${error.message}`); reject(error); return; } emitStatus(artnet, ""); resolve(true); }); }); } function configureOsc(nextConfig) { osc.config = normalizeOscConfig(nextConfig); emitStatus(osc, ""); return osc.config; } function encodeOscString(value) { const base = Buffer.from(`${value}\0`, "utf8"); const padding = (4 - (base.length % 4)) % 4; return padding === 0 ? base : Buffer.concat([base, Buffer.alloc(padding)]); } function encodeOscArgument(value, argType) { if (argType === "string") { return { tag: "s", data: encodeOscString(String(value ?? "")) }; } if (argType === "int") { const data = Buffer.alloc(4); data.writeInt32BE(Math.round(Number(value) || 0), 0); return { tag: "i", data }; } const data = Buffer.alloc(4); data.writeFloatBE(Number(value) || 0, 0); return { tag: "f", data }; } function sendOsc(payload) { if (!osc.config.enabled) { return Promise.resolve(false); } const address = String(payload.address || "/time4midi"); const normalizedAddress = address.startsWith("/") ? address : `/${address}`; const argument = encodeOscArgument(payload.value, payload.argType || "float"); const packet = Buffer.concat([ encodeOscString(normalizedAddress), encodeOscString(`,${argument.tag}`), argument.data ]); return new Promise((resolve, reject) => { osc.socket.send(packet, 0, packet.length, osc.config.port, osc.config.host, (error) => { if (error) { emitStatus(osc, `OSC Send Fehler: ${error.message}`); reject(error); return; } emitStatus(osc, ""); resolve(true); }); }); } function configureSacn(nextConfig) { sacn.config = normalizeSacnConfig(nextConfig); emitStatus(sacn, ""); return sacn.config; } function clampSacnUniverse(universe) { return Math.max(1, Math.min(63999, Number(universe) || 1)); } function sacnDestination(universe) { const safeUniverse = clampSacnUniverse(universe); if (!sacn.config.multicast) { return sacn.config.host || "127.0.0.1"; } const high = (safeUniverse >> 8) & 0xff; const low = safeUniverse & 0xff; return `239.255.${high}.${low}`; } function encodeFlagsAndLength(length) { return 0x7000 | length; } function writeFixedString(buffer, offset, size, value) { buffer.fill(0, offset, offset + size); Buffer.from(String(value || ""), "utf8").copy(buffer, offset, 0, size); } function sendSacnDmx(payload) { if (!sacn.config.enabled) { return Promise.resolve(false); } const universe = clampSacnUniverse(payload.universe); const values = Array.isArray(payload.values) ? payload.values : []; const slotCount = Math.max(1, Math.min(512, values.length || 512)); const packetLength = 126 + slotCount; const propertyValueCount = slotCount + 1; const packet = Buffer.alloc(packetLength); packet.writeUInt16BE(0x0010, 0); packet.writeUInt16BE(0x0000, 2); writeFixedString(packet, 4, 12, "ASC-E1.17"); packet.writeUInt16BE(encodeFlagsAndLength(packetLength - 16), 16); packet.writeUInt32BE(0x00000004, 18); sacn.cid.copy(packet, 22); packet.writeUInt16BE(encodeFlagsAndLength(packetLength - 38), 38); packet.writeUInt32BE(0x00000002, 40); writeFixedString(packet, 44, 64, sacn.config.sourceName || "Time4Midi"); packet[108] = 100; packet.writeUInt16BE(0x0000, 109); packet[111] = sacn.sequence; packet[112] = 0x00; packet.writeUInt16BE(universe, 113); packet.writeUInt16BE(encodeFlagsAndLength(packetLength - 115), 115); packet[117] = 0x02; packet[118] = 0xa1; packet.writeUInt16BE(0x0000, 119); packet.writeUInt16BE(0x0001, 121); packet.writeUInt16BE(propertyValueCount, 123); packet[125] = 0x00; for (let index = 0; index < slotCount; index += 1) { packet[126 + index] = Math.max(0, Math.min(255, Math.round(values[index] || 0))); } sacn.sequence = sacn.sequence >= 255 ? 1 : sacn.sequence + 1; const host = sacnDestination(universe); return new Promise((resolve, reject) => { sacn.socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => { if (error) { emitStatus(sacn, `sACN Send Fehler: ${error.message}`); reject(error); return; } emitStatus(sacn, ""); resolve(true); }); }); } function disconnectMqtt() { if (mqttState.client) { try { mqttState.client.removeAllListeners(); mqttState.client.end(true); } catch (_error) { // Best effort disconnect. } } mqttState.client = null; mqttState.connected = false; } function configureMqtt(nextConfig) { mqttState.config = normalizeMqttConfig(nextConfig); disconnectMqtt(); if (!mqttState.config.enabled) { emitStatus(mqttState, ""); return mqttState.config; } const options = { reconnectPeriod: 1000 }; if (mqttState.config.clientId) options.clientId = mqttState.config.clientId; if (mqttState.config.username) options.username = mqttState.config.username; if (mqttState.config.password) options.password = mqttState.config.password; try { mqttState.client = mqtt.connect(mqttState.config.brokerUrl, options); mqttState.client.on("connect", () => { mqttState.connected = true; emitStatus(mqttState, ""); }); mqttState.client.on("reconnect", () => { mqttState.connected = false; emitStatus(mqttState, "MQTT verbindet neu..."); }); mqttState.client.on("close", () => { mqttState.connected = false; }); mqttState.client.on("error", (error) => { mqttState.connected = false; emitStatus(mqttState, `MQTT Fehler: ${error.message}`); }); } catch (error) { emitStatus(mqttState, `MQTT Fehler: ${error.message}`); } return mqttState.config; } function publishMqtt(payload) { if (!mqttState.config.enabled) { return Promise.resolve(false); } return new Promise((resolve, reject) => { if (!mqttState.client || !mqttState.connected) { reject(new Error("MQTT nicht verbunden")); return; } mqttState.client.publish(String(payload.topic || ""), String(payload.value ?? ""), {}, (error) => { if (error) { emitStatus(mqttState, `MQTT Send Fehler: ${error.message}`); reject(error); return; } emitStatus(mqttState, ""); resolve(true); }); }); } function closeSerialPort() { return new Promise((resolve) => { if (!serialState.port) { resolve(); return; } const port = serialState.port; serialState.port = null; if (!port.isOpen) { resolve(); return; } port.close(() => resolve()); }); } async function configureSerial(nextConfig) { serialState.config = normalizeSerialConfig(nextConfig); await closeSerialPort(); emitStatus(serialState, ""); return serialState.config; } async function ensureSerialPortOpen() { if (!serialState.config.enabled) { throw new Error("Serial deaktiviert"); } if (!serialState.config.path) { throw new Error("Serial Port nicht gesetzt"); } if (serialState.port && serialState.port.isOpen && serialState.port.path === serialState.config.path) { return serialState.port; } await closeSerialPort(); serialState.port = new SerialPort({ path: serialState.config.path, baudRate: serialState.config.baudRate, dataBits: serialState.config.dataBits, stopBits: serialState.config.stopBits, parity: serialState.config.parity, autoOpen: false }); serialState.port.on("error", (error) => { emitStatus(serialState, `Serial Fehler: ${error.message}`); }); await new Promise((resolve, reject) => { serialState.port.open((error) => { if (error) { reject(error); return; } resolve(true); }); }); emitStatus(serialState, ""); return serialState.port; } async function sendSerial(payload) { if (!serialState.config.enabled) { return false; } const port = await ensureSerialPortOpen(); const text = String(payload.text ?? ""); return new Promise((resolve, reject) => { port.write(text, "utf8", (error) => { if (error) { emitStatus(serialState, `Serial Send Fehler: ${error.message}`); reject(error); return; } emitStatus(serialState, ""); resolve(true); }); }); } async function listSerialPorts() { const ports = await SerialPort.list(); return ports.map((port) => ({ path: port.path, manufacturer: port.manufacturer || "", friendlyName: port.path })); } async function disconnectModbusClient(state) { if (state.client?.isOpen) { try { await state.client.close(); } catch (_error) { // Best effort close. } } state.connected = false; state.signature = ""; } async function configureModbusTcp(nextConfig) { modbusTcpState.config = normalizeModbusTcpConfig(nextConfig); await disconnectModbusClient(modbusTcpState); emitStatus(modbusTcpState, ""); return modbusTcpState.config; } async function configureModbusRtu(nextConfig) { modbusRtuState.config = normalizeModbusRtuConfig(nextConfig); await disconnectModbusClient(modbusRtuState); emitStatus(modbusRtuState, ""); return modbusRtuState.config; } async function ensureModbusTcpConnected() { if (!modbusTcpState.config.enabled) { throw new Error("Modbus TCP deaktiviert"); } const signature = `${modbusTcpState.config.host}:${modbusTcpState.config.port}:${modbusTcpState.config.timeout}`; if (modbusTcpState.connected && modbusTcpState.signature === signature && modbusTcpState.client.isOpen) { return modbusTcpState.client; } await disconnectModbusClient(modbusTcpState); await modbusTcpState.client.connectTCP(modbusTcpState.config.host, { port: modbusTcpState.config.port, timeout: modbusTcpState.config.timeout }); modbusTcpState.client.setTimeout(modbusTcpState.config.timeout); modbusTcpState.connected = true; modbusTcpState.signature = signature; emitStatus(modbusTcpState, ""); return modbusTcpState.client; } async function ensureModbusRtuConnected() { if (!modbusRtuState.config.enabled) { throw new Error("Modbus RTU deaktiviert"); } if (!modbusRtuState.config.path) { throw new Error("Modbus RTU Port nicht gesetzt"); } const signature = `${modbusRtuState.config.path}:${modbusRtuState.config.baudRate}:${modbusRtuState.config.parity}:${modbusRtuState.config.dataBits}:${modbusRtuState.config.stopBits}`; if (modbusRtuState.connected && modbusRtuState.signature === signature && modbusRtuState.client.isOpen) { return modbusRtuState.client; } await disconnectModbusClient(modbusRtuState); await modbusRtuState.client.connectRTUBuffered(modbusRtuState.config.path, { baudRate: modbusRtuState.config.baudRate, parity: modbusRtuState.config.parity, dataBits: modbusRtuState.config.dataBits, stopBits: modbusRtuState.config.stopBits }); modbusRtuState.client.setTimeout(modbusRtuState.config.timeout); modbusRtuState.connected = true; modbusRtuState.signature = signature; emitStatus(modbusRtuState, ""); return modbusRtuState.client; } async function writeModbus(client, payload) { client.setID(Number(payload.unitId) || 1); const address = Number(payload.address) || 0; const value = Number(payload.value) || 0; if (payload.registerType === "coil") { await client.writeCoil(address, value >= 1); } else { await client.writeRegister(address, Math.max(0, Math.min(65535, Math.round(value)))); } } async function sendModbusTcp(payload) { const client = await ensureModbusTcpConnected(); try { await writeModbus(client, payload); emitStatus(modbusTcpState, ""); return true; } catch (error) { emitStatus(modbusTcpState, `Modbus TCP Fehler: ${error.message}`); throw error; } } async function sendModbusRtu(payload) { const client = await ensureModbusRtuConnected(); try { await writeModbus(client, payload); emitStatus(modbusRtuState, ""); return true; } catch (error) { emitStatus(modbusRtuState, `Modbus RTU Fehler: ${error.message}`); throw error; } } function disconnectKnx() { return new Promise((resolve) => { if (!knxState.connection) { knxState.connected = false; knxState.connecting = null; resolve(); return; } const connection = knxState.connection; knxState.connection = null; knxState.connected = false; knxState.connecting = null; try { connection.Disconnect(() => resolve()); } catch (_error) { resolve(); } }); } async function configureKnx(nextConfig) { knxState.config = normalizeKnxConfig(nextConfig); await disconnectKnx(); emitStatus(knxState, ""); return knxState.config; } async function ensureKnxConnection() { if (!knxState.config.enabled) { throw new Error("KNX deaktiviert"); } if (knxState.connection && knxState.connected) { return knxState.connection; } if (knxState.connecting) { return knxState.connecting; } knxState.connecting = new Promise((resolve, reject) => { try { const connection = knx.Connection({ ipAddr: knxState.config.ipAddr, ipPort: knxState.config.ipPort, physAddr: knxState.config.physAddr, forceTunneling: knxState.config.forceTunneling, handlers: { connected: function () { knxState.connection = connection; knxState.connected = true; knxState.connecting = null; emitStatus(knxState, ""); resolve(connection); }, disconnected: function () { knxState.connected = false; knxState.connection = null; }, error: function (error) { knxState.connected = false; knxState.connection = null; knxState.connecting = null; const message = error?.message || String(error); emitStatus(knxState, `KNX Fehler: ${message}`); reject(error instanceof Error ? error : new Error(message)); } } }); knxState.connection = connection; } catch (error) { knxState.connecting = null; emitStatus(knxState, `KNX Fehler: ${error.message}`); reject(error); } }); return knxState.connecting; } async function sendKnx(payload) { const connection = await ensureKnxConnection(); const dpt = String(payload.dpt || "DPT5.001"); let value = payload.value; if (dpt.startsWith("DPT1")) { value = Number(value) >= 1; } else if (dpt.startsWith("DPT16")) { value = String(value ?? ""); } else { value = Number(value) || 0; } return new Promise((resolve, reject) => { try { connection.write(String(payload.groupAddress || "1/1/1"), value, dpt, (error) => { if (error) { emitStatus(knxState, `KNX Send Fehler: ${error.message}`); reject(error); return; } emitStatus(knxState, ""); resolve(true); }); } catch (error) { emitStatus(knxState, `KNX Send Fehler: ${error.message}`); reject(error); } }); } artnet.sendSocket.on("error", (error) => { emitStatus(artnet, `Art-Net Socket Fehler: ${error.message}`); }); osc.socket.on("error", (error) => { emitStatus(osc, `OSC Socket Fehler: ${error.message}`); }); sacn.socket.on("error", (error) => { emitStatus(sacn, `sACN Socket Fehler: ${error.message}`); }); configureMqtt(mqttState.config); contextBridge.exposeInMainWorld("time4midi", { project: { save: (payload) => ipcRenderer.invoke("project:save", payload), load: () => ipcRenderer.invoke("project:load") }, artnet: { configure: (config) => configureArtNet(config), sendDmx: (payload) => sendArtNetDmx(payload), onTimecode: (handler) => { artnet.listeners.add(handler); ensureArtNetListener(); return () => { artnet.listeners.delete(handler); }; }, onStatus: (handler) => addStatusApi(artnet, handler) }, sacn: { configure: (config) => configureSacn(config), sendDmx: (payload) => sendSacnDmx(payload), onStatus: (handler) => addStatusApi(sacn, handler) }, osc: { configure: (config) => configureOsc(config), send: (payload) => sendOsc(payload), onStatus: (handler) => addStatusApi(osc, handler) }, mqtt: { configure: (config) => configureMqtt(config), publish: (payload) => publishMqtt(payload), onStatus: (handler) => addStatusApi(mqttState, handler) }, serial: { configure: (config) => configureSerial(config), send: (payload) => sendSerial(payload), listPorts: () => listSerialPorts(), onStatus: (handler) => addStatusApi(serialState, handler) }, modbusTcp: { configure: (config) => configureModbusTcp(config), send: (payload) => sendModbusTcp(payload), onStatus: (handler) => addStatusApi(modbusTcpState, handler) }, modbusRtu: { configure: (config) => configureModbusRtu(config), send: (payload) => sendModbusRtu(payload), onStatus: (handler) => addStatusApi(modbusRtuState, handler) }, knx: { configure: (config) => configureKnx(config), send: (payload) => sendKnx(payload), onStatus: (handler) => addStatusApi(knxState, handler) } });