API added

This commit is contained in:
jhartworks
2026-07-24 13:43:47 +02:00
parent 19d27f9de9
commit 986a43d5a5
12 changed files with 1112 additions and 146 deletions
+313 -48
View File
@@ -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";
@@ -33,7 +33,7 @@ function baseStore() {
return {
sources: [],
datapoints: [],
scans: { opcua: [], bacnet: [] },
scans: { opcua: [], bacnet: [], ipsymcon: [] },
updatedAt: null,
};
}
@@ -112,12 +112,26 @@ function sanitizeTrendTable(value) {
function normalizeProtocol(value) {
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", "ipsymcon"].includes(protocol)) {
return protocol;
}
return "unknown";
}
function redactSourceSecrets(source) {
const options = { ...(source?.options || {}) };
const hasIpsymconApiKey = Boolean(options.ipsymconApiKey);
delete options.ipsymconApiKey;
return { ...source, options, hasIpsymconApiKey };
}
function redactStoreSecrets(store) {
return {
...store,
sources: (store.sources || []).map(redactSourceSecrets),
};
}
function normalizeWriteMode(value) {
return String(value || "").toLowerCase() === "cov" ? "cov" : "interval";
}
@@ -204,6 +218,9 @@ function ensureSource(store, body) {
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 ipsymconCategoryIds = normalizeText(body.ipsymconCategoryIds ?? body.options?.ipsymconCategoryIds ?? existing?.options?.ipsymconCategoryIds ?? "");
const requestedIpsymconApiKey = normalizeText(body.ipsymconApiKey ?? body.options?.ipsymconApiKey ?? "");
const ipsymconApiKey = requestedIpsymconApiKey || normalizeText(existing?.options?.ipsymconApiKey);
const tableName = sanitizeTrendTable(existing?.tableName || buildSourceTableName(body));
const source = {
id: body.id || `src-${Date.now()}`,
@@ -225,6 +242,8 @@ function ensureSource(store, body) {
bacnetBroadcastAddress,
bacnetForeignDeviceTtl,
bacnetScanSubnets,
ipsymconCategoryIds,
ipsymconApiKey,
},
lastValues: body.lastValues || {},
lastPollAt: body.lastPollAt || null,
@@ -461,6 +480,9 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) {
unit: existing.enabled === true ? normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind) : normalizePointUnit(scanned.unit || existing.unit, scanned.kind || existing.kind),
kind: existing.kind || scanned.kind,
enabled: existing.enabled === true,
alarmEnabled: existing.alarmEnabled === true,
alarmCondition: normalizeText(existing.alarmCondition || scanned.alarmCondition),
alarmGroupId: normalizeText(existing.alarmGroupId || scanned.alarmGroupId),
writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode),
condition: normalizeText(existing.condition || scanned.condition),
dataType: normalizeText(existing.dataType || scanned.dataType),
@@ -476,7 +498,7 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) {
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));
store.datapoints = store.datapoints.filter((item) => !(item.sourceId === datapoint.sourceId && item.id === datapoint.id));
store.datapoints.push(datapoint);
});
}
@@ -795,6 +817,175 @@ async function readOpcuaValue(source, datapoint) {
}
}
function parseIpsymconCategoryIds(value) {
const unique = new Set();
for (const entry of String(value || "").split(/[;,\s]+/)) {
const id = Number(entry.trim());
if (Number.isInteger(id) && id > 0) {
unique.add(id);
}
}
return Array.from(unique);
}
function buildIpsymconEndpoint(source) {
const rawHost = normalizeText(source?.host);
if (!rawHost) {
throw new Error("IP-Symcon Host oder URL fehlt.");
}
const endpoint = /^https?:\/\//i.test(rawHost) ? rawHost : `http://${rawHost}`;
let url;
try {
url = new URL(endpoint);
} catch {
throw new Error("IP-Symcon Host oder URL ist ungültig.");
}
const port = Number(source?.port || 3777) || 3777;
if (!url.port) {
url.port = String(port);
}
const path = url.pathname.replace(/\/+$/, "");
url.pathname = !path || path === "/" ? "/hook/api" : (/\/hook\/api$/i.test(path) ? path : `${path}/hook/api`);
url.search = "";
url.hash = "";
return url.toString();
}
async function requestIpsymconApi(source, payload) {
const apiKey = normalizeText(source?.options?.ipsymconApiKey || source?.ipsymconApiKey);
if (!apiKey) {
throw new Error("Für diese IP-Symcon-Quelle fehlt der API-Schlüssel.");
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(buildIpsymconEndpoint(source), {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
api_key: apiKey,
include_hidden: true,
follow_links: true,
stale_after_seconds: 240,
...payload,
}),
signal: controller.signal,
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data?.ok !== true) {
throw new Error(data?.error?.message || data?.error || `IP-Symcon API antwortet mit HTTP ${response.status}.`);
}
return data;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("IP-Symcon API hat nicht innerhalb von 30 Sekunden geantwortet.");
}
throw error;
} finally {
clearTimeout(timer);
}
}
function mapIpsymconVariable(source, variable, categoryId, index) {
const variableId = Number(variable?.id);
const variableType = normalizeText(variable?.variable_type || variable?.type || "");
const kind = variableType === "boolean" || typeof variable?.value === "boolean" ? "digital" : "analog";
const name = normalizeText(variable?.full_name || variable?.name || `Variable ${variableId}`) || `Variable ${variableId}`;
return {
id: `${source.id}::ipsymcon::${variableId}`,
protocol: "ipsymcon",
pointIndex: index + 1,
alias: name,
name,
address: String(variableId),
nodeId: String(variableId),
dataType: variableType ? `ipsymcon-${variableType}` : "ipsymcon-variable",
kind,
unit: normalizePointUnit(variable?.unit, kind),
currentValue: variable?.value ?? null,
quality: normalizeText(variable?.quality || "unknown"),
path: normalizeText(variable?.actual_path || ""),
categoryId: Number(categoryId) || null,
};
}
async function scanIpsymconSource(source) {
const categoryIds = parseIpsymconCategoryIds(source?.options?.ipsymconCategoryIds || source?.ipsymconCategoryIds);
if (!categoryIds.length) {
throw new Error("Bitte mindestens eine IP-Symcon Kategorie-ID angeben.");
}
const variablesById = new Map();
const categories = [];
for (const categoryId of categoryIds) {
let offset = 0;
let pageCount = 0;
do {
const response = await requestIpsymconApi(source, {
function: "getcategory",
id: categoryId,
value: 0,
max_depth: 30,
limit: 1000,
offset,
});
const result = response.result || {};
const variables = Array.isArray(result.variables) ? result.variables : [];
categories.push({
id: categoryId,
name: normalizeText(result.root?.name || `Kategorie ${categoryId}`),
path: normalizeText(result.root?.path || ""),
returned: variables.length,
total: Number(result.pagination?.total || variables.length),
});
for (const variable of variables) {
const variableId = Number(variable?.id);
if (Number.isInteger(variableId) && variableId > 0 && !variablesById.has(variableId)) {
variablesById.set(variableId, { variable, categoryId });
}
}
const pagination = result.pagination || {};
if (!pagination.has_more || variables.length === 0 || pageCount >= 20) {
break;
}
offset = Number(pagination.next_offset);
if (!Number.isFinite(offset) || offset < 0) {
break;
}
pageCount += 1;
} while (variablesById.size < LOCAL_POINT_LIMIT);
}
const points = Array.from(variablesById.values())
.slice(0, LOCAL_POINT_LIMIT)
.map(({ variable, categoryId }, index) => mapIpsymconVariable(source, variable, categoryId, index));
return {
host: buildIpsymconEndpoint(source),
categoryIds,
categories,
points,
total: variablesById.size,
truncated: variablesById.size > points.length,
scannedAt: new Date().toISOString(),
};
}
async function readIpsymconValue(source, datapoint) {
const variableId = Number(datapoint.nodeId || datapoint.address);
if (!Number.isInteger(variableId) || variableId <= 0) {
throw new Error(`IP-Symcon Variablen-ID fehlt für ${datapoint.alias || datapoint.name}.`);
}
const response = await requestIpsymconApi(source, { function: "getvalue", id: variableId });
const raw = response.value ?? response.variable?.value;
if (typeof raw === "boolean") {
return raw ? 1 : 0;
}
const numeric = Number(raw);
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : raw === null || raw === undefined ? null : String(raw);
}
function parseBacnetObject(datapoint) {
const rawAddress = String(datapoint.address || "").trim();
const shorthand = rawAddress.match(/^(ai|ao|av|bi|bo|bv)(\d+)$/i);
@@ -1445,7 +1636,9 @@ async function readDatapointCurrent(source, datapoint) {
if (source.protocol === "knx-ip") {
return await readKnxValue(source, datapoint);
}
throw new Error("Protokoll wird nicht unterstützt.");
if (source.protocol === "ipsymcon") {
return await readIpsymconValue(source, datapoint);
} throw new Error("Protokoll wird nicht unterstützt.");
}
async function pollGenericSource(source, datapoints, readValue) {
@@ -1596,13 +1789,32 @@ async function ensureSourceDatapointRegistryTable() {
\`protocol\` VARCHAR(32) NOT NULL,
\`unit\` VARCHAR(64) NULL,
\`kind\` VARCHAR(32) NULL,
\`enabled\` TINYINT(1) NOT NULL DEFAULT 0,
\`alarm_enabled\` TINYINT(1) NOT NULL DEFAULT 0,
\`last_seen_at\` TIMESTAMP 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_point\` (\`source_id\`, \`point_index\`),
KEY \`idx_source_datapoint_table\` (\`table_name\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const migrations = [
"ALTER TABLE `source_datapoint_registry` ADD COLUMN IF NOT EXISTS `enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `kind`",
"ALTER TABLE `source_datapoint_registry` ADD COLUMN IF NOT EXISTS `alarm_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `enabled`",
"ALTER TABLE `source_datapoint_registry` ADD COLUMN IF NOT EXISTS `last_seen_at` TIMESTAMP NULL AFTER `alarm_enabled`",
"ALTER TABLE `source_datapoint_registry` DROP INDEX IF EXISTS `uniq_source_point`",
"ALTER TABLE `source_datapoint_registry` ADD INDEX IF NOT EXISTS `idx_source_point` (`source_id`, `point_index`)",
];
for (const statement of migrations) {
try {
await db.query(statement);
} catch (error) {
console.warn("Registry-Migration übersprungen:", error.message);
}
}
}
async function syncDatapointRegistryEntries(datapoints) {
@@ -1617,16 +1829,6 @@ async function syncDatapointRegistryEntries(datapoints) {
}
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\` (
@@ -1638,8 +1840,23 @@ async function syncDatapointRegistryEntries(datapoints) {
\`name\`,
\`protocol\`,
\`unit\`,
\`kind\`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
\`kind\`,
\`enabled\`,
\`alarm_enabled\`,
\`last_seen_at\`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
\`source_id\` = VALUES(\`source_id\`),
\`table_name\` = VALUES(\`table_name\`),
\`point_index\` = VALUES(\`point_index\`),
\`alias\` = VALUES(\`alias\`),
\`name\` = VALUES(\`name\`),
\`protocol\` = VALUES(\`protocol\`),
\`unit\` = VALUES(\`unit\`),
\`kind\` = VALUES(\`kind\`),
\`enabled\` = VALUES(\`enabled\`),
\`alarm_enabled\` = VALUES(\`alarm_enabled\`),
\`last_seen_at\` = CURRENT_TIMESTAMP
`,
[
String(datapoint.id),
@@ -1651,11 +1868,12 @@ async function syncDatapointRegistryEntries(datapoints) {
String(datapoint.protocol || "unknown"),
normalizeUnit(datapoint.unit || ""),
String(datapoint.kind || "analog"),
datapoint.enabled === true ? 1 : 0,
datapoint.alarmEnabled === true ? 1 : 0,
]
);
}
}
async function deleteDatapointRegistryForSource(sourceId) {
if (!sourceId) {
return;
@@ -1673,7 +1891,7 @@ async function rebuildRegistryTablesFromStore() {
for (const source of store.sources || []) {
await syncSourceRegistryEntry(source);
}
await syncDatapointRegistryEntries((store.datapoints || []).filter((datapoint) => datapoint.enabled === true));
await syncDatapointRegistryEntries(store.datapoints || []);
}
@@ -1911,6 +2129,9 @@ async function pollDueSources() {
const store = readStore();
const now = Date.now();
for (const source of store.sources) {
if (source.protocol === "ipsymcon") {
continue;
}
if (shouldPollSource(source, now)) {
await pollSource(source, store.datapoints);
}
@@ -1924,6 +2145,28 @@ setInterval(() => {
pollDueSources().catch((error) => console.error("Collector polling failed", error));
}, Math.max(500, pollTickMs));
async function applyExternalWorkerStates(sources) {
try {
const [rows] = await db.query(
"SELECT source_id, worker, last_poll_at, last_poll_status, last_poll_error FROM source_worker_state WHERE worker = 'seltd-symcon-collector'",
);
const states = new Map(rows.map((row) => [String(row.source_id), row]));
return (sources || []).map((source) => {
const state = source.protocol === "ipsymcon" ? states.get(String(source.id)) : null;
if (!state) return source;
return {
...source,
lastPollAt: state.last_poll_at ? new Date(state.last_poll_at).toISOString() : source.lastPollAt,
lastPollStatus: state.last_poll_status || source.lastPollStatus,
lastPollError: state.last_poll_error || "",
worker: state.worker,
};
});
} catch {
return sources || [];
}
}
const server = createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
@@ -1939,18 +2182,20 @@ const server = createServer(async (req, res) => {
if (req.method === "GET" && url.pathname === "/capabilities") {
replyJson(res, 200, {
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"],
imports: ["modbus-csv", "knx-xml"],
scans: ["opc-ua", "bacnet-ip"],
manualDatapoints: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
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.",
scans: ["opc-ua", "bacnet-ip", "ipsymcon"],
manualDatapoints: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"],
polling: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"],
note: "Modbus TCP, OPC UA, BACnet IP, KNX IP und IP-Symcon werden zyklisch gelesen und in Trendtabellen geschrieben.",
});
return;
}
if (req.method === "GET" && url.pathname === "/sources") {
replyJson(res, 200, readStore());
const store = readStore();
store.sources = await applyExternalWorkerStates(store.sources);
replyJson(res, 200, redactStoreSecrets(store));
return;
}
@@ -1964,23 +2209,25 @@ const server = createServer(async (req, res) => {
if (req.method === "GET" && url.pathname === "/debug") {
const store = readStore();
store.sources = await applyExternalWorkerStates(store.sources);
replyJson(res, 200, {
updatedAt: store.updatedAt,
sourceCount: store.sources.length,
datapointCount: store.datapoints.length,
alarmDatapointCount: store.datapoints.filter((item) => item.alarmEnabled === true).length,
sources: store.sources.map((source) => ({
...source,
...redactSourceSecrets(source),
datapointCount: store.datapoints.filter((item) => item.sourceId === source.id).length,
alarmDatapointCount: store.datapoints.filter((item) => item.sourceId === source.id && item.alarmEnabled === true).length,
})),
latestScans: {
opcua: store.scans.opcua[0] || null,
bacnet: store.scans.bacnet[0] || null,
ipsymcon: store.scans.ipsymcon[0] || null,
},
driverState: {
pollingImplemented: true,
pollingProtocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
pollingProtocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"],
note: "Alle konfigurierten Protokollquellen pollten über native Treiber. Fehler stehen direkt an der Quelle.",
},
});
@@ -1996,7 +2243,7 @@ const server = createServer(async (req, res) => {
});
const createdSource = next.sources.at(-1);
await ensureSourceTrendTable(createdSource);
replyJson(res, 201, createdSource);
replyJson(res, 201, redactSourceSecrets(createdSource));
} catch {
replyJson(res, 400, { error: "Ungültige JSON-Daten." });
}
@@ -2017,7 +2264,7 @@ const server = createServer(async (req, res) => {
});
const updatedSource = next.sources.find((item) => item.id === sourceId);
await ensureSourceTrendTable(updatedSource);
replyJson(res, 200, updatedSource);
replyJson(res, 200, redactSourceSecrets(updatedSource));
} catch (error) {
replyJson(res, error.message === "not-found" ? 404 : 400, {
error: error.message === "not-found" ? "Quelle nicht gefunden." : "Ungültige JSON-Daten.",
@@ -2195,6 +2442,42 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "POST" && url.pathname === "/scan/ipsymcon") {
try {
const body = await readBody(req);
const store = readStore();
const source = store.sources.find((item) => item.id === body.sourceId);
if (!source || source.protocol !== "ipsymcon") {
replyJson(res, 404, { error: "IP-Symcon-Quelle nicht gefunden." });
return;
}
const result = await scanIpsymconSource(source);
let scannedCandidates = [];
touchStore((current) => {
const currentSource = current.sources.find((item) => item.id === source.id);
if (currentSource) {
scannedCandidates = buildScannedDatapoints(current, currentSource, result.points || [], (point, index) => ({
...point,
id: point.id || `${currentSource.id}::ipsymcon::${point.address || index + 1}`,
protocol: "ipsymcon",
pointIndex: index + 1,
alias: point.alias || point.name || `Variable ${point.address || index + 1}`,
name: point.name || point.alias || `Variable ${point.address || index + 1}`,
}));
upsertDatapoints(current, scannedCandidates);
}
current.scans.ipsymcon.unshift(result);
current.scans.ipsymcon = current.scans.ipsymcon.slice(0, 20);
return current;
});
await syncDatapointRegistryEntries(scannedCandidates);
replyJson(res, 200, result);
} catch (error) {
replyJson(res, 400, { error: error.message || "IP-Symcon-Scan fehlgeschlagen." });
}
return;
}
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
try {
const body = await readBody(req);
@@ -2240,21 +2523,3 @@ rebuildRegistryTablesFromStore().catch((error) => console.error("Registry sync f
server.listen(port, () => {
console.log(`SE Local Trenddata collector listening on port ${port}`);
});