Rewrite .NET solar API in rust
This commit is contained in:
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
resolver = "3"
|
||||
members = [
|
||||
"src/electricity_api"
|
||||
]
|
||||
, "src/solar_api"]
|
||||
|
||||
[workspace.dependencies]
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
2
src/Solar.Api/.gitignore
vendored
2
src/Solar.Api/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
bin/
|
||||
obj/
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Solar.Api.Constants;
|
||||
|
||||
internal static class Format
|
||||
{
|
||||
public const string Date = "yyyy-MM-dd";
|
||||
}
|
||||
@@ -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<SolarLogController> logger;
|
||||
|
||||
public SolarLogController(SolarService solarService, ILogger<SolarLogController> logger)
|
||||
{
|
||||
this.solarService = solarService;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet()]
|
||||
[Route("/day")]
|
||||
public async Task<ActionResult<DayResponse>> 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 <parameter>to</parameter> is inclusive
|
||||
[HttpGet()]
|
||||
[Route("/days")]
|
||||
public async Task<ActionResult<DaysResponse>> 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<ActionResult<MonthSummariesResponse>> 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;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Solar.Api.Converters;
|
||||
|
||||
public class DateOnlyConverter : JsonConverter<DateOnly>
|
||||
{
|
||||
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));
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Solar.Api.Converters;
|
||||
|
||||
public class TimeOnlyConverter : JsonConverter<TimeOnly>
|
||||
{
|
||||
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));
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Solar.Api.Entities;
|
||||
|
||||
namespace Solar.Api
|
||||
{
|
||||
public partial class DatabaseContext : DbContext
|
||||
{
|
||||
public DatabaseContext() { }
|
||||
|
||||
public DatabaseContext(DbContextOptions<DatabaseContext> options)
|
||||
: base(options) { }
|
||||
|
||||
public virtual DbSet<EnvoyLog> EnvoyLogs { get; set; } = null!;
|
||||
public virtual DbSet<ZeverLog> ZeverLogs { get; set; } = null!;
|
||||
public virtual DbSet<ZeverSummary> ZeverSummaries { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<EnvoyLog>(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<ZeverLog>(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<ZeverSummary>(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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Solar.Api.Models;
|
||||
|
||||
public record DayResponse(ZeverDayLog[] ZeverLogs, EnvoyDayLog[] EnvoyLogs);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Solar.Api.Models;
|
||||
|
||||
public record DaySummaryLog(DateOnly Date, int ZeverTotalWatts, int EnvoyTotalWatts);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Solar.Api.Models;
|
||||
|
||||
public record DaysResponse(DaySummaryLog[] DayLogs);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Solar.Api.Models;
|
||||
|
||||
public record EnvoyDayLog(TimeOnly TimeUtc, int CurrentWatts, int TotalWatts);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Solar.Api.Models;
|
||||
|
||||
public record MonthLog(int Year, int Month, int ZeverTotalWatts, int EnvoyTotalWatts);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Solar.Api.Models;
|
||||
|
||||
public record MonthSummariesResponse(MonthLog[] MonthLogs);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Solar.Api.Models;
|
||||
|
||||
public record ZeverDayLog(TimeOnly TimeUtc, int CurrentWatts, int TotalWatts);
|
||||
@@ -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<SolarService>();
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
// Database
|
||||
builder.Services.AddDbContext<DatabaseContext>(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<DateOnly>(() =>
|
||||
new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Format = "date",
|
||||
Example = OpenApiAnyFactory.CreateFromJson("\"2022-12-31\""),
|
||||
}
|
||||
);
|
||||
config.MapType<TimeOnly>(() =>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SolarService> logger;
|
||||
|
||||
public SolarService(
|
||||
DatabaseContext databaseContext,
|
||||
IMemoryCache memoryCache,
|
||||
ILogger<SolarService> logger
|
||||
)
|
||||
{
|
||||
this.databaseContext = databaseContext;
|
||||
this.memoryCache = memoryCache;
|
||||
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DayResponse> 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<DaysResponse> GetDaySummaries(DateOnly start, DateOnly stop)
|
||||
{
|
||||
var zeverRecords = await databaseContext
|
||||
.ZeverSummaries.Where(zl => zl.Date >= start && zl.Date <= stop)
|
||||
.ToArrayAsync();
|
||||
|
||||
List<DaySummaryLog> 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<int> 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<MonthSummariesResponse> GetMonthSummaries(
|
||||
int fromYear,
|
||||
int fromMonth,
|
||||
int toYear,
|
||||
int toMonth
|
||||
)
|
||||
{
|
||||
var results = new List<MonthLog>();
|
||||
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<int> 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<int> 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<int> GetOrAddToCache(
|
||||
string source,
|
||||
int year,
|
||||
int month,
|
||||
Func<int, int, Task<int>> 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
15
src/solar_api/Cargo.toml
Normal file
15
src/solar_api/Cargo.toml
Normal file
@@ -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"] }
|
||||
596
src/solar_api/src/database.rs
Normal file
596
src/solar_api/src/database.rs
Normal file
@@ -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<EnvoyLogEntity>,
|
||||
) {
|
||||
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<ZeverLogEntity>,
|
||||
) {
|
||||
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<EnvoyLogEntity>,
|
||||
pub zever_logs: Vec<ZeverLogEntity>,
|
||||
}
|
||||
|
||||
pub fn get_day_solar_summaries(
|
||||
start: &chrono::NaiveDate,
|
||||
stop: &chrono::NaiveDate,
|
||||
database_path: &str,
|
||||
) -> Vec<LogDateSummary> {
|
||||
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<LogDateSummary>,
|
||||
) {
|
||||
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::<usize, i32>(1)? - row.get::<usize, i32>(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<LogDateSummary>,
|
||||
) {
|
||||
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<LogYearMonthSummary> {
|
||||
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<LogYearMonthSummary>,
|
||||
) {
|
||||
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::<usize, i32>(1)? - row.get::<usize, i32>(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<LogYearMonthSummary>,
|
||||
) {
|
||||
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,
|
||||
}
|
||||
284
src/solar_api/src/main.rs
Normal file
284
src/solar_api/src/main.rs
Normal file
@@ -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<CommandLineArgs>) {
|
||||
let day = warp::get()
|
||||
.and(warp::path("day"))
|
||||
.and(warp::query::<HashMap<String, String>>())
|
||||
.and(warp::any().map({
|
||||
let configuration = configuration.clone();
|
||||
move || configuration.clone()
|
||||
}))
|
||||
.map(
|
||||
|query: HashMap<String, String>, configuration: std::sync::Arc<CommandLineArgs>| {
|
||||
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::<HashMap<String, String>>())
|
||||
.and(warp::any().map({
|
||||
let configuration = configuration.clone();
|
||||
move || configuration.clone()
|
||||
}))
|
||||
.map(
|
||||
|query: HashMap<String, String>, configuration: std::sync::Arc<CommandLineArgs>| {
|
||||
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::<HashMap<String, String>>())
|
||||
.and(warp::any().map({
|
||||
let configuration = configuration.clone();
|
||||
move || configuration.clone()
|
||||
}))
|
||||
.map(
|
||||
|query: HashMap<String, String>, configuration: std::sync::Arc<CommandLineArgs>| {
|
||||
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<DayResponseLogItem>,
|
||||
zever_logs: Vec<DayResponseLogItem>,
|
||||
}
|
||||
|
||||
#[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<DaysResponseItem>,
|
||||
}
|
||||
|
||||
#[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<MonthsResponseItem>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MonthsResponseItem {
|
||||
year: i32,
|
||||
month: u8,
|
||||
envoy_total_watts: i32,
|
||||
zever_total_watts: i32,
|
||||
}
|
||||
23
src/solar_api/src/query_helpers.rs
Normal file
23
src/solar_api/src/query_helpers.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::year_month::YearMonth;
|
||||
|
||||
pub fn try_parse_query_date(value: Option<&String>) -> Option<NaiveDate> {
|
||||
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<YearMonth> {
|
||||
match value {
|
||||
Some(string_value) => YearMonth::try_parse(string_value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
96
src/solar_api/src/year_month.rs
Normal file
96
src/solar_api/src/year_month.rs
Normal file
@@ -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<YearMonth> {
|
||||
let parts: std::vec::Vec<&str> = string_value.split('-').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
match (parts[0].parse::<i32>(), parts[1].parse::<u8>()) {
|
||||
(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<std::cmp::Ordering> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user