API added
This commit is contained in:
+313
-48
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user