From 05a0974ce8eb0c57f2b441a3e03e724b31113319 Mon Sep 17 00:00:00 2001 From: Tijmen van Nesselrooij Date: Wed, 3 Sep 2025 17:25:57 +0200 Subject: [PATCH] Rewrite .NET solar API in rust --- Cargo.lock | 15 + Cargo.toml | 4 +- README.md | 3 +- home-data-collection-tools.sln | 22 - src/Solar.Api/.gitignore | 2 - src/Solar.Api/Constants/CacheKeys.cs | 9 - src/Solar.Api/Constants/Format.cs | 6 - .../Controllers/SolarLogController.cs | 145 ----- src/Solar.Api/Converters/DateOnlyConverter.cs | 25 - src/Solar.Api/Converters/TimeOnlyConverter.cs | 25 - src/Solar.Api/DatabaseContext.cs | 57 -- 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 | 3 - 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 | 88 --- src/Solar.Api/Properties/launchSettings.json | 31 - src/Solar.Api/Services/SolarService.cs | 188 ------ src/Solar.Api/Solar.Api.csproj | 19 - src/Solar.Api/appsettings.Development.json | 11 - src/Solar.Api/appsettings.json | 12 - src/solar_api/Cargo.toml | 15 + src/solar_api/src/database.rs | 596 ++++++++++++++++++ src/solar_api/src/main.rs | 284 +++++++++ src/solar_api/src/query_helpers.rs | 23 + src/solar_api/src/year_month.rs | 96 +++ systemd/electricity-server.service | 4 +- systemd/solar-server.service | 7 +- 34 files changed, 1037 insertions(+), 703 deletions(-) delete mode 100644 home-data-collection-tools.sln delete mode 100644 src/Solar.Api/.gitignore delete mode 100644 src/Solar.Api/Constants/CacheKeys.cs delete mode 100644 src/Solar.Api/Constants/Format.cs delete mode 100644 src/Solar.Api/Controllers/SolarLogController.cs delete mode 100644 src/Solar.Api/Converters/DateOnlyConverter.cs delete mode 100644 src/Solar.Api/Converters/TimeOnlyConverter.cs delete mode 100644 src/Solar.Api/DatabaseContext.cs delete mode 100644 src/Solar.Api/Entities/EnvoyLog.cs delete mode 100644 src/Solar.Api/Entities/ZeverLog.cs delete mode 100644 src/Solar.Api/Entities/ZeverSummary.cs delete mode 100644 src/Solar.Api/Models/DayResponse.cs delete mode 100644 src/Solar.Api/Models/DaySummaryLog.cs delete mode 100644 src/Solar.Api/Models/DaysResponse.cs delete mode 100644 src/Solar.Api/Models/EnvoyDayLog.cs delete mode 100644 src/Solar.Api/Models/MonthLog.cs delete mode 100644 src/Solar.Api/Models/MonthSummariesResponse.cs delete mode 100644 src/Solar.Api/Models/ZeverDayLog.cs delete mode 100644 src/Solar.Api/Program.cs delete mode 100644 src/Solar.Api/Properties/launchSettings.json delete mode 100644 src/Solar.Api/Services/SolarService.cs delete mode 100644 src/Solar.Api/Solar.Api.csproj delete mode 100644 src/Solar.Api/appsettings.Development.json delete mode 100644 src/Solar.Api/appsettings.json create mode 100644 src/solar_api/Cargo.toml create mode 100644 src/solar_api/src/database.rs create mode 100644 src/solar_api/src/main.rs create mode 100644 src/solar_api/src/query_helpers.rs create mode 100644 src/solar_api/src/year_month.rs diff --git a/Cargo.lock b/Cargo.lock index 4744403..66a3a68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1049,6 +1049,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "solar_api" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "colog", + "log", + "rusqlite", + "serde", + "serde_json", + "tokio", + "warp", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 1d56b74..1a89ee0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,6 @@ resolver = "3" members = [ "src/electricity_api" -] +, "src/solar_api"] -[workspace.dependencies] \ No newline at end of file +[workspace.dependencies] diff --git a/README.md b/README.md index 3652ea9..7a44576 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ A mono repository containing all the homebrew collectors and REST APIs running o ### Runtime (server) -- .NET 6, [website](https://dotnet.microsoft.com/en-us/download/dotnet/6.0) - Curl: the multiprotocol file tranfser library, [website](https://curl.haxx.se/libcurl/) - GNU Make, [website](https://www.gnu.org/software/make/) - Libfmt, [website](https://github.com/fmtlib/fmt) +- Rust, [website](https://www.rust-lang.org/learn/get-started) - Spdlog, [website](https://github.com/gabime/spdlog) - Sqlite3: a small SQL database engine, v3.39.2, [website](https://www.sqlite.org/index.html) @@ -27,7 +27,6 @@ In addition to all the runtime dependencies the following dependencies are requi - A C++20 compatible compiler - Nlohmann JSON, [website](https://github.com/nlohmann/json/tree/v3.11.2) -- cxxopts, [website](https://github.com/jarro2783/cxxopts/tree/v3.0.0) ## Examples diff --git a/home-data-collection-tools.sln b/home-data-collection-tools.sln deleted file mode 100644 index 800b2d6..0000000 --- a/home-data-collection-tools.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Solar.Api", "src\Solar.Api\Solar.Api.csproj", "{5EC49F22-9031-4124-88E6-C7C1F636695A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5EC49F22-9031-4124-88E6-C7C1F636695A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5EC49F22-9031-4124-88E6-C7C1F636695A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5EC49F22-9031-4124-88E6-C7C1F636695A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5EC49F22-9031-4124-88E6-C7C1F636695A}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/src/Solar.Api/.gitignore b/src/Solar.Api/.gitignore deleted file mode 100644 index cd42ee3..0000000 --- a/src/Solar.Api/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -bin/ -obj/ diff --git a/src/Solar.Api/Constants/CacheKeys.cs b/src/Solar.Api/Constants/CacheKeys.cs deleted file mode 100644 index c845622..0000000 --- a/src/Solar.Api/Constants/CacheKeys.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Solar.Api.Constants; - -internal static class CacheKeys -{ - public static string GetMonthSummaryCacheKey(string source, int year, int month) - { - return $"month-summary-{source}-{year}-{month}"; - } -} diff --git a/src/Solar.Api/Constants/Format.cs b/src/Solar.Api/Constants/Format.cs deleted file mode 100644 index a82ecc2..0000000 --- a/src/Solar.Api/Constants/Format.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Solar.Api.Constants; - -internal static class Format -{ - public const string Date = "yyyy-MM-dd"; -} diff --git a/src/Solar.Api/Controllers/SolarLogController.cs b/src/Solar.Api/Controllers/SolarLogController.cs deleted file mode 100644 index 9f9fd3e..0000000 --- a/src/Solar.Api/Controllers/SolarLogController.cs +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index 9713aa7..0000000 --- a/src/Solar.Api/Converters/DateOnlyConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -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)); -} diff --git a/src/Solar.Api/Converters/TimeOnlyConverter.cs b/src/Solar.Api/Converters/TimeOnlyConverter.cs deleted file mode 100644 index 3afc2e2..0000000 --- a/src/Solar.Api/Converters/TimeOnlyConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -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)); -} diff --git a/src/Solar.Api/DatabaseContext.cs b/src/Solar.Api/DatabaseContext.cs deleted file mode 100644 index 7c9e2bf..0000000 --- a/src/Solar.Api/DatabaseContext.cs +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index bcc8a1d..0000000 --- a/src/Solar.Api/Entities/EnvoyLog.cs +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 8070edb..0000000 --- a/src/Solar.Api/Entities/ZeverLog.cs +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 3c85c7e..0000000 --- a/src/Solar.Api/Entities/ZeverSummary.cs +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 2ec248f..0000000 --- a/src/Solar.Api/Models/DayResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Solar.Api.Models; - -public record DayResponse(ZeverDayLog[] ZeverLogs, EnvoyDayLog[] EnvoyLogs); diff --git a/src/Solar.Api/Models/DaySummaryLog.cs b/src/Solar.Api/Models/DaySummaryLog.cs deleted file mode 100644 index 267b24c..0000000 --- a/src/Solar.Api/Models/DaySummaryLog.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Solar.Api.Models; - -public record DaySummaryLog(DateOnly Date, int ZeverTotalWatts, int EnvoyTotalWatts); diff --git a/src/Solar.Api/Models/DaysResponse.cs b/src/Solar.Api/Models/DaysResponse.cs deleted file mode 100644 index 186f37e..0000000 --- a/src/Solar.Api/Models/DaysResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Solar.Api.Models; - -public record DaysResponse(DaySummaryLog[] DayLogs); diff --git a/src/Solar.Api/Models/EnvoyDayLog.cs b/src/Solar.Api/Models/EnvoyDayLog.cs deleted file mode 100644 index 889d1ca..0000000 --- a/src/Solar.Api/Models/EnvoyDayLog.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Solar.Api.Models; - -public record EnvoyDayLog(TimeOnly TimeUtc, int CurrentWatts, int TotalWatts); diff --git a/src/Solar.Api/Models/MonthLog.cs b/src/Solar.Api/Models/MonthLog.cs deleted file mode 100644 index a89aafc..0000000 --- a/src/Solar.Api/Models/MonthLog.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Solar.Api.Models; - -public record MonthLog(int Year, int Month, int ZeverTotalWatts, int EnvoyTotalWatts); diff --git a/src/Solar.Api/Models/MonthSummariesResponse.cs b/src/Solar.Api/Models/MonthSummariesResponse.cs deleted file mode 100644 index 34f6025..0000000 --- a/src/Solar.Api/Models/MonthSummariesResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Solar.Api.Models; - -public record MonthSummariesResponse(MonthLog[] MonthLogs); diff --git a/src/Solar.Api/Models/ZeverDayLog.cs b/src/Solar.Api/Models/ZeverDayLog.cs deleted file mode 100644 index e05d7e1..0000000 --- a/src/Solar.Api/Models/ZeverDayLog.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Solar.Api.Models; - -public record ZeverDayLog(TimeOnly TimeUtc, int CurrentWatts, int TotalWatts); diff --git a/src/Solar.Api/Program.cs b/src/Solar.Api/Program.cs deleted file mode 100644 index a17ca3c..0000000 --- a/src/Solar.Api/Program.cs +++ /dev/null @@ -1,88 +0,0 @@ -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 -{ - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - // Services - builder.Services.AddScoped(); - builder.Services.AddMemoryCache(); - - // Database - builder.Services.AddDbContext(options => - { - options.UseSqlite(builder.Configuration.GetConnectionString("Database")); - }); - - // 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 deleted file mode 100644 index eb1942d..0000000 --- a/src/Solar.Api/Properties/launchSettings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$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 deleted file mode 100644 index fa9f5e5..0000000 --- a/src/Solar.Api/Services/SolarService.cs +++ /dev/null @@ -1,188 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; -using Solar.Api.Constants; -using Solar.Api.Entities; -using Solar.Api.Models; - -namespace Solar.Api.Services; - -public class SolarService -{ - private readonly DatabaseContext databaseContext; - private readonly IMemoryCache memoryCache; - private readonly ILogger logger; - - public SolarService( - DatabaseContext databaseContext, - IMemoryCache memoryCache, - ILogger logger - ) - { - this.databaseContext = databaseContext; - this.memoryCache = memoryCache; - - 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() - ); - } - - private static void NormalizeEnvoyLogs(EnvoyLog[] entities) - { - if (entities.Length < 1) - { - return; - } - - var baseValue = entities[0].TotalWatts; - foreach (var entity in entities) - { - entity.TotalWatts -= baseValue; - } - } - - 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()); - } - - 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); - } - - 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 = await GetOrAddToCache( - "zever", - current.Year, - current.Month, - GetZeverMonthTotalWatts - ); - var envoyYear = await GetOrAddToCache( - "envoy", - current.Year, - current.Month, - GetEnvoyMonthTotalWatts - ); - 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.Year == toYear && current.Month <= toMonth)); - return new MonthSummariesResponse(results.ToArray()); - } - - private async Task GetZeverMonthTotalWatts(int year, int month) - { - return (int) - await databaseContext - .ZeverSummaries.Where(zs => zs.Date.Year == year && zs.Date.Month == month) - .SumAsync(zs => zs.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 async Task GetOrAddToCache( - string source, - int year, - int month, - Func> fetchMethod - ) - { - return await memoryCache.GetOrCreateAsync( - CacheKeys.GetMonthSummaryCacheKey(source, year, month), - async (cacheEntry) => - { - if (DateTime.Today.Month == month && DateTime.Today.Year == year) - { - cacheEntry.SlidingExpiration = TimeSpan.FromHours(12); - } - return await fetchMethod(year, month); - } - ); - } -} diff --git a/src/Solar.Api/Solar.Api.csproj b/src/Solar.Api/Solar.Api.csproj deleted file mode 100644 index a6377af..0000000 --- a/src/Solar.Api/Solar.Api.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - net8.0 - enable - enable - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - \ No newline at end of file diff --git a/src/Solar.Api/appsettings.Development.json b/src/Solar.Api/appsettings.Development.json deleted file mode 100644 index 58af2e9..0000000 --- a/src/Solar.Api/appsettings.Development.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "ConnectionStrings": { - "Database": "Data Source=/home/tijmen/project/home-data-collection-tools/samples/solarpaneloutput.db" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/Solar.Api/appsettings.json b/src/Solar.Api/appsettings.json deleted file mode 100644 index 7a9c5de..0000000 --- a/src/Solar.Api/appsettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "ConnectionStrings": { - "Database": "Data Source=/home/tijmen/project/home-data-collection-tools/samples/solarpaneloutput.db" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/src/solar_api/Cargo.toml b/src/solar_api/Cargo.toml new file mode 100644 index 0000000..ffd9ce7 --- /dev/null +++ b/src/solar_api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "solar_api" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = "0.4.41" +clap = { version = "4.5.46", features = ["derive"] } +colog = "1.3.0" +log = "0.4.27" +rusqlite = { version = "0.37.0", features = ["bundled"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.143" +tokio = { version = "1", features = ["full"] } +warp = { version = "0.4", features = ["server"] } diff --git a/src/solar_api/src/database.rs b/src/solar_api/src/database.rs new file mode 100644 index 0000000..d3a929d --- /dev/null +++ b/src/solar_api/src/database.rs @@ -0,0 +1,596 @@ +use crate::year_month::YearMonth; + +pub fn get_day_solar_entities(date: &chrono::NaiveDate, database_path: &str) -> DayEntities { + let mut result = DayEntities { + envoy_logs: Vec::new(), + zever_logs: Vec::new(), + }; + + let maybe_connection = rusqlite::Connection::open(database_path); + if maybe_connection.is_err() { + log::error!( + "Failed to open database connection to {} with error {}", + database_path, + maybe_connection.unwrap_err() + ); + return result; + } + + let connection = maybe_connection.unwrap(); + get_day_solar_entities_envoy(&date, &connection, &mut result.envoy_logs); + get_day_solar_entities_zever(&date, &connection, &mut result.zever_logs); + return result; +} + +fn get_day_solar_entities_envoy( + date: &chrono::NaiveDate, + connection: &rusqlite::Connection, + output: &mut Vec, +) { + let maybe_statement = connection.prepare( + "SELECT + \"TimeUtc\", + \"CurrentWatts\", + \"TotalWatts\" + FROM \"EnvoyLogs\" + WHERE \"Date\" = :date + ORDER BY \"TimeUtc\";", + ); + if maybe_statement.is_err() { + log::error!( + "Failed to prepare envoy solar database statement with error {}", + maybe_statement.unwrap_err() + ); + return; + } + + let formatted_date = format!("{}", date); + log::info!("Fetching envoy solar day data for date {}", formatted_date); + let mut statement = maybe_statement.unwrap(); + let maybe_row_iterator = statement.query_map(&[(":date", &formatted_date)], |row| { + Ok(EnvoyLogEntity { + time_utc: row.get(0)?, + current_watts: row.get(1)?, + total_watts: row.get(2)?, + }) + }); + + match maybe_row_iterator { + Ok(iterator) => { + for row in iterator { + match row { + Ok(entity) => { + output.push(entity); + } + Err(error) => log::error!( + "Failed to interpret envoy solar row from SQL query with error {}", + error + ), + } + } + } + Err(error) => { + log::error!( + "Failed to execute envoy solar data SQL query with error {}", + error + ); + } + } + + // Envoy logs have a total_watts column that is cumulative instead of + // resetting every day like Zever logs are + if output.len() > 0 { + let offset = output[0].total_watts; + output[0].total_watts = 0; + for entity in output.iter_mut().skip(1) { + entity.total_watts -= offset; + } + } +} + +fn get_day_solar_entities_zever( + date: &chrono::NaiveDate, + connection: &rusqlite::Connection, + output: &mut Vec, +) { + let maybe_statement = connection.prepare( + "SELECT + \"TimeUtc\", + \"CurrentWatts\", + \"TotalWatts\" + FROM \"ZeverLogs\" + WHERE \"Date\" = :date + ORDER BY \"TimeUtc\";", + ); + if maybe_statement.is_err() { + log::error!( + "Failed to prepare database statement with error {}", + maybe_statement.unwrap_err() + ); + return; + } + + let formatted_date = format!("{}", date); + log::info!("Fetching zever solar day data for date {}", formatted_date); + let mut statement = maybe_statement.unwrap(); + let maybe_row_iterator = statement.query_map(&[(":date", &formatted_date)], |row| { + Ok(ZeverLogEntity { + time_utc: row.get(0)?, + current_watts: row.get(1)?, + total_watts: row.get(2)?, + }) + }); + + match maybe_row_iterator { + Ok(iterator) => { + for row in iterator { + match row { + Ok(entity) => { + output.push(entity); + } + Err(error) => log::error!( + "Failed to interpret zever solar row from SQL query with error {}", + error + ), + } + } + } + Err(error) => { + log::error!( + "Failed to execute zever solar data SQL query with error {}", + error + ); + } + } +} + +pub struct EnvoyLogEntity { + pub time_utc: String, + pub current_watts: i32, + pub total_watts: i32, +} + +pub struct ZeverLogEntity { + pub time_utc: String, + pub current_watts: i32, + pub total_watts: i32, +} + +pub struct DayEntities { + pub envoy_logs: Vec, + pub zever_logs: Vec, +} + +pub fn get_day_solar_summaries( + start: &chrono::NaiveDate, + stop: &chrono::NaiveDate, + database_path: &str, +) -> Vec { + let mut result = Vec::new(); + + let mut current_date = *start; + loop { + result.push(LogDateSummary { + date: current_date.to_string(), + envoy_total_watts: 0, + zever_total_watts: 0, + }); + match current_date.checked_add_days(chrono::Days::new(1)) { + Some(next_date) => { + if next_date > *stop { + break; + } + current_date = next_date + } + None => break, + } + } + + let maybe_connection = rusqlite::Connection::open(database_path); + if maybe_connection.is_err() { + log::error!( + "Failed to open database connection to {} with error {}", + database_path, + maybe_connection.unwrap_err() + ); + return result; + } + + let connection = maybe_connection.unwrap(); + get_day_solar_summaries_envoy(&start, &stop, &connection, &mut result); + get_day_solar_summaries_zever(&start, &stop, &connection, &mut result); + + return result; +} + +fn get_day_solar_summaries_envoy( + start: &chrono::NaiveDate, + stop: &chrono::NaiveDate, + connection: &rusqlite::Connection, + output: &mut Vec, +) { + let maybe_statement = connection.prepare( + "SELECT \"Date\", MAX(\"TotalWatts\"), MIN(\"TotalWatts\") + FROM \"EnvoyLogs\" + WHERE \"Date\" >= :start_date AND \"Date\" <= :stop_date + GROUP BY \"Date\";", + ); + if maybe_statement.is_err() { + log::error!( + "Failed to prepare envoy day summary database statement with error {}", + maybe_statement.unwrap_err() + ); + return; + } + + let formatted_start_date = format!("{}", start); + let formatted_stop_date = format!("{}", stop); + log::info!( + "Fetching envoy solar day summary data for date range {} to {}", + formatted_start_date, + formatted_stop_date + ); + let mut statement = maybe_statement.unwrap(); + let maybe_row_iterator = statement.query_map( + &[ + (":start_date", &formatted_start_date), + (":stop_date", &formatted_stop_date), + ], + |row| { + Ok(LogDateSummary { + date: row.get(0)?, + envoy_total_watts: row.get::(1)? - row.get::(2)?, + zever_total_watts: 0, + }) + }, + ); + + match maybe_row_iterator { + Ok(iterator) => { + for row in iterator { + match row { + Ok(entity) => match output.iter_mut().find(|e| e.date == entity.date) { + Some(existing_output_element) => { + existing_output_element.envoy_total_watts = entity.envoy_total_watts + } + _ => { + log::error!( + "Processed entity with non existing date {} in output of envoy solar summary data", + entity.date + ); + continue; + } + }, + Err(error) => log::error!( + "Failed to interpret envoy solar summary row from SQL query with error {}", + error + ), + } + } + } + Err(error) => { + log::error!( + "Failed to execute envoy solar summary data SQL query with error {}", + error + ); + } + } +} + +fn get_day_solar_summaries_zever( + start: &chrono::NaiveDate, + stop: &chrono::NaiveDate, + connection: &rusqlite::Connection, + output: &mut Vec, +) { + let maybe_statement = connection.prepare( + "SELECT \"Date\", MAX(\"TotalWatts\") + FROM \"ZeverLogs\" + WHERE \"Date\" >= :start_date AND \"Date\" <= :stop_date + GROUP BY \"Date\";", + ); + if maybe_statement.is_err() { + log::error!( + "Failed to prepare zever day summary database statement with error {}", + maybe_statement.unwrap_err() + ); + return; + } + + let formatted_start_date = format!("{}", start); + let formatted_stop_date = format!("{}", stop); + log::info!( + "Fetching zever solar day summary data for date range {} to {}", + formatted_start_date, + formatted_stop_date + ); + let mut statement = maybe_statement.unwrap(); + let maybe_row_iterator = statement.query_map( + &[ + (":start_date", &formatted_start_date), + (":stop_date", &formatted_stop_date), + ], + |row| { + Ok(LogDateSummary { + date: row.get(0)?, + envoy_total_watts: 0, + zever_total_watts: row.get(1)?, + }) + }, + ); + + match maybe_row_iterator { + Ok(iterator) => { + for row in iterator { + match row { + Ok(entity) => match output.iter_mut().find(|e| e.date == entity.date) { + Some(existing_output_element) => { + existing_output_element.zever_total_watts = entity.zever_total_watts + } + _ => { + log::error!( + "Processed entity with non existing date {} in output of zever solar summary data", + entity.date + ); + continue; + } + }, + Err(error) => log::error!( + "Failed to interpret zever solar summary row from SQL query with error {}", + error + ), + } + } + } + Err(error) => { + log::error!( + "Failed to execute zever solar summary data SQL query with error {}", + error + ); + } + } +} + +pub struct LogDateSummary { + pub date: String, + pub envoy_total_watts: i32, + pub zever_total_watts: i32, +} + +pub fn get_month_solar_summaries( + start: &YearMonth, + stop: &YearMonth, + database_path: &str, +) -> Vec { + let mut result = Vec::new(); + let mut current_year_month = *start; + loop { + result.push(LogYearMonthSummary { + year: current_year_month.year, + month: current_year_month.month, + envoy_total_watts: 0, + zever_total_watts: 0, + }); + + current_year_month = current_year_month.get_next_month(); + if current_year_month > *stop { + break; + } + } + + let maybe_connection = rusqlite::Connection::open(database_path); + if maybe_connection.is_err() { + log::error!( + "Failed to open database connection to {} with error {}", + database_path, + maybe_connection.unwrap_err() + ); + return result; + } + + let connection = maybe_connection.unwrap(); + get_month_solar_summaries_envoy(&start, &stop, &connection, &mut result); + get_month_solar_summaries_zever(&start, &stop, &connection, &mut result); + + result +} + +fn get_month_solar_summaries_envoy( + start: &YearMonth, + stop: &YearMonth, + connection: &rusqlite::Connection, + output: &mut Vec, +) { + let formatted_start_date = format!("{}-01", start); + let formatted_stop_date = format!("{}-31", stop); + log::info!( + "Fetching envoy solar month summary data for date range {} to {}", + formatted_start_date, + formatted_stop_date + ); + + let mut statement = match connection.prepare( + "SELECT + STRFTIME('%Y-%m', \"Date\") AS YearMonth, + MAX(TotalWatts), + MIN(TotalWatts) + FROM EnvoyLogs + WHERE \"Date\" >= :start_date AND \"Date\" <= :stop_date + GROUP BY YearMonth + ORDER BY YearMonth;", + ) { + Ok(s) => s, + Err(e) => { + log::error!( + "Failed to prepare envoy month summary database statement with error {}", + e + ); + return; + } + }; + + let rows = match statement.query_map( + &[ + (":start_date", &formatted_start_date), + (":stop_date", &formatted_stop_date), + ], + |row| { + Ok(LogYearMonthSummaryEntity { + year_month: row.get(0)?, + total_watts: row.get::(1)? - row.get::(2)?, + }) + }, + ) { + Ok(it) => it, + Err(e) => { + log::error!( + "Failed to execute envoy solar month summary data SQL query with error {}", + e + ); + return; + } + }; + + for row in rows { + let entity = match row { + Ok(e) => e, + Err(e) => { + log::error!( + "Failed to interpret envoy month solar summary row from SQL query with error {}", + e + ); + continue; + } + }; + + let Some(entity_year_month) = YearMonth::try_parse(&entity.year_month) else { + log::error!( + "Processed entity with invalid year month {} in output of envoy solar month summary data", + entity.year_month + ); + continue; + }; + + let Some(existing_output_element) = output + .iter_mut() + .find(|e| e.year == entity_year_month.year && e.month == entity_year_month.month) + else { + log::error!( + "Processed entity with non existing year month {} in output of envoy solar month summary data", + entity.year_month + ); + continue; + }; + + existing_output_element.envoy_total_watts = entity.total_watts; + } +} + +fn get_month_solar_summaries_zever( + start: &YearMonth, + stop: &YearMonth, + connection: &rusqlite::Connection, + output: &mut Vec, +) { + let formatted_start_date = format!("{}-01", start); + let formatted_stop_date = format!("{}-31", stop); + log::info!( + "Fetching zever solar month summary data for date range {} to {}", + formatted_start_date, + formatted_stop_date + ); + + let mut statement = match connection.prepare( + "WITH MaximumValuesPerDate AS ( + SELECT \"Date\", MAX(\"TotalWatts\") AS \"TotalWatts\" + FROM \"ZeverLogs\" + WHERE + \"Date\" >= :start_date AND + \"Date\" <= :stop_date + GROUP BY \"Date\" + ) + SELECT + STRFTIME('%Y-%m', \"Date\") AS YearMonth, + SUM(TotalWatts) + FROM MaximumValuesPerDate + GROUP BY YearMonth + ORDER BY YearMonth;", + ) { + Ok(s) => s, + Err(e) => { + log::error!( + "Failed to prepare zever month summary database statement with error {}", + e + ); + return; + } + }; + + let rows = match statement.query_map( + &[ + (":start_date", &formatted_start_date), + (":stop_date", &formatted_stop_date), + ], + |row| { + Ok(LogYearMonthSummaryEntity { + year_month: row.get(0)?, + total_watts: row.get(1)?, + }) + }, + ) { + Ok(it) => it, + Err(e) => { + log::error!( + "Failed to execute zever solar month summary data SQL query with error {}", + e + ); + return; + } + }; + + for row in rows { + let entity = match row { + Ok(e) => e, + Err(e) => { + log::error!( + "Failed to interpret zever month solar summary row from SQL query with error {}", + e + ); + continue; + } + }; + + let Some(entity_year_month) = YearMonth::try_parse(&entity.year_month) else { + log::error!( + "Processed entity with invalid year month {} in output of zever solar month summary data", + entity.year_month + ); + continue; + }; + + let Some(existing_output_element) = output + .iter_mut() + .find(|e| e.year == entity_year_month.year && e.month == entity_year_month.month) + else { + log::error!( + "Processed entity with non existing year month {} in output of zever solar month summary data", + entity.year_month + ); + continue; + }; + + existing_output_element.zever_total_watts = entity.total_watts; + } +} + +struct LogYearMonthSummaryEntity { + pub year_month: String, + pub total_watts: i32, +} + +pub struct LogYearMonthSummary { + pub year: i32, + pub month: u8, + pub envoy_total_watts: i32, + pub zever_total_watts: i32, +} diff --git a/src/solar_api/src/main.rs b/src/solar_api/src/main.rs new file mode 100644 index 0000000..fa04042 --- /dev/null +++ b/src/solar_api/src/main.rs @@ -0,0 +1,284 @@ +use clap::Parser; +use std::collections::HashMap; +use warp::Filter; +use warp::http::Response; + +use crate::year_month::YearMonth; + +mod database; +mod query_helpers; +mod year_month; + +#[tokio::main] +async fn main() { + let args = std::sync::Arc::new(CommandLineArgs::parse()); + + colog::init(); + log::info!("Solar API is starting up"); + + serve(&args).await; +} + +#[derive(clap::Parser)] +struct CommandLineArgs { + #[arg(long = "database-path")] + database_path: String, + + #[arg(long = "listening-port")] + listening_port: u16, +} + +async fn serve(configuration: &std::sync::Arc) { + let day = warp::get() + .and(warp::path("day")) + .and(warp::query::>()) + .and(warp::any().map({ + let configuration = configuration.clone(); + move || configuration.clone() + })) + .map( + |query: HashMap, configuration: std::sync::Arc| { + match query_helpers::try_parse_query_date(query.get("date")) { + Some(date) => { + let json = get_day_solar_json(&date, &configuration.database_path); + return Response::builder() + .header("Content-Type", "application/json") + .body(json); + } + _ => Response::builder() + .status(400) + .body(String::from("Unsupported \"date\" param in query.")), + } + }, + ); + + let days = warp::get() + .and(warp::path("days")) + .and(warp::query::>()) + .and(warp::any().map({ + let configuration = configuration.clone(); + move || configuration.clone() + })) + .map( + |query: HashMap, configuration: std::sync::Arc| { + let maybe_start = query_helpers::try_parse_query_date(query.get("start")); + if maybe_start.is_none() { + return Response::builder() + .status(400) + .body(String::from("Unsupported \"start\" param in query.")); + } + + let maybe_stop = query_helpers::try_parse_query_date(query.get("stop")); + if maybe_stop.is_none() { + return Response::builder() + .status(400) + .body(String::from("Unsupported \"stop\" param in query.")); + } + + let start = maybe_start.unwrap(); + let stop = maybe_stop.unwrap(); + if start > stop { + return Response::builder().status(400).body(String::from( + "Param \"start\" must be smaller than or equal to param \"stop\" in query.", + )); + } + + let maybe_day_before_start = start.checked_sub_days(chrono::Days::new(1)); + if maybe_day_before_start.is_none() { + return Response::builder() + .status(400) + .body(String::from("Param \"start\" in query is too early.")); + } + + let day_before_start = maybe_day_before_start.unwrap(); + let json = + get_days_solar_json(&day_before_start, &stop, &configuration.database_path); + return Response::builder() + .header("Content-Type", "application/json") + .body(json); + }, + ); + + let months = warp::get() + .and(warp::path("months")) + .and(warp::query::>()) + .and(warp::any().map({ + let configuration = configuration.clone(); + move || configuration.clone() + })) + .map( + |query: HashMap, configuration: std::sync::Arc| { + let maybe_start = query_helpers::try_parse_query_month_year(query.get("start")); + if maybe_start.is_none() { + return Response::builder() + .status(400) + .body(String::from("Unsupported \"start\" param in query.")); + } + + let maybe_stop = query_helpers::try_parse_query_month_year(query.get("stop")); + if maybe_stop.is_none() { + return Response::builder() + .status(400) + .body(String::from("Unsupported \"stop\" param in query.")); + } + + let start = maybe_start.unwrap(); + let stop = maybe_stop.unwrap(); + if start > stop { + return Response::builder().status(400).body(String::from( + "Param \"start\" must be smaller than or equal to param \"stop\" in query.", + )); + } + + let json = get_months_solar_json(&start, &stop, &configuration.database_path); + return Response::builder() + .header("Content-Type", "application/json") + .body(json); + }, + ); + + warp::serve(day.or(days).or(months)) + .run(([127, 0, 0, 1], configuration.listening_port)) + .await; +} + +fn get_day_solar_json(date: &chrono::NaiveDate, database_path: &str) -> String { + let entities = database::get_day_solar_entities(date, database_path); + let mut response_model = DayResponse { + envoy_logs: Vec::new(), + zever_logs: Vec::new(), + }; + + for entity in entities.envoy_logs { + response_model.envoy_logs.push(DayResponseLogItem { + time_utc: entity.time_utc, + current_watts: entity.current_watts, + total_watts: entity.total_watts, + }); + } + + for entity in entities.zever_logs { + response_model.zever_logs.push(DayResponseLogItem { + time_utc: entity.time_utc, + current_watts: entity.current_watts, + total_watts: entity.total_watts, + }); + } + + match serde_json::to_string(&response_model) { + Ok(json) => json, + Err(error) => { + log::error!( + "Failed to format JSON data for date {} with error {}", + date, + error + ); + return String::from("[]"); + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct DayResponse { + envoy_logs: Vec, + zever_logs: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct DayResponseLogItem { + time_utc: String, // HH:mm:ss + current_watts: i32, + total_watts: i32, +} + +fn get_days_solar_json( + day_before_start: &chrono::NaiveDate, + stop: &chrono::NaiveDate, + database_path: &str, +) -> String { + let summaries = database::get_day_solar_summaries(&day_before_start, &stop, database_path); + let mut response_model = DaysResponse { + day_logs: Vec::new(), + }; + + for summary in summaries { + response_model.day_logs.push(DaysResponseItem { + date: summary.date.clone(), + envoy_total_watts: summary.envoy_total_watts, + zever_total_watts: summary.zever_total_watts, + }); + } + + match serde_json::to_string(&response_model) { + Ok(json) => json, + Err(error) => { + log::error!( + "Failed to format JSON data for date range {} to {} with error {}", + day_before_start, + stop, + error + ); + return String::from("[]"); + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct DaysResponse { + day_logs: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct DaysResponseItem { + date: String, // yyyy-MM-dd + envoy_total_watts: i32, + zever_total_watts: i32, +} + +fn get_months_solar_json(start: &YearMonth, stop: &YearMonth, database_path: &str) -> String { + let summaries = database::get_month_solar_summaries(&start, &stop, &database_path); + let mut response_model = MonthsResponse { + month_logs: Vec::new(), + }; + + for summary in summaries { + response_model.month_logs.push(MonthsResponseItem { + year: summary.year, + month: summary.month, + envoy_total_watts: summary.envoy_total_watts, + zever_total_watts: summary.zever_total_watts, + }); + } + + match serde_json::to_string(&response_model) { + Ok(json) => json, + Err(error) => { + log::error!( + "Failed to format JSON data for date range {} to {} with error {}", + start, + stop, + error + ); + return String::from("[]"); + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct MonthsResponse { + month_logs: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct MonthsResponseItem { + year: i32, + month: u8, + envoy_total_watts: i32, + zever_total_watts: i32, +} diff --git a/src/solar_api/src/query_helpers.rs b/src/solar_api/src/query_helpers.rs new file mode 100644 index 0000000..49246a2 --- /dev/null +++ b/src/solar_api/src/query_helpers.rs @@ -0,0 +1,23 @@ +use chrono::NaiveDate; + +use crate::year_month::YearMonth; + +pub fn try_parse_query_date(value: Option<&String>) -> Option { + match value { + Some(string_value) => match chrono::NaiveDate::parse_from_str(string_value, "%Y-%m-%d") { + Ok(date) => Some(date), + Err(error) => { + log::error!("Failed to parse date {} with error {}", string_value, error); + return None; + } + }, + _ => None, + } +} + +pub fn try_parse_query_month_year(value: Option<&String>) -> Option { + match value { + Some(string_value) => YearMonth::try_parse(string_value), + _ => None, + } +} diff --git a/src/solar_api/src/year_month.rs b/src/solar_api/src/year_month.rs new file mode 100644 index 0000000..b86b0c4 --- /dev/null +++ b/src/solar_api/src/year_month.rs @@ -0,0 +1,96 @@ +#[derive(Copy, Clone)] +pub struct YearMonth { + pub year: i32, + pub month: u8, +} + +impl YearMonth { + pub fn get_next_month(&self) -> YearMonth { + if self.month >= 12 { + return YearMonth { + year: self.year + 1, + month: 1, + }; + } + + return YearMonth { + year: self.year, + month: self.month + 1, + }; + } + + pub fn try_parse(string_value: &str) -> Option { + let parts: std::vec::Vec<&str> = string_value.split('-').collect(); + if parts.len() != 2 { + return None; + } + + match (parts[0].parse::(), parts[1].parse::()) { + (Ok(year), Ok(month)) => { + if month < 1 || month > 12 { + return None; + } + + return Some(YearMonth { + year: year, + month: month, + }); + } + _ => None, + } + } +} + +impl std::fmt::Display for YearMonth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}-{:02}", self.year, self.month) + } +} + +impl PartialEq for YearMonth { + fn eq(&self, other: &Self) -> bool { + self.year == other.year && self.month == other.month + } +} + +impl PartialOrd for YearMonth { + fn partial_cmp(&self, other: &Self) -> Option { + if self.year > other.year { + return Some(std::cmp::Ordering::Greater); + } + + if self.year < other.year { + return Some(std::cmp::Ordering::Less); + } + + if self.month > other.month { + return Some(std::cmp::Ordering::Greater); + } + + if self.month < other.month { + return Some(std::cmp::Ordering::Less); + } + + Some(std::cmp::Ordering::Equal) + } + + fn lt(&self, other: &Self) -> bool { + self.partial_cmp(other) + .is_some_and(std::cmp::Ordering::is_lt) + } + + fn le(&self, other: &Self) -> bool { + self.partial_cmp(other) + .is_some_and(std::cmp::Ordering::is_le) + } + + fn gt(&self, other: &Self) -> bool { + self.partial_cmp(other) + .is_some_and(std::cmp::Ordering::is_gt) + } + + fn ge(&self, other: &Self) -> bool { + self.partial_cmp(other) + .is_some_and(std::cmp::Ordering::is_ge) + } +} diff --git a/systemd/electricity-server.service b/systemd/electricity-server.service index 5708cbd..c8f2593 100644 --- a/systemd/electricity-server.service +++ b/systemd/electricity-server.service @@ -1,12 +1,12 @@ [Unit] -Description=The Electricity JSON API written in rust +Description=The electricity JSON API written in rust Requires=network.target After=network.target [Service] Type=simple WorkingDirectory=/home/pi/project/home-data-collection-tools/target/release -ExecStart=/home/pi/project/home-data-collection-tools/target/release/electricity_api --database-path /home/pi/logs/electricity.logs --listening-port 3002 --http-header-name-to-validate 'X-Real-IP' --http-header-value-to-validate 'IP_ADDR_V4_1,IP_ADDR_V4_2' +ExecStart=/home/pi/project/home-data-collection-tools/target/release/electricity_api --database-path /home/pi/logs/electricity.db --listening-port 3002 --http-header-name-to-validate 'X-Real-IP' --http-header-value-to-validate 'IP_ADDR_V4_1,IP_ADDR_V4_2' Restart=on-abnormal RestartSec=30 User=pi diff --git a/systemd/solar-server.service b/systemd/solar-server.service index be8980d..34780ea 100644 --- a/systemd/solar-server.service +++ b/systemd/solar-server.service @@ -1,13 +1,12 @@ [Unit] -Description=A .NET Core application providing the Solar REST API +Description=The solar JSON API written in rust Requires=network.target After=network.target [Service] Type=simple -Environment=ASPNETCORE_URLS=http://*:3001 -WorkingDirectory=/home/pi -ExecStart=/home/pi/.dotnet/dotnet /home/pi/bin/Solar.Api/Solar.Api.dll +WorkingDirectory=/home/pi/project/home-data-collection-tools/target/release +ExecStart=/home/pi/project/home-data-collection-tools/target/release/solar_api --database-path /home/pi/logs/solarpaneloutput.db --listening-port 3001 Restart=on-abnormal RestartSec=30 User=pi