SignalRC – in Echtzeit ans Steuer
Der DDC-Truck: Auf in die Welt mit SignalR, Raspberry Pi und sechs Rädern
Wie beginnt man eine Reise? Normalerweise am Bahnsteig oder Flughafenterminal. Das machen wir heute nicht. Heute beginnen wir am Linux-Terminal. Statt mit dem Rucksack durch fremde Länder zu ziehen, entdecken wir mit unserem Fahrzeug, das sich per LTE von überall aus fernsteuern lässt, die Welt – inklusive Livebild und Statusdaten. Der Maßstab mag klein sein, doch die Herausforderung ist groß. Wie klein? Uns reicht ein Maßstab 1:10, erst einmal. Die Idee dahinter ist simpel: Die Welt erkunden, ohne vor der Tür zu stehen.
Das klingt nach Spielzeug? Streng genommen ist das schon richtig. Doch so spielerisch das Ziel auch wirkt, die Anforderungen sind interessanter und herausfordernder, als man im ersten Moment denken mag. Das System muss in Echtzeit reagieren, zuverlässig auch über Mobilfunknetze funktionieren und dabei möglichst wenig Energie verbrauchen. Die Software soll auf einem Raspberry Pi laufen, mit möglichst wenig Overhead. Die Architektur basiert auf .NET, und im Zentrum steht SignalR. Warum ausgerechnet SignalR? Ist das nicht etwas für Webanwendungen?
In klassischen Webanwendungen zählt man selten die einzelnen Millisekunden. Doch bei einem ferngesteuerten Fahrzeug kommt es auf jede Verzögerung an. SignalR ermöglicht bidirektionale Kommunikation mit WebSocket-Unterstützung, Fallback-Strategien und automatischer Reconnect-Logik und ist damit ideal für alle mobilen Szenarien. Außerdem ist SignalR der Inbegriff für eine stabile, bidirektionale Kommunikation zwischen zwei Anwendungen im .NET-Universum. Also genau das, was wir brauchen, um ein Auto fernzusteuern.
Herausforderungen
Wer schon mal in Deutschland LTE benutzt hat, weiß, dass stabile Realtime-Kommunikation mit minimaler Latenz auf mobiler Hardware keine Selbstverständlichkeit ist. Weiterhin darf nicht vergessen werden, dass allein die Aufzeichnung und Umwandlung des Livestreams vom Fahrzeug den größten Teil der Kapazität eines Raspberry Pi und des Netzwerks auffrisst. Daher soll die Anwendung für die Steuerung aller Fahrzeugfunktionen möglichst ressourcensparend arbeiten. Ressourcensparend ist besonders wichtig im Batteriebetrieb, denn ein System, das den ganzen Tag im Feld stehen soll, darf den Akku nicht in zwei Stunden leer saugen. Was macht man nicht alles, um eine kleine digitale Mini-Weltreise möglich zu machen.
Aufbau
Auf einer echten Reise begegnet man sich nicht immer zur gleichen Zeit am gleichen Ort. Dieses Prinzip gilt auch für die Architektur unseres Projekts. Fahrzeug und Client sind voneinander entkoppelt, sie verbinden sich unabhängig voneinander mit dem Server. Das Fahrzeug stellt regelmäßig die Verbindung zum Server her, und das möglichst dauerhaft, sofern die LTE-Verbindung stabil bleibt. Es meldet sich beim SignalR-Hub an, überträgt seine ID und hält einen Kanal für eingehende Befehle offen. Sensorwerte, Akkustand oder Statusänderungen sendet es aktiv über denselben Kanal zurück.
Auf der anderen Seite verbindet sich der Client, eine Webanwendung, die ebenfalls per SignalR mit dem Server verbunden ist. Auch hier gilt: Es gibt keinen festen Zeitpunkt und keinen Zwang zur Gleichzeitigkeit, der Benutzer kann sich jederzeit einloggen. Man öffnet die Steueroberfläche im Browser und bekommt sofort eine Übersicht über die verfügbaren Fahrzeuge, angefangen mit denen, zu denen er bereits eine Verbindung hatte. Der Server fungiert als neutraler Treffpunkt. Er verwaltet alle aktiven Verbindungen und hält sie vor. Sobald beide Seiten verbunden sind, kann die Kommunikation beginnen. Der Client hat volle Kontrolle über das Fahrzeug, sofern er sich am Fahrzeug angemeldet hat und von diesem eine Session zurückbekommen hat. Aber selbst wenn das Fahrzeug gerade offline ist, bleibt die Steueroberfläche funktional: Der Server zeigt den letzten bekannten Zustand, informiert über Verbindungsabbrüche und bietet eine klare Statusanzeige, auf die wir später genauer eingehen. Um den Video-Stream zu übertragen, kommt Janus zum Einsatz. Das ist ein minimalistischer WebRTC-Server, der für diesen Artikel aber keine Rolle spielt. Bild 1 verdeutlicht das Schema der Architektur.
Architekturschema des Gesamtsystems (Bild 1)
AutorAbfahrt
Dieses Modell erlaubt maximale Flexibilität und lehnt sich an das Look and Feel eines Open-World-Spiels an. Wir starten in der Lobby: Die Fahrzeuge stehen bereit, laufen im Hintergrund und senden ihre Daten. Der Benutzer kann jederzeit „einsteigen“. Es ist wie beim Spieleklassiker GTA: Das Fahrzeug steht da, die Tür ist offen, der Motor läuft. Der einzige Unterschied: Ohne Schlüssel passiert nichts. Dieser Schlüssel ist die aktive Verbindung über SignalR und eine Session. Wer sich einloggt, bekommt Zugriff auf Steuerung und Feedback. Die Kontrolle wechselt in Echtzeit, ohne Neustart, ohne manuelle Kopplung. Alle anderen können die Fahrt im Spectator-Modus verfolgen, wenn sie möchten. Kontrolle wird aber immer nur von einem Benutzer ausgeübt, und der kann auch nur durch Time-out oder Log-out abgelöst werden. Clients steigen bei Bedarf ein, übernehmen die Steuerung und steigen auch wieder aus. Der Server sorgt dafür, dass dabei niemand dem anderen in Lenkrad greifen kann und jedes Kommando genau dort ankommt, wo es hinsoll. Einfach den Schlüssel, der im Fahrzeug konfiguriert ist, eingeben, und die Straße gehört dir. Wer komplexere Fahrzeuge hat, die mehrere Fahrer benötigen, kann den zweiten Platz als weitere Instanz laufen lassen, die sich dann als weiteres Fahrzeug am Server anmeldet. Nicht der häufigste Use Case, aber vielleicht lohnend zu erwähnen.
Der Server: Schaltzentrale mit SignalR
Im Zentrum des Systems steht der Server, ein ASP.NET Core Web API, das alle Kommunikationsströme zwischen Fahrzeugen und Steuer-Clients koordiniert. SignalR übernimmt dabei die Rolle der Transportebene: leichtgewichtig, bidirektional und echtzeitfähig. Die gesamte bisher beschriebene Logik basiert auf einem dedizierten Hub namens CarControlHub.
Ganz von vorne – Initialisierung von SignalR
Bereits beim Start der Serveranwendung wird SignalR in der Program.cs oder Startup.cs eingebunden. Die Konfiguration ist minimal, aber entscheidend; sie findet sich in Listing 1.
Listing 1: SignalR initialisieren in Program.cs
builder.Services.AddSignalR();
// Alle anderen Services
app.MapHub<CarConnectionHub>(HubPaths.CarConnectionHub);
app.MapHub<CarControlHub>(HubPaths.CarControlHub);
app.MapHub<TelemetryHub>(HubPaths.TelemetryHub);
// Alle anderen Hubs
Der Hub wird im Routing definiert und unter einem festen Pfad verfügbar gemacht. Damit ist die Kommunikationsschnittstelle offen, sowohl für Fahrzeuge als auch für Webclients. In unserem Projekt gibt es natürlich mehr Hubs, die allerdings im Listing der Einfachheit halber fehlen.
Für mich hat es sich bewährt, die Pfade in Konstanten auszulagern. Bei der Konstellation unserer beteiligten Programme ist das Auslagern besonders lohnend, denn der Server und die Onboard-Software teilen sich eine Bibliothek. Auf diese Weise kann der Compiler schon mal checken, dass die Pfade passen. Damit werden keine Verbindungen versucht, die schon allein vom Mapping her nicht funktionieren können.
Der ControlHub als Vermittler
Der CarControlHub ist der zentrale Vermittler. Er verwaltet grundlegende Funktionen wie die Registrierung eines Fahrzeugs, das Anfordern einer Steuersitzung, die Freigabe dieser Kontrolle sowie die Kanalübertragung in Echtzeit. Eine gekürzte Version des Hubs findet sich in Listing 2.
Listing 2: Zentraler Verteiler
public interface ICarControlClient
{
Task<string> AquireCarControl(string carSecret);
Task ReleaseCarControl(string sessionId);
Task UpdateChannel(string sessionId, string channelId, decimal value);
}
public interface ICarControlServer
{
Task<string?> AquireCarControl(string carId, string carSecret);
Task ReleaseCarControl(string carId, string sessionId);
Task UpdateChannel(string carId, string sessionId, string channelId, decimal value);
Task RegisterForControl(string carId);
}
public class CarControlHub : Hub<ICarControlClient>, ICarControlServer
{
public async Task RegisterForControl(string carId)
{
// Fahrzeug meldet sich an
}
public async Task<string?> AquireCarControl(string carId, string? carSecret)
{
// Steuersitzung anfordern
return await Clients.Client(carClientId).AquireCarControl(carSecret);
}
public async Task ReleaseCarControl(string carId, string sessionId)
{
// Steuersitzung beenden
await Clients.Client(carClientId).ReleaseCarControl(sessionId);
}
public async Task UpdateChannel(string carId, string sessionId, string channelId, decimal value)
{
// Steuerdaten senden
await Clients.Client(carClientId).UpdateChannel(sessionId, channelId, value);
}
}
Zu beachten sind die beiden Interfaces. Der generische Parameter ICarControlClient gibt an welche Funktionen der Server beim Client aufrufen kann. Andersherum wird das Interface: ICarControlServer vom Hub implementiert. Das stellt sicher, dass der Client die entsprechenden Funktionen des Hubs aufrufen kann. Dazu gleich mehr, schauen wir uns zuvor erst den Code von CarControlHub an.
Das Herz des Fahrzeugs: Der Onboard-Client
Sobald das Fahrzeug startet, nimmt es Kontakt mit dem Server auf. Es meldet sich an, stellt seine verfügbaren Steuerkanäle bereit und wartet auf Kommandos. Die Verbindung erfolgt ebenfalls über SignalR, also derselben Technologie wie auf der Serverseite, was Sinn ergibt, denn schließlich müssen sich beide verstehen. Auf dem Onboard-Teil allerdings in Form eines Hub-Clients, der aktiv einen WebSocket-Kanal zum Server aufbaut. Die Initialisierung beginnt mit dem Aufbau der Verbindung. Dazu wird in der Klasse ServerConnectionService ein HubConnection-Objekt erzeugt. Der Ziel-URL wird dynamisch auf Basis der Konfiguration generiert, einschließlich Hostname, Port und Protokoll. Die Verbindung nutzt eine automatische Wiederverbindung mit exponentiellem Backoff, um auch bei instabiler LTE-Verbindung zuverlässig online zu bleiben. Wichtig hierbei: Ist der Server bereits eine Weile nicht erreichbar, nehmen die Verbindungsversuche ab. Das spart ein wenig Strom. Nach erfolgreichem Verbindungsaufbau ruft das Fahrzeug aktiv die Methode OpenCarConnection auf. Diese Übergabe enthält neben der Fahrzeug-ID auch eine Prüfsumme der lokalen Kanaldefinition, quasi ein Fingerabdruck der Steuerkanäle im Fahrzeug. Den Verbindungsaufbau und die Anmeldung zeigt Listing 3.
Listing 3: Als wäre der Hub eine lokale Klasse
_connection = new HubConnectionBuilder()
.WithUrl(connectionHubEndpoint)
.WithAutomaticReconnect(Enumerable.Range(0, 50)
.Select(e => TimeSpan.FromMilliseconds(Math.Pow(1.25d, e) * 1000))
.ToArray())
.AddMessagePackProtocol()
.Build();
// Hub Client Callbacks auf aktuelle Instanz mappen
_connection.Register<ICarControlClient>(this);
await _connection.StartAsync();
// Instanz für Hub Aufrufe erstellen
_server = _connection.CreateHubProxy<ICarControlServer>();
// Am Hub anmelden, damit er weiß welches Fahrzeug online gekommen ist
var carId = Configuration.GetValue<string>("carId");
await _server.RegisterForControl(carId);
var channelMapHash = ChannelMapHashProvider.GenerateHash(_channelMap);
var config = await connectionServer.OpenCarConnection(carId, channelMapHash);
TypedSignalR: Starke Typisierung, weniger Fehler
Für die Kommunikation zwischen Server und Client wird TypedSignalR verwendet. Das ist eine Erweiterung, die stark typisierte Schnittstellen zwischen beiden Seiten ermöglicht. Statt auf lose InvokeAsync("MethodName", ...)-Aufrufe zu setzen, werden feste Interfaces definiert, wie in Listing 2 zu sehen ist. Dadurch entstehen auf beiden Seiten Klarheit und Typsicherheit über die erlaubten Methoden. Zugleich greift dadurch das Fail-Fast-Prinzip: Fehler in der Schnittstellendefinition fallen bereits dem Compiler auf und nicht erst zur Laufzeit. Außerdem profitieren Entwickler von vollständigem IntelliSense-Support. Die Methoden, Parameter und Rückgabewerte sind sofort sichtbar und dokumentierbar. Das beschleunigt die Entwicklung und reduziert typische Fehlerquellen wie Tippfehler oder falsche Argumente. Der Client erzeugt zur Laufzeit einen typisierten Proxy, der entsprechend alle Funktionen des Interfaces, das der Hub implementiert, enthält. Listing 3 zeigt, wie einfach eine Server-Instanz aus der HubConnection erzeugt werden kann.
Das Interface ICarConnectionServer definiert exakt, welche Methoden das Fahrzeug beim Server aufrufen darf. Der Server kennt im Gegenzug den Client mit dem entsprechendem Client-Interface ICarConnectionClient, um Kommandos an das Fahrzeug zu senden. Der Client registriert sich aktiv als solcher beim Hub, indem er die Service-Instanz an die Server-Events bindet. So entsteht eine klar definierte, typisierte Zwei-Wege-Kommunikation, die sicher und wartbar ist. Die Interfaces werden in einer eigenen Shared-Bibliothek gehalten, sodass abweichende Definitionen nicht passieren können. Der Compiler klärt auch hier direkt über Fehler auf.
Channel-Abgleich: Kontrolle mit Vertrag
Beim ersten Verbindungsaufbau wird die Kanalstruktur des Fahrzeugs in Form eines Hashwerts an den Server übergeben. Der Server prüft, ob dieser mit dem zuletzt bekannten Kanalplan übereinstimmt. Falls nicht, fordert er eine Aktualisierung an. Damit ist sichergestellt, dass der Server stets über die aktuelle Steuerstruktur des Fahrzeugs informiert ist, ohne unnötig große Datenmengen zu übertragen. Änderungen an der Kanalstruktur, etwa durch neue Aktoren oder Sensoren, lassen sich so leicht erfassen und synchronisieren.
Anschließend übergibt das Fahrzeug seine Konfigurationsstruktur, die der Server speichert und für die Konfiguration verwendet. Diese enthält die Steuer- und Telemetrieinformationen, also welche Kanäle das Fahrzeug annehmen und welche Werte der Fahrer abonnieren kann. Sofern ein Kanal abonniert wird, sendet das Fahrzeug alle Updates an diesen Kanal. Sowohl Telemetrie als auch Kanäle für sämtliche Aktoren – also Lenkservos, Fahrtregler, Beleuchtung und vieles mehr – können zentral konfiguriert werden. Die Konfiguration übersetzt sich dann zur Laufzeit in Instanzen, die wie kleine Unterprogramme Informationen lesen oder Aktionen ausführen können. All dies erfolgt über eine einfache Schnittstelle, bestehend aus dem Namen des jeweiligen Aktors sowie seinem neuen Wert. Alle übrigen Informationen wurden zu Beginn bereits ausgehandelt und müssen nicht mehr übertragen werden.
Zellenwechsel
Damit die Steuerung des Fahrzeugs auch bei temporären LTE-Ausfällen oder IP-Adress-Wechseln zuverlässig funktioniert, implementiert der ControlService eine robuste Wiederverbindungs-Logik mit TypedSignalR. Sobald ein Nutzer mittels AquireCarControl die Steuerung des Fahrzeugs übernimmt, wird auf Basis von ShortGuid eine eindeutige Session-ID generiert. Sie ist kompakt, aber zufällig, denn auch hier gilt es Datensparsamkeit zu beachten. Diese Session-ID dient als Schlüssel zur Authentifizierung bei jeder Steueraktion. Wird die Verbindung zwischenzeitlich getrennt, bleibt die Session weiter bestehen, solange kein Time-out oder aktives ReleaseCarControl erfolgt.
Ein weiteres zentrales Feature ist die Implementierung von IHubConnectionObserver, einer Schnittstelle aus TypedSignalR. Sie stellt drei Events bereit:
- OnReconnecting(Exception?): Wird ausgelöst, wenn die Verbindung verloren geht. In diesem Moment werden alle Kanäle des Fahrzeugs auf default zurückgestellt (Control.ReleaseControl()), um nicht den letzten Status vor dem Verbindungsabbruch zu behalten, denn das könnte im Zweifel geradeaus Vollgas sein. Auf diese Weise kommt das Auto zumindest zum Stehen.
- OnReconnected(string?): Wird nach erfolgreichem Reconnect aufgerufen. Der Client meldet sich mit RegisterForControl automatisch wieder beim Server an, sodass der Server die Fahrzeugverbindung erneut zuordnen kann. Gleichzeitig wird der Telemetrie-Service informiert, dass die Verbindung wieder aktiv ist.
- OnClosed(Exception?): Signalisiert, dass die Verbindung endgültig getrennt wurde, zum Beispiel nach einem längeren Ausfall. Auch hier wird die Steuerung zurückgesetzt und die Trennung dokumentiert.
Telemetrie
Neben der Steuerung muss das Fahrzeug auch Rückmeldung über seinen Zustand beziehungsweise den seiner einzelnen Komponenten geben. Die Summe dieser Rückmeldungen bilden die Telemetrie, also Messwerte, die regelmäßig oder bei Bedarf an den Server gesendet werden. Jede Telemetriequelle ist als eigene Reader-Klasse implementiert, die von TelemetryReaderBase erbt und über einen Ticker regelmäßig ausgeführt wird. Der TelemetryService koordiniert alle aktiven Reader, verwaltet deren Intervalle und sendet die Messwerte über SignalR an den Server. Der Ablauf folgt einem klaren festgelegten Schema: lesen, loggen, übertragen.
Die Kommunikation läuft dabei über einen separaten SignalR-Hub (TelemetryHub), zu dem das Fahrzeug beim Start seine eigene Verbindung aufbaut. Das bedeutet zugegebenermaßen ein wenig Mehrarbeit, ist das (kleine) Opfer für die „Separation of Concerns“ aber wert. Die Telemetriekanäle selbst sind auch in der ChannelMap definiert und lassen sich dynamisch aktivieren und deaktivieren. Der Server kann dazu gezielt Kanäle abonnieren oder abwählen, und der Onboard-Client erstellt dafür passende Reader-Instanzen mithilfe des Service Providers. Innerhalb der Anwendung läuft eine Service Loop, die den Telemetrie-Readern einen sogenannten Tick zukommen lässt. Diese Loop zeigt Listing 4.
Listing 4: Einen Tick bereitstellen
await Task.Run(async () =>
{
var telemetryService = serviceProvider.GetRequiredService<TelemetryService>();
while (true)
{
await Task.WhenAll(telemetryService.Tick(), Task.Delay(100));
}
});
Der Einfachheit halber – und um das System nicht mit Telemetriedaten zu fluten – soll immer mindestens 100 ms zwischen den Ticks gewartet werden, denn kaum eine Telemetrie ist so zeitkritisch, dass sie das Risiko wert ist, die Video- oder Kontrollverbindung zu verstopfen. Einige Beispiele:
- Batterieladestand ändert sich um 1% alle ~1–10 Minuten (600 Ticks)
- GPS-Position 1-mal pro Sekunde (10 Ticks)
- LTE-Empfang ändert sich alle 50 m, also alle ~5 Sekunden (50 Ticks)
- Ausnahmen: Distanzmesser, Aktueller Stromverbrauch, Gyroskop wenn vorhanden
Außerdem werden die Werte nur dann gesendet, wenn sie sich auch innerhalb ihrer festgelegten Genauigkeit geändert haben.
MessagePack: Straff gepackte Pakete
Wir haben gesehen, was alles übertragen werden soll. Kommen wir nun zum „Wie“. Für die Serialisierung der Kommunikationsdaten zwischen Fahrzeug und Server wird MessagePack verwendet. Dabei handelt es sich um ein binäres Format zur Darstellung strukturierter Objekte. Man kann es gut mit JSON vergleichen. Es ist nur deutlich kompakter und schneller. Anders als bei textbasierten Formaten wird jedes Feld typisiert und direkt binär codiert, was nicht nur die Übertragungsgröße, sondern auch die CPU-Last reduziert. Gerade bei Echtzeitkommunikation über LTE, wo Latenz und Paketgröße entscheidend sind, spielt MessagePack seine Stärken voll aus. Weiterhin lässt sich MessagePack besonders elegant in .NET einsetzen. Die Bibliothek ist tief in das ASP.NET-Ökosystem integriert, sodass einfache POCO-Klassen ohne weitere Konfiguration serialisiert werden können.
Noch mehr straffen
MessagePack punktet in diesem Projekt besonders, weil massenhaft float-Werte gesendet werden. Hier gewinnt MessagePack bereits nach zwei Nachkommastellen. Um jedoch maximale Effizienz zu erreichen, kann auf die Attribute MessagePackObjectAttribute und KeyAttribute zurückgegriffen werden. Letzteres erlaubt den Einsatz numerischer Schlüssel anstelle von Property-Namen. Das spart weiteres Übertragungsvolumen und sorgt für ein deterministisches Verhalten beim Serialisieren. Auch wenn das zwischen zwei .NET-Anwendungen durch die Typisierung nicht zwingend erforderlich ist, ist es genauso unnötig, den Property-Namen in jeder Nachricht noch einmal zu senden. Ist man also an dem Punkt angekommen, an dem man über MessagePack nachdenkt, kann man auch diese Attribute verwenden. Diese machen zwar den Code länger, dafür aber die gesendeten Nachrichten deutlich kürzer. Ich behaupte, dass es keinen Fall mit einem .NET-Client gibt, in dem man die Attribute weglassen sollte. Denn offenbar überwiegt das Interesse an Übertragungsoptimierung schon bei der Entscheidung für MessagePack. Listing 5 zeigt ein Beispiel für eine stark optimierte Struktur und Listing 6 den Unterschied in der Darstellung.
Listing 5: Kompakte Datenstruktur mit MessagePackObject
[MessagePackObject]
public class ChannelUpdate
{
[Key(0)]
public string ChannelId { get; set; }
[Key(1)]
public float Value { get; set; }
}
Listing 6: Vergleich in einem kleinen Modell
//JSON
{"ChannelId":"steering","Value":0.850000}
// MassagePack
82 // Map mit 2 Einträgen
00 // Key 0 (ChannelId)
A8 73 74 65 65 72 69 6E 67 // "steering" (8 Bytes String)
01 // Key 1 (Value)
CA 3F EB 33 33 // float32 für 0.850000 (5 Bytes inkl. Typ-Byte)
// 42 vs 17 bytes
Der Vergleich aus Listing 6 klingt zunächst nach einem geringen Unterschied. Hält man sich allerdings vor Augen, dass etwa 15 bis 20 Nachrichten pro Sekunde über den Stream gehen, und das auch noch via LTE, summiert sich das zu einem erheblichen Wert. Nun mag man einwenden: „Neben dem Video macht das doch aber keinen wirklichen Unterschied!“ Doch! Den macht es. Es geht um eine Differenz von 17 MByte pro Stunde allein für die Steuerbefehle, unter Berücksichtigung nur eines Channels mit lediglich zehn Zeilen Code. Weiterhin ist das Beispiel das mit Abstand kleinste Objekt, das in dem Projekt gesendet wird. Bei den Datenvolumenkosten in Deutschland ist die Einsparung schon eine Überlegung wert, und gleichzeitig veranschaulicht sie, wie effizient MessagePack wirken kann. Merklicher wird der Unterschied beispielsweise bei der Antwort für den Kanalabgleich an den Server. Ich erspare euch die Seite, die der JSON-MessagePack-Vergleich ausgeschrieben umfassen würde. Der Unterschied beim Abgleich beträgt 761 zu 417 Bytes, also nur etwas mehr als die Hälfte oder 54,79632063 Prozent, um genau zu sein.
Grandios! Das Ende für JSON?
Auch in TypeScript ist die Verwendung von MessagePack grundsätzlich möglich, bringt jedoch einige Einschränkungen mit sich. Die JavaScript-Runtime kennt keine echten Typinformationen, weshalb die Struktur der Daten strikt manuell eingehalten werden muss. Fehler bei der Reihenfolge oder Typen der Felder führen dort erst zur Laufzeit zu Problemen, was die Fehlersuche entsprechend erschwert. Zudem ist MessagePack nicht wirklich menschenlesbar, außer natürlich für all diejenigen, die mit dem Hex-Editor aufgewachsen sind. Dieser Mangel erschwert das Debugging und Logging ungemein. Tools oder Hilfsfunktionen zur Deserialisierung sind daher notwendig, aber auch verfügbar. Trotzdem überwiegen für unser Projekt die Vorteile. Der reduzierte Overhead, die besonders kleinen Transportvolumina und die exzellente .NET-Integration machen MessagePack zur idealen Wahl, insbesondere für mobile, latenzkritische Anwendungen wie unser ferngesteuertes Fahrzeug.
Weitere Optimierungen
Das gesamte Protokoll lässt sich allerdings auch noch weiter straffen, wenn man komplett auf die „Magic Strings“ verzichtet, denn im Grundsatz kennen sowohl der Server als auch das Fahrzeug die Namen der Kanäle. Das bedeutet, dass eine weitere Verkleinerung der Pakete lediglich eine Antwort auf den Kanalabgleich entfernt wäre, damit auch das Auto die IDs, die der Server für die Kanäle nutzt, kennt. Der Haken daran: Das Nachverfolgen der Nachrichten auf dem Transportweg ist dann aber so gut wie gar nicht mehr möglich.
Cockpit und Maschinenraum
Wie bereits erwähnt, wäre die viele Kommunikation ohne eine Oberfläche, die der Fahrer nutzen kann, relativ witzlos. Daher gibt es ein UI mit lediglich zwei Ansichten – dem Cockpit und dem Maschinenraum. Das Cockpit ist die Ansicht, die während der Fahrt genutzt wird. Sie zeigt den Videostream, die Eingaben vom Lenkrad beziehungsweise Controller sowie die Telemetriedaten an. Weiterhin findet hier die Anmeldung am Fahrzeug statt. Alle Daten werden unverzüglich geupdatet und anhand der Konfiguration im Maschinenraum an das ausgewählte Fahrzeug übertragen (siehe Bild 2).
Die Ansicht des Cockpits (Bild 2)
AutorDer Maschinenraum bietet die Möglichkeit, die Kanäle des Lenkrads und die des Fahrzeugs zusammenzubringen. Dabei erlaubt er es nicht nur, Key Bindings zu erstellen, sondern ermöglicht es über einen Flow auch, das Verhalten der Inputs anzupassen. So ist der Server darauf vorbereitet, unterschiedliche Fahrzeuge zu unterstützen, die vor Funktionen nur so strotzen: Von einem simplen Remote-Auto mit lediglich Gas und Lenkung über einen Remote-Lkw mit zwei Achsen, zwei Blinkern und Abblendlicht bis hin zu einem Remote-Bagger mit zwei Ketten, zwei Armteilen, dreh- und schwenkbarer Kamera, einer Drehachse, ausfahrbaren Stützen und Beleuchtung an allen beweglichen Teilen. Kein Problem! Wenn die Buttons am Lenkrad nicht reichen, kann auch noch die Tastatur herhalten.
Die Kanäle sind dabei auf Werte aus dem float-Wertebereich beschränkt, was von einer normalen RC-Hardware-Fernbedienung abgekupfert ist. Demnach müssen Kanäle die 2D-Daten liefern und entsprechend auch zwei Kanäle aufmachen. Das gilt sowohl für Telemetrie- als auch für die Steuerkanäle. Bild 3 zeigt, wie die Konfiguration vonstattengeht.
Blick in den MaschinAutorenraum (Bild 3)
AutorHier findet sich das UI, das es uns erlaubt, mit React Flow eine komplexe Steuerung zu erstellen. Alle Flows werden dabei direkt im Browser ausgeführt, um auch hier wieder Daten zu sparen und den Raspberry Pi zu entlasten. Alle Inputs – auch die, die als Telemetriewerte angekommen sind – stehen damit dem Benutzer zur Verfügung. So kann man über einige Funktionsblöcke einfach die Eingaben anpassen, um beispielsweise die Steuerung zu kalibrieren oder zu invertieren. Ein Flow startet dabei immer mit einem Input. Ändert sich ein Inputwert eines Blocks, wird er entsprechend neu berechnet. Dieser Flow setzt sich über alle Verbindungen fort, bis entweder ein Output-Node kommt, der dann seinen Wert direkt an das Fahrzeug sendet, oder sich der Ergebniswert nicht mehr ändert. Ein Output kann dabei mit mehreren Inputs verbunden werden. Die Verzweigungen können hier beliebig groß werden.
Zusammengesteckt
Erst durch das Zusammenspiel aller Komponenten entsteht aus dem Projekt ein vollständiges, fernsteuerbares System. Der Server bildet mit SignalR das Rückgrat der Kommunikation, nimmt Verbindungen entgegen, koordiniert Steuerbefehle und verwaltet die Fahrzeug-Clients. Der ControlHub vermittelt dazu gezielt, stabil und latenzarm zwischen Fahrer und Fahrzeug. Der Client auf dem Fahrzeug verwaltet über den ServerConnectionService die Verbindung, regelt den Kanalabgleich und setzt Steuerkommandos, die sofort vom Fahrzeug ausgeführt werden, direkt ab. Gleichzeitig liefert der TelemetryService alle wichtigen Statusdaten zurück. Durch die Verwendung typisierter Schnittstellen und die effiziente Datenübertragung entsteht eine Architektur, die sowohl robust als auch leichtgewichtig ist. Sie erlaubt es, mehrere Fahrzeuge parallel zu betreiben, Zustände live zu überwachen, Steuerverbindungen dynamisch zuzuweisen und selbst bei schwankender Netzqualität reaktiv zu bleiben.
Damit ist der Weg frei für das eigentliche Ziel: Ein Fahrzeug zu bauen, das sich von überall auf der Welt präzise steuern lässt, mit Echtzeitverhalten, Sichtkontakt per Video und voller Kontrolle über alles, was unter der Haube passiert.
Keine Auto-Fans? Realtime geht auch ohne Räder!
Wer mit Autos nicht so viel anfangen kann, muss das System trotzdem nicht links liegen lassen. Denn die Architektur, die hier für ein ferngesteuertes Fahrzeug entwickelt wurde, lässt sich eins zu eins auf andere Szenarien übertragen. Die Trennung von Steuerung, Rückmeldung und zentraler Koordination ist ebenso universell einsetzbar wie die Anforderungen an Echtzeitkommunikation, Zuverlässigkeit und Skalierbarkeit. Gleiches gilt für die einzelnen Softwarebausteine: Ob es sich um Smart-Home-Komponenten, Industrieroboter, Drohnen (Achtung: Latenzanforderung noch höher und niemals ohne Spotter, da sonst Ärger droht!), Fernwartung von Maschinen oder interaktive Dashboards handelt, spielt keine Rolle. Denn sobald ein Gerät auf Kommandos reagieren und gleichzeitig Statusdaten liefern soll, kann diese Architektur eine Lösung bieten. SignalR übernimmt dabei weitestgehend die Rolle des Echtzeitkanals, TypedSignalR bringt Ordnung und Typensicherheit hinein, und MessagePack sorgt dafür, dass selbst auf schmaler Leitung schnelle Kommunikation möglich ist. Was bleibt, ist das Grundmuster:
- Eine klar definierte Steuerverbindung,
- eine asynchrone Telemetrie-Schnittstelle,
- ein leichtgewichtiges, loses Kopplungskonzept.
Das Projekt mag mit einem kleinen Fahrzeug begonnen haben, aber es endet erst, wo euch eure Vorstellungskraft verlässt. Wer die Bausteine kennt, kann damit alles bewegen.
Schon Schluss? Und nun?
Wer mit mir um die Welt fahren möchte, fühle sich dazu eingeladen, das Repository zum Artikel auf GitHub zu klonen und dabei zu sein. Contributions sind jederzeit willkommen, denn zusammen zu entwickeln macht immer noch mehr Spaß als allein. Lasst mich gerne wissen, wenn ihr an etwas Eigenem bastelt. Wer der weiteren Entwicklung folgen möchte, findet unter www.developer-world.de/dwx-insights unter dem Stichwort „DDC-Truck“ weitere Beiträge zu diesem Projekt.
- Herausforderungen
- Aufbau
- Abfahrt
- Der Server: Schaltzentrale mit SignalR
- Ganz von vorne – Initialisierung von SignalR
- Der ControlHub als Vermittler
- Das Herz des Fahrzeugs: Der Onboard-Client
- TypedSignalR: Starke Typisierung, weniger Fehler
- Channel-Abgleich: Kontrolle mit Vertrag
- Zellenwechsel
- Telemetrie
- MessagePack: Straff gepackte Pakete
- Noch mehr straffen
- Grandios! Das Ende für JSON?
- Weitere Optimierungen
- Cockpit und Maschinenraum
- Zusammengesteckt
- Keine Auto-Fans? Realtime geht auch ohne Räder!
- Schon Schluss? Und nun?