diff --git a/README.md b/README.md index 769922d79..406613aee 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ TeslaSolarCharger is a service to set one or multiple Teslas' charging current u - [How to use](#how-to-use) - [Charge Modes](#charge-modes) - [Generate logfiles](#generate-logfiles) +- [Privacy notes](#privacy-notes) ## How to install @@ -1031,3 +1032,9 @@ If you get an error like `Error: No such container:` you can look up the contain ```bash docker ps ``` + + +As the new Tesla Fleet API requires a domain and external Token creation from version 2.23.0 onwards, TSC transfers some data to the owner of this repository. By using this software, you accept the transfer of this data. As this is open source, you can see which data is transferred. For now (6th December 2023), the following data is transferred: +- Your access code is used to get the access token from Tesla (Note: the token itself is only stored locally in your TSC installation. It is only transferred via my server, but the token only exists in memory on the server itself. It is not stored in a database or log file) +- Your installation ID (GUID) is at the bottom of the page. Do not post this GUID in public forums, as it is used to deliver the Tesla access token to your installation. Note: There is only a five-minute time window between requesting and providing the token using the installation ID. After these 5 minutes, all requests are blocked.) +- Your installed version. diff --git a/TeslaSolarCharger.Model/Contracts/ITeslaSolarChargerContext.cs b/TeslaSolarCharger.Model/Contracts/ITeslaSolarChargerContext.cs index 09fdea53a..67cff6814 100644 --- a/TeslaSolarCharger.Model/Contracts/ITeslaSolarChargerContext.cs +++ b/TeslaSolarCharger.Model/Contracts/ITeslaSolarChargerContext.cs @@ -15,5 +15,7 @@ public interface ITeslaSolarChargerContext Task SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()); DatabaseFacade Database { get; } DbSet SpotPrices { get; set; } + DbSet TeslaTokens { get; set; } + DbSet TscConfigurations { get; set; } void RejectChanges(); } diff --git a/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/TeslaToken.cs b/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/TeslaToken.cs new file mode 100644 index 000000000..23356bf02 --- /dev/null +++ b/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/TeslaToken.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Primitives; +using TeslaSolarCharger.Model.Enums; + +namespace TeslaSolarCharger.Model.Entities.TeslaSolarCharger; + +public class TeslaToken +{ + public int Id { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string IdToken { get; set; } + public DateTime ExpiresAtUtc { get; set; } + public TeslaFleetApiRegion Region { get; set; } +} diff --git a/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/TscConfiguration.cs b/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/TscConfiguration.cs new file mode 100644 index 000000000..7d413c7d1 --- /dev/null +++ b/TeslaSolarCharger.Model/Entities/TeslaSolarCharger/TscConfiguration.cs @@ -0,0 +1,8 @@ +namespace TeslaSolarCharger.Model.Entities.TeslaSolarCharger; + +public class TscConfiguration +{ + public int Id { get; set; } + public string Key { get; set; } + public string? Value { get; set; } +} diff --git a/TeslaSolarCharger.Model/EntityFramework/TeslaSolarChargerContext.cs b/TeslaSolarCharger.Model/EntityFramework/TeslaSolarChargerContext.cs index 4546c6b70..86d618e2d 100644 --- a/TeslaSolarCharger.Model/EntityFramework/TeslaSolarChargerContext.cs +++ b/TeslaSolarCharger.Model/EntityFramework/TeslaSolarChargerContext.cs @@ -12,6 +12,8 @@ public class TeslaSolarChargerContext : DbContext, ITeslaSolarChargerContext public DbSet HandledCharges { get; set; } = null!; public DbSet PowerDistributions { get; set; } = null!; public DbSet SpotPrices { get; set; } = null!; + public DbSet TeslaTokens { get; set; } = null!; + public DbSet TscConfigurations { get; set; } = null!; // ReSharper disable once UnassignedGetOnlyAutoProperty public string DbPath { get; } @@ -41,6 +43,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .Property(c => c.EnergyProvider) .HasDefaultValue(EnergyProvider.OldTeslaSolarChargerConfig); + + modelBuilder.Entity() + .HasIndex(c => c.Key) + .IsUnique(); } #pragma warning disable CS8618 diff --git a/TeslaSolarCharger.Model/Enums/TeslaFleetApiRegion.cs b/TeslaSolarCharger.Model/Enums/TeslaFleetApiRegion.cs new file mode 100644 index 000000000..f58546ba3 --- /dev/null +++ b/TeslaSolarCharger.Model/Enums/TeslaFleetApiRegion.cs @@ -0,0 +1,7 @@ +namespace TeslaSolarCharger.Model.Enums; + +public enum TeslaFleetApiRegion +{ + Emea, + NorthAmerica, +} diff --git a/TeslaSolarCharger.Model/Migrations/20231127233402_AddTeslaToken.Designer.cs b/TeslaSolarCharger.Model/Migrations/20231127233402_AddTeslaToken.Designer.cs new file mode 100644 index 000000000..e008509cb --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231127233402_AddTeslaToken.Designer.cs @@ -0,0 +1,208 @@ +// +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("20231127233402_AddTeslaToken")] + partial class AddTeslaToken + { + /// + 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.TeslaToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("IdToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TeslaTokens"); + }); + + 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/20231127233402_AddTeslaToken.cs b/TeslaSolarCharger.Model/Migrations/20231127233402_AddTeslaToken.cs new file mode 100644 index 000000000..a4010fb25 --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231127233402_AddTeslaToken.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + /// + public partial class AddTeslaToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TeslaTokens", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AccessToken = table.Column(type: "TEXT", nullable: false), + RefreshToken = table.Column(type: "TEXT", nullable: false), + IdToken = table.Column(type: "TEXT", nullable: false), + ExpiresAtUtc = table.Column(type: "TEXT", nullable: false), + Region = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeslaTokens", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TeslaTokens"); + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/20231201103452_AddTscConfigurations.Designer.cs b/TeslaSolarCharger.Model/Migrations/20231201103452_AddTscConfigurations.Designer.cs new file mode 100644 index 000000000..0548a9c5e --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231201103452_AddTscConfigurations.Designer.cs @@ -0,0 +1,226 @@ +// +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("20231201103452_AddTscConfigurations")] + partial class AddTscConfigurations + { + /// + 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.TeslaToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("IdToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TeslaTokens"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.TscConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TscConfigurations"); + }); + + 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/20231201103452_AddTscConfigurations.cs b/TeslaSolarCharger.Model/Migrations/20231201103452_AddTscConfigurations.cs new file mode 100644 index 000000000..8842a2963 --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231201103452_AddTscConfigurations.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + /// + public partial class AddTscConfigurations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TscConfigurations", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Key = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TscConfigurations", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TscConfigurations"); + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/20231204133244_MakeConfigurationKeysUnique.Designer.cs b/TeslaSolarCharger.Model/Migrations/20231204133244_MakeConfigurationKeysUnique.Designer.cs new file mode 100644 index 000000000..dde528dc4 --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231204133244_MakeConfigurationKeysUnique.Designer.cs @@ -0,0 +1,229 @@ +// +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("20231204133244_MakeConfigurationKeysUnique")] + partial class MakeConfigurationKeysUnique + { + /// + 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.TeslaToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("IdToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TeslaTokens"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.TscConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("TscConfigurations"); + }); + + 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/20231204133244_MakeConfigurationKeysUnique.cs b/TeslaSolarCharger.Model/Migrations/20231204133244_MakeConfigurationKeysUnique.cs new file mode 100644 index 000000000..146ea81d8 --- /dev/null +++ b/TeslaSolarCharger.Model/Migrations/20231204133244_MakeConfigurationKeysUnique.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeslaSolarCharger.Model.Migrations +{ + /// + public partial class MakeConfigurationKeysUnique : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_TscConfigurations_Key", + table: "TscConfigurations", + column: "Key", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_TscConfigurations_Key", + table: "TscConfigurations"); + } + } +} diff --git a/TeslaSolarCharger.Model/Migrations/TeslaSolarChargerContextModelSnapshot.cs b/TeslaSolarCharger.Model/Migrations/TeslaSolarChargerContextModelSnapshot.cs index 167cc6a6d..3bc817e36 100644 --- a/TeslaSolarCharger.Model/Migrations/TeslaSolarChargerContextModelSnapshot.cs +++ b/TeslaSolarCharger.Model/Migrations/TeslaSolarChargerContextModelSnapshot.cs @@ -155,6 +155,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SpotPrices"); }); + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.TeslaToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("IdToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TeslaTokens"); + }); + + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.TscConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("TscConfigurations"); + }); + modelBuilder.Entity("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.PowerDistribution", b => { b.HasOne("TeslaSolarCharger.Model.Entities.TeslaSolarCharger.HandledCharge", "HandledCharge") diff --git a/TeslaSolarCharger.Model/TeslaSolarCharger.Model.csproj b/TeslaSolarCharger.Model/TeslaSolarCharger.Model.csproj index 9752af60e..59adf1234 100644 --- a/TeslaSolarCharger.Model/TeslaSolarCharger.Model.csproj +++ b/TeslaSolarCharger.Model/TeslaSolarCharger.Model.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/TeslaSolarCharger.SharedBackend/Contracts/IConstants.cs b/TeslaSolarCharger.SharedBackend/Contracts/IConstants.cs index 38d113c4f..7c669fd2d 100644 --- a/TeslaSolarCharger.SharedBackend/Contracts/IConstants.cs +++ b/TeslaSolarCharger.SharedBackend/Contracts/IConstants.cs @@ -10,4 +10,9 @@ public interface IConstants /// Soc Difference needs to be higher than this value /// int MinimumSocDifference { get; } + + string InstallationIdKey { get; } + string FleetApiTokenRequested { get; } + string TokenRefreshUnauthorized { get; } + string TokenMissingScopes { get; } } diff --git a/TeslaSolarCharger.SharedBackend/Values/Constants.cs b/TeslaSolarCharger.SharedBackend/Values/Constants.cs index f92c3260f..b38907b48 100644 --- a/TeslaSolarCharger.SharedBackend/Values/Constants.cs +++ b/TeslaSolarCharger.SharedBackend/Values/Constants.cs @@ -9,4 +9,9 @@ public class Constants : IConstants public int MinSocLimit => 50; public int DefaultOverage => -1000000; public int MinimumSocDifference => 2; + + public string InstallationIdKey => "InstallationId"; + public string FleetApiTokenRequested => "FleetApiTokenRequested"; + public string TokenRefreshUnauthorized => "TokenRefreshUnauthorized"; + public string TokenMissingScopes => "TokenMissingScopes"; } diff --git a/TeslaSolarCharger.Tests/Services/Server/BackendApiService.cs b/TeslaSolarCharger.Tests/Services/Server/BackendApiService.cs new file mode 100644 index 000000000..32c12ea0f --- /dev/null +++ b/TeslaSolarCharger.Tests/Services/Server/BackendApiService.cs @@ -0,0 +1,31 @@ +using TeslaSolarCharger.Server.Dtos.TscBackend; +using Xunit; +using Xunit.Abstractions; + +namespace TeslaSolarCharger.Tests.Services.Server; + +public class BackendApiService : TestBase +{ + public BackendApiService(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + public void CanEnCodeCorrectUrl() + { + var backendApiService = Mock.Create(); + var requestInformation = new DtoTeslaOAuthRequestInformation() + { + ClientId = "f29f71d6285a-4873-8b6b-80f15854892e", + Prompt = "login", + RedirectUri = "https://www.teslasolarcharger.de/", + ResponseType = "code", + Scope = "offline_access vehicle_device_data vehicle_cmds vehicle_charging_cmds", + State = "8774fbe7-f9aa-4e36-8e88-5c8b27137f20", + }; + var url = backendApiService.GenerateAuthUrl(requestInformation, "en-US"); + var expectedUrl = "https://auth.tesla.com/oauth2/v3/authorize?&client_id=f29f71d6285a-4873-8b6b-80f15854892e&locale=en-US&prompt=login&redirect_uri=https%3A%2F%2Fwww.teslasolarcharger.de%2F&response_type=code&scope=offline_access%20vehicle_device_data%20vehicle_cmds%20vehicle_charging_cmds&state=8774fbe7-f9aa-4e36-8e88-5c8b27137f20"; + Assert.Equal(expectedUrl, url); + } +} diff --git a/TeslaSolarCharger/Client/Pages/BaseConfiguration.razor b/TeslaSolarCharger/Client/Pages/BaseConfiguration.razor index 6f9327704..6fd498a21 100644 --- a/TeslaSolarCharger/Client/Pages/BaseConfiguration.razor +++ b/TeslaSolarCharger/Client/Pages/BaseConfiguration.razor @@ -1,9 +1,12 @@ @page "/BaseConfiguration" +@using System.Globalization @using TeslaSolarCharger.Shared.Dtos.BaseConfiguration @using TeslaSolarCharger.Shared @using TeslaSolarCharger.Shared.Enums @using Majorsoft.Blazor.Components.Collapse +@using TeslaSolarCharger.Shared.Dtos @inject HttpClient HttpClient +@inject NavigationManager NavigationManager @inject IToastService ToastService @@ -30,6 +33,52 @@ else
+ @if (_fleetApiTokenState != FleetApiTokenState.NotNeeded) + { +

Tesla Fleet API:

+ @switch (_fleetApiTokenState) + { + case FleetApiTokenState.NotRequested: +
+ You have to generate a token in order to use the Tesla Fleet API. Important: You have to allow access to all scopes. +
+ break; + case FleetApiTokenState.NotReceived: +
+ You already have requested a token but did not receive it yet. It can take up to five minutes to receive the token. If the token did not arrive within five minutes please try again: +
+ break; + case FleetApiTokenState.TokenRequestExpired: +
+ Your token request has expired. Please generate a new token: +
+ break; + case FleetApiTokenState.TokenUnauthorized: +
+ Your token is unauthorized, Please generate a new token, allow access to all scopes and enable mobile access in your car. +
+ break; + case FleetApiTokenState.MissingScopes: +
+ Your token has missing scopes. Remove Tesla Solar Charger from your third party apps as you won't get asked again for the scopes. After that generate a new token and allow access to all scopes. +
+ break; + case FleetApiTokenState.Expired: +
+ Your token has expired. This could happen if you changed your Tesla password or did not use TeslaSolarCharger for too long. Please generate a new token: +
+ break; + case FleetApiTokenState.UpToDate: +
+ Everything is fine! If you want to generate a new token e.g. to switch to another Tesla Account please click the button below: +
+ break; + } +
+ +
+
+ }

TeslaMate:


- - - - - -
- - + @if (_fleetApiTokenState == FleetApiTokenState.NotNeeded) + { + + + + + +
+ } + ("/api/BaseConfiguration/GetBaseConfiguration").ConfigureAwait(false); + var value = await HttpClient.GetFromJsonAsync>("api/FleetApi/FleetApiTokenState").ConfigureAwait(false); + if (value != null) + { + _fleetApiTokenState = value.Value; + } } private async Task HandleValidSubmit() @@ -546,6 +606,17 @@ else } } + private async Task GenerateFleetApiToken() + { + _tokenGenerationButtonDisabled = true; + var locale = CultureInfo.CurrentCulture.ToString(); + var url = await HttpClient.GetFromJsonAsync>($"api/FleetApi/GetOauthUrl?locale={locale}").ConfigureAwait(false); + if (url?.Value != null) + { + NavigationManager.NavigateTo(url.Value); + } + } + private string _collapsedColor = "LightGray"; private string _expandedColor = "LightGray"; private string _hoverColor = "LightGray"; diff --git a/TeslaSolarCharger/Client/Pages/Index.razor b/TeslaSolarCharger/Client/Pages/Index.razor index 0578955f7..529372702 100644 --- a/TeslaSolarCharger/Client/Pages/Index.razor +++ b/TeslaSolarCharger/Client/Pages/Index.razor @@ -358,6 +358,12 @@ else {

Version: @_version

} +
+ Installation ID: @_installationId +
+
+ Language settings: @CultureInfo.CurrentCulture +
@@ -394,6 +400,7 @@ else private string? _serverTimeZoneDisplayName; private bool? _shouldDisplayApiRequestCounter; private int? _apiRequestCount; + private string _installationId = ""; private Timer? _timer; @@ -410,6 +417,7 @@ else var dtoSolarChargerInstallation = await HttpClient.GetFromJsonAsync>("api/Hello/IsSolarEdgeInstallation").ConfigureAwait(false); _isSolarEdgeInstallation = dtoSolarChargerInstallation?.Value; _version = await HttpClient.GetStringAsync("api/Hello/ProductVersion").ConfigureAwait(false); + _installationId = await HttpClient.GetStringAsync("api/Hello/GetInstallationId").ConfigureAwait(false); foreach (var carBaseState in _carBaseStates!) { _collapsedCarDetails.Add(carBaseState.CarId); diff --git a/TeslaSolarCharger/Server/Contracts/ICoreService.cs b/TeslaSolarCharger/Server/Contracts/ICoreService.cs index 706836fb9..8b5572642 100644 --- a/TeslaSolarCharger/Server/Contracts/ICoreService.cs +++ b/TeslaSolarCharger/Server/Contracts/ICoreService.cs @@ -19,4 +19,5 @@ public interface ICoreService DtoValue TeslaApiRequestsSinceStartup(); DtoValue ShouldDisplayApiRequestCounter(); Task> GetPriceData(DateTimeOffset from, DateTimeOffset to); + Task GetInstallationId(); } diff --git a/TeslaSolarCharger/Server/Contracts/ITeslaService.cs b/TeslaSolarCharger/Server/Contracts/ITeslaService.cs index 42f648a74..ea364af13 100644 --- a/TeslaSolarCharger/Server/Contracts/ITeslaService.cs +++ b/TeslaSolarCharger/Server/Contracts/ITeslaService.cs @@ -9,4 +9,5 @@ public interface ITeslaService Task StopCharging(int carId); Task SetAmp(int carId, int amps); Task SetScheduledCharging(int carId, DateTimeOffset? chargingStartTime); + Task SetChargeLimit(int carId, int limitSoC); } diff --git a/TeslaSolarCharger/Server/Controllers/ConfigController.cs b/TeslaSolarCharger/Server/Controllers/ConfigController.cs index 16e66961d..f2a015ff6 100644 --- a/TeslaSolarCharger/Server/Controllers/ConfigController.cs +++ b/TeslaSolarCharger/Server/Controllers/ConfigController.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc; using TeslaSolarCharger.Server.Contracts; +using TeslaSolarCharger.Server.Dtos.TscBackend; +using TeslaSolarCharger.Server.Services.Contracts; using TeslaSolarCharger.Shared.Dtos; using TeslaSolarCharger.Shared.Dtos.Contracts; using TeslaSolarCharger.Shared.Dtos.Settings; @@ -10,10 +12,12 @@ namespace TeslaSolarCharger.Server.Controllers public class ConfigController : ApiBaseController { private readonly IConfigService _service; + private readonly ITeslaFleetApiService _teslaFleetApiService; - public ConfigController(IConfigService service) + public ConfigController(IConfigService service, ITeslaFleetApiService teslaFleetApiService) { _service = service; + _teslaFleetApiService = teslaFleetApiService; } /// @@ -28,7 +32,7 @@ public ConfigController(IConfigService service) /// Car Id of car to update /// Car Configuration which should be set to car [HttpPut] - public void UpdateCarConfiguration(int carId, [FromBody] CarConfiguration carConfiguration) => + public Task UpdateCarConfiguration(int carId, [FromBody] CarConfiguration carConfiguration) => _service.UpdateCarConfiguration(carId, carConfiguration); /// @@ -43,7 +47,11 @@ public void UpdateCarConfiguration(int carId, [FromBody] CarConfiguration carCon /// Car Id of car to update /// Car Configuration which should be set to car [HttpPut] - public void UpdateCarBasicConfiguration(int carId, [FromBody] CarBasicConfiguration carBasicConfiguration) => + public Task UpdateCarBasicConfiguration(int carId, [FromBody] CarBasicConfiguration carBasicConfiguration) => _service.UpdateCarBasicConfiguration(carId, carBasicConfiguration); + + [HttpPost] + public Task AddTeslaFleetApiToken([FromBody] DtoTeslaTscDeliveryToken token) => + _teslaFleetApiService.AddNewTokenAsync(token); } } diff --git a/TeslaSolarCharger/Server/Controllers/FleetApiController.cs b/TeslaSolarCharger/Server/Controllers/FleetApiController.cs new file mode 100644 index 000000000..29b645810 --- /dev/null +++ b/TeslaSolarCharger/Server/Controllers/FleetApiController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using TeslaSolarCharger.Server.Contracts; +using TeslaSolarCharger.Server.Services.Contracts; +using TeslaSolarCharger.Shared.Contracts; +using TeslaSolarCharger.Shared.Dtos; +using TeslaSolarCharger.Shared.Enums; +using TeslaSolarCharger.SharedBackend.Abstracts; + +namespace TeslaSolarCharger.Server.Controllers; + +public class FleetApiController : ApiBaseController +{ + private readonly ITeslaFleetApiService _fleetApiService; + private readonly IBackendApiService _backendApiService; + private readonly ITeslaService _teslaService; + private readonly IConfigurationWrapper _configurationWrapper; + + public FleetApiController(ITeslaFleetApiService fleetApiService, IBackendApiService backendApiService, ITeslaService teslaService, + IConfigurationWrapper configurationWrapper) + { + _fleetApiService = fleetApiService; + _backendApiService = backendApiService; + _teslaService = teslaService; + _configurationWrapper = configurationWrapper; + } + + [HttpGet] + public Task> FleetApiTokenState() => _fleetApiService.GetFleetApiTokenState(); + + [HttpGet] + public Task> GetOauthUrl(string locale) => _backendApiService.StartTeslaOAuth(locale); + + [HttpGet] + public Task RefreshFleetApiToken() => _fleetApiService.RefreshTokenAsync(); + + /// + /// Note: This endpoint is only available in development environment + /// + /// Is thrown when not beeing in dev Mode + [HttpGet] + public Task SetChargeLimit(int carId, int percent) + { + if (!_configurationWrapper.IsDevelopmentEnvironment()) + { + throw new InvalidOperationException("This method is only available in development environment"); + } + return _teslaService.SetChargeLimit(carId, percent); + } +} diff --git a/TeslaSolarCharger/Server/Controllers/HelloController.cs b/TeslaSolarCharger/Server/Controllers/HelloController.cs index f1fe03f4c..b28605ce9 100644 --- a/TeslaSolarCharger/Server/Controllers/HelloController.cs +++ b/TeslaSolarCharger/Server/Controllers/HelloController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using TeslaSolarCharger.GridPriceProvider.Data; using TeslaSolarCharger.Server.Contracts; +using TeslaSolarCharger.Server.Services.Contracts; using TeslaSolarCharger.Shared.Dtos; using TeslaSolarCharger.SharedBackend.Abstracts; @@ -9,10 +10,12 @@ namespace TeslaSolarCharger.Server.Controllers public class HelloController : ApiBaseController { private readonly ICoreService _coreService; + private readonly ITscConfigurationService _tscConfigurationService; - public HelloController(ICoreService coreService) + public HelloController(ICoreService coreService, ITscConfigurationService tscConfigurationService) { _coreService = coreService; + _tscConfigurationService = tscConfigurationService; } [HttpGet] @@ -53,5 +56,8 @@ public HelloController(ICoreService coreService) [HttpGet] public Task> GetPriceData(DateTimeOffset from, DateTimeOffset to) => _coreService.GetPriceData(from, to); + + [HttpGet] + public Task GetInstallationId() => _coreService.GetInstallationId(); } } diff --git a/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoGenericTeslaResponse.cs b/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoGenericTeslaResponse.cs new file mode 100644 index 000000000..af25751f3 --- /dev/null +++ b/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoGenericTeslaResponse.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace TeslaSolarCharger.Server.Dtos.TeslaFleetApi; + +public class DtoGenericTeslaResponse where T : class +{ + [JsonProperty("response")] + public T Response { get; set; } +} diff --git a/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoTeslaFleetApiRefreshToken.cs b/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoTeslaFleetApiRefreshToken.cs new file mode 100644 index 000000000..afd86d893 --- /dev/null +++ b/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoTeslaFleetApiRefreshToken.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace TeslaSolarCharger.Server.Dtos.TeslaFleetApi; + +public class DtoTeslaFleetApiRefreshToken +{ + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + + [JsonProperty("id_token")] + public string IdToken { get; set; } + + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } +} diff --git a/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoVehicleCommandResult.cs b/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoVehicleCommandResult.cs new file mode 100644 index 000000000..9e92583c6 --- /dev/null +++ b/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoVehicleCommandResult.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace TeslaSolarCharger.Server.Dtos.TeslaFleetApi; + +public class DtoVehicleCommandResult +{ + [JsonProperty("reason")] + public string Reason { get; set; } + + [JsonProperty("result")] + public bool Result { get; set; } +} diff --git a/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoWakeUpResult.cs b/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoWakeUpResult.cs new file mode 100644 index 000000000..034b6571b --- /dev/null +++ b/TeslaSolarCharger/Server/Dtos/TeslaFleetApi/DtoWakeUpResult.cs @@ -0,0 +1,57 @@ +using Newtonsoft.Json; + +namespace TeslaSolarCharger.Server.Dtos.TeslaFleetApi; + +public class DtoWakeUpResult +{ + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("user_id")] + public int UserId { get; set; } + + [JsonProperty("vehicle_id")] + public int VehicleId { get; set; } + + [JsonProperty("vin")] + public string Vin { get; set; } + + [JsonProperty("color")] + public object Color { get; set; } + + [JsonProperty("access_type")] + public string AccessType { get; set; } + + [JsonProperty("granular_access")] + public GranularAccess GranularAccess { get; set; } + + [JsonProperty("tokens")] + public List Tokens { get; set; } + + [JsonProperty("state")] + public object State { get; set; } + + [JsonProperty("in_service")] + public bool InService { get; set; } + + [JsonProperty("id_s")] + public string IdS { get; set; } + + [JsonProperty("calendar_enabled")] + public bool CalendarEnabled { get; set; } + + [JsonProperty("api_version")] + public object ApiVersion { get; set; } + + [JsonProperty("backseat_token")] + public object BackseatToken { get; set; } + + [JsonProperty("backseat_token_updated_at")] + public object BackseatTokenUpdatedAt { get; set; } +} + +public class GranularAccess +{ + [JsonProperty("hide_private")] + public bool HidePrivate { get; set; } +} diff --git a/TeslaSolarCharger/Server/Dtos/TscBackend/DtoErrorInformation.cs b/TeslaSolarCharger/Server/Dtos/TscBackend/DtoErrorInformation.cs new file mode 100644 index 000000000..efd1595e9 --- /dev/null +++ b/TeslaSolarCharger/Server/Dtos/TscBackend/DtoErrorInformation.cs @@ -0,0 +1,11 @@ +namespace TeslaSolarCharger.Server.Dtos.TscBackend; + +public class DtoErrorInformation +{ + public string InstallationId { get; set; } + public string Source { get; set; } + public string MethodName { get; set; } + public string Message { get; set; } + public string Version { get; set; } + public string? StackTrace { get; set; } +} diff --git a/TeslaSolarCharger/Server/Dtos/TscBackend/DtoInstallationInformation.cs b/TeslaSolarCharger/Server/Dtos/TscBackend/DtoInstallationInformation.cs new file mode 100644 index 000000000..df05e1ee1 --- /dev/null +++ b/TeslaSolarCharger/Server/Dtos/TscBackend/DtoInstallationInformation.cs @@ -0,0 +1,8 @@ +namespace TeslaSolarCharger.Server.Dtos.TscBackend; + +public class DtoInstallationInformation +{ + public string InstallationId { get; set; } + public string Version { get; set; } + public string InfoReason { get; set; } +} diff --git a/TeslaSolarCharger/Server/Dtos/TscBackend/DtoTeslaOAuthRequestInformation.cs b/TeslaSolarCharger/Server/Dtos/TscBackend/DtoTeslaOAuthRequestInformation.cs new file mode 100644 index 000000000..99ee3eeac --- /dev/null +++ b/TeslaSolarCharger/Server/Dtos/TscBackend/DtoTeslaOAuthRequestInformation.cs @@ -0,0 +1,11 @@ +namespace TeslaSolarCharger.Server.Dtos.TscBackend; + +public class DtoTeslaOAuthRequestInformation +{ + public string ClientId { get; set; } + public string Prompt { get; set; } + public string RedirectUri { get; set; } + public string ResponseType { get; set; } + public string Scope { get; set; } + public string State { get; set; } +} diff --git a/TeslaSolarCharger/Server/Dtos/TscBackend/DtoTeslaTscDeliveryToken.cs b/TeslaSolarCharger/Server/Dtos/TscBackend/DtoTeslaTscDeliveryToken.cs new file mode 100644 index 000000000..1d9facb72 --- /dev/null +++ b/TeslaSolarCharger/Server/Dtos/TscBackend/DtoTeslaTscDeliveryToken.cs @@ -0,0 +1,12 @@ +using TeslaSolarCharger.Model.Enums; + +namespace TeslaSolarCharger.Server.Dtos.TscBackend; + +public class DtoTeslaTscDeliveryToken +{ + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string IdToken { get; set; } + public TeslaFleetApiRegion Region { get; set; } + public int ExpiresIn { get; set; } +} diff --git a/TeslaSolarCharger/Server/Program.cs b/TeslaSolarCharger/Server/Program.cs index e70e02fc1..a933d2973 100644 --- a/TeslaSolarCharger/Server/Program.cs +++ b/TeslaSolarCharger/Server/Program.cs @@ -5,6 +5,7 @@ using TeslaSolarCharger.Server; using TeslaSolarCharger.Server.Contracts; using TeslaSolarCharger.Server.Scheduling; +using TeslaSolarCharger.Server.Services.Contracts; using TeslaSolarCharger.Shared.Contracts; var builder = WebApplication.CreateBuilder(args); @@ -14,14 +15,15 @@ // Add services to the container. builder.Services.AddControllersWithViews(); -builder.Services.AddControllers(); +builder.Services.AddControllers().AddNewtonsoftJson(); builder.Services.AddRazorPages(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddMyDependencies(); +var useFleetApi = configurationManager.GetValue("UseFleetApi"); +builder.Services.AddMyDependencies(useFleetApi); builder.Services.AddGridPriceProvider(); builder.Host.UseSerilog((context, configuration) => configuration @@ -58,6 +60,9 @@ var coreService = app.Services.GetRequiredService(); coreService.LogVersion(); + var backendApiService = app.Services.GetRequiredService(); + await backendApiService.PostInstallationInformation("Startup").ConfigureAwait(false); + await coreService.BackupDatabaseIfNeeded().ConfigureAwait(false); var life = app.Services.GetRequiredService(); @@ -69,6 +74,10 @@ var teslaSolarChargerContext = app.Services.GetRequiredService(); await teslaSolarChargerContext.Database.MigrateAsync().ConfigureAwait(false); + var tscConfigurationService = app.Services.GetRequiredService(); + var installationId = await tscConfigurationService.GetInstallationId().ConfigureAwait(false); + logger.LogTrace("Installation Id: {installationId}", installationId); + var chargingCostService = app.Services.GetRequiredService(); await chargingCostService.DeleteDuplicatedHandleCharges().ConfigureAwait(false); diff --git a/TeslaSolarCharger/Server/Resources/PossibleIssues/IssueKeys.cs b/TeslaSolarCharger/Server/Resources/PossibleIssues/IssueKeys.cs index fbbe6ac69..a17e1c3df 100644 --- a/TeslaSolarCharger/Server/Resources/PossibleIssues/IssueKeys.cs +++ b/TeslaSolarCharger/Server/Resources/PossibleIssues/IssueKeys.cs @@ -2,22 +2,28 @@ public class IssueKeys { - public string MqttNotConnected = "MqttNotConnected"; - public string CarSocLimitNotReadable = "CarSocLimitNotReadable"; - public string CarSocNotReadable = "CarSocNotReadable"; - public string GridPowerNotAvailable = "GridPowerNotAvailable"; - public string InverterPowerNotAvailable = "InverterPowerNotAvailable"; - public string HomeBatterySocNotAvailable = "HomeBatterySocNotAvailable"; - public string HomeBatterySocNotPlausible = "HomeBatterySocNotPlausible"; - public string HomeBatteryPowerNotAvailable = "HomeBatteryPowerNotAvailable"; - public string HomeBatteryMinimumSocNotConfigured = "HomeBatteryMinimumSocNotConfigured"; - public string HomeBatteryChargingPowerNotConfigured = "HomeBatteryChargingPowerNotConfigured"; - public string TeslaMateApiNotAvailable = "TeslaMateApiNotAvailable"; - public string DatabaseNotAvailable = "DatabaseNotAvailable"; - public string GeofenceNotAvailable = "GeofenceNotAvailable"; - public string CarIdNotAvailable = "CarIdNotAvailable"; - public string VersionNotUpToDate = "VersionNotUpToDate"; - public string CorrectionFactorZero = "CorrectionFactorZero"; - public string ServerTimeZoneDifferentFromClient = "ServerTimeZoneDifferentFromClient"; - public string NewTeslaApi = "NewTeslaApi"; + public string MqttNotConnected => "MqttNotConnected"; + public string CarSocLimitNotReadable => "CarSocLimitNotReadable"; + public string CarSocNotReadable => "CarSocNotReadable"; + public string GridPowerNotAvailable => "GridPowerNotAvailable"; + public string InverterPowerNotAvailable => "InverterPowerNotAvailable"; + public string HomeBatterySocNotAvailable => "HomeBatterySocNotAvailable"; + public string HomeBatterySocNotPlausible => "HomeBatterySocNotPlausible"; + public string HomeBatteryPowerNotAvailable => "HomeBatteryPowerNotAvailable"; + public string HomeBatteryMinimumSocNotConfigured => "HomeBatteryMinimumSocNotConfigured"; + public string HomeBatteryChargingPowerNotConfigured => "HomeBatteryChargingPowerNotConfigured"; + public string TeslaMateApiNotAvailable => "TeslaMateApiNotAvailable"; + public string DatabaseNotAvailable => "DatabaseNotAvailable"; + public string GeofenceNotAvailable => "GeofenceNotAvailable"; + public string CarIdNotAvailable => "CarIdNotAvailable"; + public string VersionNotUpToDate => "VersionNotUpToDate"; + public string CorrectionFactorZero => "CorrectionFactorZero"; + public string ServerTimeZoneDifferentFromClient => "ServerTimeZoneDifferentFromClient"; + public string NewTeslaApiNotUsed => "NewTeslaApiNotUsed"; + public string FleetApiTokenNotRequested => "FleetApiTokenNotRequested"; + public string FleetApiTokenUnauthorized => "FleetApiTokenUnauthorized"; + public string FleetApiTokenMissingScopes => "FleetApiTokenMissingScopes"; + public string FleetApiTokenRequestExpired => "FleetApiTokenRequestExpired"; + public string FleetApiTokenNotReceived => "FleetApiTokenNotReceived"; + public string FleetApiTokenExpired => "FleetApiTokenExpired"; } diff --git a/TeslaSolarCharger/Server/Resources/PossibleIssues/PossibleIssues.cs b/TeslaSolarCharger/Server/Resources/PossibleIssues/PossibleIssues.cs index a25fdb6f0..0b6255f2d 100644 --- a/TeslaSolarCharger/Server/Resources/PossibleIssues/PossibleIssues.cs +++ b/TeslaSolarCharger/Server/Resources/PossibleIssues/PossibleIssues.cs @@ -135,10 +135,49 @@ public PossibleIssues(IssueKeys issueKeys) ) }, { - issueKeys.NewTeslaApi, CreateIssue("New cars are currently not supported", + issueKeys.NewTeslaApiNotUsed, CreateIssue("New cars need a new Tesla API. As this is in a very early beta state I highly recommend not using it if your car supports the old API!", IssueType.Information, - "If there are any updates on this topic, I will post them here.", - "Sorry for this information beeing not removable - it will be possible in a future update." + "To use the new API add UseFleetApi=true as environment variable in your docker-compose.yml", + "Sorry for this information beeing not removable - as switching to the new API in January 2024 as default you won't see this information from then on." + ) + }, + { + issueKeys.FleetApiTokenNotRequested, CreateIssue("You did not request a Tesla Token, yet.", + IssueType.Error, + "Open the Base Configuration and request a new token. Important: You need to allow acces to all selectable scopes." + ) + }, + { + issueKeys.FleetApiTokenUnauthorized, CreateIssue("Your Tesla token is unauthorized, this could be due to a changed Tesla account password, or your you disabled mobile access in your car.", + IssueType.Error, + "Open the Base Configuration, request a new token and select all available scopes.", + "Enable mobile access in your car." + ) + }, + { + issueKeys.FleetApiTokenMissingScopes, CreateIssue("Your Tesla token has missing scopes.", + IssueType.Error, + "Remove Tesla Solar Charger from your third party apps as you won't get asked again for the scopes. After that request a new token in the Base Configuration and select all available scopes." + ) + }, + { + issueKeys.FleetApiTokenNotReceived, CreateIssue("The Tesla token was not received, yet.", + IssueType.Error, + "Getting the Token can take up to five minutes after submitting your password.", + "If waiting five minutes does not help, open the Base Configuration and request a new token." + ) + }, + { + issueKeys.FleetApiTokenRequestExpired, CreateIssue("The Tesla token could not be received.", + IssueType.Error, + "Open the Base Configuration and request a new token.", + "If this issue keeps occuring, feel free to open an issue on Github including the last 5 chars of your installation ID (bottom of the page). Do NOT include the whole ID." + ) + }, + { + issueKeys.FleetApiTokenExpired, CreateIssue("Your Tesla token has expired, this can occur when you changed your password or did not use the TeslaSolarCharger for too long..", + IssueType.Error, + "Open the Base Configuration and request a new token." ) }, }; diff --git a/TeslaSolarCharger/Server/Scheduling/JobManager.cs b/TeslaSolarCharger/Server/Scheduling/JobManager.cs index 960cc803b..f37f27e41 100644 --- a/TeslaSolarCharger/Server/Scheduling/JobManager.cs +++ b/TeslaSolarCharger/Server/Scheduling/JobManager.cs @@ -42,6 +42,7 @@ public async Task StartJobs() var mqttReconnectionJob = JobBuilder.Create().Build(); var newVersionCheckJob = JobBuilder.Create().Build(); var spotPriceJob = JobBuilder.Create().Build(); + var fleetApiTokenRefreshJob = JobBuilder.Create().Build(); var currentDate = _dateTimeProvider.DateTimeOffSetNow(); var chargingTriggerStartTime = currentDate.AddSeconds(5); @@ -63,8 +64,6 @@ public async Task StartJobs() .WithSchedule(SimpleScheduleBuilder.RepeatSecondlyForever((int)pvValueJobIntervall.TotalSeconds)) .Build(); - - var carStateCachingTrigger = TriggerBuilder.Create() .WithSchedule(SimpleScheduleBuilder.RepeatMinutelyForever(3)).Build(); @@ -83,6 +82,9 @@ public async Task StartJobs() var spotPricePlanningTrigger = TriggerBuilder.Create() .WithSchedule(SimpleScheduleBuilder.RepeatHourlyForever(1)).Build(); + var fleetApiTokenRefreshTrigger = TriggerBuilder.Create() + .WithSchedule(SimpleScheduleBuilder.RepeatSecondlyForever(59)).Build(); + var triggersAndJobs = new Dictionary> { {chargingValueJob, new HashSet { chargingValueTrigger }}, @@ -93,6 +95,7 @@ public async Task StartJobs() {mqttReconnectionJob, new HashSet {mqttReconnectionTrigger}}, {newVersionCheckJob, new HashSet {newVersionCheckTrigger}}, {spotPriceJob, new HashSet {spotPricePlanningTrigger}}, + {fleetApiTokenRefreshJob, new HashSet {fleetApiTokenRefreshTrigger}}, }; await _scheduler.ScheduleJobs(triggersAndJobs, false).ConfigureAwait(false); diff --git a/TeslaSolarCharger/Server/Scheduling/Jobs/FleetApiTokenRefreshJob.cs b/TeslaSolarCharger/Server/Scheduling/Jobs/FleetApiTokenRefreshJob.cs new file mode 100644 index 000000000..cf5a21377 --- /dev/null +++ b/TeslaSolarCharger/Server/Scheduling/Jobs/FleetApiTokenRefreshJob.cs @@ -0,0 +1,23 @@ +using Quartz; +using TeslaSolarCharger.Server.Services.Contracts; + +namespace TeslaSolarCharger.Server.Scheduling.Jobs; + +[DisallowConcurrentExecution] +public class FleetApiTokenRefreshJob : IJob +{ + private readonly ILogger _logger; + private readonly ITeslaFleetApiService _service; + + public FleetApiTokenRefreshJob(ILogger logger, ITeslaFleetApiService service) + { + _logger = logger; + _service = service; + } + + public async Task Execute(IJobExecutionContext context) + { + _logger.LogTrace("{method}({context})", nameof(Execute), context); + await _service.RefreshTokenAsync().ConfigureAwait(false); + } +} diff --git a/TeslaSolarCharger/Server/ServiceCollectionExtensions.cs b/TeslaSolarCharger/Server/ServiceCollectionExtensions.cs index 8c1da5182..50fee7932 100644 --- a/TeslaSolarCharger/Server/ServiceCollectionExtensions.cs +++ b/TeslaSolarCharger/Server/ServiceCollectionExtensions.cs @@ -33,8 +33,9 @@ namespace TeslaSolarCharger.Server; public static class ServiceCollectionExtensions { - public static IServiceCollection AddMyDependencies(this IServiceCollection services) - => services + public static IServiceCollection AddMyDependencies(this IServiceCollection services, bool useFleetApi) + { + services .AddSingleton() .AddTransient() .AddTransient() @@ -44,6 +45,7 @@ public static IServiceCollection AddMyDependencies(this IServiceCollection servi .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() @@ -52,7 +54,6 @@ public static IServiceCollection AddMyDependencies(this IServiceCollection servi .AddTransient() .AddTransient() .AddTransient() - .AddTransient() .AddSingleton() .AddSingleton() .AddSingleton() @@ -93,6 +94,20 @@ public static IServiceCollection AddMyDependencies(this IServiceCollection servi .AddTransient() .AddTransient() .AddTransient() - .AddSharedBackendDependencies() - ; + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddSharedBackendDependencies(); + if (useFleetApi) + { + services.AddTransient(); + } + else + { + services.AddTransient(); + } + + return services; + } } diff --git a/TeslaSolarCharger/Server/Services/ApiServices/Contracts/ITeslamateApiService.cs b/TeslaSolarCharger/Server/Services/ApiServices/Contracts/ITeslamateApiService.cs new file mode 100644 index 000000000..e40103129 --- /dev/null +++ b/TeslaSolarCharger/Server/Services/ApiServices/Contracts/ITeslamateApiService.cs @@ -0,0 +1,6 @@ +namespace TeslaSolarCharger.Server.Services.ApiServices.Contracts; + +public interface ITeslamateApiService +{ + Task ResumeLogging(int carId); +} diff --git a/TeslaSolarCharger/Server/Services/BackendApiService.cs b/TeslaSolarCharger/Server/Services/BackendApiService.cs new file mode 100644 index 000000000..82f5785d3 --- /dev/null +++ b/TeslaSolarCharger/Server/Services/BackendApiService.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using System.Diagnostics; +using System.Reflection; +using TeslaSolarCharger.Model.Contracts; +using TeslaSolarCharger.Model.Entities.TeslaSolarCharger; +using TeslaSolarCharger.Server.Dtos.TscBackend; +using TeslaSolarCharger.Server.Services.Contracts; +using TeslaSolarCharger.Shared.Contracts; +using TeslaSolarCharger.Shared.Dtos; +using TeslaSolarCharger.SharedBackend.Contracts; + +namespace TeslaSolarCharger.Server.Services; + +public class BackendApiService : IBackendApiService +{ + private readonly ILogger _logger; + private readonly ITscConfigurationService _tscConfigurationService; + private readonly IConfigurationWrapper _configurationWrapper; + private readonly ITeslaSolarChargerContext _teslaSolarChargerContext; + private readonly IConstants _constants; + private readonly IDateTimeProvider _dateTimeProvider; + + public BackendApiService(ILogger logger, ITscConfigurationService tscConfigurationService, + IConfigurationWrapper configurationWrapper, ITeslaSolarChargerContext teslaSolarChargerContext, IConstants constants, + IDateTimeProvider dateTimeProvider) + { + _logger = logger; + _tscConfigurationService = tscConfigurationService; + _configurationWrapper = configurationWrapper; + _teslaSolarChargerContext = teslaSolarChargerContext; + _constants = constants; + _dateTimeProvider = dateTimeProvider; + } + + public async Task> StartTeslaOAuth(string locale) + { + _logger.LogTrace("{method}()", nameof(StartTeslaOAuth)); + var currentTokens = await _teslaSolarChargerContext.TeslaTokens.ToListAsync().ConfigureAwait(false); + _teslaSolarChargerContext.TeslaTokens.RemoveRange(currentTokens); + var cconfigEntriesToRemove = await _teslaSolarChargerContext.TscConfigurations + .Where(c => c.Key == _constants.TokenRefreshUnauthorized + || c.Key == _constants.TokenMissingScopes) + .ToListAsync().ConfigureAwait(false); + _teslaSolarChargerContext.TscConfigurations.RemoveRange(cconfigEntriesToRemove); + await _teslaSolarChargerContext.SaveChangesAsync().ConfigureAwait(false); + var installationId = await _tscConfigurationService.GetInstallationId().ConfigureAwait(false); + var backendApiBaseUrl = _configurationWrapper.BackendApiBaseUrl(); + using var httpClient = new HttpClient(); + var requestUri = $"{backendApiBaseUrl}Tsc/StartTeslaOAuth?installationId={installationId}"; + var responseString = await httpClient.GetStringAsync(requestUri).ConfigureAwait(false); + var oAuthRequestInformation = JsonConvert.DeserializeObject(responseString) ?? throw new InvalidDataException("Could not get oAuth data"); + var requestUrl = GenerateAuthUrl(oAuthRequestInformation, locale); + var tokenRequested = await _teslaSolarChargerContext.TscConfigurations + .Where(c => c.Key == _constants.FleetApiTokenRequested) + .FirstOrDefaultAsync().ConfigureAwait(false); + if (tokenRequested == null) + { + var config = new TscConfiguration + { + Key = _constants.FleetApiTokenRequested, + Value = _dateTimeProvider.UtcNow().ToString("O"), + }; + _teslaSolarChargerContext.TscConfigurations.Add(config); + } + else + { + tokenRequested.Value = _dateTimeProvider.UtcNow().ToString("O"); + } + await _teslaSolarChargerContext.SaveChangesAsync().ConfigureAwait(false); + return new DtoValue(requestUrl); + } + + internal string GenerateAuthUrl(DtoTeslaOAuthRequestInformation oAuthInformation, string locale) + { + _logger.LogTrace("{method}({@oAuthInformation})", nameof(GenerateAuthUrl), oAuthInformation); + var url = + $"https://auth.tesla.com/oauth2/v3/authorize?&client_id={Uri.EscapeDataString(oAuthInformation.ClientId)}&locale={Uri.EscapeDataString(locale)}&prompt={Uri.EscapeDataString(oAuthInformation.Prompt)}&redirect_uri={Uri.EscapeDataString(oAuthInformation.RedirectUri)}&response_type={Uri.EscapeDataString(oAuthInformation.ResponseType)}&scope={Uri.EscapeDataString(oAuthInformation.Scope)}&state={Uri.EscapeDataString(oAuthInformation.State)}"; + return url; + } + + public async Task PostInstallationInformation(string reason) + { + try + { + var url = _configurationWrapper.BackendApiBaseUrl() + "Tsc/NotifyInstallation"; + var installationId = await _tscConfigurationService.GetInstallationId().ConfigureAwait(false); + var currentVersion = await GetCurrentVersion().ConfigureAwait(false); + var installationInformation = new DtoInstallationInformation + { + InstallationId = installationId.ToString(), + Version = currentVersion ?? "unknown", + InfoReason = reason, + }; + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromSeconds(10); + var response = await httpClient.PostAsJsonAsync(url, installationInformation).ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogError(e, "Could not post installation information"); + } + + } + + public async Task PostErrorInformation(string source, string methodName, string message, string? stackTrace = null) + { + try + { + var url = _configurationWrapper.BackendApiBaseUrl() + "Tsc/NotifyError"; + var installationId = await _tscConfigurationService.GetInstallationId().ConfigureAwait(false); + var currentVersion = await GetCurrentVersion().ConfigureAwait(false); + var errorInformation = new DtoErrorInformation() + { + InstallationId = installationId.ToString(), + Source = source, + MethodName = methodName, + Message = message, + Version = currentVersion ?? "unknown", + StackTrace = stackTrace, + }; + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromSeconds(10); + var response = await httpClient.PostAsJsonAsync(url, errorInformation).ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogError(e, "Could not post error information"); + } + + } + + public Task GetCurrentVersion() + { + _logger.LogTrace("{method}()", nameof(GetCurrentVersion)); + var assembly = Assembly.GetExecutingAssembly(); + var fileVersionInfo = FileVersionInfo.GetVersionInfo(assembly.Location); + return Task.FromResult(fileVersionInfo.ProductVersion); + } +} diff --git a/TeslaSolarCharger/Server/Services/BaseConfigurationService.cs b/TeslaSolarCharger/Server/Services/BaseConfigurationService.cs index 27178b6a9..3fe2ec9d4 100644 --- a/TeslaSolarCharger/Server/Services/BaseConfigurationService.cs +++ b/TeslaSolarCharger/Server/Services/BaseConfigurationService.cs @@ -1,8 +1,5 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Data.Sqlite; -using Microsoft.Net.Http.Headers; +using Microsoft.Data.Sqlite; using System.IO.Compression; -using System.Net; using TeslaSolarCharger.Model.Contracts; using TeslaSolarCharger.Server.Contracts; using TeslaSolarCharger.Server.Scheduling; @@ -10,7 +7,6 @@ using TeslaSolarCharger.Shared.Dtos.BaseConfiguration; using TeslaSolarCharger.Shared.Dtos.Contracts; using TeslaSolarCharger.Shared.Enums; -using MediaTypeHeaderValue = System.Net.Http.Headers.MediaTypeHeaderValue; namespace TeslaSolarCharger.Server.Services; @@ -103,9 +99,7 @@ public async Task DownloadBackup() //Backup config files var baseConfigFileFullName = _configurationWrapper.BaseConfigFileFullName(); - var carConfigFileFullName = _configurationWrapper.CarConfigFileFullName(); File.Copy(baseConfigFileFullName, Path.Combine(backupCopyDestinationDirectory, Path.GetFileName(baseConfigFileFullName)), true); - File.Copy(carConfigFileFullName, Path.Combine(backupCopyDestinationDirectory, Path.GetFileName(carConfigFileFullName)), true); var backupFileName = "TSC-Backup.zip"; diff --git a/TeslaSolarCharger/Server/Services/Contracts/IBackendApiService.cs b/TeslaSolarCharger/Server/Services/Contracts/IBackendApiService.cs new file mode 100644 index 000000000..c5449df0f --- /dev/null +++ b/TeslaSolarCharger/Server/Services/Contracts/IBackendApiService.cs @@ -0,0 +1,10 @@ +using TeslaSolarCharger.Shared.Dtos; + +namespace TeslaSolarCharger.Server.Services.Contracts; + +public interface IBackendApiService +{ + Task> StartTeslaOAuth(string locale); + Task PostInstallationInformation(string reason); + Task PostErrorInformation(string source, string methodName, string message, string? stackTrace = null); +} diff --git a/TeslaSolarCharger/Server/Services/Contracts/ITeslaFleetApiService.cs b/TeslaSolarCharger/Server/Services/Contracts/ITeslaFleetApiService.cs new file mode 100644 index 000000000..1d02b4204 --- /dev/null +++ b/TeslaSolarCharger/Server/Services/Contracts/ITeslaFleetApiService.cs @@ -0,0 +1,15 @@ +using TeslaSolarCharger.Model.Enums; +using TeslaSolarCharger.Server.Dtos; +using TeslaSolarCharger.Server.Dtos.TeslaFleetApi; +using TeslaSolarCharger.Server.Dtos.TscBackend; +using TeslaSolarCharger.Shared.Dtos; +using TeslaSolarCharger.Shared.Enums; + +namespace TeslaSolarCharger.Server.Services.Contracts; + +public interface ITeslaFleetApiService +{ + Task AddNewTokenAsync(DtoTeslaTscDeliveryToken token); + Task> GetFleetApiTokenState(); + Task RefreshTokenAsync(); +} diff --git a/TeslaSolarCharger/Server/Services/Contracts/ITscConfigurationService.cs b/TeslaSolarCharger/Server/Services/Contracts/ITscConfigurationService.cs new file mode 100644 index 000000000..55e694179 --- /dev/null +++ b/TeslaSolarCharger/Server/Services/Contracts/ITscConfigurationService.cs @@ -0,0 +1,6 @@ +namespace TeslaSolarCharger.Server.Services.Contracts; + +public interface ITscConfigurationService +{ + Task GetInstallationId(); +} diff --git a/TeslaSolarCharger/Server/Services/CoreService.cs b/TeslaSolarCharger/Server/Services/CoreService.cs index d43c9f8f8..95c4c50bb 100644 --- a/TeslaSolarCharger/Server/Services/CoreService.cs +++ b/TeslaSolarCharger/Server/Services/CoreService.cs @@ -2,8 +2,11 @@ using System.Reflection; using TeslaSolarCharger.GridPriceProvider.Data; using TeslaSolarCharger.GridPriceProvider.Services.Interfaces; +using TeslaSolarCharger.Model.Contracts; using TeslaSolarCharger.Server.Contracts; +using TeslaSolarCharger.Server.Dtos.TscBackend; using TeslaSolarCharger.Server.Scheduling; +using TeslaSolarCharger.Server.Services.Contracts; using TeslaSolarCharger.Shared.Contracts; using TeslaSolarCharger.Shared.Dtos; using TeslaSolarCharger.Shared.Dtos.Contracts; @@ -22,11 +25,12 @@ public class CoreService : ICoreService private readonly ISolarMqttService _solarMqttService; private readonly ISettings _settings; private readonly IFixedPriceService _fixedPriceService; + private readonly ITscConfigurationService _tscConfigurationService; public CoreService(ILogger logger, IChargingService chargingService, IConfigurationWrapper configurationWrapper, IDateTimeProvider dateTimeProvider, IConfigJsonService configJsonService, JobManager jobManager, ITeslaMateMqttService teslaMateMqttService, ISolarMqttService solarMqttService, ISettings settings, - IFixedPriceService fixedPriceService) + IFixedPriceService fixedPriceService, ITscConfigurationService tscConfigurationService) { _logger = logger; _chargingService = chargingService; @@ -38,6 +42,7 @@ public CoreService(ILogger logger, IChargingService chargingService _solarMqttService = solarMqttService; _settings = settings; _fixedPriceService = fixedPriceService; + _tscConfigurationService = tscConfigurationService; } public Task GetCurrentVersion() @@ -169,4 +174,12 @@ public Task> GetPriceData(DateTimeOffset from, DateTimeOffset _logger.LogTrace("{method}({from}, {to})", nameof(GetPriceData), from, to); return _fixedPriceService.GetPriceData(from, to, null); } + + + + public async Task GetInstallationId() + { + var installationId = await _tscConfigurationService.GetInstallationId().ConfigureAwait(false); + return installationId.ToString(); + } } diff --git a/TeslaSolarCharger/Server/Services/IssueValidationService.cs b/TeslaSolarCharger/Server/Services/IssueValidationService.cs index 1d6c5eee8..15565ea0c 100644 --- a/TeslaSolarCharger/Server/Services/IssueValidationService.cs +++ b/TeslaSolarCharger/Server/Services/IssueValidationService.cs @@ -3,6 +3,7 @@ using TeslaSolarCharger.Model.Contracts; using TeslaSolarCharger.Server.Contracts; using TeslaSolarCharger.Server.Resources.PossibleIssues; +using TeslaSolarCharger.Server.Services.Contracts; using TeslaSolarCharger.Shared.Contracts; using TeslaSolarCharger.Shared.Dtos; using TeslaSolarCharger.Shared.Dtos.BaseConfiguration; @@ -23,11 +24,12 @@ public class IssueValidationService : IIssueValidationService private readonly ITeslamateContext _teslamateContext; private readonly IConstants _constants; private readonly IDateTimeProvider _dateTimeProvider; + private readonly ITeslaFleetApiService _teslaFleetApiService; public IssueValidationService(ILogger logger, ISettings settings, ITeslaMateMqttService teslaMateMqttService, IPossibleIssues possibleIssues, IssueKeys issueKeys, IConfigurationWrapper configurationWrapper, ITeslamateContext teslamateContext, - IConstants constants, IDateTimeProvider dateTimeProvider) + IConstants constants, IDateTimeProvider dateTimeProvider, ITeslaFleetApiService teslaFleetApiService) { _logger = logger; _settings = settings; @@ -38,23 +40,59 @@ public IssueValidationService(ILogger logger, ISettings _teslamateContext = teslamateContext; _constants = constants; _dateTimeProvider = dateTimeProvider; + _teslaFleetApiService = teslaFleetApiService; } public async Task> RefreshIssues(TimeSpan clientTimeZoneId) { _logger.LogTrace("{method}()", nameof(RefreshIssues)); - var issueList = new List + var issueList = new List(); + if (!_configurationWrapper.UseFleetApi()) { - _possibleIssues.GetIssueByKey(_issueKeys.NewTeslaApi), - }; + issueList.Add(_possibleIssues.GetIssueByKey(_issueKeys.NewTeslaApiNotUsed)); + } issueList.AddRange(GetServerConfigurationIssues(clientTimeZoneId)); if (Debugger.IsAttached) { - return issueList; + //return issueList; } issueList.AddRange(GetMqttIssues()); issueList.AddRange(PvValueIssues()); - issueList.AddRange(await GetTeslaMateApiIssues().ConfigureAwait(false)); + if (!_configurationWrapper.UseFleetApi()) + { + issueList.AddRange(await GetTeslaMateApiIssues().ConfigureAwait(false)); + } + else + { + var tokenState = (await _teslaFleetApiService.GetFleetApiTokenState().ConfigureAwait(false)).Value; + switch (tokenState) + { + case FleetApiTokenState.NotNeeded: + break; + case FleetApiTokenState.NotRequested: + issueList.Add(_possibleIssues.GetIssueByKey(_issueKeys.FleetApiTokenNotRequested)); + break; + case FleetApiTokenState.TokenRequestExpired: + issueList.Add(_possibleIssues.GetIssueByKey(_issueKeys.FleetApiTokenRequestExpired)); + break; + case FleetApiTokenState.TokenUnauthorized: + issueList.Add(_possibleIssues.GetIssueByKey(_issueKeys.FleetApiTokenUnauthorized)); + break; + case FleetApiTokenState.MissingScopes: + issueList.Add(_possibleIssues.GetIssueByKey(_issueKeys.FleetApiTokenMissingScopes)); + break; + case FleetApiTokenState.NotReceived: + issueList.Add(_possibleIssues.GetIssueByKey(_issueKeys.FleetApiTokenNotReceived)); + break; + case FleetApiTokenState.Expired: + issueList.Add(_possibleIssues.GetIssueByKey(_issueKeys.FleetApiTokenExpired)); + break; + case FleetApiTokenState.UpToDate: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } issueList.AddRange(await GetDatabaseIssues().ConfigureAwait(false)); issueList.AddRange(SofwareIssues()); issueList.AddRange(ConfigurationIssues()); diff --git a/TeslaSolarCharger/Server/Services/NewVersionCheckService.cs b/TeslaSolarCharger/Server/Services/NewVersionCheckService.cs index 7dca13011..4116c5729 100644 --- a/TeslaSolarCharger/Server/Services/NewVersionCheckService.cs +++ b/TeslaSolarCharger/Server/Services/NewVersionCheckService.cs @@ -1,4 +1,7 @@ using TeslaSolarCharger.Server.Contracts; +using TeslaSolarCharger.Server.Dtos.TscBackend; +using TeslaSolarCharger.Server.Services.Contracts; +using TeslaSolarCharger.Shared.Contracts; using TeslaSolarCharger.Shared.Dtos.Contracts; namespace TeslaSolarCharger.Server.Services; @@ -8,23 +11,28 @@ public class NewVersionCheckService : INewVersionCheckService private readonly ILogger _logger; private readonly ICoreService _coreService; private readonly ISettings _settings; + private readonly IBackendApiService _backendApiService; - public NewVersionCheckService(ILogger logger, ICoreService coreService, ISettings settings) + public NewVersionCheckService(ILogger logger, ICoreService coreService, ISettings settings, + IBackendApiService backendApiService) { _logger = logger; _coreService = coreService; _settings = settings; + _backendApiService = backendApiService; } public async Task CheckForNewVersion() { _logger.LogTrace("{method}()", nameof(CheckForNewVersion)); var currentVersion = await _coreService.GetCurrentVersion().ConfigureAwait(false); + await _backendApiService.PostInstallationInformation("CheckForNewVersion").ConfigureAwait(false); if (string.IsNullOrEmpty(currentVersion)) { _settings.IsNewVersionAvailable = false; return; } + try { diff --git a/TeslaSolarCharger/Server/Services/TeslaFleetApiService.cs b/TeslaSolarCharger/Server/Services/TeslaFleetApiService.cs new file mode 100644 index 000000000..d5ff11b52 --- /dev/null +++ b/TeslaSolarCharger/Server/Services/TeslaFleetApiService.cs @@ -0,0 +1,459 @@ +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using TeslaSolarCharger.Model.Contracts; +using TeslaSolarCharger.Model.Entities.TeslaMate; +using TeslaSolarCharger.Model.Entities.TeslaSolarCharger; +using TeslaSolarCharger.Model.Enums; +using TeslaSolarCharger.Server.Contracts; +using TeslaSolarCharger.Server.Dtos; +using TeslaSolarCharger.Server.Dtos.TeslaFleetApi; +using TeslaSolarCharger.Server.Dtos.TscBackend; +using TeslaSolarCharger.Server.Services.ApiServices.Contracts; +using TeslaSolarCharger.Server.Services.Contracts; +using TeslaSolarCharger.Shared.Contracts; +using TeslaSolarCharger.Shared.Dtos; +using TeslaSolarCharger.Shared.Dtos.Contracts; +using TeslaSolarCharger.Shared.Dtos.Settings; +using TeslaSolarCharger.Shared.Enums; +using TeslaSolarCharger.SharedBackend.Contracts; +using Car = TeslaSolarCharger.Shared.Dtos.Settings.Car; + +namespace TeslaSolarCharger.Server.Services; + +public class TeslaFleetApiService : ITeslaService, ITeslaFleetApiService +{ + private readonly ILogger _logger; + private readonly ITeslaSolarChargerContext _teslaSolarChargerContext; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly ITeslamateContext _teslamateContext; + private readonly IConfigurationWrapper _configurationWrapper; + private readonly ITeslamateApiService _teslamateApiService; + private readonly IConstants _constants; + private readonly ITscConfigurationService _tscConfigurationService; + private readonly IBackendApiService _backendApiService; + private readonly ISettings _settings; + + private readonly string _chargeStartComand = "command/charge_start"; + private readonly string _chargeStopComand = "command/charge_stop"; + private readonly string _setChargingAmps = "command/set_charging_amps"; + private readonly string _setScheduledCharging = "command/set_scheduled_charging"; + private readonly string _setSocLimit = "command/set_charge_limit"; + private readonly string _wakeUpComand = "wake_up"; + + public TeslaFleetApiService(ILogger logger, ITeslaSolarChargerContext teslaSolarChargerContext, + IDateTimeProvider dateTimeProvider, ITeslamateContext teslamateContext, IConfigurationWrapper configurationWrapper, + ITeslamateApiService teslamateApiService, IConstants constants, ITscConfigurationService tscConfigurationService, + IBackendApiService backendApiService, ISettings settings) + { + _logger = logger; + _teslaSolarChargerContext = teslaSolarChargerContext; + _dateTimeProvider = dateTimeProvider; + _teslamateContext = teslamateContext; + _configurationWrapper = configurationWrapper; + _teslamateApiService = teslamateApiService; + _constants = constants; + _tscConfigurationService = tscConfigurationService; + _backendApiService = backendApiService; + _settings = settings; + } + + public async Task StartCharging(int carId, int startAmp, CarStateEnum? carState) + { + _logger.LogTrace("{method}({carId}, {startAmp}, {carState})", nameof(StartCharging), carId, startAmp, carState); + if (startAmp == 0) + { + _logger.LogDebug("Should start charging with 0 amp. Skipping charge start."); + return; + } + await WakeUpCarIfNeeded(carId, carState).ConfigureAwait(false); + + var id = await _teslamateContext.Cars.Where(c => c.Id == carId).Select(c => c.Eid).FirstAsync().ConfigureAwait(false); + var result = await SendCommandToTeslaApi(id, _chargeStartComand).ConfigureAwait(false); + } + + + public async Task WakeUpCar(int carId) + { + _logger.LogTrace("{method}({carId})", nameof(WakeUpCar), carId); + var id = await _teslamateContext.Cars.Where(c => c.Id == carId).Select(c => c.Eid).FirstAsync().ConfigureAwait(false); + var result = await SendCommandToTeslaApi(id, _wakeUpComand).ConfigureAwait(false); + await _teslamateApiService.ResumeLogging(carId).ConfigureAwait(false); + + await Task.Delay(TimeSpan.FromSeconds(20)).ConfigureAwait(false); + } + + public async Task StopCharging(int carId) + { + _logger.LogTrace("{method}({carId})", nameof(StopCharging), carId); + var id = await _teslamateContext.Cars.Where(c => c.Id == carId).Select(c => c.Eid).FirstAsync().ConfigureAwait(false); + var result = await SendCommandToTeslaApi(id, _chargeStopComand).ConfigureAwait(false); + } + + public async Task SetAmp(int carId, int amps) + { + _logger.LogTrace("{method}({carId}, {amps})", nameof(SetAmp), carId, amps); + var id = await _teslamateContext.Cars.Where(c => c.Id == carId).Select(c => c.Eid).FirstAsync().ConfigureAwait(false); + var commandData = $"{{\"charging_amps\":{amps}}}"; + var result = await SendCommandToTeslaApi(id, _setChargingAmps, commandData).ConfigureAwait(false); + } + + public async Task SetScheduledCharging(int carId, DateTimeOffset? chargingStartTime) + { + _logger.LogTrace("{method}({param1}, {param2})", nameof(SetScheduledCharging), carId, chargingStartTime); + var id = await _teslamateContext.Cars.Where(c => c.Id == carId).Select(c => c.Eid).FirstAsync().ConfigureAwait(false); + var car = _settings.Cars.First(c => c.Id == carId); + if (!IsChargingScheduleChangeNeeded(chargingStartTime, _dateTimeProvider.DateTimeOffSetNow(), car, out var parameters)) + { + _logger.LogDebug("No change in updating scheduled charging needed."); + return; + } + + await WakeUpCarIfNeeded(carId, car.CarState.State).ConfigureAwait(false); + + var result = await SendCommandToTeslaApi(id, _setScheduledCharging, JsonConvert.SerializeObject(parameters)).ConfigureAwait(false); + //assume update was sucessfull as update is not working after mosquitto restart (or wrong cached State) + if (parameters["enable"] == "false") + { + car.CarState.ScheduledChargingStartTime = null; + } + } + + public async Task SetChargeLimit(int carId, int limitSoC) + { + _logger.LogTrace("{method}({param1}, {param2})", nameof(SetChargeLimit), carId, limitSoC); + var id = await _teslamateContext.Cars.Where(c => c.Id == carId).Select(c => c.Eid).FirstAsync().ConfigureAwait(false); + var car = _settings.Cars.First(c => c.Id == carId); + await WakeUpCarIfNeeded(carId, car.CarState.State).ConfigureAwait(false); + var parameters = new Dictionary() + { + { "percent", limitSoC.ToString() }, + }; + await SendCommandToTeslaApi(id, _setSocLimit, JsonConvert.SerializeObject(parameters)).ConfigureAwait(false); + } + + internal bool IsChargingScheduleChangeNeeded(DateTimeOffset? chargingStartTime, DateTimeOffset currentDate, Car car, out Dictionary parameters) + { + _logger.LogTrace("{method}({startTime}, {currentDate}, {carId}, {parameters})", nameof(IsChargingScheduleChangeNeeded), chargingStartTime, currentDate, car.Id, nameof(parameters)); + parameters = new Dictionary(); + if (chargingStartTime != null) + { + _logger.LogTrace("{chargingStartTime} is not null", nameof(chargingStartTime)); + chargingStartTime = RoundToNextQuarterHour(chargingStartTime.Value); + } + if (car.CarState.ScheduledChargingStartTime == chargingStartTime) + { + _logger.LogDebug("Correct charging start time already set."); + return false; + } + + if (chargingStartTime == null) + { + _logger.LogDebug("Set chargingStartTime to null."); + parameters = new Dictionary() + { + { "enable", "false" }, + { "time", 0.ToString() }, + }; + return true; + } + + var localStartTime = chargingStartTime.Value.ToLocalTime().TimeOfDay; + var minutesFromMidNight = (int)localStartTime.TotalMinutes; + var timeUntilChargeStart = chargingStartTime.Value - currentDate; + var scheduledChargeShouldBeSet = true; + + if (car.CarState.ScheduledChargingStartTime == chargingStartTime) + { + _logger.LogDebug("Correct charging start time already set."); + return true; + } + + //ToDo: maybe disable scheduled charge in this case. + if (timeUntilChargeStart <= TimeSpan.Zero || timeUntilChargeStart.TotalHours > 24) + { + _logger.LogDebug("Charge schedule should not be changed, as time until charge start is higher than 24 hours or lower than zero."); + return false; + } + + if (car.CarState.ScheduledChargingStartTime == null && !scheduledChargeShouldBeSet) + { + _logger.LogDebug("No charge schedule set and no charge schedule should be set."); + return true; + } + _logger.LogDebug("Normal parameter set."); + parameters = new Dictionary() + { + { "enable", scheduledChargeShouldBeSet ? "true" : "false" }, + { "time", minutesFromMidNight.ToString() }, + }; + _logger.LogTrace("{@parameters}", parameters); + return true; + } + + internal DateTimeOffset RoundToNextQuarterHour(DateTimeOffset chargingStartTime) + { + var maximumTeslaChargeStartAccuracyMinutes = 15; + var minutes = chargingStartTime.Minute; // Aktuelle Minute des DateTimeOffset-Objekts + + // Runden auf die nächste viertel Stunde + var roundedMinutes = (int)Math.Ceiling((double)minutes / maximumTeslaChargeStartAccuracyMinutes) * + maximumTeslaChargeStartAccuracyMinutes; + var additionalHours = 0; + if (roundedMinutes == 60) + { + roundedMinutes = 0; + additionalHours = 1; + } + + var newNotRoundedDateTime = chargingStartTime.AddHours(additionalHours); + chargingStartTime = new DateTimeOffset(newNotRoundedDateTime.Year, newNotRoundedDateTime.Month, + newNotRoundedDateTime.Day, newNotRoundedDateTime.Hour, roundedMinutes, 0, newNotRoundedDateTime.Offset); + _logger.LogDebug("Rounded charging Start time: {chargingStartTime}", chargingStartTime); + return chargingStartTime; + } + + private async Task WakeUpCarIfNeeded(int carId, CarStateEnum? carState) + { + switch (carState) + { + case CarStateEnum.Offline or CarStateEnum.Asleep: + _logger.LogInformation("Wakeup car."); + await WakeUpCar(carId).ConfigureAwait(false); + break; + case CarStateEnum.Suspended: + _logger.LogInformation("Resume logging as is suspended"); + await _teslamateApiService.ResumeLogging(carId).ConfigureAwait(false); + break; + } + } + + private async Task SendCommandToTeslaApi(long id, string commandName, string contentData = "{}") + { + _logger.LogTrace("{method}({id}, {commandName}, {contentData})", nameof(SendCommandToTeslaApi), id, commandName, contentData); + var accessToken = await GetAccessTokenAndRefreshWhenNeededAsync().ConfigureAwait(false); + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.AccessToken); + var content = new StringContent(contentData, System.Text.Encoding.UTF8, "application/json"); + var regionCode = accessToken.Region switch + { + TeslaFleetApiRegion.Emea => "eu", + TeslaFleetApiRegion.NorthAmerica => "na", + _ => throw new NotImplementedException($"Region {accessToken.Region} is not implemented."), + }; + var requestUri = $"https://fleet-api.prd.{regionCode}.vn.cloud.tesla.com/api/1/vehicles/{id}/{commandName}"; + var response = await httpClient.PostAsync(requestUri, content).ConfigureAwait(false); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + await _backendApiService.PostErrorInformation(nameof(TeslaFleetApiService), nameof(SendCommandToTeslaApi), + $"Sending command to Tesla API resulted in non succes status code: {response.StatusCode} : Command name:{commandName}, Content data:{contentData}. Response string: {responseString}").ConfigureAwait(false); + await HandleNonSuccessTeslaApiStatusCodes(response.StatusCode, accessToken, responseString).ConfigureAwait(false); + } + _logger.LogDebug("Response: {responseString}", responseString); + //Wake up command returns another result which is irrelevant + if (commandName == _wakeUpComand) + { + return null; + } + var teslaCommandResultResponse = JsonConvert.DeserializeObject>(responseString); + var result = teslaCommandResultResponse?.Response; + if (result?.Result == false) + { + await _backendApiService.PostErrorInformation(nameof(TeslaFleetApiService), nameof(SendCommandToTeslaApi), + $"Result of command request is false: {commandName}, {contentData}. Response string: {responseString}").ConfigureAwait(false); + } + return result ?? null; + } + + public async Task RefreshTokenAsync() + { + _logger.LogTrace("{method}()", nameof(RefreshTokenAsync)); + var tokenState = (await GetFleetApiTokenState().ConfigureAwait(false)).Value; + switch (tokenState) + { + case FleetApiTokenState.NotNeeded: + _logger.LogDebug("Refreshing token not needed."); + return; + case FleetApiTokenState.NotRequested: + _logger.LogDebug("No token has been requested, yet."); + return; + case FleetApiTokenState.TokenRequestExpired: + _logger.LogError("Your token request has expired, create a new one."); + return; + case FleetApiTokenState.TokenUnauthorized: + _logger.LogError("Your refresh token is unauthorized, create a new token."); + return; + case FleetApiTokenState.NotReceived: + break; + case FleetApiTokenState.Expired: + break; + case FleetApiTokenState.UpToDate: + _logger.LogDebug("Token is up to date."); + break; + default: + throw new ArgumentOutOfRangeException(); + } + var token = await _teslaSolarChargerContext.TeslaTokens.FirstOrDefaultAsync().ConfigureAwait(false); + if (token == null) + { + using var httpClient = new HttpClient(); + var installationId = await _tscConfigurationService.GetInstallationId().ConfigureAwait(false); + var url = _configurationWrapper.BackendApiBaseUrl() + $"Tsc/DeliverAuthToken?installationId={installationId}"; + var response = await httpClient.GetAsync(url).ConfigureAwait(false); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + await _backendApiService.PostErrorInformation(nameof(TeslaFleetApiService), nameof(SendCommandToTeslaApi), + $"Getting token from TscBackend. Response status code: {response.StatusCode} Response string: {responseString}").ConfigureAwait(false); + } + response.EnsureSuccessStatusCode(); + var newToken = JsonConvert.DeserializeObject(responseString) ?? throw new InvalidDataException("Could not get token from string."); + await AddNewTokenAsync(newToken).ConfigureAwait(false); + } + var dbToken = await GetAccessTokenAndRefreshWhenNeededAsync().ConfigureAwait(false); + } + + public async Task AddNewTokenAsync(DtoTeslaTscDeliveryToken token) + { + var currentTokens = await _teslaSolarChargerContext.TeslaTokens.ToListAsync().ConfigureAwait(false); + _teslaSolarChargerContext.TeslaTokens.RemoveRange(currentTokens); + await _teslaSolarChargerContext.SaveChangesAsync().ConfigureAwait(false); + _teslaSolarChargerContext.TeslaTokens.Add(new TeslaToken + { + AccessToken = token.AccessToken, + RefreshToken = token.RefreshToken, + IdToken = token.IdToken, + ExpiresAtUtc = _dateTimeProvider.UtcNow().AddSeconds(token.ExpiresIn), + Region = token.Region, + }); + await _teslaSolarChargerContext.SaveChangesAsync().ConfigureAwait(false); + } + + public async Task> GetFleetApiTokenState() + { + if (!_configurationWrapper.UseFleetApi()) + { + return new DtoValue(FleetApiTokenState.NotNeeded); + } + var isCurrentRefreshTokenUnauthorized = await _teslaSolarChargerContext.TscConfigurations + .Where(c => c.Key == _constants.TokenRefreshUnauthorized) + .AnyAsync().ConfigureAwait(false); + if (isCurrentRefreshTokenUnauthorized) + { + return new DtoValue(FleetApiTokenState.TokenUnauthorized); + } + var hasCurrentTokenMissingScopes = await _teslaSolarChargerContext.TscConfigurations + .Where(c => c.Key == _constants.TokenMissingScopes) + .AnyAsync().ConfigureAwait(false); + if (hasCurrentTokenMissingScopes) + { + return new DtoValue(FleetApiTokenState.MissingScopes); + } + var token = await _teslaSolarChargerContext.TeslaTokens.FirstOrDefaultAsync().ConfigureAwait(false); + if (token != null) + { + return new DtoValue(token.ExpiresAtUtc < _dateTimeProvider.UtcNow() ? FleetApiTokenState.Expired : FleetApiTokenState.UpToDate); + } + var tokenRequestedDateString = await _teslaSolarChargerContext.TscConfigurations + .Where(c => c.Key == _constants.FleetApiTokenRequested) + .Select(c => c.Value) + .FirstOrDefaultAsync().ConfigureAwait(false); + if (tokenRequestedDateString == null) + { + return new DtoValue(FleetApiTokenState.NotRequested); + } + var tokenRequestedDate = DateTime.Parse(tokenRequestedDateString, null, DateTimeStyles.RoundtripKind); + var currentDate = _dateTimeProvider.UtcNow(); + if (tokenRequestedDate < currentDate.AddMinutes(-5)) + { + return new DtoValue(FleetApiTokenState.TokenRequestExpired); + } + return new DtoValue(FleetApiTokenState.NotReceived); + } + + private async Task GetAccessTokenAndRefreshWhenNeededAsync() + { + _logger.LogTrace("{method}()", nameof(GetAccessTokenAndRefreshWhenNeededAsync)); + var token = await _teslaSolarChargerContext.TeslaTokens + .OrderByDescending(t => t.ExpiresAtUtc) + .FirstAsync().ConfigureAwait(false); + var minimumTokenLifeTime = TimeSpan.FromMinutes(5); + var isCurrentRefreshTokenUnauthorized = await _teslaSolarChargerContext.TscConfigurations + .Where(c => c.Key == _constants.TokenRefreshUnauthorized) + .AnyAsync().ConfigureAwait(false); + if (isCurrentRefreshTokenUnauthorized) + { + _logger.LogError("Token is unauthorized"); + throw new InvalidDataException("Current Tesla Fleet Api Token is unauthorized"); + } + if (token.ExpiresAtUtc < (_dateTimeProvider.UtcNow() + minimumTokenLifeTime)) + { + _logger.LogInformation("Token is expired. Getting new token."); + using var httpClient = new HttpClient(); + var tokenUrl = "https://auth.tesla.com/oauth2/v3/token"; + var requestData = new Dictionary + { + { "grant_type", "refresh_token" }, + { "client_id", _configurationWrapper.FleetApiClientId() }, + { "refresh_token", token.RefreshToken }, + }; + var encodedContent = new FormUrlEncodedContent(requestData); + encodedContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); + var response = await httpClient.PostAsync(tokenUrl, encodedContent).ConfigureAwait(false); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + await _backendApiService.PostErrorInformation(nameof(TeslaFleetApiService), nameof(SendCommandToTeslaApi), + $"Refreshing token did result in non success status code. Response status code: {response.StatusCode} Response string: {responseString}").ConfigureAwait(false); + await HandleNonSuccessTeslaApiStatusCodes(response.StatusCode, token, responseString).ConfigureAwait(false); + } + response.EnsureSuccessStatusCode(); + var newToken = JsonConvert.DeserializeObject(responseString) ?? throw new InvalidDataException("Could not get token from string."); + token.AccessToken = newToken.AccessToken; + token.RefreshToken = newToken.RefreshToken; + token.IdToken = newToken.IdToken; + token.ExpiresAtUtc = _dateTimeProvider.UtcNow().AddSeconds(newToken.ExpiresIn); + await _teslaSolarChargerContext.SaveChangesAsync().ConfigureAwait(false); + _logger.LogInformation("New Token saved to database."); + } + return token; + } + + private async Task HandleNonSuccessTeslaApiStatusCodes(HttpStatusCode statusCode, TeslaToken token, + string responseString) + { + _logger.LogTrace("{method}({statusCode}, {token}, {responseString})", nameof(HandleNonSuccessTeslaApiStatusCodes), statusCode, token, responseString); + switch (statusCode) + { + case HttpStatusCode.Unauthorized: + _logger.LogError( + "Your token or refresh token is invalid. Very likely you have changed your Tesla password."); + _teslaSolarChargerContext.TeslaTokens.Remove(token); + _teslaSolarChargerContext.TscConfigurations.Add(new TscConfiguration() + { + Key = _constants.TokenRefreshUnauthorized, + Value = responseString, + }); + break; + case HttpStatusCode.Forbidden: + _logger.LogError("You did not select all scopes, so TSC can't send commands to your car."); + _teslaSolarChargerContext.TeslaTokens.Remove(token); + _teslaSolarChargerContext.TscConfigurations.Add(new TscConfiguration() + { + Key = _constants.TokenMissingScopes, + Value = responseString, + }); + break; + default: + _logger.LogWarning( + "Staus Code {statusCode} is currently not handled, look into https://developer.tesla.com/docs/fleet-api#response-codes to check status code information", + statusCode); + return; + } + + await _teslaSolarChargerContext.SaveChangesAsync().ConfigureAwait(false); + } +} diff --git a/TeslaSolarCharger/Server/Services/TeslamateApiService.cs b/TeslaSolarCharger/Server/Services/TeslamateApiService.cs index 9dca541b3..19b9d52f2 100644 --- a/TeslaSolarCharger/Server/Services/TeslamateApiService.cs +++ b/TeslaSolarCharger/Server/Services/TeslamateApiService.cs @@ -1,6 +1,7 @@ using System.Text; using Newtonsoft.Json; using TeslaSolarCharger.Server.Contracts; +using TeslaSolarCharger.Server.Services.ApiServices.Contracts; using TeslaSolarCharger.Shared.Contracts; using TeslaSolarCharger.Shared.Dtos.Contracts; using TeslaSolarCharger.Shared.Dtos.Settings; @@ -8,7 +9,7 @@ namespace TeslaSolarCharger.Server.Services; -public class TeslamateApiService : ITeslaService +public class TeslamateApiService : ITeslaService, ITeslamateApiService { private readonly ILogger _logger; private readonly ITelegramService _telegramService; @@ -149,6 +150,11 @@ public async Task SetScheduledCharging(int carId, DateTimeOffset? chargingStartT _logger.LogTrace("result: {resultContent}", result.Content.ReadAsStringAsync().Result); } + public Task SetChargeLimit(int carId, int limitSoC) + { + throw new NotImplementedException(); + } + internal bool IsChargingScheduleChangeNeeded(DateTimeOffset? chargingStartTime, DateTimeOffset currentDate, Car car, out Dictionary parameters) { _logger.LogTrace("{method}({startTime}, {currentDate}, {carId}, {parameters})", nameof(IsChargingScheduleChangeNeeded), chargingStartTime, currentDate, car.Id, nameof(parameters)); @@ -230,7 +236,7 @@ internal DateTimeOffset RoundToNextQuarterHour(DateTimeOffset chargingStartTime) return chargingStartTime; } - private async Task ResumeLogging(int carId) + public async Task ResumeLogging(int carId) { _logger.LogTrace("{method}({param1})", nameof(ResumeLogging), carId); var url = $"{_teslaMateBaseUrl}/api/v1/cars/{carId}/logging/resume"; diff --git a/TeslaSolarCharger/Server/Services/TscConfigurationService.cs b/TeslaSolarCharger/Server/Services/TscConfigurationService.cs new file mode 100644 index 000000000..ea311e138 --- /dev/null +++ b/TeslaSolarCharger/Server/Services/TscConfigurationService.cs @@ -0,0 +1,46 @@ +using TeslaSolarCharger.Model.Contracts; +using TeslaSolarCharger.Model.Entities.TeslaSolarCharger; +using TeslaSolarCharger.Server.Contracts; +using TeslaSolarCharger.Server.Dtos.TscBackend; +using TeslaSolarCharger.Server.Services.Contracts; +using TeslaSolarCharger.Shared.Contracts; +using TeslaSolarCharger.SharedBackend.Contracts; + +namespace TeslaSolarCharger.Server.Services; + +public class TscConfigurationService : ITscConfigurationService +{ + private readonly ILogger _logger; + private readonly ITeslaSolarChargerContext _teslaSolarChargerContext; + private readonly IConstants _constants; + + public TscConfigurationService(ILogger logger, ITeslaSolarChargerContext teslaSolarChargerContext, IConstants constants) + { + _logger = logger; + _teslaSolarChargerContext = teslaSolarChargerContext; + _constants = constants; + } + + public async Task GetInstallationId() + { + _logger.LogTrace("{method}()", nameof(GetInstallationId)); + var configurationIdString = _teslaSolarChargerContext.TscConfigurations + .Where(c => c.Key == _constants.InstallationIdKey) + .Select(c => c.Value) + .FirstOrDefault(); + + if (configurationIdString != default) + { + return Guid.Parse(configurationIdString); + } + + var installationIdConfiguration = new TscConfiguration() + { + Key = _constants.InstallationIdKey, + Value = Guid.NewGuid().ToString(), + }; + _teslaSolarChargerContext.TscConfigurations.Add(installationIdConfiguration); + await _teslaSolarChargerContext.SaveChangesAsync().ConfigureAwait(false); + return Guid.Parse(installationIdConfiguration.Value); + } +} diff --git a/TeslaSolarCharger/Server/appsettings.Development.json b/TeslaSolarCharger/Server/appsettings.Development.json index f208db1bb..9eb11a115 100644 --- a/TeslaSolarCharger/Server/appsettings.Development.json +++ b/TeslaSolarCharger/Server/appsettings.Development.json @@ -56,6 +56,7 @@ "TeslaMateDbPassword": "secret", "AllowCORS": true, "DisplayApiRequestCounter": true, + "UseFleetApi": true, "GridPriceProvider": { "EnergyProvider": "Tibber", "Octopus": { diff --git a/TeslaSolarCharger/Server/appsettings.json b/TeslaSolarCharger/Server/appsettings.json index e94baeeba..386519662 100644 --- a/TeslaSolarCharger/Server/appsettings.json +++ b/TeslaSolarCharger/Server/appsettings.json @@ -59,6 +59,9 @@ "GeoFence": "Home", "DisplayApiRequestCounter": false, "IgnoreSslErrors": false, + "UseFleetApi": false, + "FleetApiClientId": "f29f71d6285a-4873-8b6b-80f15854892e", + "BackendApiBaseUrl": "https://www.teslasolarcharger.de/api/", "GridPriceProvider": { "EnergyProvider": "FixedPrice", "Octopus": { diff --git a/TeslaSolarCharger/Shared/Contracts/IConfigurationWrapper.cs b/TeslaSolarCharger/Shared/Contracts/IConfigurationWrapper.cs index 67e3897d5..e23caf90f 100644 --- a/TeslaSolarCharger/Shared/Contracts/IConfigurationWrapper.cs +++ b/TeslaSolarCharger/Shared/Contracts/IConfigurationWrapper.cs @@ -86,4 +86,8 @@ public interface IConfigurationWrapper string BackupCopyDestinationDirectory(); string GetSqliteFileNameWithoutPath(); string BackupZipDirectory(); + string FleetApiClientId(); + bool UseFleetApi(); + string BackendApiBaseUrl(); + bool IsDevelopmentEnvironment(); } diff --git a/TeslaSolarCharger/Shared/Enums/FleetApiTokenState.cs b/TeslaSolarCharger/Shared/Enums/FleetApiTokenState.cs new file mode 100644 index 000000000..215c423c6 --- /dev/null +++ b/TeslaSolarCharger/Shared/Enums/FleetApiTokenState.cs @@ -0,0 +1,13 @@ +namespace TeslaSolarCharger.Shared.Enums; + +public enum FleetApiTokenState +{ + NotNeeded, + NotRequested, + TokenRequestExpired, + TokenUnauthorized, + MissingScopes, + NotReceived, + Expired, + UpToDate, +} diff --git a/TeslaSolarCharger/Shared/Wrappers/ConfigurationWrapper.cs b/TeslaSolarCharger/Shared/Wrappers/ConfigurationWrapper.cs index 491e5bd77..3582e8a88 100644 --- a/TeslaSolarCharger/Shared/Wrappers/ConfigurationWrapper.cs +++ b/TeslaSolarCharger/Shared/Wrappers/ConfigurationWrapper.cs @@ -99,6 +99,33 @@ public bool AllowCors() return value; } + public bool UseFleetApi() + { + var environmentVariableName = "UseFleetApi"; + var value = _configuration.GetValue(environmentVariableName); + return value; + } + + public string BackendApiBaseUrl() + { + var environmentVariableName = "BackendApiBaseUrl"; + var value = _configuration.GetValue(environmentVariableName); + return value; + } + + public bool IsDevelopmentEnvironment() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + return environment == "Development"; + } + + public string FleetApiClientId() + { + var environmentVariableName = "FleetApiClientId"; + var value = GetNotNullableConfigurationValue(environmentVariableName); + return value; + } + public TimeSpan ChargingValueJobUpdateIntervall() { var minimum = TimeSpan.FromSeconds(20);