Rewrite electricity server to .NET web api

This commit is contained in:
2022-07-01 21:15:31 +02:00
parent b8387dfa1d
commit 642170172a
22 changed files with 354 additions and 509 deletions

2
src/Electricity.Api/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
obj/
bin/

View File

@@ -0,0 +1,6 @@
namespace Electricity.Api;
internal static class Constants
{
public const string isoDateFormat = "yyyy-MM-dd";
}

View File

@@ -0,0 +1,45 @@
using System.Net.Mime;
using Electricity.Api.Models;
using Electricity.Api.Services;
using Microsoft.AspNetCore.Mvc;
namespace Electricity.Api.Controllers;
[ApiController]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public class ElectricityLogController : ControllerBase
{
private readonly ElectricityService electricityService;
private readonly ILogger<ElectricityLogController> logger;
public ElectricityLogController(
ElectricityService electricityService,
ILogger<ElectricityLogController> logger)
{
this.electricityService = electricityService;
this.logger = logger;
}
[HttpGet()]
[Route("/day")]
public async Task<ActionResult<Day[]>> Get([FromQuery] string? start, [FromQuery] string? stop)
{
if (string.IsNullOrWhiteSpace(start) || !DateOnly.TryParseExact(start, Constants.isoDateFormat, out _))
{
return BadRequest();
}
if (string.IsNullOrWhiteSpace(stop))
{
return await electricityService.GetDetailsFor(start);
}
if (!DateOnly.TryParseExact(stop, Constants.isoDateFormat, out _) || string.Compare(start, stop) > 0)
{
return BadRequest();
}
return await electricityService.GetSummariesFor(start, stop);
}
}

View File

@@ -0,0 +1,45 @@
using Electricity.Api.Entities;
using Microsoft.EntityFrameworkCore;
namespace Electricity.Api
{
public partial class DatabaseContext : DbContext
{
public DatabaseContext()
{
}
public DatabaseContext(DbContextOptions<DatabaseContext> options)
: base(options)
{
}
public virtual DbSet<ElectricityLog> ElectricityLogs { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseSqlite("Data Source=test.db");
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ElectricityLog>(entity =>
{
entity.HasNoKey();
entity.ToTable("ElectricityLog");
entity.HasIndex(e => e.Date, "idx_Date");
entity.HasIndex(e => e.TimeUtc, "idx_TimeUtc");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
namespace Electricity.Api.Entities
{
public partial class ElectricityLog
{
public string Date { get; set; } = null!;
public string TimeUtc { get; set; } = null!;
public double CurrentPowerUsage { get; set; }
public double TotalPowerConsumptionDay { get; set; }
public double TotalPowerConsumptionNight { get; set; }
public double CurrentPowerReturn { get; set; }
public double TotalPowerReturnDay { get; set; }
public double TotalPowerReturnNight { get; set; }
public long DayTarifEnabled { get; set; }
public double GasConsumptionInCubicMeters { get; set; }
}
}

View File

@@ -0,0 +1,36 @@
namespace Electricity.Api.Models;
public record Day(uint DateTime, double TotalPowerUse, double TotalPowerReturn, double TotalGasUse)
{
public static Day Empty(DateOnly date)
{
return new Day(
ConvertToEpoch(date.ToDateTime(new TimeOnly(0, 0), DateTimeKind.Utc)),
0, 0, 0);
}
public static Day FromEntity(Entities.ElectricityLog entity)
{
return new Day(
DateTime: ConvertToEpoch(ParseEntityDateTime(entity.Date, entity.TimeUtc)),
TotalPowerUse: entity.TotalPowerConsumptionDay + entity.TotalPowerConsumptionNight,
TotalPowerReturn: entity.TotalPowerReturnDay + entity.TotalPowerReturnNight,
TotalGasUse: entity.GasConsumptionInCubicMeters
);
}
private static DateTime ParseEntityDateTime(string date, string timeUtc)
{
return System.DateTime
.Parse(
$"{date}T{timeUtc}Z",
null,
System.Globalization.DateTimeStyles.AssumeUniversal)
.ToUniversalTime();
}
private static uint ConvertToEpoch(DateTime dateTime)
{
return (uint)Math.Round((dateTime - System.DateTime.UnixEpoch).TotalSeconds);
}
};

View File

@@ -0,0 +1,59 @@
using System.CommandLine;
using Electricity.Api;
using Electricity.Api.Services;
using Microsoft.EntityFrameworkCore;
var rootCommand = new RootCommand("ElectricityServer is a small REST API to access the electricity log database");
var connectionStringArgument = new Option<string>(
name: "--connection-string",
description: "Filepath to the Sqlite3 database file (*.db)");
rootCommand.AddOption(connectionStringArgument);
var result = rootCommand.Parse(args);
if (result.Errors.Any())
{
foreach (var error in result.Errors)
{
Console.WriteLine(error.Message);
}
return 1;
}
var sqlite3DatabaseFilePath = result.GetValueForOption(connectionStringArgument);
if (!File.Exists(sqlite3DatabaseFilePath))
{
Console.WriteLine($"Sqlite3 database <{sqlite3DatabaseFilePath}> does not exist or is inaccessible");
return 1;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<DatabaseContext>((DbContextOptionsBuilder builder) =>
{
builder.UseSqlite($"Data Source={sqlite3DatabaseFilePath}");
});
builder.Services.AddControllers()
.AddJsonOptions(config =>
{
config.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
config.JsonSerializerOptions.WriteIndented = true;
});
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<ElectricityService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthorization();
app.MapControllers();
app.Run();
return 0;

View File

@@ -0,0 +1,31 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:44706",
"sslPort": 44335
}
},
"profiles": {
"Electricity.Api": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7290;http://localhost:5093",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,66 @@
using Electricity.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace Electricity.Api.Services;
public class ElectricityService
{
private readonly DatabaseContext databaseContext;
public ElectricityService(DatabaseContext databaseContext)
{
this.databaseContext = databaseContext;
}
public async Task<Day[]> GetDetailsFor(string date)
{
return (await databaseContext.ElectricityLogs
.Where(l => l.Date == date)
.OrderBy(l => l.TimeUtc)
.ToArrayAsync())
.Select(Day.FromEntity)
.ToArray();
}
public async Task<Day[]> GetSummariesFor(string from, string to)
{
var list = new List<Day>();
var current = DateOnly.ParseExact(from, Constants.isoDateFormat);
var limit = DateOnly.ParseExact(to, Constants.isoDateFormat);
do
{
list.Add(await GetSummaryFor(current));
current = current.AddDays(1);
} while (current <= limit);
return list.ToArray();
}
private async Task<Day> GetSummaryFor(DateOnly date)
{
var databaseDate = date.ToString(Constants.isoDateFormat);
var baseQuery = databaseContext.ElectricityLogs
.Where(l => l.Date == databaseDate)
.OrderBy(l => l.TimeUtc);
var first = await baseQuery.FirstOrDefaultAsync();
if (first == null)
{
return Day.Empty(date);
}
var last = await baseQuery.LastAsync();
var firstAsModel = Day.FromEntity(first);
if (last.TimeUtc == first.TimeUtc)
{
return Day.Empty(date);
}
var lastAsModel = Day.FromEntity(last);
return new Day(
lastAsModel.DateTime,
lastAsModel.TotalPowerUse - firstAsModel.TotalPowerUse,
lastAsModel.TotalPowerReturn - firstAsModel.TotalPowerReturn,
lastAsModel.TotalGasUse - firstAsModel.TotalGasUse
);
}
}

View File

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

View File

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

3
src/Electricity.Api/publish.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
dotnet publish -c Release -r linux-arm --no-self-contained

View File

@@ -1,51 +0,0 @@
#include <electricity/server/api.hpp>
#include <electricity/server/configuration.hpp>
#include <pistache/endpoint.h>
#include <tclap/CmdLine.h>
int main(int argc, char ** argv)
{
TCLAP::CmdLine cmd(
"electricity-server is a small Pistache based HTTP content server with a REST API to access the solar log database",
' ',
"1.0.0");
TCLAP::ValueArg<unsigned>
listeningPortArg("p", "listening-port", "TCP listening port number", true, 0u, "TCP listening port number");
cmd.add(listeningPortArg);
TCLAP::ValueArg<std::string> logDirectoryPath(
"d",
"data-directory",
"Absolute path pointing to the logging directory",
true,
"",
"Absolute path pointing to the logging directory");
cmd.add(logDirectoryPath);
TCLAP::ValueArg<std::string> serverDomain(
"s",
"server-domain",
"Domain this server is hosted on",
true,
"",
"Domain this server is hosted on");
cmd.add(serverDomain);
cmd.parse(argc, argv);
auto & config = Server::Configuration::Get();
config.Setup(logDirectoryPath.getValue(), serverDomain.getValue());
Pistache::Address address(Pistache::Ipv4::any(), listeningPortArg.getValue());
Pistache::Http::Endpoint server(address);
auto options = Pistache::Http::Endpoint::options().threads(2);
server.init(options);
Pistache::Rest::Router router;
Server::Api::SetupRouting(router);
server.setHandler(router.handler());
server.serve();
}

View File

@@ -1,96 +0,0 @@
#include <electricity/server/api.hpp>
#include <electricity/server/configuration.hpp>
#include <electricity/server/database.hpp>
#include <fstream>
#include <iomanip>
#include <spdlog/spdlog.h>
#include <sstream>
#include <util/date.hpp>
#include <vector>
namespace Server::Api
{
using Pistache::Http::Mime::Subtype;
using Pistache::Http::Mime::Type;
// Only allow serving to ourselves, all behind the same NAT address
bool IsRequesteeAllowed(Pistache::Http::Request const & request)
{
Configuration & config = Configuration::Get();
auto const & serverAddress = config.GetExternalServerIp();
auto realIpHeader = request.headers().tryGetRaw("X-Real-IP");
if(realIpHeader.isEmpty())
{
spdlog::error("Blocking request without X-Real-IP header");
return false;
}
if(realIpHeader.unsafeGet().value() != serverAddress)
{
spdlog::info(
"Blocking request {} due to host mismatch (expected {})",
realIpHeader.unsafeGet().value(),
serverAddress);
return false;
}
return true;
}
void GetDay(Pistache::Http::Request const & request, Pistache::Http::ResponseWriter responseWrite)
{
spdlog::info(
"{} {} {}",
Pistache::Http::methodString(request.method()),
request.resource(),
request.query().as_str());
if(!IsRequesteeAllowed(request))
{
responseWrite.send(Pistache::Http::Code::Unauthorized);
return;
}
auto const startQuery = request.query().get("start");
if(startQuery.isEmpty())
{
responseWrite.send(Pistache::Http::Code::Bad_Request);
return;
}
Util::Date const startDate(startQuery.unsafeGet());
if(!startDate.IsValid())
{
responseWrite.send(Pistache::Http::Code::Bad_Request);
return;
}
auto const stopQuery = request.query().get("stop");
if(stopQuery.isEmpty())
{
responseWrite.send(
Pistache::Http::Code::Ok,
Database::GetDetailedJsonOf(startDate),
MIME(Application, Json));
return;
}
Util::Date const stopDate(stopQuery.unsafeGet());
if(!stopDate.IsValid() || stopDate.IsBefore(startDate))
{
responseWrite.send(Pistache::Http::Code::Bad_Request);
return;
}
responseWrite.send(
Pistache::Http::Code::Ok,
Database::GetSummaryJsonOf(startDate, stopDate),
MIME(Application, Json));
}
void SetupRouting(Pistache::Rest::Router & router)
{
Pistache::Rest::Routes::Get(router, "/day", Pistache::Rest::Routes::bind(&GetDay));
}
}

View File

@@ -1,98 +0,0 @@
#include <arpa/inet.h>
#include <electricity/server/configuration.hpp>
#include <errno.h>
#include <netdb.h>
#include <regex>
#include <spdlog/spdlog.h>
#include <sys/socket.h>
namespace Server
{
Configuration::Configuration() : logDirectory() { }
void Configuration::RefreshExternalIp()
{
addrinfo hints;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
hints.ai_protocol = 0;
hints.ai_canonname = nullptr;
hints.ai_addr = nullptr;
hints.ai_next = nullptr;
addrinfo * serverInfo;
auto const result = getaddrinfo(serverDomain.c_str(), "http", &hints, &serverInfo);
if(result)
{
spdlog::error(
"Error {} when parsing domain {} to determine external IP address",
gai_strerror(result),
serverDomain);
lastExternalIp.clear();
return;
}
char addressBuffer[INET_ADDRSTRLEN];
sockaddr_in * socketAddress = reinterpret_cast<sockaddr_in *>(serverInfo->ai_addr);
if(inet_ntop(AF_INET, &socketAddress->sin_addr, addressBuffer, INET_ADDRSTRLEN) != nullptr)
{
lastIpCheckTimePoint = std::chrono::steady_clock::now();
lastExternalIp = std::string(addressBuffer);
spdlog::info("External IP address {} cached", lastExternalIp);
}
else
{
auto const error = errno;
spdlog::error("Error {} returned by inet_ntop when trying to determine external IP address", error);
lastExternalIp.clear();
}
freeaddrinfo(serverInfo);
return;
}
bool Configuration::ExternalIpRequiresRefresh() const
{
if(!std::regex_match(lastExternalIp, std::regex("^([0-9]+\\.){3}[0-9]+$")))
{
return true;
}
auto timeSinceLastRefresh = std::chrono::steady_clock::now() - lastIpCheckTimePoint;
return timeSinceLastRefresh >= std::chrono::minutes(5);
}
void Configuration::Setup(std::string & electricityLogDirectory, std::string const & _serverDomain)
{
logDirectory = electricityLogDirectory;
if(electricityLogDirectory.size() > 0 && electricityLogDirectory[electricityLogDirectory.size() - 1] != '/')
{
logDirectory += '/';
}
serverDomain = _serverDomain;
}
std::string const & Configuration::GetLogDirectory() const { return logDirectory; }
std::string const & Configuration::GetExternalServerIp()
{
if(ExternalIpRequiresRefresh())
{
std::lock_guard<std::mutex> lock(externalIpRefreshMutex);
if(ExternalIpRequiresRefresh())
{
RefreshExternalIp();
}
}
return lastExternalIp;
}
Configuration & Configuration::Get()
{
static Configuration c;
return c;
}
}

View File

@@ -1,211 +0,0 @@
#include <electricity/server/configuration.hpp>
#include <electricity/server/database.hpp>
#include <fstream>
#include <iomanip>
#include <sstream>
#include <util/date.hpp>
#include <vector>
namespace Server::Database
{
struct Record
{
private:
bool isValid;
public:
long epoch;
double currentPowerUsage;
double totalPowerUsageDay;
double totalPowerUsageNight;
double currentPowerReturn;
double totalPowerReturnDay;
double totalPowerReturnNight;
double totalGasUsage;
bool IsValid() const { return isValid; }
void AppendAsJson(std::stringstream & ss) const
{
ss << "{\"dateTime\":" << epoch << ",\"totalPowerUse\":" << totalPowerUsageDay + totalPowerUsageNight
<< ",\"totalPowerReturn\":" << totalPowerReturnDay + totalPowerReturnNight
<< ",\"totalGasUse\":" << totalGasUsage << '}';
}
Record()
: isValid(false), currentPowerUsage(0.0), totalPowerUsageDay(0.0), totalPowerUsageNight(0.0),
currentPowerReturn(0.0), totalPowerReturnDay(0.0), totalPowerReturnNight(0.0), totalGasUsage(0.0)
{ }
Record(std::string const & line)
: isValid(false), currentPowerUsage(0.0), totalPowerUsageDay(0.0), totalPowerUsageNight(0.0),
currentPowerReturn(0.0), totalPowerReturnDay(0.0), totalPowerReturnNight(0.0), totalGasUsage(0.0)
{
std::vector<std::string> values;
char const delimiter = ',';
std::size_t previousIndex = 0;
while(true)
{
auto const commaIndex = line.find(delimiter, previousIndex + 1);
if(commaIndex != std::string::npos)
{
values.push_back(line.substr(previousIndex, commaIndex - previousIndex));
previousIndex = commaIndex + 1;
}
else
{
break;
}
}
if(previousIndex < line.size())
{
values.push_back(line.substr(previousIndex));
}
if(values.size() != 10)
{
return;
}
Util::Date const date(values[0]);
if(!date.IsValid())
{
return;
}
int hours, minutes, seconds;
if(std::sscanf(values[0].substr(11).c_str(), "%d:%d:%d", &hours, &minutes, &seconds) != 3)
{
return;
}
/* Fields are separated by comma's and represent:
1 datetime
2 currentPowerUsage (kw)
3 totalPowerConsumptionDay (kwh)
4 totalPowerConsumptionNight (kwh)
5 currentPowerReturn (kw)
6 totalPowerReturnedDay (kwh)
7 totalPowerReturnedNight (kwh)
8 tarif type (unused)
9 gas timestamp (unused)
10 gas consumption (M^3)
*/
epoch = date.ToEpoch() + hours * 3600 + minutes * 60 + seconds - 3600;
currentPowerUsage = std::atof(values[1].c_str());
totalPowerUsageDay = std::atof(values[2].c_str());
totalPowerUsageNight = std::atof(values[3].c_str());
currentPowerReturn = std::atof(values[4].c_str());
totalPowerReturnDay = std::atof(values[5].c_str());
totalPowerReturnNight = std::atof(values[6].c_str());
totalGasUsage = std::atof(values[9].c_str());
isValid = true;
}
};
std::string GetFileName(Util::Date const & date)
{
std::stringstream filePath;
filePath << Configuration::Get().GetLogDirectory() << date.Year() << '/' << std::setw(2) << std::setfill('0')
<< date.Month() << '_' << std::setw(2) << date.Day() << ".txt";
return filePath.str();
}
std::vector<Record> GetFileContents(Util::Date const & date)
{
std::ifstream logFile(GetFileName(date));
if(!logFile.is_open())
{
return std::vector<Record>();
}
std::vector<Record> retval;
std::string line;
while(std::getline(logFile, line))
{
Record record(line);
if(record.IsValid())
{
retval.push_back(record);
}
}
logFile.close();
return retval;
}
std::string GetDetailedJsonOf(Util::Date const & date)
{
auto const records = GetFileContents(date);
std::stringstream json;
json << '[';
bool first = true;
for(std::size_t i = 0; i < records.size(); ++i)
{
if(!first)
{
json << ',';
}
first = false;
records[i].AppendAsJson(json);
}
json << ']';
return json.str();
}
bool GetSummaryJsonOf(Util::Date const & date, bool const prependComma, std::stringstream & json)
{
if(prependComma)
{
json << ',';
}
auto const records = GetFileContents(date);
if(records.size() < 2)
{
Record record;
record.epoch = date.ToEpoch();
record.AppendAsJson(json);
return false;
}
auto const firstRecord = records[0];
auto lastRecord = records[records.size() - 1];
lastRecord.totalPowerUsageDay -= firstRecord.totalPowerUsageDay;
lastRecord.totalPowerUsageNight -= firstRecord.totalPowerUsageNight;
lastRecord.totalPowerReturnDay -= firstRecord.totalPowerReturnDay;
lastRecord.totalPowerReturnNight -= firstRecord.totalPowerReturnNight;
lastRecord.totalGasUsage -= firstRecord.totalGasUsage;
lastRecord.AppendAsJson(json);
return true;
}
std::string GetSummaryJsonOf(Util::Date const & startDate, Util::Date const & stopDate)
{
std::stringstream json;
json << '[';
long const dayInSeconds = 24 * 60 * 60;
long const start = startDate.ToEpoch();
long const stop = stopDate.ToEpoch();
bool first = true;
for(long current = start; current <= stop; current += dayInSeconds)
{
GetSummaryJsonOf(Util::Date(current), !first, json);
first = false;
}
json << ']';
return json.str();
}
}