diff --git a/smappee-api/ChargerOptions.cs b/smappee-api/ChargerOptions.cs new file mode 100644 index 0000000..adfeb07 --- /dev/null +++ b/smappee-api/ChargerOptions.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace options; + +internal class ChargerOptions +{ + public const string Name = "CHARGER"; + + [Required] + [ConfigurationKeyName("URL")] + public required Uri Url { get; init; } + + [Required] + [ConfigurationKeyName("CLIENT_ID")] + public required string ClientId { get; init; } + + [Required] + [ConfigurationKeyName("CLIENT_SECRET")] + public required string ClientSecret { get; init; } + + [Required] + [ConfigurationKeyName("USERNAME")] + public required string UserName { get; init; } + + [Required] + [ConfigurationKeyName("PASSWORD")] + public required string Password { get; init; } + + [Required] + [ConfigurationKeyName("STATION")] + public required string ChargingStation { get; init; } + + [Required] + [ConfigurationKeyName("CONNECTOR")] + public required int Connector { get; init; } +} diff --git a/smappee-api/Program.cs b/smappee-api/Program.cs new file mode 100644 index 0000000..abce3ca --- /dev/null +++ b/smappee-api/Program.cs @@ -0,0 +1,43 @@ +using enums; +using interfaces; +using options; +using services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHttpClient(); +builder.Services.AddRedaction(); +builder.Services.AddExtendedHttpClientLogging( + builder.Configuration.GetSection("HttpClientLogging")); + +builder + .Services.AddOptions() + .Bind(builder.Configuration.GetSection(ChargerOptions.Name)) + .ValidateDataAnnotations(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.MapPost( + "/enable", + async (IChargerService chargerService) => + { + await chargerService.ExecuteRequest(ChargerRequests.Enable); + return; + } + ) + .WithName("Enable"); + +app.MapPost( + "/disable", + () => + async (IChargerService chargerService) => + { + await chargerService.ExecuteRequest(ChargerRequests.Disable); + return; + } + ) + .WithName("Disable"); + +app.Run(); diff --git a/smappee-api/Properties/launchSettings.json b/smappee-api/Properties/launchSettings.json new file mode 100644 index 0000000..dac2b51 --- /dev/null +++ b/smappee-api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5057", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7028;http://localhost:5057", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/smappee-api/appsettings.Development.json b/smappee-api/appsettings.Development.json new file mode 100644 index 0000000..b16b55c --- /dev/null +++ b/smappee-api/appsettings.Development.json @@ -0,0 +1,32 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information" + } + }, + "CHARGER": { + "URL": "https://app1pub.smappee.net", + "CLIENT_ID": "5700002178", + "CLIENT_SECRET": "cPcRKc26ul", + "USERNAME": "serruysw@gmail.com", + "PASSWORD": "uAfUhnBXkZeqs$@7ca7P3M9QE6z0Dg1N0cbz&", + "STATION": "6230003897", + "CONNECTOR": 1 + }, + "HttpClientLogging": { + "LogRequestStart": false, + "LogBody": true, + "BodySizeLimit": 32768, + "BodyReadTimeout": "00:00:01", + "RequestHeadersDataClasses": { + "User-Agent": "None", + "Content-Type": "None" + }, + "ResponseHeadersDataClasses": { + "Content-Type": "None" + }, + "RequestPathLoggingMode": "Formatted", + "RequestPathParameterRedactionMode": "Strict" + } +} diff --git a/smappee-api/appsettings.json b/smappee-api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/smappee-api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/smappee-api/enums/ChargerRequests.cs b/smappee-api/enums/ChargerRequests.cs new file mode 100644 index 0000000..c73ca66 --- /dev/null +++ b/smappee-api/enums/ChargerRequests.cs @@ -0,0 +1,7 @@ +namespace enums; + +internal enum ChargerRequests +{ + Enable, + Disable +} diff --git a/smappee-api/interfaces/IBearerTokenService.cs b/smappee-api/interfaces/IBearerTokenService.cs new file mode 100644 index 0000000..365e74b --- /dev/null +++ b/smappee-api/interfaces/IBearerTokenService.cs @@ -0,0 +1,5 @@ +namespace interfaces; + +internal interface IBearerTokenService { + ValueTask GetToken(bool forceNewToken); +} diff --git a/smappee-api/interfaces/IChargerService.cs b/smappee-api/interfaces/IChargerService.cs new file mode 100644 index 0000000..7e47521 --- /dev/null +++ b/smappee-api/interfaces/IChargerService.cs @@ -0,0 +1,8 @@ +using enums; + +namespace interfaces; + +internal interface IChargerService +{ + Task ExecuteRequest(ChargerRequests request); +} diff --git a/smappee-api/services/SmappeeChargerService.cs b/smappee-api/services/SmappeeChargerService.cs new file mode 100644 index 0000000..78ab00a --- /dev/null +++ b/smappee-api/services/SmappeeChargerService.cs @@ -0,0 +1,51 @@ +using enums; +using interfaces; +using Microsoft.Extensions.Options; +using options; + +namespace services; + +internal class SmappeeChargerService : IChargerService +{ + private readonly ChargerOptions options; + private readonly IBearerTokenService tokenService; + private readonly IHttpClientFactory httpClientFactory; + + public SmappeeChargerService( + IOptions options, + IBearerTokenService tokenService, + IHttpClientFactory httpClientFactory + ) + { + ArgumentNullException.ThrowIfNull(tokenService); + this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + this.tokenService = tokenService; + this.httpClientFactory = + httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + } + + public async Task ExecuteRequest(ChargerRequests request) + { + var token = await tokenService.GetToken(false); + var httpClient = httpClientFactory.CreateClient("smappee"); + httpClient.BaseAddress = options.Url; + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}"); + var response = request switch + { + ChargerRequests.Enable => await httpClient.PutAsJsonAsync( + $"/dev/v3/chargingstations/${options.ChargingStation}/connectors/{options.Connector}/mode", + new SmappeeRequestBody("NORMAL", new Limit("AMPERE", 6)) + ), + ChargerRequests.Disable => await httpClient.PutAsJsonAsync( + "/dev/v3/chargingstations", + new SmappeeRequestBody("PAUSED", null) + ), + _ => throw new NotImplementedException(), + }; + response.EnsureSuccessStatusCode(); + } + + record SmappeeRequestBody(string mode, Limit? limit); + + record Limit(string unit, int value); +} diff --git a/smappee-api/services/SmappeeTokenService.cs b/smappee-api/services/SmappeeTokenService.cs new file mode 100644 index 0000000..be272f6 --- /dev/null +++ b/smappee-api/services/SmappeeTokenService.cs @@ -0,0 +1,73 @@ +using interfaces; +using Microsoft.Extensions.Options; +using options; + +namespace services; + +internal class SmappeeTokenService : IBearerTokenService +{ + private readonly ChargerOptions options; + private readonly IHttpClientFactory httpClientFactory; + + private TokenData? tokenData = null; + + public SmappeeTokenService( + IOptions options, + IHttpClientFactory httpClientFactory + ) + { + this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + this.httpClientFactory = + httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + } + + public async ValueTask GetToken(bool forceNewToken) + { + if ( + tokenData == null + || tokenData.Expiration < DateTime.UtcNow.AddSeconds(-10) + || forceNewToken + ) + { + tokenData = await RequestNewToken(); + } + return tokenData.Token; + } + + private async Task RequestNewToken() + { + var httpClient = httpClientFactory.CreateClient("smappeeTokenService"); + httpClient.BaseAddress = options.Url; + + var result = await httpClient.PostAsJsonAsync( + "/dev/v3/oauth2/token", + new TokenRequestBody( + "password", + options.ClientId, + options.ClientSecret, + options.UserName, + options.Password + ) + ); + result.EnsureSuccessStatusCode(); + var tokenResponse = + await result.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Token could not be fetched"); + return new TokenData( + tokenResponse.access_token, + DateTime.UtcNow.AddSeconds(tokenResponse.expires_in) + ); + } + + record TokenRequestBody( + string grant_type, + string client_id, + string client_secret, + string username, + string password + ); + + record TokenResponse(string access_token, int expires_in); + + record TokenData(string Token, DateTime Expiration); +} diff --git a/smappee-api/smappee-api.csproj b/smappee-api/smappee-api.csproj new file mode 100644 index 0000000..c38a885 --- /dev/null +++ b/smappee-api/smappee-api.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + smappee_api + + + + + + + + + diff --git a/smappee-api/smappee-api.http b/smappee-api/smappee-api.http new file mode 100644 index 0000000..2546b6f --- /dev/null +++ b/smappee-api/smappee-api.http @@ -0,0 +1,6 @@ +@smappee_api_HostAddress = http://localhost:5057 + +GET {{smappee_api_HostAddress}}/weatherforecast/ +Accept: application/json + +###