SignalRC mit ReactFlow – alles im Fluss
Der DDC-Truck, Teil 9
Ein Gamepad hat Achsen und Buttons. Ein Fahrzeug wie unser DDC-Truck, den wir im Artikel „SignalRC – in Echtzeit ans Steuer“ [1] ausführlich vorgestellt haben, hat Lenkung, Gas, Licht und Blinker. Irgendwo dazwischen muss jemand entscheiden, welcher Input welchen Output ansteuert und was auf dem Weg dorthin mit dem Signal passiert.
Das klingt erst einmal trivial. Achse 0 auf Lenkung, Achse 1 auf Gas, Button 3 auf Licht. Fertig.
Aber was, wenn der Joystick-Wert von -1 bis 1 geht, der Servo aber 0.3 bis 0.7 braucht? Was, wenn das Gas nicht linear, sondern geglättet gesteuert werden soll, damit das Fahrzeug nicht ruckartig anfährt? Was, wenn ein Button nicht direkt schaltet, sondern als Toggle fungieren soll? Und was, wenn jeder Fahrer seine eigene Belegung und Konfiguration haben möchte?
Willkommen im Signalverarbeitungsproblem. SignalRC löst das mit einem visuellen Node-Editor, gebaut auf ReactFlow.
Was ist ReactFlow?
ReactFlow ist eine React-Bibliothek für interaktive, knotenbasierte Graphen. Man kennt das Konzept aus Tools wie Unreal Blueprints, Node-RED oder Shader-Editoren. Knoten werden auf einer Fläche platziert, haben Ein- und Ausgänge, und diese verbindet man mit Kanten. Die Daten fließen entlang der Kanten von Knoten zu Knoten.
In SignalRC wird ReactFlow eingesetzt, um eine vollständig visuelle Signalverarbeitungskette zu bauen. Der Benutzer sieht seine Gamepad-Eingänge oben, seine Fahrzeugfunktionen unten, und dazwischen kann er sich mit beliebigen Funktionsknoten austoben. Einfach platzieren und verbinden.
Das Ganze ist keine statische Visualisierung. Die Werte fließen in Echtzeit durch den Graphen. Man sieht an jedem Knoten live, welcher Wert gerade anliegt und was hinten rauskommt.
Die drei Knotentypen
Der Signalfluss in SignalRC kennt drei fundamentale Knotentypen mit klaren Rollen:
Input Nodes
Input Nodes repräsentieren die Eingabequellen. Im Normalfall sind das Gamepad-Achsen und Buttons. Jeder Input Node ist an einen UserChannel in der Datenbank gebunden, also an eine spezifische Achse oder einen spezifischen Button eines registrierten Gamepads.
Der Input Node hat keine Eingänge, nur einen Ausgang. Er liefert den aktuellen Wert des Gamepad-Kanals als Zahl.
// Input Node registrieren: POST /api/flow/input/{channelId}
async registerInput(dbId: number) {
const res = await fetch(`/api/flow/input/${dbId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userSetupId: get().userSetupId,
id: dbId,
positionX: 100,
positionY: 100 + get().nodes.length * 40,
})
});
const input = await res.json();
// Node wird dem lokalen State hinzugefügt
set(state => ({
nodes: [...state.nodes, {
nodeId: input.nodeId,
representingId: dbId,
type: 'input',
position: { x: input.positionX, y: input.positionY },
nodeTypeName: input.nodeTypeName,
label: input.label,
params: {},
}],
}));
}Output Nodes
Output Nodes sind das Gegenstück. Sie repräsentieren die Fahrzeugfunktionen: Lenkung, Gas, Licht, Blinker. Jeder Output Node ist an einen CarChannel gebunden, der bei der Fahrzeugregistrierung angelegt wurde. Output Nodes haben einen Eingang, aber keinen Ausgang. Was hereinkommt, wird ans Fahrzeug gesendet, sofern das Fahrzeug nicht im Idle-Modus ist.
Wenn ein Wert am Output Node ankommt, wird er über die bestehende SignalR-Verbindung als Steuerbefehl ans Fahrzeug geschickt:
async sendOutput(channelId: number, value: number) {
const { carId, carSession, connection, updatesEnabled } = get();
if (connection && carId && carSession && updatesEnabled) {
await connection.invoke("UpdateChannel", carId, carSession, channelId, value);
}
}Der Output Node wird also zum letzten Glied in der Kette. Er wandelt den berechneten Signalwert in einen realen Steuerbefehl um.
Das updatesEnabled-Flag steuert den Idle-Modus. Wer seine Signalverarbeitung konfiguriert, will wahrscheinlich nicht, dass dabei Steuerbefehle ans Fahrzeug gehen und es zum Beispiel einfach spontan vom Schreibtisch fährt. Deshalb startet die Konfigurationsseite standardmäßig im Idle-Modus.
Function Nodes
Function Nodes sind das Herzstück der Signalverarbeitung. Sie nehmen Eingangswerte, verarbeiten sie nach einer definierten Logik und geben das Ergebnis an ihren Ausgängen weiter. Hier passiert die eigentliche Magie: Umrechnung, Glättung, Bedingungen, Mathematik.
Jeder Function Node verweist auf eine Funktion aus der FilterFunctionRegistry. Das ist ein zentrales Verzeichnis aller verfügbaren Verarbeitungsfunktionen:
export const filterFunctionRegistry = {
Abs: new AbsFunction(),
Clamp: new ClampFunction(),
Reverse: new ReverseFunction(),
Add: new AddFunction(),
Subtract: new SubtractFunction(),
Multiply: new MultiplyFunction(),
Divide: new DivideFunction(),
Rescale: new RescaleFunction(),
Iif: new IifFunction(),
Toggle: new ToggleFunction(),
Gearbox: new GearboxFunction(),
FloatValue: new FloatValueFunction(),
Smooth: new SmoothFunction(),
Sign: new SignFunction(),
// ... weitere
};Derzeit gibt es 18 Funktionen, Tendenz steigend. Jede davon ist eine Klasse, die das FilterFunctionDef-Interface implementiert.
Das FilterFunction-Interface
Jede Verarbeitungsfunktion folgt einem einheitlichen Schema. Das ist entscheidend für die Erweiterbarkeit des ganzen Systems.
export type FilterFunctionDef<TInputs extends ReadonlyArray<string>> = {
name: string;
label: string;
params: FilterFunctionParam[];
inputLabels: string[];
outputLabels: string[];
apply: (inputs: InputMap<TInputs>, params: Record<string, any>, nodeId?: number) => (number | string)[];
};Die zentrale Methode ist apply. Sie bekommt die aktuellen Eingangswerte, die konfigurierten Parameter und optional die Node-ID (für zustandsbehaftete Funktionen). Zurück kommt ein Array von Ausgangswerten.
Was es schon gibt
SignalRC bringt von Haus aus eine Reihe von Funktionen mit, die die meisten Anwendungsfälle abdecken:
- Mathematik: Add, Subtract, Multiply, Divide, Modulo, Power, Abs, Sign, Log – Standard-Rechenoperationen mit je zwei Eingängen.
- Wertbegrenzung: Clamp begrenzt einen Wert auf Min./Max., Rescale bildet einen Wertebereich linear auf einen anderen ab (zum Beispiel Gamepad -1..1 auf Servo 0.3..0.7).
- Logik: Iif ist ein If-Then-Else-Knoten mit vier Eingängen (a, b, ifTrue, ifFalse) und konfigurierbarem Operator. Toggle macht aus einem momentanen Button-Signal einen dauerhaften Schaltzustand per Flankenerkennung.
- Signalverarbeitung: Smooth glättet Wertänderungen über die Zeit mit exponentieller Interpolation. Reverse invertiert ein Signal. TurnSignal erzeugt ein periodisches Blinksignal.
- Spezialfunktionen: Gearbox simuliert ein Schaltgetriebe mit Rising-Edge Detection für Hoch- und Runterschalten und drei Ausgängen (Gang, Wert, Name). FloatValue ist eine Konstante mit konfigurierbarem Wert, Min, Max und Schrittweite.
Einige dieser Funktionen sind zustandslos, sie berechnen ihren Ausgang rein aus den aktuellen Eingängen. Andere, wie Toggle, Gearbox und Smooth, sind zustandsbehaftet. Sie merken sich über die nodeId ihren internen State zwischen Aufrufen.
Eine neue Funktion einbauen
Das Interface sorgt dafür, dass neue Funktionen mit minimalem Aufwand hinzugefügt werden können. Drei Schritte, drei Dateien, fertig.
Schritt 1: Eine neue Klasse im filters/ Ordner anlegen, die FilterFunctionDef implementiert:
// filters/DeadzoneFunction.ts
export class DeadzoneFunction implements FilterFunctionDef<readonly ["input"]> {
name = 'Deadzone';
label = 'Deadzone';
params: FilterFunctionParam[] = [
{ name: 'threshold', label: 'Threshold', type: 'number', default: 0.05 },
];
inputLabels = ["input"];
outputLabels = ['output'];
apply(inputs: InputMap<readonly ["input"]>, params: Record<string, any>) {
const threshold = params?.threshold ?? 0.05;
const value = inputs.input ?? 0;
return [Math.abs(value) < threshold ? 0 : value];
}
}Die Klasse definiert, was die Funktion braucht (inputLabels), was sie liefert (outputLabels), welche Parameter der Benutzer konfigurieren kann (params) und was sie tut (apply).
Schritt 2: Die Funktion in der Registry registrieren:
// filters/filter-function-registry.ts
import { DeadzoneFunction } from "./DeadzoneFunction";
export const filterFunctionRegistry = {
// ... bestehende Funktionen
Deadzone: new DeadzoneFunction(),
};Schritt 3: Benutzen.
Die Funktion taucht automatisch in der FunctionNodesView auf, kann per Klick als Knoten zum Graphen hinzugefügt werden und der generische CustomFlowNode-Renderer zeigt sie mit Handles, Parametern und Live-Werten an.
Braucht die Funktion eine spezielle Darstellung, weil sie wie Gearbox oder Smooth mehr visuelle Informationen zeigen soll, kann man optional eine eigene React-Komponente bauen und sie im CustomFlowNode-Dispatcher registrieren. Aber das ist Kür, nicht Pflicht.
Die Rechenmaschine – der Control Flow Store
Die zentrale Schaltstelle für den gesamten Signalfluss ist der useControlFlowStore. Das ist ein Zustand-Store, der den kompletten Graphen verwaltet: Knoten, Kanten, aktuelle Werte und die Berechnung.
export const useControlFlowStore = create<ControlFlowState>((set, get) => ({
nodes: [],
edges: [],
nodeLatestValues: {},
frame: 0,
isLoading: true,
// ...
}));Laden des Graphen
Beim Öffnen der Seite wird der Graph vom Server geladen. Dafür gibt es zwei API-Aufrufe:
- GET /api/userconfig/setup/{carId} – für welches Fahrzeug ändert der User gerade das Setup?
- GET /api/flow/{setupId} – welche Knoten und Kanten gehören zu diesem Setup?
Der Server liefert die Knoten als polymorphe Entitäten zurück. Der FlowController unterscheidet zwischen UserSetupUserChannelNode (Input), UserSetupCarChannelNode (Output) und UserSetupFunctionNode (Verarbeitung):
private NodeInfo? FlowNodeToNodeInfo(UserSetupFlowNodeBase flowNode)
{
var result = new NodeInfo
{
NodeId = flowNode.Id,
Position = new NodePosition { X = flowNode.PositionX, Y = flowNode.PositionY },
NodeTypeName = flowNode.GetType().Name,
};
switch (flowNode)
{
case UserSetupUserChannelNode userChannel:
result.Type = "input";
result.RepresentingId = userChannel.UserChannelId;
break;
case UserSetupCarChannelNode carChannel:
result.Type = "output";
result.RepresentingId = carChannel.CarChannelId;
break;
case UserSetupFunctionNode fn:
result.Label = fn.SetupFunctionName;
result.Metadata = new { functionName = fn.SetupFunctionName };
result.Params = fn.Parameters.ToDictionary(p => p.ParameterName, p => p.ParameterValue);
break;
}
return result;
}Die Neuberechnung
Das Herzstück ist die recalculateNode-Funktion. Sie wird immer aufgerufen, wenn sich ein Wert ändert, sei es durch einen Gamepad-Input, eine Parameteränderung oder eine Smooth-Interpolation.
recalculateNode: (nodeId: number) => {
const state = get();
const node = state.nodes.find(n => n.nodeId === nodeId);
if (!node) return;
// Input Nodes: Wert durchreichen und downstream propagieren
if (node.nodeTypeName === "UserSetupUserChannelNode") {
const value = state.nodeLatestValues[nodeId] ?? 0;
const nextEdges = state.edges.filter(e => e.source === nodeId);
nextEdges.forEach(edge => get().recalculateNode(edge.target));
return;
}
// Output Nodes: Wert an Fahrzeug senden
if (node.nodeTypeName === "UserSetupCarChannelNode") {
const incomingEdge = state.edges.find(e => e.target === nodeId);
if (!incomingEdge) return;
const inputValue = state.nodeLatestValues[incomingEdge.source] ?? 0;
state.nodeLatestValues[nodeId] = inputValue;
if (node.representingId !== undefined) {
state.sendOutput(node.representingId, inputValue);
}
return;
}
// Function Nodes: Eingänge sammeln, Funktion ausführen, weiterpropagieren
if (node.nodeTypeName === "UserSetupFunctionNode") {
const fnDef = filterFunctionRegistry[fnName];
const inputValues = {};
fnDef.inputLabels.forEach(label => {
const incoming = state.edges.find(e => e.target === nodeId && e.targetPort === label);
inputValues[label] = incoming ? state.nodeLatestValues[incoming.source] ?? 0 : 0;
});
const result = fnDef.apply(inputValues, node.params ?? {}, nodeId);
state.nodeLatestValues[nodeId] = result[0];
// Downstream propagieren
state.edges.filter(e => e.source === nodeId)
.forEach(edge => get().recalculateNode(edge.target));
}
}Das ist ein rekursiver Graph-Traversal. Wenn sich ein Input ändert, wird recalculateNode auf dem Input Node aufgerufen. Der propagiert zu allen verbundenen Knoten. Diese berechnen ihre Werte neu und propagieren wiederum weiter, bis die Werte bei den Output Nodes ankommen und als Steuerbefehle hinausgehen oder einfach keine Verbindung mehr hinausgeht. Ein eingebauter visited-Check verhindert Endlosschleifen bei zirkulären Verbindungen.
Das alles passiert auf dem Web-Client, um die Last auf dem Fahrzeug und dem Server so gering wie möglich zu halten.
Custom Node Rendering
ReactFlow rendert standardmäßig schlichte rechteckige Knoten. In SignalRC hat jeder Knotentyp sein eigenes visuelles Erscheinungsbild. Das wird über den nodeTypes-Mechanismus von ReactFlow gelöst:
const nodeTypes = { custom: CustomFlowNode };
<ReactFlow
nodes={flowControl.nodes.map(controlNodeToReact)}
edges={flowControl.edges.map(controlEdgeToReact)}
nodeTypes={nodeTypes}
fitView
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onConnect={onConnect}
onEdgeClick={onEdgeClick}
draggable
>
<MiniMap />
<Controls />
<Background />
</ReactFlow>Alle Knoten werden als Typ custom registriert und landen im CustomFlowNode. Dieser fungiert als Dispatcher:
export default function CustomFlowNode(props: NodeProps) {
const data = flowControl.nodes.find((n) => n.nodeId == id);
// Spezialknoten mit eigener Darstellung
if (data?.metadata?.functionName === 'FloatValue')
return <FloatValueFlowNode {...props} />;
if (data?.metadata?.functionName === 'Gearbox')
return <GearboxFlowNode {...props} />;
if (data?.metadata?.functionName === 'Iif')
return <IifFlowNode {...props} />;
if (data?.metadata?.functionName === 'Smooth')
return <SmoothFlowNode {...props} />;
// Generischer Fallback für alle anderen Funktionen
return (
<div className="bg-zinc-800 border border-zinc-700 rounded p-2">
<span>{data?.label}</span>
{/* Generische Parameter-Inputs */}
{params.map(e => <ParamInput key={e.name} name={e.name} value={e.value} />)}
{/* Input und Output Handles */}
{inputHandles}
{outputHandles}
</div>
);
}Der Clou ist der zweistufige Ansatz. Funktionen wie Smooth, Gearbox oder IIF haben spezialisierte Komponenten mit individueller Darstellung. Der SmoothFlowNode zeigt beispielsweise die aktuelle Übergangszeit, die Anzahl der Frames und eine Current-to-Target-Anzeige. Der GearboxFlowNode zeigt eine visuelle Ganganzeige.
Alle anderen Funktionen, wie Add, Multiply, Clamp oder Abs, fallen auf die generische Standarddarstellung zurück. Die zeigt den Funktionsnamen, die aktuellen Werte an den Ports und die konfigurierbaren Parameter. Das reicht für einfache mathematische Operationen vollkommen aus. Braucht eine Funktion eine spezielle Darstellung, schreibt man eine eigene Komponente und registriert sie im Dispatcher. Fertig.
Handles und Ports
ReactFlow arbeitet mit dem Konzept von Handles, also den Ankerpunkten an Knoten, an denen Kanten andocken. In SignalRC werden die Handles dynamisch aus der Funktionsdefinition generiert:
const inputHandles = inputs.map((label, index) => (
<Handle
key={label}
type="target"
position={Position.Top}
id={label}
className="bg-green-500"
style={{ left: `${100 / (inputs.length + 1) * (index + 1)}%` }}
>
<span className="text-[8px] text-green-300 font-mono">{label}</span>
</Handle>
));
const outputHandles = outputs.map((label, index) => (
<Handle
type="source"
position={Position.Bottom}
id={label}
className="bg-blue-500"
style={{ left: `${100 / (outputs.length + 1) * (index + 1)}%` }}
>
<span className="text-[8px] text-blue-300 font-mono">
{label}: {Number(outputValues[index]).toFixed(3)}
</span>
</Handle>
));Die Handle-IDs entsprechen den Port-Labels aus der Funktionsdefinition. Wenn ReactFlow eine Verbindung herstellt, übergibt es sourceHandle und targetHandle, die dann exakt den Port-Namen entsprechen.
Der Server speichert diese Port-Zuordnungen mit. So weiß das System beim Neuladen, dass bei einer IIF-Funktion der a-Eingang mit dem Lenkachsen-Ausgang verbunden ist und nicht mit dem b-Eingang.
Persistenz – der Server als Datenhaltung
Jede Änderung am Graphen wird sofort auf dem Server persistiert.
Knoten verschieben:
const onNodeDragStop: NodeDragHandler = async (event, node) => {
await fetch("/api/flow/movenode", {
method: "POST",
body: JSON.stringify({
NodeId: node.id, PositionX: node.position.x, PositionY: node.position.y,
}),
});
};Kanten verbinden:
const onConnect = useCallback((params: Connection) => {
flowControl.addEdge(params);
// Intern: POST /api/flow/link
}, [flowControl]);Kanten entfernen:
const onEdgeClick = useCallback((event, edge) => {
flowControl.removeEdge(Number(edge.id));
// Intern: DELETE /api/flow/unlink/{id}
}, [flowControl]);Parameter ändern:
async updateNodeParams(nodeId: number, params: Record<string, any>) {
await fetch(`/api/flow/${nodeId}/params`, {
method: 'POST',
body: JSON.stringify(params),
});
get().recalculateNode(nodeId);
}Jede Änderung wird sofort an den Server gesendet.
Der Server validiert bei Verbindungen die Richtung. Man kann nicht zu einem Input Node verbinden und nicht von einem Output Node aus. Pro Zielport ist zudem nur eine eingehende Verbindung erlaubt, eine bestehende wird beim Neuverbinden automatisch ersetzt.
Remote Gamepads und Sessionübergabe
Ein Gamepad steckt per USB oder Bluetooth an genau einem Rechner. Aber was, wenn man die Konfiguration am Desktop baut und dann auf dem PC im Wohnzimmer weiterfahren will? Oder wenn das Lenkrad am Flightseat-PC installiert ist?
In SignalRC ist das Gamepad nicht an einen bestimmten Browser gebunden. Man kann auch mehrere Browser in eine Session holen, sodass man die Eingabegeräte von verschiedenen Rechnern benutzen kann.
Für den Szenariowechsel zwischen Geräten gibt es den Session Transfer. Am aktuellen Gerät generiert man einen zeitlich begrenzten Transfercode, gibt ihn auf dem neuen Gerät ein, und die komplette Session, also Fahrzeugzuordnung, Setup und Authentifizierung, wird übernommen. Die Eingabegeräte werden jetzt auch als Remote-Geräte verwendet.
Technisch löst SignalRC das über den UserChannelHub. Wenn ein Input Node aktiv ist, aber kein lokales Gamepad erkannt wird, abonniert der Client automatisch den Kanal über SignalR:
async subscribeToInputNodes() {
const { userChannelConnection } = get();
for (const gp of Object.values(gamepadStore.knownGamepads)) {
if (!gp.connected && !subscribedChannels.has(gp.id)) {
await userChannelConnection.invoke("SubscribeToGamepad", gp.id);
}
}
}Lokale Gamepad-Werte werden über denselben Hub an andere Clients gebroadcastet:
handleInputUpdate(inputDbId: number, value: number, fromRemote: boolean = false) {
// Lokale Updates an andere Clients senden
if (!fromRemote) {
userChannelConnection.invoke("UpdateUserChannelValue", inputDbId, value);
}
// Wert im Graphen aktualisieren und Neuberechnung auslösen
inputNodes.forEach(inputNode => {
state.nodeLatestValues[inputNode.nodeId] = value;
get().recalculateNode(inputNode.nodeId);
});
}Das Gamepad-Symbol im UI zeigt den Verbindungsstatus: grün für lokal verbunden, gelb für Remote, rot für nicht verbunden.
Warum ReactFlow?
Die Wahl fiel aus mehreren Gründen auf ReactFlow:
- React-nativ: SignalRC nutzt Next.js mit React. ReactFlow integriert sich nahtlos, kein Framework-Bruch.
- Interaktivität out of the box: Drag and Drop, Zoom, Pan, Minimap, Controls – all das kommt im Lieferumfang.
- Custom Nodes: Die Möglichkeit, beliebige React-Komponenten als Knoten zu rendern, ist entscheidend. So kann jede Funktion ihre eigene, optimale Darstellung haben.
- Handle-basierte Verbindungen: Das Konzept von benannten Ein- und Ausgängen passt perfekt zum Signalverarbeitungsmodell.
- Community und Dokumentation: ReactFlow ist ein ausgereiftes Open-Source-Projekt mit aktiver Entwicklung.
Die Alternative wäre gewesen, das Mapping in einer Tabelle oder einem Formular zu konfigurieren. Das funktioniert, aber es fehlt die visuelle Übersicht. Bei einem komplexen Setup mit 8 Achsen, 16 Buttons und einem Dutzend Fahrzeugfunktionen plus Verarbeitungsknoten wird eine tabellarische Darstellung schnell zur Tortur.
Der Node-Editor macht die Datenflüsse sichtbar und greifbar. Man sieht sofort, was wohin geht und was auf dem Weg passiert.
Fazit
ReactFlow verwandelt ein abstraktes Signalverarbeitungsproblem in etwas, das man buchstäblich sehen und anfassen kann. Inputs oben, Outputs unten, Verarbeitung in der Mitte. Verbinden, parametrisieren, live testen.
Das FilterFunction-Interface sorgt dafür, dass neue Verarbeitungsfunktionen mit minimalem Aufwand hinzugefügt werden können. Klasse schreiben, in die Registry eintragen, fertig. Der generische Node-Renderer zeigt sie automatisch an. Die Persistenz über den Server stellt sicher, dass jede Konfiguration dauerhaft gespeichert wird. Das Remote-Gamepad-Feature ermöglicht es, mit mehreren Geräten gleichzeitig zu arbeiten.
Und das Schönste: Alles, was hier gebaut wurde, ist kein Einbahnstraßencode. Die Signalverarbeitungskette ist vollständig datengetrieben. Der Signalfluss wird nicht programmiert, er wird konfiguriert. Und genau das macht den Unterschied.
Was noch kommt: Telemetrie als Eingang
Aktuell fließen Daten im Signalfluss nur in eine Richtung: vom Gamepad über die Verarbeitungskette zum Fahrzeug. Aber das Fahrzeug sendet auch Daten zurück. Über den TelemetryHub kommen Werte wie Akkustand, Betriebszeit oder BMS-Daten in Echtzeit beim Client an.
Die Infrastruktur dafür existiert bereits: ITelemetryServer und ITelemetryClient definieren die Schnittstelle, der Onboard-Client liest die Sensoren zyklisch aus und schickt die Werte per SignalR an den Browser.
Was noch fehlt, ist die Brücke in den Signalfluss. Ein Telemetrie-Input-Node, der sich auf einen CarTelemetry-Kanal abonniert und dessen Wert als Eingang in den Graphen einspeist.
Damit ließe sich zum Beispiel die Maximalgeschwindigkeit automatisch drosseln, wenn der Akkustand unter 20 Prozent fällt. Oder ein Warnlicht aktivieren, wenn die Temperatur steigt.
Die Architektur des Signalfluss-Systems ist darauf vorbereitet. Ein neuer Knotentyp, ein neuer nodeTypeName, eine Subscription auf den Telemetrie-Hub, und die Werte fließen genauso durch die recalculateNode-Kette wie jeder Gamepad-Input.
Praxisbeispiel
Schaut auch gerne mal im konkreten Projekt auf GitHub vorbei.
[1] Georg Poweleit, SignalRC – in Echtzeit ans Steuer, dotnetpro 10-11/2025, Seite 46 ff.