Rewrite .NET solar API in rust

This commit is contained in:
2025-09-03 17:25:57 +02:00
parent e56a8b0ccf
commit 05a0974ce8
34 changed files with 1037 additions and 703 deletions

15
Cargo.lock generated
View File

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

View File

@@ -2,6 +2,6 @@
resolver = "3"
members = [
"src/electricity_api"
]
, "src/solar_api"]
[workspace.dependencies]
[workspace.dependencies]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
namespace Solar.Api.Models;
public record DayResponse(ZeverDayLog[] ZeverLogs, EnvoyDayLog[] EnvoyLogs);

View File

@@ -1,3 +0,0 @@
namespace Solar.Api.Models;
public record DaySummaryLog(DateOnly Date, int ZeverTotalWatts, int EnvoyTotalWatts);

View File

@@ -1,3 +0,0 @@
namespace Solar.Api.Models;
public record DaysResponse(DaySummaryLog[] DayLogs);

View File

@@ -1,3 +0,0 @@
namespace Solar.Api.Models;
public record EnvoyDayLog(TimeOnly TimeUtc, int CurrentWatts, int TotalWatts);

View File

@@ -1,3 +0,0 @@
namespace Solar.Api.Models;
public record MonthLog(int Year, int Month, int ZeverTotalWatts, int EnvoyTotalWatts);

View File

@@ -1,3 +0,0 @@
namespace Solar.Api.Models;
public record MonthSummariesResponse(MonthLog[] MonthLogs);

View File

@@ -1,3 +0,0 @@
namespace Solar.Api.Models;
public record ZeverDayLog(TimeOnly TimeUtc, int CurrentWatts, int TotalWatts);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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,
}

View 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,
}
}

View 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)
}
}

View File

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

View File

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