
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.
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“.
[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:
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.
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.
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.
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.
Was im Architektur-Dokument als „Microsoft Entra External ID + Role-Claims“ steht, hatte in der Umsetzung drei nicht offensichtliche Hindernisse:
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.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.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.
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.
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.

Unser Geschäftsführer Tibor Csizmadia und unser Kundenbetreuer Jens Walter stehen Ihnen persönlich zur Verfügung. Profitieren Sie von unserer langjährigen Erfahrung und erhalten Sie eine kompetente Beratung in einem unverbindlichen Austausch.