chore: added smappee api

This commit is contained in:
Willem Serruys
2026-08-21 07:38:21 +02:00
parent df21ce581b
commit 1d0a5beb8c
12 changed files with 309 additions and 0 deletions

View File

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

43
smappee-api/Program.cs Normal file
View File

@@ -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<ChargerOptions>()
.Bind(builder.Configuration.GetSection(ChargerOptions.Name))
.ValidateDataAnnotations();
builder.Services.AddSingleton<IBearerTokenService, SmappeeTokenService>();
builder.Services.AddScoped<IChargerService, SmappeeChargerService>();
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();

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,7 @@
namespace enums;
internal enum ChargerRequests
{
Enable,
Disable
}

View File

@@ -0,0 +1,5 @@
namespace interfaces;
internal interface IBearerTokenService {
ValueTask<string> GetToken(bool forceNewToken);
}

View File

@@ -0,0 +1,8 @@
using enums;
namespace interfaces;
internal interface IChargerService
{
Task ExecuteRequest(ChargerRequests request);
}

View File

@@ -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<ChargerOptions> 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<SmappeeRequestBody>(
$"/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);
}

View File

@@ -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<ChargerOptions> options,
IHttpClientFactory httpClientFactory
)
{
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
this.httpClientFactory =
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
}
public async ValueTask<string> GetToken(bool forceNewToken)
{
if (
tokenData == null
|| tokenData.Expiration < DateTime.UtcNow.AddSeconds(-10)
|| forceNewToken
)
{
tokenData = await RequestNewToken();
}
return tokenData.Token;
}
private async Task<TokenData> RequestNewToken()
{
var httpClient = httpClientFactory.CreateClient("smappeeTokenService");
httpClient.BaseAddress = options.Url;
var result = await httpClient.PostAsJsonAsync<TokenRequestBody>(
"/dev/v3/oauth2/token",
new TokenRequestBody(
"password",
options.ClientId,
options.ClientSecret,
options.UserName,
options.Password
)
);
result.EnsureSuccessStatusCode();
var tokenResponse =
await result.Content.ReadFromJsonAsync<TokenResponse>()
?? 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);
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>smappee_api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Compliance.Redaction" Version="10.9.0" />
<PackageReference Include="Microsoft.Extensions.Http.Diagnostics" Version="10.9.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@smappee_api_HostAddress = http://localhost:5057
GET {{smappee_api_HostAddress}}/weatherforecast/
Accept: application/json
###