Compare commits

...

35 Commits

Author SHA1 Message Date
a9e56b131c Removes one-time sanity log
All checks were successful
Build, Push and Run Container / build (push) Successful in 30s
Removes the temporary logging middleware used for verifying forwarded headers. This was a one-time check and is no longer needed.
2025-10-15 19:51:16 +02:00
ecb4482a1b Configures forwarded headers options
All checks were successful
Build, Push and Run Container / build (push) Successful in 32s
Configures the forwarded headers options to accept all forwarded headers,
clears the default known networks and proxies, and adds a new known IP network
to allow any IP address. This is necessary to handle requests from proxies
and load balancers correctly.
2025-10-15 19:48:58 +02:00
86c000f323 Reverse proxy mess
All checks were successful
Build, Push and Run Container / build (push) Successful in 29s
2025-10-15 19:43:32 +02:00
84dc22f324 Add forwarded headers
All checks were successful
Build, Push and Run Container / build (push) Successful in 29s
2025-10-15 19:32:23 +02:00
6ac6d05f5f Simplifies authentication logic and adds VIN.
All checks were successful
Build, Push and Run Container / build (push) Successful in 35s
Removes temporary test endpoints and refactors the authorize endpoint.

Adds support for VIN notifications for a new vehicle by adding it to the dictionary.
2025-10-15 19:13:50 +02:00
f39d900fbb Updates base image versions
All checks were successful
Build, Push and Run Container / build (push) Successful in 1m8s
Updates the base images for both the ASP.NET runtime and the SDK to the stable 10.0 version.
This ensures the application uses the latest stable dependencies.
2025-09-23 18:33:31 +02:00
387ef9e70a Redirects to Tesla authorization on missing token
All checks were successful
Build, Push and Run Container / build (push) Successful in 53s
The application now redirects to the Tesla authorization endpoint when the access token is missing.
This ensures the user is prompted to authorize the application.
2025-09-19 13:28:39 +02:00
22df381755 Enables location and gear testing via API
All checks were successful
Build, Push and Run Container / build (push) Successful in 27s
Adds query parameters for latitude and longitude to the `/TestStartParking` endpoint, allowing to simulate different locations.

Adds logic to set the gear to "P" if the received value is null to cover edge cases.

Ensures asynchronous message processing by awaiting the result of ProcessMessage to prevent potential race conditions.
2025-08-25 08:49:57 +02:00
c44f0d327d Improves parking state logic
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
Ensures the parking state is correctly managed by setting the parking started state before any parking zone checks.
Also, updates the logging message to more accurately reflect the car's state.
2025-08-22 08:53:42 +02:00
1272ecab46 Adds logging for parking zone code
All checks were successful
Build, Push and Run Container / build (push) Successful in 24s
Adds logging of the parking zone code when parking starts in a determined zone.
This provides more detailed information for debugging and monitoring purposes.
2025-08-21 23:31:30 +02:00
3c64228f8c Updates zone file path
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
Updates the default zone file path to point to the parking zones file.

This change ensures the application uses the correct GeoJSON file for determining parking zones.
2025-08-21 17:26:26 +02:00
b00cebbd0a Add test cases
All checks were successful
Build, Push and Run Container / build (push) Successful in 27s
2025-08-21 17:23:52 +02:00
00e79097d6 Fixes telemetry config endpoint call
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
Removes redundant verb from the telemetry config endpoint URI, ensuring the request is correctly routed.
2025-08-21 15:15:27 +02:00
c62dc22245 Plain text instead of JSON
All checks were successful
Build, Push and Run Container / build (push) Successful in 26s
2025-08-21 15:13:18 +02:00
85cf55f878 Check config
All checks were successful
Build, Push and Run Container / build (push) Successful in 24s
2025-08-21 15:11:30 +02:00
6c59133e13 Status endpoint
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
2025-08-21 15:06:18 +02:00
d2e976aee1 Adds VIN to RePair telemetry config request
All checks were successful
Build, Push and Run Container / build (push) Successful in 29s
Ensures the RePair endpoint includes the VIN in the telemetry config request.
This guarantees that telemetry data is specifically requested for the provided VIN.
2025-08-21 14:59:51 +02:00
576b3b91a4 Updates access token for RePair endpoint
All checks were successful
Build, Push and Run Container / build (push) Successful in 24s
Updates the hardcoded access token used in the /RePair endpoint.

The previous token was likely outdated or invalid, preventing the endpoint from functioning correctly. This change ensures the endpoint can properly authenticate and access necessary resources.
2025-08-21 14:56:31 +02:00
0549406720 Specifies ILogger type for RePair endpoint
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
Specifies ILogger type for the RePair endpoint,
ensuring the correct logger context is used for configuration-related logging.
2025-08-21 14:55:14 +02:00
1de73e0bca Uses dependency injection for logger/client
All checks were successful
Build, Push and Run Container / build (push) Successful in 27s
Specifies that the logger and http client factory should be injected via dependency injection instead of directly from the request.

This allows for better testability and separation of concerns.
2025-08-21 14:54:05 +02:00
430daa39dc Configures fleet telemetry via API
All checks were successful
Build, Push and Run Container / build (push) Successful in 26s
Implements fleet telemetry configuration by first removing the existing telemetry configuration and then posting a new configuration.

This configures telemetry data collection intervals and host information.

Adds logging for request/response from the API.
2025-08-21 14:52:03 +02:00
4cfad4ae33 Add diagnose endpoint
All checks were successful
Build, Push and Run Container / build (push) Successful in 29s
2025-08-21 14:32:06 +02:00
df999abf6c Improves parking state detection and logging
All checks were successful
Build, Push and Run Container / build (push) Successful in 28s
Enhances parking state logic by setting the initial gear to "P" and adding more detailed logging for state changes and parking events.

This provides better insight into vehicle states and parking behaviors.
2025-08-21 09:41:51 +02:00
c0a14e070c Change API key
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
2025-08-20 12:22:35 +02:00
317b3eeacd Zone determinator service wired
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
2025-08-20 12:03:10 +02:00
a9121cf48e Notification and parking zones
All checks were successful
Build, Push and Run Container / build (push) Successful in 37s
2025-08-20 12:00:41 +02:00
326c46cb27 Subscribes to a broader telemetry topic
All checks were successful
Build, Push and Run Container / build (push) Successful in 30s
Updates the MQTT client to subscribe to all subtopics under "telemetry"
using the wildcard "#". This allows the client to receive messages from various telemetry sources, improving data ingestion.
2025-08-19 12:59:19 +02:00
0d2fdc4de6 Enhances MQTT server logging
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
Improves logging for MQTT server events to provide more detailed
information for debugging and monitoring. Logs the message payload
when a client publishes a message and includes username, password,
and remote IP address when an observer fails to connect due to
invalid credentials.
2025-08-19 10:16:31 +02:00
de4e06401b Read payload as string
All checks were successful
Build, Push and Run Container / build (push) Successful in 24s
2025-08-18 22:34:47 +02:00
05c102aee8 Add KeyPairing to /Tesla endpoint
All checks were successful
Build, Push and Run Container / build (push) Successful in 24s
2025-08-18 22:07:10 +02:00
5daf0825a0 Adds fleet status endpoint
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
Adds an endpoint to retrieve fleet status information.

This endpoint uses a Tesla API proxy to fetch the fleet status
based on provided VINs. It handles authentication using a bearer
token and sends a POST request to the /api/1/vehicles/fleet_status
endpoint.

Also introduces new data models to properly serialize/deserialize the fleet status response.
2025-08-18 21:47:06 +02:00
166ac0b290 Publish MQTT
All checks were successful
Build, Push and Run Container / build (push) Successful in 3s
2025-08-18 15:04:29 +02:00
1aa42c1cd6 Increases log verbosity during development
All checks were successful
Build, Push and Run Container / build (push) Successful in 24s
Configures logging to show more detailed information during development.
Improves logging for debugging and troubleshooting authentication issues.
2025-08-18 14:22:32 +02:00
ce5852cbe8 Register automatic-parking.app
All checks were successful
Build, Push and Run Container / build (push) Successful in 26s
2025-08-18 14:18:42 +02:00
d291e6ec3e Removes redundant client configuration
All checks were successful
Build, Push and Run Container / build (push) Successful in 25s
Removes the redundant base address and header configurations from within the endpoint handler. This configuration should be handled elsewhere, avoiding unnecessary repetition.
2025-08-18 12:34:07 +02:00
15 changed files with 138471 additions and 46 deletions

View File

@@ -43,12 +43,15 @@ jobs:
docker run -d \
--name automatic-parking \
--network traefik \
--network-alias mqtt \
--label 'traefik.enable=true' \
--label 'traefik.http.routers.automatic-parking.rule=Host(`automatic-parking.app`)' \
--label 'traefik.http.routers.automatic-parking.entrypoints=websecure' \
--label 'traefik.http.routers.automatic-parking.tls.certresolver=le' \
--label 'traefik.http.services.automatic-parking.loadbalancer.server.port=8080' \
--label 'traefik.tcp.routers.mqtt.rule=HostSNI(`*`)' \
--label 'traefik.tcp.routers.mqtt.entrypoints=mqtt' \
--label 'traefik.tcp.services.mqtt.loadbalancer.server.port=1883' \
--label 'traefik.docker.network=traefik' \
-e ASPNETCORE_ENVIRONMENT=Development \
docker-registry.automatic-parking.dev/automatic-parking:poc

View File

@@ -1,9 +1,9 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["ProofOfConcept/ProofOfConcept.csproj", "ProofOfConcept/"]

View File

@@ -0,0 +1,25 @@
namespace ProofOfConcept.Models;
using System.Collections.Generic;
using System.Text.Json.Serialization;
public class FleetRootResponse
{
[JsonPropertyName("response")] public FleetResponse FleetResponse { get; set; } = new FleetResponse();
}
public class FleetResponse
{
[JsonPropertyName("key_paired_vins")] public List<string> KeyPairedVins { get; set; } = new List<string>();
[JsonPropertyName("unpaired_vins")] public List<string> UnpairedVins { get; set; } = new List<string>();
[JsonPropertyName("vehicle_info")] public Dictionary<string, VehicleInfo> VehicleInfo { get; set; } = new Dictionary<string, VehicleInfo>();
}
public class VehicleInfo
{
[JsonPropertyName("firmware_version")] public string FirmwareVersion { get; set; } = "";
[JsonPropertyName("vehicle_command_protocol_required")] public bool VehicleCommandProtocolRequired { get; set; }
[JsonPropertyName("discounted_device_data")] public bool DiscountedDeviceData { get; set; }
[JsonPropertyName("fleet_telemetry_version")] public string FleetTelemetryVersion { get; set; } = "";
[JsonPropertyName("total_number_of_keys")] public int TotalNumberOfKeys { get; set; }
}

View 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;
}
}
}

View File

@@ -0,0 +1,73 @@
namespace ProofOfConcept.Models;
public class TeslaState
{
private string gear = "P";
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; }
}

View File

@@ -1,3 +1,5 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
@@ -11,6 +13,8 @@ using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using ProofOfConcept.Models;
using ProofOfConcept.Services;
using ProofOfConcept.Utilities;
using SzakatsA.Result;
using IPNetwork = System.Net.IPNetwork;
Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true;
@@ -136,8 +140,9 @@ builder.Services
});
// Add own services
builder.Services.AddSingleton<IMessageProcessor, MessageProcessor>();
builder.Services.AddSingleton<MessageProcessor>();
builder.Services.AddTransient<ITeslaAuthenticatorService, TeslaAuthenticatorService>();
builder.Services.AddTransient<ZoneDeterminatorService>();
// Add hosted services
builder.Services.AddHostedService<MQTTServer>();
@@ -147,8 +152,9 @@ builder.Services.AddHostedService<MQTTClient>();
WebApplication app = builder.Build();
ForwardedHeadersOptions forwardedHeadersOptions = new ForwardedHeadersOptions() { ForwardedHeaders = ForwardedHeaders.All };
forwardedHeadersOptions.KnownNetworks.Clear();
forwardedHeadersOptions.KnownIPNetworks.Clear();
forwardedHeadersOptions.KnownProxies.Clear();
forwardedHeadersOptions.KnownIPNetworks.Add(new IPNetwork(IPAddress.Any, 0));
app.UseForwardedHeaders(forwardedHeadersOptions);
if (app.Environment.IsDevelopment())
@@ -171,9 +177,8 @@ if (app.Environment.IsDevelopment())
});
app.MapGet("/CheckRegisteredApplication", ([FromServices] ITeslaAuthenticatorService service) => service.CheckApplicationRegistrationAsync());
app.MapGet("/RegisterApplication", ([FromServices] ITeslaAuthenticatorService service) => service.RegisterApplicationAsync());
app.MapGet("/Authorize", async (IHttpContextAccessor contextAccessor) => await (contextAccessor.HttpContext!).ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/Tesla" }));
app.MapGet("/KeyPairing", () => Results.Redirect("https://tesla.com/_ak/tesla-connector.automatic-parking.app"));
app.MapGet("/KeyPairing2", () => Results.Redirect("https://tesla.com/_ak/automatic-parking.app"));
app.MapGet("/Authorize", async ([FromQuery] string redirect, [FromServices] IHttpContextAccessor contextAccessor) => await (contextAccessor.HttpContext!).ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties() { RedirectUri = redirect }));
app.MapGet("/KeyPairing", () => Results.Redirect("https://tesla.com/_ak/automatic-parking.app"));
app.MapGet("/Tokens", async (IHttpContextAccessor httpContextAccessor) =>
{
var ctx = httpContextAccessor.HttpContext;
@@ -232,6 +237,45 @@ if (app.Environment.IsDevelopment())
return json;
});
app.MapGet("/Diagnose", async ([FromServices] ILogger<Configurator> logger, [FromServices] IHttpContextAccessor httpContextAccessor, [FromServices] IHttpClientFactory httpClientFactory) =>
{
logger.LogTrace("Checking errors for car...");
HttpContext? context = httpContextAccessor.HttpContext;
if (context is null)
return Results.BadRequest();
string? access_token = await context.GetTokenAsync("access_token");
string? refresh_token = await context.GetTokenAsync("refresh_token");
logger.LogCritical("User has access_token: {access_token} and refresh_token: {refresh_token}", access_token, refresh_token);
if (String.IsNullOrEmpty(access_token))
return Results.LocalRedirect("/Authorize?redirect=Diagnose");
HttpClient client = httpClientFactory.CreateClient("InsecureClient");
client.BaseAddress = new Uri("https://tesla_command_proxy");
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {access_token}");
//Get cars
VehiclesEnvelope? vehiclesEnvelope = await client.GetFromJsonAsync<VehiclesEnvelope>("/api/1/vehicles");
string[] vinNumbers = vehiclesEnvelope?.Response.Select(x => x.Vin ?? "").Where(v => !String.IsNullOrWhiteSpace(v)).ToArray() ?? Array.Empty<string>();
logger.LogCritical("User has access to {count} cars: {vins}", vinNumbers.Length, String.Join(", ", vinNumbers));
if (vinNumbers.Length == 0)
return Results.Ok("No cars found");
foreach (string vinNumber in vinNumbers)
{
HttpResponseMessage responseMessage = await client.GetAsync($"/api/1/vehicles/{vinNumber}/fleet_telemetry_errors");
string response = await responseMessage.Content.ReadAsStringAsync();
logger.LogInformation("Telemetry errors for {vinNumber}: {response}", vinNumber, response);
}
return Results.Ok("Done");
});
app.MapGet("/Tesla", async ([FromServices] ILogger<Configurator> logger, [FromServices] IHttpContextAccessor httpContextAccessor, [FromServices] IHttpClientFactory httpClientFactory) =>
{
HttpContext? context = httpContextAccessor.HttpContext;
@@ -244,7 +288,7 @@ if (app.Environment.IsDevelopment())
logger.LogCritical("User has access_token: {access_token} and refresh_token: {refresh_token}", access_token, refresh_token);
if (String.IsNullOrEmpty(access_token))
return Results.LocalRedirect("/Authorize");
return Results.LocalRedirect("/Authorize?redirect=Tesla");
HttpClient client = httpClientFactory.CreateClient("InsecureClient");
client.BaseAddress = new Uri("https://tesla_command_proxy");
@@ -252,9 +296,22 @@ if (app.Environment.IsDevelopment())
//Get cars
VehiclesEnvelope? vehiclesEnvelope = await client.GetFromJsonAsync<VehiclesEnvelope>("/api/1/vehicles");
string[] vins = vehiclesEnvelope?.Response.Select(x => x.Vin ?? "").Where(v => !String.IsNullOrWhiteSpace(v)).ToArray() ?? Array.Empty<string>();
logger.LogCritical("User has access to {count} cars: {vins}", vins.Length, String.Join(", ", vins));
string[] vinNumbers = vehiclesEnvelope?.Response.Select(x => x.Vin ?? "").Where(v => !String.IsNullOrWhiteSpace(v)).ToArray() ?? Array.Empty<string>();
logger.LogCritical("User has access to {count} cars: {vins}", vinNumbers.Length, String.Join(", ", vinNumbers));
if (vinNumbers.Length == 0)
return Results.Ok("No cars found");
//Check if key pairing is required
var requestObject = new { vins = vinNumbers };
HttpResponseMessage statusResponse = await client.PostAsJsonAsync("/api/1/vehicles/fleet_status", requestObject);
string statusResponseContent = await statusResponse.Content.ReadAsStringAsync();
logger.LogTrace("Status response: {statusResponseContent}", statusResponseContent);
FleetResponse? fleetResponse = JsonSerializer.Deserialize<FleetResponse>(statusResponseContent);
if (!fleetResponse?.KeyPairedVins.Any() ?? false)
return Results.Redirect("/KeyPairing");
//Get CA from validate server file
string fileContent = await File.ReadAllTextAsync("Resources/validate_server.json");
@@ -262,10 +319,10 @@ if (app.Environment.IsDevelopment())
TelemetryConfigRequest configRequest = new TelemetryConfigRequest()
{
Vins = new List<string>(vins),
Vins = new List<string>(vinNumbers),
Config = new TelemetryConfig()
{
Hostname = "tesla-connector.automatic-parking.app",
Hostname = "tesla-telemetry.automatic-parking.app",
Port = 443,
CertificateAuthority = vm?.CA ?? "EMPTY",
Fields = new Dictionary<string, TelemetryFieldConfig>()
@@ -280,18 +337,23 @@ if (app.Environment.IsDevelopment())
};
logger.LogInformation("Config request: {configRequest}", JsonSerializer.Serialize(configRequest, new JsonSerializerOptions() { WriteIndented = true }));
if (vins.Length == 0)
return Results.Ok("No cars found");
client.BaseAddress = new Uri("https://fleet-api.prd.eu.vn.cloud.tesla.com");
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {access_token}");
client.DefaultRequestHeaders.Add("X-Tesla-User-Agent", "Tesla-Connector/1.0.0");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/1/vehicles/fleet_telemetry_config", configRequest);
return Results.Ok(response.Content.ReadAsStringAsync());
});
}
app.MapGet("/Zone", async ([FromQuery] double latitude, [FromQuery] double longitude, [FromServices] ILogger<Configurator> logger, [FromServices] ZoneDeterminatorService zoneDeterminator) =>
{
logger.LogTrace("Getting zone for: {latitude}, {longitude}...", latitude, longitude);
Result<string> result = await zoneDeterminator.DetermineZoneCodeAsync(latitude, longitude);
if (result.IsSuccessful)
return Results.Ok(result.Value);
else
return Results.Ok(result.Exception);
});
//Map static assets
app.MapStaticAssets();

View File

@@ -21,6 +21,10 @@
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.7.0" />
<PackageReference Include="MQTTnet" 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" />
</ItemGroup>
@@ -34,6 +38,9 @@
<None Update="Resources\Signature\public-key.pem">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources\parking_zones.geojson">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
using System.Text;
using Microsoft.Extensions.Options;
using MQTTnet;
@@ -5,13 +6,13 @@ namespace ProofOfConcept.Services;
public class MQTTClient : IHostedService
{
private ILogger<MQTTClient> logger;
private MQTTClientConfiguration configuration;
private MQTTServerConfiguration serverConfiguration;
private readonly ILogger<MQTTClient> logger;
private readonly MQTTClientConfiguration configuration;
private readonly MQTTServerConfiguration serverConfiguration;
private readonly IMqttClient client;
public MQTTClient(ILogger<MQTTClient> logger, IOptions<MQTTClientConfiguration> options, IOptions<MQTTServerConfiguration> serverOptions, IMessageProcessor messageProcessor)
public MQTTClient(ILogger<MQTTClient> logger, IOptions<MQTTClientConfiguration> options, IOptions<MQTTServerConfiguration> serverOptions, MessageProcessor messageProcessor)
{
this.logger = logger;
this.configuration = options.Value;
@@ -21,8 +22,27 @@ public class MQTTClient : IHostedService
this.client.ApplicationMessageReceivedAsync += (e) =>
{
logger.LogInformation("Message received: {Message}", e.ApplicationMessage.Payload);
messageProcessor.ProcessMessage(e.ApplicationMessage.Payload.ToString());
string topic = e.ApplicationMessage.Topic;
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()).GetAwaiter().GetResult();
}
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;
};
}
@@ -39,7 +59,7 @@ public class MQTTClient : IHostedService
await this.client.ConnectAsync(options, cancellationToken);
this.logger.LogTrace("Connected");
await this.client.SubscribeAsync("telemetry", cancellationToken: cancellationToken);
await this.client.SubscribeAsync("telemetry/#", cancellationToken: cancellationToken);
this.logger.LogTrace("Subscribed");
this.logger.LogInformation("Started");
@@ -63,3 +83,5 @@ public class MQTTClientConfiguration
{
}
file static class StringExtensions { public static string StripQuotes(this string value) => value.Trim('"'); }

View File

@@ -1,4 +1,5 @@
using System.Net;
using System.Text;
using Microsoft.Extensions.Options;
using MQTTnet.Protocol;
using MQTTnet.Server;
@@ -54,7 +55,7 @@ public class MQTTServer : IHostedService
return Task.CompletedTask;
}
logger.LogInformation("Client {ClientID} published message to topic: {Topic}", e.ClientId, e.ApplicationMessage.Topic);
logger.LogInformation("Client {ClientID} published message to topic: {Topic}: {Message}", e.ClientId, e.ApplicationMessage.Topic, Encoding.UTF8.GetString(e.ApplicationMessage.Payload));
return Task.CompletedTask;
};
@@ -93,7 +94,7 @@ public class MQTTServer : IHostedService
{
if (e.UserName != configuration.Observer.Username || e.Password != configuration.Observer.Password)
{
logger.LogWarning("Observer tried to connect with invalid credentials");
logger.LogWarning("Observer tried to connect with invalid credentials: {Username} / {Password} from {RemoteIP}", e.UserName, e.Password, e.RemoteEndPoint is IPEndPoint ipEndPoint ? ipEndPoint.ToString() : e.RemoteEndPoint.GetType().FullName);
e.ReasonCode = MqttConnectReasonCode.NotAuthorized;
}
}

View File

@@ -1,35 +1,178 @@
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using ProofOfConcept.Models;
using Pushover;
using SzakatsA.Result;
namespace ProofOfConcept.Services;
public interface IMessageProcessor
{
Task ProcessMessage(string jsonMessage);
}
public class MessageProcessor : IMessageProcessor
public class MessageProcessor
{
private readonly ILogger<MessageProcessor> logger;
private MessageProcessorConfiguration configuration;
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.configuration = options.Value;
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 == "gear" && value == "null")
this.teslaState.Gear = "P";
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 for {VIN}. Current state is Gear: {Gear}, Locked: {locked}, driver seat occupied: {DriverSeatOccupied}, Location: {Latitude},{Longitude}", vin, this.teslaState.Gear, this.teslaState.Locked, this.teslaState.DriverSeatOccupied, this.teslaState.Latitude, this.teslaState.Longitude);
if (this.teslaState is { Gear: "P", Locked: true, DriverSeatOccupied: false })
{
this.parkingState.SetCarParked();
this.logger.LogInformation("{vin} is in parked state", vin);
}
else
{
this.parkingState.SetCarMoved();
this.logger.LogInformation("{vin} is NOT in a parked state", vin);
}
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);
}
public async Task StartParkingAsync(string vin)
{
this.logger.LogTrace("Start parking for {vin}...", 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)
{
//Set parking started
this.parkingState.SetParkingStarted();
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);
}
else
{
// Push parking started in zone
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} at {ZoneCode}", vin, zoneLookupResult.Value);
}
}
else
this.logger.LogError(zoneLookupResult.Exception, "Can't start parking: error while determining parking zone");
}
public async Task StopParkingAsync(string vin)
{
this.logger.LogTrace("Stopping parking for {vin}...", 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 string PushoverAPIKey { get; set; } = "acr9fqxafqeqjpr4apryh17m4ak24b";
public Dictionary<string, string> VinNotifications { get; set; } = new Dictionary<string, string>()
{
{ "5YJ3E7EB7KF291652", "u2ouaqqu5gd9f1bq3rmrtwriumaffu"}, /*Zoli*/
{ "LRW3E7EK4NC482668", "udbz5g2hi24m4wcanx44qqkwf7r1c7" /*Nagy Balázs*/ }
};
}
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);
}
}

View File

@@ -201,7 +201,7 @@ public class TeslaAuthenticatorService : ITeslaAuthenticatorService
HttpResponseMessage response = await httpClient.PostAsJsonAsync($"{euBaseURL}/api/1/partner_accounts", postBody, cancellationToken);
bool success = response.IsSuccessStatusCode;
logger.LogInformation("Application registration result: {Success}", success ? "Success" : "Failed");
logger.LogInformation("Application response code: {Success}", success ? "Success" : "Failed");
//Load response from server
string json = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -320,5 +320,5 @@ public class TeslaAuthenticatorServiceConfiguration
public string ClientID { get; set; } = "b2240ee4-332a-4252-91aa-bbcc24f78fdb";
public string ClientSecret { get; set; } = "ta-secret.YG+XSdlvr6Lv8U-x";
public TimeSpan MemoryCacheDelta { get; set; } = TimeSpan.FromSeconds(5);
public string Domain { get; set; } = "tesla-connector.automatic-parking.app";
public string Domain { get; set; } = "automatic-parking.app";
}

View File

@@ -0,0 +1,71 @@
using System.Diagnostics;
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()!);
}
public async Task InitializeAsync(CancellationToken cancellationToken = default(CancellationToken))
{
this.logger.LogTrace("Initializing...");
Stopwatch sw = Stopwatch.StartNew();
this.logger.LogTrace("Reading file...");
string geojson = await File.ReadAllTextAsync(this.configuration.ZoneFilePath, cancellationToken);
this.logger.LogTrace("File read in {Elapsed} ms", sw.ElapsedMilliseconds);
this.logger.LogTrace("Parsing geojson...");
GeoJsonReader reader = new GeoJsonReader();
this.parkingZones = reader.Read<FeatureCollection>(geojson);
this.logger.LogTrace("Geojson parsed in {Elapsed} ms: {FeatureCount} features", sw.ElapsedMilliseconds, this.parkingZones.Count);
this.initialized = true;
this.logger.LogInformation("Initialized");
}
}
public class ZoneDeterminatorServiceConfiguration
{
public string ZoneFilePath { get; set; } = "Resources/parking_zones.geojson";
}

View File

@@ -1,8 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Default": "Trace",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning",
"Microsoft.Extensions.Http": "Information"
}
}
}