beta. OPC fixed + tested
This commit is contained in:
+390
-44
@@ -1,4 +1,4 @@
|
||||
import { createServer } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -72,7 +72,7 @@ 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,PUT,DELETE,OPTIONS",
|
||||
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
@@ -130,16 +130,40 @@ function normalizePointKind(value) {
|
||||
return String(value || "").toLowerCase() === "digital" ? "digital" : "analog";
|
||||
}
|
||||
|
||||
function repairText(value) {
|
||||
let current = String(value ?? "");
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const currentScore = (current.match(/[\u00C3\u00C2\uFFFD]/g) || []).length;
|
||||
if (!currentScore) {
|
||||
break;
|
||||
}
|
||||
const repaired = Buffer.from(current, "latin1").toString("utf8");
|
||||
const repairedScore = (repaired.match(/[\u00C3\u00C2\uFFFD]/g) || []).length;
|
||||
if (repairedScore < currentScore) {
|
||||
current = repaired;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return current.replace(/\u00A0/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return repairText(String(value === undefined || value === null ? "" : value).trim());
|
||||
}
|
||||
|
||||
function normalizeUnit(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return "°C";
|
||||
const raw = normalizeText(value);
|
||||
const collapsed = raw.replace(/\s+/g, "");
|
||||
if (!collapsed) {
|
||||
return "\u00B0C";
|
||||
}
|
||||
|
||||
return raw
|
||||
.replace(/°/g, "°")
|
||||
.replace(/^\?C$/i, "°C")
|
||||
.replace(/^°\s*C$/i, "°C");
|
||||
if (/^\?C$/i.test(collapsed) || /^\u00B0C$/i.test(collapsed)) {
|
||||
return "\u00B0C";
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizePointIndex(value, fallback) {
|
||||
@@ -159,11 +183,11 @@ function ensureSource(store, body) {
|
||||
const source = {
|
||||
id: body.id || `src-${Date.now()}`,
|
||||
protocol: normalizeProtocol(body.protocol),
|
||||
name: body.name || "Neue Quelle",
|
||||
host: body.host || "",
|
||||
port: body.port || "",
|
||||
deviceId: body.deviceId || "",
|
||||
displayName: body.displayName || body.name || "Neue Quelle",
|
||||
name: normalizeText(body.name) || "Neue Quelle",
|
||||
host: normalizeText(body.host),
|
||||
port: normalizeText(body.port),
|
||||
deviceId: normalizeText(body.deviceId),
|
||||
displayName: normalizeText(body.displayName || body.name) || "Neue Quelle",
|
||||
tableName,
|
||||
writeMode: normalizeWriteMode(body.writeMode),
|
||||
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
|
||||
@@ -297,8 +321,8 @@ function createManualDatapoint(store, payload) {
|
||||
const fallbackIndex = existing.length + 1;
|
||||
const pointIndex = normalizePointIndex(payload.pointIndex, fallbackIndex);
|
||||
const protocol = normalizeProtocol(payload.protocol || source.protocol);
|
||||
const alias = String(payload.alias || payload.name || `Punkt ${pointIndex}`).trim() || `Punkt ${pointIndex}`;
|
||||
const name = String(payload.name || payload.alias || alias).trim() || alias;
|
||||
const alias = normalizeText(payload.alias || payload.name || `Punkt ${pointIndex}`) || `Punkt ${pointIndex}`;
|
||||
const name = normalizeText(payload.name || payload.alias || alias) || alias;
|
||||
|
||||
const datapoint = {
|
||||
id: payload.id || `dp-${protocol}-${Date.now()}-${pointIndex}`,
|
||||
@@ -311,10 +335,10 @@ function createManualDatapoint(store, payload) {
|
||||
kind: normalizePointKind(payload.kind),
|
||||
enabled: payload.enabled !== false,
|
||||
writeMode: normalizeWriteMode(payload.writeMode || source.writeMode),
|
||||
condition: String(payload.condition || "").trim(),
|
||||
dataType: String(payload.dataType || (protocol === "knx-ip" ? "group-address" : "holding-register")).trim(),
|
||||
address: String(payload.address || payload.register || "").trim(),
|
||||
nodeId: String(payload.nodeId || "").trim(),
|
||||
condition: normalizeText(payload.condition),
|
||||
dataType: normalizeText(payload.dataType || (protocol === "knx-ip" ? "group-address" : "holding-register")),
|
||||
address: normalizeText(payload.address || payload.register || ""),
|
||||
nodeId: normalizeText(payload.nodeId || ""),
|
||||
scale: Number(payload.scale || 1) || 1,
|
||||
host: source.host || "",
|
||||
tableName: source.tableName || "",
|
||||
@@ -333,27 +357,27 @@ function createManualDatapoint(store, payload) {
|
||||
function createScanDatapoint(source, payload, index) {
|
||||
const pointIndex = normalizePointIndex(payload.pointIndex, index + 1);
|
||||
const protocol = normalizeProtocol(payload.protocol || source.protocol);
|
||||
const alias = String(payload.alias || payload.name || payload.displayName || `Punkt ${pointIndex}`).trim() || `Punkt ${pointIndex}`;
|
||||
const alias = normalizeText(payload.alias || payload.name || payload.displayName || `Punkt ${pointIndex}`) || `Punkt ${pointIndex}`;
|
||||
return {
|
||||
id: payload.id || `dp-${protocol}-${Date.now()}-${Math.round(Math.random() * 100000)}-${pointIndex}`,
|
||||
sourceId: source.id,
|
||||
protocol,
|
||||
pointIndex,
|
||||
name: String(payload.name || alias).trim() || alias,
|
||||
name: normalizeText(payload.name || alias) || alias,
|
||||
alias,
|
||||
unit: normalizeUnit(payload.unit),
|
||||
kind: normalizePointKind(payload.kind),
|
||||
enabled: payload.enabled === true,
|
||||
writeMode: normalizeWriteMode(payload.writeMode || source.writeMode),
|
||||
condition: String(payload.condition || "").trim(),
|
||||
dataType: String(payload.dataType || "").trim(),
|
||||
address: String(payload.address || "").trim(),
|
||||
nodeId: String(payload.nodeId || "").trim(),
|
||||
condition: normalizeText(payload.condition),
|
||||
dataType: normalizeText(payload.dataType),
|
||||
address: normalizeText(payload.address),
|
||||
nodeId: normalizeText(payload.nodeId),
|
||||
objectType: payload.objectType,
|
||||
objectInstance: payload.objectInstance,
|
||||
propertyId: payload.propertyId || 85,
|
||||
scale: Number(payload.scale || 1) || 1,
|
||||
host: payload.host || source.host || "",
|
||||
host: normalizeText(payload.host || source.host),
|
||||
tableName: source.tableName || "",
|
||||
currentValue: payload.currentValue ?? null,
|
||||
lastReadAt: payload.currentValue === undefined ? null : new Date().toISOString(),
|
||||
@@ -361,6 +385,50 @@ function createScanDatapoint(source, payload, index) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildScannedDatapoints(store, source, rawPoints, toPayload) {
|
||||
const existingPoints = store.datapoints.filter((item) => item.sourceId === source.id);
|
||||
const existingById = new Map(existingPoints.map((item) => [String(item.id), item]));
|
||||
const seenIds = new Set();
|
||||
let nextPointIndex = existingPoints.reduce((max, item) => Math.max(max, Number(item.pointIndex) || 0), 0) + 1;
|
||||
|
||||
return rawPoints.reduce((items, rawPoint, index) => {
|
||||
const payload = toPayload(rawPoint, index);
|
||||
const stableId = String(payload.id || "");
|
||||
if (stableId) {
|
||||
if (seenIds.has(stableId)) {
|
||||
return items;
|
||||
}
|
||||
seenIds.add(stableId);
|
||||
}
|
||||
|
||||
const existing = stableId ? existingById.get(stableId) : null;
|
||||
const pointIndex = existing ? existing.pointIndex : normalizePointIndex(payload.pointIndex, nextPointIndex);
|
||||
if (!existing) {
|
||||
nextPointIndex = Math.max(nextPointIndex, pointIndex + 1);
|
||||
}
|
||||
const scanned = createScanDatapoint(source, { ...payload, pointIndex }, index);
|
||||
items.push(!existing ? scanned : {
|
||||
...existing,
|
||||
...scanned,
|
||||
pointIndex: existing.pointIndex,
|
||||
alias: existing.alias || scanned.alias,
|
||||
name: existing.name || scanned.name,
|
||||
unit: normalizeUnit(existing.unit || scanned.unit),
|
||||
kind: existing.kind || scanned.kind,
|
||||
enabled: existing.enabled === true,
|
||||
writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode),
|
||||
condition: normalizeText(existing.condition || scanned.condition),
|
||||
dataType: normalizeText(existing.dataType || scanned.dataType),
|
||||
address: normalizeText(existing.address || scanned.address),
|
||||
nodeId: normalizeText(existing.nodeId || scanned.nodeId),
|
||||
scale: Number(existing.scale || scanned.scale || 1) || 1,
|
||||
host: normalizeText(existing.host || scanned.host),
|
||||
createdAt: existing.createdAt || scanned.createdAt,
|
||||
});
|
||||
return items;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function upsertDatapoints(store, datapoints) {
|
||||
datapoints.forEach((datapoint) => {
|
||||
store.datapoints = store.datapoints.filter((item) => item.id !== datapoint.id && !(item.sourceId === datapoint.sourceId && item.pointIndex === datapoint.pointIndex));
|
||||
@@ -634,7 +702,7 @@ async function readOpcuaValue(source, datapoint) {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : String(value ?? "");
|
||||
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : String(value || "");
|
||||
} finally {
|
||||
await client.disconnect().catch(() => undefined);
|
||||
}
|
||||
@@ -797,7 +865,7 @@ async function readBacnetValue(source, datapoint) {
|
||||
return;
|
||||
}
|
||||
const numeric = Number(raw);
|
||||
resolve(Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : String(raw ?? ""));
|
||||
resolve(Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : String(raw || ""));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -823,7 +891,7 @@ async function readKnxValue(source, datapoint) {
|
||||
reject(error);
|
||||
} else {
|
||||
const numeric = Number(value);
|
||||
resolve(typeof value === "boolean" ? (value ? 1 : 0) : Number.isFinite(numeric) ? numeric : String(value ?? ""));
|
||||
resolve(typeof value === "boolean" ? (value ? 1 : 0) : Number.isFinite(numeric) ? numeric : String(value || ""));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -970,12 +1038,251 @@ async function ensureTrendTable(tableName) {
|
||||
return table;
|
||||
}
|
||||
|
||||
async function ensureSourceRegistryTable() {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`source_registry\` (
|
||||
\`source_id\` VARCHAR(191) NOT NULL,
|
||||
\`table_name\` VARCHAR(191) NOT NULL,
|
||||
\`source_name\` VARCHAR(255) NOT NULL,
|
||||
\`display_name\` VARCHAR(255) NOT NULL,
|
||||
\`protocol\` VARCHAR(32) NOT NULL,
|
||||
\`created_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`updated_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`source_id\`),
|
||||
KEY \`idx_source_registry_table_name\` (\`table_name\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
async function syncSourceRegistryEntry(source) {
|
||||
if (!source?.id || !source?.tableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureSourceRegistryTable();
|
||||
await db.query(
|
||||
`
|
||||
INSERT INTO \`source_registry\` (
|
||||
\`source_id\`,
|
||||
\`table_name\`,
|
||||
\`source_name\`,
|
||||
\`display_name\`,
|
||||
\`protocol\`
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
\`table_name\` = VALUES(\`table_name\`),
|
||||
\`source_name\` = VALUES(\`source_name\`),
|
||||
\`display_name\` = VALUES(\`display_name\`),
|
||||
\`protocol\` = VALUES(\`protocol\`)
|
||||
`,
|
||||
[
|
||||
String(source.id),
|
||||
sanitizeTrendTable(source.tableName),
|
||||
String(source.name || source.displayName || source.id),
|
||||
String(source.displayName || source.name || source.id),
|
||||
String(source.protocol || "unknown"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteSourceRegistryEntry(sourceId) {
|
||||
if (!sourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureSourceRegistryTable();
|
||||
await db.query("DELETE FROM `source_registry` WHERE `source_id` = ?", [String(sourceId)]);
|
||||
}
|
||||
|
||||
async function ensureSourceDatapointRegistryTable() {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`source_datapoint_registry\` (
|
||||
\`datapoint_id\` VARCHAR(255) NOT NULL,
|
||||
\`source_id\` VARCHAR(191) NOT NULL,
|
||||
\`table_name\` VARCHAR(191) NOT NULL,
|
||||
\`point_index\` INT NOT NULL,
|
||||
\`alias\` VARCHAR(255) NOT NULL,
|
||||
\`name\` VARCHAR(255) NOT NULL,
|
||||
\`protocol\` VARCHAR(32) NOT NULL,
|
||||
\`unit\` VARCHAR(64) NULL,
|
||||
\`kind\` VARCHAR(32) NULL,
|
||||
\`created_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`updated_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`datapoint_id\`),
|
||||
UNIQUE KEY \`uniq_source_point\` (\`source_id\`, \`point_index\`),
|
||||
KEY \`idx_source_datapoint_table\` (\`table_name\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
async function syncDatapointRegistryEntries(datapoints) {
|
||||
if (!Array.isArray(datapoints) || !datapoints.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureSourceDatapointRegistryTable();
|
||||
for (const datapoint of datapoints) {
|
||||
if (!datapoint?.id || !datapoint?.sourceId || !datapoint?.tableName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointIndex = normalizePointIndex(datapoint.pointIndex, 1);
|
||||
await db.query("DELETE FROM `source_datapoint_registry` WHERE `datapoint_id` = ? OR (`source_id` = ? AND `point_index` = ?)", [
|
||||
String(datapoint.id),
|
||||
String(datapoint.sourceId),
|
||||
pointIndex,
|
||||
]);
|
||||
|
||||
if (datapoint.enabled !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`
|
||||
INSERT INTO \`source_datapoint_registry\` (
|
||||
\`datapoint_id\`,
|
||||
\`source_id\`,
|
||||
\`table_name\`,
|
||||
\`point_index\`,
|
||||
\`alias\`,
|
||||
\`name\`,
|
||||
\`protocol\`,
|
||||
\`unit\`,
|
||||
\`kind\`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
String(datapoint.id),
|
||||
String(datapoint.sourceId),
|
||||
sanitizeTrendTable(datapoint.tableName),
|
||||
pointIndex,
|
||||
normalizeText(datapoint.alias || datapoint.name || `Wert${datapoint.pointIndex}`),
|
||||
normalizeText(datapoint.name || datapoint.alias || `Wert${datapoint.pointIndex}`),
|
||||
String(datapoint.protocol || "unknown"),
|
||||
normalizeUnit(datapoint.unit || ""),
|
||||
String(datapoint.kind || "analog"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDatapointRegistryForSource(sourceId) {
|
||||
if (!sourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureSourceDatapointRegistryTable();
|
||||
await db.query("DELETE FROM `source_datapoint_registry` WHERE `source_id` = ?", [String(sourceId)]);
|
||||
}
|
||||
|
||||
async function rebuildRegistryTablesFromStore() {
|
||||
const store = readStore();
|
||||
await ensureSourceRegistryTable();
|
||||
await ensureSourceDatapointRegistryTable();
|
||||
await db.query("DELETE FROM `source_datapoint_registry`");
|
||||
for (const source of store.sources || []) {
|
||||
await syncSourceRegistryEntry(source);
|
||||
}
|
||||
await syncDatapointRegistryEntries((store.datapoints || []).filter((datapoint) => datapoint.enabled === true));
|
||||
}
|
||||
|
||||
|
||||
async function getTrendTableInfo(tableName) {
|
||||
const table = sanitizeTrendTable(tableName);
|
||||
const [columns] = await db.query(
|
||||
`
|
||||
SELECT COLUMN_NAME AS columnName
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION
|
||||
`,
|
||||
[table]
|
||||
);
|
||||
const names = columns.map((column) => column.columnName);
|
||||
const lower = new Set(names.map((name) => String(name).toLowerCase()));
|
||||
const wideColumnCount = names.filter((name) => /^Wert\d+$/i.test(name)).length;
|
||||
|
||||
if (wideColumnCount > 0) {
|
||||
return { table, mode: "wide", exists: true };
|
||||
}
|
||||
|
||||
if (lower.has("source_id") && lower.has("point_index") && lower.has("value") && lower.has("datum")) {
|
||||
return { table, mode: "narrow", exists: true };
|
||||
}
|
||||
|
||||
return { table, mode: null, exists: names.length > 0 };
|
||||
}
|
||||
|
||||
async function ensureSourceTrendTable(source) {
|
||||
const info = await getTrendTableInfo(source.tableName);
|
||||
if (info.mode === "wide") {
|
||||
return info;
|
||||
}
|
||||
if (info.mode === "narrow") {
|
||||
await syncSourceRegistryEntry(source);
|
||||
return info;
|
||||
}
|
||||
if (info.exists) {
|
||||
throw new Error(`Trendtabelle ${info.table} hat ein unbekanntes Format.`);
|
||||
}
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`${info.table}\` (
|
||||
\`id\` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
\`datum\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`userlevel\` INT(1) NOT NULL DEFAULT 0,
|
||||
\`source_id\` VARCHAR(191) NOT NULL,
|
||||
\`point_index\` INT NOT NULL,
|
||||
\`alias\` VARCHAR(255) NOT NULL,
|
||||
\`value\` TEXT NULL,
|
||||
PRIMARY KEY (\`id\`),
|
||||
KEY \`idx_${info.table}_datum\` (\`datum\`),
|
||||
KEY \`idx_${info.table}_point_datum\` (\`point_index\`, \`datum\`),
|
||||
KEY \`idx_${info.table}_source_point\` (\`source_id\`, \`point_index\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await syncSourceRegistryEntry(source);
|
||||
return { ...info, mode: "narrow", exists: true };
|
||||
}
|
||||
|
||||
async function writeTrendRow(source, readings) {
|
||||
if (!readings.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const table = await ensureTrendTable(source.tableName);
|
||||
const tableInfo = await ensureSourceTrendTable(source);
|
||||
const table = tableInfo.table;
|
||||
|
||||
if (tableInfo.mode === "narrow") {
|
||||
const statement = `
|
||||
INSERT INTO \`${table}\` (
|
||||
\`userlevel\`,
|
||||
\`source_id\`,
|
||||
\`point_index\`,
|
||||
\`alias\`,
|
||||
\`value\`
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
for (const reading of readings) {
|
||||
const pointIndex = normalizePointIndex(reading.pointIndex, 0);
|
||||
if (!pointIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const datapoint = source.__activeDatapoints?.find((item) => item.pointIndex === pointIndex);
|
||||
await db.query(statement, [
|
||||
0,
|
||||
String(source.id),
|
||||
pointIndex,
|
||||
String(datapoint?.alias || `Wert${pointIndex}`),
|
||||
String(reading.value),
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const columns = ["`userlevel`"];
|
||||
const placeholders = ["?"];
|
||||
const values = [0];
|
||||
@@ -1057,7 +1364,7 @@ async function pollModbusSource(source, datapoints) {
|
||||
}
|
||||
|
||||
async function pollSource(source, datapoints) {
|
||||
const sourcePoints = datapoints.filter((datapoint) => datapoint.sourceId === source.id && datapoint.enabled !== false);
|
||||
const sourcePoints = datapoints.filter((datapoint) => datapoint.sourceId === source.id && datapoint.enabled === true);
|
||||
if (!sourcePoints.length) {
|
||||
updateSourceState(source.id, {
|
||||
lastPollAt: new Date().toISOString(),
|
||||
@@ -1068,6 +1375,7 @@ async function pollSource(source, datapoints) {
|
||||
}
|
||||
|
||||
try {
|
||||
source.__activeDatapoints = sourcePoints;
|
||||
if (source.protocol === "modbus-tcp") {
|
||||
await pollModbusSource(source, sourcePoints);
|
||||
return;
|
||||
@@ -1091,7 +1399,7 @@ async function pollSource(source, datapoints) {
|
||||
updateSourceState(source.id, {
|
||||
lastPollAt: new Date().toISOString(),
|
||||
lastPollStatus: "unsupported",
|
||||
lastPollError: `${source.protocol} wird vom Collector nicht unterstützt.`,
|
||||
lastPollError: `${source.protocol} wird vom Collector nicht unterst?tzt.`,
|
||||
});
|
||||
} catch (error) {
|
||||
updateSourceState(source.id, {
|
||||
@@ -1099,6 +1407,8 @@ async function pollSource(source, datapoints) {
|
||||
lastPollStatus: "error",
|
||||
lastPollError: error.message || "Polling fehlgeschlagen.",
|
||||
});
|
||||
} finally {
|
||||
delete source.__activeDatapoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,14 +1495,16 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/sources") {
|
||||
if (req.method === "POST" && url.pathname === "/sources") {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
const next = touchStore((store) => {
|
||||
const source = ensureSource(store, body);
|
||||
return { ...store, lastSource: source.id };
|
||||
});
|
||||
replyJson(res, 201, next.sources.at(-1));
|
||||
const createdSource = next.sources.at(-1);
|
||||
await ensureSourceTrendTable(createdSource);
|
||||
replyJson(res, 201, createdSource);
|
||||
} catch {
|
||||
replyJson(res, 400, { error: "Ungültige JSON-Daten." });
|
||||
}
|
||||
@@ -1211,7 +1523,9 @@ const server = createServer(async (req, res) => {
|
||||
ensureSource(store, { ...existing, ...body, id: sourceId });
|
||||
return store;
|
||||
});
|
||||
replyJson(res, 200, next.sources.find((item) => item.id === sourceId));
|
||||
const updatedSource = next.sources.find((item) => item.id === sourceId);
|
||||
await ensureSourceTrendTable(updatedSource);
|
||||
replyJson(res, 200, updatedSource);
|
||||
} catch (error) {
|
||||
replyJson(res, error.message === "not-found" ? 404 : 400, {
|
||||
error: error.message === "not-found" ? "Quelle nicht gefunden." : "Ungültige JSON-Daten.",
|
||||
@@ -1227,6 +1541,8 @@ const server = createServer(async (req, res) => {
|
||||
sources: store.sources.filter((item) => item.id !== sourceId),
|
||||
datapoints: store.datapoints.filter((item) => item.sourceId !== sourceId),
|
||||
}));
|
||||
await deleteSourceRegistryEntry(sourceId);
|
||||
await deleteDatapointRegistryForSource(sourceId);
|
||||
replyJson(res, 200, { ok: true, sources: next.sources.length, datapoints: next.datapoints.length });
|
||||
return;
|
||||
}
|
||||
@@ -1241,6 +1557,7 @@ const server = createServer(async (req, res) => {
|
||||
return store;
|
||||
});
|
||||
const created = next.datapoints.find((item) => item.sourceId === body.sourceId && item.pointIndex === normalizePointIndex(body.pointIndex, 1));
|
||||
await syncDatapointRegistryEntries(created ? [created] : []);
|
||||
replyJson(res, 201, { ok: true, datapoint: created || null, updatedAt: next.updatedAt });
|
||||
} catch (error) {
|
||||
replyJson(res, 400, { error: error.message || "Datenpunkt konnte nicht angelegt werden." });
|
||||
@@ -1250,7 +1567,7 @@ const server = createServer(async (req, res) => {
|
||||
|
||||
if (req.method === "PATCH" && url.pathname.startsWith("/datapoints/")) {
|
||||
try {
|
||||
const datapointId = url.pathname.split("/").at(-1);
|
||||
const datapointId = decodeURIComponent(url.pathname.split("/").at(-1) || "");
|
||||
const body = await readBody(req);
|
||||
const next = touchStore((store) => {
|
||||
const index = store.datapoints.findIndex((item) => item.id === datapointId);
|
||||
@@ -1270,7 +1587,9 @@ const server = createServer(async (req, res) => {
|
||||
};
|
||||
return store;
|
||||
});
|
||||
replyJson(res, 200, { datapoint: next.datapoints.find((item) => item.id === datapointId), updatedAt: next.updatedAt });
|
||||
const updatedDatapoint = next.datapoints.find((item) => item.id === datapointId);
|
||||
await syncDatapointRegistryEntries(updatedDatapoint ? [updatedDatapoint] : []);
|
||||
replyJson(res, 200, { datapoint: updatedDatapoint, updatedAt: next.updatedAt });
|
||||
} catch (error) {
|
||||
replyJson(res, error.message === "not-found" ? 404 : 400, { error: error.message === "not-found" ? "Datenpunkt nicht gefunden." : error.message });
|
||||
}
|
||||
@@ -1279,7 +1598,7 @@ const server = createServer(async (req, res) => {
|
||||
|
||||
if (req.method === "POST" && url.pathname.match(/^\/datapoints\/[^/]+\/read$/)) {
|
||||
try {
|
||||
const datapointId = url.pathname.split("/").at(-2);
|
||||
const datapointId = decodeURIComponent(url.pathname.split("/").at(-2) || "");
|
||||
const store = readStore();
|
||||
const datapoint = store.datapoints.find((item) => item.id === datapointId);
|
||||
const source = datapoint ? store.sources.find((item) => item.id === datapoint.sourceId) : null;
|
||||
@@ -1299,7 +1618,6 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/imports/modbus-csv") {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
@@ -1311,6 +1629,7 @@ const server = createServer(async (req, res) => {
|
||||
upsertDatapoints(store, imported);
|
||||
return store;
|
||||
});
|
||||
await syncDatapointRegistryEntries(imported);
|
||||
replyJson(res, 200, { imported: imported.length, datapoints: imported, updatedAt: next.updatedAt });
|
||||
} catch {
|
||||
replyJson(res, 400, { error: "CSV-Import fehlgeschlagen." });
|
||||
@@ -1329,6 +1648,7 @@ const server = createServer(async (req, res) => {
|
||||
upsertDatapoints(store, imported);
|
||||
return store;
|
||||
});
|
||||
await syncDatapointRegistryEntries(imported);
|
||||
replyJson(res, 200, { imported: imported.length, datapoints: imported, updatedAt: next.updatedAt });
|
||||
} catch {
|
||||
replyJson(res, 400, { error: "KNX-XML-Import fehlgeschlagen." });
|
||||
@@ -1344,12 +1664,23 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
const result = await scanOpcuaEndpoint(body);
|
||||
let scannedCandidates = [];
|
||||
touchStore((store) => {
|
||||
if (body.sourceId) {
|
||||
const source = store.sources.find((item) => item.id === body.sourceId);
|
||||
if (source) {
|
||||
const existingCount = store.datapoints.filter((item) => item.sourceId === source.id).length;
|
||||
const candidates = (result.nodes || []).filter((node) => node.nodeId && node.hasValue).map((node, index) => createScanDatapoint(source, { ...node, protocol: "opc-ua", pointIndex: existingCount + index + 1, alias: node.path || node.displayName, name: node.displayName, address: node.nodeId, nodeId: node.nodeId }, index));
|
||||
const rawNodes = (result.nodes || []).filter((node) => node.nodeId);
|
||||
const candidates = buildScannedDatapoints(store, source, rawNodes, (node, index) => ({
|
||||
...node,
|
||||
id: `${source.id}::${node.nodeId}`,
|
||||
protocol: "opc-ua",
|
||||
pointIndex: index + 1,
|
||||
alias: node.displayName || node.browseName || node.nodeId,
|
||||
name: node.displayName || node.browseName || node.nodeId,
|
||||
address: node.nodeId,
|
||||
nodeId: node.nodeId,
|
||||
}));
|
||||
scannedCandidates = candidates;
|
||||
upsertDatapoints(store, candidates);
|
||||
}
|
||||
}
|
||||
@@ -1357,6 +1688,7 @@ const server = createServer(async (req, res) => {
|
||||
store.scans.opcua = store.scans.opcua.slice(0, 20);
|
||||
return store;
|
||||
});
|
||||
await syncDatapointRegistryEntries(scannedCandidates);
|
||||
replyJson(res, 200, result);
|
||||
} catch (error) {
|
||||
replyJson(res, 400, { error: error.message || "OPC-UA-Scan fehlgeschlagen." });
|
||||
@@ -1368,12 +1700,21 @@ const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
const payload = await scanBacnetNetwork(body);
|
||||
let scannedCandidates = [];
|
||||
touchStore((store) => {
|
||||
if (body.sourceId) {
|
||||
const source = store.sources.find((item) => item.id === body.sourceId);
|
||||
if (source) {
|
||||
const existingCount = store.datapoints.filter((item) => item.sourceId === source.id).length;
|
||||
const candidates = (payload.points || []).map((point, index) => createScanDatapoint(source, { ...point, protocol: "bacnet-ip", pointIndex: existingCount + index + 1 }, index));
|
||||
const rawPoints = payload.points || [];
|
||||
const candidates = buildScannedDatapoints(store, source, rawPoints, (point, index) => ({
|
||||
...point,
|
||||
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}`,
|
||||
}));
|
||||
scannedCandidates = candidates;
|
||||
upsertDatapoints(store, candidates);
|
||||
}
|
||||
}
|
||||
@@ -1381,6 +1722,7 @@ const server = createServer(async (req, res) => {
|
||||
store.scans.bacnet = store.scans.bacnet.slice(0, 20);
|
||||
return store;
|
||||
});
|
||||
await syncDatapointRegistryEntries(scannedCandidates);
|
||||
replyJson(res, 200, payload);
|
||||
} catch (error) {
|
||||
replyJson(res, 400, { error: error.message || "BACnet-Scan fehlgeschlagen." });
|
||||
@@ -1391,6 +1733,10 @@ const server = createServer(async (req, res) => {
|
||||
replyJson(res, 404, { error: "Nicht gefunden." });
|
||||
});
|
||||
|
||||
rebuildRegistryTablesFromStore().catch((error) => console.error("Registry sync failed", error));
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`SE Local Trenddata collector listening on port ${port}`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user