diff --git a/Plugins.Modbus/Plugins.Modbus.csproj b/Plugins.Modbus/Plugins.Modbus.csproj index eb727105b..2c1f728af 100644 --- a/Plugins.Modbus/Plugins.Modbus.csproj +++ b/Plugins.Modbus/Plugins.Modbus.csproj @@ -13,7 +13,7 @@ - + diff --git a/Plugins.SmaEnergymeter/Plugins.SmaEnergymeter.csproj b/Plugins.SmaEnergymeter/Plugins.SmaEnergymeter.csproj index 6b5f10cb1..2c7f97774 100644 --- a/Plugins.SmaEnergymeter/Plugins.SmaEnergymeter.csproj +++ b/Plugins.SmaEnergymeter/Plugins.SmaEnergymeter.csproj @@ -14,7 +14,7 @@ - + diff --git a/Plugins.SolarEdge/Plugins.SolarEdge.csproj b/Plugins.SolarEdge/Plugins.SolarEdge.csproj index c72e1350e..1eedf85e5 100644 --- a/Plugins.SolarEdge/Plugins.SolarEdge.csproj +++ b/Plugins.SolarEdge/Plugins.SolarEdge.csproj @@ -13,7 +13,7 @@ - + diff --git a/Plugins.Solax/Plugins.Solax.csproj b/Plugins.Solax/Plugins.Solax.csproj index 6a3416ad8..c88c97df6 100644 --- a/Plugins.Solax/Plugins.Solax.csproj +++ b/Plugins.Solax/Plugins.Solax.csproj @@ -8,11 +8,11 @@ - + - + diff --git a/TeslaSolarCharger.GridPriceProvider/Data/Options/AwattarOptions.cs b/TeslaSolarCharger.GridPriceProvider/Data/Options/AwattarOptions.cs new file mode 100644 index 000000000..b2b2dffeb --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Data/Options/AwattarOptions.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace TeslaSolarCharger.GridPriceProvider.Data.Options; + +public class AwattarOptions +{ + [Required] + public string BaseUrl { get; set; } + + public decimal VATMultiplier { get; set; } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Data/Options/EnerginetOptions.cs b/TeslaSolarCharger.GridPriceProvider/Data/Options/EnerginetOptions.cs new file mode 100644 index 000000000..85a1f32d6 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Data/Options/EnerginetOptions.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace TeslaSolarCharger.GridPriceProvider.Data.Options; + +public class EnerginetOptions +{ + [Required] + public string BaseUrl { get; set; } + + [Required] + public EnerginetRegion Region { get; set; } + + [Required] + public EnerginetCurrency Currency { get; set; } + + public decimal? VAT { get; set; } + + public FixedPriceOptions? FixedPrices { get; set; } +} + +public enum EnerginetRegion +{ + DK1, + DK2, + NO2, + SE3, + SE4 +} + +public enum EnerginetCurrency +{ + DKK, + EUR +} diff --git a/TeslaSolarCharger.GridPriceProvider/Data/Options/FixedPriceOptions.cs b/TeslaSolarCharger.GridPriceProvider/Data/Options/FixedPriceOptions.cs new file mode 100644 index 000000000..8fc032f57 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Data/Options/FixedPriceOptions.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace TeslaSolarCharger.GridPriceProvider.Data.Options; + +public class FixedPriceOptions +{ + public List Prices { get; set; } = new(); +} diff --git a/TeslaSolarCharger.GridPriceProvider/Data/Options/HomeAssistantOptions.cs b/TeslaSolarCharger.GridPriceProvider/Data/Options/HomeAssistantOptions.cs new file mode 100644 index 000000000..f42168585 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Data/Options/HomeAssistantOptions.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace TeslaSolarCharger.GridPriceProvider.Data.Options; + +public class HomeAssistantOptions +{ + [Required] + public string BaseUrl { get; set; } + + [Required] + public string AccessToken { get; set; } + + [Required] + public string EntityId { get; set; } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Data/Options/OctopusOptions.cs b/TeslaSolarCharger.GridPriceProvider/Data/Options/OctopusOptions.cs new file mode 100644 index 000000000..3c9865d80 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Data/Options/OctopusOptions.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace TeslaSolarCharger.GridPriceProvider.Data.Options; + +public class OctopusOptions +{ + [Required] + public string BaseUrl { get; set; } + + [Required] + public string ProductCode { get; set; } + + [Required] + public string TariffCode { get; set; } + + [Required] + public string RegionCode { get; set; } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Data/Options/TibberOptions.cs b/TeslaSolarCharger.GridPriceProvider/Data/Options/TibberOptions.cs new file mode 100644 index 000000000..cec472719 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Data/Options/TibberOptions.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace TeslaSolarCharger.GridPriceProvider.Data.Options; + +public class TibberOptions +{ + [Required] + public string BaseUrl { get; set; } + + [Required] + public string AccessToken { get; set; } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Data/Price.cs b/TeslaSolarCharger.GridPriceProvider/Data/Price.cs new file mode 100644 index 000000000..43e4fb148 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Data/Price.cs @@ -0,0 +1,10 @@ +namespace TeslaSolarCharger.GridPriceProvider.Data; + +public class Price +{ + public decimal Value { get; set; } + + public DateTimeOffset ValidFrom { get; set; } + + public DateTimeOffset ValidTo { get; set; } +} diff --git a/TeslaSolarCharger.GridPriceProvider/ServiceCollectionExtensions.cs b/TeslaSolarCharger.GridPriceProvider/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..9fd98b61a --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using GraphQL.Client.Abstractions; +using GraphQL.Client.Serializer.SystemTextJson; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TeslaSolarCharger.GridPriceProvider.Services; +using TeslaSolarCharger.GridPriceProvider.Services.Interfaces; + +namespace TeslaSolarCharger.GridPriceProvider; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddGridPriceProvider(this IServiceCollection services) + { + services.AddHttpClient(); + services.AddTransient(); + + return services; + } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Services/AwattarService.cs b/TeslaSolarCharger.GridPriceProvider/Services/AwattarService.cs new file mode 100644 index 000000000..8b63c902a --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Services/AwattarService.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.Options; +using System.Text.Json; +using System.Text.Json.Serialization; +using TeslaSolarCharger.GridPriceProvider.Data; +using TeslaSolarCharger.GridPriceProvider.Data.Options; +using TeslaSolarCharger.GridPriceProvider.Services.Interfaces; + +namespace TeslaSolarCharger.GridPriceProvider.Services; + +public class AwattarService : IPriceDataService +{ + private readonly HttpClient _client; + private readonly AwattarOptions _options; + + public AwattarService(HttpClient client, IOptions options) + { + _client = client; + _options = options.Value; + } + + public async Task> GetPriceData(DateTimeOffset from, DateTimeOffset to) + { + var url = $"marketdata?start={from.UtcDateTime.AddHours(-1):o}&end={to.UtcDateTime.AddHours(1):o}"; + var resp = await _client.GetAsync(url); + resp.EnsureSuccessStatusCode(); + var agileResponse = await JsonSerializer.DeserializeAsync(await resp.Content.ReadAsStreamAsync()); + if (agileResponse == null) + { + throw new Exception($"Deserialization of aWATTar API response failed"); + } + if (agileResponse.Results.Any(x => x.Unit != "Eur/MWh")) + { + throw new Exception($"Unknown price unit(s) detected from aWATTar API: {string.Join(", ", agileResponse.Results.Select(x => x.Unit).Distinct())}"); + } + return agileResponse.Results.Select(x => new Price + { + Value = (x.MarketPrice / 1000) * _options.VATMultiplier, + ValidFrom = DateTimeOffset.FromUnixTimeSeconds(x.StartTimestamp / 1000), + ValidTo = DateTimeOffset.FromUnixTimeSeconds(x.EndTimestamp / 1000) + }); + } + + public class AwattarPrice + { + [JsonPropertyName("marketprice")] + public decimal MarketPrice { get; set; } + + [JsonPropertyName("unit")] + public string Unit { get; set; } + + [JsonPropertyName("start_timestamp")] + public long StartTimestamp { get; set; } + + [JsonPropertyName("end_timestamp")] + public long EndTimestamp { get; set; } + } + + public class AwattarResponse + { + [JsonPropertyName("data")] + public List Results { get; set; } + } + + public Task> GetPriceData(DateTimeOffset from, DateTimeOffset to, string? configString) + { + throw new NotImplementedException(); + } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Services/EnerginetService.cs b/TeslaSolarCharger.GridPriceProvider/Services/EnerginetService.cs new file mode 100644 index 000000000..3f2430d21 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Services/EnerginetService.cs @@ -0,0 +1,91 @@ +//using Microsoft.Extensions.Options; +//using System.Text.Json; +//using System.Text.Json.Serialization; +//using TeslaSolarCharger.GridPriceProvider.Data; +//using TeslaSolarCharger.GridPriceProvider.Data.Options; +//using TeslaSolarCharger.GridPriceProvider.Services.Interfaces; + +//namespace TeslaSolarCharger.GridPriceProvider.Services; + +//public class EnerginetService : IPriceDataService +//{ +// private readonly HttpClient _client; +// private readonly EnerginetOptions _options; +// private readonly FixedPriceService _fixedPriceService; + +// public EnerginetService(HttpClient client, IOptions options) +// { +// _client = client; +// _options = options.Value; + +// if (_options.FixedPrices != null) +// { +// _fixedPriceService = new FixedPriceService(Options.Create(_options.FixedPrices)); +// } +// } + +// public async Task> GetPriceData(DateTimeOffset from, DateTimeOffset to) +// { +// var url = "Elspotprices?offset=0&start=" + from.AddHours(-2).AddMinutes(-1).UtcDateTime.ToString("yyyy-MM-ddTHH:mm") + "&end=" + to.AddHours(2).AddMinutes(1).UtcDateTime.ToString("yyyy-MM-ddTHH:mm") + "&filter={\"PriceArea\":[\"" + _options.Region + "\"]}&sort=HourUTC ASC&timezone=dk".Replace(@"\", string.Empty); ; +// var resp = await _client.GetAsync(url); + +// resp.EnsureSuccessStatusCode(); + +// var prices = new List(); +// var EnerginetResponse = await JsonSerializer.DeserializeAsync(await resp.Content.ReadAsStreamAsync()); + +// if (EnerginetResponse.Records.Count > 0) +// { +// foreach (var record in EnerginetResponse.Records) +// { +// decimal fixedPrice = 0; +// if (_fixedPriceService != null) +// { +// var fixedPrices = await _fixedPriceService.GetPriceData(record.HourUTC, record.HourUTC.AddHours(1)); +// fixedPrice = fixedPrices.Sum(p => p.Value); +// } + +// var spotPrice = _options.Currency switch +// { +// EnerginetCurrency.DKK => record.SpotPriceDKK, +// EnerginetCurrency.EUR => record.SpotPriceEUR, +// _ => throw new ArgumentOutOfRangeException(nameof(_options.Currency)), +// }; + +// var price = ((spotPrice / 1000) + fixedPrice); +// if (_options.VAT.HasValue) +// { +// price *= _options.VAT.Value; +// } +// prices.Add(new Price +// { +// ValidFrom = record.HourUTC, +// ValidTo = record.HourUTC.AddHours(1), +// Value = price +// }); +// } +// } + +// return prices; +// } + +// private class EnerginetResponse +// { +// [JsonPropertyName("records")] +// public List Records { get; set; } +// } + +// private class EnerginetResponseRow +// { +// private DateTime _hourUTC; + +// [JsonPropertyName("HourUTC")] +// public DateTime HourUTC { get => _hourUTC; set => _hourUTC = DateTime.SpecifyKind(value, DateTimeKind.Utc); } + +// [JsonPropertyName("SpotPriceDKK")] +// public decimal SpotPriceDKK { get; set; } + +// [JsonPropertyName("SpotPriceEUR")] +// public decimal SpotPriceEUR { get; set; } +// } +//} diff --git a/TeslaSolarCharger.GridPriceProvider/Services/FixedPriceService.cs b/TeslaSolarCharger.GridPriceProvider/Services/FixedPriceService.cs new file mode 100644 index 000000000..c6aafc24c --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Services/FixedPriceService.cs @@ -0,0 +1,130 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using TeslaSolarCharger.GridPriceProvider.Data; +using TeslaSolarCharger.GridPriceProvider.Services.Interfaces; +using TeslaSolarCharger.Shared.Dtos.ChargingCost.CostConfigurations; + +[assembly: InternalsVisibleTo("TeslaSolarCharger.Tests")] +namespace TeslaSolarCharger.GridPriceProvider.Services; + +public class FixedPriceService : IFixedPriceService +{ + private readonly ILogger _logger; + + public FixedPriceService(ILogger logger) + { + _logger = logger; + } + + public Task> GetPriceData(DateTimeOffset from, DateTimeOffset to, string? configString) + { + _logger.LogTrace("{method}({from}, {to}, {fixedPriceStrings}", nameof(GetPriceData), from, to, configString); + if (string.IsNullOrWhiteSpace(configString)) + { + throw new ArgumentNullException(nameof(configString)); + } + + var fixedPrices = ParseConfigString(configString); + var prices = GeneratePricesBasedOnFixedPrices(from, to, fixedPrices); + + + return Task.FromResult(prices.AsEnumerable()); + } + + internal List GeneratePricesBasedOnFixedPrices(DateTimeOffset from, DateTimeOffset to, List fixedPrices) + { + var result = new List(); + var midnightseparatedFixedPrices = SplitFixedPricesAtMidnight(fixedPrices); + foreach (var fixedPrice in midnightseparatedFixedPrices) + { + var fromLocal = from.ToLocalTime(); + var toLocal = to.ToLocalTime(); + // Check each day in the range + for (var day = fromLocal.Date; day <= toLocal.Date; day = day.AddDays(1)) + { + // If ValidOnDays is null, the price is considered valid every day + if (fixedPrice.ValidOnDays == null || fixedPrice.ValidOnDays.Contains(day.DayOfWeek)) + { + var validFrom = new DateTimeOffset(day.AddHours(fixedPrice.FromHour).AddMinutes(fixedPrice.FromMinute)).ToUniversalTime(); + var validTo = new DateTimeOffset(day.AddHours(fixedPrice.ToHour).AddMinutes(fixedPrice.ToMinute)).ToUniversalTime(); + + if (validTo.TimeOfDay == TimeSpan.Zero) + { + validTo = validTo.AddDays(1); + } + + result.Add(new Price + { + Value = fixedPrice.Value, + ValidFrom = validFrom, + ValidTo = validTo, + }); + } + } + } + return result; + } + + internal List SplitFixedPricesAtMidnight(List originalPrices) + { + var splitPrices = new List(); + + foreach (var price in originalPrices) + { + // If the 'To' time is on or after midnight (next day), and 'From' time is before midnight (same day) + if (price.ToHour < price.FromHour || (price.ToHour == price.FromHour && price.ToMinute < price.FromMinute)) + { + // Create a new FixedPrice for the period before midnight + var priceBeforeMidnight = new FixedPrice + { + FromHour = price.FromHour, + FromMinute = price.FromMinute, + ToHour = 0, // Set to last hour of the day + ToMinute = 0, // Set to last minute of the day + Value = price.Value, + ValidOnDays = price.ValidOnDays, + }; + + // Create a new FixedPrice for the period after midnight + var priceAfterMidnight = new FixedPrice + { + FromHour = 0, + FromMinute = 0, + ToHour = price.ToHour, + ToMinute = price.ToMinute, + Value = price.Value, + ValidOnDays = price.ValidOnDays, + }; + + splitPrices.Add(priceBeforeMidnight); + splitPrices.Add(priceAfterMidnight); + } + else + { + // If the price does not span over midnight, just add it to the list + splitPrices.Add(price); + } + } + + return splitPrices; + } + + + public string GenerateConfigString(List prices) + { + var json = JsonConvert.SerializeObject(prices); + return json; + } + + public List ParseConfigString(string configString) + { + var fixedPrices = JsonConvert.DeserializeObject>(configString); + if (fixedPrices == null) + { + throw new ArgumentNullException(nameof(fixedPrices)); + } + return fixedPrices; + } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Services/HomeAssistantService.cs b/TeslaSolarCharger.GridPriceProvider/Services/HomeAssistantService.cs new file mode 100644 index 000000000..523ef123a --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Services/HomeAssistantService.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Json; +using System.Text.Json.Serialization; +using TeslaSolarCharger.GridPriceProvider.Data; +using TeslaSolarCharger.GridPriceProvider.Data.Options; +using TeslaSolarCharger.GridPriceProvider.Services.Interfaces; + +namespace TeslaSolarCharger.GridPriceProvider.Services; + +public class HomeAssistantService : IPriceDataService +{ + private readonly HomeAssistantOptions _options; + private readonly HttpClient _client; + + public HomeAssistantService(HttpClient client, IOptions options, ILogger logger) + { + _options = options.Value; + _client = client; + } + + public async Task> GetPriceData(DateTimeOffset from, DateTimeOffset to) + { + var url = $"api/history/period/{from.UtcDateTime:o}?end={to.UtcDateTime:o}&filter_entity_id={_options.EntityId}"; + var resp = await _client.GetAsync(url); + resp.EnsureSuccessStatusCode(); + var homeAssistantResponse = await JsonSerializer.DeserializeAsync>>(await resp.Content.ReadAsStreamAsync()) ?? throw new Exception("Deserialization of Home Assistant API response failed"); + var history = homeAssistantResponse.SingleOrDefault(); + if (history == null || !history.Any()) + { + throw new Exception($"No data from Home Assistant for entity id {_options.EntityId}, ensure it."); + } + var prices = new List(); + for (var i = 0; i < history.Count; i++) + { + var state = history[i]; + var price = decimal.Parse(state.State); + var validFrom = state.LastUpdated; + var validTo = (i < history.Count - 1) ? history[i + 1].LastUpdated : to; + + prices.Add(new Price + { + Value = price, + ValidFrom = validFrom, + ValidTo = validTo + }); + } + return prices; + } + + public class HomeAssistantResponse + { + [JsonPropertyName("entity_id")] + public string EntityId { get; set; } + + [JsonPropertyName("state")] + public string State { get; set; } + + [JsonPropertyName("last_changed")] + public DateTimeOffset LastChanged { get; set; } + + [JsonPropertyName("last_updated")] + public DateTimeOffset LastUpdated { get; set; } + } + + public Task> GetPriceData(DateTimeOffset from, DateTimeOffset to, string? configString) + { + throw new NotImplementedException(); + } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Services/Interfaces/IFixedPriceService.cs b/TeslaSolarCharger.GridPriceProvider/Services/Interfaces/IFixedPriceService.cs new file mode 100644 index 000000000..b1279b222 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Services/Interfaces/IFixedPriceService.cs @@ -0,0 +1,9 @@ +using TeslaSolarCharger.Shared.Dtos.ChargingCost.CostConfigurations; + +namespace TeslaSolarCharger.GridPriceProvider.Services.Interfaces; + +public interface IFixedPriceService : IPriceDataService +{ + string GenerateConfigString(List prices); + List ParseConfigString(string configString); +} diff --git a/TeslaSolarCharger.GridPriceProvider/Services/Interfaces/IPriceDataService.cs b/TeslaSolarCharger.GridPriceProvider/Services/Interfaces/IPriceDataService.cs new file mode 100644 index 000000000..b382aee44 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Services/Interfaces/IPriceDataService.cs @@ -0,0 +1,8 @@ +using TeslaSolarCharger.GridPriceProvider.Data; + +namespace TeslaSolarCharger.GridPriceProvider.Services.Interfaces; + +public interface IPriceDataService +{ + Task> GetPriceData(DateTimeOffset from, DateTimeOffset to, string? configString); +} diff --git a/TeslaSolarCharger.GridPriceProvider/Services/OctopusService.cs b/TeslaSolarCharger.GridPriceProvider/Services/OctopusService.cs new file mode 100644 index 000000000..eb82ba51c --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Services/OctopusService.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Options; +using System.Text.Json; +using System.Text.Json.Serialization; +using TeslaSolarCharger.GridPriceProvider.Data; +using TeslaSolarCharger.GridPriceProvider.Data.Options; +using TeslaSolarCharger.GridPriceProvider.Services.Interfaces; + +namespace TeslaSolarCharger.GridPriceProvider.Services; + +public class OctopusService : IPriceDataService +{ + private readonly OctopusOptions _options; + private readonly HttpClient _client; + + public OctopusService(HttpClient client, IOptions options) + { + _options = options.Value; + _client = client; + } + + public async Task> GetPriceData(DateTimeOffset from, DateTimeOffset to) + { + var url = $"products/{_options.ProductCode}/electricity-tariffs/{_options.TariffCode}-{_options.RegionCode}/standard-unit-rates?period_from={from.UtcDateTime:o}&period_to={to.UtcDateTime:o}"; + var list = new List(); + do + { + var resp = await _client.GetAsync(url); + resp.EnsureSuccessStatusCode(); + var agileResponse = await JsonSerializer.DeserializeAsync(await resp.Content.ReadAsStreamAsync()) ?? throw new Exception($"Deserialization of Octopus Agile API response failed"); + list.AddRange(agileResponse.Results); + url = agileResponse.Next; + if (string.IsNullOrEmpty(url)) + { + break; + } + else + { + Thread.Sleep(5000); // back off API so they don't ban us + } + } + while (true); + return list + .Select(x => new Price + { + Value = x.ValueIncVAT / 100, + ValidFrom = x.ValidFrom, + ValidTo = x.ValidTo + }); + } + + public class AgilePrice + { + [JsonPropertyName("value_exc_vat")] + public decimal ValueExcVAT { get; set; } + + [JsonPropertyName("value_inc_vat")] + public decimal ValueIncVAT { get; set; } + + [JsonPropertyName("valid_from")] + public DateTimeOffset ValidFrom { get; set; } + + [JsonPropertyName("valid_to")] + public DateTimeOffset ValidTo { get; set; } + } + + public class AgileResponse + { + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("next")] + public string Next { get; set; } + + [JsonPropertyName("previous")] + public string Previous { get; set; } + + [JsonPropertyName("results")] + public List Results { get; set; } + } + + public Task> GetPriceData(DateTimeOffset from, DateTimeOffset to, string? configString) + { + throw new NotImplementedException(); + } +} diff --git a/TeslaSolarCharger.GridPriceProvider/Services/TibberService.cs b/TeslaSolarCharger.GridPriceProvider/Services/TibberService.cs new file mode 100644 index 000000000..a239525b7 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/Services/TibberService.cs @@ -0,0 +1,183 @@ +using GraphQL; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Http; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; +using TeslaSolarCharger.GridPriceProvider.Data; +using TeslaSolarCharger.GridPriceProvider.Data.Options; +using TeslaSolarCharger.GridPriceProvider.Services.Interfaces; + +namespace TeslaSolarCharger.GridPriceProvider.Services; + +public class TibberService : IPriceDataService +{ + private readonly HttpClient _client; + private readonly GraphQLHttpClientOptions _graphQLHttpClientOptions; + private readonly TibberOptions _options; + private readonly IGraphQLJsonSerializer _graphQLJsonSerializer; + + public TibberService( + HttpClient client, + IGraphQLJsonSerializer graphQLJsonSerializer, + IOptions options + ) + { + _client = client; + _options = options.Value; + _graphQLHttpClientOptions = new GraphQLHttpClientOptions { EndPoint = new Uri(_options.BaseUrl) }; + _graphQLJsonSerializer = graphQLJsonSerializer; + } + + public async Task> GetPriceData(DateTimeOffset from, DateTimeOffset to) + { + var fetch = (int)Math.Ceiling((to - from).TotalHours) + 1; + var request = new GraphQLHttpRequest + { + Query = @" +query PriceData($after: String, $first: Int) { + viewer { + homes { + currentSubscription { + priceInfo { + range(resolution: HOURLY, after: $after, first: $first) { + nodes { + total + startsAt + } + } + current { + total + startsAt + level + } + } + } + } + } +} +", + OperationName = "PriceData", + Variables = new + { + after = Convert.ToBase64String(Encoding.UTF8.GetBytes(from.AddHours(-1).ToString("o"))), + first = fetch + } + }; + var graphQLHttpResponse = await SendRequest(request); + var priceInfo = graphQLHttpResponse + .Data + .Viewer + .Homes + .First() + .CurrentSubscription + .PriceInfo; + + var prices = priceInfo + .Range + .Nodes + .Select(x => new Price + { + ValidFrom = x.StartsAt, + ValidTo = x.StartsAt.AddHours(1), + Value = x.Total + }) + .ToList(); + + var count = prices.Count(); + // The Tibber range API only returns historical and does not include the current price + // This will add the current price to the list if it should be in there but isn't + if (count + 1 == fetch + && priceInfo.Current.StartsAt >= from.AddHours(-1) + && priceInfo.Current.StartsAt < to + && !prices.Any(x => x.ValidFrom == priceInfo.Current.StartsAt) + ) + { + prices.Add(new Price + { + ValidFrom = priceInfo.Current.StartsAt, + ValidTo = priceInfo.Current.StartsAt.AddHours(1), + Value = priceInfo.Current.Total + }); + } + else if (count != fetch) + { + throw new Exception($"Mismatch of requested price info from Tibber API (expected: {fetch}, actual: {count})"); + } + + return prices; + } + + private async Task> SendRequest(GraphQLHttpRequest request) + { + using var httpRequestMessage = request.ToHttpRequestMessage(_graphQLHttpClientOptions, _graphQLJsonSerializer); + using var httpResponseMessage = await _client.SendAsync(httpRequestMessage); + var contentStream = await httpResponseMessage.Content.ReadAsStreamAsync(); + if (httpResponseMessage.IsSuccessStatusCode) + { + var graphQLResponse = await JsonSerializer.DeserializeAsync>(contentStream, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (graphQLResponse == null) + { + throw new Exception($"Deserialization of Tibber API response failed"); + } + var graphQLHttpResponse = graphQLResponse.ToGraphQLHttpResponse(httpResponseMessage.Headers, httpResponseMessage.StatusCode); + if (graphQLHttpResponse.Errors?.Any() ?? false) + { + var errorMessages = string.Join(", ", graphQLHttpResponse.Errors.Select(x => x.Message)); + throw new HttpRequestException($"Failed to call Tibber API: {errorMessages}"); + } + return graphQLHttpResponse; + } + + string content = null; + if (contentStream != null) + { + using var sr = new StreamReader(contentStream); + content = await sr.ReadToEndAsync(); + } + + throw new GraphQLHttpRequestException(httpResponseMessage.StatusCode, httpResponseMessage.Headers, content); + } + + private class ResponseType + { + public Viewer Viewer { get; set; } + } + + private class Viewer + { + public List Homes { get; set; } + } + + private class Home + { + public Subscription CurrentSubscription { get; set; } + } + + private class Subscription + { + public PriceInfo PriceInfo { get; set; } + } + + private class PriceInfo + { + public RangeInfo Range { get; set; } + public Node Current { get; set; } + } + + private class RangeInfo + { + public List Nodes { get; set; } + } + + private class Node + { + public decimal Total { get; set; } + public DateTimeOffset StartsAt { get; set; } + } + + public Task> GetPriceData(DateTimeOffset from, DateTimeOffset to, string? configString) + { + throw new NotImplementedException(); + } +} diff --git a/TeslaSolarCharger.GridPriceProvider/TeslaSolarCharger.GridPriceProvider.csproj b/TeslaSolarCharger.GridPriceProvider/TeslaSolarCharger.GridPriceProvider.csproj new file mode 100644 index 000000000..514075da5 --- /dev/null +++ b/TeslaSolarCharger.GridPriceProvider/TeslaSolarCharger.GridPriceProvider.csproj @@ -0,0 +1,28 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + diff --git a/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/ChargePrice.cs b/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/ChargePrice.cs index 18778c6bc..16bf9ab20 100644 --- a/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/ChargePrice.cs +++ b/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/ChargePrice.cs @@ -1,9 +1,13 @@ -namespace TeslaSolarCharger.Model.Entities.TeslaSolarCharger; +using TeslaSolarCharger.Shared.Enums; + +namespace TeslaSolarCharger.Model.Entities.TeslaSolarCharger; public class ChargePrice { public int Id { get; set; } public DateTime ValidSince { get; set; } + public EnergyProvider EnergyProvider { get; set; } + public string? EnergyProviderConfiguration { get; set; } public decimal SolarPrice { get; set; } public decimal GridPrice { get; set; } public bool AddSpotPriceToGridPrice { get; set; } diff --git a/TeslaSolarCharger.Model/EntityFramework/TeslaSolarChargerContext.cs b/TeslaSolarCharger.Model/EntityFramework/TeslaSolarChargerContext.cs index 6e2d55dff..4546c6b70 100644 --- a/TeslaSolarCharger.Model/EntityFramework/TeslaSolarChargerContext.cs +++ b/TeslaSolarCharger.Model/EntityFramework/TeslaSolarChargerContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using TeslaSolarCharger.Model.Contracts; using TeslaSolarCharger.Model.Entities.TeslaSolarCharger; +using TeslaSolarCharger.Shared.Enums; namespace TeslaSolarCharger.Model.EntityFramework; @@ -33,6 +34,15 @@ public void RejectChanges() } } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .Property(c => c.EnergyProvider) + .HasDefaultValue(EnergyProvider.OldTeslaSolarChargerConfig); + } + #pragma warning disable CS8618 public TeslaSolarChargerContext() #pragma warning restore CS8618 diff --git a/TeslaSolarCharger.Model/Migrations/20231106195720_AddDifferentGridPriceSources.Designer.cs b/TeslaSolarCharger.Model/Migrations/20231106195720_AddDifferentGridPriceSources.Designer.cs new file mode 100644 index 000000000..8890d1bd5 --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231106195720_AddDifferentGridPriceSources.Designer.cs @@ -0,0 +1,179 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TeslaSolarCharger.Model.EntityFramework; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + [DbContext(typeof(TeslaSolarChargerContext))] + [Migration("20231106195720_AddDifferentGridPriceSources")] + partial class AddDifferentGridPriceSources + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.CachedCarState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CarId") + .HasColumnType("INTEGER"); + + b.Property("CarStateJson") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CachedCarStates"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.ChargePrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AddSpotPriceToGridPrice") + .HasColumnType("INTEGER"); + + b.Property("EnergyProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(6); + + b.Property("EnergyProviderConfiguration") + .HasColumnType("TEXT"); + + b.Property("GridPrice") + .HasColumnType("TEXT"); + + b.Property("SolarPrice") + .HasColumnType("TEXT"); + + b.Property("SpotPriceCorrectionFactor") + .HasColumnType("TEXT"); + + b.Property("ValidSince") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ChargePrices"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageSpotPrice") + .HasColumnType("TEXT"); + + b.Property("CalculatedPrice") + .HasColumnType("TEXT"); + + b.Property("CarId") + .HasColumnType("INTEGER"); + + b.Property("ChargingProcessId") + .HasColumnType("INTEGER"); + + b.Property("UsedGridEnergy") + .HasColumnType("TEXT"); + + b.Property("UsedSolarEnergy") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("HandledCharges"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.PowerDistribution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChargingPower") + .HasColumnType("INTEGER"); + + b.Property("GridProportion") + .HasColumnType("REAL"); + + b.Property("HandledChargeId") + .HasColumnType("INTEGER"); + + b.Property("PowerFromGrid") + .HasColumnType("INTEGER"); + + b.Property("TimeStamp") + .HasColumnType("TEXT"); + + b.Property("UsedWattHours") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("HandledChargeId"); + + b.ToTable("PowerDistributions"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.SpotPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SpotPrices"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.PowerDistribution", b => + { + b.HasOne("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", "HandledCharge") + .WithMany("PowerDistributions") + .HasForeignKey("HandledChargeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HandledCharge"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", b => + { + b.Navigation("PowerDistributions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/20231106195720_AddDifferentGridPriceSources.cs b/TeslaSolarCharger.Model/Migrations/20231106195720_AddDifferentGridPriceSources.cs new file mode 100644 index 000000000..0d075d1d3 --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231106195720_AddDifferentGridPriceSources.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + /// + public partial class AddDifferentGridPriceSources : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnergyProvider", + table: "ChargePrices", + type: "INTEGER", + nullable: false, + defaultValue: 6); + + migrationBuilder.AddColumn( + name: "EnergyProviderConfiguration", + table: "ChargePrices", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnergyProvider", + table: "ChargePrices"); + + migrationBuilder.DropColumn( + name: "EnergyProviderConfiguration", + table: "ChargePrices"); + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/20231111140619_MakeGridPriceOptional.Designer.cs b/TeslaSolarCharger.Model/Migrations/20231111140619_MakeGridPriceOptional.Designer.cs new file mode 100644 index 000000000..6da8d970f --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231111140619_MakeGridPriceOptional.Designer.cs @@ -0,0 +1,179 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TeslaSolarCharger.Model.EntityFramework; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + [DbContext(typeof(TeslaSolarChargerContext))] + [Migration("20231111140619_MakeGridPriceOptional")] + partial class MakeGridPriceOptional + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.CachedCarState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CarId") + .HasColumnType("INTEGER"); + + b.Property("CarStateJson") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CachedCarStates"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.ChargePrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AddSpotPriceToGridPrice") + .HasColumnType("INTEGER"); + + b.Property("EnergyProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(6); + + b.Property("EnergyProviderConfiguration") + .HasColumnType("TEXT"); + + b.Property("GridPrice") + .HasColumnType("TEXT"); + + b.Property("SolarPrice") + .HasColumnType("TEXT"); + + b.Property("SpotPriceCorrectionFactor") + .HasColumnType("TEXT"); + + b.Property("ValidSince") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ChargePrices"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageSpotPrice") + .HasColumnType("TEXT"); + + b.Property("CalculatedPrice") + .HasColumnType("TEXT"); + + b.Property("CarId") + .HasColumnType("INTEGER"); + + b.Property("ChargingProcessId") + .HasColumnType("INTEGER"); + + b.Property("UsedGridEnergy") + .HasColumnType("TEXT"); + + b.Property("UsedSolarEnergy") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("HandledCharges"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.PowerDistribution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChargingPower") + .HasColumnType("INTEGER"); + + b.Property("GridProportion") + .HasColumnType("REAL"); + + b.Property("HandledChargeId") + .HasColumnType("INTEGER"); + + b.Property("PowerFromGrid") + .HasColumnType("INTEGER"); + + b.Property("TimeStamp") + .HasColumnType("TEXT"); + + b.Property("UsedWattHours") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("HandledChargeId"); + + b.ToTable("PowerDistributions"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.SpotPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SpotPrices"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.PowerDistribution", b => + { + b.HasOne("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", "HandledCharge") + .WithMany("PowerDistributions") + .HasForeignKey("HandledChargeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HandledCharge"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", b => + { + b.Navigation("PowerDistributions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/20231111140619_MakeGridPriceOptional.cs b/TeslaSolarCharger.Model/Migrations/20231111140619_MakeGridPriceOptional.cs new file mode 100644 index 000000000..c7cdd8dcc --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231111140619_MakeGridPriceOptional.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + /// + public partial class MakeGridPriceOptional : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "GridPrice", + table: "ChargePrices", + type: "TEXT", + nullable: true, + oldClrType: typeof(decimal), + oldType: "TEXT"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "GridPrice", + table: "ChargePrices", + type: "TEXT", + nullable: false, + defaultValue: 0m, + oldClrType: typeof(decimal), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/20231112161412_MakeGridPriceRequired.Designer.cs b/TeslaSolarCharger.Model/Migrations/20231112161412_MakeGridPriceRequired.Designer.cs new file mode 100644 index 000000000..e766a009a --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231112161412_MakeGridPriceRequired.Designer.cs @@ -0,0 +1,179 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TeslaSolarCharger.Model.EntityFramework; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + [DbContext(typeof(TeslaSolarChargerContext))] + [Migration("20231112161412_MakeGridPriceRequired")] + partial class MakeGridPriceRequired + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.CachedCarState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CarId") + .HasColumnType("INTEGER"); + + b.Property("CarStateJson") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CachedCarStates"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.ChargePrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AddSpotPriceToGridPrice") + .HasColumnType("INTEGER"); + + b.Property("EnergyProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(6); + + b.Property("EnergyProviderConfiguration") + .HasColumnType("TEXT"); + + b.Property("GridPrice") + .HasColumnType("TEXT"); + + b.Property("SolarPrice") + .HasColumnType("TEXT"); + + b.Property("SpotPriceCorrectionFactor") + .HasColumnType("TEXT"); + + b.Property("ValidSince") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ChargePrices"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageSpotPrice") + .HasColumnType("TEXT"); + + b.Property("CalculatedPrice") + .HasColumnType("TEXT"); + + b.Property("CarId") + .HasColumnType("INTEGER"); + + b.Property("ChargingProcessId") + .HasColumnType("INTEGER"); + + b.Property("UsedGridEnergy") + .HasColumnType("TEXT"); + + b.Property("UsedSolarEnergy") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("HandledCharges"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.PowerDistribution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChargingPower") + .HasColumnType("INTEGER"); + + b.Property("GridProportion") + .HasColumnType("REAL"); + + b.Property("HandledChargeId") + .HasColumnType("INTEGER"); + + b.Property("PowerFromGrid") + .HasColumnType("INTEGER"); + + b.Property("TimeStamp") + .HasColumnType("TEXT"); + + b.Property("UsedWattHours") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("HandledChargeId"); + + b.ToTable("PowerDistributions"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.SpotPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SpotPrices"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.PowerDistribution", b => + { + b.HasOne("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", "HandledCharge") + .WithMany("PowerDistributions") + .HasForeignKey("HandledChargeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HandledCharge"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", b => + { + b.Navigation("PowerDistributions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/20231112161412_MakeGridPriceRequired.cs b/TeslaSolarCharger.Model/Migrations/20231112161412_MakeGridPriceRequired.cs new file mode 100644 index 000000000..6c48d6016 --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231112161412_MakeGridPriceRequired.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + /// + public partial class MakeGridPriceRequired : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "GridPrice", + table: "ChargePrices", + type: "TEXT", + nullable: false, + defaultValue: 0m, + oldClrType: typeof(decimal), + oldType: "TEXT", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "GridPrice", + table: "ChargePrices", + type: "TEXT", + nullable: true, + oldClrType: typeof(decimal), + oldType: "TEXT"); + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/TeslaSolarChargerContextModelSnapshot.cs b/TeslaSolarCharger.Model/Migrations/TeslaSolarChargerContextModelSnapshot.cs index 5d0c3812d..167cc6a6d 100644 --- a/TeslaSolarCharger.Model/Migrations/TeslaSolarChargerContextModelSnapshot.cs +++ b/TeslaSolarCharger.Model/Migrations/TeslaSolarChargerContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class TeslaSolarChargerContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.CachedCarState", b => { @@ -50,6 +50,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AddSpotPriceToGridPrice") .HasColumnType("INTEGER"); + b.Property("EnergyProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(6); + + b.Property("EnergyProviderConfiguration") + .HasColumnType("TEXT"); + b.Property("GridPrice") .HasColumnType("TEXT"); diff --git a/TeslaSolarCharger.Model/TeslaSolarCharger.Model.csproj b/TeslaSolarCharger.Model/TeslaSolarCharger.Model.csproj index 5d0a1d8c7..9752af60e 100644 --- a/TeslaSolarCharger.Model/TeslaSolarCharger.Model.csproj +++ b/TeslaSolarCharger.Model/TeslaSolarCharger.Model.csproj @@ -20,6 +20,7 @@ + diff --git a/TeslaSolarCharger.Tests/Services/Server/ChargingCostService.cs b/TeslaSolarCharger.Tests/Services/Server/ChargingCostService.cs index 79d8aac3c..71a6b311e 100644 --- a/TeslaSolarCharger.Tests/Services/Server/ChargingCostService.cs +++ b/TeslaSolarCharger.Tests/Services/Server/ChargingCostService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using TeslaSolarCharger.GridPriceProvider.Data; using TeslaSolarCharger.Model.Entities.TeslaSolarCharger; using Xunit; using Xunit.Abstractions; @@ -173,4 +174,146 @@ public async Task Calculates_Correct_Average_SpotPrice() var expectedValueWithoutAdditionalCosts = new decimal(0.175); Assert.Equal(expectedValueWithoutAdditionalCosts + expectedValueWithoutAdditionalCosts * additionalChargePrice, averagePrice); } + + + [Fact] + public void Calculates_Correct_FixedPrice_Cost() + { + var prices = new List() + { + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 17, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 18, 0, 0, TimeSpan.Zero), + Value = new decimal(0.11), + }, + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 18, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 19, 0, 0, TimeSpan.Zero), + Value = new decimal(0.10), + }, + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 19, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 20, 0, 0, TimeSpan.Zero), + Value = new decimal(0.30), + }, + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 20, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 21, 0, 0, TimeSpan.Zero), + Value = new decimal(0.11), + }, + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 21, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 22, 0, 0, TimeSpan.Zero), + Value = new decimal(0.11), + }, + }; + + + + var powerDistributions = new List() + { + new PowerDistribution() + { + UsedWattHours = 0, + GridProportion = (float)0.5, + TimeStamp = new DateTime(2023, 1, 22, 18, 1, 0), + }, + new PowerDistribution() + { + UsedWattHours = 10000, + GridProportion = (float)0.5, + TimeStamp = new DateTime(2023, 1, 22, 18, 59, 59), + }, + new PowerDistribution() + { + UsedWattHours = 3000, + GridProportion = 1, + TimeStamp = new DateTime(2023, 1, 22, 19, 59, 59), + }, + }; + + + var chargingCostService = Mock.Create(); + + var averagePrice = chargingCostService.GetGridChargeCosts(powerDistributions, prices, 0.1m); + + var expectedValueWithoutAdditionalCosts = new decimal(1.4); + Assert.Equal(expectedValueWithoutAdditionalCosts, averagePrice); + } + + [Fact] + public void Calculates_Correct_FixedPrice_Cost_With_default_value() + { + var prices = new List() + { + //Instead of out commented prices, the default value should be used + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 17, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 18, 0, 0, TimeSpan.Zero), + Value = new decimal(0.11), + }, + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 18, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 19, 0, 0, TimeSpan.Zero), + Value = new decimal(0.10), + }, + //new Price() + //{ + // ValidFrom = new DateTimeOffset(2023, 1, 22, 19, 0, 0, TimeSpan.Zero), + // ValidTo = new DateTimeOffset(2023, 1, 22, 20, 0, 0, TimeSpan.Zero), + // Value = new decimal(0.30), + //}, + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 20, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 21, 0, 0, TimeSpan.Zero), + Value = new decimal(0.11), + }, + new Price() + { + ValidFrom = new DateTimeOffset(2023, 1, 22, 21, 0, 0, TimeSpan.Zero), + ValidTo = new DateTimeOffset(2023, 1, 22, 22, 0, 0, TimeSpan.Zero), + Value = new decimal(0.11), + }, + }; + + + + var powerDistributions = new List() + { + new PowerDistribution() + { + UsedWattHours = 0, + GridProportion = (float)0.5, + TimeStamp = new DateTime(2023, 1, 22, 18, 1, 0), + }, + new PowerDistribution() + { + UsedWattHours = 10000, + GridProportion = (float)0.5, + TimeStamp = new DateTime(2023, 1, 22, 18, 59, 59), + }, + new PowerDistribution() + { + UsedWattHours = 3000, + GridProportion = 1, + TimeStamp = new DateTime(2023, 1, 22, 19, 59, 59), + }, + }; + + + var chargingCostService = Mock.Create(); + + var averagePrice = chargingCostService.GetGridChargeCosts(powerDistributions, prices, 0.3m); + + var expectedValueWithoutAdditionalCosts = new decimal(1.4); + Assert.Equal(expectedValueWithoutAdditionalCosts, averagePrice); + } } diff --git a/TeslaSolarCharger.Tests/Services/Server/FixedPriceService.cs b/TeslaSolarCharger.Tests/Services/Server/FixedPriceService.cs new file mode 100644 index 000000000..7c04cf8c0 --- /dev/null +++ b/TeslaSolarCharger.Tests/Services/Server/FixedPriceService.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using TeslaSolarCharger.Shared.Dtos.ChargingCost.CostConfigurations; +using Xunit; +using Xunit.Abstractions; + +namespace TeslaSolarCharger.Tests.Services.Server; + +public class FixedPriceService : TestBase +{ + public FixedPriceService(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + public void Can_Generate_Fixed_Price_Config() + { + var fixedPrices = new List() + { + new() + { + FromHour = 6, + FromMinute = 0, + ToHour = 15, + ToMinute = 0, + Value = 0.11m, + }, + new() + { + FromHour = 15, + FromMinute = 0, + ToHour = 6, + ToMinute = 0, + Value = 0.30m, + }, + }; + + var fixedPriceService = Mock.Create(); + var jsonString = fixedPriceService.GenerateConfigString(fixedPrices); + var expectedJson = "[{\"FromHour\":6,\"FromMinute\":0,\"ToHour\":15,\"ToMinute\":0,\"Value\":0.11,\"ValidOnDays\":null},{\"FromHour\":15,\"FromMinute\":0,\"ToHour\":6,\"ToMinute\":0,\"Value\":0.30,\"ValidOnDays\":null}]"; + Assert.Equal(expectedJson, jsonString); + } + + [Fact] + public void Can_Generate_Prices_Based_On_Fixed_Prices() + { + var fixedPrices = new List() + { + new() + { + FromHour = 6, + FromMinute = 0, + ToHour = 15, + ToMinute = 0, + Value = 0.11m, + }, + new() + { + FromHour = 15, + FromMinute = 0, + ToHour = 6, + ToMinute = 0, + Value = 0.30m, + }, + }; + var fixedPriceService = Mock.Create(); + var prices = fixedPriceService.GeneratePricesBasedOnFixedPrices(new DateTimeOffset(2023, 1, 21, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2023, 1, 21, 23, 59, 59, TimeSpan.Zero), fixedPrices); + //ToDo: to test this properly, a timezone has to be set in the test + } + + [Fact] + public void Can_Split_Fixed_Prices_On_Midnight_Weekdays_Null() + { + var fixedPrices = new List() + { + new() + { + FromHour = 6, + FromMinute = 0, + ToHour = 15, + ToMinute = 0, + Value = 0.11m, + }, + new() + { + FromHour = 15, + FromMinute = 0, + ToHour = 6, + ToMinute = 0, + Value = 0.30m, + }, + }; + var fixedPriceService = Mock.Create(); + var midnightSeparatedFixedPrices = fixedPriceService.SplitFixedPricesAtMidnight(fixedPrices); + Assert.Equal(3, midnightSeparatedFixedPrices.Count); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 6, FromMinute: 0, ToHour: 15, ToMinute: 0, Value: 0.11m, ValidOnDays: null})); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 0, FromMinute: 0, ToHour: 6, ToMinute: 0, Value: 0.30m, ValidOnDays: null })); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 15, FromMinute: 0, ToHour: 0, ToMinute: 0, Value: 0.30m, ValidOnDays: null })); + } + + [Fact] + public void Can_Split_Fixed_Prices_On_Midnight_Weekdays_Not_Null() + { + var fixedPrices = new List() + { + new() + { + FromHour = 7, + FromMinute = 0, + ToHour = 20, + ToMinute = 0, + Value = 0.2462m, + ValidOnDays = new List() { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday }, + }, + new() + { + FromHour = 7, + FromMinute = 0, + ToHour = 13, + ToMinute = 0, + Value = 0.2462m, + ValidOnDays = new List() { DayOfWeek.Saturday }, + }, + new() + { + FromHour = 20, + FromMinute = 0, + ToHour = 7, + ToMinute = 0, + Value = 0.2134m, + ValidOnDays = new List() { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday }, + }, + new() + { + FromHour = 13, + FromMinute = 0, + ToHour = 7, + ToMinute = 0, + Value = 0.2134m, + ValidOnDays = new List() { DayOfWeek.Saturday }, + }, + new() + { + FromHour = 0, + FromMinute = 0, + ToHour = 0, + ToMinute = 0, + Value = 0.2134m, + ValidOnDays = new List() { DayOfWeek.Sunday }, + }, + }; + var fixedPriceService = Mock.Create(); + var midnightSeparatedFixedPrices = fixedPriceService.SplitFixedPricesAtMidnight(fixedPrices); + Assert.Equal(7, midnightSeparatedFixedPrices.Count); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 0, FromMinute: 0, ToHour: 7, ToMinute: 0, Value: 0.2134m, ValidOnDays.Count: 5 } + && p.ValidOnDays.Contains(DayOfWeek.Monday) + && p.ValidOnDays.Contains(DayOfWeek.Tuesday) + && p.ValidOnDays.Contains(DayOfWeek.Wednesday) + && p.ValidOnDays.Contains(DayOfWeek.Thursday) + && p.ValidOnDays.Contains(DayOfWeek.Friday))); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 7, FromMinute: 0, ToHour: 20, ToMinute: 0, Value: 0.2462m, ValidOnDays.Count: 5 } + && p.ValidOnDays.Contains(DayOfWeek.Monday) + && p.ValidOnDays.Contains(DayOfWeek.Tuesday) + && p.ValidOnDays.Contains(DayOfWeek.Wednesday) + && p.ValidOnDays.Contains(DayOfWeek.Thursday) + && p.ValidOnDays.Contains(DayOfWeek.Friday))); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 20, FromMinute: 0, ToHour: 0, ToMinute: 0, Value: 0.2134m, ValidOnDays.Count: 5 } + && p.ValidOnDays.Contains(DayOfWeek.Monday) + && p.ValidOnDays.Contains(DayOfWeek.Tuesday) + && p.ValidOnDays.Contains(DayOfWeek.Wednesday) + && p.ValidOnDays.Contains(DayOfWeek.Thursday) + && p.ValidOnDays.Contains(DayOfWeek.Friday))); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 0, FromMinute: 0, ToHour: 7, ToMinute: 0, Value: 0.2134m, ValidOnDays.Count: 1 } + && p.ValidOnDays.Contains(DayOfWeek.Saturday))); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 7, FromMinute: 0, ToHour: 13, ToMinute: 0, Value: 0.2462m, ValidOnDays.Count: 1 } + && p.ValidOnDays.Contains(DayOfWeek.Saturday))); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 13, FromMinute: 0, ToHour: 0, ToMinute: 0, Value: 0.2134m, ValidOnDays.Count: 1 } + && p.ValidOnDays.Contains(DayOfWeek.Saturday))); + Assert.Single(midnightSeparatedFixedPrices.Where(p => p is { FromHour: 0, FromMinute: 0, ToHour: 0, ToMinute: 0, Value: 0.2134m, ValidOnDays.Count: 1 } + && p.ValidOnDays.Contains(DayOfWeek.Sunday))); + } +} diff --git a/TeslaSolarCharger.Tests/TeslaSolarCharger.Tests.csproj b/TeslaSolarCharger.Tests/TeslaSolarCharger.Tests.csproj index 8cb645ee6..2a78f998f 100644 --- a/TeslaSolarCharger.Tests/TeslaSolarCharger.Tests.csproj +++ b/TeslaSolarCharger.Tests/TeslaSolarCharger.Tests.csproj @@ -9,7 +9,7 @@ - + @@ -18,7 +18,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/TeslaSolarCharger.sln b/TeslaSolarCharger.sln index 5ea150bad..e345e9c12 100644 --- a/TeslaSolarCharger.sln +++ b/TeslaSolarCharger.sln @@ -29,6 +29,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeslaSolarCharger.GridPriceProvider", "TeslaSolarCharger.GridPriceProvider\TeslaSolarCharger.GridPriceProvider.csproj", "{1BE60FFB-0C76-4D8A-8FD5-04C886885AB9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -75,6 +77,10 @@ Global {9958124C-3B96-4186-AFFA-1477C6582D84}.Debug|Any CPU.Build.0 = Debug|Any CPU {9958124C-3B96-4186-AFFA-1477C6582D84}.Release|Any CPU.ActiveCfg = Release|Any CPU {9958124C-3B96-4186-AFFA-1477C6582D84}.Release|Any CPU.Build.0 = Release|Any CPU + {1BE60FFB-0C76-4D8A-8FD5-04C886885AB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1BE60FFB-0C76-4D8A-8FD5-04C886885AB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1BE60FFB-0C76-4D8A-8FD5-04C886885AB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1BE60FFB-0C76-4D8A-8FD5-04C886885AB9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/TeslaSolarCharger/Client/Components/FixedPriceComponent.razor b/TeslaSolarCharger/Client/Components/FixedPriceComponent.razor new file mode 100644 index 000000000..b1ddc07fa --- /dev/null +++ b/TeslaSolarCharger/Client/Components/FixedPriceComponent.razor @@ -0,0 +1,91 @@ +@using TeslaSolarCharger.Shared.Dtos.ChargingCost.CostConfigurations + +@if (FixedPrice == null) +{ +
+} +else +{ +
+ @foreach (var day in Enum.GetValues()) + { +
+ + +
+ } +
+
+
+ + + + + +
+
+ : +
+
+ + + + + +
+
+
+
+ + + + + +
+
+ : +
+
+ + + + + +
+
+ +} + +@code { + [Parameter] + public FixedPrice? FixedPrice { get; set; } + + private void CheckboxChanged(DayOfWeek day) + { + if(FixedPrice?.ValidOnDays == null) + { + return; + } + + if (FixedPrice.ValidOnDays.Contains(day)) + { + FixedPrice.ValidOnDays.Remove(day); + } + else + { + FixedPrice.ValidOnDays.Add(day); + } + } +} diff --git a/TeslaSolarCharger/Client/Pages/ChargeCostDetail.razor b/TeslaSolarCharger/Client/Pages/ChargeCostDetail.razor index d380cf9de..3b7c3ace2 100644 --- a/TeslaSolarCharger/Client/Pages/ChargeCostDetail.razor +++ b/TeslaSolarCharger/Client/Pages/ChargeCostDetail.razor @@ -2,6 +2,8 @@ @page "/ChargePrice/new" @using TeslaSolarCharger.Shared.Dtos.ChargingCost @using TeslaSolarCharger.Shared.Contracts +@using TeslaSolarCharger.Shared.Dtos.ChargingCost.CostConfigurations +@using TeslaSolarCharger.Shared.Enums @inject HttpClient HttpClient @inject NavigationManager NavigationManager @inject IDateTimeProvider DateTimeProvider @@ -44,6 +46,18 @@ else + + + + + + + + + -
- - -
- Enable this if you are using dynamic prices based on EPEX Spot DE (e.g. Tibber or aWATTar) -
-
- @if (ChargePrice.AddSpotPriceToGridPrice) + @if (ChargePrice.EnergyProvider == EnergyProvider.FixedPrice && ChargePrice.FixedPrices != null) { - - - - - +
You can specify times with special prices here. If there are times left, you didn't specify a price for, the default grid price, specified above, is used.
+ @foreach (var fixedPrice in ChargePrice.FixedPrices) + { +
+
+ +
+ +
+
+
+ + + + + +
+
+ +
+
+ +
+ } + } + + @if (ChargePrice.EnergyProvider == EnergyProvider.OldTeslaSolarChargerConfig) + { +
+ + +
+ Enable this if you are using dynamic prices based on EPEX Spot DE (e.g. Tibber or aWATTar) +
+
+ @if (ChargePrice.AddSpotPriceToGridPrice) + { + + + + + + } + } +