Notification and parking zones
All checks were successful
Build, Push and Run Container / build (push) Successful in 37s
All checks were successful
Build, Push and Run Container / build (push) Successful in 37s
This commit is contained in:
47
Source/ProofOfConcept/Models/ParkingState.cs
Normal file
47
Source/ProofOfConcept/Models/ParkingState.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
namespace ProofOfConcept.Models;
|
||||||
|
|
||||||
|
public class ParkingState
|
||||||
|
{
|
||||||
|
public bool CarParked { get; set; }
|
||||||
|
public bool ParkingInProgress { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? CarParkedAt { get; set; }
|
||||||
|
public DateTimeOffset? ParkingStartedAt { get; set; }
|
||||||
|
public DateTimeOffset? ParkingStoppedAt { get; set; }
|
||||||
|
|
||||||
|
public void SetCarParked()
|
||||||
|
{
|
||||||
|
if (!CarParked)
|
||||||
|
{
|
||||||
|
CarParked = true;
|
||||||
|
CarParkedAt = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCarMoved()
|
||||||
|
{
|
||||||
|
if (CarParked)
|
||||||
|
{
|
||||||
|
CarParked = false;
|
||||||
|
CarParkedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetParkingStarted()
|
||||||
|
{
|
||||||
|
if (!ParkingInProgress)
|
||||||
|
{
|
||||||
|
ParkingInProgress = true;
|
||||||
|
ParkingStartedAt = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetParkingStopped()
|
||||||
|
{
|
||||||
|
if (ParkingInProgress)
|
||||||
|
{
|
||||||
|
ParkingInProgress = false;
|
||||||
|
ParkingStoppedAt = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Source/ProofOfConcept/Models/TeslaState.cs
Normal file
73
Source/ProofOfConcept/Models/TeslaState.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
namespace ProofOfConcept.Models;
|
||||||
|
|
||||||
|
public class TeslaState
|
||||||
|
{
|
||||||
|
private string gear = "";
|
||||||
|
private bool locked;
|
||||||
|
private bool driverSeatOccupied;
|
||||||
|
private bool gpsState;
|
||||||
|
private double latitude;
|
||||||
|
private double longitude;
|
||||||
|
|
||||||
|
public string Gear
|
||||||
|
{
|
||||||
|
get => this.gear;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
this.gear = value;
|
||||||
|
LastUpdate = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Locked
|
||||||
|
{
|
||||||
|
get => this.locked;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
this.locked = value;
|
||||||
|
LastUpdate = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DriverSeatOccupied
|
||||||
|
{
|
||||||
|
get => this.driverSeatOccupied;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
this.driverSeatOccupied = value;
|
||||||
|
LastUpdate = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool GPSState
|
||||||
|
{
|
||||||
|
get => this.gpsState;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
this.gpsState = value;
|
||||||
|
LastUpdate = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Latitude
|
||||||
|
{
|
||||||
|
get => this.latitude;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
this.latitude = value;
|
||||||
|
LastUpdate = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Longitude
|
||||||
|
{
|
||||||
|
get => this.longitude;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
this.longitude = value;
|
||||||
|
LastUpdate = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset LastUpdate { get; private set; }
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.7.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.7.0" />
|
||||||
<PackageReference Include="MQTTnet" Version="5.0.1.1416" />
|
<PackageReference Include="MQTTnet" Version="5.0.1.1416" />
|
||||||
<PackageReference Include="MQTTnet.Server" Version="5.0.1.1416" />
|
<PackageReference Include="MQTTnet.Server" Version="5.0.1.1416" />
|
||||||
|
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
|
||||||
|
<PackageReference Include="NetTopologySuite.Features" Version="2.2.0" />
|
||||||
|
<PackageReference Include="NetTopologySuite.IO.GeoJSON" Version="4.0.0" />
|
||||||
|
<PackageReference Include="Pushover" Version="1.0.0" />
|
||||||
<PackageReference Include="SzakatsA.Result" Version="1.1.0" />
|
<PackageReference Include="SzakatsA.Result" Version="1.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -34,6 +38,9 @@
|
|||||||
<None Update="Resources\Signature\public-key.pem">
|
<None Update="Resources\Signature\public-key.pem">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<None Update="Resources\parking_zones.geojson">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
1
Source/ProofOfConcept/Resources/nmfr_full.json
Normal file
1
Source/ProofOfConcept/Resources/nmfr_full.json
Normal file
File diff suppressed because one or more lines are too long
137968
Source/ProofOfConcept/Resources/parking_zones.geojson
Normal file
137968
Source/ProofOfConcept/Resources/parking_zones.geojson
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,9 +22,27 @@ public class MQTTClient : IHostedService
|
|||||||
|
|
||||||
this.client.ApplicationMessageReceivedAsync += (e) =>
|
this.client.ApplicationMessageReceivedAsync += (e) =>
|
||||||
{
|
{
|
||||||
string message = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
|
string topic = e.ApplicationMessage.Topic;
|
||||||
logger.LogInformation("Message received: {Message}", message);
|
|
||||||
messageProcessor.ProcessMessage(message);
|
if (topic.IndexOf("/", StringComparison.Ordinal) > 0)
|
||||||
|
{
|
||||||
|
string[] parts = topic.Split('/'); //telemetry/5YJ3E7EB7KF291652/v/Location
|
||||||
|
|
||||||
|
if (parts is ["telemetry", _, "v", _])
|
||||||
|
{
|
||||||
|
string vin = parts[1];
|
||||||
|
string field = parts[3];
|
||||||
|
|
||||||
|
string? message = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
|
||||||
|
logger.LogInformation("Message received: {Message}", message);
|
||||||
|
messageProcessor.ProcessMessage(vin, field.ToLowerInvariant(), message.StripQuotes());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
logger.LogWarning("Topic not passed to message processor: {Topic}", topic);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
logger.LogWarning("Topic not passed to message processor: {Topic}", topic);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -65,3 +83,5 @@ public class MQTTClientConfiguration
|
|||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file static class StringExtensions { public static string StripQuotes(this string value) => value.Trim('"'); }
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ProofOfConcept.Models;
|
||||||
|
using Pushover;
|
||||||
|
using SzakatsA.Result;
|
||||||
|
|
||||||
namespace ProofOfConcept.Services;
|
namespace ProofOfConcept.Services;
|
||||||
|
|
||||||
public interface IMessageProcessor
|
public interface IMessageProcessor
|
||||||
{
|
{
|
||||||
Task ProcessMessage(string jsonMessage);
|
Task ProcessMessage(string vin, string field, string value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MessageProcessor : IMessageProcessor
|
public class MessageProcessor : IMessageProcessor
|
||||||
@@ -14,22 +18,145 @@ public class MessageProcessor : IMessageProcessor
|
|||||||
private MessageProcessorConfiguration configuration;
|
private MessageProcessorConfiguration configuration;
|
||||||
|
|
||||||
private readonly IMemoryCache memoryCache;
|
private readonly IMemoryCache memoryCache;
|
||||||
|
private readonly ZoneDeterminatorService zoneDeterminatorService;
|
||||||
|
|
||||||
public MessageProcessor(ILogger<MessageProcessor> logger, IOptions<MessageProcessorConfiguration> options, IMemoryCache memoryCache)
|
private readonly TeslaState teslaState;
|
||||||
|
private readonly ParkingState parkingState;
|
||||||
|
|
||||||
|
private readonly PushoverClient pushApi;
|
||||||
|
|
||||||
|
public MessageProcessor(ILogger<MessageProcessor> logger, IOptions<MessageProcessorConfiguration> options, IMemoryCache memoryCache, ZoneDeterminatorService zoneDeterminatorService)
|
||||||
{
|
{
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.configuration = options.Value;
|
this.configuration = options.Value;
|
||||||
|
|
||||||
this.memoryCache = memoryCache;
|
this.memoryCache = memoryCache;
|
||||||
|
this.zoneDeterminatorService = zoneDeterminatorService;
|
||||||
|
|
||||||
|
this.teslaState = new TeslaState();
|
||||||
|
this.parkingState = new ParkingState();
|
||||||
|
|
||||||
|
this.pushApi = new PushoverClient(this.configuration.PushoverAPIKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ProcessMessage(string jsonMessage)
|
public async Task ProcessMessage(string vin, string field, string value)
|
||||||
{
|
{
|
||||||
this.logger.LogTrace("Processing message from Tesla: {Message}", jsonMessage);
|
this.logger.LogTrace("Processing {Field} = {Value} for {VIN}...", field, value, vin);
|
||||||
|
|
||||||
|
string[] validGears = [ "P", "R", "N", "D", "SNA" ];
|
||||||
|
if (field == "gear" && validGears.Contains(value))
|
||||||
|
this.teslaState.Gear = value;
|
||||||
|
|
||||||
|
else if (field == "locked" && bool.TryParse(value, out bool locked))
|
||||||
|
this.teslaState.Locked = locked;
|
||||||
|
|
||||||
|
else if (field == "driverseatoccupied" && bool.TryParse(value, out bool driverSeatOccupied))
|
||||||
|
this.teslaState.DriverSeatOccupied = driverSeatOccupied;
|
||||||
|
|
||||||
|
else if (field == "location")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(value);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
this.teslaState.Latitude = root.GetProperty("latitude").GetDouble();
|
||||||
|
this.teslaState.Longitude = root.GetProperty("longitude").GetDouble();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
this.logger.LogError("Invalid location data: {LocationValue}", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.LogTrace("State updated");
|
||||||
|
|
||||||
|
if (this.teslaState is { Gear: "P", Locked: true, DriverSeatOccupied: false })
|
||||||
|
this.parkingState.SetCarParked();
|
||||||
|
else
|
||||||
|
this.parkingState.SetCarMoved();
|
||||||
|
|
||||||
|
if (this.parkingState is { ParkingInProgress: false, CarParked: true })
|
||||||
|
await StartParkingAsync(vin);
|
||||||
|
|
||||||
|
else if (this.parkingState.ParkingInProgress && (this.teslaState.Gear != "P" || this.teslaState.DriverSeatOccupied || !this.teslaState.Locked))
|
||||||
|
await StopParkingAsync(vin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartParkingAsync(string vin)
|
||||||
|
{
|
||||||
|
//Get parking zone
|
||||||
|
Result<string> zoneLookupResult = await this.zoneDeterminatorService.DetermineZoneCodeAsync(this.teslaState.Latitude, this.teslaState.Longitude);
|
||||||
|
bool sendNotification = this.configuration.VinNotifications.TryGetValue(vin, out string? pushoverToken);
|
||||||
|
|
||||||
|
if (zoneLookupResult.IsSuccessful)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrWhiteSpace(zoneLookupResult.Value))
|
||||||
|
{
|
||||||
|
// Push not a parking zone
|
||||||
|
if (sendNotification)
|
||||||
|
this.pushApi.Send(pushoverToken, new PushoverMessage
|
||||||
|
{
|
||||||
|
Title = "Nem parkolózóna",
|
||||||
|
Message = $"Megálltál nem parkoló zónában, a GPS szerint: {this.teslaState.Latitude},{this.teslaState.Longitude}",
|
||||||
|
Priority = Priority.Normal,
|
||||||
|
Timestamp = DateTimeOffset.Now.ToLocalTime().ToString(),
|
||||||
|
});
|
||||||
|
this.logger.LogInformation("Parking started in non-parking zone for {VIN}", vin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push parking started in zone
|
||||||
|
this.parkingState.SetParkingStarted();
|
||||||
|
if (sendNotification)
|
||||||
|
this.pushApi.Send(pushoverToken, new PushoverMessage
|
||||||
|
{
|
||||||
|
Title = $"Parkolás elindult: {zoneLookupResult.Value}",
|
||||||
|
Message = $"Megálltál egy parkolási zónában, a GPS szerint: {this.teslaState.Latitude},{this.teslaState.Longitude}" + Environment.NewLine +
|
||||||
|
$"A zónatérkép szerint ez a {zoneLookupResult.Value} jelű zóna",
|
||||||
|
Priority = Priority.Normal,
|
||||||
|
Timestamp = DateTimeOffset.Now.ToLocalTime().ToString(),
|
||||||
|
});
|
||||||
|
this.logger.LogInformation("Parking started for {VIN}", vin);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.logger.LogError(zoneLookupResult.Exception, "Can't start parking: error while determining parking zone");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopParkingAsync(string vin)
|
||||||
|
{
|
||||||
|
// Push parking stopped
|
||||||
|
this.parkingState.SetParkingStopped();
|
||||||
|
if (this.configuration.VinNotifications.TryGetValue(vin, out string? pushoverToken))
|
||||||
|
this.pushApi.Send(pushoverToken, new PushoverMessage
|
||||||
|
{
|
||||||
|
Title = $"Parkolás leállt ({DateTimeOffset.Now.Subtract(this.parkingState.ParkingStartedAt!.Value).ToElapsed()})",
|
||||||
|
Message = $"A {this.parkingState.ParkingStartedAt?.ToString("yyyy-MM-dd HH:mm")} -kor indult parkolásod leállt",
|
||||||
|
Priority = Priority.Normal,
|
||||||
|
Timestamp = DateTimeOffset.Now.ToLocalTime().ToString(),
|
||||||
|
});
|
||||||
|
this.logger.LogInformation("Parking stopped for {VIN}", vin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MessageProcessorConfiguration
|
public class MessageProcessorConfiguration
|
||||||
{
|
{
|
||||||
|
public string PushoverAPIKey { get; set; } = "a255e6nkpguw1i96iyj3z9faacgjp7";
|
||||||
|
public Dictionary<string, string> VinNotifications { get; set; } = new Dictionary<string, string>() { { "5YJ3E7EB7KF291652", "u2ouaqqu5gd9f1bq3rmrtwriumaffu"} /*Zoli*/ };
|
||||||
|
}
|
||||||
|
|
||||||
|
file static class DateTimeOffsetExtensions
|
||||||
|
{
|
||||||
|
public static string ToElapsed(this TimeSpan ts)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
|
||||||
|
if (ts.Days > 0)
|
||||||
|
parts.Add($"{ts.Days} nap");
|
||||||
|
if (ts.Hours > 0)
|
||||||
|
parts.Add($"{ts.Hours} óra");
|
||||||
|
if (ts.Minutes > 0)
|
||||||
|
parts.Add($"{ts.Minutes} perc");
|
||||||
|
|
||||||
|
return string.Join(", ", parts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
63
Source/ProofOfConcept/Services/ZoneDeterminatorService.cs
Normal file
63
Source/ProofOfConcept/Services/ZoneDeterminatorService.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NetTopologySuite.Features;
|
||||||
|
using NetTopologySuite.Geometries;
|
||||||
|
using NetTopologySuite.IO;
|
||||||
|
using SzakatsA.Result;
|
||||||
|
|
||||||
|
namespace ProofOfConcept.Services;
|
||||||
|
|
||||||
|
public class ZoneDeterminatorService
|
||||||
|
{
|
||||||
|
private readonly ILogger<ZoneDeterminatorService> logger;
|
||||||
|
private ZoneDeterminatorServiceConfiguration configuration;
|
||||||
|
|
||||||
|
private FeatureCollection parkingZones;
|
||||||
|
private bool initialized;
|
||||||
|
|
||||||
|
public ZoneDeterminatorService(ILogger<ZoneDeterminatorService> logger, IOptions<ZoneDeterminatorServiceConfiguration> options)
|
||||||
|
{
|
||||||
|
this.logger = logger;
|
||||||
|
this.configuration = options.Value;
|
||||||
|
|
||||||
|
this.parkingZones = new FeatureCollection();
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<string>> DetermineZoneCodeAsync(double latitude, double longitude, CancellationToken cancellationToken = default(CancellationToken))
|
||||||
|
{
|
||||||
|
this.logger.LogTrace("Determinating parking zone code for coordinates: {Latitude}, {Longitude}...", latitude, longitude);
|
||||||
|
|
||||||
|
if (!this.initialized)
|
||||||
|
await InitializeAsync(cancellationToken);
|
||||||
|
|
||||||
|
Point point = new Point(longitude, latitude);
|
||||||
|
IFeature? zone = this.parkingZones.FirstOrDefault(f => f.Geometry.Contains(point));
|
||||||
|
|
||||||
|
if (zone is null)
|
||||||
|
return Result.Success(String.Empty);
|
||||||
|
else if (!zone.Attributes.Exists("zoneid"))
|
||||||
|
return Result.Fail(new MissingFieldException("Zone ID not found for parking zone"));
|
||||||
|
else if (zone.Attributes["zoneid"] is null)
|
||||||
|
return Result.Fail(new NullReferenceException("Zone ID null for parking zone"));
|
||||||
|
else if (zone.Attributes["zoneid"].ToString() is null)
|
||||||
|
return Result.Fail(new InvalidCastException($"Zone ID is of type {zone.Attributes["zoneID"].GetType().FullName}"));
|
||||||
|
else
|
||||||
|
return Result.Success(zone.Attributes["zoneid"].ToString()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeAsync(CancellationToken cancellationToken = default(CancellationToken))
|
||||||
|
{
|
||||||
|
this.logger.LogTrace("Initializing...");
|
||||||
|
|
||||||
|
string geojson = await File.ReadAllTextAsync(this.configuration.ZoneFilePath, cancellationToken);
|
||||||
|
GeoJsonReader reader = new GeoJsonReader();
|
||||||
|
this.parkingZones = reader.Read<FeatureCollection>(geojson);
|
||||||
|
|
||||||
|
this.logger.LogInformation("Initialized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ZoneDeterminatorServiceConfiguration
|
||||||
|
{
|
||||||
|
public string ZoneFilePath { get; set; } = "Resources/zones.geojson";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user