diff --git a/.gitignore b/.gitignore index dfcfd56..85d8065 100644 --- a/.gitignore +++ b/.gitignore @@ -348,3 +348,9 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +# Project specific +*.sqlite* +src/Elektrifikatsiya/Elektrifikatsiya/appsettings.Development.json +/src/Elektrifikatsiya/Elektrifikatsiya/appsettings.Development.json~origin_Development +/src/Elektrifikatsiya/Elektrifikatsiya/appsettings.Development.json diff --git a/src/Elektrifikatsiya/Elektrifikatsiya.sln b/src/Elektrifikatsiya/Elektrifikatsiya.sln index 7b2638d..3257144 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya.sln +++ b/src/Elektrifikatsiya/Elektrifikatsiya.sln @@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.4.33205.214 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elektrifikatsiya", "Elektrifikatsiya\Elektrifikatsiya.csproj", "{B35B4694-ACC4-4CD6-9518-B338F30111A0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elektrifikatsiya", "Elektrifikatsiya\Elektrifikatsiya.csproj", "{B35B4694-ACC4-4CD6-9518-B338F30111A0}" +EndProject +Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{7350C55E-0EAD-47DE-A1E0-2A2EA3291D0B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +17,10 @@ Global {B35B4694-ACC4-4CD6-9518-B338F30111A0}.Debug|Any CPU.Build.0 = Debug|Any CPU {B35B4694-ACC4-4CD6-9518-B338F30111A0}.Release|Any CPU.ActiveCfg = Release|Any CPU {B35B4694-ACC4-4CD6-9518-B338F30111A0}.Release|Any CPU.Build.0 = Release|Any CPU + {7350C55E-0EAD-47DE-A1E0-2A2EA3291D0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7350C55E-0EAD-47DE-A1E0-2A2EA3291D0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7350C55E-0EAD-47DE-A1E0-2A2EA3291D0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7350C55E-0EAD-47DE-A1E0-2A2EA3291D0B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/App.razor b/src/Elektrifikatsiya/Elektrifikatsiya/App.razor index 9c0ed48..a21c2fc 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/App.razor +++ b/src/Elektrifikatsiya/Elektrifikatsiya/App.razor @@ -1,4 +1,5 @@ - +@using Blazorise.Components + @@ -16,11 +17,20 @@ { BarOptions = new() { - HorizontalHeight = "72px" + HorizontalHeight = "72px", + DarkColors = new() + { + ItemColorOptions = new() + { + ActiveBackgroundColor = "#1a237e", + ActiveColor = "#1a237e", + HoverColor = "#1a237e", + } + } }, ColorOptions = new() { - Primary = "#0288D1", + Primary = "#1a237e", Secondary = "#A65529", Success = "#23C02E", Info = "#9BD8FE", @@ -31,7 +41,7 @@ }, BackgroundOptions = new() { - Primary = "#0288D1", + Primary = "#1a237e", Secondary = "#A65529", Success = "#23C02E", Info = "#9BD8FE", @@ -42,7 +52,12 @@ }, InputOptions = new() { - CheckColor = "#0288D1", + CheckColor = "#1a237e", + }, + TextColorOptions = new() + { + Dark = "#000000", + Primary = "#1a237e" } }; -} \ No newline at end of file +} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Components/Layout/SideMenu.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Components/Layout/SideMenu.razor index 7450e71..e81b308 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Components/Layout/SideMenu.razor +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Components/Layout/SideMenu.razor @@ -1,6 +1,7 @@ - - + @@ -12,42 +13,45 @@ - - + + Dashboard + + + + + + Admin + + + + - - - - Pages - - - Simple Form - - + + + Settings + + + + + + Add Plug + - - - - Apps - - - - Todo - - - + + + Logout + @code { private bool pagesBarVisible = true; - - RenderFragment customIcon =@; + RenderFragment customIcon =@; } \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Components/Layout/TopMenu.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Components/Layout/TopMenu.razor deleted file mode 100644 index 2fd68fd..0000000 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Components/Layout/TopMenu.razor +++ /dev/null @@ -1,163 +0,0 @@ -@using Blazorise.Localization - - - - - - - Elektrifikatsiya - - - - - - - - Home - - - Documentation - - - - More - - - Quick-Start Guide - - - - Usage - - - - - - - Layout - - - @if ( LayoutType == "fixed-header" ) - { - - } - Fixed Header - - - @if ( LayoutType == "fixed-header-footer-only" ) - { - - } - Fixed Header and Footer only - - - @if ( LayoutType == "sider-with-header-on-top" ) - { - - } - Sider with Header on top - - - - - - - - - - - - - @foreach ( var cultureInfo in LocalizationService.AvailableCultures ) - { - - @if ( cultureInfo.IsNeutralCulture ) - { - @cultureInfo.EnglishName - } - else - { - @cultureInfo.Parent.EnglishName - } - - } - - - - - - Theme - - - - - Theme enabled - - - - - - - Gradient colors - - - Rounded elements - - - - - - - - - - - - - - - GitHub - - - - -@code { - protected override async Task OnInitializedAsync() - { - await SelectCulture( "en-US" ); - - await base.OnInitializedAsync(); - } - - Task SelectCulture( string name ) - { - LocalizationService.ChangeLanguage( name ); - - return Task.CompletedTask; - } - - private bool topbarVisible = false; - - Task OnLayoutTypeChecked( string layoutType ) - { - LayoutType = layoutType; - - return LayoutTypeChanged.InvokeAsync( layoutType ); - } - - [Parameter] public EventCallback ThemeEnabledChanged { get; set; } - - [Parameter] public EventCallback ThemeGradientChanged { get; set; } - - [Parameter] public EventCallback ThemeRoundedChanged { get; set; } - - [Parameter] public EventCallback ThemeColorChanged { get; set; } - - [Parameter] public string LayoutType { get; set; } - - [Parameter] public EventCallback LayoutTypeChanged { get; set; } - - [Inject] protected ITextLocalizerService LocalizationService { get; set; } - - [CascadingParameter] protected Theme Theme { get; set; } -} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Components/Protected.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Components/Protected.razor new file mode 100644 index 0000000..38b04b4 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Components/Protected.razor @@ -0,0 +1,42 @@ +@using Elektrifikatsiya.Models +@inject Services.IAuthorizationService AuthorizationService + +@if (IsAuthorized) +{ + @Authorized +} +else if (!loaded && UseLoadingScreen) +{ + @Loading +} +else if (loaded) +{ + @NotAuthorized +} + +@code { + [Parameter] + public RenderFragment? Authorized { get; set; } + + [Parameter] + public RenderFragment? NotAuthorized { get; set; } + + [Parameter] + public RenderFragment? Loading { get; set; } + + [Parameter] + public Role RequiredRole { get; set; } + + [Parameter] + public bool UseLoadingScreen { get; set; } + + public bool IsAuthorized { get; set; } + private bool loaded; + + protected override async Task OnParametersSetAsync() + { + IsAuthorized = (await AuthorizationService.IsAuthorized(RequiredRole)).ValueOrDefault; + StateHasChanged(); + loaded = true; + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/BaseTodoItems.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/BaseTodoItems.cs deleted file mode 100644 index 9a7799c..0000000 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/BaseTodoItems.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Blazorise; - -using Elektrifikatsiya.Components.TodoApp; - -using Microsoft.AspNetCore.Components; - -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Elektrifikatsiya.Components.TodoApp; -public abstract class BaseTodoItems : ComponentBase -{ - protected Validations validations; - - protected string description; - - protected Filter filter = Filter.All; - - protected List todos = new() - { - new() { Description = "Buy milk" }, - new() { Description = "Call John regarding the meeting" }, - new() { Description = "Walk a dog" }, - }; - - protected IEnumerable Todos - { - get - { - var query = from t in todos select t; - - if (filter == Filter.Active) - query = from q in query where !q.Completed select q; - - if (filter == Filter.Completed) - query = from q in query where q.Completed select q; - - return query; - } - } - - protected void SetFilter(Filter filter) - { - this.filter = filter; - } - - protected void OnCheckAll(bool isChecked) - { - todos.ForEach(x => x.Completed = isChecked); - } - - protected async Task OnAddTodo() - { - if (await validations.ValidateAll()) - { - todos.Add(new() { Description = description }); - description = null; - - await validations.ClearAll(); - } - } - - protected void OnClearCompleted() - { - todos.RemoveAll(x => x.Completed); - filter = Filter.All; - } - - protected Task OnTodoStatusChanged(bool isChecked) - { - return InvokeAsync(StateHasChanged); - } -} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/Filter.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/Filter.cs deleted file mode 100644 index 7cd3ac5..0000000 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/Filter.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Elektrifikatsiya.Components.TodoApp; - -public enum Filter -{ - All, - Active, - Completed, -} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/Todo.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/Todo.cs deleted file mode 100644 index fdf4888..0000000 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/Todo.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Elektrifikatsiya.Components.TodoApp; - -public class Todo -{ - public bool Completed { get; set; } - - public string Description { get; set; } -} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/TodoItem.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/TodoItem.razor deleted file mode 100644 index 8233b49..0000000 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/TodoItem.razor +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - @Todo.Description - - - -@code{ - Task OnCheckedChanged( bool isChecked ) - { - Todo.Completed = isChecked; - - return StatusChanged?.Invoke( isChecked ); - } - - [Parameter] public Todo Todo { get; set; } - - [Parameter] public Func StatusChanged { get; set; } -} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/TodoItems.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/TodoItems.razor deleted file mode 100644 index a9db980..0000000 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Components/TodoApp/TodoItems.razor +++ /dev/null @@ -1,57 +0,0 @@ -@inherits BaseTodoItems - - - - - - Todo List - - - - - All - - - - - - - - - - - - - - - - - - - - @foreach ( var todo in Todos ) - { - - } - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Database/MainDatabaseContext.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Database/MainDatabaseContext.cs new file mode 100644 index 0000000..63fcd63 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Database/MainDatabaseContext.cs @@ -0,0 +1,18 @@ +using Elektrifikatsiya.Models; + +using Microsoft.EntityFrameworkCore; + +namespace Elektrifikatsiya.Database; + +public class MainDatabaseContext : DbContext +{ + public DbSet Devices { get; set; } + + public DbSet Users { get; set; } + + public DbSet EnergyPriceChanges { get; set; } + + public MainDatabaseContext(DbContextOptions dbContextOptions) : base(dbContextOptions) + { + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Elektrifikatsiya.csproj b/src/Elektrifikatsiya/Elektrifikatsiya/Elektrifikatsiya.csproj index 6b1a00c..87cca3b 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Elektrifikatsiya.csproj +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Elektrifikatsiya.csproj @@ -14,13 +14,46 @@ Copyright 2018-2022 Megabit bd144edc-e5c5-4b97-84d6-38c6b1edcbf9 Linux + ..\docker-compose.dcproj - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Layouts/LoginLayout.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Layouts/LoginLayout.razor new file mode 100644 index 0000000..0cfd389 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Layouts/LoginLayout.razor @@ -0,0 +1,42 @@ +@using System.Diagnostics; +@using FluentResults; + +@inject Elektrifikatsiya.Services.IAuthenticationService AuthenticationService +@inject NavigationManager NavigationManager + +@inherits LayoutComponentBase + + + + + + + + + + + + +@code { + private string username = string.Empty; + private string password = string.Empty; + private Color color = Color.Primary; + private bool disabled; + private async Task Clicked() + { + color = Color.Primary; + disabled = true; + + Result loginResult = await AuthenticationService.LoginUserAsync(username, password); + + if (loginResult.IsSuccess) + { + NavigationManager.NavigateTo("/", true); + } + else + { + color = Color.Danger; + disabled = false; + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Layouts/MainLayout.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Layouts/MainLayout.razor index 7d66732..1cc0b66 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Layouts/MainLayout.razor +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Layouts/MainLayout.razor @@ -1,68 +1,26 @@ -@using Elektrifikatsiya.Components.Layout +@using Elektrifikatsiya.Components +@using Elektrifikatsiya.Components.Layout +@using Elektrifikatsiya.Models @inherits LayoutComponentBase - - -@if ( layoutType == "fixed-header" ) -{ - - - - - - - - - - - - @Body - - - -} -else if ( layoutType == "fixed-header-footer-only" ) -{ - - - - - - @Body - - - - - -} -else if ( layoutType == "sider-with-header-on-top" ) -{ - - - - - - - - - - - - - @Body - - - - -} \ No newline at end of file +@inject NavigationManager NavigationManager + + + + + + + + + + + @Body + + + + + + @{ + NavigationManager.NavigateTo("/login"); + } + + diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/20230322092207_fix foreign keys.Designer.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/20230322092207_fix foreign keys.Designer.cs new file mode 100644 index 0000000..1b26893 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/20230322092207_fix foreign keys.Designer.cs @@ -0,0 +1,96 @@ +// +using System; +using Elektrifikatsiya.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elektrifikatsiya.Migrations +{ + [DbContext(typeof(MainDatabaseContext))] + [Migration("20230322092207_fix foreign keys")] + partial class fixforeignkeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.4"); + + modelBuilder.Entity("Elektrifikatsiya.Models.Device", b => + { + b.Property("MacAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Room") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("MacAddress"); + + b.HasIndex("UserId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Elektrifikatsiya.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SessionToken") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Elektrifikatsiya.Models.Device", b => + { + b.HasOne("Elektrifikatsiya.Models.User", "User") + .WithMany("Devices") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Elektrifikatsiya.Models.User", b => + { + b.Navigation("Devices"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/20230322092207_fix foreign keys.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/20230322092207_fix foreign keys.cs new file mode 100644 index 0000000..4617cb2 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/20230322092207_fix foreign keys.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elektrifikatsiya.Migrations +{ + /// + public partial class fixforeignkeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + SessionToken = table.Column(type: "TEXT", nullable: true), + LastLoginDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Devices", + columns: table => new + { + MacAddress = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + IpAddress = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "INTEGER", nullable: false), + Room = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Devices", x => x.MacAddress); + table.ForeignKey( + name: "FK_Devices_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Devices_UserId", + table: "Devices", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Devices"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/MainDatabaseContextModelSnapshot.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/MainDatabaseContextModelSnapshot.cs new file mode 100644 index 0000000..01b1092 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Migrations/MainDatabaseContextModelSnapshot.cs @@ -0,0 +1,93 @@ +// +using System; +using Elektrifikatsiya.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elektrifikatsiya.Migrations +{ + [DbContext(typeof(MainDatabaseContext))] + partial class MainDatabaseContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.4"); + + modelBuilder.Entity("Elektrifikatsiya.Models.Device", b => + { + b.Property("MacAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Room") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("MacAddress"); + + b.HasIndex("UserId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Elektrifikatsiya.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SessionToken") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Elektrifikatsiya.Models.Device", b => + { + b.HasOne("Elektrifikatsiya.Models.User", "User") + .WithMany("Devices") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Elektrifikatsiya.Models.User", b => + { + b.Navigation("Devices"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/Device.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/Device.cs new file mode 100644 index 0000000..000e5ef --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/Device.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Net; + +namespace Elektrifikatsiya.Models; + +public class Device +{ + [Key] + public string MacAddress { get; private set; } + + [Required] + public string Name { get; set; } + + [Required] + public IPAddress IpAddress { get; set; } + + [Required] + public User User { get; set; } + + [NotMapped] + public double PowerUsage { get; set; } + + public string Room { get; set; } + + [NotMapped] + public bool Enabled { get; set; } + + public Device(string macAddress, string name, IPAddress address, User user, string room) + { + MacAddress = macAddress; + Name = name; + IpAddress = address; + User = user; + Room = room; + } + + public Device CopyDevice() + { + return new Device(MacAddress, Name, IpAddress, User, Room); + } + + public void OverwiteDevice(Device overwriter) + { + Room = overwriter.Room; + Enabled = overwriter.Enabled; + PowerUsage = overwriter.PowerUsage; + Name = overwriter.Name; + User = overwriter.User; + MacAddress = overwriter.MacAddress; + IpAddress = overwriter.IpAddress; + } + + private Device() + { } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/DeviceStatusChangedEventArgs.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/DeviceStatusChangedEventArgs.cs new file mode 100644 index 0000000..caad485 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/DeviceStatusChangedEventArgs.cs @@ -0,0 +1,11 @@ +namespace Elektrifikatsiya.Models; + +public class DeviceStatusChangedEventArgs : EventArgs +{ + public string MacAddress { get; set; } + + public DeviceStatusChangedEventArgs(string macAddress) + { + MacAddress = macAddress; + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/EnergyPriceChange.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/EnergyPriceChange.cs new file mode 100644 index 0000000..82214fb --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/EnergyPriceChange.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Elektrifikatsiya.Models; + +public class EnergyPriceChange +{ + [Key] + public DateTime DateTime { get; set; } + public double EnergyPrice { get; set; } + + + public EnergyPriceChange(DateTime dateTime, double energyPrice) + { + DateTime = dateTime; + EnergyPrice = energyPrice; + } + + public EnergyPriceChange(double energyPrice) + { + EnergyPrice = energyPrice; + DateTime = DateTime.Now; + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/Event.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/Event.cs new file mode 100644 index 0000000..01a91d3 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/Event.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; + +namespace Elektrifikatsiya.Models +{ + public class Event + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Key] + public int Id { get; private set; } + public string EventName { get; set; } + public string Description { get; set; } + public DateTime Date { get; set; } + public Device Plug { get; set; } + + public Event() { } + public Event(string eventName, string description, DateTime date, Device plug) + { + EventName = eventName; + Description = description; + Date = date; + Plug = plug; + } + } +} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/EventServiceEventArgs.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/EventServiceEventArgs.cs new file mode 100644 index 0000000..4065bb5 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/EventServiceEventArgs.cs @@ -0,0 +1,11 @@ +namespace Elektrifikatsiya.Models +{ + public class EventServiceEventArgs : EventArgs + { + public Event NewEvent { get; set; } = new Event(); + public EventServiceEventArgs(Event newEvent) + { + NewEvent = newEvent; + } + } +} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/PrometheusQueryResult.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/PrometheusQueryResult.cs new file mode 100644 index 0000000..a8f8d06 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/PrometheusQueryResult.cs @@ -0,0 +1,114 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json.Serialization; + +namespace Elektrifikatsiya.Models; + +public enum Status +{ + Success, + Error +} + +public enum ResultType +{ + Matrix, + Vector, + Scalar, + String +} + +public class PrometheusQueryResult +{ + public Status Status { get; set; } + + public string? ErrorType { get; set; } + public string? Error { get; set; } + public List? Warnings { get; set; } + + public PrometheusDataWrapper? Data { get; set; } + + public PrometheusQueryResult(Status status, string? errorType, string? error, List? warnings) + { + Status = status; + ErrorType = errorType; + Error = error; + Warnings = warnings; + } +} + +public class PrometheusDataWrapper +{ + public ResultType ResultType { get; set; } + public List Result { get; set; } + + public PrometheusDataWrapper(ResultType resultType, List result) + { + ResultType = resultType; + Result = result; + } + + public FluentResults.Result> MatrixTypeToTimestampFloatTuple() + { + if (ResultType != ResultType.Matrix) + { + return FluentResults.Result.Fail("The response did not have the Matrix Type"); + } + + List<(double, double)> result = new List<(double, double)>(); + + if (Result.Count == 0 || Result[0]?.Values is null || Result[0]?.Values?[0] is null) + { + return FluentResults.Result.Fail("There was no result in the Response Body"); + } + + foreach (object value in Result[0]!.Values!) + { + + string[] segment = value.ToString()!.Split(","); + + result.Add((Convert.ToDouble(segment[0][2..^1], CultureInfo.InvariantCulture), Convert.ToDouble(segment[1][1..^2], CultureInfo.InvariantCulture))); + } + Debug.WriteLine(result); + return result; + } + + public FluentResults.Result<(double, double)> VectorTypeToTimestampFloatTuple() + { + if (ResultType != ResultType.Vector) + { + return FluentResults.Result.Fail("The response did not have the Vector Type"); + } + + string[]? segment = Result.FirstOrDefault()?.Value?.ToString()?.Split(","); + + if (segment is null) + { + return FluentResults.Result.Fail("There was no result in the Response Body"); + } + + return (Convert.ToDouble(segment[0][2..^1]), Convert.ToDouble(segment[1][1..^2], CultureInfo.InvariantCulture)); + } +} + +public class PrometheusDataMetric +{ + [JsonPropertyName("__name__")] public string Name { get; set; } + + public string Job { get; set; } + public string Instance { get; set; } + + public PrometheusDataMetric(string name, string job, string instance) + { + Name = name; + Job = job; + Instance = instance; + } +} + +public class PrometheusData +{ + public PrometheusDataMetric? Metric { get; set; } = null; + public object? Value { get; set; } = null; + public List? Values { get; set; } = null; +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/Role.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/Role.cs new file mode 100644 index 0000000..08e4fb4 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/Role.cs @@ -0,0 +1,9 @@ +namespace Elektrifikatsiya.Models; + +// Higher up = Higher permission +// Or you can set the priority manually = +public enum Role +{ + Admin, + User +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/ShellyResponse.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/ShellyResponse.cs new file mode 100644 index 0000000..bf6cd03 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/ShellyResponse.cs @@ -0,0 +1,13 @@ +namespace Elektrifikatsiya.Models; + +public class ShellyResponse +{ + public string Type { get; set; } + public string Mac { get; set; } + + public ShellyResponse(string type, string mac) + { + Type = type; + Mac = mac; + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Models/User.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Models/User.cs new file mode 100644 index 0000000..845600e --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Models/User.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Net.Mail; + +namespace Elektrifikatsiya.Models; + +public class User +{ + [Required] + public List Devices { get; set; } = new(); + + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Key] + public int Id { get; set; } + + public DateTime LastLoginDate { get; set; } + + [Required] + public string Name { get; set; } + + [Required] + public string PasswordHash { get; set; } + + [Required] + public string Email { get; set; } + + [Required] + public Role Role { get; set; } + + + public string? SessionToken { get; set; } + + public User(string name, string passwordHash, string email, Role role) + { + Name = name; + PasswordHash = passwordHash; + Email = email; + Role = role; + } + public User CopyUser() + { + return new User(Name, PasswordHash, Email, Role) { Id = Id }; + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/AddPlug.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/AddPlug.razor new file mode 100644 index 0000000..d7c1b7f --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/AddPlug.razor @@ -0,0 +1,168 @@ +@page "/addplug" +@using Blazorise.Components; +@using Elektrifikatsiya.Models; +@using System.Net +@using Elektrifikatsiya.Services +@using Elektrifikatsiya.Services.Implementations +@using Elektrifikatsiya.Utilities +@using FluentResults; +@using Blazorise.LoadingIndicator; +@inject IVersionProvider VersionProvider +@inject IDeviceManagmentService DeviceManagmentService +@inject IAuthenticationService AuthenticationService +@inject IMessageService MessageService + + + + + + + + + + Add a plug via wifi + + + + + + + + + + + + + + + + + IP: @context.Item + + + + + + + + + + + + + + + + + + + + + Add a plug via IP + + + + +
+ + + + + + Please enter a ip address. + Ip address is valid. + Ip address is not valid! + + +
+ +
+
+
+
+
+
+
+ +
+
+ +@code { + List ipAddresses = new List(); + + Validation? ipValidation; + + bool visible = true; + bool disabled = false; + + string ipText = null!; + + protected override void OnInitialized() + { + MdnsDiscovery.OnDeviceFound += async ipAddress => + { + visible = false; + if (!ipAddresses.Contains(ipAddress)) + { + ipAddresses.Add(ipAddress); + await InvokeAsync(StateHasChanged); + } + }; + + MdnsDiscovery.FetchChachedDevices(); + } + + private async Task RegisterDevice(IPAddress ipAddress) + { + disabled = true; + Result result = await DeviceManagmentService.RegisterDevice(ipAddress, (await AuthenticationService.GetUserAsync()).ValueOrDefault); + + if (result.IsSuccess) + { + await MessageService.Success("Device added!"); + } + else + { + await MessageService.Error(string.Join(',', result.Errors.Select(e => e.Message)), "Adding device failed!"); + } + disabled = false; + } + + public void ValidateIPv4(ValidatorEventArgs e) + { + string? input = Convert.ToString(e.Value); + + if (string.IsNullOrWhiteSpace(input)) + { + e.Status = ValidationStatus.None; + return; + } + + string[] splitValues = input.Split('.'); + if (splitValues.Length != 4) + { + e.Status = ValidationStatus.Error; + return; + } + + e.Status = splitValues.All(r => byte.TryParse(r, out _)) ? ValidationStatus.Success : ValidationStatus.Error; + } + +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Admin.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Admin.razor new file mode 100644 index 0000000..9209005 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Admin.razor @@ -0,0 +1,344 @@ +@page "/Admin" +@using Blazorise.Components; +@using Elektrifikatsiya.Components +@using Elektrifikatsiya.Models; +@using Elektrifikatsiya.Database; +@using System.Net +@using Elektrifikatsiya.Services; +@using Elektrifikatsiya.Services.Implementations; +@using FluentResults; +@using BC = BCrypt.Net.BCrypt; + +@inject IDeviceManagmentService DeviceManagmentService +@inject IMessageService MessageService +@inject IAuthenticationService AuthenthicationService; +@inject NavigationManager NavigationManager; +@inject MainDatabaseContext MainDatabaseContext; + + + +
+ + +
+ + + + + Users + + + + + + + + + + + + + + + + + + + @context.Item.Name + + + + + Name: @context.Item.Name +
+ Rolle: @context.Item.Role +
+ E-Mail: @context.Item.Email +
+
+
+ + + + + + + + + Add User + + + + + User Name + + + + User E-Mail + + + + User Password + + + + + + + + + + +
+
+ + + + + + +
+
+
+
+
+ + @if (SelectedUser is not null) + { + + } + +
+
+
+
+ + Bro no! this page is only for admins! 😠 + +
+@code { + List users = new List(); + + User? SelectedUser = null; + User? UserCopy = null; + + bool hideButtonSettings = true; + bool newpage = true; + + private Modal? modalRefAddUser; + private TextEdit? modalAddUserName; + private TextEdit? modalAddUserEmail; + private TextEdit? modalAddUserPW; + private TextEdit? updateName; + private TextEdit? updateEmail; + private TextEdit? updatePW; + bool admin = false; + + Validation? updateValidation; + + protected override async void OnInitialized() + { + Result> getUsersResult = await AuthenthicationService.GetUsers(); + + if (getUsersResult.IsSuccess) + { + users = getUsersResult.Value; + } + } + + private void Toggle(User user) + { + if (user == SelectedUser || newpage) + { + hideButtonSettings = !hideButtonSettings; + } + else + { + if (hideButtonSettings) + { + hideButtonSettings = !hideButtonSettings; + } + } + SelectedUser = user; + UserCopy = user.CopyUser(); + newpage = false; + } + + private Task ShowModalAddUser() + { + return modalRefAddUser!.Show(); + } + + private Task HideModalAddUser() => modalRefAddUser!.Hide(); + + private async void AddButtonPressed() + { + await HideModalAddUser(); + await RegisterUser(); + } + + private async Task RegisterUser() + { + Result result = await AuthenthicationService.RegisterUserAsync(modalAddUserName.Text.ToString(), modalAddUserPW.Text.ToString(), modalAddUserEmail.Text.ToString(), Role.User); + + if (result.IsSuccess) + { + await MessageService.Success("User added!"); + } + else + { + await MessageService.Error(string.Join(',', result.Errors.Select(e => e.Message)), "Adding User failed!"); + } + + Result> getUsersResult = await AuthenthicationService.GetUsers(); + + if (getUsersResult.IsSuccess) + { + users = getUsersResult.Value; + } + + StateHasChanged(); + } + + private async Task OnSave() + { + if (UserCopy is not null && SelectedUser is not null) + { + MainDatabaseContext.ChangeTracker.Clear(); + + UserCopy.Name = updateName.Text.ToString(); + UserCopy.Email = updateEmail.Text.ToString(); + UserCopy.PasswordHash = BC.HashPassword(updatePW.Text.ToString()); + if (admin) + { + UserCopy.Role = Role.Admin; + } + else + { + UserCopy.Role = Role.User; + } + + MainDatabaseContext.Update(UserCopy); + + await Result.Try(Task () => MainDatabaseContext.SaveChangesAsync()); + await MessageService.Success("User updated!"); + } + } + + private async Task Delete(User contextItem) + { + bool succ = await MessageService.Confirm($"Do you really want to delete the User \"{contextItem.Name}\"?"); + + if (!succ) + { + return; + } + + await AuthenthicationService.DeleteUser(contextItem); + + NavigationManager.NavigateTo(NavigationManager.Uri, true); + } + private void SetRoleAdmin() + { + admin = true; + } + private void SetRoleUser() + { + admin = false; + } +} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Dashboard.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Dashboard.razor index d918c4b..db23e16 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Dashboard.razor +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Dashboard.razor @@ -1,31 +1,136 @@ -@page "/" +@page "/" +@using Blazorise.Components; +@using Elektrifikatsiya.Database; +@using Elektrifikatsiya.Models; +@using System.Net +@using Elektrifikatsiya.Services +@using Elektrifikatsiya.Utilities +@using System.Diagnostics +@using Elektrifikatsiya.Services.Implementations @inject IVersionProvider VersionProvider -Blazorise - - Blazorise is a component library built on top of Blazor and CSS frameworks like Bootstrap, Bulma, Ant Design, and Material. It can be used to build responsive, single-page web applications. - +@inject IDeviceStatusService DeviceStatusService; +@inject IDeviceManagmentService DeviceManagmentService; +@inject IEventService EventService; +@inject IAuthenticationService AuthenticationService; +@inject IEnergyPriceService EnergyPriceService; +@inject MainDatabaseContext MainDatabaseContext; - - - This is a Blazorise Starting Template allowing you to quickly get started building your project! - +
+ +
+ + + + Plugs + + + + + + + + + + @context.Item.Name + + + + + User: @context.Item.User.Name +
+ Room: @context.Item.Room +
+ + @context.Item.PowerUsage W + +
+
+ + + +
+
+
+
+
+ + + + Logs + + + + +
+ @context.Item.EventName + @context.Item.Date +
+ @context.Item.Description +
+
+
+
+
+ + + + + Electricity Cost + + - - The bare minimum has been installed for you: -
    -
  • Blazorise @($"v{VersionProvider.MilestoneVersion}")
  • -
  • The Blazorise Provider you have selected
  • -
  • The corresponding icon provider
  • -
- However Blazorise has many more extensions at your disposal. - You can find them here. -
+ + +
+ Daily Cost +
+ @($"{EnergyPriceService.DailyEnergyCost:F2}")€ +
+ +
+ Monthly Cost +
+ @($"{EnergyPriceService.MonthlyEnergyCost:F2}")€ +
+ +
+ Yearly Cost +
+ @($"{EnergyPriceService.YearlyEnergyCost:F2}")€ +
- - Please visit the official Blazorise Demo for component examples. - - - Please visit the official Blazorise Documentation to learn more about the available components. - - +
+
+
+ + + + + + +
\ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Dashboard.razor.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Dashboard.razor.cs new file mode 100644 index 0000000..56abfce --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Dashboard.razor.cs @@ -0,0 +1,101 @@ +using Blazorise.Charts; + +using Elektrifikatsiya.Database; +using Elektrifikatsiya.Models; +using Elektrifikatsiya.Utilities; + +namespace Elektrifikatsiya.Pages; + +public partial class Dashboard +{ + private readonly List labels = new List(); + + //TODO: insert new event here if plug produces one + private List events = new List() { new Event("Text", "Text", DateTime.Today, null) }; + + //code for graph + private LineChart lineChart; + + //TODO: insert new Device here if user adds one + private List plugs = new List(); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await HandleRedraw(); + } + } + + protected override async Task OnInitializedAsync() + { + plugs = DeviceManagmentService.GetDevices((await AuthenticationService.GetUserAsync()).ValueOrDefault).ValueOrDefault ?? new List(); + events = EventService.Events; + + EventService.OnEventCalled += (__, e) => + { + _ = InvokeAsync(StateHasChanged); + }; + DeviceStatusService.OnDeviceStatusChanged += (__, e) => + { + _ = InvokeAsync(StateHasChanged); + }; + } + + private async Task> GetData() + { + if (plugs.Count == 0) + { + return new(); + } + PrometheusQuery promQueryer = new PrometheusQuery("http://localhost:9090"); + + string plugnames = ""; + foreach (Device device in plugs) + { + plugnames += $"shellyplug-s-{device.MacAddress}/relay/0|"; + } + plugnames = plugnames[0..^1]; + + double energyPrice = MainDatabaseContext.EnergyPriceChanges.OrderByDescending(e => e.DateTime).FirstOrDefault()?.EnergyPrice ?? 0; + + PrometheusDataWrapper? deviceData = (await promQueryer.Query($$"""sum(power{sensor=~"{{plugnames}}"})[1h:1m]"""))?.Data; + //PrometheusDataWrapper? priceData = (await promQueryer.Query($$"""sum(sum_over_time(power{sensor=~"{{plugnames}}"}[1y])*{{energyPrice}})"""))?.Data; + + //Console.WriteLine(priceData.VectorTypeToTimestampFloatTuple()); + + Random r = new Random(DateTime.Now.Millisecond); + + return deviceData?.MatrixTypeToTimestampFloatTuple().ValueOrDefault?.Select(x => + { + labels.Add(DateTimeOffset.FromUnixTimeSeconds(long.Parse($"1{x.Item1}0")).UtcDateTime.ToLocalTime().ToShortTimeString()); + return x.Item2; + }).ToList() ?? new List(); + } + + //TODO: insert dataset of current and last voltage usages + private async Task> GetLineChartDataset() + { + return new LineChartDataset + { + Label = "Wattage", + Data = await GetData(), + Fill = true, + PointRadius = 3, + CubicInterpolationMode = "monotone", + }; + } + + private async Task HandleRedraw() + { + labels.Clear(); + await lineChart.Clear(); + await lineChart.AddLabelsDatasetsAndUpdate(labels, await GetLineChartDataset()); + } + + private async Task Switch(Device device) + { + device.Enabled = !device.Enabled; + _ = await DeviceManagmentService.UpdateDevice(device); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Login.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Login.razor new file mode 100644 index 0000000..f62fe54 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Login.razor @@ -0,0 +1,19 @@ +@using System.Diagnostics; +@using Elektrifikatsiya.Layouts; +@using Elektrifikatsiya.Services; +@inject IAuthenticationService AuthenticationService; +@inject NavigationManager NavigationManager +@page "/login" +@layout LoginLayout; + +@code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + bool isAuthenticated = (await AuthenticationService.IsAuthenticated()).ValueOrDefault; + + if (isAuthenticated) + { + NavigationManager.NavigateTo("/"); + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Settings.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Settings.razor new file mode 100644 index 0000000..f6ae112 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/Settings.razor @@ -0,0 +1,225 @@ +@page "/Settings" +@using Blazorise.Components; +@using Elektrifikatsiya.Database; +@using Elektrifikatsiya.Models; +@using System.Net +@using System.Text.Json +@using Elektrifikatsiya.Services.Implementations +@using Elektrifikatsiya.Services; +@using FluentResults + +@inject IDeviceManagmentService DeviceManagmentService +@inject IMessageService MessageService +@inject IAuthenticationService AuthenticationService +@inject MainDatabaseContext MainDatabaseContext + +
+ +
+ + + + Plugs + + + + + + + + + + + @context.Item.Name + + + + + User: @context.Item.User.Name +
+ Room: @context.Item.Room +
+
+
+ + + + + +
+
+ + + + + + +
+
+
+
+
+ + + @if (DeviceCopy is not null) + { + + } + + + + + +
+
+
+ + +@code { + ListView deviceList = new ListView(); + List plugs = new List(); + + bool hideButtonSettings = true; + bool newpage = true; + + int clicks = 0; + + double electricityPrice = 0; + + Device? SelectedDevice = null; + Device? DeviceCopy = null; + + protected override async Task OnInitializedAsync() + { + Result> getDevicesResult = DeviceManagmentService.GetDevices((await AuthenticationService.GetUserAsync()).ValueOrDefault); + + if (getDevicesResult.IsSuccess) + { + plugs = getDevicesResult.Value; + } + + electricityPrice = MainDatabaseContext.EnergyPriceChanges.OrderByDescending(e => e.DateTime).FirstOrDefault()?.EnergyPrice ?? 0; + } + + private void Toggle(Device device) + { + if (device == SelectedDevice || newpage) + { + hideButtonSettings = !hideButtonSettings; + } + else + { + if (hideButtonSettings) + { + hideButtonSettings = !hideButtonSettings; + } + } + SelectedDevice = device; + DeviceCopy = device.CopyDevice(); + newpage = false; + } + + private async void OnSave() + { + if (hideButtonSettings) + { + MainDatabaseContext.ChangeTracker.Clear(); + MainDatabaseContext.EnergyPriceChanges.Add(new EnergyPriceChange(electricityPrice)); + await MainDatabaseContext.SaveChangesAsync(); + } + else if (DeviceCopy is not null && SelectedDevice is not null) + { + + SelectedDevice.OverwiteDevice(DeviceCopy); + await DeviceManagmentService.UpdateDevice(SelectedDevice); + } + } + + private async Task Delete(Device contextItem) + { + bool succ = await MessageService.Confirm($"Do you really want to delete the device \"{contextItem.Name}\"?"); + + if (!succ) + { + return; + } + + await DeviceManagmentService.UnregisterDevice(contextItem.MacAddress); + + int index = plugs.FindIndex(x => x.MacAddress == contextItem.MacAddress); + + if (index != -1) + { + plugs.RemoveAt(index); + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/TodoAppPage.razor b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/TodoAppPage.razor deleted file mode 100644 index ed5201a..0000000 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/TodoAppPage.razor +++ /dev/null @@ -1,3 +0,0 @@ -@using Elektrifikatsiya.Components.TodoApp -@page "/apps/todo" - \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Host.cshtml b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Host.cshtml index 3e71a2d..6f92073 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Host.cshtml +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Host.cshtml @@ -6,3 +6,4 @@ } + \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Layout.cshtml b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Layout.cshtml index a689909..45c56e9 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Layout.cshtml +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Pages/_Layout.cshtml @@ -40,5 +40,35 @@ + + + diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Program.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Program.cs index 44ecc11..e98d07c 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Program.cs +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Program.cs @@ -1,12 +1,66 @@ +global using INotificationService = Elektrifikatsiya.Services.INotifcationService; + using Blazorise; +using Blazorise.Bootstrap; using Blazorise.Icons.Material; using Blazorise.Material; +using Elektrifikatsiya.Database; +using Elektrifikatsiya.Models; +using Elektrifikatsiya.Services; +using Elektrifikatsiya.Services.Implementations; +using Elektrifikatsiya.Utilities; + +using HiveMQtt.Client; +using HiveMQtt.Client.Options; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +// Read configuration +builder.Services.Configure(builder.Configuration.GetRequiredSection("EmailSettings")); + // Add services to the container. builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); +builder.Services.AddOptions(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(s => s.GetRequiredService()); +builder.Services.AddSingleton(s => s.GetRequiredService()); +builder.Services.AddSingleton(s => s.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton((provider) => +{ + HiveMQClientOptions options = new() + { + Host = "localhost", + Port = 1883, + UseTLS = false, + }; + + HiveMQClient client = new(options); + _ = client.ConnectAsync().ConfigureAwait(false); + return client; +}); +builder.Services.AddTransient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddDbContext(options => options.UseSqlite("Data Source=./MainDatabase.sqlite")); +builder.Services.AddBootstrapProviders(); +builder.Services.AddHttpClient(); +builder.Services.AddBlazorise(options => +{ + options.Immediate = true; +}); AddBlazorise(builder.Services); @@ -25,20 +79,29 @@ app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); +app.MapBlazorHub(); +app.MapFallbackToPage("/_Host"); +app.MapControllers(); -app.UseEndpoints(endpoints => +app.Services.GetRequiredService>().Value.CompileTemplates(); + +IServiceScope serviceScope = app.Services.GetRequiredService().CreateScope(); + +MainDatabaseContext mainDatabase = serviceScope.ServiceProvider.GetRequiredService(); +mainDatabase.Database.EnsureCreated(); + +IAuthenticationService authenticationService = serviceScope.ServiceProvider.GetRequiredService(); + +if (!mainDatabase.Users.Any()) { - _ = endpoints.MapBlazorHub(); - _ = endpoints.MapFallbackToPage("/_Host"); -}); + _ = authenticationService.RegisterUserAsync("admin", "admin", "", Role.Admin); +} app.Run(); -void AddBlazorise(IServiceCollection services) +static void AddBlazorise(IServiceCollection services) { - _ = services - .AddBlazorise(); - _ = services - .AddMaterialProviders() - .AddMaterialIcons(); + _ = services.AddBlazorise(); + _ = services.AddMaterialProviders(); + _ = services.AddMaterialIcons(); } \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Properties/launchSettings.json b/src/Elektrifikatsiya/Elektrifikatsiya/Properties/launchSettings.json index 7111a52..a35b142 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/Properties/launchSettings.json +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Properties/launchSettings.json @@ -7,7 +7,7 @@ "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, - "applicationUrl": "https://localhost:7297;http://localhost:5255" + "applicationUrl": "https://localhost:56656;http://localhost:55552" }, "IIS Express": { "commandName": "IISExpress", @@ -19,7 +19,7 @@ "Docker": { "commandName": "Docker", "launchBrowser": true, - "launchUrl": "{Scheme}://{IPAdress}:{ServicePort}", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", "publishAllPorts": true, "useSSL": true, "DockerfileRunArguments": "-p 80:80 -p 443:443" diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthenticationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthenticationService.cs new file mode 100644 index 0000000..6e3fe1f --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthenticationService.cs @@ -0,0 +1,61 @@ +using Elektrifikatsiya.Models; + +using FluentResults; + +namespace Elektrifikatsiya.Services; + +public interface IAuthenticationService +{ + /// + /// Gets the current user. + /// + /// to and the currently authenticated user. + Task> GetUserAsync(); + + Task>> GetUsers(); + + /// + /// Registers a new user. + /// + /// The name address of the user. + /// The password of the user. + /// The default role of the user. + /// A to . + + Task RegisterUserAsync(string name, string password, string email, Role role); + + /// + /// Logs a user in. + /// + /// The name of the user. + /// The password of the user. + /// A to . + Task LoginUserAsync(string name, string password); + + /// + /// Logs the user out. + /// + /// A to . + Task LogoutUserAsync(); + + /// + /// Deletes the current user. + /// + /// A to . + Task DeleteUserAsync(); + + Task DeleteUser(User user); + + /// + /// Checks if a user exists. + /// + /// The name address of the user. + /// A to and a that indicates if the user exists. + Task> UserExistsAsync(string name); + + /// + /// Check if the current user is authenticated. + /// + /// A to and a that indicates if the user is authenticated. + Task> IsAuthenticated(); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthorizationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthorizationService.cs new file mode 100644 index 0000000..30be3d0 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IAuthorizationService.cs @@ -0,0 +1,15 @@ +using Elektrifikatsiya.Models; + +using FluentResults; + +namespace Elektrifikatsiya.Services; + +public interface IAuthorizationService +{ + /// + /// Check if the current user is authorized. + /// + /// The minimum required role. + /// A to and a that indicates if the user is authorized. + Task> IsAuthorized(Role requiredRole); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/ICookieService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/ICookieService.cs new file mode 100644 index 0000000..a954ea8 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/ICookieService.cs @@ -0,0 +1,13 @@ +namespace Elektrifikatsiya.Services; + +public interface ICookieService +{ + /// + /// Writes a cookie. + /// + /// The name of the cookie. + /// The value of the cookie. + /// Indicates the maximum lifetime of the cookie. + /// + Task WriteCookieAsync(string name, string value, int days); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IDeviceManagmentService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IDeviceManagmentService.cs new file mode 100644 index 0000000..b372442 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IDeviceManagmentService.cs @@ -0,0 +1,24 @@ +using Elektrifikatsiya.Models; + +using FluentResults; + +using System.Net; + +namespace Elektrifikatsiya.Services; + +public interface IDeviceManagmentService +{ + public Task> RegisterDevice(IPAddress ip, User user, string? name = null, string room = "default"); + + public Task UnregisterDevice(string macAdress); + + public Result GetDevice(string macAdress); + + public Result> GetDevices(User user); + + public Result> GetDevicesInRoom(string room); + + public Result> GetDevicesOfUser(int userId); + + public Task UpdateDevice(Device device); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IDeviceStatusService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IDeviceStatusService.cs new file mode 100644 index 0000000..ef10788 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IDeviceStatusService.cs @@ -0,0 +1,18 @@ +using Elektrifikatsiya.Models; + +using FluentResults; + +namespace Elektrifikatsiya.Services; + +public interface IDeviceStatusService +{ + public event EventHandler OnDeviceStatusChanged; + + public Result> GetDevices(); + + public Result UpdateDeviceStatus(Device device); + + public Result TrackDevice(Device device); + + public Result UntrackDevice(string macAddress); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEmailService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEmailService.cs new file mode 100644 index 0000000..01caf9d --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEmailService.cs @@ -0,0 +1,8 @@ +namespace Elektrifikatsiya.Services; + +public interface IEmailService +{ + Task SendAsync(string to, string subject, string html, string? from = null); + + Task SendWithTemeplateAsync(string to, string subject, string templateKey, string? from = null, Dictionary? templateParameters = null); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEnergyPriceService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEnergyPriceService.cs new file mode 100644 index 0000000..179cce7 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEnergyPriceService.cs @@ -0,0 +1,13 @@ +using Elektrifikatsiya.Models; +using Elektrifikatsiya.Utilities; + +namespace Elektrifikatsiya.Services; + +public interface IEnergyPriceService : IScheduledService +{ + public double DailyEnergyCost { get; } + public double MonthlyEnergyCost { get; } + public double YearlyEnergyCost { get; } + + public Task GetEnergyPrice(DateTime startime, DateTime endTime, List plugs, List changes); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEventService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEventService.cs new file mode 100644 index 0000000..9445447 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IEventService.cs @@ -0,0 +1,10 @@ +using Elektrifikatsiya.Models; + +namespace Elektrifikatsiya.Services +{ + public interface IEventService + { + public List Events { get; } + public event EventHandler OnEventCalled; + } +} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/INotifcationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/INotifcationService.cs new file mode 100644 index 0000000..96e1b23 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/INotifcationService.cs @@ -0,0 +1,8 @@ +using Elektrifikatsiya.Utilities; + +namespace Elektrifikatsiya.Services; + +public interface INotifcationService : IScheduledService +{ + +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/IUpdateService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IUpdateService.cs new file mode 100644 index 0000000..acfae54 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/IUpdateService.cs @@ -0,0 +1,8 @@ +using Elektrifikatsiya.Utilities; + +namespace Elektrifikatsiya.Services; + +public interface IUpdateService : IScheduledService +{ + +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthenticationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthenticationService.cs new file mode 100644 index 0000000..55c0df8 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthenticationService.cs @@ -0,0 +1,145 @@ +using Elektrifikatsiya.Database; +using Elektrifikatsiya.Models; +using Elektrifikatsiya.Utilities; + +using FluentResults; + +using Microsoft.EntityFrameworkCore; + +using BC = BCrypt.Net.BCrypt; + +namespace Elektrifikatsiya.Services.Implementations; + +public class AuthenticationService : IAuthenticationService +{ + private readonly MainDatabaseContext mainDatabaseContext; + private readonly IHttpContextAccessor httpContextAccessor; + private readonly ICookieService cookieService; + + public AuthenticationService(MainDatabaseContext mainDatabaseContext, IHttpContextAccessor httpContextAccessor, ICookieService cookieService) + { + this.mainDatabaseContext = mainDatabaseContext; + this.httpContextAccessor = httpContextAccessor; + this.cookieService = cookieService; + } + + public async Task DeleteUserAsync() + { + Result getUserResult = await GetUserAsync(); + + if (getUserResult.IsFailed) + { + return getUserResult.ToResult(); + } + + _ = mainDatabaseContext.Users.Remove(getUserResult.Value); + return (await Result.Try(() => mainDatabaseContext.SaveChangesAsync())).ToResult(); + } + + public async Task DeleteUser(User user) + { + _ = mainDatabaseContext.Users.Remove(user); + return (await Result.Try(() => mainDatabaseContext.SaveChangesAsync())).ToResult(); + } + + public async Task> GetUserAsync() + { + string? token = httpContextAccessor.HttpContext?.Request.Cookies["token"]?.ToString(); + + if (!TokenGenerator.ValidateToken(token, "auth")) + { + return Result.Fail("Token is not valid!"); + } + + User? user = await mainDatabaseContext.Users.FirstOrDefaultAsync(u => u.SessionToken == token); + + if (user is null || DateTime.UtcNow - user.LastLoginDate > TimeSpan.FromDays(7)) + { + return Result.Fail("Token is not valid or expired!"); + } + + return user; + } + + public async Task>> GetUsers() + { + return await mainDatabaseContext.Users.ToListAsync(); + } + + public async Task> IsAuthenticated() + { + return (await GetUserAsync()).IsSuccess; + } + + public async Task LoginUserAsync(string name, string password) + { + User? user = await mainDatabaseContext.Users.FirstOrDefaultAsync(u => u.Name == name); + + if (user is null) + { + return Result.Fail("User does not exist!"); + } + + if (!BC.Verify(password, user.PasswordHash)) + { + return Result.Fail("Invalid credentials!"); + } + + string newToken = TokenGenerator.GenerateToken("auth", user.Id); + + user.LastLoginDate = DateTime.UtcNow; + user.SessionToken = newToken; + + _ = await mainDatabaseContext.SaveChangesAsync(); + + await cookieService.WriteCookieAsync("token", newToken, 7); + + return Result.Ok(); + } + + public async Task LogoutUserAsync() + { + string? token = httpContextAccessor.HttpContext?.Request.Cookies["token"]?.ToString(); + + await cookieService.WriteCookieAsync("token", string.Empty, 0); + + if (!TokenGenerator.ValidateToken(token, "auth")) + { + return Result.Ok(); + } + + User? user = await mainDatabaseContext.Users.FirstOrDefaultAsync(u => u.SessionToken == token); + + if (user is null) + { + return Result.Ok(); + } + + user.SessionToken = null; + + _ = await mainDatabaseContext.SaveChangesAsync(); + + return Result.Ok(); + } + + public async Task RegisterUserAsync(string name, string password, string email, Role role) + { + Result userExistsResult = await UserExistsAsync(name); + + if (userExistsResult.IsFailed || userExistsResult.Value) + { + return Result.Fail("User name already exists!"); + } + + User user = new User(name, BC.HashPassword(password), email, role); + _ = mainDatabaseContext.Users.Add(user); + + return (await Result.Try(() => mainDatabaseContext.SaveChangesAsync())).ToResult(); + } + + public Task> UserExistsAsync(string name) + { + return Result.Try(() => + mainDatabaseContext.Users.AnyAsync(u => u.Name == name)); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthorizationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthorizationService.cs new file mode 100644 index 0000000..e62b137 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/AuthorizationService.cs @@ -0,0 +1,29 @@ +using Elektrifikatsiya.Models; + +using FluentResults; + +namespace Elektrifikatsiya.Services.Implementations; + +public class AuthorizationService : IAuthorizationService +{ + private readonly IAuthenticationService authenticationService; + + public AuthorizationService(IAuthenticationService authenticationService) + { + this.authenticationService = authenticationService; + } + + public async Task> IsAuthorized(Role requiredRole) + { + Result userResult = await authenticationService.GetUserAsync(); + + if (userResult.IsSuccess && userResult.Value.Role <= requiredRole) + { + return Result.Ok(true); + } + else + { + return Result.Fail("User doesn't have the required role!"); + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/CookieService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/CookieService.cs new file mode 100644 index 0000000..d654fdc --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/CookieService.cs @@ -0,0 +1,18 @@ +using Microsoft.JSInterop; + +namespace Elektrifikatsiya.Services.Implementations; + +public class CookieService : ICookieService +{ + private readonly IJSRuntime jsRuntime; + + public CookieService(IJSRuntime jsRuntime) + { + this.jsRuntime = jsRuntime; + } + + public async Task WriteCookieAsync(string name, string value, int days) + { + _ = await jsRuntime.InvokeAsync("blazorExtensions.WriteCookie", name, value, days); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/DeviceManagmentService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/DeviceManagmentService.cs new file mode 100644 index 0000000..1bbc3c3 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/DeviceManagmentService.cs @@ -0,0 +1,182 @@ +using Elektrifikatsiya.Database; +using Elektrifikatsiya.Models; + +using FluentResults; + +using HiveMQtt.Client; +using HiveMQtt.Client.Results; + +using Microsoft.EntityFrameworkCore; + +using System.Net; +using System.Net.NetworkInformation; + +namespace Elektrifikatsiya.Services.Implementations; + +public class DeviceManagmentService : IDeviceManagmentService +{ + private readonly IServiceScopeFactory serviceScopeFactory; + private readonly IDeviceStatusService deviceStatusService; + private readonly ILogger logger; + private readonly IHiveMQClient hiveMQClient; + private readonly HttpClient httpClient; + + public DeviceManagmentService(IServiceScopeFactory serviceScopeFactory, IDeviceStatusService deviceStatusService, ILogger logger, IHiveMQClient hiveMQClient, HttpClient httpClient) + { + this.serviceScopeFactory = serviceScopeFactory; + this.deviceStatusService = deviceStatusService; + this.logger = logger; + this.hiveMQClient = hiveMQClient; + this.httpClient = httpClient; + } + + public Result GetDevice(string macAdress) + { + Result> getDevicesResult = deviceStatusService.GetDevices(); + + if (getDevicesResult.IsFailed) + { + return getDevicesResult.ToResult(); + } + + Device? device = getDevicesResult.Value.FirstOrDefault(d => d.MacAddress == macAdress); + + if (device is null) + { + return Result.Fail("Device does not exist!"); + } + + return device; + } + + public Result> GetDevices(User user) + { + Result> getDevicesResult = deviceStatusService.GetDevices(); + + if (getDevicesResult.IsFailed) + { + return getDevicesResult.ToResult(); + } + + return getDevicesResult.Value.Where(d => d.User.Id == user.Id || user.Role == Role.Admin).ToList(); + } + + public Result> GetDevicesInRoom(string room) + { + Result> getDevicesResult = deviceStatusService.GetDevices(); + + if (getDevicesResult.IsFailed) + { + return getDevicesResult.ToResult(); + } + + return getDevicesResult.Value.Where(d => d.Room == room).ToList(); + } + + public Result> GetDevicesOfUser(int userId) + { + Result> getDevicesResult = deviceStatusService.GetDevices(); + + if (getDevicesResult.IsFailed) + { + return getDevicesResult.ToResult(); + } + + return getDevicesResult.Value.Where(d => d.User.Id == userId).ToList(); + } + + public async Task> RegisterDevice(IPAddress ip, User user, string? name = null, string room = "default") + { + using IServiceScope scope = serviceScopeFactory.CreateScope(); + MainDatabaseContext mainDatabaseContext = scope.ServiceProvider.GetRequiredService(); + + ShellyResponse? shellyResponse = null; + + try + { + shellyResponse = await httpClient.GetFromJsonAsync($"http://{ip}/shelly"); + } + catch (Exception ex) + { + logger.LogError("HTTP request failed! {message}", ex.Message); + } + + if (shellyResponse is null || shellyResponse.Type != "SHPLG-S") + { + return Result.Fail("Device is not reachable or not a \"SHPLG-S\"!"); + } + + string mac = shellyResponse.Mac; + + if (!PhysicalAddress.TryParse(mac, out _)) + { + return Result.Fail("Invalid mac address!"); + } + + Device device = new Device(mac, name ?? mac, ip, user, room); + + mainDatabaseContext.Entry(user).State = EntityState.Unchanged; + + _ = mainDatabaseContext.Add(device); + Result saveDatabaseChangesResult = await Result.Try(Task () => mainDatabaseContext.SaveChangesAsync()); + + if (saveDatabaseChangesResult.IsFailed) + { + return saveDatabaseChangesResult; + } + + return deviceStatusService.TrackDevice(device); + } + + public async Task UnregisterDevice(string macAdress) + { + using IServiceScope scope = serviceScopeFactory.CreateScope(); + MainDatabaseContext mainDatabaseContext = scope.ServiceProvider.GetRequiredService(); + + Result getDeviceResult = GetDevice(macAdress); + + if (getDeviceResult.IsFailed) + { + return getDeviceResult.ToResult(); + } + + Result untrackDeviceResult = deviceStatusService.UntrackDevice(macAdress); + + if (untrackDeviceResult.IsFailed) + { + return untrackDeviceResult; + } + + _ = mainDatabaseContext.Remove(getDeviceResult.Value); + + return await Result.Try(Task () => mainDatabaseContext.SaveChangesAsync()); + } + + public async Task UpdateDevice(Device device) + { + using IServiceScope scope = serviceScopeFactory.CreateScope(); + MainDatabaseContext mainDatabaseContext = scope.ServiceProvider.GetRequiredService(); + + Result getDeviceResult = GetDevice(device.MacAddress); + + if (getDeviceResult.IsFailed) + { + return getDeviceResult.ToResult(); + } + + Result updateDeviceResult = deviceStatusService.UpdateDeviceStatus(device); + + if (updateDeviceResult.IsFailed) + { + return updateDeviceResult; + } + + PublishResult res = await hiveMQClient.PublishAsync($"shellies/shellyplug-s-{device.MacAddress}/relay/0/command", device.Enabled ? "on" : "off"); + + device.User = mainDatabaseContext.Users.First(u => u.Id == device.User.Id); + + _ = mainDatabaseContext.Update(device); + + return await Result.Try(Task () => mainDatabaseContext.SaveChangesAsync()); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/DeviceStatusService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/DeviceStatusService.cs new file mode 100644 index 0000000..4c4f35b --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/DeviceStatusService.cs @@ -0,0 +1,60 @@ +using Elektrifikatsiya.Models; +using FluentResults; + +namespace Elektrifikatsiya.Services.Implementations; + +public class DeviceStatusService : IDeviceStatusService +{ + private readonly ILogger logger; + public event EventHandler? OnDeviceStatusChanged; + + private readonly Dictionary devices = new(); + + public DeviceStatusService(ILogger logger) + { + this.logger = logger; + } + + public Result> GetDevices() + { + return devices.Values.ToList(); + } + + public Result UpdateDeviceStatus(Device device) + { + Device? modDevice = devices.GetValueOrDefault(device.MacAddress); + + if (modDevice is null) + { + return Result.Fail("Device not tracked!"); + } + + modDevice.PowerUsage = device.PowerUsage; + modDevice.Enabled = device.Enabled; + modDevice.IpAddress = device.IpAddress; + modDevice.Name = device.Name; + + OnDeviceStatusChanged?.Invoke(this, new DeviceStatusChangedEventArgs(device.MacAddress)); + + return Result.Ok(); + } + + public Result TrackDevice(Device device) + { + if (devices.ContainsKey(device.MacAddress)) + { + return Result.Fail("Device already tracked!"); + } + + logger.LogInformation("Tracking new Device {device}", device.IpAddress); + + devices[device.MacAddress] = device; + + return Result.Ok(); + } + + public Result UntrackDevice(string macAddress) + { + return devices.Remove(macAddress) ? Result.Ok() : Result.Fail("Device is not tracked!"); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EmailService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EmailService.cs new file mode 100644 index 0000000..5ea518a --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EmailService.cs @@ -0,0 +1,43 @@ +using Elektrifikatsiya.Utilities; +using HandlebarsDotNet; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; +using MimeKit.Text; + +namespace Elektrifikatsiya.Services.Implementations +{ + public class EmailService : IEmailService + { + private readonly EmailSettings emailSettings; + + public EmailService(IOptions emailSettings) + { + this.emailSettings = emailSettings.Value; + } + + public async Task SendAsync(string to, string subject, string html, string? from = null) + { + from ??= emailSettings.DefaultEmail; + + MimeMessage message = new MimeMessage(); + message.From.Add(MailboxAddress.Parse(from)); + message.To.Add(MailboxAddress.Parse(to)); + message.Subject = subject; + message.Body = new TextPart(TextFormat.Html) { Text = html }; + + using SmtpClient smtp = new SmtpClient(); + await smtp.ConnectAsync(emailSettings.SmtpServer, emailSettings.SmtpPort, SecureSocketOptions.StartTls); + await smtp.AuthenticateAsync(emailSettings.User, emailSettings.Key); + await smtp.SendAsync(message); + await smtp.DisconnectAsync(true); + } + + public Task SendWithTemeplateAsync(string to, string subject, string templateKey, string? from = null, Dictionary? templateParameters = null) + { + string content = emailSettings.CompiledTemplates[templateKey](templateParameters ?? new()); + return SendAsync(to, subject, content, from); + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EnergyPriceService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EnergyPriceService.cs new file mode 100644 index 0000000..a893135 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EnergyPriceService.cs @@ -0,0 +1,91 @@ +using Elektrifikatsiya.Database; +using Elektrifikatsiya.Models; +using Elektrifikatsiya.Utilities; + +using System.Globalization; + +namespace Elektrifikatsiya.Services.Implementations; + +public class EnergyPriceService : IEnergyPriceService +{ + private readonly MainDatabaseContext mainDatabaseContext; + private readonly PrometheusQuery prometheusQuerier = new("http://127.0.0.1:9090"); + + public TimeSpan ExecutionRepeatDelay { get; } = TimeSpan.FromSeconds(5); + public DateTime FirstExecutionTime { get; } = DateTime.Now.AddSeconds(5); + public double MonthlyEnergyCost { get; private set; } + + public double YearlyEnergyCost { get; private set; } + + public double DailyEnergyCost { get; private set; } + + public EnergyPriceService(IServiceScopeFactory serviceScopeFactory) + { + mainDatabaseContext = serviceScopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + } + + /// + /// Calculate the energy price for the given timespan + /// + /// The timestamp that has the greater value + /// The timestamp that has the lower value + /// + /// + /// + /// + public async Task GetEnergyPrice(DateTime startime, DateTime endTime, List plugs, List changes) + { + string plugnames = ""; + foreach (Device device in plugs) + { + plugnames += $"shellyplug-s-{device.MacAddress}/relay/0|"; + } + + if(plugnames.Length <= 0 || changes.Count <= 0) + { + return 0; + } + plugnames = plugnames[0..^1]; + + if (startime < endTime) + { + throw new ArgumentException("Read the xmlDoc"); + } + + changes = changes.OrderByDescending(x => x.DateTime).ToList(); + + double total = (await prometheusQuerier.Query($$"""sum_over_time(sum(power{sensor=~"{{plugnames}}"})[{{(int)Math.Ceiling((startime - changes.First().DateTime).TotalMinutes)}}m:1s] @ {{((DateTimeOffset)startime).ToUnixTimeSeconds()}}) * {{(changes.First().EnergyPrice / 3600).ToString(CultureInfo.InvariantCulture)}}"""))?.Data?.VectorTypeToTimestampFloatTuple().ValueOrDefault.Item2 ?? 0; + + for (int i = 0; i < changes.Count - 1; i++) + { + bool inMinutes = true; + int duration = (int)Math.Ceiling((changes[i].DateTime - changes[i + 1].DateTime).TotalMinutes); + if (duration > 100000) + { + inMinutes = false; + duration = (int)Math.Ceiling(duration / 1440.0); + } + long timestamp = ((DateTimeOffset)changes[i].DateTime).ToUnixTimeSeconds(); + + double price = changes[i + 1].EnergyPrice; + + total += (await prometheusQuerier.Query($$"""sum_over_time(sum(power{sensor=~"{{plugnames}}"})[{{duration}}{{(inMinutes ? "m" : "d")}}:1s] @ {{timestamp}}) * {{(price / 3600).ToString(CultureInfo.InvariantCulture)}}"""))?.Data?.VectorTypeToTimestampFloatTuple().ValueOrDefault.Item2 ?? 0; + if (changes[i].DateTime < endTime) + { + break; + } + } + + return total; + } + + public async void Update() + { + List plugs = mainDatabaseContext.Devices.ToList(); + List energyPriceChanges = mainDatabaseContext.EnergyPriceChanges.ToList(); + + DailyEnergyCost = await GetEnergyPrice(DateTime.Now, DateTime.Now.AddDays(-1), plugs, energyPriceChanges); + MonthlyEnergyCost = await GetEnergyPrice(DateTime.Now, DateTime.Now.AddMonths(-1), plugs, energyPriceChanges); + YearlyEnergyCost = await GetEnergyPrice(DateTime.Now, DateTime.Now.AddYears(-1), plugs, energyPriceChanges); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EventService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EventService.cs new file mode 100644 index 0000000..d886efb --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/EventService.cs @@ -0,0 +1,53 @@ +using Elektrifikatsiya.Models; + +using System.Reflection; + +namespace Elektrifikatsiya.Services.Implementations; + +public class EventService : IEventService +{ + public event EventHandler? OnEventCalled; + + public List Events { get; } = new(); + + public EventService(IDeviceStatusService deviceStatusService, IDeviceManagmentService deviceManagmentService) + { + List deviceList = deviceStatusService.GetDevices().ValueOrDefault.Select(x => x.CopyDevice()).ToList(); + + deviceStatusService.OnDeviceStatusChanged += (_, e) => + { + bool newDevice = true; + Device currentDevice = deviceManagmentService.GetDevice(e.MacAddress).ValueOrDefault; + + for (int i = 0; i < deviceList.Count; i++) + { + if (deviceList[i].MacAddress == currentDevice.MacAddress) + { + PropertyInfo[] properties = typeof(Device).GetProperties(); + + foreach (PropertyInfo property in properties) + { + object? currentvalue = property.GetValue(deviceList[i], null); + object? newvalue = property.GetValue(currentDevice, null); + if (newvalue is not null && !newvalue?.ToString()?.Equals(currentvalue?.ToString()) == true) + { + string eventName = $"Plug {deviceList[i].Name} changed: {property.Name}"; + string description = $"Changed {property.Name} of plug [{currentvalue}] to [{newvalue}]"; + DateTime dateTime = DateTime.Now; + + Event newEvent = new Event(eventName, description, dateTime, currentDevice); + Events.Insert(0, newEvent); + OnEventCalled?.Invoke(this, new EventServiceEventArgs(newEvent)); + } + } + deviceList[i].OverwiteDevice(currentDevice); + newDevice = false; break; + } + } + if (newDevice) + { + deviceList.Add(deviceManagmentService.GetDevice(e.MacAddress).ValueOrDefault); + } + }; + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/NotifcationService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/NotifcationService.cs new file mode 100644 index 0000000..42f5a78 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/NotifcationService.cs @@ -0,0 +1,39 @@ +using Elektrifikatsiya.Database; +using Elektrifikatsiya.Models; + +namespace Elektrifikatsiya.Services.Implementations; + +public class NotifcationService : INotificationService +{ + public TimeSpan ExecutionRepeatDelay => TimeSpan.FromDays(1); + public DateTime FirstExecutionTime => DateTime.Now.AddDays(0); + private readonly Microsoft.Extensions.Configuration.IConfiguration configuration; + private readonly IEmailService emailService; + private readonly IEnergyPriceService energyPriceService; + private readonly ILogger logger; + private readonly MainDatabaseContext mainDatabaseContext; + + public NotifcationService(ILogger logger, IEmailService emailService, IEnergyPriceService energyPriceService, Microsoft.Extensions.Configuration.IConfiguration configuration, IServiceScopeFactory serviceScopeFactory) + { + this.logger = logger; + this.emailService = emailService; + this.energyPriceService = energyPriceService; + this.configuration = configuration; + + mainDatabaseContext = serviceScopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + } + + public async void Update() + { + logger.LogInformation("Sending Email"); + + string url = configuration.GetValue("Url") ?? "https://http.cat/images/404.jpg"; + + await foreach (User user in mainDatabaseContext.Users) + { + await emailService.SendWithTemeplateAsync(user.Email, "Daily Notification", "DailyNotification", templateParameters: new() { { "yesterday", $"{energyPriceService.DailyEnergyCost:F2}" }, { "yearly", $"{energyPriceService.YearlyEnergyCost:F2}" }, { "link", url } }); + } + + logger.LogInformation("Done sending"); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/ServiceScheduler.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/ServiceScheduler.cs new file mode 100644 index 0000000..c430ade --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/ServiceScheduler.cs @@ -0,0 +1,61 @@ +using Elektrifikatsiya.Utilities; + +namespace Elektrifikatsiya.Services.Implementations; + +public class ServiceScheduler : IHostedService +{ + private readonly ILogger logger; + private readonly IEnumerable scheduledServices; + private readonly Dictionary lastExectued; + + private Timer timer = null!; + + public ServiceScheduler(ILogger logger, IServiceProvider serviceProvider) + { + this.logger = logger; + scheduledServices = serviceProvider.GetServices(); + lastExectued = scheduledServices.ToDictionary(x => x, x => x.FirstExecutionTime); + + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Starting service scheduler..."); + + timer = new Timer(async (_) => await Update(), null, TimeSpan.Zero, Timeout.InfiniteTimeSpan); + + logger.LogInformation("Service scheduler started."); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Stopping service scheduler."); + + timer.Dispose(); + + logger.LogInformation("Stopped service scheduler."); + + return Task.CompletedTask; + } + + private async Task Update() + { + TimeSpan s = lastExectued.Min(x => (x.Value + x.Key.ExecutionRepeatDelay)) - DateTime.Now; + + if (s < TimeSpan.Zero) + { + s = TimeSpan.FromSeconds(1); + } + + timer.Change(s, Timeout.InfiniteTimeSpan); + + foreach (IScheduledService scheduledService in scheduledServices.Where(s => s.FirstExecutionTime < DateTime.Now)) + { + if (lastExectued[scheduledService] + scheduledService.ExecutionRepeatDelay <= DateTime.Now) + { + lastExectued[scheduledService] = DateTime.Now; + scheduledService.Update(); + } + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/UpdateService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/UpdateService.cs new file mode 100644 index 0000000..e1073c3 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Services/Implementations/UpdateService.cs @@ -0,0 +1,55 @@ +using Elektrifikatsiya.Database; +using Elektrifikatsiya.Models; +using Elektrifikatsiya.Utilities; + +using FluentResults; + +using Microsoft.EntityFrameworkCore; + +namespace Elektrifikatsiya.Services.Implementations; + +public class UpdateService : IUpdateService +{ + private readonly ILogger logger; + private readonly IDeviceStatusService deviceStatusService; + + public UpdateService(ILogger logger, IDeviceStatusService deviceStatusService, IServiceScopeFactory serviceScopeFactory) + { + this.logger = logger; + this.deviceStatusService = deviceStatusService; + + IServiceScope serviceScope = serviceScopeFactory.CreateScope(); + MainDatabaseContext mainDatabaseContext = serviceScope.ServiceProvider.GetRequiredService(); + + foreach (Device device in mainDatabaseContext.Devices.Include(d => d.User).AsNoTracking().ToList()) + { + _ = deviceStatusService.TrackDevice(device); + } + } + + public DateTime FirstExecutionTime { get; } = DateTime.Now.AddSeconds(10); + public TimeSpan ExecutionRepeatDelay { get; } = TimeSpan.FromSeconds(1); + + public async void Update() + { + Result> getDeviceStatusResult = deviceStatusService.GetDevices(); + + if (getDeviceStatusResult.IsFailed) + { + logger.LogError("Updating devices failed!"); + } + PrometheusQuery promQueryer = new PrometheusQuery("http://localhost:9090"); + foreach (Device device in getDeviceStatusResult.Value) + { + PrometheusDataWrapper? devicePowerData = (await promQueryer.Query($"power{{sensor=\"shellyplug-s-{device.MacAddress}/relay/0\"}}"))?.Data; + PrometheusDataWrapper? deviceStatusData = (await promQueryer.Query($"state{{sensor=\"shellyplug-s-{device.MacAddress}/relay\"}}"))?.Data; + + if (devicePowerData is not null && deviceStatusData is not null) + { + device.PowerUsage = devicePowerData.VectorTypeToTimestampFloatTuple()?.ValueOrDefault.Item2 ?? 0; + device.Enabled = (deviceStatusData.VectorTypeToTimestampFloatTuple()?.ValueOrDefault.Item2 ?? 0) == 1; + _ = deviceStatusService.UpdateDeviceStatus(device); + } + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/EmailSettings.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/EmailSettings.cs new file mode 100644 index 0000000..46f1575 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/EmailSettings.cs @@ -0,0 +1,23 @@ +using HandlebarsDotNet; + +namespace Elektrifikatsiya.Utilities; + +public class EmailSettings +{ + public string User { get; set; } + public string DefaultEmail { get; set; } + public string Key { get; set; } + public Dictionary Templates { get; set; } + public string SmtpServer { get; set; } + public int SmtpPort { get; set; } + + public Dictionary> CompiledTemplates { get; } = new(); + + public void CompileTemplates() + { + foreach ((string templateName, string templatePath) in Templates) + { + CompiledTemplates.Add(templateName, Handlebars.Compile(File.ReadAllText(templatePath))); + } + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/IScheduledService.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/IScheduledService.cs new file mode 100644 index 0000000..7c4e0f7 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/IScheduledService.cs @@ -0,0 +1,9 @@ +namespace Elektrifikatsiya.Utilities; + +public interface IScheduledService +{ + DateTime FirstExecutionTime { get; } + TimeSpan ExecutionRepeatDelay { get; } + + void Update(); +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/MdnsDiscovery.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/MdnsDiscovery.cs new file mode 100644 index 0000000..813c50c --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/MdnsDiscovery.cs @@ -0,0 +1,68 @@ +using Elektrifikatsiya.Models; + +using Microsoft.Extensions.Caching.Memory; + +using System.Net; + +using Tmds.MDns; + +namespace Elektrifikatsiya.Utilities; + +public static class MdnsDiscovery +{ + public static event Action OnDeviceFound = null!; + + private static readonly ServiceBrowser serviceBrowser = new ServiceBrowser(); + private static readonly HttpClient client = new(); + private static readonly MemoryCache memoryCache = new MemoryCache(new MemoryCacheOptions()); + + static MdnsDiscovery() + { + serviceBrowser.StartBrowse("_http._tcp"); + serviceBrowser.ServiceAdded += async (_, eventArgs) => await AddDevice(eventArgs.Announcement); + serviceBrowser.ServiceChanged += async (_, eventArgs) => await AddDevice(eventArgs.Announcement); + serviceBrowser.ServiceRemoved += async (_, eventArgs) => await AddDevice(eventArgs.Announcement); + } + + public static void FetchChachedDevices() + { + serviceBrowser.Services.ToList().ForEach(async d => await AddDevice(d)); + } + + private static async Task AddDevice(ServiceAnnouncement announcement) + { + IPAddress ipAddress = announcement.Addresses.First(); + + if (!memoryCache.TryGetValue(ipAddress.ToString(), out bool isShellyDevice)) + { + isShellyDevice = await IsShellyDevice(ipAddress); + _ = memoryCache.Set(ipAddress.ToString(), isShellyDevice, TimeSpan.FromHours(1)); + } + + if (isShellyDevice) + { + OnDeviceFound?.Invoke(ipAddress); + } + } + + private static async Task IsShellyDevice(IPAddress ipAddress) + { + ShellyResponse? shellyResponse; + + try + { + shellyResponse = await client.GetFromJsonAsync($"http://{ipAddress}/shelly"); + } + catch (HttpRequestException) + { + return false; + } + + if (shellyResponse is null || shellyResponse.Type != "SHPLG-S") + { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/PrometheusQuery.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/PrometheusQuery.cs new file mode 100644 index 0000000..975cd16 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/PrometheusQuery.cs @@ -0,0 +1,31 @@ +using Elektrifikatsiya.Models; + +using System.Diagnostics; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elektrifikatsiya.Utilities; + +public class PrometheusQuery +{ + private readonly HttpClient client = new(); + + public PrometheusQuery(string connectionString) + { + client.BaseAddress = new Uri(connectionString); + } + + public Task Query(string query) + { + Debug.WriteLine($"{client.BaseAddress}/api/v1/query?query={query}"); + return client.GetFromJsonAsync($"/api/v1/query?query={UrlEncoder.Create().Encode(query)}", new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true, + Converters = + { + new JsonStringEnumConverter() + } + }); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/SecureStringGenerator.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/SecureStringGenerator.cs new file mode 100644 index 0000000..a37cb29 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/SecureStringGenerator.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; + +namespace Elektrifikatsiya.Utilities; + +public static class SecureStringGenerator +{ + public static string CreateCryptographicRandomString(int count) + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(count)); + } + + public static string CreateCryptographicRandomString(int count, int uid) + { + List bytes = RandomNumberGenerator.GetBytes(count).ToList(); + + bytes.AddRange(BitConverter.GetBytes(uid)); + + return Convert.ToBase64String(bytes.ToArray()); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/TokenGenerator.cs b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/TokenGenerator.cs new file mode 100644 index 0000000..17e6a02 --- /dev/null +++ b/src/Elektrifikatsiya/Elektrifikatsiya/Utilities/TokenGenerator.cs @@ -0,0 +1,32 @@ +namespace Elektrifikatsiya.Utilities; + +public static class TokenGenerator +{ + /// + /// Creates secure token and uses an UID to make it unique. + /// + /// The type of the token. + /// The UID of a user. + /// The length of the token. + /// + public static string GenerateToken(string tokenType, int uid = 0, int length = 128) + { + return tokenType + "-" + SecureStringGenerator.CreateCryptographicRandomString(length, uid); + } + + /// + /// Checks if a token is valid. + /// + /// The token to validate. + /// The type the token should have. + /// + public static bool ValidateToken(string? token, string tokenType) + { + if (token is null) + { + return false; + } + + return token.StartsWith(tokenType + "-"); + } +} \ No newline at end of file diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/_Imports.razor b/src/Elektrifikatsiya/Elektrifikatsiya/_Imports.razor index 7fc555e..9dc4cde 100644 --- a/src/Elektrifikatsiya/Elektrifikatsiya/_Imports.razor +++ b/src/Elektrifikatsiya/Elektrifikatsiya/_Imports.razor @@ -9,3 +9,5 @@ @using Elektrifikatsiya @using Blazorise +@using Blazorise.Charts +@using Blazorise.LoadingIndicator diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/appsettings.Development.json b/src/Elektrifikatsiya/Elektrifikatsiya/appsettings.Development.json deleted file mode 100644 index 770d3e9..0000000 --- a/src/Elektrifikatsiya/Elektrifikatsiya/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "DetailedErrors": true, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/Pictures/logo.png b/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/Pictures/logo.png new file mode 100644 index 0000000..1904fe3 Binary files /dev/null and b/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/Pictures/logo.png differ diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/brand-logo.png b/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/brand-logo.png deleted file mode 100644 index 3746003..0000000 Binary files a/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/brand-logo.png and /dev/null differ diff --git a/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/logo.png b/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/logo.png new file mode 100644 index 0000000..413ea80 Binary files /dev/null and b/src/Elektrifikatsiya/Elektrifikatsiya/wwwroot/logo.png differ diff --git a/src/Elektrifikatsiya/assets/DailyNotif.html b/src/Elektrifikatsiya/assets/DailyNotif.html new file mode 100644 index 0000000..ab13c0d --- /dev/null +++ b/src/Elektrifikatsiya/assets/DailyNotif.html @@ -0,0 +1,26 @@ +
Daily Notification
Energy Consumption yesterday: {{yesterday}}
Energy Consumption since the start of the year: {{yearly}}
\ No newline at end of file diff --git a/src/Elektrifikatsiya/assets/DailyNotif.mjml b/src/Elektrifikatsiya/assets/DailyNotif.mjml new file mode 100644 index 0000000..a4cfbb6 --- /dev/null +++ b/src/Elektrifikatsiya/assets/DailyNotif.mjml @@ -0,0 +1,49 @@ + + + + + + + @font-face { + font-family: Nunito; + src: url(https://fonts.gstatic.com/s/nunito/v16/XRXV3I6Li01BKofINeaB.woff2) + format('truetype'); + } + + + + + + + + + + Daily Notification + + + + + + + Energy Consumption yesterday: {{yesterday}} + + + + + Energy Consumption since the start of the year: {{yearly}} + + + + + + + + Goto Dashboard + + + + + + + + \ No newline at end of file diff --git a/src/Elektrifikatsiya/docker-compose.dcproj b/src/Elektrifikatsiya/docker-compose.dcproj new file mode 100644 index 0000000..8901d12 --- /dev/null +++ b/src/Elektrifikatsiya/docker-compose.dcproj @@ -0,0 +1,15 @@ + + + + 2.1 + Linux + 7350c55e-0ead-47de-a1e0-2a2ea3291d0b + LaunchBrowser + {Scheme}://localhost:{ServicePort} + elektrifikatsiya + + + + + + \ No newline at end of file diff --git a/src/Elektrifikatsiya/docker-compose.yml b/src/Elektrifikatsiya/docker-compose.yml new file mode 100644 index 0000000..70891b2 --- /dev/null +++ b/src/Elektrifikatsiya/docker-compose.yml @@ -0,0 +1,38 @@ +version: '3.4' + +services: + #elektrifikatsiya: + # build: + # context: . + # dockerfile: Elektrifikatsiya/Dockerfile + # ports: + # - target: 5353 + # published: 5353 + # protocol: udp + # mode: host + prometheus: + image: prom/prometheus:latest + volumes: + - ./host/prometheus/:/etc/prometheus/ + - prometheus_data:/prometheus + ports: + - 9090:9090 + mqtt2prom: + image: ghcr.io/hikhvar/mqtt2prometheus:latest + volumes: + - ./host/mqtt/mqtt2prom/config.yaml:/config.yaml + ports: + - 9641:9641 + mosquitto: + image: eclipse-mosquitto:2 + volumes: + - ./host/mqtt/mosquitto/config/:/mosquitto/config/:ro + - ./host/log/mqtt/mosquitto/:/mosquitto/log/ + - mosquitto_data:/mosquitto/data/ + ports: + - 1883:1883 + - 9001:9001 + +volumes: + mosquitto_data: + prometheus_data: \ No newline at end of file diff --git a/src/Elektrifikatsiya/host/mqtt/mosquitto/config/mosquitto.conf b/src/Elektrifikatsiya/host/mqtt/mosquitto/config/mosquitto.conf new file mode 100644 index 0000000..9ddba6a --- /dev/null +++ b/src/Elektrifikatsiya/host/mqtt/mosquitto/config/mosquitto.conf @@ -0,0 +1,904 @@ +# Config file for mosquitto +# +# See mosquitto.conf(5) for more information. +# +# Default values are shown, uncomment to change. +# +# Use the # character to indicate a comment, but only if it is the +# very first character on the line. + +# ================================================================= +# General configuration +# ================================================================= + +# Use per listener security settings. +# +# It is recommended this option be set before any other options. +# +# If this option is set to true, then all authentication and access control +# options are controlled on a per listener basis. The following options are +# affected: +# +# acl_file +# allow_anonymous +# allow_zero_length_clientid +# auto_id_prefix +# password_file +# plugin +# plugin_opt_* +# psk_file +# +# Note that if set to true, then a durable client (i.e. with clean session set +# to false) that has disconnected will use the ACL settings defined for the +# listener that it was most recently connected to. +# +# The default behaviour is for this to be set to false, which maintains the +# setting behaviour from previous versions of mosquitto. +#per_listener_settings false + + +# This option controls whether a client is allowed to connect with a zero +# length client id or not. This option only affects clients using MQTT v3.1.1 +# and later. If set to false, clients connecting with a zero length client id +# are disconnected. If set to true, clients will be allocated a client id by +# the broker. This means it is only useful for clients with clean session set +# to true. +#allow_zero_length_clientid true + +# If allow_zero_length_clientid is true, this option allows you to set a prefix +# to automatically generated client ids to aid visibility in logs. +# Defaults to 'auto-' +#auto_id_prefix auto- + +# This option affects the scenario when a client subscribes to a topic that has +# retained messages. It is possible that the client that published the retained +# message to the topic had access at the time they published, but that access +# has been subsequently removed. If check_retain_source is set to true, the +# default, the source of a retained message will be checked for access rights +# before it is republished. When set to false, no check will be made and the +# retained message will always be published. This affects all listeners. +#check_retain_source true + +# QoS 1 and 2 messages will be allowed inflight per client until this limit +# is exceeded. Defaults to 0. (No maximum) +# See also max_inflight_messages +#max_inflight_bytes 0 + +# The maximum number of QoS 1 and 2 messages currently inflight per +# client. +# This includes messages that are partway through handshakes and +# those that are being retried. Defaults to 20. Set to 0 for no +# maximum. Setting to 1 will guarantee in-order delivery of QoS 1 +# and 2 messages. +#max_inflight_messages 20 + +# For MQTT v5 clients, it is possible to have the server send a "server +# keepalive" value that will override the keepalive value set by the client. +# This is intended to be used as a mechanism to say that the server will +# disconnect the client earlier than it anticipated, and that the client should +# use the new keepalive value. The max_keepalive option allows you to specify +# that clients may only connect with keepalive less than or equal to this +# value, otherwise they will be sent a server keepalive telling them to use +# max_keepalive. This only applies to MQTT v5 clients. The default, and maximum +# value allowable, is 65535. +# +# Set to 0 to allow clients to set keepalive = 0, which means no keepalive +# checks are made and the client will never be disconnected by the broker if no +# messages are received. You should be very sure this is the behaviour that you +# want. +# +# For MQTT v3.1.1 and v3.1 clients, there is no mechanism to tell the client +# what keepalive value they should use. If an MQTT v3.1.1 or v3.1 client +# specifies a keepalive time greater than max_keepalive they will be sent a +# CONNACK message with the "identifier rejected" reason code, and disconnected. +# +#max_keepalive 65535 + +# For MQTT v5 clients, it is possible to have the server send a "maximum packet +# size" value that will instruct the client it will not accept MQTT packets +# with size greater than max_packet_size bytes. This applies to the full MQTT +# packet, not just the payload. Setting this option to a positive value will +# set the maximum packet size to that number of bytes. If a client sends a +# packet which is larger than this value, it will be disconnected. This applies +# to all clients regardless of the protocol version they are using, but v3.1.1 +# and earlier clients will of course not have received the maximum packet size +# information. Defaults to no limit. Setting below 20 bytes is forbidden +# because it is likely to interfere with ordinary client operation, even with +# very small payloads. +#max_packet_size 0 + +# QoS 1 and 2 messages above those currently in-flight will be queued per +# client until this limit is exceeded. Defaults to 0. (No maximum) +# See also max_queued_messages. +# If both max_queued_messages and max_queued_bytes are specified, packets will +# be queued until the first limit is reached. +#max_queued_bytes 0 + +# Set the maximum QoS supported. Clients publishing at a QoS higher than +# specified here will be disconnected. +#max_qos 2 + +# The maximum number of QoS 1 and 2 messages to hold in a queue per client +# above those that are currently in-flight. Defaults to 1000. Set +# to 0 for no maximum (not recommended). +# See also queue_qos0_messages. +# See also max_queued_bytes. +#max_queued_messages 1000 +# +# This option sets the maximum number of heap memory bytes that the broker will +# allocate, and hence sets a hard limit on memory use by the broker. Memory +# requests that exceed this value will be denied. The effect will vary +# depending on what has been denied. If an incoming message is being processed, +# then the message will be dropped and the publishing client will be +# disconnected. If an outgoing message is being sent, then the individual +# message will be dropped and the receiving client will be disconnected. +# Defaults to no limit. +#memory_limit 0 + +# This option sets the maximum publish payload size that the broker will allow. +# Received messages that exceed this size will not be accepted by the broker. +# The default value is 0, which means that all valid MQTT messages are +# accepted. MQTT imposes a maximum payload size of 268435455 bytes. +#message_size_limit 0 + +# This option allows the session of persistent clients (those with clean +# session set to false) that are not currently connected to be removed if they +# do not reconnect within a certain time frame. This is a non-standard option +# in MQTT v3.1. MQTT v3.1.1 and v5.0 allow brokers to remove client sessions. +# +# Badly designed clients may set clean session to false whilst using a randomly +# generated client id. This leads to persistent clients that connect once and +# never reconnect. This option allows these clients to be removed. This option +# allows persistent clients (those with clean session set to false) to be +# removed if they do not reconnect within a certain time frame. +# +# The expiration period should be an integer followed by one of h d w m y for +# hour, day, week, month and year respectively. For example +# +# persistent_client_expiration 2m +# persistent_client_expiration 14d +# persistent_client_expiration 1y +# +# The default if not set is to never expire persistent clients. +#persistent_client_expiration + +# Write process id to a file. Default is a blank string which means +# a pid file shouldn't be written. +# This should be set to /var/run/mosquitto/mosquitto.pid if mosquitto is +# being run automatically on boot with an init script and +# start-stop-daemon or similar. +#pid_file + +# Set to true to queue messages with QoS 0 when a persistent client is +# disconnected. These messages are included in the limit imposed by +# max_queued_messages and max_queued_bytes +# Defaults to false. +# This is a non-standard option for the MQTT v3.1 spec but is allowed in +# v3.1.1. +#queue_qos0_messages false + +# Set to false to disable retained message support. If a client publishes a +# message with the retain bit set, it will be disconnected if this is set to +# false. +#retain_available true + +# Disable Nagle's algorithm on client sockets. This has the effect of reducing +# latency of individual messages at the potential cost of increasing the number +# of packets being sent. +#set_tcp_nodelay false + +# Time in seconds between updates of the $SYS tree. +# Set to 0 to disable the publishing of the $SYS tree. +#sys_interval 10 + +# The MQTT specification requires that the QoS of a message delivered to a +# subscriber is never upgraded to match the QoS of the subscription. Enabling +# this option changes this behaviour. If upgrade_outgoing_qos is set true, +# messages sent to a subscriber will always match the QoS of its subscription. +# This is a non-standard option explicitly disallowed by the spec. +#upgrade_outgoing_qos false + +# When run as root, drop privileges to this user and its primary +# group. +# Set to root to stay as root, but this is not recommended. +# If set to "mosquitto", or left unset, and the "mosquitto" user does not exist +# then it will drop privileges to the "nobody" user instead. +# If run as a non-root user, this setting has no effect. +# Note that on Windows this has no effect and so mosquitto should be started by +# the user you wish it to run as. +#user mosquitto + +# ================================================================= +# Listeners +# ================================================================= + +# Listen on a port/ip address combination. By using this variable +# multiple times, mosquitto can listen on more than one port. If +# this variable is used and neither bind_address nor port given, +# then the default listener will not be started. +# The port number to listen on must be given. Optionally, an ip +# address or host name may be supplied as a second argument. In +# this case, mosquitto will attempt to bind the listener to that +# address and so restrict access to the associated network and +# interface. By default, mosquitto will listen on all interfaces. +# Note that for a websockets listener it is not possible to bind to a host +# name. +# +# On systems that support Unix Domain Sockets, it is also possible +# to create a # Unix socket rather than opening a TCP socket. In +# this case, the port number should be set to 0 and a unix socket +# path must be provided, e.g. +# listener 0 /tmp/mosquitto.sock +# +# listener port-number [ip address/host name/unix socket path] +listener 1883 + +# By default, a listener will attempt to listen on all supported IP protocol +# versions. If you do not have an IPv4 or IPv6 interface you may wish to +# disable support for either of those protocol versions. In particular, note +# that due to the limitations of the websockets library, it will only ever +# attempt to open IPv6 sockets if IPv6 support is compiled in, and so will fail +# if IPv6 is not available. +# +# Set to `ipv4` to force the listener to only use IPv4, or set to `ipv6` to +# force the listener to only use IPv6. If you want support for both IPv4 and +# IPv6, then do not use the socket_domain option. +# +#socket_domain + +# Bind the listener to a specific interface. This is similar to +# the [ip address/host name] part of the listener definition, but is useful +# when an interface has multiple addresses or the address may change. If used +# with the [ip address/host name] part of the listener definition, then the +# bind_interface option will take priority. +# Not available on Windows. +# +# Example: bind_interface eth0 +#bind_interface + +# When a listener is using the websockets protocol, it is possible to serve +# http data as well. Set http_dir to a directory which contains the files you +# wish to serve. If this option is not specified, then no normal http +# connections will be possible. +#http_dir + +# The maximum number of client connections to allow. This is +# a per listener setting. +# Default is -1, which means unlimited connections. +# Note that other process limits mean that unlimited connections +# are not really possible. Typically the default maximum number of +# connections possible is around 1024. +#max_connections -1 + +# The listener can be restricted to operating within a topic hierarchy using +# the mount_point option. This is achieved be prefixing the mount_point string +# to all topics for any clients connected to this listener. This prefixing only +# happens internally to the broker; the client will not see the prefix. +#mount_point + +# Choose the protocol to use when listening. +# This can be either mqtt or websockets. +# Certificate based TLS may be used with websockets, except that only the +# cafile, certfile, keyfile, ciphers, and ciphers_tls13 options are supported. +#protocol mqtt + +# Set use_username_as_clientid to true to replace the clientid that a client +# connected with with its username. This allows authentication to be tied to +# the clientid, which means that it is possible to prevent one client +# disconnecting another by using the same clientid. +# If a client connects with no username it will be disconnected as not +# authorised when this option is set to true. +# Do not use in conjunction with clientid_prefixes. +# See also use_identity_as_username. +# This does not apply globally, but on a per-listener basis. +#use_username_as_clientid + +# Change the websockets headers size. This is a global option, it is not +# possible to set per listener. This option sets the size of the buffer used in +# the libwebsockets library when reading HTTP headers. If you are passing large +# header data such as cookies then you may need to increase this value. If left +# unset, or set to 0, then the default of 1024 bytes will be used. +#websockets_headers_size + +# ----------------------------------------------------------------- +# Certificate based SSL/TLS support +# ----------------------------------------------------------------- +# The following options can be used to enable certificate based SSL/TLS support +# for this listener. Note that the recommended port for MQTT over TLS is 8883, +# but this must be set manually. +# +# See also the mosquitto-tls man page and the "Pre-shared-key based SSL/TLS +# support" section. Only one of certificate or PSK encryption support can be +# enabled for any listener. + +# Both of certfile and keyfile must be defined to enable certificate based +# TLS encryption. + +# Path to the PEM encoded server certificate. +#certfile + +# Path to the PEM encoded keyfile. +#keyfile + +# If you wish to control which encryption ciphers are used, use the ciphers +# option. The list of available ciphers can be optained using the "openssl +# ciphers" command and should be provided in the same format as the output of +# that command. This applies to TLS 1.2 and earlier versions only. Use +# ciphers_tls1.3 for TLS v1.3. +#ciphers + +# Choose which TLS v1.3 ciphersuites are used for this listener. +# Defaults to "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256" +#ciphers_tls1.3 + +# If you have require_certificate set to true, you can create a certificate +# revocation list file to revoke access to particular client certificates. If +# you have done this, use crlfile to point to the PEM encoded revocation file. +#crlfile + +# To allow the use of ephemeral DH key exchange, which provides forward +# security, the listener must load DH parameters. This can be specified with +# the dhparamfile option. The dhparamfile can be generated with the command +# e.g. "openssl dhparam -out dhparam.pem 2048" +#dhparamfile + +# By default an TLS enabled listener will operate in a similar fashion to a +# https enabled web server, in that the server has a certificate signed by a CA +# and the client will verify that it is a trusted certificate. The overall aim +# is encryption of the network traffic. By setting require_certificate to true, +# the client must provide a valid certificate in order for the network +# connection to proceed. This allows access to the broker to be controlled +# outside of the mechanisms provided by MQTT. +#require_certificate false + +# cafile and capath define methods of accessing the PEM encoded +# Certificate Authority certificates that will be considered trusted when +# checking incoming client certificates. +# cafile defines the path to a file containing the CA certificates. +# capath defines a directory that will be searched for files +# containing the CA certificates. For capath to work correctly, the +# certificate files must have ".crt" as the file ending and you must run +# "openssl rehash " each time you add/remove a certificate. +#cafile +#capath + + +# If require_certificate is true, you may set use_identity_as_username to true +# to use the CN value from the client certificate as a username. If this is +# true, the password_file option will not be used for this listener. +#use_identity_as_username false + +# ----------------------------------------------------------------- +# Pre-shared-key based SSL/TLS support +# ----------------------------------------------------------------- +# The following options can be used to enable PSK based SSL/TLS support for +# this listener. Note that the recommended port for MQTT over TLS is 8883, but +# this must be set manually. +# +# See also the mosquitto-tls man page and the "Certificate based SSL/TLS +# support" section. Only one of certificate or PSK encryption support can be +# enabled for any listener. + +# The psk_hint option enables pre-shared-key support for this listener and also +# acts as an identifier for this listener. The hint is sent to clients and may +# be used locally to aid authentication. The hint is a free form string that +# doesn't have much meaning in itself, so feel free to be creative. +# If this option is provided, see psk_file to define the pre-shared keys to be +# used or create a security plugin to handle them. +#psk_hint + +# When using PSK, the encryption ciphers used will be chosen from the list of +# available PSK ciphers. If you want to control which ciphers are available, +# use the "ciphers" option. The list of available ciphers can be optained +# using the "openssl ciphers" command and should be provided in the same format +# as the output of that command. +#ciphers + +# Set use_identity_as_username to have the psk identity sent by the client used +# as its username. Authentication will be carried out using the PSK rather than +# the MQTT username/password and so password_file will not be used for this +# listener. +#use_identity_as_username false + + +# ================================================================= +# Persistence +# ================================================================= + +# If persistence is enabled, save the in-memory database to disk +# every autosave_interval seconds. If set to 0, the persistence +# database will only be written when mosquitto exits. See also +# autosave_on_changes. +# Note that writing of the persistence database can be forced by +# sending mosquitto a SIGUSR1 signal. +#autosave_interval 1800 + +# If true, mosquitto will count the number of subscription changes, retained +# messages received and queued messages and if the total exceeds +# autosave_interval then the in-memory database will be saved to disk. +# If false, mosquitto will save the in-memory database to disk by treating +# autosave_interval as a time in seconds. +#autosave_on_changes false + +# Save persistent message data to disk (true/false). +# This saves information about all messages, including +# subscriptions, currently in-flight messages and retained +# messages. +# retained_persistence is a synonym for this option. +#persistence false + +# The filename to use for the persistent database, not including +# the path. +#persistence_file mosquitto.db + +# Location for persistent database. +# Default is an empty string (current directory). +# Set to e.g. /var/lib/mosquitto if running as a proper service on Linux or +# similar. +#persistence_location + + +# ================================================================= +# Logging +# ================================================================= + +# Places to log to. Use multiple log_dest lines for multiple +# logging destinations. +# Possible destinations are: stdout stderr syslog topic file dlt +# +# stdout and stderr log to the console on the named output. +# +# syslog uses the userspace syslog facility which usually ends up +# in /var/log/messages or similar. +# +# topic logs to the broker topic '$SYS/broker/log/', +# where severity is one of D, E, W, N, I, M which are debug, error, +# warning, notice, information and message. Message type severity is used by +# the subscribe/unsubscribe log_types and publishes log messages to +# $SYS/broker/log/M/susbcribe or $SYS/broker/log/M/unsubscribe. +# +# The file destination requires an additional parameter which is the file to be +# logged to, e.g. "log_dest file /var/log/mosquitto.log". The file will be +# closed and reopened when the broker receives a HUP signal. Only a single file +# destination may be configured. +# +# The dlt destination is for the automotive `Diagnostic Log and Trace` tool. +# This requires that Mosquitto has been compiled with DLT support. +# +# Note that if the broker is running as a Windows service it will default to +# "log_dest none" and neither stdout nor stderr logging is available. +# Use "log_dest none" if you wish to disable logging. +#log_dest stderr + +# Types of messages to log. Use multiple log_type lines for logging +# multiple types of messages. +# Possible types are: debug, error, warning, notice, information, +# none, subscribe, unsubscribe, websockets, all. +# Note that debug type messages are for decoding the incoming/outgoing +# network packets. They are not logged in "topics". +#log_type error +#log_type warning +#log_type notice +#log_type information + + +# If set to true, client connection and disconnection messages will be included +# in the log. +#connection_messages true + +# If using syslog logging (not on Windows), messages will be logged to the +# "daemon" facility by default. Use the log_facility option to choose which of +# local0 to local7 to log to instead. The option value should be an integer +# value, e.g. "log_facility 5" to use local5. +#log_facility + +# If set to true, add a timestamp value to each log message. +#log_timestamp true + +# Set the format of the log timestamp. If left unset, this is the number of +# seconds since the Unix epoch. +# This is a free text string which will be passed to the strftime function. To +# get an ISO 8601 datetime, for example: +# log_timestamp_format %Y-%m-%dT%H:%M:%S +#log_timestamp_format + +# Change the websockets logging level. This is a global option, it is not +# possible to set per listener. This is an integer that is interpreted by +# libwebsockets as a bit mask for its lws_log_levels enum. See the +# libwebsockets documentation for more details. "log_type websockets" must also +# be enabled. +#websockets_log_level 0 + + +# ================================================================= +# Security +# ================================================================= + +# If set, only clients that have a matching prefix on their +# clientid will be allowed to connect to the broker. By default, +# all clients may connect. +# For example, setting "secure-" here would mean a client "secure- +# client" could connect but another with clientid "mqtt" couldn't. +#clientid_prefixes + +# Boolean value that determines whether clients that connect +# without providing a username are allowed to connect. If set to +# false then a password file should be created (see the +# password_file option) to control authenticated client access. +# +# Defaults to false, unless there are no listeners defined in the configuration +# file, in which case it is set to true, but connections are only allowed from +# the local machine. +allow_anonymous true + +# ----------------------------------------------------------------- +# Default authentication and topic access control +# ----------------------------------------------------------------- + +# Control access to the broker using a password file. This file can be +# generated using the mosquitto_passwd utility. If TLS support is not compiled +# into mosquitto (it is recommended that TLS support should be included) then +# plain text passwords are used, in which case the file should be a text file +# with lines in the format: +# username:password +# The password (and colon) may be omitted if desired, although this +# offers very little in the way of security. +# +# See the TLS client require_certificate and use_identity_as_username options +# for alternative authentication options. If a plugin is used as well as +# password_file, the plugin check will be made first. +#password_file + +# Access may also be controlled using a pre-shared-key file. This requires +# TLS-PSK support and a listener configured to use it. The file should be text +# lines in the format: +# identity:key +# The key should be in hexadecimal format without a leading "0x". +# If an plugin is used as well, the plugin check will be made first. +#psk_file + +# Control access to topics on the broker using an access control list +# file. If this parameter is defined then only the topics listed will +# have access. +# If the first character of a line of the ACL file is a # it is treated as a +# comment. +# Topic access is added with lines of the format: +# +# topic [read|write|readwrite|deny] +# +# The access type is controlled using "read", "write", "readwrite" or "deny". +# This parameter is optional (unless contains a space character) - if +# not given then the access is read/write. can contain the + or # +# wildcards as in subscriptions. +# +# The "deny" option can used to explicity deny access to a topic that would +# otherwise be granted by a broader read/write/readwrite statement. Any "deny" +# topics are handled before topics that grant read/write access. +# +# The first set of topics are applied to anonymous clients, assuming +# allow_anonymous is true. User specific topic ACLs are added after a +# user line as follows: +# +# user +# +# The username referred to here is the same as in password_file. It is +# not the clientid. +# +# +# If is also possible to define ACLs based on pattern substitution within the +# topic. The patterns available for substition are: +# +# %c to match the client id of the client +# %u to match the username of the client +# +# The substitution pattern must be the only text for that level of hierarchy. +# +# The form is the same as for the topic keyword, but using pattern as the +# keyword. +# Pattern ACLs apply to all users even if the "user" keyword has previously +# been given. +# +# If using bridges with usernames and ACLs, connection messages can be allowed +# with the following pattern: +# pattern write $SYS/broker/connection/%c/state +# +# pattern [read|write|readwrite] +# +# Example: +# +# pattern write sensor/%u/data +# +# If an plugin is used as well as acl_file, the plugin check will be +# made first. +#acl_file + +# ----------------------------------------------------------------- +# External authentication and topic access plugin options +# ----------------------------------------------------------------- + +# External authentication and access control can be supported with the +# plugin option. This is a path to a loadable plugin. See also the +# plugin_opt_* options described below. +# +# The plugin option can be specified multiple times to load multiple +# plugins. The plugins will be processed in the order that they are specified +# here. If the plugin option is specified alongside either of +# password_file or acl_file then the plugin checks will be made first. +# +# If the per_listener_settings option is false, the plugin will be apply to all +# listeners. If per_listener_settings is true, then the plugin will apply to +# the current listener being defined only. +# +# This option is also available as `auth_plugin`, but this use is deprecated +# and will be removed in the future. +# +#plugin + +# If the plugin option above is used, define options to pass to the +# plugin here as described by the plugin instructions. All options named +# using the format plugin_opt_* will be passed to the plugin, for example: +# +# This option is also available as `auth_opt_*`, but this use is deprecated +# and will be removed in the future. +# +# plugin_opt_db_host +# plugin_opt_db_port +# plugin_opt_db_username +# plugin_opt_db_password + + +# ================================================================= +# Bridges +# ================================================================= + +# A bridge is a way of connecting multiple MQTT brokers together. +# Create a new bridge using the "connection" option as described below. Set +# options for the bridges using the remaining parameters. You must specify the +# address and at least one topic to subscribe to. +# +# Each connection must have a unique name. +# +# The address line may have multiple host address and ports specified. See +# below in the round_robin description for more details on bridge behaviour if +# multiple addresses are used. Note that if you use an IPv6 address, then you +# are required to specify a port. +# +# The direction that the topic will be shared can be chosen by +# specifying out, in or both, where the default value is out. +# The QoS level of the bridged communication can be specified with the next +# topic option. The default QoS level is 0, to change the QoS the topic +# direction must also be given. +# +# The local and remote prefix options allow a topic to be remapped when it is +# bridged to/from the remote broker. This provides the ability to place a topic +# tree in an appropriate location. +# +# For more details see the mosquitto.conf man page. +# +# Multiple topics can be specified per connection, but be careful +# not to create any loops. +# +# If you are using bridges with cleansession set to false (the default), then +# you may get unexpected behaviour from incoming topics if you change what +# topics you are subscribing to. This is because the remote broker keeps the +# subscription for the old topic. If you have this problem, connect your bridge +# with cleansession set to true, then reconnect with cleansession set to false +# as normal. +#connection +#address [:] [[:]] +#topic [[[out | in | both] qos-level] local-prefix remote-prefix] + +# If you need to have the bridge connect over a particular network interface, +# use bridge_bind_address to tell the bridge which local IP address the socket +# should bind to, e.g. `bridge_bind_address 192.168.1.10` +#bridge_bind_address + +# If a bridge has topics that have "out" direction, the default behaviour is to +# send an unsubscribe request to the remote broker on that topic. This means +# that changing a topic direction from "in" to "out" will not keep receiving +# incoming messages. Sending these unsubscribe requests is not always +# desirable, setting bridge_attempt_unsubscribe to false will disable sending +# the unsubscribe request. +#bridge_attempt_unsubscribe true + +# Set the version of the MQTT protocol to use with for this bridge. Can be one +# of mqttv50, mqttv311 or mqttv31. Defaults to mqttv311. +#bridge_protocol_version mqttv311 + +# Set the clean session variable for this bridge. +# When set to true, when the bridge disconnects for any reason, all +# messages and subscriptions will be cleaned up on the remote +# broker. Note that with cleansession set to true, there may be a +# significant amount of retained messages sent when the bridge +# reconnects after losing its connection. +# When set to false, the subscriptions and messages are kept on the +# remote broker, and delivered when the bridge reconnects. +#cleansession false + +# Set the amount of time a bridge using the lazy start type must be idle before +# it will be stopped. Defaults to 60 seconds. +#idle_timeout 60 + +# Set the keepalive interval for this bridge connection, in +# seconds. +#keepalive_interval 60 + +# Set the clientid to use on the local broker. If not defined, this defaults to +# 'local.'. If you are bridging a broker to itself, it is important +# that local_clientid and clientid do not match. +#local_clientid + +# If set to true, publish notification messages to the local and remote brokers +# giving information about the state of the bridge connection. Retained +# messages are published to the topic $SYS/broker/connection//state +# unless the notification_topic option is used. +# If the message is 1 then the connection is active, or 0 if the connection has +# failed. +# This uses the last will and testament feature. +#notifications true + +# Choose the topic on which notification messages for this bridge are +# published. If not set, messages are published on the topic +# $SYS/broker/connection//state +#notification_topic + +# Set the client id to use on the remote end of this bridge connection. If not +# defined, this defaults to 'name.hostname' where name is the connection name +# and hostname is the hostname of this computer. +# This replaces the old "clientid" option to avoid confusion. "clientid" +# remains valid for the time being. +#remote_clientid + +# Set the password to use when connecting to a broker that requires +# authentication. This option is only used if remote_username is also set. +# This replaces the old "password" option to avoid confusion. "password" +# remains valid for the time being. +#remote_password + +# Set the username to use when connecting to a broker that requires +# authentication. +# This replaces the old "username" option to avoid confusion. "username" +# remains valid for the time being. +#remote_username + +# Set the amount of time a bridge using the automatic start type will wait +# until attempting to reconnect. +# This option can be configured to use a constant delay time in seconds, or to +# use a backoff mechanism based on "Decorrelated Jitter", which adds a degree +# of randomness to when the restart occurs. +# +# Set a constant timeout of 20 seconds: +# restart_timeout 20 +# +# Set backoff with a base (start value) of 10 seconds and a cap (upper limit) of +# 60 seconds: +# restart_timeout 10 30 +# +# Defaults to jitter with a base of 5 and cap of 30 +#restart_timeout 5 30 + +# If the bridge has more than one address given in the address/addresses +# configuration, the round_robin option defines the behaviour of the bridge on +# a failure of the bridge connection. If round_robin is false, the default +# value, then the first address is treated as the main bridge connection. If +# the connection fails, the other secondary addresses will be attempted in +# turn. Whilst connected to a secondary bridge, the bridge will periodically +# attempt to reconnect to the main bridge until successful. +# If round_robin is true, then all addresses are treated as equals. If a +# connection fails, the next address will be tried and if successful will +# remain connected until it fails +#round_robin false + +# Set the start type of the bridge. This controls how the bridge starts and +# can be one of three types: automatic, lazy and once. Note that RSMB provides +# a fourth start type "manual" which isn't currently supported by mosquitto. +# +# "automatic" is the default start type and means that the bridge connection +# will be started automatically when the broker starts and also restarted +# after a short delay (30 seconds) if the connection fails. +# +# Bridges using the "lazy" start type will be started automatically when the +# number of queued messages exceeds the number set with the "threshold" +# parameter. It will be stopped automatically after the time set by the +# "idle_timeout" parameter. Use this start type if you wish the connection to +# only be active when it is needed. +# +# A bridge using the "once" start type will be started automatically when the +# broker starts but will not be restarted if the connection fails. +#start_type automatic + +# Set the number of messages that need to be queued for a bridge with lazy +# start type to be restarted. Defaults to 10 messages. +# Must be less than max_queued_messages. +#threshold 10 + +# If try_private is set to true, the bridge will attempt to indicate to the +# remote broker that it is a bridge not an ordinary client. If successful, this +# means that loop detection will be more effective and that retained messages +# will be propagated correctly. Not all brokers support this feature so it may +# be necessary to set try_private to false if your bridge does not connect +# properly. +#try_private true + +# Some MQTT brokers do not allow retained messages. MQTT v5 gives a mechanism +# for brokers to tell clients that they do not support retained messages, but +# this is not possible for MQTT v3.1.1 or v3.1. If you need to bridge to a +# v3.1.1 or v3.1 broker that does not support retained messages, set the +# bridge_outgoing_retain option to false. This will remove the retain bit on +# all outgoing messages to that bridge, regardless of any other setting. +#bridge_outgoing_retain true + +# If you wish to restrict the size of messages sent to a remote bridge, use the +# bridge_max_packet_size option. This sets the maximum number of bytes for +# the total message, including headers and payload. +# Note that MQTT v5 brokers may provide their own maximum-packet-size property. +# In this case, the smaller of the two limits will be used. +# Set to 0 for "unlimited". +#bridge_max_packet_size 0 + + +# ----------------------------------------------------------------- +# Certificate based SSL/TLS support +# ----------------------------------------------------------------- +# Either bridge_cafile or bridge_capath must be defined to enable TLS support +# for this bridge. +# bridge_cafile defines the path to a file containing the +# Certificate Authority certificates that have signed the remote broker +# certificate. +# bridge_capath defines a directory that will be searched for files containing +# the CA certificates. For bridge_capath to work correctly, the certificate +# files must have ".crt" as the file ending and you must run "openssl rehash +# " each time you add/remove a certificate. +#bridge_cafile +#bridge_capath + + +# If the remote broker has more than one protocol available on its port, e.g. +# MQTT and WebSockets, then use bridge_alpn to configure which protocol is +# requested. Note that WebSockets support for bridges is not yet available. +#bridge_alpn + +# When using certificate based encryption, bridge_insecure disables +# verification of the server hostname in the server certificate. This can be +# useful when testing initial server configurations, but makes it possible for +# a malicious third party to impersonate your server through DNS spoofing, for +# example. Use this option in testing only. If you need to resort to using this +# option in a production environment, your setup is at fault and there is no +# point using encryption. +#bridge_insecure false + +# Path to the PEM encoded client certificate, if required by the remote broker. +#bridge_certfile + +# Path to the PEM encoded client private key, if required by the remote broker. +#bridge_keyfile + +# ----------------------------------------------------------------- +# PSK based SSL/TLS support +# ----------------------------------------------------------------- +# Pre-shared-key encryption provides an alternative to certificate based +# encryption. A bridge can be configured to use PSK with the bridge_identity +# and bridge_psk options. These are the client PSK identity, and pre-shared-key +# in hexadecimal format with no "0x". Only one of certificate and PSK based +# encryption can be used on one +# bridge at once. +#bridge_identity +#bridge_psk + + +# ================================================================= +# External config files +# ================================================================= + +# External configuration files may be included by using the +# include_dir option. This defines a directory that will be searched +# for config files. All files that end in '.conf' will be loaded as +# a configuration file. It is best to have this as the last option +# in the main file. This option will only be processed from the main +# configuration file. The directory specified must not contain the +# main configuration file. +# Files within include_dir will be loaded sorted in case-sensitive +# alphabetical order, with capital letters ordered first. If this option is +# given multiple times, all of the files from the first instance will be +# processed before the next instance. See the man page for examples. +#include_dir diff --git a/src/Elektrifikatsiya/host/mqtt/mqtt2prom/config.yaml b/src/Elektrifikatsiya/host/mqtt/mqtt2prom/config.yaml new file mode 100644 index 0000000..225e946 --- /dev/null +++ b/src/Elektrifikatsiya/host/mqtt/mqtt2prom/config.yaml @@ -0,0 +1,73 @@ +mqtt: + # The MQTT broker to connect to + server: mqtt://mosquitto:1883 + # Optional: Username and Password for authenticating with the MQTT Server + # Optional: for TLS client certificates + # Optional: Used to specify ClientID. The default is - + # The Topic path to subscribe to. Be aware that you have to specify the wildcard, if you want to follow topics for multiple sensors. + topic_path: shellies/+/# + device_id_regex: "shellies/(?P.*)/.*" + metric_per_topic_config: + metric_name_regex: "shellies/(?P.*)/(?P.*)" + # Optional: Regular expression to extract the device ID from the topic path. The default regular expression, assumes + # that the last "element" of the topic_path is the device id. + # The regular expression must contain a named capture group with the name deviceid + # For example the expression for tasamota based sensors is "tele/(?P.*)/.*" + # device_id_regex: "(.*/)?(shelly-(?P.*?)/)" + # The MQTT QoS level + qos: 0 + # NOTE: Only one of metric_per_topic_config or object_per_topic_config should be specified in the configuration + # Optional: Configures mqtt2prometheus to expect a single metric to be published as the value on an mqtt topic. + #metric_per_topic_config: + # A regex used for extracting the metric name from the topic. Must contain a named group for `metricname`. + #metric_name_regex: "(.*/)?(?P.*)" + # Optional: Configures mqtt2prometheus to expect an object containing multiple metrics to be published as the value on an mqtt topic. + # This is the default. + #object_per_topic_config: + # The encoding of the object, currently only json is supported + # encoding: json +cache: + # Timeout. Each received metric will be presented for this time if no update is send via MQTT. + # Set the timeout to -1 to disable the deletion of metrics from the cache. The exporter presents the ingest timestamp + # to prometheus. + timeout: 24h +json_parsing: + # Separator. Used to split path to elements when accessing json fields. + # You can access json fields with dots in it. F.E. {"key.name": {"nested": "value"}} + # Just set separator to -> and use key.name->nested as mqtt_name + separator: . +# This is a list of valid metrics. Only metrics listed here will be exported +metrics: + # The name of the metric in prometheus + - prom_name: temperature + # The name of the metric in a MQTT JSON message + mqtt_name: temperature + # The prometheus help text for this metric + help: The temperature of the Shelly sensor + # The prometheus type for this metric. Valid values are: "gauge" and "counter" + type: gauge + # A map of string to string for constant labels. This labels will be attached to every prometheus metric + const_labels: + sensor_type: shelly + - prom_name: power + # The name of the metric in a MQTT JSON message + mqtt_name: power + # The scale of the metric in a MQTT JSON message (prom_value = mqtt_value * scale) + # The prometheus help text for this metric + help: The current power draw of the attached device + # The prometheus type for this metric. Valid values are: "gauge" and "counter" + type: gauge + const_labels: + sensor_type: shelly + #A map of string to string for constant labels. This labels will be attached to every prometheus metric + - prom_name: state + mqtt_name: 0 + help: The current state the relay is in + type: gauge + const_labels: + sensor_type: shelly + string_value_mapping: + map: + off: 0 + on: 1 + error_value: 0 \ No newline at end of file diff --git a/src/Elektrifikatsiya/host/prometheus/prometheus.yml b/src/Elektrifikatsiya/host/prometheus/prometheus.yml new file mode 100644 index 0000000..9ae35f2 --- /dev/null +++ b/src/Elektrifikatsiya/host/prometheus/prometheus.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 1s + evaluation_interval: 1s + +rule_files: + # - "first.rules" + # - "second.rules" + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] + - job_name: shellies + static_configs: + - targets: ['mqtt2prom:9641']