From 756435ae2a55cfcdedefb82e23f7abeb3c6c7fbf Mon Sep 17 00:00:00 2001 From: Tijmen van Nesselrooij Date: Wed, 28 Dec 2022 19:16:26 +0100 Subject: [PATCH] Replace pistache solar-server with .net solar api --- .gitignore | 1 + src/Solar.Api/.gitignore | 2 + src/Solar.Api/Constants/Format.cs | 6 + .../Controllers/SolarLogController.cs | 125 +++++++++ src/Solar.Api/Converters/DateOnlyConverter.cs | 24 ++ src/Solar.Api/Converters/TimeOnlyConverter.cs | 24 ++ src/Solar.Api/DatabaseContext.cs | 63 +++++ src/Solar.Api/Entities/EnvoyLog.cs | 12 + src/Solar.Api/Entities/ZeverLog.cs | 11 + src/Solar.Api/Entities/ZeverSummary.cs | 9 + src/Solar.Api/Models/DayResponse.cs | 4 + src/Solar.Api/Models/DaySummaryLog.cs | 3 + src/Solar.Api/Models/DaysResponse.cs | 3 + src/Solar.Api/Models/EnvoyDayLog.cs | 3 + src/Solar.Api/Models/MonthLog.cs | 3 + .../Models/MonthSummariesResponse.cs | 3 + src/Solar.Api/Models/ZeverDayLog.cs | 3 + src/Solar.Api/Program.cs | 82 ++++++ src/Solar.Api/Properties/launchSettings.json | 31 +++ src/Solar.Api/Services/SolarService.cs | 151 +++++++++++ src/Solar.Api/Solar.Api.csproj | 19 ++ src/Solar.Api/appsettings.Development.json | 8 + src/Solar.Api/appsettings.json | 9 + src/solar-server/api.cpp | 93 ------- src/solar-server/configuration.cpp | 24 -- src/solar-server/database/connection.cpp | 254 ------------------ src/solar-server/database/database.cpp | 33 --- src/solar-server/main.cpp | 65 ----- 28 files changed, 599 insertions(+), 469 deletions(-) create mode 100644 src/Solar.Api/.gitignore create mode 100644 src/Solar.Api/Constants/Format.cs create mode 100644 src/Solar.Api/Controllers/SolarLogController.cs create mode 100644 src/Solar.Api/Converters/DateOnlyConverter.cs create mode 100644 src/Solar.Api/Converters/TimeOnlyConverter.cs create mode 100644 src/Solar.Api/DatabaseContext.cs create mode 100644 src/Solar.Api/Entities/EnvoyLog.cs create mode 100644 src/Solar.Api/Entities/ZeverLog.cs create mode 100644 src/Solar.Api/Entities/ZeverSummary.cs create mode 100644 src/Solar.Api/Models/DayResponse.cs create mode 100644 src/Solar.Api/Models/DaySummaryLog.cs create mode 100644 src/Solar.Api/Models/DaysResponse.cs create mode 100644 src/Solar.Api/Models/EnvoyDayLog.cs create mode 100644 src/Solar.Api/Models/MonthLog.cs create mode 100644 src/Solar.Api/Models/MonthSummariesResponse.cs create mode 100644 src/Solar.Api/Models/ZeverDayLog.cs create mode 100644 src/Solar.Api/Program.cs create mode 100644 src/Solar.Api/Properties/launchSettings.json create mode 100644 src/Solar.Api/Services/SolarService.cs create mode 100644 src/Solar.Api/Solar.Api.csproj create mode 100644 src/Solar.Api/appsettings.Development.json create mode 100644 src/Solar.Api/appsettings.json delete mode 100644 src/solar-server/api.cpp delete mode 100644 src/solar-server/configuration.cpp delete mode 100644 src/solar-server/database/connection.cpp delete mode 100644 src/solar-server/database/database.cpp delete mode 100644 src/solar-server/main.cpp diff --git a/.gitignore b/.gitignore index 4492e5a..9da867e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build/ bin/ +samples/ .vscode/ !.vscode/settings.json diff --git a/src/Solar.Api/.gitignore b/src/Solar.Api/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/src/Solar.Api/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/src/Solar.Api/Constants/Format.cs b/src/Solar.Api/Constants/Format.cs new file mode 100644 index 0000000..a28fa37 --- /dev/null +++ b/src/Solar.Api/Constants/Format.cs @@ -0,0 +1,6 @@ +namespace Solar.Api.Constants; + +internal static class Format +{ + public const string Date = "yyyy-MM-dd"; +} \ No newline at end of file diff --git a/src/Solar.Api/Controllers/SolarLogController.cs b/src/Solar.Api/Controllers/SolarLogController.cs new file mode 100644 index 0000000..cf7f9a5 --- /dev/null +++ b/src/Solar.Api/Controllers/SolarLogController.cs @@ -0,0 +1,125 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using Solar.Api.Models; +using Solar.Api.Services; +using Swashbuckle.AspNetCore.Annotations; + +namespace Solar.Api.Controllers; + +[ApiController] +[Consumes(MediaTypeNames.Application.Json)] +[Produces(MediaTypeNames.Application.Json)] +public class SolarLogController : ControllerBase +{ + private readonly SolarService solarService; + private readonly ILogger logger; + + public SolarLogController( + SolarService solarService, + ILogger logger) + { + this.solarService = solarService; + this.logger = logger; + } + + [HttpGet()] + [Route("/day")] + public async Task> GetDayDetails( + [SwaggerParameter(Required = true)] + [SwaggerSchema(Format = "date")] + [FromQuery] + string date) + { + var parsedDate = TryParseDate(date); + if (!parsedDate.HasValue || parsedDate.Value.Year < 2000 || parsedDate.Value.Year > 3000) + { + logger.LogInformation("Invalid date {Date} requested", date); + return BadRequest(); + } + + return await solarService.GetDayDetails(parsedDate.Value); + } + + /// Parameter to is inclusive + [HttpGet()] + [Route("/days")] + public async Task> GetDaySummaries( + [SwaggerParameter(Required = true)] + [SwaggerSchema(Format = "date")] + [FromQuery] + string start, + [SwaggerParameter(Required = true)] + [SwaggerSchema(Format = "date")] + [FromQuery] + string stop) + { + var parsedStartDate = TryParseDate(start); + if (!parsedStartDate.HasValue || parsedStartDate.Value.Year < 2000 || parsedStartDate.Value.Year > 3000) + { + logger.LogInformation("Invalid start date {Date} requested", start); + return BadRequest(); + } + + var parsedStopDate = TryParseDate(stop); + if (!parsedStopDate.HasValue || parsedStopDate.Value.Year < 2000 || parsedStopDate.Value.Year > 3000) + { + logger.LogInformation("Invalid stop date {Date} requested", stop); + return BadRequest(); + } + else if (parsedStopDate < parsedStartDate) + { + logger.LogInformation("Stop date {StopDate} must come before start date {StartDate} requested", stop, start); + return BadRequest(); + } + + return await solarService.GetDaySummaries(parsedStartDate.Value, parsedStopDate.Value); + } + + [HttpGet()] + [Route("/months")] + public async Task> GetMonthSummaries( + [SwaggerParameter(Required = true)] + [SwaggerSchema(Format = "yyyy-MM")] + [FromQuery] + string start, + [SwaggerParameter(Required = true)] + [SwaggerSchema(Format = "yyyy-MM")] + [FromQuery] + string stop) + { + var parsedStartDate = TryParseDate($"{start}-01"); + if (!parsedStartDate.HasValue || parsedStartDate.Value.Year < 2000 || parsedStartDate.Value.Year > 3000) + { + logger.LogInformation("Invalid start year month {YearMonth} requested", start); + return BadRequest(); + } + + var parsedStopDate = TryParseDate($"{stop}-01"); + if (!parsedStopDate.HasValue || parsedStopDate.Value.Year < 2000 || parsedStopDate.Value.Year > 3000) + { + logger.LogInformation("Invalid stop year month {YearMonth} requested", stop); + return BadRequest(); + } + else if (parsedStopDate < parsedStartDate) + { + logger.LogInformation("Stop year month {StopYearMonth} must come before start year month {StartYearMonth} requested", stop, start); + return BadRequest(); + } + + return await solarService.GetMonthSummaries( + parsedStartDate.Value.Year, + parsedStartDate.Value.Month, + parsedStopDate.Value.Year, + parsedStopDate.Value.Month); + } + + private static DateOnly? TryParseDate(string value) + { + if (DateOnly.TryParseExact(value, "yyyy-MM-dd", out var date)) + { + return date; + } + + return null; + } +} diff --git a/src/Solar.Api/Converters/DateOnlyConverter.cs b/src/Solar.Api/Converters/DateOnlyConverter.cs new file mode 100644 index 0000000..660b427 --- /dev/null +++ b/src/Solar.Api/Converters/DateOnlyConverter.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Solar.Api.Converters; + +public class DateOnlyConverter : JsonConverter +{ + private const string serializationFormat = "yyyy-MM-dd"; + + public override DateOnly Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + var value = reader.GetString(); + return DateOnly.ParseExact(value!, serializationFormat); + } + + public override void Write( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString(serializationFormat)); +} \ No newline at end of file diff --git a/src/Solar.Api/Converters/TimeOnlyConverter.cs b/src/Solar.Api/Converters/TimeOnlyConverter.cs new file mode 100644 index 0000000..ca41d15 --- /dev/null +++ b/src/Solar.Api/Converters/TimeOnlyConverter.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Solar.Api.Converters; + +public class TimeOnlyConverter : JsonConverter +{ + private const string serializationFormat = "HH:mm"; + + public override TimeOnly Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + var value = reader.GetString(); + return TimeOnly.ParseExact(value!, serializationFormat); + } + + public override void Write( + Utf8JsonWriter writer, + TimeOnly value, + JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString(serializationFormat)); +} \ No newline at end of file diff --git a/src/Solar.Api/DatabaseContext.cs b/src/Solar.Api/DatabaseContext.cs new file mode 100644 index 0000000..a5e35b8 --- /dev/null +++ b/src/Solar.Api/DatabaseContext.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using Solar.Api.Entities; + +namespace Solar.Api +{ + public partial class DatabaseContext : DbContext + { + public DatabaseContext() + { + } + + public DatabaseContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet EnvoyLogs { get; set; } = null!; + public virtual DbSet ZeverLogs { get; set; } = null!; + public virtual DbSet ZeverSummaries { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.Date, "idx_EnvoyLogs_Date"); + + entity.HasIndex(e => e.TimeUtc, "idx_EnvoyLogs_TimeUtc"); + + entity.HasIndex(e => e.TotalWatts, "idx_EnvoyLogs_TotalWatts"); + + entity.Property(e => e.TotalWatts).HasColumnType("BIGINT"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.Date, "idx_ZeverLogs_Date"); + + entity.HasIndex(e => e.TimeUtc, "idx_ZeverLogs_TimeUtc"); + + entity.HasIndex(e => e.TotalWatts, "idx_ZeverLogs_TotalWatts"); + + entity.Property(e => e.TotalWatts).HasColumnType("BIGINT"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ZeverSummary"); + + entity.HasIndex(e => e.Date, "IX_ZeverSummary_Date") + .IsUnique(); + + entity.HasIndex(e => e.Date, "idx_ZeverSummary_Date") + .IsUnique(); + + entity.Property(e => e.TotalWatts).HasColumnType("BIGINT"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); + } +} diff --git a/src/Solar.Api/Entities/EnvoyLog.cs b/src/Solar.Api/Entities/EnvoyLog.cs new file mode 100644 index 0000000..bcc8a1d --- /dev/null +++ b/src/Solar.Api/Entities/EnvoyLog.cs @@ -0,0 +1,12 @@ +namespace Solar.Api.Entities +{ + public partial class EnvoyLog + { + public long Id { get; set; } + public DateOnly Date { get; set; } + public TimeOnly TimeUtc { get; set; } + public long CurrentWatts { get; set; } + public long TotalWatts { get; set; } + public long Inverters { get; set; } + } +} diff --git a/src/Solar.Api/Entities/ZeverLog.cs b/src/Solar.Api/Entities/ZeverLog.cs new file mode 100644 index 0000000..8070edb --- /dev/null +++ b/src/Solar.Api/Entities/ZeverLog.cs @@ -0,0 +1,11 @@ +namespace Solar.Api.Entities +{ + public partial class ZeverLog + { + public long Id { get; set; } + public DateOnly Date { get; set; } + public TimeOnly TimeUtc { get; set; } + public long CurrentWatts { get; set; } + public long TotalWatts { get; set; } + } +} diff --git a/src/Solar.Api/Entities/ZeverSummary.cs b/src/Solar.Api/Entities/ZeverSummary.cs new file mode 100644 index 0000000..3c85c7e --- /dev/null +++ b/src/Solar.Api/Entities/ZeverSummary.cs @@ -0,0 +1,9 @@ +namespace Solar.Api.Entities +{ + public partial class ZeverSummary + { + public long Id { get; set; } + public DateOnly Date { get; set; } + public long TotalWatts { get; set; } + } +} diff --git a/src/Solar.Api/Models/DayResponse.cs b/src/Solar.Api/Models/DayResponse.cs new file mode 100644 index 0000000..2703053 --- /dev/null +++ b/src/Solar.Api/Models/DayResponse.cs @@ -0,0 +1,4 @@ + +namespace Solar.Api.Models; + +public record DayResponse(ZeverDayLog[] ZeverLogs, EnvoyDayLog[] EnvoyLogs); \ No newline at end of file diff --git a/src/Solar.Api/Models/DaySummaryLog.cs b/src/Solar.Api/Models/DaySummaryLog.cs new file mode 100644 index 0000000..a09c350 --- /dev/null +++ b/src/Solar.Api/Models/DaySummaryLog.cs @@ -0,0 +1,3 @@ +namespace Solar.Api.Models; + +public record DaySummaryLog(DateOnly Date, int ZeverTotalWatts, int EnvoyTotalWatts); \ No newline at end of file diff --git a/src/Solar.Api/Models/DaysResponse.cs b/src/Solar.Api/Models/DaysResponse.cs new file mode 100644 index 0000000..0cfc3e8 --- /dev/null +++ b/src/Solar.Api/Models/DaysResponse.cs @@ -0,0 +1,3 @@ +namespace Solar.Api.Models; + +public record DaysResponse(DaySummaryLog[] DayLogs); \ No newline at end of file diff --git a/src/Solar.Api/Models/EnvoyDayLog.cs b/src/Solar.Api/Models/EnvoyDayLog.cs new file mode 100644 index 0000000..c32fd28 --- /dev/null +++ b/src/Solar.Api/Models/EnvoyDayLog.cs @@ -0,0 +1,3 @@ +namespace Solar.Api.Models; + +public record EnvoyDayLog(TimeOnly TimeUtc, int CurrentWatts, int TotalWatts); \ No newline at end of file diff --git a/src/Solar.Api/Models/MonthLog.cs b/src/Solar.Api/Models/MonthLog.cs new file mode 100644 index 0000000..1aae764 --- /dev/null +++ b/src/Solar.Api/Models/MonthLog.cs @@ -0,0 +1,3 @@ +namespace Solar.Api.Models; + +public record MonthLog(int Year, int Month, int ZeverTotalWatts, int EnvoyTotalWatts); \ No newline at end of file diff --git a/src/Solar.Api/Models/MonthSummariesResponse.cs b/src/Solar.Api/Models/MonthSummariesResponse.cs new file mode 100644 index 0000000..de2ad46 --- /dev/null +++ b/src/Solar.Api/Models/MonthSummariesResponse.cs @@ -0,0 +1,3 @@ +namespace Solar.Api.Models; + +public record MonthSummariesResponse(MonthLog[] MonthLogs); \ No newline at end of file diff --git a/src/Solar.Api/Models/ZeverDayLog.cs b/src/Solar.Api/Models/ZeverDayLog.cs new file mode 100644 index 0000000..f18ba28 --- /dev/null +++ b/src/Solar.Api/Models/ZeverDayLog.cs @@ -0,0 +1,3 @@ +namespace Solar.Api.Models; + +public record ZeverDayLog(TimeOnly TimeUtc, int CurrentWatts, int TotalWatts); \ No newline at end of file diff --git a/src/Solar.Api/Program.cs b/src/Solar.Api/Program.cs new file mode 100644 index 0000000..eeed7cc --- /dev/null +++ b/src/Solar.Api/Program.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; +using Solar.Api.Converters; +using Solar.Api.Services; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Solar.Api; + +public partial class Program +{ + // TODO add launch parameters + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Services + builder.Services.AddScoped(); + + // Database + builder.Services.AddDbContext(options => + { + // TODO replace with launch argument + options.UseSqlite("Data Source=/home/tijmen/project/home-data-collection-tools/samples/solarpaneloutput.db"); + }); + + // REST infrastructure + builder.Services.AddCors(options => + { + options.AddPolicy( + name: "default", + policy => + { + policy.WithOrigins("http://localhost:8080"); + }); + }); + + builder.Services.AddControllers().AddJsonOptions(config => + { + config.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + config.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + config.JsonSerializerOptions.Converters.Add(new DateOnlyConverter()); + config.JsonSerializerOptions.Converters.Add(new TimeOnlyConverter()); + }); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(config => + { + config.MapType(() => new OpenApiSchema + { + Type = "string", + Format = "date", + Example = OpenApiAnyFactory.CreateFromJson("\"2022-12-31\"") + }); + config.MapType(() => new OpenApiSchema + { + Type = "string", + Format = "time", + Example = OpenApiAnyFactory.CreateFromJson("\"13:45:42.0000000\"") + }); + + config.SupportNonNullableReferenceTypes(); + config.EnableAnnotations(); + }); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseHttpsRedirection(); + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseCors("default"); + } + + app.UseAuthorization(); + + app.MapControllers(); + + app.Run(); + } +} diff --git a/src/Solar.Api/Properties/launchSettings.json b/src/Solar.Api/Properties/launchSettings.json new file mode 100644 index 0000000..eb1942d --- /dev/null +++ b/src/Solar.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:16253", + "sslPort": 44321 + } + }, + "profiles": { + "Solar.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7012;http://localhost:5199", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Solar.Api/Services/SolarService.cs b/src/Solar.Api/Services/SolarService.cs new file mode 100644 index 0000000..2c1e044 --- /dev/null +++ b/src/Solar.Api/Services/SolarService.cs @@ -0,0 +1,151 @@ +using Microsoft.EntityFrameworkCore; +using Solar.Api.Entities; +using Solar.Api.Models; + +namespace Solar.Api.Services; + +public class SolarService +{ + private readonly DatabaseContext databaseContext; + private readonly ILogger logger; + + public SolarService( + DatabaseContext databaseContext, + ILogger logger) + { + this.databaseContext = databaseContext; + + this.logger = logger; + } + + public async Task GetDayDetails(DateOnly date) + { + var zeverRecords = await databaseContext + .ZeverLogs + .Where(zl => zl.Date == date) + .OrderBy(zl => zl.TimeUtc) + .ToArrayAsync(); + var envoyRecords = await databaseContext + .EnvoyLogs + .Where(er => er.Date == date) + .OrderBy(er => er.TimeUtc) + .ToArrayAsync(); + + NormalizeEnvoyLogs(envoyRecords); + + return new DayResponse( + zeverRecords + .Select(zr => new ZeverDayLog( + zr.TimeUtc, + (int)zr.CurrentWatts, + (int)zr.TotalWatts)) + .ToArray(), + envoyRecords + .Select(er => new EnvoyDayLog( + er.TimeUtc, + (int)er.CurrentWatts, + (int)er.TotalWatts)) + .ToArray()); + } + + public async Task GetDaySummaries(DateOnly start, DateOnly stop) + { + var zeverRecords = await databaseContext + .ZeverSummaries + .Where(zl => zl.Date >= start && zl.Date <= stop) + .ToArrayAsync(); + + List logs = new(); + var current = start; + do + { + var zeverResult = zeverRecords.FirstOrDefault(zr => zr.Date == current)?.TotalWatts ?? 0; + var envoyResult = await GetEnvoyDayTotalWatts(current); + + logs.Add(new DaySummaryLog( + current, + (int)zeverResult, + envoyResult)); + + current = current.AddDays(1); + } while (current <= stop); + + return new DaysResponse(logs.ToArray()); + } + + public async Task GetMonthSummaries(int fromYear, int fromMonth, int toYear, int toMonth) + { + var results = new List(); + var current = (Year: fromYear, Month: fromMonth); + do + { + var zeverResult = (int)await databaseContext.ZeverSummaries + .Where(zs => zs.Date.Year == current.Year && zs.Date.Month == current.Month) + .SumAsync(zs => zs.TotalWatts); + var envoyYear = await GetEnvoyMonthTotalWatts(current.Year, current.Month); + results.Add(new MonthLog(current.Year, current.Month, zeverResult, envoyYear)); + + if (current.Month == 12) + { + current = (current.Year + 1, 1); + } + else + { + current = (current.Year, current.Month + 1); + } + } while (current.Year < toYear || current.Month < toMonth); + return new MonthSummariesResponse(results.ToArray()); + } + + private async Task GetEnvoyDayTotalWatts(DateOnly date) + { + var min = await databaseContext.EnvoyLogs + .Where(el => el.Date == date) + .OrderBy(el => el.TotalWatts) + .FirstOrDefaultAsync(); + var max = await databaseContext.EnvoyLogs + .Where(el => el.Date == date) + .OrderByDescending(el => el.TotalWatts) + .FirstOrDefaultAsync(); + + if (min == null || max == null) + { + return 0; + } + + return (int)(max.TotalWatts - min.TotalWatts); + } + + private async Task GetEnvoyMonthTotalWatts(int year, int month) + { + var min = await databaseContext.EnvoyLogs + .Where(el => el.Date.Year == year && el.Date.Month == month) + .OrderBy(el => el.TotalWatts) + .FirstOrDefaultAsync(); + var max = await databaseContext.EnvoyLogs + .Where(el => el.Date.Year == year && el.Date.Month == month) + .OrderByDescending(el => el.TotalWatts) + .FirstOrDefaultAsync(); + + if (min == null || max == null) + { + return 0; + } + + return (int)(max.TotalWatts - min.TotalWatts); + } + + private static void NormalizeEnvoyLogs(EnvoyLog[] entities) + { + if (entities.Length < 1) + { + return; + } + + var baseValue = entities[0].TotalWatts; + foreach (var entity in entities) + { + entity.TotalWatts -= baseValue; + } + } +} \ No newline at end of file diff --git a/src/Solar.Api/Solar.Api.csproj b/src/Solar.Api/Solar.Api.csproj new file mode 100644 index 0000000..f57928d --- /dev/null +++ b/src/Solar.Api/Solar.Api.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/src/Solar.Api/appsettings.Development.json b/src/Solar.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Solar.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Solar.Api/appsettings.json b/src/Solar.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/Solar.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/solar-server/api.cpp b/src/solar-server/api.cpp deleted file mode 100644 index bd2eb0c..0000000 --- a/src/solar-server/api.cpp +++ /dev/null @@ -1,93 +0,0 @@ -#include -#include -#include - -namespace Api -{ - using Pistache::Http::Mime::Subtype; - using Pistache::Http::Mime::Type; - - Util::Date ParseUtcDate(std::string const & date, Util::Date const & fallbackValue) - { - Util::Date result; - if(!result.TryParse(date)) - { - return fallbackValue; - } - - return result; - } - - Util::Date ParseUtcDate(Pistache::Optional const & date, Util::Date const & fallbackValue) - { - if(date.isEmpty() || date.unsafeGet().size() != 10) - { - return fallbackValue; - } - - return ParseUtcDate(date.unsafeGet(), fallbackValue); - } - - void GetDay(Pistache::Http::Request const & request, Pistache::Http::ResponseWriter responseWrite) - { - Util::Date const start = ParseUtcDate(request.query().get("start"), Util::Date::UtcNow()); - auto const stopQuery = request.query().get("stop"); - if(stopQuery.isEmpty()) - { - responseWrite.send( - Pistache::Http::Code::Ok, - Configuration::Get().GetDatabaseConnection().GetEntireDay(start), - MIME(Application, Json)); - return; - } - - Util::Date const stop = ParseUtcDate(request.query().get("stop"), start); - if(!start.IsBefore(stop)) - { - responseWrite.send(Pistache::Http::Code::Bad_Request); - return; - } - - responseWrite.send( - Pistache::Http::Code::Ok, - Configuration::Get().GetDatabaseConnection().GetSummarizedPerDayRecords(start, stop), - MIME(Application, Json)); - } - - void GetMonth(Pistache::Http::Request const & request, Pistache::Http::ResponseWriter responseWrite) - { - auto const startQuery = request.query().get("start"); - auto const stopQuery = request.query().get("stop"); - if(startQuery.isEmpty() || stopQuery.isEmpty()) - { - responseWrite.send(Pistache::Http::Code::Bad_Request); - return; - } - - auto const start = Util::Date(startQuery.unsafeGet()); - auto stop = Util::Date(stopQuery.unsafeGet()); - if(!start.IsValid() || !stop.IsValid()) - { - responseWrite.send(Pistache::Http::Code::Bad_Request); - return; - } - - stop.SetDayToEndOfMonth(); - if(stop.IsBefore(start)) - { - responseWrite.send(Pistache::Http::Code::Bad_Request); - return; - } - - responseWrite.send( - Pistache::Http::Code::Ok, - Configuration::Get().GetDatabaseConnection().GetSummarizedPerMonthRecords(start, stop), - MIME(Application, Json)); - } - - void SetupRouting(Pistache::Rest::Router & router) - { - Pistache::Rest::Routes::Get(router, "/day", Pistache::Rest::Routes::bind(&Api::GetDay)); - Pistache::Rest::Routes::Get(router, "/month", Pistache::Rest::Routes::bind(&Api::GetMonth)); - } -} diff --git a/src/solar-server/configuration.cpp b/src/solar-server/configuration.cpp deleted file mode 100644 index 5b68929..0000000 --- a/src/solar-server/configuration.cpp +++ /dev/null @@ -1,24 +0,0 @@ -#include -#include -#include - -Configuration::Configuration() : database() { } - -Configuration & Configuration::SetupDatabase(std::string const & filePath) -{ - if(!database.Connect(filePath)) - { - throw std::runtime_error("Cannot open SQLite database at " + filePath); - } - - return *this; -} - -Database::Connection Configuration::GetDatabaseConnection() const { return database.GetConnection(); } - -Configuration & Configuration::Get() -{ - static Configuration c; - - return c; -} \ No newline at end of file diff --git a/src/solar-server/database/connection.cpp b/src/solar-server/database/connection.cpp deleted file mode 100644 index f9efdff..0000000 --- a/src/solar-server/database/connection.cpp +++ /dev/null @@ -1,254 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include - -namespace Database -{ - Connection::Connection(sqlite3 * const databaseConnectionPtr) : connectionPtr(databaseConnectionPtr) { } - - class JsonResult { - private: - long dateEpoch; - std::stringstream jsonStream; - unsigned insertions; - - void Reset() - { - insertions = 0u; - - jsonStream.clear(); - jsonStream.str(std::string()); - jsonStream << std::fixed << std::setprecision(2); - jsonStream << '['; - } - - public: - bool AddRecord(long const epoch, char const * currentWatts, double const kwh) - { - ++insertions; - jsonStream << "{\"time\":" << epoch << ",\"watt\":" << currentWatts << ",\"kwh\":" << kwh << "},"; - - return true; - } - - bool AddRecord(long const epoch, char const * currentWatts, char const * totalWatts) - { - auto const totalWattsInt = std::atoi(totalWatts); - auto const kwh = static_cast(totalWattsInt) / 1000; - - return AddRecord(epoch, currentWatts, kwh); - } - - bool AddRecord(char const * timeUtc, char const * currentWatts, char const * totalWatts) - { - auto const timeInSeconds = Util::ToSecondsFromTimeString(timeUtc); - if(timeInSeconds < 0) - { - spdlog::error("AddRecord: cannot parse {0} to hours, minutes and seconds", timeUtc); - return false; - } - - return AddRecord(timeInSeconds + dateEpoch, currentWatts, totalWatts); - } - - bool AddRecord( - unsigned const year, - unsigned const month, - unsigned const day, - char const * currentWatts, - char const * totalWatts) - { - return AddRecord(Util::GetEpoch(year, month, day), currentWatts, totalWatts); - } - - // Returns the records added thus far as JSON array and resets - // itself to an empty array afterwards. - std::string GetJsonArray() - { - if(insertions) - { - // Replace last inserted comma - jsonStream.seekp(-1, std::ios_base::end); - jsonStream << ']'; - } - else - { - jsonStream << ']'; - } - - auto const result = jsonStream.str(); - Reset(); - return result; - } - - JsonResult(long _dateEpoch) : dateEpoch(_dateEpoch), jsonStream(), insertions(0u) { Reset(); } - }; - - int DetailedCallback(void * data, int argc, char ** argv, char ** columnNames) - { - // TimeUtc, Watt and KilowattHour - if(argc != 3) - { - spdlog::error("DetailedCallback: unexpected number of arguments {0}", argc); - return -1; - } - - JsonResult * result = reinterpret_cast(data); - result->AddRecord(argv[0], argv[1], argv[2]); - - return 0; - } - - std::string Connection::GetEntireDay(Util::Date const & date) - { - std::stringstream queryStream; - queryStream << "SELECT TimeUtc, CurrentWatts, TotalWatts FROM ZeverLogs WHERE Date = " << '\'' - << date.ToISOString() << '\'' << " ORDER BY TimeUtc ASC;"; - - JsonResult result(date.ToEpoch()); - auto const sqlResult - = sqlite3_exec(connectionPtr, queryStream.str().c_str(), DetailedCallback, &result, nullptr); - if(sqlResult) - { - spdlog::error("GetEntireDay: SQLite error code {0}, returning empty JSON array", sqlResult); - return "[]"; - } - - return result.GetJsonArray(); - } - - int SummaryCallback(void * data, int argc, char ** argv, char ** columnNames) - { - // Date and KilowattHour - if(argc != 2) - { - spdlog::error("SummaryCallback: unexpected number of arguments {0}", argc); - return -1; - } - - JsonResult * result = reinterpret_cast(data); - Util::Date recordDate(argv[0]); - result->AddRecord(recordDate.Year(), recordDate.Month(), recordDate.Day(), "0", argv[1]); - - return 0; - } - - std::string Connection::GetSummarizedPerDayRecords(Util::Date const & startDate, Util::Date const & endDate) - { - std::stringstream queryStream; - queryStream << "SELECT Date, TotalWatts FROM ZeverSummary" - << " WHERE Date >= '" << startDate.ToISOString() << '\'' << " AND Date <= '" - << endDate.ToISOString() << '\'' << " ORDER BY Date;"; - - JsonResult result(0); - auto const sqlResult - = sqlite3_exec(connectionPtr, queryStream.str().c_str(), SummaryCallback, &result, nullptr); - if(sqlResult) - { - spdlog::error("GetSummarizedPerDayRecords: SQLite error code {0}, returning empty JSON array", sqlResult); - return "[]"; - } - - return result.GetJsonArray(); - } - - struct YearResult - { - int year; - std::array monthValues; - - // month is in range 1 to 12 - void AddMonthValue(int month, int value) { monthValues[month - 1] += value; } - - YearResult(int _year) : year(_year), monthValues() { } - }; - - int MonthSummaryCallback(void * data, int argc, char ** argv, char ** columnNames) - { - // Date and KilowattHour - if(argc != 2) - { - spdlog::error("MonthSummaryCallback: unexpected number of arguments {0}", argc); - return -1; - } - - Util::Date recordDate; - if(!recordDate.TryParse(argv[0])) - { - spdlog::error("MonthSummaryCallback: error parsing date {0}", argv[0]); - return -1; - } - - std::vector & yearResults = *reinterpret_cast *>(data); - auto const totalWatts = std::atoi(argv[1]); - if(std::isnan(totalWatts) || totalWatts < 0) - { - // This value makes no sense, ignore it - spdlog::warn("MonthSummaryCallback: ignoring bogus value for year month {0}", argv[1]); - return 0; - } - - for(std::size_t i = 0; i < yearResults.size(); ++i) - { - if(yearResults[i].year == recordDate.Year()) - { - yearResults[i].AddMonthValue(recordDate.Month(), totalWatts); - return 0; - } - if(yearResults[i].year > recordDate.Year()) - { - yearResults.insert(yearResults.begin() + i, YearResult(recordDate.Year())); - yearResults[i].AddMonthValue(recordDate.Month(), totalWatts); - return 0; - } - } - - yearResults.push_back(YearResult(recordDate.Year())); - yearResults[yearResults.size() - 1].AddMonthValue(recordDate.Month(), totalWatts); - - return 0; - } - - std::string Connection::GetSummarizedPerMonthRecords(Util::Date const & startDate, Util::Date const & endDate) - { - std::stringstream queryStream; - queryStream << "SELECT Date, TotalWatts FROM ZeverSummary" - << " WHERE Date >= '" << startDate.ToISOString() << '\'' << " AND Date <= '" - << endDate.ToISOString() << "';"; - - std::vector yearResults; - auto const sqlResult - = sqlite3_exec(connectionPtr, queryStream.str().c_str(), MonthSummaryCallback, &yearResults, nullptr); - if(sqlResult || yearResults.size() == 0) - { - spdlog::error( - "GetSummarizedPerMonthRecords: SQLite return code {0} and {0} years retrieved, returning empty JSON array", - sqlResult, - yearResults.size()); - return "[]"; - } - - JsonResult result(0); - for(std::size_t i = 0; i < yearResults.size(); ++i) - { - auto const year = yearResults[i].year; - for(int month = 0; month < yearResults[i].monthValues.size(); ++month) - { - if(startDate.IsAfter(year, month + 1, 1) || endDate.IsBefore(year, month + 1, 1)) - { - continue; - } - - auto const epoch = Util::GetEpoch(year, month + 1, 1); - auto const totalWatts = yearResults[i].monthValues[month]; - result.AddRecord(epoch, "0", totalWatts); - } - } - - return result.GetJsonArray(); - } -} \ No newline at end of file diff --git a/src/solar-server/database/database.cpp b/src/solar-server/database/database.cpp deleted file mode 100644 index 181baac..0000000 --- a/src/solar-server/database/database.cpp +++ /dev/null @@ -1,33 +0,0 @@ -#include - -namespace Database -{ - bool Database::Connect(std::string const & path) - { - if(connectionPtr) - { - // Already connected - return true; - } - - if(sqlite3_open(path.c_str(), &connectionPtr)) - { - return false; - } - - sqlite3_extended_result_codes(connectionPtr, 1); - return true; - } - - Connection Database::GetConnection() const { return Connection(connectionPtr); } - - Database::Database() : connectionPtr(nullptr) { } - - Database::~Database() - { - if(connectionPtr) - { - sqlite3_close(connectionPtr); - } - } -} diff --git a/src/solar-server/main.cpp b/src/solar-server/main.cpp deleted file mode 100644 index 0a061a6..0000000 --- a/src/solar-server/main.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include -#include -#include -#include -#include -#include - -std::optional ExtractArgs(int argc, char ** argv) -{ - cxxopts::Options options( - "solar-server", - "solar-server is a small Pistache based HTTP content server with a REST API to access the solar log database"); - - options.add_options()( - "p,listening-port", - "TCP port to listen on for REST API requests.", - cxxopts::value())( - "connection-string", - "Path to the sqlite3 database file", - cxxopts::value()); - - if(argc == 1) - { - std::cout << options.help() << std::endl; - return {}; - } - - try - { - auto const parsed = options.parse(argc, argv); - return parsed; - } - catch(cxxopts::OptionException const & e) - { - spdlog::error(e.what()); - return {}; - } -} - -int main(int argc, char ** argv) -{ - auto const maybeArgs = ExtractArgs(argc, argv); - if(!maybeArgs.has_value()) - { - return 1; - } - - auto const & args = maybeArgs.value(); - - Configuration & config = Configuration::Get(); - config.SetupDatabase(args["connection-string"].as()); - - Pistache::Address address(Pistache::Ipv4::any(), args["listening-port"].as()); - Pistache::Http::Endpoint server(address); - - auto options = Pistache::Http::Endpoint::options().threads(2); - server.init(options); - - Pistache::Rest::Router router; - Api::SetupRouting(router); - server.setHandler(router.handler()); - - spdlog::info("solar-server listening on localhost:{0}", args["listening-port"].as()); - server.serve(); -} \ No newline at end of file