update
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
EXPOSE 18110
|
EXPOSE 18110
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|||||||
+13
-7
@@ -1,9 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "seltd-collector",
|
"name": "seltd-collector",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js"
|
"start": "node src/index.js"
|
||||||
}
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mysql2": "^3.12.0",
|
||||||
|
"node-opcua": "latest",
|
||||||
|
"node-bacnet": "latest",
|
||||||
|
"knx": "latest"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+560
-45
@@ -1,14 +1,29 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const dataDir = join(__dirname, "..", "data");
|
const dataDir = join(__dirname, "..", "data");
|
||||||
const storeFile = join(dataDir, "sources.json");
|
const storeFile = join(dataDir, "sources.json");
|
||||||
const port = Number(process.env.PORT || 18110);
|
const port = Number(process.env.PORT || 18110);
|
||||||
|
const pollTickMs = Number(process.env.POLL_TICK_MS || 1000);
|
||||||
|
let transactionId = 1;
|
||||||
|
let polling = false;
|
||||||
|
|
||||||
|
const db = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || "localhost",
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
user: process.env.DB_USER || "root",
|
||||||
|
password: process.env.DB_PASSWORD || "SE3112",
|
||||||
|
database: process.env.DB_NAME || "wago",
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 4,
|
||||||
|
charset: "utf8mb4",
|
||||||
|
});
|
||||||
|
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
@@ -85,6 +100,14 @@ function slugify(value) {
|
|||||||
.replace(/^_+|_+$/g, "") || "quelle";
|
.replace(/^_+|_+$/g, "") || "quelle";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeTrendTable(value) {
|
||||||
|
const table = String(value || "").toLowerCase();
|
||||||
|
if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(table)) {
|
||||||
|
throw new Error("Ungültiger Trendtabellenname.");
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeProtocol(value) {
|
function normalizeProtocol(value) {
|
||||||
const protocol = String(value || "").toLowerCase();
|
const protocol = String(value || "").toLowerCase();
|
||||||
if (["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"].includes(protocol)) {
|
if (["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"].includes(protocol)) {
|
||||||
@@ -123,8 +146,15 @@ function normalizePointIndex(value, fallback) {
|
|||||||
return Number.isInteger(numeric) && numeric >= 1 && numeric <= 200 ? numeric : fallback;
|
return Number.isInteger(numeric) && numeric >= 1 && numeric <= 200 ? numeric : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSourceTableName(body) {
|
||||||
|
return `trend_${slugify(body.displayName || body.name || body.protocol || "daten")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureSource(store, body) {
|
function ensureSource(store, body) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const existingIndex = store.sources.findIndex((item) => item.id === body.id);
|
||||||
|
const existing = existingIndex >= 0 ? store.sources[existingIndex] : null;
|
||||||
|
const tableName = sanitizeTrendTable(existing?.tableName || buildSourceTableName(body));
|
||||||
const source = {
|
const source = {
|
||||||
id: body.id || `src-${Date.now()}`,
|
id: body.id || `src-${Date.now()}`,
|
||||||
protocol: normalizeProtocol(body.protocol),
|
protocol: normalizeProtocol(body.protocol),
|
||||||
@@ -133,7 +163,7 @@ function ensureSource(store, body) {
|
|||||||
port: body.port || "",
|
port: body.port || "",
|
||||||
deviceId: body.deviceId || "",
|
deviceId: body.deviceId || "",
|
||||||
displayName: body.displayName || body.name || "Neue Quelle",
|
displayName: body.displayName || body.name || "Neue Quelle",
|
||||||
tableName: body.tableName || `trend_${slugify(body.name || body.protocol || "daten")}`,
|
tableName,
|
||||||
writeMode: normalizeWriteMode(body.writeMode),
|
writeMode: normalizeWriteMode(body.writeMode),
|
||||||
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
||||||
options: {
|
options: {
|
||||||
@@ -141,6 +171,7 @@ function ensureSource(store, body) {
|
|||||||
writeMode: normalizeWriteMode(body.writeMode),
|
writeMode: normalizeWriteMode(body.writeMode),
|
||||||
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
||||||
},
|
},
|
||||||
|
lastValues: body.lastValues || {},
|
||||||
lastPollAt: body.lastPollAt || null,
|
lastPollAt: body.lastPollAt || null,
|
||||||
lastPollStatus: body.lastPollStatus || "idle",
|
lastPollStatus: body.lastPollStatus || "idle",
|
||||||
lastPollError: body.lastPollError || "",
|
lastPollError: body.lastPollError || "",
|
||||||
@@ -148,7 +179,6 @@ function ensureSource(store, body) {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
const existingIndex = store.sources.findIndex((item) => item.id === source.id);
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
store.sources[existingIndex] = { ...store.sources[existingIndex], ...source };
|
store.sources[existingIndex] = { ...store.sources[existingIndex], ...source };
|
||||||
} else {
|
} else {
|
||||||
@@ -313,6 +343,520 @@ function tryTcpConnect(host, portNumber, timeout = 1200) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferModbusAddress(datapoint) {
|
||||||
|
const rawAddress = Number(String(datapoint.address || datapoint.register || "").replace(/[^0-9]/g, ""));
|
||||||
|
const dataType = String(datapoint.dataType || "").toLowerCase();
|
||||||
|
const pointIndex = normalizePointIndex(datapoint.pointIndex, 1);
|
||||||
|
const raw = Number.isFinite(rawAddress) && rawAddress > 0 ? rawAddress : pointIndex;
|
||||||
|
let functionCode = dataType.includes("coil") ? 1 : dataType.includes("discrete") ? 2 : dataType.includes("input") ? 4 : 3;
|
||||||
|
let address = Math.max(0, raw - 1);
|
||||||
|
|
||||||
|
if (raw >= 40001) {
|
||||||
|
functionCode = 3;
|
||||||
|
address = raw - 40001;
|
||||||
|
} else if (raw >= 30001) {
|
||||||
|
functionCode = 4;
|
||||||
|
address = raw - 30001;
|
||||||
|
} else if (raw >= 10001) {
|
||||||
|
functionCode = 2;
|
||||||
|
address = raw - 10001;
|
||||||
|
} else if (raw <= 9999 && functionCode > 2) {
|
||||||
|
address = raw - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { functionCode, address };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readModbusTcp(source, datapoint) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const host = String(source.host || "").trim();
|
||||||
|
const portNumber = Number(source.port || 502);
|
||||||
|
if (!host) {
|
||||||
|
reject(new Error("Modbus Host fehlt."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { functionCode, address } = inferModbusAddress(datapoint);
|
||||||
|
const unitId = Number(source.deviceId || 1) & 0xff;
|
||||||
|
const currentTransaction = transactionId;
|
||||||
|
transactionId = transactionId >= 0xffff ? 1 : transactionId + 1;
|
||||||
|
|
||||||
|
const request = Buffer.alloc(12);
|
||||||
|
request.writeUInt16BE(currentTransaction, 0);
|
||||||
|
request.writeUInt16BE(0, 2);
|
||||||
|
request.writeUInt16BE(6, 4);
|
||||||
|
request.writeUInt8(unitId, 6);
|
||||||
|
request.writeUInt8(functionCode, 7);
|
||||||
|
request.writeUInt16BE(address, 8);
|
||||||
|
request.writeUInt16BE(1, 10);
|
||||||
|
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const chunks = [];
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (error, value) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(1800);
|
||||||
|
socket.once("timeout", () => finish(new Error("Modbus Timeout.")));
|
||||||
|
socket.once("error", (error) => finish(error));
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
const response = Buffer.concat(chunks);
|
||||||
|
if (response.length < 9) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedLength = response.readUInt16BE(4) + 6;
|
||||||
|
if (response.length < expectedLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseFunction = response.readUInt8(7);
|
||||||
|
if (responseFunction & 0x80) {
|
||||||
|
finish(new Error(`Modbus Exception ${response.readUInt8(8)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseFunction !== functionCode) {
|
||||||
|
finish(new Error("Modbus Antwort passt nicht zur Anfrage."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Number(datapoint.scale || 1) || 1;
|
||||||
|
if (functionCode === 1 || functionCode === 2) {
|
||||||
|
const digital = (response.readUInt8(9) & 0x01) ? 1 : 0;
|
||||||
|
finish(null, digital);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.length < 11) {
|
||||||
|
finish(new Error("Modbus Antwort zu kurz."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = String(datapoint.dataType || "").toLowerCase();
|
||||||
|
const raw = dataType.includes("int16") || dataType.includes("signed")
|
||||||
|
? response.readInt16BE(9)
|
||||||
|
: response.readUInt16BE(9);
|
||||||
|
finish(null, raw * scale);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.connect(portNumber, host, () => socket.write(request));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptionalPackage(packageName) {
|
||||||
|
try {
|
||||||
|
const module = await import(packageName);
|
||||||
|
return module.default || module;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${packageName} ist nicht installiert. Container bitte neu bauen, damit der Treiber verfügbar ist.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpcuaEndpoint(sourceOrBody) {
|
||||||
|
const host = String(sourceOrBody.host || "").trim();
|
||||||
|
if (!host) {
|
||||||
|
throw new Error("OPC-UA Host fehlt.");
|
||||||
|
}
|
||||||
|
if (host.startsWith("opc.tcp://")) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
return `opc.tcp://${host}:${Number(sourceOrBody.port || 4840)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanOpcuaEndpoint(body) {
|
||||||
|
const { OPCUAClient } = await loadOptionalPackage("node-opcua");
|
||||||
|
const endpointUrl = buildOpcuaEndpoint(body);
|
||||||
|
const client = OPCUAClient.create({
|
||||||
|
endpointMustExist: false,
|
||||||
|
connectionStrategy: { initialDelay: 250, maxRetry: 0 },
|
||||||
|
requestedSessionTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(endpointUrl);
|
||||||
|
const session = await client.createSession();
|
||||||
|
const browseResult = await session.browse("ObjectsFolder");
|
||||||
|
const sampleNodes = (browseResult.references || []).slice(0, 80).map((reference) => ({
|
||||||
|
nodeId: reference.nodeId?.toString?.() || String(reference.nodeId || ""),
|
||||||
|
browseName: reference.browseName?.toString?.() || String(reference.browseName || ""),
|
||||||
|
displayName: reference.displayName?.text || reference.displayName?.toString?.() || "",
|
||||||
|
}));
|
||||||
|
await session.close();
|
||||||
|
return {
|
||||||
|
host: body.host || "",
|
||||||
|
port: Number(body.port || 4840),
|
||||||
|
reachable: true,
|
||||||
|
endpoints: [{ url: endpointUrl, securityMode: "auto", securityPolicy: "auto" }],
|
||||||
|
sampleNodes,
|
||||||
|
scannedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.disconnect().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOpcuaValue(source, datapoint) {
|
||||||
|
const { OPCUAClient } = await loadOptionalPackage("node-opcua");
|
||||||
|
const endpointUrl = buildOpcuaEndpoint(source);
|
||||||
|
const nodeId = datapoint.nodeId || datapoint.address;
|
||||||
|
if (!nodeId) {
|
||||||
|
throw new Error(`OPC-UA NodeId fehlt für ${datapoint.alias || datapoint.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = OPCUAClient.create({
|
||||||
|
endpointMustExist: false,
|
||||||
|
connectionStrategy: { initialDelay: 250, maxRetry: 0 },
|
||||||
|
requestedSessionTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(endpointUrl);
|
||||||
|
const session = await client.createSession();
|
||||||
|
const dataValue = await session.readVariableValue(nodeId);
|
||||||
|
await session.close();
|
||||||
|
const value = dataValue?.value?.value;
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? 1 : 0;
|
||||||
|
}
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : String(value ?? "");
|
||||||
|
} finally {
|
||||||
|
await client.disconnect().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBacnetObject(datapoint) {
|
||||||
|
const dataType = String(datapoint.dataType || "analogInput").trim();
|
||||||
|
const objectTypeMap = {
|
||||||
|
analoginput: 0,
|
||||||
|
analogoutput: 1,
|
||||||
|
analogvalue: 2,
|
||||||
|
binaryinput: 3,
|
||||||
|
binaryoutput: 4,
|
||||||
|
binaryvalue: 5,
|
||||||
|
multiinput: 13,
|
||||||
|
multioutput: 14,
|
||||||
|
multivalue: 19,
|
||||||
|
device: 8,
|
||||||
|
};
|
||||||
|
const objectType = Number.isFinite(Number(datapoint.objectType))
|
||||||
|
? Number(datapoint.objectType)
|
||||||
|
: objectTypeMap[dataType.toLowerCase().replace(/[^a-z]/g, "")] ?? 0;
|
||||||
|
const instance = Number(datapoint.objectInstance || datapoint.address || datapoint.pointIndex || 0);
|
||||||
|
return { type: objectType, instance };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanBacnetNetwork(body) {
|
||||||
|
const bacnet = await loadOptionalPackage("node-bacnet");
|
||||||
|
const client = new bacnet({ apduTimeout: Number(body.timeout || 5000), port: Number(body.port || 47808) });
|
||||||
|
const devices = [];
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
client.close();
|
||||||
|
resolve({ devices, scannedAt: new Date().toISOString() });
|
||||||
|
}, Number(body.timeout || 5000));
|
||||||
|
|
||||||
|
client.on("iAm", (device) => {
|
||||||
|
devices.push({
|
||||||
|
host: device.address,
|
||||||
|
port: Number(body.port || 47808),
|
||||||
|
reachable: true,
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
name: `BACnet Gerät ${device.deviceId}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (body.deviceId) {
|
||||||
|
client.whoIs(Number(body.deviceId), Number(body.deviceId));
|
||||||
|
} else {
|
||||||
|
client.whoIs();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
client.close();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBacnetValue(source, datapoint) {
|
||||||
|
const bacnet = await loadOptionalPackage("node-bacnet");
|
||||||
|
const client = new bacnet({ apduTimeout: 5000, port: Number(source.port || 47808) });
|
||||||
|
const propertyId = Number(datapoint.propertyId || 85);
|
||||||
|
const objectId = parseBacnetObject(datapoint);
|
||||||
|
const host = String(source.host || datapoint.host || "").trim();
|
||||||
|
if (!host) {
|
||||||
|
throw new Error("BACnet Host fehlt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
client.readProperty(host, objectId, propertyId, (error, value) => {
|
||||||
|
client.close();
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = value?.values?.[0]?.value;
|
||||||
|
if (typeof raw === "boolean") {
|
||||||
|
resolve(raw ? 1 : 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numeric = Number(raw);
|
||||||
|
resolve(Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : String(raw ?? ""));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readKnxValue(source, datapoint) {
|
||||||
|
const knx = await loadOptionalPackage("knx");
|
||||||
|
const groupAddress = String(datapoint.address || "").trim();
|
||||||
|
if (!groupAddress) {
|
||||||
|
throw new Error(`KNX Gruppenadresse fehlt für ${datapoint.alias || datapoint.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let done = false;
|
||||||
|
let connection;
|
||||||
|
const finish = (error, value) => {
|
||||||
|
if (done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { connection?.Disconnect?.(); } catch {}
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const numeric = Number(value);
|
||||||
|
resolve(typeof value === "boolean" ? (value ? 1 : 0) : Number.isFinite(numeric) ? numeric : String(value ?? ""));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => finish(new Error("KNX Timeout.")), 7000);
|
||||||
|
connection = new knx.Connection({
|
||||||
|
ipAddr: source.host,
|
||||||
|
ipPort: Number(source.port || 3671),
|
||||||
|
handlers: {
|
||||||
|
connected: () => {
|
||||||
|
try {
|
||||||
|
connection.read(groupAddress);
|
||||||
|
} catch (error) {
|
||||||
|
finish(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event: (_event, _src, dest, value) => {
|
||||||
|
if (!dest || dest === groupAddress) {
|
||||||
|
finish(null, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (connectionError) => finish(connectionError instanceof Error ? connectionError : new Error(String(connectionError))),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollGenericSource(source, datapoints, readValue) {
|
||||||
|
const readings = [];
|
||||||
|
const nextValues = { ...(source.lastValues || {}) };
|
||||||
|
|
||||||
|
for (const datapoint of datapoints) {
|
||||||
|
const value = await readValue(source, datapoint);
|
||||||
|
const key = String(datapoint.pointIndex);
|
||||||
|
const serialized = String(value);
|
||||||
|
if (source.writeMode !== "cov" || nextValues[key] !== serialized) {
|
||||||
|
readings.push({ pointIndex: datapoint.pointIndex, value });
|
||||||
|
}
|
||||||
|
nextValues[key] = serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const written = await writeTrendRow(source, readings);
|
||||||
|
updateSourceState(source.id, {
|
||||||
|
lastValues: nextValues,
|
||||||
|
lastPollAt: new Date().toISOString(),
|
||||||
|
lastPollStatus: written ? `ok: ${readings.length} Wert(e) geschrieben` : "ok: keine Änderung",
|
||||||
|
lastPollError: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function buildValueColumns() {
|
||||||
|
const columns = [];
|
||||||
|
for (let index = 1; index <= 200; index += 1) {
|
||||||
|
columns.push('`Wert' + index + '` TEXT NULL');
|
||||||
|
}
|
||||||
|
return columns.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTrendTable(tableName) {
|
||||||
|
const table = sanitizeTrendTable(tableName);
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS \`${table}\` (
|
||||||
|
\`id\` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
\`datum\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
\`userlevel\` INT(1) NOT NULL,
|
||||||
|
${buildValueColumns()},
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTrendRow(source, readings) {
|
||||||
|
if (!readings.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = await ensureTrendTable(source.tableName);
|
||||||
|
const columns = ["`userlevel`"];
|
||||||
|
const placeholders = ["?"];
|
||||||
|
const values = [0];
|
||||||
|
|
||||||
|
readings.forEach((reading) => {
|
||||||
|
const pointIndex = normalizePointIndex(reading.pointIndex, 0);
|
||||||
|
if (!pointIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
columns.push("`Wert" + pointIndex + "`");
|
||||||
|
placeholders.push("?");
|
||||||
|
values.push(String(reading.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (values.length === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(`INSERT INTO \`${table}\` (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`, values);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPollSource(source, now) {
|
||||||
|
if (!source.id || !source.protocol || !source.tableName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMs = normalizePollInterval(source.pollIntervalSeconds) * 1000;
|
||||||
|
const lastPoll = source.lastPollAt ? new Date(source.lastPollAt).getTime() : 0;
|
||||||
|
return !lastPoll || now - lastPoll >= intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSourceState(sourceId, changes) {
|
||||||
|
touchStore((store) => {
|
||||||
|
const index = store.sources.findIndex((source) => source.id === sourceId);
|
||||||
|
if (index >= 0) {
|
||||||
|
store.sources[index] = {
|
||||||
|
...store.sources[index],
|
||||||
|
...changes,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollModbusSource(source, datapoints) {
|
||||||
|
const readings = [];
|
||||||
|
const nextValues = { ...(source.lastValues || {}) };
|
||||||
|
|
||||||
|
for (const datapoint of datapoints) {
|
||||||
|
const value = await readModbusTcp(source, datapoint);
|
||||||
|
const key = String(datapoint.pointIndex);
|
||||||
|
const serialized = String(value);
|
||||||
|
if (source.writeMode !== "cov" || nextValues[key] !== serialized) {
|
||||||
|
readings.push({ pointIndex: datapoint.pointIndex, value });
|
||||||
|
}
|
||||||
|
nextValues[key] = serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const written = await writeTrendRow(source, readings);
|
||||||
|
updateSourceState(source.id, {
|
||||||
|
lastValues: nextValues,
|
||||||
|
lastPollAt: new Date().toISOString(),
|
||||||
|
lastPollStatus: written ? `ok: ${readings.length} Wert(e) geschrieben` : "ok: keine Änderung",
|
||||||
|
lastPollError: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollSource(source, datapoints) {
|
||||||
|
const sourcePoints = datapoints.filter((datapoint) => datapoint.sourceId === source.id);
|
||||||
|
if (!sourcePoints.length) {
|
||||||
|
updateSourceState(source.id, {
|
||||||
|
lastPollAt: new Date().toISOString(),
|
||||||
|
lastPollStatus: "idle: keine Datenpunkte",
|
||||||
|
lastPollError: "",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (source.protocol === "modbus-tcp") {
|
||||||
|
await pollModbusSource(source, sourcePoints);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.protocol === "opc-ua") {
|
||||||
|
await pollGenericSource(source, sourcePoints, readOpcuaValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.protocol === "bacnet-ip") {
|
||||||
|
await pollGenericSource(source, sourcePoints, readBacnetValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.protocol === "knx-ip") {
|
||||||
|
await pollGenericSource(source, sourcePoints, readKnxValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSourceState(source.id, {
|
||||||
|
lastPollAt: new Date().toISOString(),
|
||||||
|
lastPollStatus: "unsupported",
|
||||||
|
lastPollError: `${source.protocol} wird vom Collector nicht unterstützt.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
updateSourceState(source.id, {
|
||||||
|
lastPollAt: new Date().toISOString(),
|
||||||
|
lastPollStatus: "error",
|
||||||
|
lastPollError: error.message || "Polling fehlgeschlagen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollDueSources() {
|
||||||
|
if (polling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
polling = true;
|
||||||
|
try {
|
||||||
|
const store = readStore();
|
||||||
|
const now = Date.now();
|
||||||
|
for (const source of store.sources) {
|
||||||
|
if (shouldPollSource(source, now)) {
|
||||||
|
await pollSource(source, store.datapoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
pollDueSources().catch((error) => console.error("Collector polling failed", error));
|
||||||
|
}, Math.max(500, pollTickMs));
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||||
|
|
||||||
@@ -331,8 +875,9 @@ const server = createServer(async (req, res) => {
|
|||||||
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
||||||
imports: ["modbus-csv", "knx-xml"],
|
imports: ["modbus-csv", "knx-xml"],
|
||||||
scans: ["opc-ua", "bacnet-ip"],
|
scans: ["opc-ua", "bacnet-ip"],
|
||||||
manualDatapoints: ["modbus-tcp", "knx-ip"],
|
manualDatapoints: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
||||||
note: "Importe, manuelle Datenpunkte und Diagnose sind aktiv. Live-Polling und echte Feldbus-Kommunikation sind noch im nächsten Ausbauschritt.",
|
polling: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
||||||
|
note: "Modbus TCP, OPC UA, BACnet IP und KNX IP werden zyklisch gelesen und in Trendtabellen geschrieben.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -365,8 +910,9 @@ const server = createServer(async (req, res) => {
|
|||||||
bacnet: store.scans.bacnet[0] || null,
|
bacnet: store.scans.bacnet[0] || null,
|
||||||
},
|
},
|
||||||
driverState: {
|
driverState: {
|
||||||
pollingImplemented: false,
|
pollingImplemented: true,
|
||||||
note: "Der Collector speichert Quellen, Importe und Scan-Ergebnisse bereits. Echtes zyklisches Polling auf Bus-Ebene ist aktuell noch nicht implementiert.",
|
pollingProtocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
||||||
|
note: "Alle konfigurierten Protokollquellen pollten über native Treiber. Fehler stehen direkt an der Quelle.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -474,38 +1020,19 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.method === "POST" && url.pathname === "/scan/opcua") {
|
if (req.method === "POST" && url.pathname === "/scan/opcua") {
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const host = body.host || "";
|
if (!body.host) {
|
||||||
if (!host) {
|
|
||||||
replyJson(res, 400, { error: "Für den OPC-UA-Scan fehlt Host oder IP." });
|
replyJson(res, 400, { error: "Für den OPC-UA-Scan fehlt Host oder IP." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const portNumber = Number(body.port || 4840);
|
const result = await scanOpcuaEndpoint(body);
|
||||||
const reachable = await tryTcpConnect(host, portNumber);
|
|
||||||
const result = {
|
|
||||||
host,
|
|
||||||
port: portNumber,
|
|
||||||
reachable,
|
|
||||||
endpoints: reachable
|
|
||||||
? [
|
|
||||||
{ url: `opc.tcp://${host}:${portNumber}`, securityMode: "None", securityPolicy: "None" },
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
sampleNodes: reachable
|
|
||||||
? [
|
|
||||||
{ nodeId: "ns=0;i=2258", browseName: "Server", displayName: "Server" },
|
|
||||||
{ nodeId: "ns=0;i=2267", browseName: "ServerStatus", displayName: "ServerStatus" },
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
scannedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
touchStore((store) => {
|
touchStore((store) => {
|
||||||
store.scans.opcua.unshift(result);
|
store.scans.opcua.unshift(result);
|
||||||
store.scans.opcua = store.scans.opcua.slice(0, 20);
|
store.scans.opcua = store.scans.opcua.slice(0, 20);
|
||||||
return store;
|
return store;
|
||||||
});
|
});
|
||||||
replyJson(res, 200, result);
|
replyJson(res, 200, result);
|
||||||
} catch {
|
} catch (error) {
|
||||||
replyJson(res, 400, { error: "OPC-UA-Scan fehlgeschlagen." });
|
replyJson(res, 400, { error: error.message || "OPC-UA-Scan fehlgeschlagen." });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -513,27 +1040,15 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
|
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const hosts = Array.isArray(body.hosts) ? body.hosts : [body.host].filter(Boolean);
|
const payload = await scanBacnetNetwork(body);
|
||||||
const results = [];
|
|
||||||
for (const host of hosts) {
|
|
||||||
const reachable = await tryTcpConnect(host, Number(body.port || 47808), 650);
|
|
||||||
results.push({
|
|
||||||
host,
|
|
||||||
port: Number(body.port || 47808),
|
|
||||||
reachable,
|
|
||||||
deviceId: body.deviceId || "",
|
|
||||||
name: reachable ? `BACnet Gerät ${host}` : "Nicht erreichbar",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const payload = { devices: results, scannedAt: new Date().toISOString() };
|
|
||||||
touchStore((store) => {
|
touchStore((store) => {
|
||||||
store.scans.bacnet.unshift(payload);
|
store.scans.bacnet.unshift(payload);
|
||||||
store.scans.bacnet = store.scans.bacnet.slice(0, 20);
|
store.scans.bacnet = store.scans.bacnet.slice(0, 20);
|
||||||
return store;
|
return store;
|
||||||
});
|
});
|
||||||
replyJson(res, 200, payload);
|
replyJson(res, 200, payload);
|
||||||
} catch {
|
} catch (error) {
|
||||||
replyJson(res, 400, { error: "BACnet-Scan fehlgeschlagen." });
|
replyJson(res, 400, { error: error.message || "BACnet-Scan fehlgeschlagen." });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
container_name: seltd-mariadb
|
container_name: seltd-mariadb
|
||||||
@@ -80,10 +80,21 @@
|
|||||||
context: ./collector
|
context: ./collector
|
||||||
container_name: seltd-collector
|
container_name: seltd-collector
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PORT: 18110
|
||||||
|
DB_HOST: mariadb
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: wago
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
|
||||||
|
POLL_TICK_MS: ${POLL_TICK_MS:-1000}
|
||||||
ports:
|
ports:
|
||||||
- "18110:18110"
|
- "18110:18110"
|
||||||
volumes:
|
volumes:
|
||||||
- collector_data:/app/data
|
- collector_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
mariadb:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
dockhand:
|
dockhand:
|
||||||
image: fnsys/dockhand:latest
|
image: fnsys/dockhand:latest
|
||||||
|
|||||||
+85
-52
@@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import Gauge from "./components/Gauge.jsx";
|
import Gauge from "./components/Gauge.jsx";
|
||||||
@@ -13,8 +13,10 @@ const views = [
|
|||||||
|
|
||||||
const adminSections = [
|
const adminSections = [
|
||||||
{ id: "aliases", label: "Aliase" },
|
{ id: "aliases", label: "Aliase" },
|
||||||
{ id: "users", label: "Benutzer" },
|
|
||||||
{ id: "sources", label: "Quellen" },
|
{ id: "sources", label: "Quellen" },
|
||||||
|
{ id: "users", label: "Benutzer" },
|
||||||
|
{ id: "roles", label: "Rollen" },
|
||||||
|
{ id: "debug", label: "Debug" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyTrendRange = {
|
const emptyTrendRange = {
|
||||||
@@ -95,6 +97,32 @@ function getDefaultProtocolPort(protocol) {
|
|||||||
return PROTOCOL_DEFAULT_PORTS[protocol] || "";
|
return PROTOCOL_DEFAULT_PORTS[protocol] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultPointDataType(protocol) {
|
||||||
|
if (protocol === "knx-ip") {
|
||||||
|
return "group-address";
|
||||||
|
}
|
||||||
|
if (protocol === "opc-ua") {
|
||||||
|
return "node-id";
|
||||||
|
}
|
||||||
|
if (protocol === "bacnet-ip") {
|
||||||
|
return "analogInput";
|
||||||
|
}
|
||||||
|
return "holding-register";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManualAddressLabel(protocol) {
|
||||||
|
if (protocol === "knx-ip") {
|
||||||
|
return "Gruppenadresse";
|
||||||
|
}
|
||||||
|
if (protocol === "opc-ua") {
|
||||||
|
return "NodeId";
|
||||||
|
}
|
||||||
|
if (protocol === "bacnet-ip") {
|
||||||
|
return "Objekt-Instanz / Adresse";
|
||||||
|
}
|
||||||
|
return "Adresse / Register";
|
||||||
|
}
|
||||||
|
|
||||||
function formatValue(value) {
|
function formatValue(value) {
|
||||||
return Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "--";
|
return Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "--";
|
||||||
}
|
}
|
||||||
@@ -488,16 +516,21 @@ export default function App() {
|
|||||||
const [selectedSourceId, setSelectedSourceId] = useState("");
|
const [selectedSourceId, setSelectedSourceId] = useState("");
|
||||||
const [isCreatingSource, setIsCreatingSource] = useState(false);
|
const [isCreatingSource, setIsCreatingSource] = useState(false);
|
||||||
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
|
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
|
||||||
const [manualDatapointDraft, setManualDatapointDraft] = useState({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" });
|
const [manualDatapointDraft, setManualDatapointDraft] = useState({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(defaultSourceDraft.protocol), kind: "analog" });
|
||||||
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
|
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
|
||||||
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
||||||
|
|
||||||
const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
|
const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
|
||||||
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
|
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
|
||||||
|
const canViewDashboard = hasPermission(user, "view_dashboard");
|
||||||
|
const canViewTrends = hasPermission(user, "view_trends");
|
||||||
const canEditAliases = hasPermission(user, "edit_aliases");
|
const canEditAliases = hasPermission(user, "edit_aliases");
|
||||||
|
const canManageSources = hasPermission(user, "manage_sources");
|
||||||
const canManageUsers = hasPermission(user, "manage_users");
|
const canManageUsers = hasPermission(user, "manage_users");
|
||||||
const canManageRoles = hasPermission(user, "manage_roles");
|
const canManageRoles = hasPermission(user, "manage_roles");
|
||||||
const canViewDebug = hasPermission(user, "view_debug");
|
const canViewDebug = hasPermission(user, "view_debug");
|
||||||
|
const canAccessAdmin = canEditAliases || canManageSources || canManageUsers || canManageRoles || canViewDebug;
|
||||||
|
const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageUsers ? "users" : canManageRoles ? "roles" : canViewDebug ? "debug" : "aliases";
|
||||||
const sidebarCollapsed = !sidebarPinned && !sidebarHover;
|
const sidebarCollapsed = !sidebarPinned && !sidebarHover;
|
||||||
const currentView = views.find((view) => view.id === activeView) || views[0];
|
const currentView = views.find((view) => view.id === activeView) || views[0];
|
||||||
const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list";
|
const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list";
|
||||||
@@ -529,6 +562,10 @@ export default function App() {
|
|||||||
return () => media.removeEventListener("change", applyTheme);
|
return () => media.removeEventListener("change", applyTheme);
|
||||||
}, [themeMode]);
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetFlash();
|
||||||
|
}, [activeView, adminSection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setSessionReady(true);
|
setSessionReady(true);
|
||||||
@@ -748,25 +785,50 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
setManualDatapointDraft((current) => ({
|
setManualDatapointDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
dataType: currentSource.protocol === "knx-ip" ? "group-address" : "holding-register",
|
dataType: getDefaultPointDataType(currentSource.protocol || "modbus-tcp"),
|
||||||
}));
|
}));
|
||||||
}, [currentSource]);
|
}, [currentSource]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canViewDashboard && activeView === "dashboard") {
|
||||||
|
setActiveView(canViewTrends ? "trends" : canAccessAdmin ? "admin" : "settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canViewTrends && activeView === "trends") {
|
||||||
|
setActiveView(canViewDashboard ? "dashboard" : canAccessAdmin ? "admin" : "settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canAccessAdmin && activeView === "admin") {
|
||||||
|
setActiveView(canViewDashboard ? "dashboard" : canViewTrends ? "trends" : "settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEditAliases && adminSection === "aliases") {
|
||||||
|
setAdminSection(firstAdminSection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(canManageSources || canEditAliases) && adminSection === "sources") {
|
||||||
|
setAdminSection(firstAdminSection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!canManageUsers && adminSection === "users") {
|
if (!canManageUsers && adminSection === "users") {
|
||||||
setAdminSection("aliases");
|
setAdminSection(firstAdminSection);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageRoles && adminSection === "roles") {
|
if (!canManageRoles && adminSection === "roles") {
|
||||||
setAdminSection("aliases");
|
setAdminSection(firstAdminSection);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canViewDebug && adminSection === "debug") {
|
if (!canViewDebug && adminSection === "debug") {
|
||||||
setAdminSection("aliases");
|
setAdminSection(firstAdminSection);
|
||||||
}
|
}
|
||||||
}, [activeView, adminSection, canAccessAdmin, canManageRoles, canManageUsers, canViewDashboard, canViewDebug, canViewTrends]);
|
}, [activeView, adminSection, canAccessAdmin, canEditAliases, canManageRoles, canManageSources, canManageUsers, canViewDashboard, canViewDebug, canViewTrends, firstAdminSection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSelectionId) {
|
if (!selectedSelectionId) {
|
||||||
@@ -1108,7 +1170,7 @@ export default function App() {
|
|||||||
const startNewSource = () => {
|
const startNewSource = () => {
|
||||||
setIsCreatingSource(true);
|
setIsCreatingSource(true);
|
||||||
setSelectedSourceId("");
|
setSelectedSourceId("");
|
||||||
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" });
|
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(defaultSourceDraft.protocol), kind: "analog" });
|
||||||
setSourceDraft({
|
setSourceDraft({
|
||||||
...defaultSourceDraft,
|
...defaultSourceDraft,
|
||||||
port: getDefaultProtocolPort(defaultSourceDraft.protocol),
|
port: getDefaultProtocolPort(defaultSourceDraft.protocol),
|
||||||
@@ -1128,6 +1190,10 @@ export default function App() {
|
|||||||
tableName: current.id ? current.tableName : buildCollectorTableName(current.name, nextProtocol),
|
tableName: current.id ? current.tableName : buildCollectorTableName(current.name, nextProtocol),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
setManualDatapointDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
dataType: getDefaultPointDataType(nextProtocol),
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveCollectorSource = async () => {
|
const saveCollectorSource = async () => {
|
||||||
@@ -1212,11 +1278,12 @@ export default function App() {
|
|||||||
pointIndex: manualDatapointDraft.pointIndex,
|
pointIndex: manualDatapointDraft.pointIndex,
|
||||||
unit: manualDatapointDraft.unit || "°C",
|
unit: manualDatapointDraft.unit || "°C",
|
||||||
dataType: manualDatapointDraft.dataType,
|
dataType: manualDatapointDraft.dataType,
|
||||||
|
nodeId: sourceDraft.protocol === "opc-ua" ? manualDatapointDraft.address : "",
|
||||||
kind: manualDatapointDraft.kind,
|
kind: manualDatapointDraft.kind,
|
||||||
});
|
});
|
||||||
await refreshCollectorData();
|
await refreshCollectorData();
|
||||||
await refreshCollectorDebug().catch(() => undefined);
|
await refreshCollectorDebug().catch(() => undefined);
|
||||||
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: sourceDraft.protocol === "knx-ip" ? "group-address" : "holding-register", kind: "analog" });
|
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(sourceDraft.protocol), kind: "analog" });
|
||||||
setSuccessMessage("Datenpunkt angelegt.");
|
setSuccessMessage("Datenpunkt angelegt.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBootError(error.message);
|
setBootError(error.message);
|
||||||
@@ -1753,6 +1820,12 @@ export default function App() {
|
|||||||
<section className="admin-section">
|
<section className="admin-section">
|
||||||
<div className="admin-subnav">
|
<div className="admin-subnav">
|
||||||
{adminSections.filter((section) => {
|
{adminSections.filter((section) => {
|
||||||
|
if (section.id === "aliases") {
|
||||||
|
return canEditAliases;
|
||||||
|
}
|
||||||
|
if (section.id === "sources") {
|
||||||
|
return canManageSources || canEditAliases;
|
||||||
|
}
|
||||||
if (section.id === "users") {
|
if (section.id === "users") {
|
||||||
return canManageUsers;
|
return canManageUsers;
|
||||||
}
|
}
|
||||||
@@ -1975,7 +2048,7 @@ export default function App() {
|
|||||||
<small className="muted">Zuletzt ausgewählte Quelle: {currentSource?.name || "noch keine"}</small>
|
<small className="muted">Zuletzt ausgewählte Quelle: {currentSource?.name || "noch keine"}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(sourceDraft.protocol === "modbus-tcp" || sourceDraft.protocol === "knx-ip") ? (
|
{canManageSources ? (
|
||||||
<div className="protocol-block">
|
<div className="protocol-block">
|
||||||
<strong>Einzelner Datenpunkt</strong>
|
<strong>Einzelner Datenpunkt</strong>
|
||||||
<div className="form-grid compact-forms">
|
<div className="form-grid compact-forms">
|
||||||
@@ -1984,7 +2057,7 @@ export default function App() {
|
|||||||
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
|
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>{sourceDraft.protocol === "knx-ip" ? "Gruppenadresse" : "Adresse / Register"}</span>
|
<span>{getManualAddressLabel(sourceDraft.protocol)}</span>
|
||||||
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
|
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -2273,43 +2346,3 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user