feat(bacnet-collector): add initial implementation of BACnet collector with scanning and reading capabilities
- Created package.json for the bacnet-collector project with necessary dependencies. - Implemented main server logic in src/index.js to handle BACnet device scanning and reading. - Added raw discovery functionality in src/raw-discover.js to facilitate device detection over the network. - Introduced utility functions for handling IP addresses, broadcasting, and BACnet packet construction. - Implemented JSON response handling for health checks and capabilities endpoints.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 18111
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "seltd-bacnet-collector",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bacstack": "^0.0.1-beta.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
import { createServer } from "node:http";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import dgram from "node:dgram";
|
||||
import Bacstack from "bacstack";
|
||||
|
||||
const port = Number(process.env.PORT || 18111);
|
||||
const defaultTimeout = Number(process.env.BACNET_SCAN_TIMEOUT || 7000);
|
||||
const pointLimit = Number(process.env.BACNET_POINT_LIMIT || 2000);
|
||||
|
||||
function replyJson(res, statusCode, body) {
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
resolve(text ? JSON.parse(text) : {});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeBacnetMode(value) {
|
||||
const mode = normalizeText(value || "broadcast").toLowerCase();
|
||||
return ["broadcast", "bbmd", "foreign-device"].includes(mode) ? mode : "broadcast";
|
||||
}
|
||||
|
||||
function normalizeBacnetTtl(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 120;
|
||||
}
|
||||
|
||||
function getRouting(body) {
|
||||
return {
|
||||
mode: normalizeBacnetMode(body?.bacnetMode || body?.options?.bacnetMode),
|
||||
host: normalizeText(body?.host || body?.options?.bbmdHost || ""),
|
||||
port: Number(body?.port || body?.options?.bbmdPort || 47808) || 47808,
|
||||
broadcastAddress: normalizeText(body?.bacnetBroadcastAddress || body?.options?.bacnetBroadcastAddress || "255.255.255.255") || "255.255.255.255",
|
||||
foreignDeviceTtl: normalizeBacnetTtl(body?.bacnetForeignDeviceTtl || body?.options?.bacnetForeignDeviceTtl),
|
||||
};
|
||||
}
|
||||
|
||||
function isSubnetMaskLike(address) {
|
||||
return /^255\.255\.255\.(0|128|192|224|240|248|252|254)$/.test(String(address || ""));
|
||||
}
|
||||
|
||||
function isAutoBroadcast(value) {
|
||||
return !normalizeText(value) || ["auto", "automatisch"].includes(normalizeText(value).toLowerCase()) || isSubnetMaskLike(value);
|
||||
}
|
||||
|
||||
function parseIpv4(value) {
|
||||
const address = normalizeText(value).replace(/^::ffff:/, "");
|
||||
const parts = address.split(".").map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return null;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isUsableLanIpv4(value) {
|
||||
const parts = parseIpv4(value);
|
||||
if (!parts) return false;
|
||||
if (parts[0] === 127 || parts[0] === 0 || parts[0] >= 224) return false;
|
||||
if (parts[0] === 172 && parts[1] >= 17 && parts[1] <= 31) return false; // Docker bridge defaults.
|
||||
return true;
|
||||
}
|
||||
|
||||
function ipv4ToNumber(parts) {
|
||||
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
|
||||
}
|
||||
|
||||
function numberToIpv4(value) {
|
||||
return [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join(".");
|
||||
}
|
||||
|
||||
function broadcastFromAddress(address, netmask = "255.255.255.0") {
|
||||
const ipParts = parseIpv4(address);
|
||||
const maskParts = parseIpv4(netmask) || [255, 255, 255, 0];
|
||||
if (!ipParts || !isUsableLanIpv4(address)) return "";
|
||||
const ip = ipv4ToNumber(ipParts);
|
||||
const mask = ipv4ToNumber(maskParts);
|
||||
return numberToIpv4((ip | (~mask >>> 0)) >>> 0);
|
||||
}
|
||||
|
||||
function getInterfaceBroadcasts() {
|
||||
return Object.values(networkInterfaces())
|
||||
.flat()
|
||||
.filter((item) => item && item.family === "IPv4" && !item.internal && isUsableLanIpv4(item.address))
|
||||
.map((item) => broadcastFromAddress(item.address, item.netmask))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getBroadcastTargets(routing, body = {}) {
|
||||
const configured = normalizeText(routing.broadcastAddress || "auto");
|
||||
const targets = [];
|
||||
if (!isAutoBroadcast(configured)) {
|
||||
targets.push(configured);
|
||||
}
|
||||
for (const subnet of parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets)) {
|
||||
const broadcast = cidrBroadcast(subnet);
|
||||
if (broadcast) targets.push(broadcast);
|
||||
}
|
||||
for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) {
|
||||
const broadcast = broadcastFromAddress(candidate);
|
||||
if (broadcast) targets.push(broadcast);
|
||||
}
|
||||
targets.push(...getInterfaceBroadcasts());
|
||||
targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS));
|
||||
if (!targets.length) {
|
||||
targets.push("255.255.255.255");
|
||||
}
|
||||
return Array.from(new Set(targets));
|
||||
}
|
||||
function expandCidrHosts(cidr) {
|
||||
const match = String(cidr || "").trim().match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/);
|
||||
if (!match) return [];
|
||||
const ipParts = parseIpv4(match[1]);
|
||||
const prefix = Number(match[2]);
|
||||
if (!ipParts || !Number.isInteger(prefix) || prefix < 16 || prefix > 32) return [];
|
||||
if (prefix === 32) return [numberToIpv4(ipv4ToNumber(ipParts))];
|
||||
const ip = ipv4ToNumber(ipParts);
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
const network = (ip & mask) >>> 0;
|
||||
const broadcast = (network | (~mask >>> 0)) >>> 0;
|
||||
const limit = Math.min(broadcast - network - 1, 4094);
|
||||
return Array.from({ length: Math.max(0, limit) }, (_, index) => numberToIpv4(network + index + 1));
|
||||
}
|
||||
|
||||
function cidrBroadcast(cidr) {
|
||||
const match = String(cidr || "").trim().match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/);
|
||||
if (!match) return "";
|
||||
const ipParts = parseIpv4(match[1]);
|
||||
const prefix = Number(match[2]);
|
||||
if (!ipParts || !Number.isInteger(prefix) || prefix < 16 || prefix > 30) return "";
|
||||
const ip = ipv4ToNumber(ipParts);
|
||||
const mask = (0xffffffff << (32 - prefix)) >>> 0;
|
||||
return numberToIpv4((ip | (~mask >>> 0)) >>> 0);
|
||||
}
|
||||
function expandDirectedBroadcast(address) {
|
||||
const parts = String(address || "").split(".").map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return [];
|
||||
}
|
||||
if (parts[3] !== 255) {
|
||||
return [];
|
||||
}
|
||||
const prefix = parts.slice(0, 3).join(".");
|
||||
return Array.from({ length: 254 }, (_, index) => prefix + "." + (index + 1));
|
||||
}
|
||||
|
||||
function parseHostList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => parseHostList(item));
|
||||
}
|
||||
return String(value || "")
|
||||
.split(/[;,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildUnicastScanHosts(body, routing) {
|
||||
const hosts = [
|
||||
...parseHostList(body.scanHosts),
|
||||
...parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets).flatMap((subnet) => subnet.includes("/") ? expandCidrHosts(subnet) : (parseIpv4(subnet) ? [subnet] : [])),
|
||||
...parseHostList(process.env.BACNET_SCAN_HOSTS),
|
||||
];
|
||||
if (routing.host && routing.mode !== "broadcast") {
|
||||
hosts.push(routing.host);
|
||||
}
|
||||
for (const broadcastAddress of getBroadcastTargets(routing, body)) {
|
||||
if (broadcastAddress !== "255.255.255.255") {
|
||||
hosts.push(...expandDirectedBroadcast(broadcastAddress));
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(hosts)).slice(0, 512);
|
||||
}
|
||||
|
||||
function sendUnicastWhoIs(client, host, deviceId) {
|
||||
const packet = captureWhoIsPacket(client, deviceId);
|
||||
packet[1] = 0x0a;
|
||||
getTransport(client).send(packet, packet.length, String(host || "").replace(/:\d+$/, ""));
|
||||
}
|
||||
|
||||
function createClient(body, timeout) {
|
||||
const routing = getRouting(body);
|
||||
const client = new Bacstack({
|
||||
apduTimeout: timeout,
|
||||
port: routing.port,
|
||||
broadcastAddress: routing.broadcastAddress,
|
||||
});
|
||||
return { client, routing };
|
||||
}
|
||||
|
||||
function closeClient(client) {
|
||||
try {
|
||||
client.close?.();
|
||||
} catch {}
|
||||
try {
|
||||
client._transport?.close?.();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getTransport(client) {
|
||||
const transport = client?._transport;
|
||||
if (!transport || typeof transport.send !== "function" || !transport._server) {
|
||||
throw new Error("BACnet-Transport konnte nicht initialisiert werden.");
|
||||
}
|
||||
return transport;
|
||||
}
|
||||
|
||||
function waitForSocket(client, timeout = 1200) {
|
||||
const transport = getTransport(client);
|
||||
try {
|
||||
const address = transport._server.address?.();
|
||||
if (address && typeof address.port === "number") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
transport._server.off("listening", onListening);
|
||||
transport._server.off("error", onError);
|
||||
clearTimeout(timer);
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const onListening = () => finish();
|
||||
const onError = (error) => finish(error);
|
||||
const timer = setTimeout(() => finish(), timeout);
|
||||
transport._server.once("listening", onListening);
|
||||
transport._server.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
function captureWhoIsPacket(client, deviceId) {
|
||||
const transport = getTransport(client);
|
||||
const originalSend = transport.send.bind(transport);
|
||||
let packet = null;
|
||||
|
||||
transport.send = (buffer, offset) => {
|
||||
packet = Buffer.from(buffer.slice(0, offset));
|
||||
};
|
||||
|
||||
try {
|
||||
const numericDeviceId = Number(deviceId);
|
||||
if (Number.isFinite(numericDeviceId)) {
|
||||
client.whoIs({ lowLimit: numericDeviceId, highLimit: numericDeviceId });
|
||||
} else {
|
||||
client.whoIs();
|
||||
}
|
||||
} finally {
|
||||
transport.send = originalSend;
|
||||
}
|
||||
|
||||
if (!packet || packet.length < 4 || packet[0] !== 0x81) {
|
||||
throw new Error("BACnet Who-Is konnte nicht aufgebaut werden.");
|
||||
}
|
||||
return packet;
|
||||
}
|
||||
|
||||
function bvlcResultText(resultCode) {
|
||||
const known = {
|
||||
0x0000: "Erfolgreich",
|
||||
0x0030: "Register-Foreign-Device NAK",
|
||||
0x0060: "Distribute-Broadcast-To-Network NAK",
|
||||
};
|
||||
return known[resultCode] || `BVLC-Fehler ${resultCode}`;
|
||||
}
|
||||
|
||||
async function sendControlPacket(client, receiver, packet, matcher, timeout = 3000) {
|
||||
const transport = getTransport(client);
|
||||
await waitForSocket(client);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const socket = transport._server;
|
||||
const cleanup = () => {
|
||||
socket.off("message", onMessage);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
const finish = (error, result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
const onMessage = (message, rinfo) => {
|
||||
try {
|
||||
if (matcher(message, rinfo)) {
|
||||
finish(null, { message, rinfo });
|
||||
}
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error("BACnet-Steuertelegramm hat nicht rechtzeitig geantwortet.")), timeout);
|
||||
socket.on("message", onMessage);
|
||||
try {
|
||||
transport.send(packet, packet.length, receiver);
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function registerForeignDevice(client, routing) {
|
||||
if (routing.mode !== "foreign-device") {
|
||||
return;
|
||||
}
|
||||
if (!routing.host) {
|
||||
throw new Error("Für BACnet Foreign Device bitte BBMD Host/IP angeben.");
|
||||
}
|
||||
|
||||
const packet = Buffer.alloc(6);
|
||||
packet[0] = 0x81;
|
||||
packet[1] = 0x05;
|
||||
packet.writeUInt16BE(6, 2);
|
||||
packet.writeUInt16BE(routing.foreignDeviceTtl, 4);
|
||||
|
||||
const { message } = await sendControlPacket(
|
||||
client,
|
||||
routing.host,
|
||||
packet,
|
||||
(candidate, rinfo) => candidate?.length >= 6 && candidate[0] === 0x81 && candidate[1] === 0x00 && Number(rinfo?.port) === routing.port,
|
||||
3000,
|
||||
);
|
||||
|
||||
const resultCode = message.readUInt16BE(4);
|
||||
if (resultCode !== 0x0000) {
|
||||
throw new Error(`Foreign-Device-Register wurde abgelehnt: ${bvlcResultText(resultCode)}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function sendWhoIs(client, routing, deviceId) {
|
||||
if (routing.mode === "bbmd" || routing.mode === "foreign-device") {
|
||||
if (!routing.host) {
|
||||
throw new Error("Für BACnet über BBMD bitte Host/IP angeben.");
|
||||
}
|
||||
const packet = captureWhoIsPacket(client, deviceId);
|
||||
packet[1] = 0x09;
|
||||
getTransport(client).send(packet, packet.length, routing.host);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericDeviceId = Number(deviceId);
|
||||
if (Number.isFinite(numericDeviceId)) {
|
||||
client.whoIs({ lowLimit: numericDeviceId, highLimit: numericDeviceId });
|
||||
} else {
|
||||
client.whoIs();
|
||||
}
|
||||
}
|
||||
|
||||
function readProperty(client, host, objectId, propertyId) {
|
||||
return new Promise((resolve) => {
|
||||
client.readProperty(host, objectId, propertyId, (error, value) => {
|
||||
resolve(error ? null : value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function objectLabel(objectId) {
|
||||
const typeMap = {
|
||||
0: "analogInput",
|
||||
1: "analogOutput",
|
||||
2: "analogValue",
|
||||
3: "binaryInput",
|
||||
4: "binaryOutput",
|
||||
5: "binaryValue",
|
||||
8: "device",
|
||||
13: "multiStateInput",
|
||||
14: "multiStateOutput",
|
||||
19: "multiStateValue",
|
||||
};
|
||||
return `${typeMap[objectId.type] || `object${objectId.type}`}${objectId.instance}`;
|
||||
}
|
||||
|
||||
function extractValue(propertyValue) {
|
||||
return propertyValue?.values?.[0]?.value ?? propertyValue?.values?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function scanObjects(client, device, portNumber) {
|
||||
const host = device.address;
|
||||
const deviceObject = { type: 8, instance: Number(device.deviceId) };
|
||||
const objectList = await readProperty(client, host, deviceObject, 76);
|
||||
const values = objectList?.values || [];
|
||||
const points = [];
|
||||
|
||||
for (const entry of values.slice(0, pointLimit)) {
|
||||
const objectId = entry?.value || entry;
|
||||
if (!objectId || objectId.type === 8 || !Number.isFinite(Number(objectId.instance))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectName = await readProperty(client, host, objectId, 77);
|
||||
const description = await readProperty(client, host, objectId, 28);
|
||||
const presentValue = await readProperty(client, host, objectId, 85);
|
||||
const raw = extractValue(presentValue);
|
||||
const name = normalizeText(extractValue(objectName)) || objectLabel(objectId);
|
||||
const alias = normalizeText(extractValue(description)) || name;
|
||||
|
||||
points.push({
|
||||
id: `${host}-${objectId.type}-${objectId.instance}`,
|
||||
host,
|
||||
port: portNumber,
|
||||
deviceId: device.deviceId,
|
||||
objectType: objectId.type,
|
||||
objectInstance: objectId.instance,
|
||||
propertyId: 85,
|
||||
address: String(objectId.instance),
|
||||
dataType: objectLabel(objectId).replace(/[0-9]+$/, ""),
|
||||
name,
|
||||
description: alias,
|
||||
alias,
|
||||
kind: typeof raw === "boolean" || (objectId.type >= 3 && objectId.type <= 5) ? "digital" : "analog",
|
||||
currentValue: raw,
|
||||
hasValue: raw !== null,
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
function encodeBacnetContextUnsigned(tagNumber, value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (numeric <= 0xff) {
|
||||
return Buffer.from([(tagNumber << 4) | 1, numeric]);
|
||||
}
|
||||
if (numeric <= 0xffff) {
|
||||
const buffer = Buffer.alloc(3);
|
||||
buffer[0] = (tagNumber << 4) | 2;
|
||||
buffer.writeUInt16BE(numeric, 1);
|
||||
return buffer;
|
||||
}
|
||||
const buffer = Buffer.alloc(5);
|
||||
buffer[0] = (tagNumber << 4) | 4;
|
||||
buffer.writeUInt32BE(numeric, 1);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function buildRawWhoIsPacket(deviceId, bvlcFunction) {
|
||||
const numericDeviceId = Number(deviceId);
|
||||
const payload = [Buffer.from([0x01, 0x00, 0x10, 0x08])];
|
||||
if (Number.isFinite(numericDeviceId)) {
|
||||
payload.push(encodeBacnetContextUnsigned(0, numericDeviceId));
|
||||
payload.push(encodeBacnetContextUnsigned(1, numericDeviceId));
|
||||
}
|
||||
const body = Buffer.concat(payload);
|
||||
const packet = Buffer.alloc(4 + body.length);
|
||||
packet[0] = 0x81;
|
||||
packet[1] = bvlcFunction;
|
||||
packet.writeUInt16BE(packet.length, 2);
|
||||
body.copy(packet, 4);
|
||||
return packet;
|
||||
}
|
||||
|
||||
function parseRawIAm(message) {
|
||||
if (!message || message.length < 12 || message[0] !== 0x81) {
|
||||
return null;
|
||||
}
|
||||
const start = message.indexOf(0xc4);
|
||||
if (start < 0 || start + 4 >= message.length) {
|
||||
return null;
|
||||
}
|
||||
const objectId = message.readUInt32BE(start + 1);
|
||||
const objectType = objectId >>> 22;
|
||||
const deviceId = objectId & 0x3fffff;
|
||||
if (objectType !== 8) {
|
||||
return null;
|
||||
}
|
||||
return { deviceId };
|
||||
}
|
||||
|
||||
function runRawDiscoveryHelper(body, routing) {
|
||||
const timeout = Number(body.timeout || defaultTimeout) + 3000;
|
||||
const helperPath = fileURLToPath(new URL("./raw-discover.js", import.meta.url));
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [helperPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
RAW_BACNET_BODY: JSON.stringify({ body, routing }),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("BACnet Raw-Discovery-Helper Timeout"));
|
||||
}, Math.max(2500, timeout));
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr || `BACnet Raw-Discovery-Helper beendet mit Code ${code}.`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout || "{}"));
|
||||
} catch (error) {
|
||||
reject(new Error(`BACnet Raw-Discovery-Helper lieferte kein gültiges JSON: ${error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
async function rawBacnetDiscover(body, routing) {
|
||||
if (routing.mode !== "broadcast") {
|
||||
return [];
|
||||
}
|
||||
const hosts = buildUnicastScanHosts(body, routing);
|
||||
const helperResult = await runRawDiscoveryHelper(body, routing).catch((error) => ({
|
||||
helperError: error?.message || String(error),
|
||||
devices: [],
|
||||
rawTrace: [],
|
||||
rawDebug: { hosts, sends: [], packets: [], helperError: error?.message || String(error) },
|
||||
}));
|
||||
const helperDevices = Array.isArray(helperResult.devices) ? helperResult.devices : [];
|
||||
if (helperDevices.length) {
|
||||
helperDevices.rawTrace = helperResult.rawTrace || [];
|
||||
helperDevices.rawDebug = { ...(helperResult.rawDebug || {}), helper: "process" };
|
||||
return helperDevices;
|
||||
}
|
||||
|
||||
const devices = [];
|
||||
const rawTrace = helperResult.rawTrace || [];
|
||||
const rawDebug = { hosts, sends: [], packets: [], helper: "fallback", helperError: helperResult.helperError || "" };
|
||||
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
||||
const addDevice = (deviceId, host, rinfo) => {
|
||||
rawTrace.push({ deviceId: Number(deviceId), host, remote: rinfo ? rinfo.address + ":" + rinfo.port : "" });
|
||||
if (!Number.isFinite(Number(deviceId)) || !host) {
|
||||
return;
|
||||
}
|
||||
if (!devices.some((item) => item.deviceId === Number(deviceId) && item.address === host)) {
|
||||
devices.push({
|
||||
address: host,
|
||||
host,
|
||||
port: routing.port,
|
||||
reachable: true,
|
||||
deviceId: Number(deviceId),
|
||||
name: "BACnet Gerät " + Number(deviceId),
|
||||
vendorId: null,
|
||||
maxApdu: null,
|
||||
segmentation: null,
|
||||
pointCount: 0,
|
||||
points: [],
|
||||
rawRemote: rinfo ? rinfo.address + ":" + rinfo.port : "",
|
||||
});
|
||||
}
|
||||
};
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.bind(routing.port, "0.0.0.0", () => {
|
||||
socket.setBroadcast(true);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
socket.on("message", (message, rinfo) => {
|
||||
rawDebug.packets.push({ remote: rinfo.address + ":" + rinfo.port, length: message.length, hex: message.toString("hex") });
|
||||
const parsed = parseRawIAm(message);
|
||||
if (parsed) {
|
||||
addDevice(parsed.deviceId, rinfo.address, rinfo);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const broadcastPacket = buildRawWhoIsPacket(body.deviceId, 0x0b);
|
||||
for (const broadcastAddress of getBroadcastTargets(routing, body)) {
|
||||
socket.send(broadcastPacket, routing.port, broadcastAddress, (error) => rawDebug.sends.push({ host: broadcastAddress, error: error?.message || "" }));
|
||||
await sleep(350);
|
||||
}
|
||||
const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 20);
|
||||
const packet = buildRawWhoIsPacket(body.deviceId, 0x0a);
|
||||
for (const host of hosts) {
|
||||
const before = devices.length;
|
||||
socket.removeAllListeners("message");
|
||||
socket.on("message", (message, rinfo) => {
|
||||
rawDebug.packets.push({ remote: rinfo.address + ":" + rinfo.port, length: message.length, hex: message.toString("hex") });
|
||||
const parsed = parseRawIAm(message);
|
||||
if (parsed) {
|
||||
addDevice(parsed.deviceId, host, rinfo);
|
||||
}
|
||||
});
|
||||
socket.send(packet, routing.port, host, (error) => rawDebug.sends.push({ host, error: error?.message || "" }));
|
||||
await sleep(Math.max(15, perHostDelay));
|
||||
if (body.deviceId && devices.length > before && devices.some((device) => String(device.deviceId) === String(body.deviceId))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await sleep(150);
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
devices.rawTrace = rawTrace;
|
||||
devices.rawDebug = rawDebug;
|
||||
return devices;
|
||||
}
|
||||
|
||||
function isLikelyDockerGatewayAddress(address) {
|
||||
return /^172\.(1[7-9]|2\d|3[01])\.0\.1$/.test(String(address || ""));
|
||||
}
|
||||
|
||||
function preferRealBacnetAddresses(devices) {
|
||||
const list = Array.isArray(devices) ? devices : [];
|
||||
const idsWithRealAddress = new Set(
|
||||
list
|
||||
.filter((device) => !isLikelyDockerGatewayAddress(device.address || device.host))
|
||||
.map((device) => String(device.deviceId)),
|
||||
);
|
||||
return list.filter((device) => {
|
||||
const address = device.address || device.host;
|
||||
return !(idsWithRealAddress.has(String(device.deviceId)) && isLikelyDockerGatewayAddress(address));
|
||||
});
|
||||
}
|
||||
async function scanNetwork(body) {
|
||||
const timeout = Number(body.timeout || defaultTimeout);
|
||||
const includeObjects = body.includeObjects === true || String(body.includeObjects || "").toLowerCase() === "true";
|
||||
const objectReadTimeout = Number(body.objectReadTimeout || process.env.BACNET_OBJECT_READ_TIMEOUT || 1500);
|
||||
const routing = getRouting(body);
|
||||
const rawDevices = await rawBacnetDiscover(body, routing);
|
||||
const { client } = createClient(body, includeObjects ? Math.min(timeout, objectReadTimeout) : timeout);
|
||||
const devices = [...rawDevices];
|
||||
const rawTrace = rawDevices.rawTrace || [];
|
||||
const rawDebug = rawDevices.rawDebug || null;
|
||||
let currentUnicastTarget = "";
|
||||
|
||||
const addDevice = (device, forcedAddress = "") => {
|
||||
const address = normalizeText(forcedAddress || device?.address || "");
|
||||
const deviceId = Number(device?.deviceId);
|
||||
if (!address || !Number.isFinite(deviceId)) {
|
||||
return;
|
||||
}
|
||||
const existing = devices.find((item) => item.deviceId === deviceId);
|
||||
if (existing && forcedAddress) {
|
||||
existing.address = address;
|
||||
existing.host = address;
|
||||
return;
|
||||
}
|
||||
if (!devices.some((item) => item.deviceId === deviceId && item.address === address)) {
|
||||
devices.push({
|
||||
address,
|
||||
host: address,
|
||||
port: routing.port,
|
||||
reachable: true,
|
||||
deviceId,
|
||||
name: "BACnet Gerät " + deviceId,
|
||||
vendorId: device?.vendorId ?? null,
|
||||
maxApdu: device?.maxApdu ?? null,
|
||||
segmentation: device?.segmentation ?? null,
|
||||
pointCount: 0,
|
||||
points: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
client.on("iAm", (device) => addDevice(device, currentUnicastTarget));
|
||||
|
||||
try {
|
||||
if ((routing.mode === "bbmd" || routing.mode === "foreign-device") && !routing.host) {
|
||||
throw new Error("Für BACnet über BBMD bitte Host/IP angeben.");
|
||||
}
|
||||
await waitForSocket(client);
|
||||
await registerForeignDevice(client, routing);
|
||||
sendWhoIs(client, routing, body.deviceId);
|
||||
await sleep(Math.min(timeout, 1800));
|
||||
|
||||
const hosts = routing.mode === "broadcast" ? buildUnicastScanHosts(body, routing) : [];
|
||||
const needsUnicastMapping = routing.mode === "broadcast" && hosts.length && (!devices.length || devices.some((device) => !hosts.includes(device.address)));
|
||||
if (needsUnicastMapping) {
|
||||
const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 20);
|
||||
for (const host of hosts) {
|
||||
currentUnicastTarget = host;
|
||||
sendUnicastWhoIs(client, host, body.deviceId);
|
||||
await sleep(Math.max(15, perHostDelay));
|
||||
if (body.deviceId && devices.some((device) => String(device.deviceId) === String(body.deviceId))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentUnicastTarget = "";
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
let visibleDevices = preferRealBacnetAddresses(devices);
|
||||
if (includeObjects) {
|
||||
devices.splice(0, devices.length, ...visibleDevices);
|
||||
for (const device of devices) {
|
||||
const deviceObject = { type: 8, instance: Number(device.deviceId) };
|
||||
const deviceName = await readProperty(client, device.address, deviceObject, 77);
|
||||
const resolvedName = normalizeText(extractValue(deviceName));
|
||||
if (resolvedName) {
|
||||
device.name = resolvedName;
|
||||
}
|
||||
device.points = await scanObjects(client, device, routing.port);
|
||||
device.pointCount = device.points.length;
|
||||
}
|
||||
}
|
||||
|
||||
visibleDevices = preferRealBacnetAddresses(devices);
|
||||
|
||||
return {
|
||||
driver: "bacstack",
|
||||
routing,
|
||||
devices: visibleDevices,
|
||||
rawTrace: body.debug === true ? rawTrace : undefined,
|
||||
rawDebug: body.debug === true ? rawDebug : undefined,
|
||||
points: visibleDevices.flatMap((device) => device.points || []),
|
||||
scannedAt: new Date().toISOString(),
|
||||
};
|
||||
} finally {
|
||||
closeClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
function parseObjectId(datapoint) {
|
||||
const typeMap = {
|
||||
analogInput: 0,
|
||||
analogOutput: 1,
|
||||
analogValue: 2,
|
||||
binaryInput: 3,
|
||||
binaryOutput: 4,
|
||||
binaryValue: 5,
|
||||
multiStateInput: 13,
|
||||
multiStateOutput: 14,
|
||||
multiStateValue: 19,
|
||||
};
|
||||
if (Number.isFinite(Number(datapoint.objectType))) {
|
||||
return { type: Number(datapoint.objectType), instance: Number(datapoint.objectInstance || datapoint.address || 0) };
|
||||
}
|
||||
const normalizedType = String(datapoint.dataType || "").replace(/[s_-]+/g, "");
|
||||
const matchedType = Object.entries(typeMap).find(([name]) => name.toLowerCase() === normalizedType.toLowerCase())?.[1];
|
||||
const shorthand = String(datapoint.address || datapoint.nodeId || "").match(/^(ai|ao|av|bi|bo|bv|msi|mso|msv)(\\d+)$/i);
|
||||
const shorthandTypes = { ai: 0, ao: 1, av: 2, bi: 3, bo: 4, bv: 5, msi: 13, mso: 14, msv: 19 };
|
||||
return {
|
||||
type: matchedType ?? (shorthand ? shorthandTypes[shorthand[1].toLowerCase()] : 0),
|
||||
instance: Number(datapoint.objectInstance || (shorthand ? shorthand[2] : datapoint.address) || datapoint.pointIndex || 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function readCurrentValue(source, datapoint) {
|
||||
const { client, routing } = createClient(source, Number(source.timeout || 5000));
|
||||
try {
|
||||
await waitForSocket(client);
|
||||
await registerForeignDevice(client, routing);
|
||||
const host = normalizeText(datapoint.host || source.host || "");
|
||||
if (!host) {
|
||||
throw new Error("BACnet Host fehlt.");
|
||||
}
|
||||
const propertyId = Number(datapoint.propertyId || 85);
|
||||
const objectId = parseObjectId(datapoint);
|
||||
const propertyValue = await new Promise((resolve, reject) => {
|
||||
client.readProperty(host, objectId, propertyId, (error, value) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
const raw = extractValue(propertyValue);
|
||||
if (typeof raw === "boolean") {
|
||||
return raw ? 1 : 0;
|
||||
}
|
||||
const numeric = Number(raw);
|
||||
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : String(raw ?? "");
|
||||
} finally {
|
||||
closeClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
replyJson(res, 204, {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/health") {
|
||||
replyJson(res, 200, { ok: true, service: "bacnet-collector", driver: "bacstack", timestamp: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/capabilities") {
|
||||
replyJson(res, 200, {
|
||||
protocols: ["bacnet-ip"],
|
||||
scans: ["bacnet-ip"],
|
||||
driver: "bacstack",
|
||||
modes: ["broadcast", "bbmd", "foreign-device"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
const payload = await scanNetwork(body);
|
||||
replyJson(res, 200, payload);
|
||||
} catch (error) {
|
||||
replyJson(res, 400, { error: error.message || "BACnet-Scan fehlgeschlagen." });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/read/bacnet") {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
const value = await readCurrentValue(body.source || {}, body.datapoint || {});
|
||||
replyJson(res, 200, { value, readAt: new Date().toISOString() });
|
||||
} catch (error) {
|
||||
replyJson(res, 400, { error: error.message || "BACnet-Livewert konnte nicht gelesen werden." });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
replyJson(res, 404, { error: "Nicht gefunden." });
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`SE Local Trenddata BACnet collector listening on port ${port}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import dgram from "node:dgram";
|
||||
import { networkInterfaces } from "node:os";
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function parseHostList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => parseHostList(item));
|
||||
}
|
||||
return String(value || "")
|
||||
.split(/[,\s;]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isSubnetMaskLike(address) {
|
||||
return /^255\.255\.255\.(0|128|192|224|240|248|252|254)$/.test(String(address || ""));
|
||||
}
|
||||
|
||||
function isAutoBroadcast(value) {
|
||||
return !normalizeText(value) || ["auto", "automatisch"].includes(normalizeText(value).toLowerCase()) || isSubnetMaskLike(value);
|
||||
}
|
||||
|
||||
function parseIpv4(value) {
|
||||
const address = normalizeText(value).replace(/^::ffff:/, "");
|
||||
const parts = address.split(".").map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return null;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isUsableLanIpv4(value) {
|
||||
const parts = parseIpv4(value);
|
||||
if (!parts) return false;
|
||||
if (parts[0] === 127 || parts[0] === 0 || parts[0] >= 224) return false;
|
||||
if (parts[0] === 172 && parts[1] >= 17 && parts[1] <= 31) return false; // Docker bridge defaults.
|
||||
return true;
|
||||
}
|
||||
|
||||
function ipv4ToNumber(parts) {
|
||||
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
|
||||
}
|
||||
|
||||
function numberToIpv4(value) {
|
||||
return [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join(".");
|
||||
}
|
||||
|
||||
function broadcastFromAddress(address, netmask = "255.255.255.0") {
|
||||
const ipParts = parseIpv4(address);
|
||||
const maskParts = parseIpv4(netmask) || [255, 255, 255, 0];
|
||||
if (!ipParts || !isUsableLanIpv4(address)) return "";
|
||||
const ip = ipv4ToNumber(ipParts);
|
||||
const mask = ipv4ToNumber(maskParts);
|
||||
return numberToIpv4((ip | (~mask >>> 0)) >>> 0);
|
||||
}
|
||||
|
||||
function getInterfaceBroadcasts() {
|
||||
return Object.values(networkInterfaces())
|
||||
.flat()
|
||||
.filter((item) => item && item.family === "IPv4" && !item.internal && isUsableLanIpv4(item.address))
|
||||
.map((item) => broadcastFromAddress(item.address, item.netmask))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getBroadcastTargets(routing, body = {}) {
|
||||
const configured = normalizeText(routing.broadcastAddress || "auto");
|
||||
const targets = [];
|
||||
if (!isAutoBroadcast(configured)) {
|
||||
targets.push(configured);
|
||||
}
|
||||
for (const subnet of parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets)) {
|
||||
const broadcast = cidrBroadcast(subnet);
|
||||
if (broadcast) targets.push(broadcast);
|
||||
}
|
||||
for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) {
|
||||
const broadcast = broadcastFromAddress(candidate);
|
||||
if (broadcast) targets.push(broadcast);
|
||||
}
|
||||
targets.push(...getInterfaceBroadcasts());
|
||||
targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS));
|
||||
if (!targets.length) {
|
||||
targets.push("255.255.255.255");
|
||||
}
|
||||
return Array.from(new Set(targets));
|
||||
}
|
||||
function expandCidrHosts(cidr) {
|
||||
const match = String(cidr || "").trim().match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/);
|
||||
if (!match) return [];
|
||||
const ipParts = parseIpv4(match[1]);
|
||||
const prefix = Number(match[2]);
|
||||
if (!ipParts || !Number.isInteger(prefix) || prefix < 16 || prefix > 32) return [];
|
||||
if (prefix === 32) return [numberToIpv4(ipv4ToNumber(ipParts))];
|
||||
const ip = ipv4ToNumber(ipParts);
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
const network = (ip & mask) >>> 0;
|
||||
const broadcast = (network | (~mask >>> 0)) >>> 0;
|
||||
const limit = Math.min(broadcast - network - 1, 4094);
|
||||
return Array.from({ length: Math.max(0, limit) }, (_, index) => numberToIpv4(network + index + 1));
|
||||
}
|
||||
|
||||
function cidrBroadcast(cidr) {
|
||||
const match = String(cidr || "").trim().match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/);
|
||||
if (!match) return "";
|
||||
const ipParts = parseIpv4(match[1]);
|
||||
const prefix = Number(match[2]);
|
||||
if (!ipParts || !Number.isInteger(prefix) || prefix < 16 || prefix > 30) return "";
|
||||
const ip = ipv4ToNumber(ipParts);
|
||||
const mask = (0xffffffff << (32 - prefix)) >>> 0;
|
||||
return numberToIpv4((ip | (~mask >>> 0)) >>> 0);
|
||||
}
|
||||
function expandDirectedBroadcast(address) {
|
||||
const parts = String(address || "").split(".").map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return [];
|
||||
}
|
||||
if (parts[3] !== 255) {
|
||||
return [];
|
||||
}
|
||||
const prefix = parts.slice(0, 3).join(".");
|
||||
return Array.from({ length: 254 }, (_, index) => prefix + "." + (index + 1));
|
||||
}
|
||||
|
||||
function buildUnicastScanHosts(body, routing) {
|
||||
const hosts = [
|
||||
...parseHostList(body.scanHosts),
|
||||
...parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets).flatMap((subnet) => subnet.includes("/") ? expandCidrHosts(subnet) : (parseIpv4(subnet) ? [subnet] : [])),
|
||||
...parseHostList(process.env.BACNET_SCAN_HOSTS),
|
||||
];
|
||||
if (routing.host && routing.mode !== "broadcast") {
|
||||
hosts.push(routing.host);
|
||||
}
|
||||
for (const broadcastAddress of getBroadcastTargets(routing, body)) {
|
||||
if (broadcastAddress !== "255.255.255.255") {
|
||||
hosts.push(...expandDirectedBroadcast(broadcastAddress));
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(hosts)).slice(0, 512);
|
||||
}
|
||||
|
||||
function encodeBacnetContextUnsigned(tagNumber, value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (numeric <= 0xff) {
|
||||
return Buffer.from([(tagNumber << 4) | 0x09, numeric]);
|
||||
}
|
||||
if (numeric <= 0xffff) {
|
||||
const buffer = Buffer.alloc(3);
|
||||
buffer[0] = (tagNumber << 4) | 0x0a;
|
||||
buffer.writeUInt16BE(numeric, 1);
|
||||
return buffer;
|
||||
}
|
||||
const buffer = Buffer.alloc(5);
|
||||
buffer[0] = (tagNumber << 4) | 0x0c;
|
||||
buffer.writeUInt32BE(numeric, 1);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function buildRawWhoIsPacket(deviceId, bvlcFunction) {
|
||||
const numericDeviceId = Number(deviceId);
|
||||
const payload = [Buffer.from([0x01, 0x00, 0x10, 0x08])];
|
||||
if (Number.isFinite(numericDeviceId)) {
|
||||
payload.push(encodeBacnetContextUnsigned(0, numericDeviceId));
|
||||
payload.push(encodeBacnetContextUnsigned(1, numericDeviceId));
|
||||
}
|
||||
const body = Buffer.concat(payload);
|
||||
const packet = Buffer.alloc(4 + body.length);
|
||||
packet[0] = 0x81;
|
||||
packet[1] = bvlcFunction;
|
||||
packet.writeUInt16BE(packet.length, 2);
|
||||
body.copy(packet, 4);
|
||||
return packet;
|
||||
}
|
||||
|
||||
function parseRawIAm(message) {
|
||||
if (!message || message.length < 12 || message[0] !== 0x81) {
|
||||
return null;
|
||||
}
|
||||
const start = message.indexOf(0xc4);
|
||||
if (start < 0 || start + 4 >= message.length) {
|
||||
return null;
|
||||
}
|
||||
const objectId = message.readUInt32BE(start + 1);
|
||||
const objectType = objectId >>> 22;
|
||||
const deviceId = objectId & 0x3fffff;
|
||||
if (objectType !== 8) {
|
||||
return null;
|
||||
}
|
||||
return { deviceId };
|
||||
}
|
||||
|
||||
async function discover() {
|
||||
const input = JSON.parse(process.env.RAW_BACNET_BODY || "{}");
|
||||
const body = input.body || {};
|
||||
const routing = input.routing || {};
|
||||
const port = Number(routing.port || 47808) || 47808;
|
||||
const hosts = buildUnicastScanHosts(body, routing);
|
||||
const devices = [];
|
||||
const rawTrace = [];
|
||||
const rawDebug = { hosts, sends: [], packets: [] };
|
||||
let currentUnicastHost = "";
|
||||
|
||||
if (routing.mode !== "broadcast") {
|
||||
return { devices, rawTrace, rawDebug };
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
||||
|
||||
const addDevice = (deviceId, host, rinfo) => {
|
||||
rawTrace.push({ deviceId: Number(deviceId), host, remote: rinfo ? rinfo.address + ":" + rinfo.port : "" });
|
||||
if (!Number.isFinite(Number(deviceId)) || !host) {
|
||||
return;
|
||||
}
|
||||
if (!devices.some((item) => item.deviceId === Number(deviceId) && item.address === host)) {
|
||||
devices.push({
|
||||
address: host,
|
||||
host,
|
||||
port,
|
||||
reachable: true,
|
||||
deviceId: Number(deviceId),
|
||||
name: "BACnet Gerät " + Number(deviceId),
|
||||
vendorId: null,
|
||||
maxApdu: null,
|
||||
segmentation: null,
|
||||
pointCount: 0,
|
||||
points: [],
|
||||
rawRemote: rinfo ? rinfo.address + ":" + rinfo.port : "",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.bind(port, "0.0.0.0", () => {
|
||||
socket.setBroadcast(true);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("message", (message, rinfo) => {
|
||||
rawDebug.packets.push({ remote: rinfo.address + ":" + rinfo.port, length: message.length, hex: message.toString("hex") });
|
||||
const parsed = parseRawIAm(message);
|
||||
if (parsed) {
|
||||
addDevice(parsed.deviceId, currentUnicastHost || rinfo.address, rinfo);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const broadcastPacket = buildRawWhoIsPacket(body.deviceId, 0x0b);
|
||||
for (const broadcastAddress of getBroadcastTargets(routing, body)) {
|
||||
socket.send(broadcastPacket, port, broadcastAddress, (error) => rawDebug.sends.push({ host: broadcastAddress, error: error?.message || "" }));
|
||||
await sleep(350);
|
||||
}
|
||||
|
||||
const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 20);
|
||||
const packet = buildRawWhoIsPacket(body.deviceId, 0x0a);
|
||||
for (const host of hosts) {
|
||||
const before = devices.length;
|
||||
currentUnicastHost = host;
|
||||
socket.send(packet, port, host, (error) => rawDebug.sends.push({ host, error: error?.message || "" }));
|
||||
await sleep(Math.max(50, perHostDelay));
|
||||
if (body.deviceId && devices.length > before && devices.some((device) => String(device.deviceId) === String(body.deviceId))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentUnicastHost = "";
|
||||
await sleep(250);
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
return { devices, rawTrace, rawDebug };
|
||||
}
|
||||
|
||||
discover()
|
||||
.then((result) => {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(error?.stack || error?.message || String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
+373
-29
@@ -11,6 +11,7 @@ const dataDir = join(__dirname, "..", "data");
|
||||
const storeFile = join(dataDir, "sources.json");
|
||||
const port = Number(process.env.PORT || 18110);
|
||||
const pollTickMs = Number(process.env.POLL_TICK_MS || 1000);
|
||||
const bacnetCollectorUrl = String(process.env.BACNET_COLLECTOR_URL || "").replace(/\/$/, "");
|
||||
const LOCAL_POINT_LIMIT = 2000;
|
||||
let transactionId = 1;
|
||||
let polling = false;
|
||||
@@ -126,6 +127,16 @@ function normalizePollInterval(value) {
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 60;
|
||||
}
|
||||
|
||||
function normalizeBacnetMode(value) {
|
||||
const mode = String(value || "broadcast").trim().toLowerCase();
|
||||
return ["broadcast", "bbmd", "foreign-device"].includes(mode) ? mode : "broadcast";
|
||||
}
|
||||
|
||||
function normalizeBacnetTtl(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 120;
|
||||
}
|
||||
|
||||
function normalizePointKind(value) {
|
||||
return String(value || "").toLowerCase() === "digital" ? "digital" : "analog";
|
||||
}
|
||||
@@ -188,10 +199,15 @@ function ensureSource(store, body) {
|
||||
const now = new Date().toISOString();
|
||||
const existingIndex = store.sources.findIndex((item) => item.id === body.id);
|
||||
const existing = existingIndex >= 0 ? store.sources[existingIndex] : null;
|
||||
const protocol = normalizeProtocol(body.protocol);
|
||||
const bacnetMode = normalizeBacnetMode(body.bacnetMode || body.options?.bacnetMode || existing?.options?.bacnetMode);
|
||||
const bacnetBroadcastAddress = normalizeText(body.bacnetBroadcastAddress || body.options?.bacnetBroadcastAddress || existing?.options?.bacnetBroadcastAddress || "255.255.255.255") || "255.255.255.255";
|
||||
const bacnetForeignDeviceTtl = normalizeBacnetTtl(body.bacnetForeignDeviceTtl || body.options?.bacnetForeignDeviceTtl || existing?.options?.bacnetForeignDeviceTtl);
|
||||
const bacnetScanSubnets = normalizeText(body.bacnetScanSubnets || body.options?.bacnetScanSubnets || existing?.options?.bacnetScanSubnets || "");
|
||||
const tableName = sanitizeTrendTable(existing?.tableName || buildSourceTableName(body));
|
||||
const source = {
|
||||
id: body.id || `src-${Date.now()}`,
|
||||
protocol: normalizeProtocol(body.protocol),
|
||||
protocol,
|
||||
name: normalizeText(body.name) || "Neue Quelle",
|
||||
host: normalizeText(body.host),
|
||||
port: normalizeText(body.port),
|
||||
@@ -201,9 +217,14 @@ function ensureSource(store, body) {
|
||||
writeMode: normalizeWriteMode(body.writeMode),
|
||||
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
||||
options: {
|
||||
...(existing?.options || {}),
|
||||
...(body.options || {}),
|
||||
writeMode: normalizeWriteMode(body.writeMode),
|
||||
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
||||
bacnetMode,
|
||||
bacnetBroadcastAddress,
|
||||
bacnetForeignDeviceTtl,
|
||||
bacnetScanSubnets,
|
||||
},
|
||||
lastValues: body.lastValues || {},
|
||||
lastPollAt: body.lastPollAt || null,
|
||||
@@ -416,12 +437,15 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) {
|
||||
nextPointIndex = Math.max(nextPointIndex, pointIndex + 1);
|
||||
}
|
||||
const scanned = createScanDatapoint(source, { ...payload, pointIndex }, index);
|
||||
const existingAlias = normalizeText(existing?.alias || "");
|
||||
const existingName = normalizeText(existing?.name || "");
|
||||
const existingLooksLikeScanDefault = existingAlias && [existingName, normalizeText(existing?.nodeId), normalizeText(existing?.address)].includes(existingAlias);
|
||||
items.push(!existing ? scanned : {
|
||||
...existing,
|
||||
...scanned,
|
||||
pointIndex: existing.pointIndex,
|
||||
alias: existing.alias || scanned.alias,
|
||||
name: existing.name || scanned.name,
|
||||
alias: !existingAlias || existingLooksLikeScanDefault ? scanned.alias : existing.alias,
|
||||
name: scanned.name || existing.name,
|
||||
unit: normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind),
|
||||
kind: existing.kind || scanned.kind,
|
||||
enabled: existing.enabled === true,
|
||||
@@ -800,6 +824,248 @@ function bacnetObjectLabel(objectId) {
|
||||
return prefix + objectId.instance;
|
||||
}
|
||||
|
||||
function getBacnetRouting(sourceOrBody) {
|
||||
return {
|
||||
mode: normalizeBacnetMode(sourceOrBody?.bacnetMode || sourceOrBody?.options?.bacnetMode),
|
||||
host: normalizeText(sourceOrBody?.host || sourceOrBody?.options?.bbmdHost || ""),
|
||||
port: Number(sourceOrBody?.port || sourceOrBody?.options?.bbmdPort || 47808) || 47808,
|
||||
broadcastAddress: normalizeText(sourceOrBody?.bacnetBroadcastAddress || sourceOrBody?.options?.bacnetBroadcastAddress || "255.255.255.255") || "255.255.255.255",
|
||||
foreignDeviceTtl: normalizeBacnetTtl(sourceOrBody?.bacnetForeignDeviceTtl || sourceOrBody?.options?.bacnetForeignDeviceTtl),
|
||||
};
|
||||
}
|
||||
|
||||
function createBacnetClientInstance(bacnet, sourceOrBody, timeout) {
|
||||
const routing = getBacnetRouting(sourceOrBody);
|
||||
const client = new bacnet({
|
||||
apduTimeout: timeout,
|
||||
port: Number(sourceOrBody?.port || 47808) || 47808,
|
||||
broadcastAddress: routing.broadcastAddress,
|
||||
});
|
||||
return { client, routing };
|
||||
}
|
||||
|
||||
function getBacnetTransport(client) {
|
||||
const transport = client?._transport;
|
||||
if (!transport || typeof transport.send !== "function" || !transport._server) {
|
||||
throw new Error("BACnet-Transport konnte nicht initialisiert werden.");
|
||||
}
|
||||
return transport;
|
||||
}
|
||||
|
||||
async function waitForBacnetClientListening(client, timeout = 2000) {
|
||||
const transport = getBacnetTransport(client);
|
||||
try {
|
||||
const address = transport._server.address?.();
|
||||
if (address && typeof address.port === "number") {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
client.off("listening", onListening);
|
||||
client.off("error", onError);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const onListening = () => finish();
|
||||
const onError = (error) => finish(error);
|
||||
const timer = setTimeout(() => finish(new Error("BACnet-Socket wurde nicht rechtzeitig bereit.")), timeout);
|
||||
client.once("listening", onListening);
|
||||
client.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
function captureBacnetWhoIsPacket(client, deviceId) {
|
||||
const transport = getBacnetTransport(client);
|
||||
const originalSend = transport.send.bind(transport);
|
||||
let packet = null;
|
||||
|
||||
transport.send = (buffer, offset) => {
|
||||
packet = Buffer.from(buffer.slice(0, offset));
|
||||
};
|
||||
|
||||
try {
|
||||
const numericDeviceId = Number(deviceId);
|
||||
if (Number.isFinite(numericDeviceId)) {
|
||||
client.whoIs(undefined, { lowLimit: numericDeviceId, highLimit: numericDeviceId });
|
||||
} else {
|
||||
client.whoIs();
|
||||
}
|
||||
} finally {
|
||||
transport.send = originalSend;
|
||||
}
|
||||
|
||||
if (!packet || packet.length < 4 || packet[0] !== 0x81) {
|
||||
throw new Error("BACnet Who-Is konnte nicht aufgebaut werden.");
|
||||
}
|
||||
return packet;
|
||||
}
|
||||
|
||||
function bacnetResultText(resultCode) {
|
||||
const known = {
|
||||
0x0000: "Erfolgreich",
|
||||
0x0010: "Write-Broadcast-Distribution-Table NAK",
|
||||
0x0020: "Read-Broadcast-Distribution-Table NAK",
|
||||
0x0030: "Register-Foreign-Device NAK",
|
||||
0x0040: "Read-Foreign-Device-Table NAK",
|
||||
0x0050: "Delete-Foreign-Device-Table-Entry NAK",
|
||||
0x0060: "Distribute-Broadcast-To-Network NAK",
|
||||
};
|
||||
return known[resultCode] || `BVLC-Fehler ${resultCode}`;
|
||||
}
|
||||
|
||||
async function sendBacnetControlPacket(client, receiver, packet, matcher, timeout = 2500) {
|
||||
const transport = getBacnetTransport(client);
|
||||
await waitForBacnetClientListening(client, timeout);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const socket = transport._server;
|
||||
const cleanup = () => {
|
||||
socket.off("message", onMessage);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
const finish = (error, result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
const onMessage = (message, rinfo) => {
|
||||
try {
|
||||
if (!matcher(message, rinfo)) {
|
||||
return;
|
||||
}
|
||||
finish(null, { message, rinfo });
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error("BACnet-Steuertelegramm hat nicht rechtzeitig geantwortet.")), timeout);
|
||||
socket.on("message", onMessage);
|
||||
try {
|
||||
transport.send(packet, packet.length, receiver);
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function registerBacnetForeignDevice(client, routing) {
|
||||
if (routing.mode !== "foreign-device") {
|
||||
return;
|
||||
}
|
||||
if (!routing.host) {
|
||||
throw new Error("F?r BACnet Foreign Device bitte BBMD Host/IP angeben.");
|
||||
}
|
||||
|
||||
if (typeof client.registerForeignDevice === "function") {
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const done = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const maybe = client.registerForeignDevice(routing.host, routing.port, routing.foreignDeviceTtl, done);
|
||||
if (maybe && typeof maybe.then === "function") {
|
||||
maybe.then(() => done(), done);
|
||||
} else if (client.registerForeignDevice.length < 4) {
|
||||
done();
|
||||
} else {
|
||||
setTimeout(() => done(), 1200);
|
||||
}
|
||||
} catch (error) {
|
||||
done(error);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const packet = Buffer.alloc(6);
|
||||
packet[0] = 0x81;
|
||||
packet[1] = 0x05;
|
||||
packet.writeUInt16BE(6, 2);
|
||||
packet.writeUInt16BE(routing.foreignDeviceTtl, 4);
|
||||
|
||||
const { message } = await sendBacnetControlPacket(
|
||||
client,
|
||||
routing.host,
|
||||
packet,
|
||||
(candidate, rinfo) => candidate?.length >= 6 && candidate[0] === 0x81 && candidate[1] === 0x00 && Number(rinfo?.port) === routing.port,
|
||||
3000,
|
||||
);
|
||||
|
||||
const resultCode = message.readUInt16BE(4);
|
||||
if (resultCode !== 0x0000) {
|
||||
throw new Error(`Foreign-Device-Register wurde abgelehnt: ${bacnetResultText(resultCode)}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function sendBacnetWhoIs(client, routing, deviceId) {
|
||||
if (routing.mode === "bbmd" || routing.mode === "foreign-device") {
|
||||
if (!routing.host) {
|
||||
throw new Error("F?r BACnet ?ber BBMD bitte Host/IP angeben.");
|
||||
}
|
||||
const packet = captureBacnetWhoIsPacket(client, deviceId);
|
||||
packet[1] = 0x09;
|
||||
const transport = getBacnetTransport(client);
|
||||
transport.send(packet, packet.length, routing.host);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericDeviceId = Number(deviceId);
|
||||
const low = Number.isFinite(numericDeviceId) ? numericDeviceId : undefined;
|
||||
const high = low;
|
||||
const targets = [null];
|
||||
|
||||
const attempts = [];
|
||||
const addAttempt = (fn) => attempts.push(fn);
|
||||
|
||||
for (const target of targets.length ? targets : [null]) {
|
||||
addAttempt(() => (low === undefined ? (target ? client.whoIs(target) : client.whoIs()) : (target ? client.whoIs(low, high, target) : client.whoIs(low, high))));
|
||||
addAttempt(() => client.whoIs({ lowLimit: low, highLimit: high, address: target || undefined }));
|
||||
addAttempt(() => client.whoIs({ lowLimit: low, highLimit: high }));
|
||||
addAttempt(() => (target ? client.whoIs(target, low, high) : client.whoIs(low, high)));
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
attempt();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new Error("Who-Is konnte nicht ausgel?st werden.");
|
||||
}
|
||||
|
||||
function readBacnetProperty(client, host, objectId, propertyId) {
|
||||
return new Promise((resolve) => {
|
||||
client.readProperty(host, objectId, propertyId, (error, value) => {
|
||||
@@ -823,9 +1089,13 @@ async function scanBacnetObjects(client, device, portNumber) {
|
||||
if (!objectId || objectId.type === 8 || !Number.isFinite(Number(objectId.instance))) {
|
||||
continue;
|
||||
}
|
||||
const objectName = await readBacnetProperty(client, host, objectId, 77);
|
||||
const description = await readBacnetProperty(client, host, objectId, 28);
|
||||
const presentValue = await readBacnetProperty(client, host, objectId, 85);
|
||||
const raw = presentValue?.values?.[0]?.value ?? null;
|
||||
const label = bacnetObjectLabel(objectId);
|
||||
const name = normalizeText(objectName?.values?.[0]?.value) || label;
|
||||
const alias = normalizeText(description?.values?.[0]?.value) || name;
|
||||
points.push({
|
||||
id: host + "-" + objectId.type + "-" + objectId.instance,
|
||||
host,
|
||||
@@ -834,9 +1104,10 @@ async function scanBacnetObjects(client, device, portNumber) {
|
||||
objectInstance: objectId.instance,
|
||||
propertyId: 85,
|
||||
address: String(objectId.instance),
|
||||
dataType: label.replace(/[0-9]+$/, ""),
|
||||
name: label,
|
||||
alias: label,
|
||||
dataType: bacnetObjectLabel(objectId).replace(/[0-9]+$/, ""),
|
||||
name,
|
||||
description: alias,
|
||||
alias,
|
||||
kind: typeof raw === "boolean" || objectId.type >= 3 && objectId.type <= 5 ? "digital" : "analog",
|
||||
currentValue: raw,
|
||||
hasValue: raw !== null,
|
||||
@@ -845,67 +1116,134 @@ async function scanBacnetObjects(client, device, portNumber) {
|
||||
return points;
|
||||
}
|
||||
|
||||
async function requestExternalBacnetScan(body) {
|
||||
if (!bacnetCollectorUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(`${bacnetCollectorUrl}/scan/bacnet`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "BACnet-Scan im separaten Collector fehlgeschlagen.");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function scanBacnetNetwork(body) {
|
||||
const externalScan = await requestExternalBacnetScan(body);
|
||||
if (externalScan) {
|
||||
return externalScan;
|
||||
}
|
||||
|
||||
const bacnet = await loadOptionalPackage("node-bacnet");
|
||||
const portNumber = Number(body.port || 47808);
|
||||
const timeout = Number(body.timeout || 6000);
|
||||
const client = new bacnet({ apduTimeout: timeout, port: portNumber });
|
||||
const { client, routing } = createBacnetClientInstance(bacnet, body, timeout);
|
||||
const devices = [];
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
for (const device of devices) {
|
||||
const deviceObject = { type: 8, instance: Number(device.deviceId) };
|
||||
const deviceName = await readBacnetProperty(client, device.address, deviceObject, 77);
|
||||
const resolvedName = repairText(String(deviceName?.values?.[0]?.value || "").trim());
|
||||
if (resolvedName) {
|
||||
device.name = resolvedName;
|
||||
}
|
||||
device.points = await scanBacnetObjects(client, device, portNumber);
|
||||
}
|
||||
client.close();
|
||||
resolve({ devices, points: devices.flatMap((device) => device.points || []), scannedAt: new Date().toISOString() });
|
||||
try { client.close(); } catch {}
|
||||
resolve({
|
||||
routing,
|
||||
devices,
|
||||
points: devices.flatMap((device) => device.points || []),
|
||||
scannedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
client.close();
|
||||
try { client.close(); } catch {}
|
||||
reject(error);
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
client.on("iAm", (device) => {
|
||||
if (!devices.some((item) => item.host === device.address && item.deviceId === device.deviceId)) {
|
||||
const address = normalizeText(device?.address || device?.header?.sender?.address || "");
|
||||
const deviceId = Number(device?.deviceId);
|
||||
if (!address || !Number.isFinite(deviceId)) {
|
||||
return;
|
||||
}
|
||||
if (!devices.some((item) => item.host === address && item.deviceId === deviceId)) {
|
||||
devices.push({
|
||||
host: device.address,
|
||||
address,
|
||||
host: address,
|
||||
port: portNumber,
|
||||
reachable: true,
|
||||
deviceId: device.deviceId,
|
||||
name: "BACnet Gerät " + device.deviceId,
|
||||
deviceId,
|
||||
name: "BACnet Ger?t " + deviceId,
|
||||
vendorId: device?.vendorId ?? null,
|
||||
maxApdu: device?.maxApdu ?? null,
|
||||
segmentation: device?.segmentation ?? null,
|
||||
points: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (body.deviceId) {
|
||||
client.whoIs(Number(body.deviceId), Number(body.deviceId));
|
||||
} else {
|
||||
client.whoIs();
|
||||
(async () => {
|
||||
try {
|
||||
if (routing.mode === "bbmd" && !routing.host) {
|
||||
throw new Error("F?r BACnet ?ber BBMD bitte Host/IP angeben.");
|
||||
}
|
||||
await registerBacnetForeignDevice(client, routing);
|
||||
sendBacnetWhoIs(client, routing, body.deviceId);
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
try { client.close(); } catch {}
|
||||
reject(error);
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
client.close();
|
||||
reject(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
async function requestExternalBacnetRead(source, datapoint) {
|
||||
if (!bacnetCollectorUrl) {
|
||||
return { delegated: false };
|
||||
}
|
||||
const response = await fetch(`${bacnetCollectorUrl}/read/bacnet`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source, datapoint }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "BACnet-Livewert im separaten Collector fehlgeschlagen.");
|
||||
}
|
||||
return { delegated: true, value: data.value };
|
||||
}
|
||||
|
||||
async function readBacnetValue(source, datapoint) {
|
||||
const externalRead = await requestExternalBacnetRead(source, datapoint);
|
||||
if (externalRead.delegated) {
|
||||
return externalRead.value;
|
||||
}
|
||||
|
||||
const bacnet = await loadOptionalPackage("node-bacnet");
|
||||
const client = new bacnet({ apduTimeout: 5000, port: Number(source.port || 47808) });
|
||||
const { client, routing } = createBacnetClientInstance(bacnet, source, 5000);
|
||||
const propertyId = Number(datapoint.propertyId || 85);
|
||||
const objectId = parseBacnetObject(datapoint);
|
||||
const host = String(source.host || datapoint.host || "").trim();
|
||||
const host = String(datapoint.host || source.host || "").trim();
|
||||
if (!host) {
|
||||
throw new Error("BACnet Host fehlt.");
|
||||
}
|
||||
|
||||
await registerBacnetForeignDevice(client, routing);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
client.readProperty(host, objectId, propertyId, (error, value) => {
|
||||
client.close();
|
||||
try { client.close(); } catch {}
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
@@ -1834,6 +2172,9 @@ const server = createServer(async (req, res) => {
|
||||
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
if (!body.clientHost && req.socket?.remoteAddress) {
|
||||
body.clientHost = String(req.socket.remoteAddress).replace(/^::ffff:/, "");
|
||||
}
|
||||
const payload = await scanBacnetNetwork(body);
|
||||
let scannedCandidates = [];
|
||||
touchStore((store) => {
|
||||
@@ -1846,8 +2187,8 @@ const server = createServer(async (req, res) => {
|
||||
id: point.id || `${source.id}::${point.host || source.host}::${point.dataType || point.objectType || "object"}::${point.objectInstance || index + 1}`,
|
||||
protocol: "bacnet-ip",
|
||||
pointIndex: index + 1,
|
||||
alias: point.alias || point.name || `${point.dataType || "obj"}${point.objectInstance || index + 1}`,
|
||||
name: point.name || point.alias || `${point.dataType || "obj"}${point.objectInstance || index + 1}`,
|
||||
alias: point.description || point.alias || point.name || `${point.dataType || "obj"}${point.objectInstance || index + 1}`,
|
||||
name: point.name || point.alias || point.description || `${point.dataType || "obj"}${point.objectInstance || index + 1}`,
|
||||
}));
|
||||
scannedCandidates = candidates;
|
||||
upsertDatapoints(store, candidates);
|
||||
@@ -1878,3 +2219,6 @@ server.listen(port, () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
services:
|
||||
mariadb:
|
||||
volumes:
|
||||
- /media/sd/seltd/mariadb:/var/lib/mysql
|
||||
- ./docker/mariadb/init:/docker-entrypoint-initdb.d:ro
|
||||
|
||||
redis:
|
||||
volumes:
|
||||
- /media/sd/seltd/redis:/data
|
||||
- ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
|
||||
collector:
|
||||
volumes:
|
||||
- /media/sd/seltd/collector:/app/data
|
||||
|
||||
dockhand:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /media/sd/seltd/dockhand:/app/data
|
||||
+21
-1
@@ -1,4 +1,4 @@
|
||||
services:
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.4
|
||||
container_name: seltd-mariadb
|
||||
@@ -89,6 +89,7 @@ services:
|
||||
DB_USER: root
|
||||
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
|
||||
POLL_TICK_MS: ${POLL_TICK_MS:-1000}
|
||||
BACNET_COLLECTOR_URL: ${BACNET_COLLECTOR_URL:-http://bacnet-collector:18111}
|
||||
ports:
|
||||
- "18110:18110"
|
||||
volumes:
|
||||
@@ -96,6 +97,23 @@ services:
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
bacnet-collector:
|
||||
condition: service_started
|
||||
|
||||
bacnet-collector:
|
||||
build:
|
||||
context: ./bacnet-collector
|
||||
container_name: seltd-bacnet-collector
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 18111
|
||||
BACNET_SCAN_TIMEOUT: ${BACNET_SCAN_TIMEOUT:-7000}
|
||||
BACNET_POINT_LIMIT: ${BACNET_POINT_LIMIT:-2000}
|
||||
BACNET_BROADCAST_FALLBACKS: ${BACNET_BROADCAST_FALLBACKS:-}
|
||||
network_mode: "host"
|
||||
ports:
|
||||
- "18111:18111"
|
||||
- "47808:47808/udp"
|
||||
|
||||
dockhand:
|
||||
image: fnsys/dockhand:latest
|
||||
@@ -131,3 +149,5 @@ volumes:
|
||||
collector_data:
|
||||
dockhand_data:
|
||||
|
||||
|
||||
|
||||
|
||||
-77153
File diff suppressed because one or more lines are too long
+180
-30
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import Gauge from "./components/Gauge.jsx";
|
||||
@@ -129,6 +129,10 @@ const defaultSourceDraft = {
|
||||
displayName: "",
|
||||
writeMode: "interval",
|
||||
pollIntervalSeconds: "60",
|
||||
bacnetMode: "broadcast",
|
||||
bacnetBroadcastAddress: "auto",
|
||||
bacnetScanSubnets: "",
|
||||
bacnetForeignDeviceTtl: "120",
|
||||
};
|
||||
|
||||
|
||||
@@ -814,6 +818,17 @@ export default function App() {
|
||||
])),
|
||||
[collectorPointFilter, scanResults.bacnet]
|
||||
);
|
||||
const filteredBacnetDevices = useMemo(
|
||||
() => (scanResults.bacnet?.devices || []).filter((device) => matchesSearch(collectorPointFilter, [
|
||||
device.name,
|
||||
device.deviceId,
|
||||
device.host,
|
||||
device.address,
|
||||
device.vendorId,
|
||||
device.pointCount,
|
||||
])),
|
||||
[collectorPointFilter, scanResults.bacnet]
|
||||
);
|
||||
const filteredAvailableCollectorPoints = useMemo(
|
||||
() => availableCollectorPoints.filter((item) => matchesSearch(collectorPointFilter, [
|
||||
item.alias,
|
||||
@@ -1120,6 +1135,10 @@ export default function App() {
|
||||
displayName: currentSource.displayName || currentSource.name || "",
|
||||
writeMode: currentSource.writeMode === "cov" ? "cov" : "interval",
|
||||
pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60),
|
||||
bacnetMode: currentSource.options?.bacnetMode || "broadcast",
|
||||
bacnetBroadcastAddress: currentSource.options?.bacnetBroadcastAddress || "auto",
|
||||
bacnetScanSubnets: currentSource.options?.bacnetScanSubnets || "",
|
||||
bacnetForeignDeviceTtl: String(currentSource.options?.bacnetForeignDeviceTtl || 120),
|
||||
});
|
||||
setManualDatapointDraft((current) => ({
|
||||
...current,
|
||||
@@ -1599,6 +1618,7 @@ export default function App() {
|
||||
...current,
|
||||
protocol: nextProtocol,
|
||||
port: shouldReplacePort ? nextDefaultPort : current.port,
|
||||
host: nextProtocol === "bacnet-ip" && current.bacnetMode === "broadcast" ? "" : current.host,
|
||||
tableName: current.id ? current.tableName : buildCollectorTableName(current.name, nextProtocol),
|
||||
};
|
||||
});
|
||||
@@ -1608,6 +1628,33 @@ export default function App() {
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBacnetSubnetSetting = async () => {
|
||||
if (!currentSource || currentSource.protocol !== "bacnet-ip") {
|
||||
return;
|
||||
}
|
||||
const nextSubnets = String(sourceDraft.bacnetScanSubnets || "").trim();
|
||||
const previousSubnets = String(currentSource.options?.bacnetScanSubnets || "").trim();
|
||||
if (nextSubnets === previousSubnets) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetFlash();
|
||||
try {
|
||||
await api.updateCollectorSource(currentSource.id, {
|
||||
...currentSource,
|
||||
bacnetScanSubnets: nextSubnets,
|
||||
options: {
|
||||
...(currentSource.options || {}),
|
||||
bacnetScanSubnets: nextSubnets,
|
||||
},
|
||||
});
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setSuccessMessage("BACnet-Subnetze gespeichert.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
const saveCollectorSource = async () => {
|
||||
if (!sourceDraft.name.trim()) {
|
||||
setBootError("Bitte einen Alias bzw. Anzeigenamen angeben.");
|
||||
@@ -1803,29 +1850,58 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const runBacnetScan = async () => {
|
||||
const activeSourceId = selectedSourceId || sourceDraft.id;
|
||||
if (!activeSourceId) {
|
||||
setBootError("Bitte zuerst eine Quelle auswählen oder anlegen.");
|
||||
return;
|
||||
}
|
||||
const selectBacnetDevice = (device) => {
|
||||
const host = device.host || device.address || "";
|
||||
const name = normalizeDisplayText(device.name, host || "BACnet Quelle");
|
||||
setSourceDraft((current) => ({
|
||||
...current,
|
||||
host,
|
||||
deviceId: String(device.deviceId || ""),
|
||||
port: String(device.port || current.port || "47808"),
|
||||
name: current.name?.trim() ? current.name : name,
|
||||
displayName: current.displayName?.trim() ? current.displayName : name,
|
||||
tableName: current.id ? current.tableName : buildCollectorTableName(current.name?.trim() ? current.name : name, "bacnet-ip"),
|
||||
}));
|
||||
};
|
||||
|
||||
const runBacnetScan = async (options = {}) => {
|
||||
const sourceOverride = options.source || null;
|
||||
const draft = sourceOverride ? {
|
||||
...defaultSourceDraft,
|
||||
...sourceOverride,
|
||||
name: sourceOverride.name || sourceOverride.displayName || "",
|
||||
bacnetMode: sourceOverride.options?.bacnetMode || "broadcast",
|
||||
bacnetBroadcastAddress: sourceOverride.options?.bacnetBroadcastAddress || "auto",
|
||||
bacnetScanSubnets: sourceOverride.options?.bacnetScanSubnets || sourceOverride.bacnetScanSubnets || "",
|
||||
bacnetForeignDeviceTtl: String(sourceOverride.options?.bacnetForeignDeviceTtl || 120),
|
||||
writeMode: sourceOverride.writeMode || sourceOverride.options?.writeMode || "interval",
|
||||
pollIntervalSeconds: String(sourceOverride.pollIntervalSeconds || sourceOverride.options?.pollIntervalSeconds || 60),
|
||||
} : sourceDraft;
|
||||
const activeSourceId = options.sourceId || sourceOverride?.id || selectedSourceId || draft.id || "";
|
||||
const scanHosts = draft.host ? String(draft.host).split(/[;,\s]+/).map((entry) => entry.trim()).filter(Boolean) : [];
|
||||
|
||||
resetFlash();
|
||||
try {
|
||||
const hosts = sourceDraft.host
|
||||
.split(/[;,\s]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const result = await api.scanBacnet({
|
||||
hosts,
|
||||
port: sourceDraft.port || "47808",
|
||||
deviceId: sourceDraft.deviceId,
|
||||
sourceId: activeSourceId,
|
||||
port: draft.port || "47808",
|
||||
deviceId: draft.deviceId,
|
||||
sourceId: activeSourceId || undefined,
|
||||
host: draft.host,
|
||||
scanHosts,
|
||||
scanSubnets: draft.bacnetScanSubnets || "",
|
||||
bacnetScanSubnets: draft.bacnetScanSubnets || "",
|
||||
includeObjects: options.includeObjects === true,
|
||||
bacnetMode: draft.bacnetMode || "broadcast",
|
||||
bacnetBroadcastAddress: draft.bacnetBroadcastAddress || "auto",
|
||||
clientHost: typeof window !== "undefined" ? window.location.hostname : "",
|
||||
bacnetForeignDeviceTtl: draft.bacnetForeignDeviceTtl || "120",
|
||||
});
|
||||
setScanResults((current) => ({ ...current, bacnet: result }));
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setSuccessMessage("BACnet-Scan abgeschlossen.");
|
||||
const deviceCount = result.devices?.length || 0;
|
||||
const pointCount = result.points?.length || 0;
|
||||
setSuccessMessage(options.includeObjects === true ? `BACnet-Objektscan abgeschlossen: ${deviceCount} Gerät(e), ${pointCount} Objekt(e).` : `BACnet-Scan abgeschlossen: ${deviceCount} Gerät(e) gefunden.`);
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
@@ -2315,6 +2391,7 @@ export default function App() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
@@ -2565,7 +2642,7 @@ export default function App() {
|
||||
<div className="source-tile-actions">
|
||||
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
|
||||
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
|
||||
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null}
|
||||
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); setScanResults((current) => ({ ...current, bacnet: null })); runBacnetScan({ source, includeObjects: true }); }}>BACnet scannen</button> : null}
|
||||
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
|
||||
</div>
|
||||
</button>
|
||||
@@ -2602,13 +2679,17 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="protocol-block">
|
||||
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
|
||||
<div className="button-row wrap-tight">
|
||||
{sourceToolProtocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
|
||||
{sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
|
||||
</div>
|
||||
{sourceToolProtocol === "opc-ua" ? <small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small> : null}
|
||||
{sourceToolProtocol === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Gerät(e) gefunden` : "Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null}
|
||||
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP Subnetze" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
|
||||
{sourceToolProtocol === "bacnet-ip" ? (
|
||||
<>
|
||||
<label className="compact-label">
|
||||
<span>Subnetze</span>
|
||||
<input value={sourceDraft.bacnetScanSubnets || ""} onBlur={saveBacnetSubnetSetting} onKeyDown={(event) => { if (event.key === "Enter") { event.currentTarget.blur(); } }} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetScanSubnets: event.target.value }))} placeholder="z.B. 192.168.1.0/24; 10.21.2.0/24" />
|
||||
</label>
|
||||
<small className="muted">Wird gespeichert und vom BACnet-Scan der Quelle für Device- und Objektsuche verwendet.</small>
|
||||
</>
|
||||
) : null}
|
||||
{sourceToolProtocol === "opc-ua" ? <><div className="button-row wrap-tight"><button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button></div><small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small></> : null}
|
||||
{sourceToolProtocol === "modbus-tcp" ? <small className="muted">Für 192.168.153.50 ist Input Register / FC4 bestätigt. Den Registertyp wählst du pro Datenpunkt über Datenart.</small> : null}
|
||||
{sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
|
||||
</div>
|
||||
@@ -2617,6 +2698,7 @@ export default function App() {
|
||||
{canManageSources ? (
|
||||
<div className="protocol-block manual-point-card source-settings-card">
|
||||
<strong>Einzelnen Datenpunkt anlegen</strong>
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Alias</span>
|
||||
@@ -2688,6 +2770,13 @@ export default function App() {
|
||||
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schließen</button>
|
||||
</div>
|
||||
|
||||
{(bootError || collectorError || successMessage) ? (
|
||||
<div className={bootError || collectorError ? "modal-status error-box" : "modal-status success-box"}>
|
||||
{bootError || collectorError || successMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Protokoll</span>
|
||||
@@ -2702,18 +2791,43 @@ export default function App() {
|
||||
<span>Alias / Anzeigename</span>
|
||||
<input value={sourceDraft.name} onChange={(event) => setSourceDraft((current) => ({ ...current, name: event.target.value, displayName: event.target.value, tableName: buildCollectorTableName(event.target.value, current.protocol) }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Host / IP</span>
|
||||
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
|
||||
</label>
|
||||
{sourceDraft.protocol !== "bacnet-ip" || sourceDraft.bacnetMode !== "broadcast" ? (
|
||||
<label>
|
||||
<span>{sourceDraft.protocol === "bacnet-ip" ? "BBMD Host / IP" : "Host / IP"}</span>
|
||||
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
<span>Port</span>
|
||||
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
|
||||
</label>
|
||||
{sourceDraft.protocol === "bacnet-ip" ? (
|
||||
<label>
|
||||
<span>BACnet Modus</span>
|
||||
<select value={sourceDraft.bacnetMode} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetMode: event.target.value, host: event.target.value === "broadcast" ? "" : current.host }))}>
|
||||
<option value="broadcast">Broadcast / Who-Is</option>
|
||||
<option value="bbmd">BBMD</option>
|
||||
<option value="foreign-device">Foreign Device</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
<span>Geräte-ID</span>
|
||||
<span>{"Geräte-ID"}</span>
|
||||
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
||||
</label>
|
||||
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "broadcast" ? (
|
||||
<label>
|
||||
<span>Broadcast-Adresse</span>
|
||||
<input value={sourceDraft.bacnetBroadcastAddress} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetBroadcastAddress: event.target.value }))} placeholder="auto oder z.B. 192.168.1.255" />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "foreign-device" ? (
|
||||
<label>
|
||||
<span>Foreign Device TTL (s)</span>
|
||||
<input type="number" min="30" value={sourceDraft.bacnetForeignDeviceTtl} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetForeignDeviceTtl: event.target.value }))} />
|
||||
</label>
|
||||
) : null}
|
||||
<label className="span-two">
|
||||
<span>Technischer Tabellenname</span>
|
||||
<input value={sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)} readOnly />
|
||||
@@ -2731,8 +2845,44 @@ export default function App() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.</small>
|
||||
<small className="muted">{"Der Tabellenname wird automatisch passend erzeugt. Bei BACnet läuft der Scan standardmäßig über echtes Who-Is per Broadcast. Host/IP ist nur für BBMD oder Foreign Device nötig."}</small>
|
||||
|
||||
{sourceDraft.protocol === "bacnet-ip" ? (
|
||||
<>
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => runBacnetScan({ includeObjects: false })}>{"Netz nach BACnet-Geräten scannen"}</button>
|
||||
</div>
|
||||
<div className="bacnet-device-panel">
|
||||
<div className="bacnet-device-title">
|
||||
<strong>Gefundene BACnet-Geräte</strong>
|
||||
<small>{scanResults.bacnet ? `${filteredBacnetDevices.length} Gerät(e)` : "Noch kein Scan gestartet"}</small>
|
||||
</div>
|
||||
{filteredBacnetDevices.length ? (
|
||||
<div className="bacnet-device-list">
|
||||
{filteredBacnetDevices.slice(0, 80).map((device, index) => {
|
||||
const isSelectedDevice = String(sourceDraft.deviceId || "") === String(device.deviceId || "");
|
||||
return (
|
||||
<button
|
||||
key={String(device.deviceId || device.host || device.address || index)}
|
||||
type="button"
|
||||
className={isSelectedDevice ? "bacnet-device-row active" : "bacnet-device-row"}
|
||||
onClick={() => selectBacnetDevice(device)}
|
||||
>
|
||||
<span>
|
||||
<strong>{normalizeDisplayText(device.name, "BACnet Gerät " + (device.deviceId || index + 1))}</strong>
|
||||
<small>{"Device " + (device.deviceId || "?") + " | " + normalizeDisplayText(device.host || device.address) + ":" + (device.port || sourceDraft.port || "47808")}</small>
|
||||
</span>
|
||||
<em>{String(device.pointCount ?? device.points?.length ?? 0) + " DP"}</em>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state slim-empty"><p>{scanResults.bacnet ? "Kein BACnet-Gerät gefunden. Prüfe Broadcast-Adresse, Device-ID oder BBMD/Foreign-Device-Einstellung." : "Scan starten, dann erscheinen die Geräte hier."}</p></div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
|
||||
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle löschen</button> : null}
|
||||
|
||||
+78
-1
@@ -1,4 +1,4 @@
|
||||
:root {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f6fb;
|
||||
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%);
|
||||
@@ -1149,11 +1149,88 @@ span {
|
||||
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.bacnet-device-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.bacnet-device-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.42rem 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bacnet-device-row:hover,
|
||||
.bacnet-device-row.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.bacnet-device-row span {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bacnet-device-row small {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bacnet-device-row em {
|
||||
color: var(--muted);
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.source-modal {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.modal-status {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.bacnet-device-panel {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem;
|
||||
background: color-mix(in srgb, var(--surface-raised) 68%, transparent);
|
||||
}
|
||||
|
||||
.bacnet-device-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.bacnet-device-title small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.source-admin-top,
|
||||
.source-split,
|
||||
|
||||
Reference in New Issue
Block a user