LoRaWAN-Geofencing mit .NET 10 und Azure: PoC für eine sicherheitskritische Tracking-Plattform

LoRaWAN-Geofencing mit .NET 10 und Azure für sicherheitskritisches Pferde-Tracking.
Tibor Csizmadia | CEO & Founder
05/2026

TL;DR

Ein zweiwöchiger PoC zeigt, dass sich nächtliches Pferde-Tracking mit LoRaWAN und Azure zuverlässig umsetzen lässt. Die Lösung kombiniert Geofencing, automatische Alarmierung per SMS und Sprachanruf sowie eine moderne .NET-10-Architektur. Alarme werden innerhalb von 4–6 Minuten ausgelöst, während Hysterese- und Qualitätsfilter Falschalarme reduzieren.

Kontext: Warum LoRaWAN, warum Geofencing, warum nachts?

Kurzer Aufschlag: Stallbetreiber haftet nachts für fremde Pferde, GPS-Halsbänder mit Mobilfunk sind zu energiehungrig für monatelangen Betrieb, also LoRaWAN-Klasse-A-Tracker mit 2-Minuten-Movement-Profil. Geschäftsanforderung: Alarm in unter 15 Minuten bei Ausbruch, Falschalarme drastisch reduzieren, wahrnehmungssicher alarmieren (SMS reicht nicht – der Empfänger schläft).

Ableitung: ein Tech-Stack-Problem, kein Hardware-Problem. Die spannende Frage war nicht „geht LoRaWAN“, sondern „wie modelliere ich Hysterese, Eskalation und Mandantengrenze sauber in .NET ab“.

Architekturüberblick

[T1000] --LoRaWAN--> [Gateway] --MQTT--> [The Things Stack]
                                                 |
                                                 v
                                         [Azure IoT Hub]
                                                 |
                                                 v
                                         [Azure Function: Ingest]
                                          |              |
                                          v              v
                                       [SQL]    [ACS SMS + Voice]
                                          ^              ^
                                          |              |
                                  [Blazor Server] --> [Minimal API]
                                          |
                                          v
                                 [Entra External ID (OIDC)]

Devware-Hausstack, ein paar bewusste Abweichungen:

  • SQL Server statt Cosmos (ADR revidiert): Migration-Pfad, InMemory-Tests, Devware-Tooling.
  • Geofence in-process statt Azure Maps Geofence Service: Latenz und Vendor-Lock-in.
  • Eigene HttpClient-Proxies statt Refit: keine Attribute-Magie, pro Endpoint eine sichtbare Methode.

Lokales Setup: ein dotnet run startet die ganze Welt

.NET Aspire orchestriert SQL Server, Event-Hubs-Emulator, API, Web und Ingest-Function. Kein lokales docker compose, kein manuelles Connection-String-Hin-und-Her.

1var builder = DistributedApplication.CreateBuilder(args);
2
3var sql = builder.AddSqlServer("sql", port: 14331)
4    .WithLifetime(ContainerLifetime.Persistent);
5
6var horsetracker = sql.AddDatabase("horsetracker");
7
8// IoT-Hub-kompatibles Event-Hubs-Backend, lokal als Emulator-Container.
9var eventhubs = builder.AddAzureEventHubs("eventhubs").RunAsEmulator();
10var uplinks   = eventhubs.AddHub("uplinks");
11
12var web = builder.AddProject<Projects.Web>("web")
13    .WithReference(horsetracker)
14    .WaitFor(horsetracker);
15
16var api = builder.AddProject<Projects.Api>("api")
17    .WithReference(horsetracker)
18    .WithEnvironment("App__WebBaseUrl", web.GetEndpoint("https"))
19    .WaitFor(horsetracker);
20
21web.WithReference(api);
22
23builder.AddAzureFunctionsProject<Projects.Ingest>("ingest")
24    .WithReference(horsetracker)
25    .WithReference(eventhubs)
26    .WaitFor(uplinks);
27
28builder.Build().Run();

Spannend dabei: Derselbe Code läuft in der Pipeline gegen Azure-Ressourcen, wenn man RunAsEmulator() weglässt. Aspire wird im Cloud-Run nicht deployt – er ist reines Dev-Tooling. Im Azure-App-Service laufen API, Web und Function standalone.

Domain-Modell: Geofence mit Hysterese als reines C#

Der spannendste Teil im Domain-Layer ist nicht der Punkt-in-Polygon-Test, sondern die Distanz zur Polygon-Kante: Nur damit lässt sich der 20-m-Hysterese-Buffer sauber gegen GPS-Drift verteidigen, ohne die Polygon-Geometrie selbst aufzublasen.

1public sealed record GeoPolygon
2{
3    public IReadOnlyList<GeoPoint> Vertices { get; }
4
5    private GeoPolygon(IReadOnlyList<GeoPoint> v) => Vertices = v;
6
7    public static GeoPolygon Create(IEnumerable<GeoPoint> vertices)
8    {
9        var list = vertices.ToList();
10
11        if (list.Count < 3)
12            throw new ArgumentException("Polygon braucht mindestens 3 Vertices.");
13
14        if (!list[0].Equals(list[^1]))
15            list.Add(list[0]); // Ring schließen
16
17        return new GeoPolygon(list);
18    }
19
20    // Klassisches Ray-Casting. Reicht für Koppel-Größen vollkommen aus.
21    public bool Contains(GeoPoint p)
22    {
23        var inside = false;
24
25        for (int i = 0, j = Vertices.Count - 1; i < Vertices.Count; j = i++)
26        {
27            var vi = Vertices[i];
28            var vj = Vertices[j];
29
30            var intersect = (vi.Longitude > p.Longitude) != (vj.Longitude > p.Longitude)
31                && p.Latitude < (vj.Latitude - vi.Latitude) * (p.Longitude - vi.Longitude)
32                    / (vj.Longitude - vi.Longitude) + vi.Latitude;
33
34            if (intersect)
35                inside = !inside;
36        }
37
38        return inside;
39    }
40
41    // Kürzeste Distanz Punkt-zu-Kante in Metern (lokal-äquatoriale Näherung).
42    // Wird vom Hysterese-Check verwendet: "draußen + > BufferMeters" = echter Breakout.
43    public double DistanceToEdgeMeters(GeoPoint point) { /* ... */ }
44}

Die Geschäftsregel sitzt einen Layer höher im Paddock-Aggregate:

1public BreakoutVerdict Evaluate(GeoPoint pos, double hdop, int consecutiveOutside)
2{
3    if (hdop > MaxHdop)         return BreakoutVerdict.IgnoreLowQuality;
4    if (Polygon.Contains(pos))  return BreakoutVerdict.Inside;
5
6    var outside = Polygon.DistanceToEdgeMeters(pos);
7
8    if (outside <= HysteresisBufferMeters && consecutiveOutside < DwellUplinkCount)
9        return BreakoutVerdict.PendingHysteresis;
10
11    return BreakoutVerdict.Breakout;
12}

Drei Falschalarm-Dämpfer auf einer Stelle: HDOP-Filter, Buffer, Dwell. Alle drei sind pro Koppel konfigurierbar, keine Magic Numbers im Code.

Application-Slice: CQRS via MediatR, FluentValidation, Result-Pattern

Devware-Standard-Pattern. Eine Slice = ein Ordner mit Command + Validator + Handler. Die Mandantengrenze ist nicht im Handler-Body verteilt, sondern als ITenantContext-Aufruf an einer Stelle.

1public sealed record CreatePaddockCommand(string Name, IReadOnlyList<GeoPointDto> Polygon)
2    : IRequest<Result<PaddockDto>>;
3
4public sealed class CreatePaddockValidator : AbstractValidator<CreatePaddockCommand>
5{
6    public CreatePaddockValidator()
7    {
8        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
9
10        RuleFor(x => x.Polygon).NotNull()
11            .Must(p => p.Count >= 3).WithMessage("Polygon braucht mindestens 3 Vertices.");
12    }
13}
14
15public sealed class CreatePaddockHandler(IPaddockRepository repo, ITenantContext tenant)
16    : IRequestHandler<CreatePaddockCommand, Result<PaddockDto>>
17{
18    public async Task<Result<PaddockDto>> Handle(CreatePaddockCommand cmd, CancellationToken ct)
19    {
20        if (tenant.StableId is not Guid stableId)
21            return Result<PaddockDto>.Failure(Error.Unauthorized("Kein Stable-Kontext."));
22
23        var polygon = GeoPolygon.Create(
24            cmd.Polygon.Select(p => GeoPoint.Create(p.Latitude, p.Longitude)));
25
26        var paddock = Paddock.Create(stableId, cmd.Name, polygon);
27
28        await repo.AddAsync(stableId, paddock, ct);
29
30        return Result<PaddockDto>.Success(paddock.ToDto());
31    }
32}

Das Result<T> ist zugleich Wire-Envelope: Der Minimal-API-Endpoint serialisiert { isSuccess, error, value } direkt, der Blazor-Client deserialisiert 1:1 und prüft IsSuccess/Error.Message, statt Exceptions zu fangen. Das macht den MAUI-Client in Phase 2 trivial – derselbe DTO, dasselbe Pattern.

Alarmkette: SMS, Voice mit TTS, DTMF-Quittierung

Die Eskalation läuft rollenbasiert über Azure Communication Services. SMS sofort an den Stallbetreiber, nach 2 Min. Sprachanruf mit Text-to-Speech, nach 5 Min. Eskalation an den Pferdehalter, nach 8 Min. an die Vertretung. Quittierung per DTMF „1“ im Anruf, per Magic-Link in der SMS oder per Klick in der WebApp.

Das Pattern für den Voice-Call sieht in Azure.Communication.CallAutomation vereinfacht so aus:

public async Task RaiseVoiceAsync(AlarmEscalation step, CancellationToken ct)
{
    var callee = new PhoneNumberIdentifier(step.RecipientE164);
    var caller = new PhoneNumberIdentifier(_options.FromPhoneE164);

    var callbackUri = new Uri($"{_options.CallbackBaseUrl}/acs/call?alarmId={step.AlarmId}");
    var invite = new CallInvite(callee, caller);

    await _client.CreateCallAsync(invite, callbackUri, cancellationToken: ct);

    // Prompt + DTMF-Recognition werden im CallConnected-Callback gestartet.
}

Der eigentliche Trick liegt im Callback: Bei CallConnected startet ein kombiniertes Play + RecognizeDtmf mit der de-DE-KatjaNeural-Stimme. Drückt der Empfänger während der Ansage die 1, wird der Prompt sofort unterbrochen und der AcknowledgeAlarmCommand getriggert.

Wichtig für den Devware-Kontext: Die ACS-Kanäle sind als IAlarmChannel abstrahiert. Der Switch Alarm:Channel=Acs aktiviert sie; in Tests läuft ein NullChannel, der nur loggt. Keine Mocks von CallAutomationClient nötig.

Auth & Multi-Tenancy: drei Stolpersteine, die in einer Architektur-Skizze fehlen

Was im Architektur-Dokument als „Microsoft Entra External ID + Role-Claims“ steht, hatte in der Umsetzung drei nicht offensichtliche Hindernisse:

  1. sub ist pairwise pro Client-Id. Web-Token und API-Token desselben Users haben unterschiedliche sub-Werte. Lösung: TenantContext liest primär oid (tokenweit stabil), sub nur als Fallback.
  2. Entra External ID emittiert App-Roles nicht out of the box. Die Devware-Lösung: Eine IClaimsTransformation ergänzt nach JWT-Auth die Role-Claims aus der DB-Verknüpfung oid -> Contact -> (HorseOwner | StableOperator). Damit bleibt der Role-Check [Authorize(Roles = "StableOperator")] Standard-ASP.NET.
  3. MSAL Token-Cache verliert bei Aspire-Restart die Tokens. Lösung: AddDistributedSqlServerCache + persistente DataProtection-Keys. Auch lokal überlebt der Cache jetzt jedes Ctrl+C.

Diese drei Punkte kosten zusammen einen halben Tag, wenn man sie kennt – und mehrere Tage, wenn man sie nicht kennt.

Learnings

  • Aspire ist Dev-Tooling, kein Deployment-Modell. Spart trotzdem für einen 9-Projekt-PoC mit Container-Abhängigkeiten mehr Zeit als jede andere Einzelmaßnahme.
  • Hysterese gehört in die Domain, nicht in die Ingest-Funktion. Sonst landet die Geschäftsregel in einer Azure-Function-DLL und ist nicht testbar.
  • Result-Pattern als Wire-Format spart einen DTO-Mapper. Die Symmetrie API <-> Blazor <-> MAUI ist mehr wert als das bisschen ASP.NET-Idiomatik, das man dabei aufgibt.
  • Refit war überflüssig. Pro Endpoint zehn handgeschriebene Zeilen – dafür null Attribut-Magie und voll suchbare Methoden.
  • Safety-First-Anforderungen werden zu Aggregat-Invarianten. „Tracker stumm > 20 Min.“ ist kein Cron-Detail, sondern ein eigener Alarm-Typ mit eigener Eskalation, gleichwertig zu Breakout.
  • Ausblick: Phase 2

    MAUI-Client mit SQLite-Offline-Cache und MSAL.NET, Downlink-Konfiguration der T1000-Tracker direkt aus der WebApp, Push-Notifications via Azure Notification Hub, Heartbeat-Monitoring für das Gateway. Die Architektur trägt das ohne Bruch – REST-First, DTOs in Contracts, idempotente Endpoints sind im Pilot schon eingebaut.

    Fazit

    Der PoC zeigt, dass sich eine sicherheitskritische Geofencing-Lösung auf Basis von LoRaWAN, .NET 10 und Azure mit überschaubarem Aufwand umsetzen lässt. Die technische Herausforderung lag dabei weniger in der Übertragung der Positionsdaten als in der zuverlässigen Modellierung von Geofence-Regeln, Hysterese, Alarmierung und Multi-Tenancy.

    Besonders bewährt haben sich die klare Trennung der Verantwortlichkeiten durch die Devware Clean Architecture, die lokale Entwicklungsumgebung mit .NET Aspire sowie die mehrstufige Alarmkette über Azure Communication Services. Durch die Kombination aus HDOP-Filter, Hysterese-Buffer und Dwell-Time konnten Falschalarme wirksam reduziert werden, ohne die Reaktionszeit zu beeinträchtigen.

    Mit Alarm-Latenzen von vier bis sechs Minuten erfüllt die Lösung die definierten Geschäftsanforderungen bereits im PoC. Gleichzeitig schafft die gewählte Architektur eine solide Grundlage für die nächste Ausbaustufe mit MAUI-App, Tracker-Konfiguration per Downlink und erweiterten Monitoring-Funktionen. Das Projekt bestätigt damit, dass LoRaWAN-Geofencing nicht nur technisch machbar ist, sondern auch die Anforderungen eines produktiven, sicherheitskritischen Tracking-Szenarios erfüllen kann.

    No items found.
    Foto von Tibor
    Tibor Csizmadia | CEO & Founder

    Mehr zum Thema

    Pfeil nach rechts (Verlinkung)
    Migration .NET 10 mit Assistenten
    06/2026

    Migration .NET 10 mit Assistenten

    Blauer Pfeil nach rechts (Verlinkung)
    Performance-Optimierung in .NET
    05/2026

    Performance-Optimierung in modernen .NET Webanwendungen

    Blauer Pfeil nach rechts (Verlinkung)
    C# LINQ optimieren: Die richtige Syntax für Abfragen und Joins wählen
    05/2026

    LINQ – Eine Syntax für alle Abfragen in C#

    Blauer Pfeil nach rechts (Verlinkung)
    Präzise Zeitverarbeitung in .NET-Projekten mit NodaTime
    03/2026

    Präzise und sichere Zeitverarbeitung in .NET-Projekten

    Blauer Pfeil nach rechts (Verlinkung)
    Devware GmbH verpflichtet sich, Ihre Privatsphäre zu schützen. Wir benötigen Ihre Kontaktinformationen, um Sie bezüglich unserer Produkte und Dienstleistungen zu kontaktieren. Mit Klick auf Absenden geben Sie sich damit einverstanden. Weitere Informationen finden Sie unter Datenschutz. Ihre Daten behandeln wir vertraulich. Versprochen.
    Vielen Dank für Ihr Vertrauen.
    Unser Team prüft Ihre Anfrage sorgfältig und meldet sich in der Regel innerhalb von 48 Stunden bei Ihnen zurück.
    Falls es besonders eilig ist, erreichen Sie uns auch telefonisch:
    + 49 (0) 202 478 269 0.
    Da ist etwas schief gegangen beim Absenden des Formulars.