feat(bacnet-collector): add initial implementation of BACnet collector with scanning and reading capabilities

- Created package.json for the bacnet-collector project with necessary dependencies.
- Implemented main server logic in src/index.js to handle BACnet device scanning and reading.
- Added raw discovery functionality in src/raw-discover.js to facilitate device detection over the network.
- Introduced utility functions for handling IP addresses, broadcasting, and BACnet packet construction.
- Implemented JSON response handling for health checks and capabilities endpoints.
This commit is contained in:
jhartworks
2026-07-09 11:13:26 +02:00
parent 7a1fe401f2
commit f247d2827d
10 changed files with 1833 additions and 77233 deletions
-77153
View File
File diff suppressed because one or more lines are too long
+180 -30
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "./api.js";
import Gauge from "./components/Gauge.jsx";
@@ -129,6 +129,10 @@ const defaultSourceDraft = {
displayName: "",
writeMode: "interval",
pollIntervalSeconds: "60",
bacnetMode: "broadcast",
bacnetBroadcastAddress: "auto",
bacnetScanSubnets: "",
bacnetForeignDeviceTtl: "120",
};
@@ -814,6 +818,17 @@ export default function App() {
])),
[collectorPointFilter, scanResults.bacnet]
);
const filteredBacnetDevices = useMemo(
() => (scanResults.bacnet?.devices || []).filter((device) => matchesSearch(collectorPointFilter, [
device.name,
device.deviceId,
device.host,
device.address,
device.vendorId,
device.pointCount,
])),
[collectorPointFilter, scanResults.bacnet]
);
const filteredAvailableCollectorPoints = useMemo(
() => availableCollectorPoints.filter((item) => matchesSearch(collectorPointFilter, [
item.alias,
@@ -1120,6 +1135,10 @@ export default function App() {
displayName: currentSource.displayName || currentSource.name || "",
writeMode: currentSource.writeMode === "cov" ? "cov" : "interval",
pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60),
bacnetMode: currentSource.options?.bacnetMode || "broadcast",
bacnetBroadcastAddress: currentSource.options?.bacnetBroadcastAddress || "auto",
bacnetScanSubnets: currentSource.options?.bacnetScanSubnets || "",
bacnetForeignDeviceTtl: String(currentSource.options?.bacnetForeignDeviceTtl || 120),
});
setManualDatapointDraft((current) => ({
...current,
@@ -1599,6 +1618,7 @@ export default function App() {
...current,
protocol: nextProtocol,
port: shouldReplacePort ? nextDefaultPort : current.port,
host: nextProtocol === "bacnet-ip" && current.bacnetMode === "broadcast" ? "" : current.host,
tableName: current.id ? current.tableName : buildCollectorTableName(current.name, nextProtocol),
};
});
@@ -1608,6 +1628,33 @@ export default function App() {
}));
};
const saveBacnetSubnetSetting = async () => {
if (!currentSource || currentSource.protocol !== "bacnet-ip") {
return;
}
const nextSubnets = String(sourceDraft.bacnetScanSubnets || "").trim();
const previousSubnets = String(currentSource.options?.bacnetScanSubnets || "").trim();
if (nextSubnets === previousSubnets) {
return;
}
resetFlash();
try {
await api.updateCollectorSource(currentSource.id, {
...currentSource,
bacnetScanSubnets: nextSubnets,
options: {
...(currentSource.options || {}),
bacnetScanSubnets: nextSubnets,
},
});
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("BACnet-Subnetze gespeichert.");
} catch (error) {
setBootError(error.message);
}
};
const saveCollectorSource = async () => {
if (!sourceDraft.name.trim()) {
setBootError("Bitte einen Alias bzw. Anzeigenamen angeben.");
@@ -1803,29 +1850,58 @@ export default function App() {
}
};
const runBacnetScan = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id;
if (!activeSourceId) {
setBootError("Bitte zuerst eine Quelle auswählen oder anlegen.");
return;
}
const selectBacnetDevice = (device) => {
const host = device.host || device.address || "";
const name = normalizeDisplayText(device.name, host || "BACnet Quelle");
setSourceDraft((current) => ({
...current,
host,
deviceId: String(device.deviceId || ""),
port: String(device.port || current.port || "47808"),
name: current.name?.trim() ? current.name : name,
displayName: current.displayName?.trim() ? current.displayName : name,
tableName: current.id ? current.tableName : buildCollectorTableName(current.name?.trim() ? current.name : name, "bacnet-ip"),
}));
};
const runBacnetScan = async (options = {}) => {
const sourceOverride = options.source || null;
const draft = sourceOverride ? {
...defaultSourceDraft,
...sourceOverride,
name: sourceOverride.name || sourceOverride.displayName || "",
bacnetMode: sourceOverride.options?.bacnetMode || "broadcast",
bacnetBroadcastAddress: sourceOverride.options?.bacnetBroadcastAddress || "auto",
bacnetScanSubnets: sourceOverride.options?.bacnetScanSubnets || sourceOverride.bacnetScanSubnets || "",
bacnetForeignDeviceTtl: String(sourceOverride.options?.bacnetForeignDeviceTtl || 120),
writeMode: sourceOverride.writeMode || sourceOverride.options?.writeMode || "interval",
pollIntervalSeconds: String(sourceOverride.pollIntervalSeconds || sourceOverride.options?.pollIntervalSeconds || 60),
} : sourceDraft;
const activeSourceId = options.sourceId || sourceOverride?.id || selectedSourceId || draft.id || "";
const scanHosts = draft.host ? String(draft.host).split(/[;,\s]+/).map((entry) => entry.trim()).filter(Boolean) : [];
resetFlash();
try {
const hosts = sourceDraft.host
.split(/[;,\s]+/)
.map((entry) => entry.trim())
.filter(Boolean);
const result = await api.scanBacnet({
hosts,
port: sourceDraft.port || "47808",
deviceId: sourceDraft.deviceId,
sourceId: activeSourceId,
port: draft.port || "47808",
deviceId: draft.deviceId,
sourceId: activeSourceId || undefined,
host: draft.host,
scanHosts,
scanSubnets: draft.bacnetScanSubnets || "",
bacnetScanSubnets: draft.bacnetScanSubnets || "",
includeObjects: options.includeObjects === true,
bacnetMode: draft.bacnetMode || "broadcast",
bacnetBroadcastAddress: draft.bacnetBroadcastAddress || "auto",
clientHost: typeof window !== "undefined" ? window.location.hostname : "",
bacnetForeignDeviceTtl: draft.bacnetForeignDeviceTtl || "120",
});
setScanResults((current) => ({ ...current, bacnet: result }));
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("BACnet-Scan abgeschlossen.");
const deviceCount = result.devices?.length || 0;
const pointCount = result.points?.length || 0;
setSuccessMessage(options.includeObjects === true ? `BACnet-Objektscan abgeschlossen: ${deviceCount} Gerät(e), ${pointCount} Objekt(e).` : `BACnet-Scan abgeschlossen: ${deviceCount} Gerät(e) gefunden.`);
} catch (error) {
setBootError(error.message);
}
@@ -2315,6 +2391,7 @@ export default function App() {
</select>
</label>
<div className="form-grid compact-forms">
<label>
<span>Name</span>
@@ -2565,7 +2642,7 @@ export default function App() {
<div className="source-tile-actions">
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null}
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); setScanResults((current) => ({ ...current, bacnet: null })); runBacnetScan({ source, includeObjects: true }); }}>BACnet scannen</button> : null}
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
</div>
</button>
@@ -2602,13 +2679,17 @@ export default function App() {
</div>
<div className="protocol-block">
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
<div className="button-row wrap-tight">
{sourceToolProtocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
{sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
</div>
{sourceToolProtocol === "opc-ua" ? <small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small> : null}
{sourceToolProtocol === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Gerät(e) gefunden` : "Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null}
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP Subnetze" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
{sourceToolProtocol === "bacnet-ip" ? (
<>
<label className="compact-label">
<span>Subnetze</span>
<input value={sourceDraft.bacnetScanSubnets || ""} onBlur={saveBacnetSubnetSetting} onKeyDown={(event) => { if (event.key === "Enter") { event.currentTarget.blur(); } }} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetScanSubnets: event.target.value }))} placeholder="z.B. 192.168.1.0/24; 10.21.2.0/24" />
</label>
<small className="muted">Wird gespeichert und vom BACnet-Scan der Quelle für Device- und Objektsuche verwendet.</small>
</>
) : null}
{sourceToolProtocol === "opc-ua" ? <><div className="button-row wrap-tight"><button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button></div><small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small></> : null}
{sourceToolProtocol === "modbus-tcp" ? <small className="muted">Für 192.168.153.50 ist Input Register / FC4 bestätigt. Den Registertyp wählst du pro Datenpunkt über Datenart.</small> : null}
{sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
</div>
@@ -2617,6 +2698,7 @@ export default function App() {
{canManageSources ? (
<div className="protocol-block manual-point-card source-settings-card">
<strong>Einzelnen Datenpunkt anlegen</strong>
<div className="form-grid compact-forms">
<label>
<span>Alias</span>
@@ -2688,6 +2770,13 @@ export default function App() {
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schließen</button>
</div>
{(bootError || collectorError || successMessage) ? (
<div className={bootError || collectorError ? "modal-status error-box" : "modal-status success-box"}>
{bootError || collectorError || successMessage}
</div>
) : null}
<div className="form-grid compact-forms">
<label>
<span>Protokoll</span>
@@ -2702,18 +2791,43 @@ export default function App() {
<span>Alias / Anzeigename</span>
<input value={sourceDraft.name} onChange={(event) => setSourceDraft((current) => ({ ...current, name: event.target.value, displayName: event.target.value, tableName: buildCollectorTableName(event.target.value, current.protocol) }))} />
</label>
<label>
<span>Host / IP</span>
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
</label>
{sourceDraft.protocol !== "bacnet-ip" || sourceDraft.bacnetMode !== "broadcast" ? (
<label>
<span>{sourceDraft.protocol === "bacnet-ip" ? "BBMD Host / IP" : "Host / IP"}</span>
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
</label>
) : null}
<label>
<span>Port</span>
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
</label>
{sourceDraft.protocol === "bacnet-ip" ? (
<label>
<span>BACnet Modus</span>
<select value={sourceDraft.bacnetMode} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetMode: event.target.value, host: event.target.value === "broadcast" ? "" : current.host }))}>
<option value="broadcast">Broadcast / Who-Is</option>
<option value="bbmd">BBMD</option>
<option value="foreign-device">Foreign Device</option>
</select>
</label>
) : null}
<label>
<span>Geräte-ID</span>
<span>{"Geräte-ID"}</span>
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
</label>
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "broadcast" ? (
<label>
<span>Broadcast-Adresse</span>
<input value={sourceDraft.bacnetBroadcastAddress} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetBroadcastAddress: event.target.value }))} placeholder="auto oder z.B. 192.168.1.255" />
</label>
) : null}
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "foreign-device" ? (
<label>
<span>Foreign Device TTL (s)</span>
<input type="number" min="30" value={sourceDraft.bacnetForeignDeviceTtl} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetForeignDeviceTtl: event.target.value }))} />
</label>
) : null}
<label className="span-two">
<span>Technischer Tabellenname</span>
<input value={sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)} readOnly />
@@ -2731,8 +2845,44 @@ export default function App() {
</label>
</div>
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.</small>
<small className="muted">{"Der Tabellenname wird automatisch passend erzeugt. Bei BACnet läuft der Scan standardmäßig über echtes Who-Is per Broadcast. Host/IP ist nur für BBMD oder Foreign Device nötig."}</small>
{sourceDraft.protocol === "bacnet-ip" ? (
<>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => runBacnetScan({ includeObjects: false })}>{"Netz nach BACnet-Geräten scannen"}</button>
</div>
<div className="bacnet-device-panel">
<div className="bacnet-device-title">
<strong>Gefundene BACnet-Geräte</strong>
<small>{scanResults.bacnet ? `${filteredBacnetDevices.length} Gerät(e)` : "Noch kein Scan gestartet"}</small>
</div>
{filteredBacnetDevices.length ? (
<div className="bacnet-device-list">
{filteredBacnetDevices.slice(0, 80).map((device, index) => {
const isSelectedDevice = String(sourceDraft.deviceId || "") === String(device.deviceId || "");
return (
<button
key={String(device.deviceId || device.host || device.address || index)}
type="button"
className={isSelectedDevice ? "bacnet-device-row active" : "bacnet-device-row"}
onClick={() => selectBacnetDevice(device)}
>
<span>
<strong>{normalizeDisplayText(device.name, "BACnet Gerät " + (device.deviceId || index + 1))}</strong>
<small>{"Device " + (device.deviceId || "?") + " | " + normalizeDisplayText(device.host || device.address) + ":" + (device.port || sourceDraft.port || "47808")}</small>
</span>
<em>{String(device.pointCount ?? device.points?.length ?? 0) + " DP"}</em>
</button>
);
})}
</div>
) : (
<div className="empty-state slim-empty"><p>{scanResults.bacnet ? "Kein BACnet-Gerät gefunden. Prüfe Broadcast-Adresse, Device-ID oder BBMD/Foreign-Device-Einstellung." : "Scan starten, dann erscheinen die Geräte hier."}</p></div>
)}
</div>
</>
) : null}
<div className="button-row wrap-tight">
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle löschen</button> : null}
+78 -1
View File
@@ -1,4 +1,4 @@
:root {
:root {
color-scheme: light;
--bg: #f4f6fb;
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%);
@@ -1149,11 +1149,88 @@ span {
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
}
.bacnet-device-list {
display: grid;
gap: 0.35rem;
max-height: 220px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.35rem;
}
.bacnet-device-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
padding: 0.42rem 0.5rem;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.bacnet-device-row:hover,
.bacnet-device-row.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.bacnet-device-row span {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.bacnet-device-row small {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bacnet-device-row em {
color: var(--muted);
font-style: normal;
font-size: 0.72rem;
}
.source-modal {
display: grid;
gap: 0.65rem;
}
.modal-status {
margin: 0;
padding: 0.55rem 0.7rem;
font-size: 0.78rem;
}
.bacnet-device-panel {
display: grid;
gap: 0.45rem;
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.55rem;
background: color-mix(in srgb, var(--surface-raised) 68%, transparent);
}
.bacnet-device-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.78rem;
}
.bacnet-device-title small {
color: var(--muted);
}
@media (max-width: 980px) {
.source-admin-top,
.source-split,