├── src ├── changelog.md ├── Client │ ├── wwwroot │ │ ├── favicon.ico │ │ ├── icon-192.png │ │ ├── icon-512.png │ │ ├── css │ │ │ ├── open-iconic │ │ │ │ ├── font │ │ │ │ │ ├── fonts │ │ │ │ │ │ ├── open-iconic.eot │ │ │ │ │ │ ├── open-iconic.otf │ │ │ │ │ │ ├── open-iconic.ttf │ │ │ │ │ │ └── open-iconic.woff │ │ │ │ │ └── css │ │ │ │ │ │ └── open-iconic-bootstrap.min.css │ │ │ │ ├── ICON-LICENSE │ │ │ │ ├── README.md │ │ │ │ └── FONT-LICENSE │ │ │ └── app.css │ │ ├── service-worker.js │ │ ├── manifest.json │ │ ├── index.html │ │ └── service-worker.published.js │ ├── AppState │ │ ├── AppState.cs │ │ └── AppStateExtensions.cs │ ├── Shared │ │ ├── MainLayout.razor │ │ ├── Loader.razor │ │ ├── NavMenu.razor.css │ │ ├── MainLayout.razor.css │ │ └── NavMenu.razor │ ├── _Imports.razor │ ├── Properties │ │ └── launchSettings.json │ ├── App.razor │ ├── Program.cs │ ├── InverterMon.Client.csproj │ └── Pages │ │ ├── PVGenForDay.razor │ │ ├── BMS.razor │ │ ├── Index.razor │ │ └── Settings.razor ├── Server │ ├── publish.cmd │ ├── BatteryService │ │ ├── protocol docs │ │ │ └── jk-protocol.pdf │ │ ├── AmpValQueue.cs │ │ ├── Extensions.cs │ │ └── JK-BMS-RS485-Service.cs │ ├── Endpoints │ │ ├── PVLog │ │ │ └── GetPVForDay │ │ │ │ ├── Request.cs │ │ │ │ └── Endpoint.cs │ │ ├── Settings │ │ │ ├── SetSystemSpec │ │ │ │ ├── Endpoint.cs │ │ │ │ └── Validator.cs │ │ │ ├── SetSettingValue │ │ │ │ └── Endpoint.cs │ │ │ ├── GetSettingValues │ │ │ │ └── Endpoint.cs │ │ │ └── GetChargeAmpereValues │ │ │ │ └── Endpoint.cs │ │ ├── GetBmsStatus │ │ │ └── Endpoint.cs │ │ └── GetStatus │ │ │ └── Endpoint.cs │ ├── appsettings.Development.json │ ├── InverterService │ │ ├── Constants.cs │ │ ├── protocol docs │ │ │ └── voltronic-inverter-protocol.pdf │ │ ├── Commands │ │ │ ├── SetSetting.cs │ │ │ ├── GetChargeAmpereValues.cs │ │ │ ├── Command.cs │ │ │ ├── GetStatus.cs │ │ │ └── GetSettings.cs │ │ ├── Extensions │ │ │ └── ResponseSanitizer.cs │ │ ├── CommandQueue.cs │ │ ├── StatusRetriever.cs │ │ ├── CommandExecutor.cs │ │ └── Inverter.cs │ ├── .config │ │ └── dotnet-tools.json │ ├── appsettings.json │ ├── Properties │ │ └── launchSettings.json │ ├── Persistance │ │ ├── PVGen │ │ │ ├── PVGeneration.cs │ │ │ └── PVGenExtensions.cs │ │ ├── Settings │ │ │ └── UserSettings.cs │ │ └── Database.cs │ ├── Program.cs │ └── InverterMon.Server.csproj ├── Shared │ ├── Models │ │ ├── SetSetting.cs │ │ ├── ChargeAmpereValues.cs │ │ ├── OutputPriority.cs │ │ ├── ChargePriority.cs │ │ ├── SystemSpec.cs │ │ ├── PVDay.cs │ │ ├── CurrentSettings.cs │ │ ├── BMSStatus.cs │ │ └── InverterStatus.cs │ └── InverterMon.Shared.csproj ├── InverterMonWindow │ ├── Program.cs │ ├── Properties │ │ ├── Settings.settings │ │ └── Settings.Designer.cs │ ├── App.config │ ├── InverterMonWindow.csproj │ ├── Main.Designer.cs │ └── Main.cs ├── .run │ └── Publish InverterMonWindow.run.xml └── InverterMon.sln ├── screenshot.png ├── jk-screenshot.png ├── LICENSE ├── .github └── workflows │ └── create-release.yaml ├── README.md └── .gitignore /src/changelog.md: -------------------------------------------------------------------------------- 1 | ## changelog 2 | 3 | - add source generator for reflection data -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/screenshot.png -------------------------------------------------------------------------------- /jk-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/jk-screenshot.png -------------------------------------------------------------------------------- /src/Client/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Client/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/Client/wwwroot/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Client/wwwroot/icon-192.png -------------------------------------------------------------------------------- /src/Client/wwwroot/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Client/wwwroot/icon-512.png -------------------------------------------------------------------------------- /src/Server/publish.cmd: -------------------------------------------------------------------------------- 1 | dotnet publish ./InverterMon.Server.csproj -c Release -r win-x64 --self-contained -p:PublishTrimmed=true -p:TrimmerLogLevel=Detailed -o D:\\DOWNLOADS -------------------------------------------------------------------------------- /src/Server/BatteryService/protocol docs/jk-protocol.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Server/BatteryService/protocol docs/jk-protocol.pdf -------------------------------------------------------------------------------- /src/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.eot -------------------------------------------------------------------------------- /src/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.otf -------------------------------------------------------------------------------- /src/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf -------------------------------------------------------------------------------- /src/Server/Endpoints/PVLog/GetPVForDay/Request.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Server.Endpoints.PVLog.GetPVForDay; 2 | 3 | public class Request 4 | { 5 | public int DayNumber { get; set; } 6 | } -------------------------------------------------------------------------------- /src/Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.woff -------------------------------------------------------------------------------- /src/Server/InverterService/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Server.InverterService; 2 | 3 | public static class Constants 4 | { 5 | public const int StatusPollingFrequencyMillis = 2000; 6 | } -------------------------------------------------------------------------------- /src/Shared/Models/SetSetting.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Shared.Models; 2 | 3 | public class SetSetting 4 | { 5 | public string Command { get; set; } 6 | public string Value { get; set; } 7 | } -------------------------------------------------------------------------------- /src/Server/InverterService/protocol docs/voltronic-inverter-protocol.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dj-nitehawk/Hybrid-Inverter-Monitor/HEAD/src/Server/InverterService/protocol docs/voltronic-inverter-protocol.pdf -------------------------------------------------------------------------------- /src/Client/AppState/AppState.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Client.AppState; 2 | 3 | public class ClientSettings 4 | { 5 | public bool ShowEndDateAndTime { get; set; } 6 | public bool ShowCapacityKwh { get; set; } = true; 7 | } 8 | -------------------------------------------------------------------------------- /src/Server/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-ef": { 6 | "version": "6.0.6", 7 | "commands": [ 8 | "dotnet-ef" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/Shared/Models/ChargeAmpereValues.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Shared.Models; 2 | 3 | public class ChargeAmpereValues 4 | { 5 | public IEnumerable CombinedAmpereValues { get; set; } 6 | public IEnumerable UtilityAmpereValues { get; set; } 7 | } -------------------------------------------------------------------------------- /src/InverterMonWindow/Program.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMonWindow; 2 | 3 | static class Program 4 | { 5 | [STAThread] 6 | static void Main() 7 | { 8 | ApplicationConfiguration.Initialize(); 9 | Application.Run(new Main()); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Shared/Models/OutputPriority.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Shared.Models; 2 | 3 | public static class OutputPriority 4 | { 5 | public const string SolarFirst = "01"; 6 | public const string SolarBatteryUtility = "02"; 7 | public const string UtilityFirst = "00"; 8 | } -------------------------------------------------------------------------------- /src/Client/Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 |
4 | 7 | 8 |
9 |
10 | @Body 11 |
12 |
13 |
-------------------------------------------------------------------------------- /src/Client/wwwroot/service-worker.js: -------------------------------------------------------------------------------- 1 | // In development, always fetch from the network and do not enable offline support. 2 | // This is because caching would make development more difficult (changes would not 3 | // be reflected on the first load after each change). 4 | self.addEventListener('fetch', () => { }); 5 | -------------------------------------------------------------------------------- /src/Shared/Models/ChargePriority.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Shared.Models; 2 | 3 | public static class ChargePriority 4 | { 5 | public const string SolarFirst = "01"; 6 | public const string SolarAndUtility = "02"; 7 | public const string OnlySolar = "03"; 8 | public const string UtilityFirst = "00"; 9 | } 10 | -------------------------------------------------------------------------------- /src/Client/Shared/Loader.razor: -------------------------------------------------------------------------------- 1 | @if (Enabled) 2 | { 3 |
4 |
5 | 6 |
... Loading ...
7 |
8 |
9 | } 10 | 11 | @code{ 12 | [Parameter] public bool Enabled { get; set; } 13 | } -------------------------------------------------------------------------------- /src/Shared/Models/SystemSpec.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Shared.Models; 2 | 3 | public class SystemSpec 4 | { 5 | public int PV_MaxCapacity { get; set; } = 1000; 6 | public int BatteryCapacity { get; set; } = 100; 7 | public float BatteryNominalVoltage { get; set; } = 25.6f; 8 | public int SunlightStartHour { get; set; } = 6; 9 | public int SunlightEndHour { get; set; } = 18; 10 | } -------------------------------------------------------------------------------- /src/Client/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Forms 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using Microsoft.AspNetCore.Components.Web.Virtualization 7 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 8 | @using Microsoft.JSInterop 9 | @using AntDesign.Charts 10 | @using InverterMon.Client 11 | @using InverterMon.Client.Shared 12 | -------------------------------------------------------------------------------- /src/Client/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "InverterMon": { 4 | "commandName": "Project", 5 | "dotnetRunMessages": true, 6 | "launchBrowser": true, 7 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 8 | "applicationUrl": "http://localhost:5238", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "LaunchSettings": { 3 | "DeviceAddress": "/dev/ttyUSB1", 4 | "JkBmsAddress": "/dev/ttyUSB0", 5 | "WebPort": 80, 6 | "TroubleMode": "no", 7 | "MppSolarPath": "/usr/local/bin/mpp-solar" 8 | }, 9 | "Logging": { 10 | "LogLevel": { 11 | "Default": "Information", 12 | "Microsoft.AspNetCore": "Debug", 13 | "Microsoft.Hosting.Lifetime": "Debug", 14 | "FastEndpoints.StartupTimer": "Debug" 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/InverterMonWindow/Properties/Settings.settings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 0, 0 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "InverterMon.Server": { 4 | "commandName": "Project", 5 | "dotnetRunMessages": true, 6 | "launchBrowser": true, 7 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 8 | "applicationUrl": "http://localhost:80;https://localhost:443", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Client/wwwroot/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "InverterMon", 3 | "short_name": "InverterMon", 4 | "start_url": "./", 5 | "display": "standalone", 6 | "background_color": "#ffffff", 7 | "theme_color": "#03173d", 8 | "prefer_related_applications": false, 9 | "icons": [ 10 | { 11 | "src": "icon-512.png", 12 | "type": "image/png", 13 | "sizes": "512x512" 14 | }, 15 | { 16 | "src": "icon-192.png", 17 | "type": "image/png", 18 | "sizes": "192x192" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/Client/App.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Not found 8 | 9 |

Sorry, there's nothing at this address.

10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /src/Shared/InverterMon.Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net7.0 4 | enable 5 | enable 6 | CS8618 7 | 8 | 9 | 10 | none 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Server/InverterService/Commands/SetSetting.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable VirtualMemberCallInConstructor 2 | 3 | namespace InverterMon.Server.InverterService.Commands; 4 | 5 | class SetSetting : Command 6 | { 7 | public override string CommandString { get; set; } 8 | public override bool IsTroublesomeCmd { get; } = true; 9 | 10 | public SetSetting(string settingName, string settingValue) 11 | { 12 | CommandString = settingName + settingValue; 13 | } 14 | 15 | public override void Parse(string responseFromInverter) 16 | { 17 | Result = responseFromInverter[1..4] == "ACK"; 18 | } 19 | } -------------------------------------------------------------------------------- /src/Shared/Models/PVDay.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace InverterMon.Shared.Models; 4 | 5 | public class PVDay 6 | { 7 | public int DayNumber { get; set; } 8 | public string DayName { get; set; } 9 | public decimal TotalKiloWattHours { get; set; } 10 | public IEnumerable WattPeaks { get; set; } 11 | public int GraphTickCount { get; set; } 12 | public int[] GraphRange { get; set; } 13 | 14 | public class WattPeak 15 | { 16 | [JsonPropertyName("Time")] 17 | public string MinuteBucket { get; set; } 18 | 19 | [JsonPropertyName("Watts")] 20 | public int PeakWatt { get; set; } 21 | } 22 | } -------------------------------------------------------------------------------- /src/Client/Program.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Client; 2 | using Microsoft.AspNetCore.Components.Web; 3 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 4 | 5 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 6 | builder.RootComponents.Add("#app"); 7 | builder.RootComponents.Add("head::after"); 8 | builder.Services.AddScoped(_ => new HttpClient 9 | { 10 | BaseAddress = new Uri(builder.HostEnvironment.BaseAddress), 11 | Timeout = TimeSpan.FromSeconds(3) 12 | }); 13 | _ = InverterMon.Client.Pages.Index.StartStatusStreaming(builder.HostEnvironment.BaseAddress); 14 | _ = InverterMon.Client.Pages.BMS.StartStatusStreaming(builder.HostEnvironment.BaseAddress); 15 | await builder.Build().RunAsync(); -------------------------------------------------------------------------------- /src/.run/Publish InverterMonWindow.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/Server/Endpoints/Settings/SetSystemSpec/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.Persistance; 2 | using InverterMon.Server.Persistance.Settings; 3 | 4 | namespace InverterMon.Server.Endpoints.Settings.SetSystemSpec; 5 | 6 | public class Endpoint : Endpoint 7 | { 8 | public UserSettings UserSettings { get; set; } 9 | public Database Db { get; set; } 10 | 11 | public override void Configure() 12 | { 13 | Post("settings/set-system-spec"); 14 | AllowAnonymous(); 15 | } 16 | 17 | public override async Task HandleAsync(Shared.Models.SystemSpec r, CancellationToken c) 18 | { 19 | UserSettings.FromSystemSpec(r); 20 | Db.UpdateUserSettings(UserSettings); 21 | await SendOkAsync(); 22 | } 23 | } -------------------------------------------------------------------------------- /src/Server/Endpoints/Settings/SetSettingValue/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.InverterService; 2 | 3 | namespace InverterMon.Server.Endpoints.Settings.SetSettingValue; 4 | 5 | public class Endpoint : Endpoint 6 | { 7 | public CommandQueue Queue { get; set; } 8 | 9 | public override void Configure() 10 | { 11 | Get("settings/set-setting/{Command}/{Value}"); 12 | AllowAnonymous(); 13 | } 14 | 15 | public override async Task HandleAsync(Shared.Models.SetSetting r, CancellationToken c) 16 | { 17 | var cmd = new InverterService.Commands.SetSetting(r.Command, r.Value); 18 | Queue.AddCommands(cmd); 19 | await cmd.WhileProcessing(c); 20 | await SendAsync(cmd.Result); 21 | } 22 | } -------------------------------------------------------------------------------- /src/Server/Persistance/PVGen/PVGeneration.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Server.Persistance.PVGen; 2 | 3 | public class PVGeneration 4 | { 5 | public int Id { get; set; } 6 | public Dictionary WattPeaks { get; set; } = new(); 7 | public decimal TotalWattHours { get; set; } 8 | 9 | public void SetWattPeaks(int newValue) 10 | { 11 | var key = DateTime.Now.ToTimeBucket(); 12 | 13 | if (WattPeaks.TryGetValue(key, out var value)) 14 | { 15 | if (value < newValue) 16 | WattPeaks[key] = newValue; 17 | } 18 | else 19 | WattPeaks[key] = newValue; 20 | } 21 | 22 | public void SetTotalWattHours(decimal totalWattHours) 23 | { 24 | TotalWattHours = totalWattHours; 25 | } 26 | } -------------------------------------------------------------------------------- /src/Client/AppState/AppStateExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.JSInterop; 2 | using System.Text.Json; 3 | 4 | namespace InverterMon.Client.AppState; 5 | 6 | public static class StateExtensions 7 | { 8 | public static async Task LoadStateAsync(this IJSRuntime jsRuntime) where T : class 9 | { 10 | var data = await jsRuntime.InvokeAsync("localStorage.getItem", typeof(T).FullName); 11 | 12 | if (!string.IsNullOrEmpty(data)) 13 | return JsonSerializer.Deserialize(data); 14 | 15 | return null; 16 | } 17 | 18 | public static ValueTask SaveStateAsync(this IJSRuntime jsRuntime, T state) where T : class 19 | { 20 | return jsRuntime.InvokeVoidAsync("localStorage.setItem", typeof(T).FullName, JsonSerializer.Serialize(state)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Server/Persistance/PVGen/PVGenExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Server.Persistance.PVGen; 2 | 3 | public static class PVGenExtensions 4 | { 5 | public static TimeSpan BucketDuration => TimeSpan.FromMinutes(5); 6 | const string BucketKey = "HH:mm"; 7 | 8 | public static string ToTimeBucket(this DateTime dt) 9 | { 10 | var delta = dt.Ticks % BucketDuration.Ticks; 11 | 12 | return new DateTime(dt.Ticks - delta, dt.Kind).ToString(BucketKey); 13 | } 14 | 15 | public static void AllocateBuckets(this PVGeneration pvGen, int startHour, int endHour) 16 | { 17 | var timeOfDay = new TimeOnly(startHour, 0); 18 | 19 | while (timeOfDay.Hour < endHour) 20 | { 21 | pvGen.WattPeaks[timeOfDay.ToString(BucketKey)] = 0; 22 | timeOfDay = timeOfDay.AddMinutes(BucketDuration.TotalMinutes); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /src/InverterMonWindow/App.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 0, 0 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Server/InverterService/Extensions/ResponseSanitizer.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace InverterMon.Server.InverterService; 4 | 5 | public static partial class Extensions 6 | { 7 | [GeneratedRegex(@"[^\u0009\u000A\u000D\u0020-\u007E]")] 8 | private static partial Regex StringSanitizer(); 9 | 10 | static readonly Regex _sanRx = StringSanitizer(); 11 | 12 | public static string Sanitize(this string input) 13 | => _sanRx.Replace(input, ""); 14 | 15 | [GeneratedRegex(@"'\((.*?)\\")] 16 | private static partial Regex CLIParser(); 17 | 18 | static readonly Regex _cliRx = CLIParser(); 19 | 20 | public static string ParseCli(this string input) 21 | { 22 | var match = _cliRx.Match(input); 23 | 24 | return match.Success 25 | ? match.Groups[0].Value 26 | : "`(NAK\\"; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Server/BatteryService/AmpValQueue.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Server.BatteryService; 2 | 3 | public sealed class AmpValQueue 4 | { 5 | readonly Queue _queue = new(); 6 | bool _lastChargingState; 7 | readonly int _fixedCapacity; 8 | 9 | public AmpValQueue(int fixedCapacity) 10 | { 11 | _fixedCapacity = fixedCapacity; 12 | } 13 | 14 | public void Store(float ampReading, bool chargingState) 15 | { 16 | if (ampReading == 0 || _lastChargingState != chargingState) 17 | { 18 | _queue.Clear(); 19 | _lastChargingState = chargingState; 20 | 21 | return; 22 | } 23 | 24 | _lastChargingState = chargingState; 25 | _queue.Enqueue(ampReading); 26 | 27 | if (_queue.Count > _fixedCapacity) 28 | _queue.Dequeue(); 29 | } 30 | 31 | public float GetAverage() 32 | => _queue.Count > 0 33 | ? _queue.Average() 34 | : 0; 35 | } -------------------------------------------------------------------------------- /src/Server/InverterService/CommandQueue.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using InverterMon.Server.InverterService.Commands; 3 | using ICommand = InverterMon.Server.InverterService.Commands.ICommand; 4 | 5 | namespace InverterMon.Server.InverterService; 6 | 7 | public class CommandQueue 8 | { 9 | public bool IsAcceptingCommands { get; set; } = true; 10 | public GetStatus StatusCommand { get; } = new(); 11 | 12 | readonly ConcurrentQueue _toProcess = new(); 13 | 14 | public bool AddCommands(params ICommand[] commands) 15 | { 16 | if (IsAcceptingCommands) 17 | { 18 | foreach (var cmd in commands) 19 | _toProcess.Enqueue(cmd); 20 | 21 | return true; 22 | } 23 | 24 | return false; 25 | } 26 | 27 | public ICommand? GetCommand() 28 | => _toProcess.TryPeek(out var command) ? command : null; 29 | 30 | public void RemoveCommand() 31 | => _toProcess.TryDequeue(out _); 32 | } -------------------------------------------------------------------------------- /src/Server/InverterService/Commands/GetChargeAmpereValues.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable VirtualMemberCallInConstructor 2 | 3 | namespace InverterMon.Server.InverterService.Commands; 4 | 5 | class GetChargeAmpereValues : Command> 6 | { 7 | public override string CommandString { get; set; } = "QMCHGCR"; 8 | public override bool IsTroublesomeCmd { get; } = true; 9 | 10 | public GetChargeAmpereValues(bool getUtilityValues) 11 | { 12 | Result.AddRange(new[] { "000" }); 13 | 14 | if (getUtilityValues) 15 | CommandString = "QMUCHGCR"; 16 | } 17 | 18 | public override void Parse(string responseFromInverter) 19 | { 20 | if (responseFromInverter.StartsWith("(NAK")) 21 | return; 22 | 23 | var parts = responseFromInverter[1..] 24 | .Split(' ', StringSplitOptions.RemoveEmptyEntries) 25 | .Select(x => x[..3]); 26 | 27 | if (parts.Any()) 28 | Result.Clear(); //remove default values 29 | 30 | Result.AddRange(parts); 31 | } 32 | } -------------------------------------------------------------------------------- /src/Server/Endpoints/Settings/SetSystemSpec/Validator.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | using InverterMon.Shared.Models; 3 | 4 | namespace InverterMon.Server.Endpoints.Settings.SetSystemSpec; 5 | 6 | public class Validator : Validator 7 | { 8 | public Validator() 9 | { 10 | RuleFor(x => x.PV_MaxCapacity) 11 | .GreaterThan(100); 12 | 13 | RuleFor(x => x.SunlightStartHour) 14 | .GreaterThanOrEqualTo(0) 15 | .LessThanOrEqualTo(24) 16 | .Must((s, h) => h < s.SunlightEndHour).WithMessage("Sunlight start hour must be earlier than end hour!"); 17 | 18 | RuleFor(x => x.SunlightEndHour) 19 | .GreaterThanOrEqualTo(0) 20 | .LessThanOrEqualTo(24) 21 | .Must((s, h) => h > s.SunlightStartHour).WithMessage("Sunlight end hour must be later than start hour!"); 22 | 23 | RuleFor(x => x.BatteryNominalVoltage) 24 | .GreaterThan(5) 25 | .WithMessage("Battery nominal voltage required!"); 26 | 27 | //todo: display validation errors on ui 28 | } 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dĵ ΝιΓΞΗΛψΚ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Client/wwwroot/css/open-iconic/ICON-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/Server/Endpoints/GetBmsStatus/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using InverterMon.Server.BatteryService; 3 | using InverterMon.Shared.Models; 4 | 5 | namespace InverterMon.Server.Endpoints.GetBmsStatus; 6 | 7 | public class Endpoint : EndpointWithoutRequest 8 | { 9 | public JkBms Bms { get; set; } 10 | 11 | public override void Configure() 12 | { 13 | Get("bms-status"); 14 | AllowAnonymous(); 15 | } 16 | 17 | public override async Task HandleAsync(CancellationToken c) 18 | { 19 | try 20 | { 21 | if (Bms.IsConnected) 22 | await SendAsync(GetDataStream(c), cancellation: c); 23 | else 24 | await SendNotFoundAsync(c); 25 | } 26 | catch (TaskCanceledException) 27 | { 28 | //nothing to do here 29 | } 30 | } 31 | 32 | async IAsyncEnumerable GetDataStream([EnumeratorCancellation] CancellationToken c) 33 | { 34 | while (!c.IsCancellationRequested) 35 | { 36 | yield return Bms.Status; 37 | 38 | await Task.Delay(1000, c); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/InverterMonWindow/InverterMonWindow.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WinExe 5 | net9.0-windows 6 | enable 7 | true 8 | enable 9 | true 10 | direct 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | True 20 | True 21 | Settings.settings 22 | 23 | 24 | 25 | 26 | 27 | SettingsSingleFileGenerator 28 | Settings.Designer.cs 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Server/InverterService/StatusRetriever.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.Persistance; 2 | using InverterMon.Server.Persistance.Settings; 3 | 4 | namespace InverterMon.Server.InverterService; 5 | 6 | class StatusRetriever : BackgroundService 7 | { 8 | readonly CommandQueue _queue; 9 | readonly Database _db; 10 | readonly UserSettings _userSettings; 11 | 12 | public StatusRetriever(CommandQueue queue, Database db, UserSettings userSettings) 13 | { 14 | _queue = queue; 15 | _db = db; 16 | _userSettings = userSettings; 17 | } 18 | 19 | protected override async Task ExecuteAsync(CancellationToken c) 20 | { 21 | var cmd = _queue.StatusCommand; 22 | 23 | while (!c.IsCancellationRequested) 24 | { 25 | if (_queue.IsAcceptingCommands) 26 | { 27 | //feels hacky. find a better solution. 28 | cmd.Result.BatteryCapacity = _userSettings.BatteryCapacity; 29 | cmd.Result.PV_MaxCapacity = _userSettings.PV_MaxCapacity; 30 | 31 | _queue.AddCommands(cmd); 32 | _ = _db.UpdateTodaysPvGeneration(cmd, c); 33 | } 34 | await Task.Delay(Constants.StatusPollingFrequencyMillis); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/Server/InverterService/Commands/Command.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnassignedGetOnlyAutoProperty 2 | 3 | namespace InverterMon.Server.InverterService.Commands; 4 | 5 | public interface ICommand 6 | { 7 | string CommandString { get; set; } 8 | bool IsTroublesomeCmd { get; } 9 | void Parse(string rawResponse); 10 | void Start(); 11 | void End(); 12 | } 13 | 14 | public abstract class Command : ICommand where TResponseDto : new() 15 | { 16 | public abstract string CommandString { get; set; } 17 | public virtual bool IsTroublesomeCmd { get; } 18 | public TResponseDto Result { get; protected set; } = new(); 19 | public bool IsComplete { get; private set; } 20 | 21 | public abstract void Parse(string responseFromInverter); 22 | 23 | protected DateTime startTime = DateTime.Now; 24 | 25 | public void Start() 26 | { 27 | startTime = DateTime.Now; 28 | IsComplete = false; 29 | } 30 | 31 | public void End() 32 | => IsComplete = true; 33 | 34 | public async Task WhileProcessing(CancellationToken c, int timeoutMillis = Constants.StatusPollingFrequencyMillis) 35 | { 36 | while (!c.IsCancellationRequested && !IsComplete && DateTime.Now.Subtract(startTime).TotalMilliseconds <= timeoutMillis) 37 | await Task.Delay(500, c); 38 | } 39 | } -------------------------------------------------------------------------------- /src/Client/InverterMon.Client.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | service-worker-assets.js 8 | false 9 | false 10 | direct 11 | 12 | 13 | 14 | none 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Release With Binary Assets 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | rid: 14 | [ 15 | linux-arm64, 16 | linux-arm, 17 | linux-musl-arm64, 18 | linux-musl-arm, 19 | linux-musl-x64, 20 | linux-x64, 21 | ] 22 | fail-fast: true 23 | steps: 24 | - uses: actions/checkout@v2 25 | 26 | - name: Setup .NET 27 | uses: actions/setup-dotnet@v2 28 | with: 29 | dotnet-version: 9.x 30 | 31 | - name: Publish Binaries 32 | working-directory: src/Server 33 | run: | 34 | dotnet publish ./InverterMon.Server.csproj -c Release -r ${{ matrix.rid }} --self-contained -p:PublishTrimmed=true -o ./bin/${{ matrix.rid }} 35 | rm -rf ./bin/${{ matrix.rid }}/BlazorDebugProxy 36 | cd ./bin/${{ matrix.rid }} 37 | zip -r -9 ../${{ matrix.rid }}.zip ./* 38 | 39 | - name: Create Release 40 | uses: softprops/action-gh-release@v1 41 | if: ${{ !contains(github.ref, 'beta') }} 42 | with: 43 | draft: false 44 | name: ${{ github.ref_name }} Release 45 | body_path: src/changelog.md 46 | files: src/Server/bin/*.zip 47 | -------------------------------------------------------------------------------- /src/Server/Persistance/Settings/UserSettings.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.Persistance.PVGen; 2 | using InverterMon.Shared.Models; 3 | 4 | namespace InverterMon.Server.Persistance.Settings; 5 | 6 | public class UserSettings 7 | { 8 | public int Id { get; set; } = 1; 9 | public int PV_MaxCapacity { get; set; } = 1000; 10 | public int BatteryCapacity { get; set; } = 100; 11 | public float BatteryNominalVoltage { get; set; } = 25.6f; 12 | public int SunlightStartHour { get; set; } = 6; 13 | public int SunlightEndHour { get; set; } = 18; 14 | public int[] PVGraphRange => new[] { 0, (SunlightEndHour - SunlightStartHour) * 60 }; 15 | public int PVGraphTickCount => PVGraphRange[1] / (int)PVGenExtensions.BucketDuration.TotalMinutes; 16 | 17 | public SystemSpec ToSystemSpec() 18 | => new() 19 | { 20 | PV_MaxCapacity = PV_MaxCapacity, 21 | BatteryCapacity = BatteryCapacity, 22 | BatteryNominalVoltage = BatteryNominalVoltage, 23 | SunlightStartHour = SunlightStartHour, 24 | SunlightEndHour = SunlightEndHour 25 | }; 26 | 27 | public void FromSystemSpec(SystemSpec spec) 28 | { 29 | Id = 1; 30 | PV_MaxCapacity = spec.PV_MaxCapacity; 31 | BatteryCapacity = spec.BatteryCapacity; 32 | BatteryNominalVoltage = spec.BatteryNominalVoltage; 33 | SunlightStartHour = spec.SunlightStartHour; 34 | SunlightEndHour = spec.SunlightEndHour; 35 | } 36 | } -------------------------------------------------------------------------------- /src/Server/Endpoints/Settings/GetSettingValues/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.InverterService; 2 | using InverterMon.Server.InverterService.Commands; 3 | using InverterMon.Server.Persistance.Settings; 4 | using InverterMon.Shared.Models; 5 | 6 | namespace InverterMon.Server.Endpoints.Settings.GetSettingValues; 7 | 8 | public class Endpoint : EndpointWithoutRequest 9 | { 10 | public CommandQueue Queue { get; set; } 11 | public UserSettings UserSettings { get; set; } 12 | 13 | public override void Configure() 14 | { 15 | Get("settings/get-setting-values"); 16 | AllowAnonymous(); 17 | } 18 | 19 | public override async Task HandleAsync(CancellationToken c) 20 | { 21 | var cmd = new GetSettings(); 22 | cmd.Result.SystemSpec = UserSettings.ToSystemSpec(); 23 | 24 | if (Env.IsDevelopment()) 25 | { 26 | cmd.Result.ChargePriority = "03"; 27 | cmd.Result.MaxACChargeCurrent = "10"; 28 | cmd.Result.MaxCombinedChargeCurrent = "020"; 29 | cmd.Result.OutputPriority = "02"; 30 | cmd.Result.BulkChargeVoltage = 27.1m; 31 | await SendAsync(cmd.Result); 32 | return; 33 | } 34 | 35 | Queue.AddCommands(cmd); 36 | 37 | await cmd.WhileProcessing(c); 38 | 39 | if (cmd.IsComplete) 40 | await SendAsync(cmd.Result); 41 | else 42 | ThrowError("Unable to read settings in a timely manner!"); 43 | } 44 | } -------------------------------------------------------------------------------- /src/Server/InverterService/Commands/GetStatus.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Shared.Models; 2 | 3 | namespace InverterMon.Server.InverterService.Commands; 4 | 5 | public class GetStatus : Command 6 | { 7 | public override string CommandString { get; set; } = "QPIGS"; 8 | 9 | public override void Parse(string responseFromInverter) 10 | { 11 | //(232.0 50.1 232.0 50.1 0000 0000 000 476 27.02 000 100 0553 0000 000.0 27.00 00000 10011101 03 04 00000 101a\xc8\r 12 | //(000.0 00.0 229.8 50.0 0851 0701 023 355 26.20 000 050 0041 00.0 058.5 00.00 00031 00010000 00 00 00000 010 0 01 0000 13 | 14 | if (responseFromInverter.StartsWith("(NAK")) 15 | return; 16 | 17 | var parts = responseFromInverter[1..].Split(' ', StringSplitOptions.RemoveEmptyEntries); 18 | 19 | Result.GridVoltage = decimal.Parse(parts[0]); 20 | Result.OutputVoltage = decimal.Parse(parts[2]); 21 | Result.LoadWatts = int.Parse(parts[5]); 22 | Result.LoadPercentage = decimal.Parse(parts[6]); 23 | Result.BatteryVoltage = decimal.Parse(parts[8]); 24 | Result.BatteryChargeCurrent = int.Parse(parts[9]); 25 | Result.HeatSinkTemperature = int.Parse(parts[11]); 26 | Result.PVInputCurrent = decimal.Parse(parts[12]); 27 | Result.PVInputVoltage = decimal.Parse(parts[13]); 28 | Result.BatteryDischargeCurrent = int.Parse(parts[15]); 29 | Result.PVInputWatt = Result.PVInputVoltage == 00 ? 0 : Convert.ToInt32(int.Parse(parts[19])); 30 | } 31 | } -------------------------------------------------------------------------------- /src/Server/Program.cs: -------------------------------------------------------------------------------- 1 | global using FastEndpoints; 2 | using System.Globalization; 3 | using System.Net; 4 | using InverterMon.Server; 5 | using InverterMon.Server.BatteryService; 6 | using InverterMon.Server.InverterService; 7 | using InverterMon.Server.Persistance; 8 | using InverterMon.Server.Persistance.Settings; 9 | 10 | //avoid parsing issues with non-english cultures 11 | var cultureInfo = new CultureInfo("en-US"); 12 | CultureInfo.DefaultThreadCurrentCulture = cultureInfo; 13 | CultureInfo.DefaultThreadCurrentUICulture = cultureInfo; 14 | 15 | var bld = WebApplication.CreateBuilder(); 16 | 17 | _ = int.TryParse(bld.Configuration["LaunchSettings:WebPort"] ?? "80", out var port); 18 | bld.WebHost.ConfigureKestrel(o => o.Listen(IPAddress.Any, port)); 19 | 20 | bld.Services 21 | .AddSingleton() 22 | .AddSingleton() 23 | .AddSingleton() 24 | .AddSingleton(); 25 | 26 | if (!bld.Environment.IsDevelopment()) 27 | { 28 | bld.Services 29 | .AddHostedService() 30 | .AddHostedService(); 31 | } 32 | 33 | bld.Services.AddFastEndpoints(o => o.SourceGeneratorDiscoveredTypes = DiscoveredTypes.All); 34 | 35 | var app = bld.Build(); 36 | 37 | if (app.Environment.IsDevelopment()) 38 | app.UseWebAssemblyDebugging(); 39 | 40 | app.UseBlazorFrameworkFiles() 41 | .UseStaticFiles(); 42 | app.MapFallbackToFile("index.html"); 43 | app.UseRouting() 44 | .UseFastEndpoints( 45 | c => 46 | { 47 | c.Endpoints.RoutePrefix = "api"; 48 | c.Binding.ReflectionCache.AddFromInverterMonServer(); 49 | }); 50 | app.Run(); -------------------------------------------------------------------------------- /src/Shared/Models/CurrentSettings.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMon.Shared.Models; 2 | 3 | public class CurrentSettings 4 | { 5 | public string ChargePriority { get; set; } = "000"; 6 | public string OutputPriority { get; set; } = "000"; 7 | public string MaxACChargeCurrent { get; set; } = "000"; 8 | public string MaxCombinedChargeCurrent { get; set; } = "000"; 9 | 10 | decimal _backToGrid; 11 | public decimal BackToGridVoltage { get => _backToGrid; set => _backToGrid = RoundToHalfPoints(value); } 12 | 13 | decimal _dischargeCuttOff; 14 | public decimal DischargeCuttOffVoltage { get => _dischargeCuttOff; set => _dischargeCuttOff = RoundToOneDecimalPoint(value); } 15 | 16 | decimal _bulkVoltage; 17 | public decimal BulkChargeVoltage 18 | { 19 | get => _bulkVoltage; 20 | set => _bulkVoltage = RoundToOneDecimalPoint(value < _floatVoltage ? _floatVoltage : value); 21 | } 22 | 23 | decimal _floatVoltage; 24 | public decimal FloatChargeVoltage 25 | { 26 | get => _floatVoltage; 27 | set => _floatVoltage = RoundToOneDecimalPoint(value > _bulkVoltage ? _bulkVoltage : value); 28 | } 29 | 30 | decimal _backToBattery; 31 | public decimal BackToBatteryVoltage { get => _backToBattery; set => _backToBattery = RoundToHalfPoints(value); } 32 | 33 | public SystemSpec SystemSpec { get; set; } = new(); 34 | 35 | static decimal RoundToHalfPoints(decimal value) 36 | => Math.Round(value * 2, MidpointRounding.AwayFromZero) / 2; 37 | 38 | static decimal RoundToOneDecimalPoint(decimal value) 39 | => Math.Round(value, 1, MidpointRounding.AwayFromZero); 40 | } 41 | -------------------------------------------------------------------------------- /src/Client/Shared/NavMenu.razor.css: -------------------------------------------------------------------------------- 1 | .navbar-toggler { 2 | background-color: rgba(255, 255, 255, 0.1); 3 | } 4 | 5 | .top-row { 6 | background-color: rgba(0,0,0,0.4); 7 | } 8 | 9 | .navbar-brand { 10 | font-size: 1.1rem; 11 | } 12 | 13 | .oi { 14 | width: 2rem; 15 | font-size: 1.1rem; 16 | vertical-align: text-top; 17 | top: -2px; 18 | } 19 | 20 | .nav-item { 21 | font-size: 0.9rem; 22 | padding-bottom: 0.5rem; 23 | } 24 | 25 | .nav-item:first-of-type { 26 | padding-top: 1rem; 27 | } 28 | 29 | .nav-item:last-of-type { 30 | padding-bottom: 1rem; 31 | } 32 | 33 | .nav-item ::deep a { 34 | color: #d7d7d7; 35 | border-radius: 4px; 36 | height: 3rem; 37 | display: flex; 38 | align-items: center; 39 | line-height: 3rem; 40 | } 41 | 42 | .nav-item ::deep a.active { 43 | background-color: rgba(255,255,255,0.25); 44 | color: white; 45 | } 46 | 47 | .nav-item ::deep a:hover { 48 | background-color: rgba(255,255,255,0.1); 49 | color: white; 50 | } 51 | 52 | .settings { 53 | display: none; 54 | margin-top: 5px; 55 | } 56 | 57 | @media (min-width: 360px) { 58 | .settings { 59 | display: inline-block; 60 | } 61 | } 62 | 63 | @media (min-width: 640px) { 64 | .settings { 65 | display: none; 66 | } 67 | } 68 | 69 | @media (min-width: 641px) { 70 | .navbar-toggler { 71 | display: none; 72 | } 73 | 74 | .collapse { 75 | /* Never collapse the sidebar for wide screens */ 76 | display: block; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/InverterMonWindow/Properties/Settings.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace InverterMonWindow.Properties { 12 | 13 | 14 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 15 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.5.0.0")] 16 | internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { 17 | 18 | private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); 19 | 20 | public static Settings Default { 21 | get { 22 | return defaultInstance; 23 | } 24 | } 25 | 26 | [global::System.Configuration.UserScopedSettingAttribute()] 27 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 28 | [global::System.Configuration.DefaultSettingValueAttribute("0, 0")] 29 | public global::System.Drawing.Point WindowPosition { 30 | get { 31 | return ((global::System.Drawing.Point)(this["WindowPosition"])); 32 | } 33 | set { 34 | this["WindowPosition"] = value; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Server/BatteryService/Extensions.cs: -------------------------------------------------------------------------------- 1 | using SerialPortLib; 2 | using System.Globalization; 3 | 4 | namespace InverterMon.Server.BatteryService; 5 | 6 | public static class Extensions 7 | { 8 | //this is the get all data command 9 | static readonly byte[] _cmd = Convert.FromHexString("4E5700130000000006030000000000006800000129"); 10 | 11 | public static void QueryData(this SerialPortInput port) 12 | { 13 | port.SendMessage(_cmd); 14 | } 15 | 16 | //note: the response is the byte representation of hex digits. 17 | // i.e. the bytes cannot be converted to int/short without first converting to hex digits. 18 | 19 | public static ushort Read2Bytes(this Span data, ushort startPos) 20 | { 21 | var hex = Convert.ToHexString(data.Slice(startPos, 2)); 22 | 23 | return ushort.Parse(hex, NumberStyles.HexNumber); 24 | } 25 | 26 | public static uint Read4Bytes(this Span input, ushort startPos) 27 | { 28 | var hex = Convert.ToHexString(input.Slice(startPos, 4)); 29 | 30 | return uint.Parse(hex, NumberStyles.HexNumber); 31 | } 32 | 33 | public static bool IsValid(this Span data) 34 | { 35 | if (data.Length < 8) 36 | return false; 37 | 38 | var header = Convert.ToHexString(data[..2]); //get hex from first 2 bytes 39 | 40 | if (header is not "4E57") 41 | return false; 42 | 43 | short crcCalc = 0; 44 | 45 | foreach (var b in data[..^3]) //sum up all bytes excluding the last 4 bytes 46 | crcCalc += b; 47 | 48 | //convert last 2 bytes to hex and get short from that hex 49 | var crcLo = data[^2..].Read2Bytes(0); 50 | 51 | return crcCalc == crcLo; 52 | } 53 | } -------------------------------------------------------------------------------- /src/Server/Endpoints/Settings/GetChargeAmpereValues/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.InverterService; 2 | using InverterMon.Server.Persistance.Settings; 3 | using InverterMon.Shared.Models; 4 | 5 | namespace InverterMon.Server.Endpoints.Settings.GetChargeAmpereValues; 6 | 7 | public class Endpoint : EndpointWithoutRequest 8 | { 9 | public CommandQueue Queue { get; set; } 10 | public UserSettings UserSettings { get; set; } 11 | 12 | static ChargeAmpereValues? _ampereValues; 13 | 14 | public override void Configure() 15 | { 16 | Get("settings/get-charge-ampere-values"); 17 | AllowAnonymous(); 18 | } 19 | 20 | public override async Task HandleAsync(CancellationToken c) 21 | { 22 | if (Env.IsDevelopment()) 23 | { 24 | await SendAsync( 25 | new() 26 | { 27 | CombinedAmpereValues = new[] { "010", "020", "030" }, 28 | UtilityAmpereValues = new[] { "04", "10", "20" } 29 | }); 30 | 31 | return; 32 | } 33 | 34 | if (_ampereValues is null) 35 | { 36 | var cmd1 = new InverterService.Commands.GetChargeAmpereValues(false); 37 | var cmd2 = new InverterService.Commands.GetChargeAmpereValues(true); 38 | Queue.AddCommands(cmd1, cmd2); 39 | 40 | await Task.WhenAll( 41 | cmd1.WhileProcessing(c, 5000), 42 | cmd2.WhileProcessing(c, 5000)); 43 | 44 | _ampereValues = new() 45 | { 46 | CombinedAmpereValues = cmd1.Result, 47 | UtilityAmpereValues = cmd2.Result 48 | }; 49 | } 50 | 51 | await SendAsync(_ampereValues); 52 | } 53 | } -------------------------------------------------------------------------------- /src/Client/Shared/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | display: flex; 20 | align-items: center; 21 | } 22 | 23 | .top-row ::deep a, .top-row ::deep .btn-link { 24 | white-space: nowrap; 25 | margin-left: 1.5rem; 26 | text-decoration: none; 27 | } 28 | 29 | .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { 30 | text-decoration: underline; 31 | } 32 | 33 | .top-row ::deep a:first-child { 34 | overflow: hidden; 35 | text-overflow: ellipsis; 36 | } 37 | 38 | @media (max-width: 640.98px) { 39 | .top-row:not(.auth) { 40 | display: none; 41 | } 42 | 43 | .top-row.auth { 44 | justify-content: space-between; 45 | } 46 | 47 | .top-row ::deep a, .top-row ::deep .btn-link { 48 | margin-left: 0; 49 | } 50 | } 51 | 52 | @media (min-width: 641px) { 53 | .page { 54 | flex-direction: row; 55 | } 56 | 57 | .sidebar { 58 | width: 250px; 59 | height: 100vh; 60 | position: sticky; 61 | top: 0; 62 | } 63 | 64 | .top-row { 65 | position: sticky; 66 | top: 0; 67 | z-index: 1; 68 | } 69 | 70 | .top-row.auth ::deep a:first-child { 71 | flex: 1; 72 | text-align: right; 73 | width: 0; 74 | } 75 | 76 | .top-row, article { 77 | padding-left: 2rem !important; 78 | padding-right: 1.5rem !important; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Server/Endpoints/PVLog/GetPVForDay/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.Persistance; 2 | using InverterMon.Server.Persistance.Settings; 3 | using InverterMon.Shared.Models; 4 | 5 | namespace InverterMon.Server.Endpoints.PVLog.GetPVForDay; 6 | 7 | public class Endpoint : Endpoint 8 | { 9 | public Database Db { get; set; } 10 | public UserSettings UsrSettings { get; set; } 11 | 12 | public override void Configure() 13 | { 14 | Get("/pv-log/get-pv-for-day/{DayNumber}"); 15 | AllowAnonymous(); 16 | } 17 | 18 | public override async Task HandleAsync(Request r, CancellationToken c) 19 | { 20 | var pvDay = Db.GetPvGenForDay(r.DayNumber); 21 | 22 | if (pvDay is null) 23 | { 24 | await SendNotFoundAsync(); 25 | 26 | return; 27 | } 28 | 29 | if (Env.IsDevelopment() && pvDay.TotalWattHours == 0) 30 | { 31 | pvDay = new() 32 | { 33 | Id = DateOnly.FromDateTime(DateTime.Now).DayNumber, 34 | TotalWattHours = Random.Shared.Next(3000) 35 | }; 36 | 37 | //pvDay.AllocateBuckets(6, 18); 38 | 39 | for (var i = 0; i < 97; i++) 40 | pvDay.WattPeaks.Add(i.ToString(), Random.Shared.Next(2000)); 41 | } 42 | 43 | Response.GraphRange = UsrSettings.PVGraphRange; 44 | Response.GraphTickCount = UsrSettings.PVGraphTickCount; 45 | Response.TotalKiloWattHours = Math.Round(pvDay.TotalWattHours / 1000, 2); 46 | Response.DayNumber = pvDay.Id; 47 | Response.DayName = DateOnly.FromDayNumber(pvDay.Id).ToString("dddd MMMM dd"); 48 | Response.WattPeaks = pvDay.WattPeaks.Select( 49 | p => new PVDay.WattPeak 50 | { 51 | MinuteBucket = p.Key, 52 | PeakWatt = p.Value 53 | }); 54 | } 55 | } -------------------------------------------------------------------------------- /src/Client/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | InverterMon 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 | 25 |
... Loading ...
26 |
27 |
28 |
29 | 30 | 31 | 43 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/Client/Shared/NavMenu.razor: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 | 42 |
43 | 44 | @code { 45 | private bool collapseNavMenu = true; 46 | 47 | private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; 48 | 49 | private void ToggleNavMenu() 50 | { 51 | collapseNavMenu = !collapseNavMenu; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/InverterMonWindow/Main.Designer.cs: -------------------------------------------------------------------------------- 1 | namespace InverterMonWindow; 2 | 3 | partial class Main 4 | { 5 | private System.ComponentModel.IContainer components = null; 6 | 7 | protected override void Dispose(bool disposing) 8 | { 9 | if (disposing && (components != null)) 10 | { 11 | components.Dispose(); 12 | } 13 | base.Dispose(disposing); 14 | } 15 | 16 | private void InitializeComponent() 17 | { 18 | components = new System.ComponentModel.Container(); 19 | System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Main)); 20 | web = new Microsoft.Web.WebView2.WinForms.WebView2(); 21 | TrayIcon = new NotifyIcon(components); 22 | ((System.ComponentModel.ISupportInitialize)web).BeginInit(); 23 | SuspendLayout(); 24 | // 25 | // web 26 | // 27 | web.AllowExternalDrop = false; 28 | web.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; 29 | web.CreationProperties = null; 30 | web.DefaultBackgroundColor = Color.White; 31 | web.Location = new Point(-1, 1); 32 | web.Name = "web"; 33 | web.Size = new Size(642, 556); 34 | web.Source = new Uri("http://inverter.djnitehawk.com", UriKind.Absolute); 35 | web.TabIndex = 0; 36 | web.ZoomFactor = 1D; 37 | // 38 | // TrayIcon 39 | // 40 | TrayIcon.Icon = (Icon)resources.GetObject("TrayIcon.Icon"); 41 | TrayIcon.Text = "TrayIcon"; 42 | // 43 | // Main 44 | // 45 | AutoScaleDimensions = new SizeF(7F, 17F); 46 | AutoScaleMode = AutoScaleMode.Font; 47 | ClientSize = new Size(639, 558); 48 | Controls.Add(web); 49 | MaximizeBox = false; 50 | Name = "Main"; 51 | ShowInTaskbar = false; 52 | StartPosition = FormStartPosition.CenterScreen; 53 | Text = "InverterMon Window"; 54 | TopMost = true; 55 | ((System.ComponentModel.ISupportInitialize)web).EndInit(); 56 | ResumeLayout(false); 57 | } 58 | 59 | private Microsoft.Web.WebView2.WinForms.WebView2 web; 60 | private NotifyIcon TrayIcon; 61 | } -------------------------------------------------------------------------------- /src/Server/Endpoints/GetStatus/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.InverterService; 2 | using InverterMon.Shared.Models; 3 | using System.Runtime.CompilerServices; 4 | 5 | namespace InverterMon.Server.Endpoints.GetStatus; 6 | 7 | public class Endpoint : EndpointWithoutRequest 8 | { 9 | public CommandQueue Queue { get; set; } 10 | 11 | public override void Configure() 12 | { 13 | Get("status"); 14 | AllowAnonymous(); 15 | } 16 | 17 | public override async Task HandleAsync(CancellationToken c) 18 | { 19 | try 20 | { 21 | await SendAsync(GetDataStream(c), cancellation: c); 22 | } 23 | catch (TaskCanceledException) 24 | { 25 | //nothing to do here 26 | } 27 | } 28 | 29 | async IAsyncEnumerable GetDataStream([EnumeratorCancellation] CancellationToken c) 30 | { 31 | var blank = new InverterStatus(); 32 | 33 | while (!c.IsCancellationRequested) 34 | { 35 | if (Env.IsDevelopment()) 36 | { 37 | var status = Queue.StatusCommand.Result; 38 | status.OutputVoltage = Random.Shared.Next(240); 39 | status.LoadWatts = Random.Shared.Next(3500); 40 | status.LoadPercentage = Random.Shared.Next(100); 41 | status.BatteryVoltage = Random.Shared.Next(24); 42 | status.BatteryChargeCurrent = Random.Shared.Next(20); 43 | status.BatteryDischargeCurrent = Random.Shared.Next(300); 44 | status.HeatSinkTemperature = Random.Shared.Next(300); 45 | status.PVInputCurrent = Random.Shared.Next(300); 46 | status.PVInputVoltage = Random.Shared.Next(300); 47 | status.PVInputWatt = Random.Shared.Next(1000); 48 | status.PV_MaxCapacity = 1000; 49 | status.BatteryCapacity = 100; 50 | 51 | yield return status; 52 | } 53 | else 54 | { 55 | yield return Queue.IsAcceptingCommands 56 | ? Queue.StatusCommand.Result 57 | : blank; 58 | } 59 | await Task.Delay(1000, c); 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/Client/wwwroot/service-worker.published.js: -------------------------------------------------------------------------------- 1 | // Caution! Be sure you understand the caveats before publishing an application with 2 | // offline support. See https://aka.ms/blazor-offline-considerations 3 | 4 | self.importScripts('./service-worker-assets.js'); 5 | self.addEventListener('install', event => event.waitUntil(onInstall(event))); 6 | self.addEventListener('activate', event => event.waitUntil(onActivate(event))); 7 | self.addEventListener('fetch', event => event.respondWith(onFetch(event))); 8 | 9 | const cacheNamePrefix = 'offline-cache-'; 10 | const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; 11 | const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; 12 | const offlineAssetsExclude = [ /^service-worker\.js$/ ]; 13 | 14 | async function onInstall(event) { 15 | console.info('Service worker: Install'); 16 | 17 | // Fetch and cache all matching items from the assets manifest 18 | const assetsRequests = self.assetsManifest.assets 19 | .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) 20 | .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) 21 | .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); 22 | await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); 23 | } 24 | 25 | async function onActivate(event) { 26 | console.info('Service worker: Activate'); 27 | 28 | // Delete unused caches 29 | const cacheKeys = await caches.keys(); 30 | await Promise.all(cacheKeys 31 | .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) 32 | .map(key => caches.delete(key))); 33 | } 34 | 35 | async function onFetch(event) { 36 | let cachedResponse = null; 37 | if (event.request.method === 'GET') { 38 | // For all navigation requests, try to serve index.html from cache 39 | // If you need some URLs to be server-rendered, edit the following check to exclude those URLs 40 | const shouldServeIndexHtml = event.request.mode === 'navigate'; 41 | 42 | const request = shouldServeIndexHtml ? 'index.html' : event.request; 43 | const cache = await caches.open(cacheName); 44 | cachedResponse = await cache.match(request); 45 | } 46 | 47 | return cachedResponse || fetch(event.request); 48 | } 49 | -------------------------------------------------------------------------------- /src/Server/InverterService/Commands/GetSettings.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Shared.Models; 2 | 3 | namespace InverterMon.Server.InverterService.Commands; 4 | 5 | class GetSettings : Command 6 | { 7 | public override string CommandString { get; set; } = "QPIRI"; 8 | 9 | public override void Parse(string responseFromInverter) 10 | { 11 | // 1) 230.0 - grid rating voltage 12 | // 2) 15.2 - grid rating current 13 | // 3) 230.0 - ac output rating voltage 14 | // 4) 50.0 - ac output rating frequency 15 | // 5) 15.2 - ac output rating current 16 | // 6) 3500 - ac output rating apparant power 17 | // 7) 3500 - ac output rating active power 18 | // 8) 24.0 - batt rating voltage 19 | // 9) 23.5 - batt back to grid voltage 20 | // 10) 23.4 - batt discharge cut off voltage 21 | // 11) 28.8 - batt bulk charging voltage 22 | // 12) 27.0 - batt float charging voltage 23 | // 13) 2 - battery type (0:agm / 1:flooded / 2: user) 24 | // 14) 10 - max ac charging current 25 | // 15) 020 - max combined charging current 26 | // 16) 1 - input voltage range (0:appliance / 1:ups) 27 | // 17) 1 - output source priority (0:utility first / 1:solar first / 2:solar>battery>utility) 28 | // 18) 3 - charge priority (0:utility first /1:solar first / 2:solar & utility / 3:only solar) 29 | // 19) 1 - parallel max number 30 | // 20) 01 - machine type 31 | // 21) 0 - topology 32 | // 22) 0 - output mode 33 | // 23) 28.5 - back to battery use voltage 34 | // 24) 0 - pv ok for parallel 35 | // 25) 1 - pv power balance 36 | 37 | if (responseFromInverter.StartsWith("(NAK")) 38 | return; 39 | 40 | var parts = responseFromInverter[1..].Split(' ', StringSplitOptions.RemoveEmptyEntries); 41 | 42 | Result.BackToGridVoltage = decimal.Parse(parts[9 - 1]); 43 | Result.DischargeCuttOffVoltage = decimal.Parse(parts[10 - 1]); 44 | Result.BulkChargeVoltage = decimal.Parse(parts[11 - 1]); 45 | Result.FloatChargeVoltage = decimal.Parse(parts[12 - 1]); 46 | Result.MaxACChargeCurrent = parts[14 - 1]; 47 | Result.MaxCombinedChargeCurrent = parts[15 - 1]; 48 | Result.OutputPriority = $"0{parts[17 - 1]}"; 49 | Result.ChargePriority = $"0{parts[18 - 1]}"; 50 | Result.BackToBatteryVoltage = decimal.Parse(parts[23 - 1]); 51 | } 52 | } -------------------------------------------------------------------------------- /src/InverterMonWindow/Main.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace InverterMonWindow; 4 | 5 | public partial class Main : Form 6 | { 7 | [LibraryImport("user32.dll")] 8 | [return: MarshalAs(UnmanagedType.Bool)] 9 | private static partial bool RegisterHotKey(IntPtr hWnd, int id, int fsModifiers, int vk); 10 | 11 | [LibraryImport("user32.dll")] 12 | [return: MarshalAs(UnmanagedType.Bool)] 13 | private static partial bool UnregisterHotKey(IntPtr hWnd, int id); 14 | 15 | public Main() 16 | { 17 | InitializeComponent(); 18 | TrayIcon.Click += TrayIcon_Click; 19 | Load += Main_Load; 20 | FormClosed += Main_FormClosed; 21 | Resize += Main_Resize; 22 | if (!RegisterHotKey(Handle, 1, (int)KeyModifier.Alt, (int)Keys.I)) 23 | MessageBox.Show("Failed to register hotkey!", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); 24 | } 25 | 26 | void Main_Resize(object? sender, EventArgs e) 27 | { 28 | if (WindowState == FormWindowState.Minimized) 29 | { 30 | WindowState = FormWindowState.Normal; 31 | Hide(); 32 | TrayIcon.Visible = true; 33 | } 34 | } 35 | 36 | void Main_FormClosed(object? sender, FormClosedEventArgs e) 37 | { 38 | Properties.Settings.Default.WindowPosition = Location; 39 | Properties.Settings.Default.Save(); 40 | UnregisterHotKey(Handle, 1); 41 | } 42 | 43 | void Main_Load(object? sender, EventArgs e) 44 | { 45 | Location = Properties.Settings.Default.WindowPosition; 46 | } 47 | 48 | void TrayIcon_Click(object? sender, EventArgs e) 49 | { 50 | Show(); 51 | WindowState = FormWindowState.Normal; 52 | TrayIcon.Visible = false; 53 | } 54 | 55 | protected override void WndProc(ref Message m) 56 | { 57 | base.WndProc(ref m); 58 | 59 | if (m.Msg == 0x0312) 60 | { 61 | switch (m.WParam.ToInt32()) 62 | { 63 | case 1: // Alt+I hotkey 64 | if (WindowState == FormWindowState.Normal) 65 | { 66 | WindowState = FormWindowState.Minimized; 67 | } 68 | else 69 | { 70 | TrayIcon_Click(null, null!); 71 | } 72 | break; 73 | } 74 | } 75 | } 76 | } 77 | 78 | [Flags] 79 | public enum KeyModifier 80 | { 81 | None = 0, 82 | Alt = 0x0001, 83 | Control = 0x0002, 84 | Shift = 0x0004, 85 | Winkey = 0x0008 86 | } 87 | -------------------------------------------------------------------------------- /src/Server/InverterMon.Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | CS8618;CA2016 8 | MIT 9 | direct 10 | 13 11 | 12 | 13 | 14 | none 15 | 16 | true 17 | linux-arm64 18 | true 19 | true 20 | false 21 | false 22 | false 23 | false 24 | false 25 | false 26 | true 27 | true 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/Shared/Models/BMSStatus.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace InverterMon.Shared.Models; 4 | 5 | public class BMSStatus 6 | { 7 | [JsonPropertyName("a")] 8 | public float AvailableCapacity => PackCapacity / 100f * CapacityPct; 9 | 10 | [JsonPropertyName("b")] 11 | public float AvgCellVoltage => Cells.Values.Average(); 12 | 13 | [JsonPropertyName("c")] 14 | public float AvgCurrentAmps { get; set; } 15 | 16 | [JsonPropertyName("d")] 17 | public ushort CapacityPct { get; set; } 18 | 19 | [JsonPropertyName("e")] 20 | public float CellDiff => MaxCell.Value - MinCell.Value; 21 | 22 | [JsonPropertyName("f")] 23 | public Dictionary Cells { get; set; } = new(); //key: cell number //val: cell voltage 24 | 25 | [JsonPropertyName("g")] 26 | public double CRate => Math.Round(AvgCurrentAmps / PackCapacity, 2, MidpointRounding.AwayFromZero); 27 | 28 | [JsonPropertyName("h")] 29 | public bool IsCharging { get; set; } 30 | 31 | [JsonPropertyName("i")] 32 | public bool IsDisCharging => !IsCharging; 33 | 34 | [JsonPropertyName("j")] 35 | public KeyValuePair MaxCell => Cells.MaxBy(x => x.Value); 36 | 37 | [JsonPropertyName("k")] 38 | public KeyValuePair MinCell => Cells.MinBy(x => x.Value); 39 | 40 | [JsonPropertyName("l")] 41 | public float MosTemp { get; set; } 42 | 43 | [JsonPropertyName("m")] 44 | public uint PackCapacity { get; set; } 45 | 46 | [JsonPropertyName("n")] 47 | public float PackVoltage { get; set; } 48 | 49 | [JsonPropertyName("o")] 50 | public float Temp1 { get; set; } 51 | 52 | [JsonPropertyName("p")] 53 | public float Temp2 { get; set; } 54 | 55 | [JsonPropertyName("q")] 56 | public ushort TimeHrs { get; set; } 57 | 58 | [JsonPropertyName("r")] 59 | public int TimeMins { get; set; } 60 | 61 | [JsonPropertyName("s")] 62 | public bool IsWarning { get; set; } 63 | 64 | [JsonPropertyName("t")] 65 | public double AvgPowerWatts => Math.Round(AvgCurrentAmps * PackVoltage, 0, MidpointRounding.AwayFromZero); 66 | 67 | [JsonPropertyName("u")] 68 | public float PackNominalVoltage { get; set; } 69 | 70 | public string GetTimeString() 71 | { 72 | var currentTime = DateTime.UtcNow 73 | .AddHours(5).AddMinutes(30); //only supports IST time zone :-( 74 | 75 | var futureTime = currentTime.AddHours(TimeHrs).AddMinutes(TimeMins); 76 | 77 | if (futureTime.Date == currentTime.Date) 78 | return futureTime.ToString("h:mm tt"); 79 | 80 | return futureTime.ToString("dddd h:mm tt"); 81 | } 82 | } -------------------------------------------------------------------------------- /src/Client/Pages/PVGenForDay.razor: -------------------------------------------------------------------------------- 1 | @page "/pvgen" 2 | @using InverterMon.Shared.Models 3 | @inject HttpClient Http 4 | 5 | PV Generation For Day 6 | 7 |
8 | @if(day == null) 9 | { 10 | 11 | } 12 | else 13 | { 14 |

15 | 18 | 19 | @day.DayName 20 | 21 | 24 |

25 | 26 |

27 | Total Generation: @day.TotalKiloWattHours kWh 28 |

29 | } 30 |
31 | 32 | @code{ 33 | private PVDay? day; 34 | 35 | private AreaConfig aConf = new() 36 | { 37 | Padding = "auto", 38 | AutoFit = true, 39 | XField = "Time", 40 | YField = "Watts", 41 | Smooth = false, 42 | Line = new() 43 | { 44 | Size = 1, 45 | Color = "#5598d7" 46 | }, 47 | AreaStyle = new() 48 | { 49 | Fill = "l(270) 0:#ffffff 0.5:#7ec2f3 1:#1890ff", 50 | } 51 | }; 52 | 53 | protected override async Task OnInitializedAsync() 54 | { 55 | await FetchForDay(); 56 | } 57 | 58 | private async Task FetchForDay(int? dayNumber = null) 59 | { 60 | try 61 | { 62 | day = await Http.GetFromJsonAsync($"api/pv-log/get-pv-for-day/{dayNumber ?? DateOnly.FromDateTime(DateTime.Now).DayNumber}"); 63 | aConf.XAxis = new() 64 | { 65 | Range = day?.GraphRange, 66 | TickCount = day?.GraphTickCount 67 | }; 68 | //StateHasChanged(); 69 | } 70 | catch (Exception) 71 | { 72 | //ignore 73 | } 74 | } 75 | 76 | private async Task GetNextDay() 77 | { 78 | var dayNum = this.day?.DayNumber+1; 79 | day = null; 80 | await FetchForDay(dayNum); 81 | 82 | if (day is null) 83 | await FetchForDay(dayNum - 1); 84 | 85 | StateHasChanged(); 86 | } 87 | 88 | private async Task GetPrevDay() 89 | { 90 | var dayNum = this.day?.DayNumber-1; 91 | day = null; 92 | await FetchForDay(dayNum); 93 | 94 | if (day is null) 95 | await FetchForDay(dayNum + 1); 96 | 97 | StateHasChanged(); 98 | } 99 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hybrid Inverter Monitor 2 | 3 | 4 | 5 | --- 6 | 7 | This application allows you to monitor chinese hybrid inverters such as `MPPSolar, Epever, Must,, Easun, Voltronic Axpert, etc.` in realtime via USB/Serial port as an alternative to the wifi module and cloud based apps such as SmartESS, Watchpower, Smartclient, etc. which can be at times unreliable. 8 | 9 | For this to work, you need to connect a linux computer such as a raspberry/orange pi or any other linux computer to the inverter via USB/Serial cable. Windows will not work due to the non-existence of a working device driver. 10 | 11 | Once your inverter is connected via a data cable, a new device path should appear either in `/dev/hidrawX` or `/dev/ttyUSBX`. Once that's available and if your inverter supports the **Voltronic** communication protocol, all you have to do is execute the `InverterMon.Server` executable. The binaries can be downloaded from the [releases section](https://github.com/dj-nitehawk/Hybrid-Inverter-Monitor/releases). Make sure to choose the correct architecture (x64/arm) for your machine. 12 | 13 | After the application has started successfully, you can simply open up a web browser and navigate to `http://ip.address.of.machine` to see the dashboard. It may take up to 5 seconds for the data to show up initially. 14 | 15 | If you have a firewall, please open port `80` to facilitate communication. 16 | 17 | In order to make the application/server automatically start at boot, follow the below procedure to create a `systemd` service. 18 | 19 | # Auto Start Configuration Steps 20 | 21 | open the following file (or the correct one for your OS): 22 | 23 | `sudo nano /lib/udev/rules.d/99-systemd.rules` 24 | 25 | add the following text to the end: 26 | ``` 27 | KERNEL=="ttyUSB1", SYMLINK="ttyUSB1", TAG+="systemd" 28 | ``` 29 | 30 | if your device is mounted as a **hidraw** device, change the value above to `hidrawX`. 31 | 32 | create a new file for the service 33 | 34 | `sudo nano /etc/systemd/system/invertermon.service` 35 | 36 | copy/paste the following: 37 | ```ini 38 | [Unit] 39 | Description=Hybrid Inverter Monitor 40 | 41 | #change here if device is hidraw 42 | After=dev-ttyUSB1.device 43 | 44 | [Service] 45 | Type=simple 46 | User=root 47 | Group=root 48 | UMask=000 49 | 50 | #put the downloaded files in here 51 | WorkingDirectory=/inverter 52 | ExecStart=/inverter/InverterMon.Server 53 | 54 | Restart=always 55 | RestartSec=30 56 | 57 | [Install] 58 | WantedBy=multi-user.target 59 | ``` 60 | run the following commands to enable and start the service: 61 | ``` 62 | sudo systemctl enable invertermon 63 | sudo systemctl start invertermon 64 | sudo systemctl status invertermon 65 | ``` 66 | restart the machine to check if the service was configured correctly. 67 | 68 | # JK BMS Support 69 | If you have a JK BMS + JK RS485 adapter + USB->TTL adapter, simply wire them up correctly and plug it in to the computer. 70 | The app will try to connect to the BMS via serial port by default at address `/dev/ttyUSB0`. 71 | If your USB->TTL device is mounted at a different device path, simply update the `appsettings.json` file with the correct path like so: 72 | ```json 73 | { 74 | "LaunchSettings": { 75 | "JkBmsAddress": "/dev/ttyUSB1" 76 | } 77 | } 78 | ``` 79 | -------------------------------------------------------------------------------- /src/Shared/Models/InverterStatus.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace InverterMon.Shared.Models; 4 | 5 | public class InverterStatus 6 | { 7 | [JsonPropertyName("a")] 8 | public int BatteryCapacity { get; set; } = 100; 9 | 10 | [JsonPropertyName("b")] 11 | public decimal BatteryChargeCRate => BatteryChargeCurrent == 0 ? 0 : Convert.ToDecimal(BatteryChargeCurrent) / BatteryCapacity; 12 | 13 | [JsonPropertyName("c")] 14 | public int BatteryChargeCurrent { get; set; } 15 | 16 | [JsonPropertyName("d")] 17 | public int BatteryChargeWatts => BatteryChargeCurrent == 0 ? 0 : Convert.ToInt32(BatteryChargeCurrent * BatteryVoltage); 18 | 19 | [JsonPropertyName("e")] 20 | public decimal BatteryDischargeCRate => BatteryDischargeCurrent == 0 ? 0 : Convert.ToDecimal(BatteryDischargeCurrent) / BatteryCapacity; 21 | 22 | [JsonPropertyName("f")] 23 | public int BatteryDischargeCurrent { get; set; } 24 | 25 | [JsonPropertyName("g")] 26 | public int BatteryDischargePotential => BatteryDischargeCurrent > 0 ? Convert.ToInt32(Convert.ToDouble(BatteryDischargeCurrent) / BatteryCapacity * 100) : 0; 27 | 28 | [JsonPropertyName("h")] 29 | public int BatteryDischargeWatts => BatteryDischargeCurrent == 0 ? 0 : Convert.ToInt32(BatteryDischargeCurrent * BatteryVoltage); 30 | 31 | [JsonPropertyName("i")] 32 | public decimal BatteryVoltage { get; set; } 33 | 34 | [JsonPropertyName("j")] 35 | public int GridUsageWatts => GridVoltage < 10 ? 0 : LoadWatts + BatteryChargeWatts - (PVInputWatt + BatteryDischargeWatts); 36 | 37 | [JsonPropertyName("k")] 38 | public decimal GridVoltage { get; set; } 39 | 40 | [JsonPropertyName("l")] 41 | public int HeatSinkTemperature { get; set; } 42 | 43 | [JsonPropertyName("m")] 44 | public decimal LoadCurrent => LoadWatts == 0 ? 0 : LoadWatts / OutputVoltage; 45 | 46 | [JsonPropertyName("n")] 47 | public decimal LoadPercentage { get; set; } 48 | 49 | [JsonPropertyName("o")] 50 | public int LoadWatts { get; set; } 51 | 52 | [JsonPropertyName("p")] 53 | public decimal OutputVoltage { get; set; } 54 | 55 | [JsonPropertyName("q")] 56 | public decimal PVInputCurrent { get; set; } 57 | 58 | [JsonPropertyName("r")] 59 | public decimal PVInputVoltage { get; set; } 60 | 61 | [JsonPropertyName("s")] 62 | public int PVInputWatt 63 | { 64 | get => pvInputWatt; 65 | set 66 | { 67 | if (value <= 0 || value == pvInputWatt) 68 | return; 69 | 70 | pvInputWatt = value; 71 | var interval = (DateTime.Now - pvInputWattHourLastComputed).TotalSeconds; 72 | PVInputWattHour += value / (3600 / Convert.ToDecimal(interval)); 73 | pvInputWattHourLastComputed = DateTime.Now; 74 | } 75 | } 76 | 77 | [JsonPropertyName("t")] 78 | public decimal PVInputWattHour { get; private set; } 79 | 80 | [JsonPropertyName("u")] 81 | public int PV_MaxCapacity { get; set; } 82 | 83 | [JsonPropertyName("v")] 84 | public int PVPotential => PVInputWatt > 0 ? Convert.ToInt32(Convert.ToDouble(PVInputWatt) / PV_MaxCapacity * 100) : 0; 85 | 86 | int pvInputWatt; 87 | DateTime pvInputWattHourLastComputed; 88 | 89 | public void RestorePVWattHours(decimal accruedWattHours) 90 | { 91 | PVInputWattHour = accruedWattHours; 92 | pvInputWattHourLastComputed = DateTime.Now; 93 | } 94 | 95 | public void ResetPVWattHourAccumulation() 96 | { 97 | PVInputWattHour = 0; 98 | pvInputWattHourLastComputed = DateTime.Now; 99 | } 100 | } -------------------------------------------------------------------------------- /src/Server/InverterService/CommandExecutor.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using ICommand = InverterMon.Server.InverterService.Commands.ICommand; 3 | 4 | namespace InverterMon.Server.InverterService; 5 | 6 | class CommandExecutor : BackgroundService 7 | { 8 | readonly CommandQueue queue; 9 | readonly ILogger logger; 10 | readonly string _devPath = "/dev/hidraw0"; 11 | readonly bool _isTroubleMode; 12 | readonly string _mppPath = "/usr/local/bin/mpp-solar"; 13 | 14 | public CommandExecutor(CommandQueue queue, IConfiguration config, ILogger log) 15 | { 16 | this.queue = queue; 17 | logger = log; 18 | _devPath = config["LaunchSettings:DeviceAddress"] ?? _devPath; 19 | _isTroubleMode = config["LaunchSettings:TroubleMode"] == "yes"; 20 | _mppPath = config["LaunchSettings:MppSolarPath"] ?? _mppPath; 21 | 22 | log.LogInformation("connecting to the inverter..."); 23 | 24 | var sw = new Stopwatch(); 25 | sw.Start(); 26 | 27 | while (!Connect() && sw.Elapsed.TotalMinutes <= 5) 28 | Thread.Sleep(10000); 29 | 30 | if (sw.Elapsed.TotalMinutes >= 5) 31 | log.LogInformation("inverter connecting timed out!"); 32 | } 33 | 34 | bool Connect() 35 | { 36 | if (!Inverter.Connect(_devPath, logger)) 37 | return false; 38 | 39 | logger.LogInformation("connected to inverter at: [{adr}]", _devPath); 40 | 41 | return true; 42 | } 43 | 44 | protected override async Task ExecuteAsync(CancellationToken ct) 45 | { 46 | var delay = 0; 47 | var timeout = TimeSpan.FromMinutes(5); 48 | 49 | while (!ct.IsCancellationRequested && delay <= timeout.TotalMilliseconds) 50 | { 51 | var cmd = queue.GetCommand(); 52 | 53 | if (cmd is not null) 54 | { 55 | try 56 | { 57 | await ExecuteCommand(cmd, ct); 58 | queue.IsAcceptingCommands = true; 59 | delay = 0; 60 | queue.RemoveCommand(); 61 | } 62 | catch (Exception x) 63 | { 64 | queue.IsAcceptingCommands = false; 65 | logger.LogError("command [{cmd}] failed with reason [{msg}]", cmd.CommandString, x.Message); 66 | await Task.Delay(delay += 1000); 67 | } 68 | } 69 | else 70 | await Task.Delay(500, ct); 71 | } 72 | logger.LogError("command execution halted due to excessive failures!"); 73 | } 74 | 75 | async Task ExecuteCommand(ICommand command, CancellationToken ct) 76 | { 77 | if (_isTroubleMode && command.IsTroublesomeCmd) 78 | { 79 | Inverter.Disconnect(); 80 | using var process = new Process(); 81 | process.StartInfo.FileName = _mppPath; 82 | process.StartInfo.Arguments = $"-p {_devPath} -o raw -c {command.CommandString}"; 83 | process.StartInfo.UseShellExecute = false; 84 | process.StartInfo.RedirectStandardOutput = true; 85 | process.Start(); 86 | command.Start(); 87 | var output = await process.StandardOutput.ReadToEndAsync(ct); 88 | var result = output.ParseCli()[1..^1]; 89 | command.Parse(result); 90 | command.End(); 91 | await process.WaitForExitAsync(ct); 92 | Inverter.Connect(_devPath, logger); 93 | } 94 | else 95 | { 96 | command.Start(); 97 | await Inverter.Write(command.CommandString, ct); 98 | command.Parse(await Inverter.Read(ct)); 99 | command.End(); 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /src/Server/Persistance/Database.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.InverterService; 2 | using InverterMon.Server.InverterService.Commands; 3 | using InverterMon.Server.Persistance.PVGen; 4 | using InverterMon.Server.Persistance.Settings; 5 | using LiteDB; 6 | 7 | namespace InverterMon.Server.Persistance; 8 | 9 | public class Database 10 | { 11 | readonly LiteDatabase _db; 12 | readonly CommandQueue _queue; 13 | readonly UserSettings _settings; 14 | readonly ILiteCollection _pvGenCollection; 15 | readonly ILiteCollection _usrSettingsCollection; 16 | PVGeneration? _today; 17 | 18 | public Database(IHostApplicationLifetime lifetime, CommandQueue queue, UserSettings settings) 19 | { 20 | _queue = queue; 21 | _settings = settings; 22 | _db = new("InverterMon.db") { CheckpointSize = 0 }; 23 | lifetime.ApplicationStopping.Register(() => _db?.Dispose()); 24 | _pvGenCollection = _db.GetCollection(); 25 | _usrSettingsCollection = _db.GetCollection(); 26 | RestoreTodaysPvWattHours(); 27 | RestoreUserSettings(); 28 | } 29 | 30 | //todo: break apart this class and put seperated logic in each vertical slice 31 | 32 | public void RestoreTodaysPvWattHours() 33 | { 34 | var todayDayNumber = DateOnly.FromDateTime(DateTime.Now).DayNumber; 35 | 36 | _today = _pvGenCollection 37 | .Query() 38 | .Where(pg => pg.Id == todayDayNumber) 39 | .SingleOrDefault(); 40 | 41 | if (_today is not null) 42 | _queue.StatusCommand.Result.RestorePVWattHours(_today.TotalWattHours); 43 | else 44 | { 45 | _today = new() { Id = todayDayNumber }; 46 | _today.SetTotalWattHours(0); 47 | _queue.StatusCommand.Result.RestorePVWattHours(0); 48 | _pvGenCollection.Insert(_today); 49 | _db.Checkpoint(); 50 | } 51 | } 52 | 53 | public async Task UpdateTodaysPvGeneration(GetStatus cmd, CancellationToken c) 54 | { 55 | var hourNow = DateTime.Now.Hour; 56 | 57 | if (hourNow < _settings.SunlightStartHour || hourNow >= _settings.SunlightEndHour) 58 | return; 59 | 60 | await cmd.WhileProcessing(c); 61 | 62 | var todayDayNumber = DateOnly.FromDateTime(DateTime.Now).DayNumber; 63 | 64 | if (_today?.Id == todayDayNumber) 65 | { 66 | _today.SetWattPeaks(cmd.Result.PVInputWatt); 67 | _today.SetTotalWattHours(cmd.Result.PVInputWattHour); 68 | _pvGenCollection.Update(_today); 69 | } 70 | else 71 | { 72 | cmd.Result.ResetPVWattHourAccumulation(); //it's a new day. start accumulation from scratch. 73 | _today = new() { Id = todayDayNumber }; 74 | _today.SetTotalWattHours(0); 75 | _pvGenCollection.Insert(_today); 76 | } 77 | _db.Checkpoint(); 78 | } 79 | 80 | public PVGeneration? GetPvGenForDay(int dayNumber) 81 | => _pvGenCollection.FindOne(p => p.Id == dayNumber); 82 | 83 | public void RestoreUserSettings() 84 | { 85 | var settings = _usrSettingsCollection.FindById(1); 86 | 87 | if (settings is not null) 88 | { 89 | _settings.PV_MaxCapacity = settings.PV_MaxCapacity; 90 | _settings.BatteryCapacity = settings.BatteryCapacity; 91 | _settings.BatteryNominalVoltage = settings.BatteryNominalVoltage; 92 | _settings.SunlightStartHour = settings.SunlightStartHour; 93 | _settings.SunlightEndHour = settings.SunlightEndHour; 94 | } 95 | else 96 | { 97 | _usrSettingsCollection.Insert(_settings); 98 | _db.Checkpoint(); 99 | } 100 | } 101 | 102 | public void UpdateUserSettings(UserSettings settings) 103 | { 104 | _usrSettingsCollection.Update(settings); 105 | _db.Checkpoint(); 106 | } 107 | } -------------------------------------------------------------------------------- /src/Client/wwwroot/css/open-iconic/README.md: -------------------------------------------------------------------------------- 1 | [Open Iconic v1.1.1](http://useiconic.com/open) 2 | =========== 3 | 4 | ### Open Iconic is the open source sibling of [Iconic](http://useiconic.com). It is a hyper-legible collection of 223 icons with a tiny footprint—ready to use with Bootstrap and Foundation. [View the collection](http://useiconic.com/open#icons) 5 | 6 | 7 | 8 | ## What's in Open Iconic? 9 | 10 | * 223 icons designed to be legible down to 8 pixels 11 | * Super-light SVG files - 61.8 for the entire set 12 | * SVG sprite—the modern replacement for icon fonts 13 | * Webfont (EOT, OTF, SVG, TTF, WOFF), PNG and WebP formats 14 | * Webfont stylesheets (including versions for Bootstrap and Foundation) in CSS, LESS, SCSS and Stylus formats 15 | * PNG and WebP raster images in 8px, 16px, 24px, 32px, 48px and 64px. 16 | 17 | 18 | ## Getting Started 19 | 20 | #### For code samples and everything else you need to get started with Open Iconic, check out our [Icons](http://useiconic.com/open#icons) and [Reference](http://useiconic.com/open#reference) sections. 21 | 22 | ### General Usage 23 | 24 | #### Using Open Iconic's SVGs 25 | 26 | We like SVGs and we think they're the way to display icons on the web. Since Open Iconic are just basic SVGs, we suggest you display them like you would any other image (don't forget the `alt` attribute). 27 | 28 | ``` 29 | icon name 30 | ``` 31 | 32 | #### Using Open Iconic's SVG Sprite 33 | 34 | Open Iconic also comes in a SVG sprite which allows you to display all the icons in the set with a single request. It's like an icon font, without being a hack. 35 | 36 | Adding an icon from an SVG sprite is a little different than what you're used to, but it's still a piece of cake. *Tip: To make your icons easily style able, we suggest adding a general class to the* `` *tag and a unique class name for each different icon in the* `` *tag.* 37 | 38 | ``` 39 | 40 | 41 | 42 | ``` 43 | 44 | Sizing icons only needs basic CSS. All the icons are in a square format, so just set the `` tag with equal width and height dimensions. 45 | 46 | ``` 47 | .icon { 48 | width: 16px; 49 | height: 16px; 50 | } 51 | ``` 52 | 53 | Coloring icons is even easier. All you need to do is set the `fill` rule on the `` tag. 54 | 55 | ``` 56 | .icon-account-login { 57 | fill: #f00; 58 | } 59 | ``` 60 | 61 | To learn more about SVG Sprites, read [Chris Coyier's guide](http://css-tricks.com/svg-sprites-use-better-icon-fonts/). 62 | 63 | #### Using Open Iconic's Icon Font... 64 | 65 | 66 | ##### …with Bootstrap 67 | 68 | You can find our Bootstrap stylesheets in `font/css/open-iconic-bootstrap.{css, less, scss, styl}` 69 | 70 | 71 | ``` 72 | 73 | ``` 74 | 75 | 76 | ``` 77 | 78 | ``` 79 | 80 | ##### …with Foundation 81 | 82 | You can find our Foundation stylesheets in `font/css/open-iconic-foundation.{css, less, scss, styl}` 83 | 84 | ``` 85 | 86 | ``` 87 | 88 | 89 | ``` 90 | 91 | ``` 92 | 93 | ##### …on its own 94 | 95 | You can find our default stylesheets in `font/css/open-iconic.{css, less, scss, styl}` 96 | 97 | ``` 98 | 99 | ``` 100 | 101 | ``` 102 | 103 | ``` 104 | 105 | 106 | ## License 107 | 108 | ### Icons 109 | 110 | All code (including SVG markup) is under the [MIT License](http://opensource.org/licenses/MIT). 111 | 112 | ### Fonts 113 | 114 | All fonts are under the [SIL Licensed](http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web). 115 | -------------------------------------------------------------------------------- /src/Client/wwwroot/css/app.css: -------------------------------------------------------------------------------- 1 | @import url('open-iconic/font/css/open-iconic-bootstrap.min.css'); 2 | 3 | html, body { 4 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 5 | } 6 | 7 | h1:focus { 8 | outline: none; 9 | } 10 | 11 | a, .btn-link { 12 | color: #0071c1; 13 | } 14 | 15 | .btn-primary { 16 | color: #fff; 17 | background-color: #1b6ec2; 18 | border-color: #1861ac; 19 | } 20 | 21 | .content { 22 | padding-top: 0.5rem; 23 | } 24 | 25 | .valid.modified:not([type=checkbox]) { 26 | outline: 1px solid #26b050; 27 | } 28 | 29 | .invalid { 30 | outline: 1px solid red; 31 | } 32 | 33 | .validation-message { 34 | color: red; 35 | } 36 | 37 | #blazor-error-ui { 38 | background: lightyellow; 39 | bottom: 0; 40 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 41 | display: none; 42 | left: 0; 43 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 44 | position: fixed; 45 | width: 100%; 46 | z-index: 1000; 47 | } 48 | 49 | #blazor-error-ui .dismiss { 50 | cursor: pointer; 51 | position: absolute; 52 | right: 0.75rem; 53 | top: 0.5rem; 54 | } 55 | 56 | .blazor-error-boundary { 57 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 58 | padding: 1rem 1rem 1rem 3.7rem; 59 | color: white; 60 | } 61 | 62 | .blazor-error-boundary::after { 63 | content: "An error has occurred." 64 | } 65 | 66 | @media only screen and (max-width: 415px) { 67 | .voltage { 68 | font-size: 0.6em !important; 69 | margin: 1em 0; 70 | } 71 | 72 | .charge-discharge { 73 | font-size: 0.8em !important; 74 | } 75 | 76 | .pack-capacity { 77 | font-size: 1.1em !important; 78 | } 79 | } 80 | 81 | .unit { 82 | font-size: 0.9em !important; 83 | } 84 | 85 | .navbar { 86 | padding-top: .1rem; 87 | padding-bottom: .1rem; 88 | } 89 | 90 | .navbar-toggler-icon { 91 | width: 0.75rem; 92 | height: 0.75rem; 93 | } 94 | 95 | .card-header { 96 | padding: 0.2rem; 97 | } 98 | 99 | .card-body { 100 | padding: 0.2rem 101 | } 102 | 103 | .row { 104 | --bs-gutter-x: 0.4rem; 105 | } 106 | 107 | .blinktext { 108 | animation: blink-text 900ms linear infinite !important; 109 | } 110 | 111 | @keyframes blink-text { 112 | 0% { 113 | opacity: 1; 114 | } 115 | 116 | 50% { 117 | opacity: 1; 118 | } 119 | 120 | 50.01% { 121 | opacity: 0; 122 | } 123 | 124 | 100% { 125 | opacity: 0; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Client/wwwroot/css/open-iconic/FONT-LICENSE: -------------------------------------------------------------------------------- 1 | SIL OPEN FONT LICENSE Version 1.1 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | PREAMBLE 6 | The goals of the Open Font License (OFL) are to stimulate worldwide 7 | development of collaborative font projects, to support the font creation 8 | efforts of academic and linguistic communities, and to provide a free and 9 | open framework in which fonts may be shared and improved in partnership 10 | with others. 11 | 12 | The OFL allows the licensed fonts to be used, studied, modified and 13 | redistributed freely as long as they are not sold by themselves. The 14 | fonts, including any derivative works, can be bundled, embedded, 15 | redistributed and/or sold with any software provided that any reserved 16 | names are not used by derivative works. The fonts and derivatives, 17 | however, cannot be released under any other type of license. The 18 | requirement for fonts to remain under this license does not apply 19 | to any document created using the fonts or their derivatives. 20 | 21 | DEFINITIONS 22 | "Font Software" refers to the set of files released by the Copyright 23 | Holder(s) under this license and clearly marked as such. This may 24 | include source files, build scripts and documentation. 25 | 26 | "Reserved Font Name" refers to any names specified as such after the 27 | copyright statement(s). 28 | 29 | "Original Version" refers to the collection of Font Software components as 30 | distributed by the Copyright Holder(s). 31 | 32 | "Modified Version" refers to any derivative made by adding to, deleting, 33 | or substituting -- in part or in whole -- any of the components of the 34 | Original Version, by changing formats or by porting the Font Software to a 35 | new environment. 36 | 37 | "Author" refers to any designer, engineer, programmer, technical 38 | writer or other person who contributed to the Font Software. 39 | 40 | PERMISSION & CONDITIONS 41 | Permission is hereby granted, free of charge, to any person obtaining 42 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 43 | redistribute, and sell modified and unmodified copies of the Font 44 | Software, subject to the following conditions: 45 | 46 | 1) Neither the Font Software nor any of its individual components, 47 | in Original or Modified Versions, may be sold by itself. 48 | 49 | 2) Original or Modified Versions of the Font Software may be bundled, 50 | redistributed and/or sold with any software, provided that each copy 51 | contains the above copyright notice and this license. These can be 52 | included either as stand-alone text files, human-readable headers or 53 | in the appropriate machine-readable metadata fields within text or 54 | binary files as long as those fields can be easily viewed by the user. 55 | 56 | 3) No Modified Version of the Font Software may use the Reserved Font 57 | Name(s) unless explicit written permission is granted by the corresponding 58 | Copyright Holder. This restriction only applies to the primary font name as 59 | presented to the users. 60 | 61 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 62 | Software shall not be used to promote, endorse or advertise any 63 | Modified Version, except to acknowledge the contribution(s) of the 64 | Copyright Holder(s) and the Author(s) or with their explicit written 65 | permission. 66 | 67 | 5) The Font Software, modified or unmodified, in part or in whole, 68 | must be distributed entirely under this license, and must not be 69 | distributed under any other license. The requirement for fonts to 70 | remain under this license does not apply to any document created 71 | using the Font Software. 72 | 73 | TERMINATION 74 | This license becomes null and void if any of the above conditions are 75 | not met. 76 | 77 | DISCLAIMER 78 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 79 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 80 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 81 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 82 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 83 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 84 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 85 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 86 | OTHER DEALINGS IN THE FONT SOFTWARE. 87 | -------------------------------------------------------------------------------- /src/Server/InverterService/Inverter.cs: -------------------------------------------------------------------------------- 1 | using System.IO.Ports; 2 | using System.Text; 3 | 4 | namespace InverterMon.Server.InverterService; 5 | 6 | public static class Inverter 7 | { 8 | static SerialPort? _serialPort; 9 | static FileStream? _fileStream; 10 | 11 | public static bool Connect(string devicePath, ILogger logger) 12 | { 13 | try 14 | { 15 | if (devicePath.Contains("/hidraw", StringComparison.OrdinalIgnoreCase)) 16 | { 17 | _fileStream = new(devicePath, FileMode.Open, FileAccess.ReadWrite); 18 | 19 | return true; 20 | } 21 | 22 | if (devicePath.Contains("/ttyUSB", StringComparison.OrdinalIgnoreCase) || devicePath.Contains("COM", StringComparison.OrdinalIgnoreCase)) 23 | { 24 | _serialPort = new(devicePath) 25 | { 26 | BaudRate = 2400, 27 | Parity = Parity.None, 28 | DataBits = 8, 29 | StopBits = StopBits.One, 30 | Handshake = Handshake.None 31 | }; 32 | _serialPort.Open(); 33 | 34 | return true; 35 | } 36 | logger.LogError("device path [{path}] is not acceptable!", devicePath); 37 | } 38 | catch (Exception x) 39 | { 40 | logger.LogError("connection error at [{path}]. reason: [{reason}]", devicePath, x.Message); 41 | } 42 | 43 | return false; 44 | } 45 | 46 | public static void Disconnect() 47 | { 48 | _serialPort?.Close(); 49 | _serialPort?.Dispose(); 50 | _fileStream?.Close(); 51 | _fileStream?.Dispose(); 52 | } 53 | 54 | static readonly byte[] _writeBuffer = new byte[512]; 55 | 56 | public static Task Write(string command, CancellationToken ct) 57 | { 58 | var cmdBytes = Encoding.ASCII.GetBytes(command); 59 | var crc = CalculateXmodemCrc16(command); 60 | 61 | Buffer.BlockCopy(cmdBytes, 0, _writeBuffer, 0, cmdBytes.Length); 62 | _writeBuffer[cmdBytes.Length] = (byte)(crc >> 8); 63 | _writeBuffer[cmdBytes.Length + 1] = (byte)(crc & 0xff); 64 | _writeBuffer[cmdBytes.Length + 2] = 0x0d; 65 | 66 | if (_fileStream != null) 67 | return _fileStream.WriteAsync(_writeBuffer, 0, cmdBytes.Length + 3, ct); 68 | 69 | return _serialPort != null 70 | ? _serialPort.BaseStream.WriteAsync(_writeBuffer, 0, cmdBytes.Length + 3, ct) 71 | : Task.CompletedTask; 72 | } 73 | 74 | static readonly byte[] _readBuffer = new byte[1024]; 75 | 76 | public static async Task Read(CancellationToken ct) 77 | { 78 | var pos = 0; 79 | const byte eol = 0x0d; 80 | 81 | if (_fileStream != null) 82 | { 83 | do 84 | { 85 | var readCount = await _fileStream.ReadAsync(_readBuffer.AsMemory(pos, _readBuffer.Length - pos), ct); 86 | 87 | if (readCount > 0) 88 | { 89 | pos += readCount; 90 | 91 | for (var i = pos - readCount; i < pos; i++) 92 | { 93 | if (_readBuffer[i] == eol) 94 | return Encoding.ASCII.GetString(_readBuffer, 0, i - 2).Sanitize(); 95 | } 96 | } 97 | } while (pos < _readBuffer.Length); 98 | } 99 | else if (_serialPort != null) 100 | { 101 | do 102 | { 103 | var readCount = await _serialPort.BaseStream.ReadAsync(_readBuffer.AsMemory(pos, _readBuffer.Length - pos), ct); 104 | 105 | if (readCount > 0) 106 | { 107 | pos += readCount; 108 | 109 | for (var i = pos - readCount; i < pos; i++) 110 | { 111 | if (_readBuffer[i] == eol) 112 | return Encoding.ASCII.GetString(_readBuffer, 0, i - 2).Sanitize(); 113 | } 114 | } 115 | } while (pos < _readBuffer.Length); 116 | } 117 | else 118 | throw new InvalidOperationException("inverter not connected."); 119 | 120 | throw new InvalidOperationException("buffer overflow."); 121 | } 122 | 123 | static ushort CalculateXmodemCrc16(string data) 124 | { 125 | ushort crc = 0; 126 | var length = data.Length; 127 | 128 | for (var i = 0; i < length; i++) 129 | { 130 | crc ^= (ushort)(data[i] << 8); 131 | 132 | for (var j = 0; j < 8; j++) 133 | { 134 | if ((crc & 0x8000) != 0) 135 | crc = (ushort)((crc << 1) ^ 0x1021); 136 | else 137 | crc <<= 1; 138 | } 139 | } 140 | 141 | return crc; 142 | } 143 | } -------------------------------------------------------------------------------- /src/InverterMon.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.2.32616.157 4 | MinimumVisualStudioVersion = 16.0.0.0 5 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InverterMon.Server", "Server\InverterMon.Server.csproj", "{B608F59F-4C72-43FD-820C-A0BADF4591AF}" 6 | EndProject 7 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InverterMon.Client", "Client\InverterMon.Client.csproj", "{A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}" 8 | EndProject 9 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InverterMon.Shared", "Shared\InverterMon.Shared.csproj", "{5B01AF46-3275-4F57-B3EA-5F616DB4214B}" 10 | EndProject 11 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InverterMonWindow", "InverterMonWindow\InverterMonWindow.csproj", "{9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}" 12 | EndProject 13 | Global 14 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 15 | Debug|Any CPU = Debug|Any CPU 16 | Debug|x64 = Debug|x64 17 | Debug|x86 = Debug|x86 18 | Release|Any CPU = Release|Any CPU 19 | Release|x64 = Release|x64 20 | Release|x86 = Release|x86 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Debug|x64.ActiveCfg = Debug|Any CPU 26 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Debug|x64.Build.0 = Debug|Any CPU 27 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Debug|x86.ActiveCfg = Debug|Any CPU 28 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Debug|x86.Build.0 = Debug|Any CPU 29 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Release|x64.ActiveCfg = Release|Any CPU 32 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Release|x64.Build.0 = Release|Any CPU 33 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Release|x86.ActiveCfg = Release|Any CPU 34 | {B608F59F-4C72-43FD-820C-A0BADF4591AF}.Release|x86.Build.0 = Release|Any CPU 35 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Debug|x64.ActiveCfg = Debug|Any CPU 38 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Debug|x64.Build.0 = Debug|Any CPU 39 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Debug|x86.ActiveCfg = Debug|Any CPU 40 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Debug|x86.Build.0 = Debug|Any CPU 41 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Release|x64.ActiveCfg = Release|Any CPU 44 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Release|x64.Build.0 = Release|Any CPU 45 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Release|x86.ActiveCfg = Release|Any CPU 46 | {A973C7CD-FE6E-4CA5-A065-1BBE250C50D6}.Release|x86.Build.0 = Release|Any CPU 47 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Debug|x64.ActiveCfg = Debug|Any CPU 50 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Debug|x64.Build.0 = Debug|Any CPU 51 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Debug|x86.ActiveCfg = Debug|Any CPU 52 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Debug|x86.Build.0 = Debug|Any CPU 53 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Release|x64.ActiveCfg = Release|Any CPU 56 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Release|x64.Build.0 = Release|Any CPU 57 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Release|x86.ActiveCfg = Release|Any CPU 58 | {5B01AF46-3275-4F57-B3EA-5F616DB4214B}.Release|x86.Build.0 = Release|Any CPU 59 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 60 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Debug|Any CPU.Build.0 = Debug|Any CPU 61 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Debug|x64.ActiveCfg = Debug|Any CPU 62 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Debug|x64.Build.0 = Debug|Any CPU 63 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Debug|x86.ActiveCfg = Debug|Any CPU 64 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Debug|x86.Build.0 = Debug|Any CPU 65 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Release|Any CPU.ActiveCfg = Release|Any CPU 66 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Release|Any CPU.Build.0 = Release|Any CPU 67 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Release|x64.ActiveCfg = Release|Any CPU 68 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Release|x64.Build.0 = Release|Any CPU 69 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Release|x86.ActiveCfg = Release|Any CPU 70 | {9DC94527-CBD8-4807-B6AA-2E079D5C8CA0}.Release|x86.Build.0 = Release|Any CPU 71 | EndGlobalSection 72 | GlobalSection(SolutionProperties) = preSolution 73 | HideSolutionNode = FALSE 74 | EndGlobalSection 75 | GlobalSection(ExtensibilityGlobals) = postSolution 76 | SolutionGuid = {BABEF52C-E442-4628-A6E7-8FC7C84CFF67} 77 | EndGlobalSection 78 | EndGlobal 79 | -------------------------------------------------------------------------------- /src/Server/BatteryService/JK-BMS-RS485-Service.cs: -------------------------------------------------------------------------------- 1 | using InverterMon.Server.Persistance.Settings; 2 | using InverterMon.Shared.Models; 3 | using SerialPortLib; 4 | 5 | namespace InverterMon.Server.BatteryService; 6 | 7 | public class JkBms 8 | { 9 | public BMSStatus Status { get; } = new(); 10 | public bool IsConnected => Status.PackVoltage > 0; 11 | 12 | const int PollFrequencyMillis = 1000; 13 | readonly AmpValQueue _recentAmpReadings = new(5); //avg value over 5 readings (~5secs) 14 | readonly SerialPortInput _bms = new(); 15 | 16 | public JkBms(UserSettings userSettings, IConfiguration config, ILogger logger, IWebHostEnvironment env, IHostApplicationLifetime appLife) 17 | { 18 | if (env.IsDevelopment()) 19 | { 20 | FillDummyData(); 21 | 22 | return; 23 | } 24 | 25 | Status.PackNominalVoltage = userSettings.BatteryNominalVoltage; 26 | var bmsAddress = config["LaunchSettings:JkBmsAddress"] ?? "/dev/ttyUSB0"; 27 | _bms.SetPort(bmsAddress); 28 | _bms.ConnectionStatusChanged += ConnectionStatusChanged; 29 | _bms.MessageReceived += MessageReceived; 30 | appLife.ApplicationStopping.Register(_bms.Disconnect); 31 | 32 | Task.Run( 33 | async () => 34 | { 35 | var ct = new CancellationTokenSource(TimeSpan.FromMinutes(5)).Token; 36 | 37 | while (!ct.IsCancellationRequested && !_bms.IsConnected) 38 | { 39 | var success = _bms.Connect(); 40 | 41 | if (success) 42 | logger.LogInformation("bms port opened!"); 43 | else 44 | { 45 | logger.LogWarning("trying to open bms port at: {address}", bmsAddress); 46 | await Task.Delay(10000); 47 | } 48 | } 49 | }); 50 | } 51 | 52 | void ConnectionStatusChanged(object sender, ConnectionStatusChangedEventArgs e) 53 | { 54 | if (e.Connected) 55 | _bms.QueryData(); 56 | } 57 | 58 | void MessageReceived(object sender, MessageReceivedEventArgs evnt) 59 | { 60 | var data = evnt.Data.AsSpan(); 61 | 62 | if (!data.IsValid()) 63 | { 64 | Thread.Sleep(PollFrequencyMillis); 65 | _bms.QueryData(); 66 | 67 | return; 68 | } 69 | 70 | var res = data[11..]; //skip the first 10 bytes 71 | var cellCount = res[1] / 3; //pos 1 is total cell bytes length. 3 bytes per cell. 72 | 73 | ushort pos = 0; 74 | for (byte i = 1; i <= cellCount; i++) 75 | 76 | //cell voltage groups (of 3 bytes) start at pos 2 77 | //first cell voltage starts at position 3 (pos 2 is cell number). voltage value is next 2 bytes. 78 | // ex: .....,1,X,X,2,Y,Y,3,Z,Z 79 | //position is increased by 3 bytes in order to skip the address/code byte 80 | Status.Cells[i] = res.Read2Bytes(pos += 3) / 1000f; 81 | 82 | Status.MosTemp = res.Read2Bytes(pos += 3); 83 | Status.Temp1 = res.Read2Bytes(pos += 3); 84 | Status.Temp2 = res.Read2Bytes(pos += 3); 85 | Status.PackVoltage = res.Read2Bytes(pos += 3) / 100f; 86 | 87 | var rawVal = res.Read2Bytes(pos += 3); 88 | Status.IsCharging = 89 | Convert.ToBoolean( 90 | int.Parse(Convert.ToString(rawVal, 2).PadLeft(16, '0')[..1])); //pick first bit of padded 16 bit binary representation and turn it in to a bool 91 | 92 | rawVal &= (1 << 15) - 1; //unset the MSB with a bitmask to get correct ampere reading 93 | var ampVal = rawVal / 100f; 94 | _recentAmpReadings.Store(ampVal, Status.IsCharging); 95 | 96 | Status.AvgCurrentAmps = _recentAmpReadings.GetAverage(); 97 | Status.CapacityPct = Convert.ToUInt16(res[pos += 3]); 98 | Status.IsWarning = res.Read2Bytes(pos += 15) > 0; 99 | Status.PackCapacity = res.Read4Bytes(pos += 88); 100 | 101 | if (Status.AvgCurrentAmps > 0) 102 | { 103 | float timeLeft; 104 | if (Status.IsCharging) 105 | timeLeft = (Status.PackCapacity - Status.AvailableCapacity) / Status.AvgCurrentAmps; 106 | else 107 | timeLeft = Status.AvailableCapacity / Status.AvgCurrentAmps; 108 | 109 | var tSpan = TimeSpan.FromHours(timeLeft); 110 | Status.TimeHrs = (ushort)tSpan.TotalHours; 111 | Status.TimeMins = tSpan.Minutes; 112 | } 113 | else 114 | { 115 | Status.TimeHrs = 0; 116 | Status.TimeMins = 0; 117 | } 118 | 119 | Thread.Sleep(PollFrequencyMillis); 120 | _bms.QueryData(); 121 | } 122 | 123 | void FillDummyData() 124 | { 125 | Status.MosTemp = 30.1f; 126 | Status.Temp1 = 28.5f; 127 | Status.Temp2 = 29.2f; 128 | Status.PackVoltage = 25.6f; 129 | Status.IsCharging = true; 130 | Status.AvgCurrentAmps = 21.444f; 131 | Status.CapacityPct = 90; 132 | Status.PackCapacity = 320; 133 | Status.PackNominalVoltage = 51.2f; 134 | Status.IsWarning = false; 135 | Status.TimeHrs = 24; 136 | Status.TimeMins = 10; 137 | for (byte i = 1; i <= 8; i++) 138 | Status.Cells.Add(i, 1.110f); 139 | } 140 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | #*.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | 352 | *.db 353 | 354 | src/InverterMonWindow/.idea/.idea.InverterMonWindow.dir/.idea/ 355 | 356 | src/.idea/.idea.InverterMon/.idea/ 357 | -------------------------------------------------------------------------------- /src/Client/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Icons;src:url(../fonts/open-iconic.eot);src:url(../fonts/open-iconic.eot?#iconic-sm) format('embedded-opentype'),url(../fonts/open-iconic.woff) format('woff'),url(../fonts/open-iconic.ttf) format('truetype'),url(../fonts/open-iconic.otf) format('opentype'),url(../fonts/open-iconic.svg#iconic-sm) format('svg');font-weight:400;font-style:normal}.oi{position:relative;top:1px;display:inline-block;speak:none;font-family:Icons;font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.oi:empty:before{width:1em;text-align:center;box-sizing:content-box}.oi.oi-align-center:before{text-align:center}.oi.oi-align-left:before{text-align:left}.oi.oi-align-right:before{text-align:right}.oi.oi-flip-horizontal:before{-webkit-transform:scale(-1,1);-ms-transform:scale(-1,1);transform:scale(-1,1)}.oi.oi-flip-vertical:before{-webkit-transform:scale(1,-1);-ms-transform:scale(-1,1);transform:scale(1,-1)}.oi.oi-flip-horizontal-vertical:before{-webkit-transform:scale(-1,-1);-ms-transform:scale(-1,1);transform:scale(-1,-1)}.oi-account-login:before{content:'\e000'}.oi-account-logout:before{content:'\e001'}.oi-action-redo:before{content:'\e002'}.oi-action-undo:before{content:'\e003'}.oi-align-center:before{content:'\e004'}.oi-align-left:before{content:'\e005'}.oi-align-right:before{content:'\e006'}.oi-aperture:before{content:'\e007'}.oi-arrow-bottom:before{content:'\e008'}.oi-arrow-circle-bottom:before{content:'\e009'}.oi-arrow-circle-left:before{content:'\e00a'}.oi-arrow-circle-right:before{content:'\e00b'}.oi-arrow-circle-top:before{content:'\e00c'}.oi-arrow-left:before{content:'\e00d'}.oi-arrow-right:before{content:'\e00e'}.oi-arrow-thick-bottom:before{content:'\e00f'}.oi-arrow-thick-left:before{content:'\e010'}.oi-arrow-thick-right:before{content:'\e011'}.oi-arrow-thick-top:before{content:'\e012'}.oi-arrow-top:before{content:'\e013'}.oi-audio-spectrum:before{content:'\e014'}.oi-audio:before{content:'\e015'}.oi-badge:before{content:'\e016'}.oi-ban:before{content:'\e017'}.oi-bar-chart:before{content:'\e018'}.oi-basket:before{content:'\e019'}.oi-battery-empty:before{content:'\e01a'}.oi-battery-full:before{content:'\e01b'}.oi-beaker:before{content:'\e01c'}.oi-bell:before{content:'\e01d'}.oi-bluetooth:before{content:'\e01e'}.oi-bold:before{content:'\e01f'}.oi-bolt:before{content:'\e020'}.oi-book:before{content:'\e021'}.oi-bookmark:before{content:'\e022'}.oi-box:before{content:'\e023'}.oi-briefcase:before{content:'\e024'}.oi-british-pound:before{content:'\e025'}.oi-browser:before{content:'\e026'}.oi-brush:before{content:'\e027'}.oi-bug:before{content:'\e028'}.oi-bullhorn:before{content:'\e029'}.oi-calculator:before{content:'\e02a'}.oi-calendar:before{content:'\e02b'}.oi-camera-slr:before{content:'\e02c'}.oi-caret-bottom:before{content:'\e02d'}.oi-caret-left:before{content:'\e02e'}.oi-caret-right:before{content:'\e02f'}.oi-caret-top:before{content:'\e030'}.oi-cart:before{content:'\e031'}.oi-chat:before{content:'\e032'}.oi-check:before{content:'\e033'}.oi-chevron-bottom:before{content:'\e034'}.oi-chevron-left:before{content:'\e035'}.oi-chevron-right:before{content:'\e036'}.oi-chevron-top:before{content:'\e037'}.oi-circle-check:before{content:'\e038'}.oi-circle-x:before{content:'\e039'}.oi-clipboard:before{content:'\e03a'}.oi-clock:before{content:'\e03b'}.oi-cloud-download:before{content:'\e03c'}.oi-cloud-upload:before{content:'\e03d'}.oi-cloud:before{content:'\e03e'}.oi-cloudy:before{content:'\e03f'}.oi-code:before{content:'\e040'}.oi-cog:before{content:'\e041'}.oi-collapse-down:before{content:'\e042'}.oi-collapse-left:before{content:'\e043'}.oi-collapse-right:before{content:'\e044'}.oi-collapse-up:before{content:'\e045'}.oi-command:before{content:'\e046'}.oi-comment-square:before{content:'\e047'}.oi-compass:before{content:'\e048'}.oi-contrast:before{content:'\e049'}.oi-copywriting:before{content:'\e04a'}.oi-credit-card:before{content:'\e04b'}.oi-crop:before{content:'\e04c'}.oi-dashboard:before{content:'\e04d'}.oi-data-transfer-download:before{content:'\e04e'}.oi-data-transfer-upload:before{content:'\e04f'}.oi-delete:before{content:'\e050'}.oi-dial:before{content:'\e051'}.oi-document:before{content:'\e052'}.oi-dollar:before{content:'\e053'}.oi-double-quote-sans-left:before{content:'\e054'}.oi-double-quote-sans-right:before{content:'\e055'}.oi-double-quote-serif-left:before{content:'\e056'}.oi-double-quote-serif-right:before{content:'\e057'}.oi-droplet:before{content:'\e058'}.oi-eject:before{content:'\e059'}.oi-elevator:before{content:'\e05a'}.oi-ellipses:before{content:'\e05b'}.oi-envelope-closed:before{content:'\e05c'}.oi-envelope-open:before{content:'\e05d'}.oi-euro:before{content:'\e05e'}.oi-excerpt:before{content:'\e05f'}.oi-expand-down:before{content:'\e060'}.oi-expand-left:before{content:'\e061'}.oi-expand-right:before{content:'\e062'}.oi-expand-up:before{content:'\e063'}.oi-external-link:before{content:'\e064'}.oi-eye:before{content:'\e065'}.oi-eyedropper:before{content:'\e066'}.oi-file:before{content:'\e067'}.oi-fire:before{content:'\e068'}.oi-flag:before{content:'\e069'}.oi-flash:before{content:'\e06a'}.oi-folder:before{content:'\e06b'}.oi-fork:before{content:'\e06c'}.oi-fullscreen-enter:before{content:'\e06d'}.oi-fullscreen-exit:before{content:'\e06e'}.oi-globe:before{content:'\e06f'}.oi-graph:before{content:'\e070'}.oi-grid-four-up:before{content:'\e071'}.oi-grid-three-up:before{content:'\e072'}.oi-grid-two-up:before{content:'\e073'}.oi-hard-drive:before{content:'\e074'}.oi-header:before{content:'\e075'}.oi-headphones:before{content:'\e076'}.oi-heart:before{content:'\e077'}.oi-home:before{content:'\e078'}.oi-image:before{content:'\e079'}.oi-inbox:before{content:'\e07a'}.oi-infinity:before{content:'\e07b'}.oi-info:before{content:'\e07c'}.oi-italic:before{content:'\e07d'}.oi-justify-center:before{content:'\e07e'}.oi-justify-left:before{content:'\e07f'}.oi-justify-right:before{content:'\e080'}.oi-key:before{content:'\e081'}.oi-laptop:before{content:'\e082'}.oi-layers:before{content:'\e083'}.oi-lightbulb:before{content:'\e084'}.oi-link-broken:before{content:'\e085'}.oi-link-intact:before{content:'\e086'}.oi-list-rich:before{content:'\e087'}.oi-list:before{content:'\e088'}.oi-location:before{content:'\e089'}.oi-lock-locked:before{content:'\e08a'}.oi-lock-unlocked:before{content:'\e08b'}.oi-loop-circular:before{content:'\e08c'}.oi-loop-square:before{content:'\e08d'}.oi-loop:before{content:'\e08e'}.oi-magnifying-glass:before{content:'\e08f'}.oi-map-marker:before{content:'\e090'}.oi-map:before{content:'\e091'}.oi-media-pause:before{content:'\e092'}.oi-media-play:before{content:'\e093'}.oi-media-record:before{content:'\e094'}.oi-media-skip-backward:before{content:'\e095'}.oi-media-skip-forward:before{content:'\e096'}.oi-media-step-backward:before{content:'\e097'}.oi-media-step-forward:before{content:'\e098'}.oi-media-stop:before{content:'\e099'}.oi-medical-cross:before{content:'\e09a'}.oi-menu:before{content:'\e09b'}.oi-microphone:before{content:'\e09c'}.oi-minus:before{content:'\e09d'}.oi-monitor:before{content:'\e09e'}.oi-moon:before{content:'\e09f'}.oi-move:before{content:'\e0a0'}.oi-musical-note:before{content:'\e0a1'}.oi-paperclip:before{content:'\e0a2'}.oi-pencil:before{content:'\e0a3'}.oi-people:before{content:'\e0a4'}.oi-person:before{content:'\e0a5'}.oi-phone:before{content:'\e0a6'}.oi-pie-chart:before{content:'\e0a7'}.oi-pin:before{content:'\e0a8'}.oi-play-circle:before{content:'\e0a9'}.oi-plus:before{content:'\e0aa'}.oi-power-standby:before{content:'\e0ab'}.oi-print:before{content:'\e0ac'}.oi-project:before{content:'\e0ad'}.oi-pulse:before{content:'\e0ae'}.oi-puzzle-piece:before{content:'\e0af'}.oi-question-mark:before{content:'\e0b0'}.oi-rain:before{content:'\e0b1'}.oi-random:before{content:'\e0b2'}.oi-reload:before{content:'\e0b3'}.oi-resize-both:before{content:'\e0b4'}.oi-resize-height:before{content:'\e0b5'}.oi-resize-width:before{content:'\e0b6'}.oi-rss-alt:before{content:'\e0b7'}.oi-rss:before{content:'\e0b8'}.oi-script:before{content:'\e0b9'}.oi-share-boxed:before{content:'\e0ba'}.oi-share:before{content:'\e0bb'}.oi-shield:before{content:'\e0bc'}.oi-signal:before{content:'\e0bd'}.oi-signpost:before{content:'\e0be'}.oi-sort-ascending:before{content:'\e0bf'}.oi-sort-descending:before{content:'\e0c0'}.oi-spreadsheet:before{content:'\e0c1'}.oi-star:before{content:'\e0c2'}.oi-sun:before{content:'\e0c3'}.oi-tablet:before{content:'\e0c4'}.oi-tag:before{content:'\e0c5'}.oi-tags:before{content:'\e0c6'}.oi-target:before{content:'\e0c7'}.oi-task:before{content:'\e0c8'}.oi-terminal:before{content:'\e0c9'}.oi-text:before{content:'\e0ca'}.oi-thumb-down:before{content:'\e0cb'}.oi-thumb-up:before{content:'\e0cc'}.oi-timer:before{content:'\e0cd'}.oi-transfer:before{content:'\e0ce'}.oi-trash:before{content:'\e0cf'}.oi-underline:before{content:'\e0d0'}.oi-vertical-align-bottom:before{content:'\e0d1'}.oi-vertical-align-center:before{content:'\e0d2'}.oi-vertical-align-top:before{content:'\e0d3'}.oi-video:before{content:'\e0d4'}.oi-volume-high:before{content:'\e0d5'}.oi-volume-low:before{content:'\e0d6'}.oi-volume-off:before{content:'\e0d7'}.oi-warning:before{content:'\e0d8'}.oi-wifi:before{content:'\e0d9'}.oi-wrench:before{content:'\e0da'}.oi-x:before{content:'\e0db'}.oi-yen:before{content:'\e0dc'}.oi-zoom-in:before{content:'\e0dd'}.oi-zoom-out:before{content:'\e0de'} -------------------------------------------------------------------------------- /src/Client/Pages/BMS.razor: -------------------------------------------------------------------------------- 1 | @page "/bms" 2 | @using InverterMon.Client.AppState 3 | @using InverterMon.Shared.Models 4 | @using System.Text.Json 5 | @implements IDisposable 6 | @inject IJSRuntime Js 7 | 8 | JK BMS Status 9 | 10 | 11 | 12 | @if (status is not null) 13 | { 14 |
15 |
16 | 17 | JK BMS 18 |
19 |
20 |
21 |
22 |
23 |
24 | @($"{status.PackVoltage:0.0}") V 25 |
26 |
27 | @status.CapacityPct% 28 |
29 |
30 | @(GetPackCapacity()) 31 |
32 |
33 |
34 |
35 |
36 | @($"{status.AvgCurrentAmps:0.0} A") 37 |
38 | @if (status.AvgCurrentAmps > 0) 39 | { 40 |
41 | @(status.IsCharging ? "Charging" : "Discharging") 42 |
43 |
44 | @($"{status.CRate:0.00} C") / @($"{status.AvgPowerWatts:0} W") 45 |
46 |
47 | @(GetTimeLeft()) 48 |
49 | } 50 | @if (status.AvgCurrentAmps == 0) 51 | { 52 |
53 | Holding
Voltage 54 |
55 | } 56 | @if (status.IsWarning) 57 | { 58 |
59 | Protection! 60 |
61 | } 62 |
63 |
64 |
65 |
66 |
67 |
68 | Mosfet 69 |
70 | @status.MosTemp C° 71 |
72 |
73 |
74 | Probe 1 75 |
76 | @status.Temp1 C° 77 |
78 |
79 |
80 | Probe 2 81 |
82 | @status.Temp2 C° 83 |
84 |
85 |
86 |
87 |
88 | Min Cell: @status.MinCell.Key 89 |
90 |
91 | @($"{status.MinCell.Value:0.000} V") 92 |
93 |
94 | Max Cell: @status.MaxCell.Key 95 |
96 |
97 | @($"{status.MaxCell.Value:0.000} V") 98 |
99 |
100 |
101 |
102 | Cell Delta: 103 |
104 |
105 | @($"{status.CellDiff:0.000} V") 106 |
107 |
108 | Cell Average: 109 |
110 |
111 | @($"{status.AvgCellVoltage:0.000} V") 112 |
113 |
114 |
115 |
116 | @foreach (var cell in status.Cells) 117 | { 118 |
119 | @($"{cell.Key:00}") @($"{cell.Value:0.000} V") 120 |
121 | } 122 |
123 |
124 |
125 |
126 | } 127 | 128 | @code { 129 | private static event Action? onStatusUpdated; 130 | private static event Action? onStatusRetrievalError; 131 | private static BMSStatus? status; 132 | private static ClientSettings state = new(); 133 | 134 | protected override void OnInitialized() 135 | { 136 | onStatusUpdated += UpdateState; 137 | onStatusRetrievalError += NullifyStatus; 138 | } 139 | 140 | protected override async Task OnInitializedAsync() 141 | { 142 | var st = await Js.LoadStateAsync(); 143 | if(st is null) 144 | { 145 | await Js.SaveStateAsync(state); 146 | } 147 | else 148 | { 149 | state = st; 150 | } 151 | } 152 | 153 | private void NullifyStatus() 154 | { 155 | status = null; 156 | StateHasChanged(); 157 | } 158 | 159 | private void UpdateState(BMSStatus? s) 160 | { 161 | status = s; 162 | StateHasChanged(); 163 | } 164 | 165 | private string GetTimeLeft() 166 | { 167 | if (state.ShowEndDateAndTime) 168 | { 169 | return status!.GetTimeString(); 170 | } 171 | return $"{status!.TimeHrs} Hrs {status.TimeMins} Mins"; 172 | } 173 | 174 | private string GetPackCapacity() 175 | { 176 | if (state.ShowCapacityKwh) 177 | { 178 | var avlCap = Math.Round((status!.AvailableCapacity * status.PackNominalVoltage) / 1000, 1); 179 | var packCap = Math.Round((status!.PackCapacity * status.PackNominalVoltage) / 1000, 1); 180 | return $"{avlCap} kWh / {packCap} kWh"; 181 | } 182 | return $"{Math.Round(status!.AvailableCapacity, 1)} Ah / {status!.PackCapacity} Ah"; 183 | } 184 | 185 | private async Task ToggleCapacityKwh() 186 | { 187 | state.ShowCapacityKwh = !state.ShowCapacityKwh; 188 | await Js.SaveStateAsync(state); 189 | } 190 | 191 | private async Task ToggleEndDateAndTime() 192 | { 193 | state.ShowEndDateAndTime = !state.ShowEndDateAndTime; 194 | await Js.SaveStateAsync(state); 195 | } 196 | 197 | public void Dispose() 198 | { 199 | onStatusUpdated -= UpdateState; 200 | onStatusRetrievalError -= NullifyStatus; 201 | } 202 | 203 | public static async Task StartStatusStreaming(string basePath) 204 | { 205 | //note: only reason we have a full-time stream download is because there's a bug in 206 | // blazor-wasm that doesn't close the fetch http requests when streaming is involved. 207 | // and it leads to a new stream download being created everytime a page is initialized. 208 | // which leads to a memory leak/ connection exhaustion. 209 | 210 | using var client = new HttpClient 211 | { 212 | BaseAddress = new(basePath), 213 | Timeout = TimeSpan.FromSeconds(5) 214 | }; 215 | 216 | var retryDelay = 1000; 217 | 218 | while (true) 219 | { 220 | try 221 | { 222 | using var request = new HttpRequestMessage(HttpMethod.Get, "api/bms-status"); 223 | request.SetBrowserResponseStreamingEnabled(true); 224 | 225 | using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); 226 | response.EnsureSuccessStatusCode(); 227 | 228 | using var stream = await response.Content.ReadAsStreamAsync(); 229 | 230 | await foreach (var s in 231 | JsonSerializer.DeserializeAsyncEnumerable( 232 | stream, 233 | new JsonSerializerOptions 234 | { 235 | PropertyNameCaseInsensitive = true, 236 | DefaultBufferSize = 64 237 | })) 238 | { 239 | onStatusUpdated?.Invoke(s); 240 | retryDelay = 1000; 241 | } 242 | } 243 | catch (Exception) 244 | { 245 | onStatusRetrievalError?.Invoke(); 246 | await Task.Delay(retryDelay); 247 | retryDelay += 500; 248 | } 249 | } 250 | } 251 | 252 | } -------------------------------------------------------------------------------- /src/Client/Pages/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @using InverterMon.Shared.Models 3 | @using System.Text.Json 4 | @implements IDisposable 5 | 6 | Dashboard 7 | 8 | 9 | 10 | @if (status is not null) 11 | { 12 |
13 | 14 | @if (status?.GridUsageWatts > 100) 15 | { 16 |
17 |
18 |
19 |
20 | 21 | Grid Usage 22 |
23 |
24 |
25 |
26 |
27 |
@status?.GridUsageWatts
28 | W 29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | } 37 |
38 |
39 |
40 |
41 | 42 | Output Load 43 |
44 |
45 |
46 |
47 |
48 | 49 |
50 |
51 |
@status?.LoadWatts
52 | W 53 |
54 |
55 |
56 |
57 |
@RoundToWholeNumber(status?.OutputVoltage)
58 | 59 | V 60 | 61 |
62 |
63 |
64 | 65 | @(status?.HeatSinkTemperature) C° 66 | 67 |
68 |
69 |
70 |
@RoundToOneDecimal(status?.LoadCurrent)
71 | A 72 |
73 |
74 |
75 |
76 |
77 |
78 | 79 |
80 |
81 |
82 |
85 |
86 |
87 |
88 |
89 | 90 |
91 |
92 |
@status?.PVInputWatt
93 | W 94 |
95 |
96 |
97 |
98 |
@RoundToWholeNumber(status?.PVInputVoltage)
99 | 100 | V 101 | 102 |
103 |
104 |
@RoundToOneDecimal(status?.PVInputCurrent)
105 | A 106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | 115 |
116 |
117 |
118 |
119 |
120 |
123 |
124 |
125 |
126 |
127 | 128 |
129 |
130 |
131 |
132 | Charging 133 |
134 |
@status?.BatteryChargeWatts
135 | W 136 |
137 |
@RoundToOneDecimal(status?.BatteryChargeCurrent)
138 | A 139 |
140 |
141 |
142 |
143 |
144 |
Voltage
145 |
@RoundToOneDecimal(status?.BatteryVoltage)
146 |
V
147 |
@GetCRate() C
148 |
149 |
150 |
151 | Discharging 152 |
153 |
@status?.BatteryDischargeWatts
154 | W 155 |
156 |
@status?.BatteryDischargeCurrent
157 | A 158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 | } 169 | 170 | @code{ 171 | private static event Action? onStatusUpdated; 172 | private static event Action? onStatusRetrievalError; 173 | private static InverterStatus? status; 174 | 175 | protected override void OnInitialized() 176 | { 177 | onStatusUpdated += UpdateState; 178 | onStatusRetrievalError += NullifyStatus; 179 | } 180 | 181 | private void NullifyStatus() 182 | { 183 | status = null; 184 | StateHasChanged(); 185 | } 186 | 187 | private void UpdateState(InverterStatus? s) 188 | { 189 | status = s; 190 | StateHasChanged(); 191 | } 192 | 193 | public void Dispose() 194 | { 195 | onStatusUpdated -= UpdateState; 196 | onStatusRetrievalError -= NullifyStatus; 197 | } 198 | 199 | private static decimal RoundToWholeNumber(decimal? val) 200 | => Math.Round(val ?? 0, 0); 201 | 202 | private static decimal RoundToOneDecimal(decimal? val) 203 | => Math.Round(val ?? 0, 1); 204 | 205 | private static string TemperatureCss() 206 | { 207 | return status?.HeatSinkTemperature switch 208 | { 209 | >= 55 and < 65 => "text-danger", 210 | >= 65 => "text-danger fw-bolder blinktext", 211 | _ => "text-muted" 212 | }; 213 | } 214 | 215 | public static async Task StartStatusStreaming(string basePath) 216 | { 217 | //note: only reason we have a full-time stream download is because there's a bug in 218 | // blazor-wasm that doesn't close the fetch http requests when streaming is involved. 219 | // and it leads to a new stream download being created everytime a page is initialized. 220 | // which leads to a memory leak/ connection exhaustion. 221 | 222 | using var client = new HttpClient(); 223 | client.BaseAddress = new(basePath); 224 | client.Timeout = TimeSpan.FromSeconds(5); 225 | 226 | var retryDelay = 1000; 227 | 228 | while (true) 229 | { 230 | try 231 | { 232 | using var request = new HttpRequestMessage(HttpMethod.Get, "api/status"); 233 | request.SetBrowserResponseStreamingEnabled(true); 234 | 235 | using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); 236 | response.EnsureSuccessStatusCode(); 237 | 238 | using var stream = await response.Content.ReadAsStreamAsync(); 239 | 240 | await foreach (var s in 241 | JsonSerializer.DeserializeAsyncEnumerable( 242 | stream, 243 | new JsonSerializerOptions 244 | { 245 | PropertyNameCaseInsensitive = true, 246 | DefaultBufferSize = 64 247 | })) 248 | { 249 | onStatusUpdated?.Invoke(s); 250 | retryDelay = 1000; 251 | } 252 | } 253 | catch (Exception) 254 | { 255 | onStatusRetrievalError?.Invoke(); 256 | await Task.Delay(retryDelay); 257 | retryDelay += 500; 258 | } 259 | } 260 | } 261 | 262 | private static decimal GetCRate() 263 | { 264 | if (status?.BatteryChargeCRate > 0) 265 | return Math.Round(status.BatteryChargeCRate, 2); 266 | 267 | if (status?.BatteryDischargeCRate > 0) 268 | return Math.Round(status.BatteryDischargeCRate, 2); 269 | 270 | return 0; 271 | } 272 | 273 | } -------------------------------------------------------------------------------- /src/Client/Pages/Settings.razor: -------------------------------------------------------------------------------- 1 | @page "/settings" 2 | @using InverterMon.Shared.Models 3 | @inject HttpClient Http 4 | 5 | Settings 6 | 7 | 8 | 9 | @if(settings is not null) 10 | { 11 | 22 |
23 |
24 |
25 |
26 | Max Combined Charge Current: 27 |
28 |
29 | 30 | @if (chargeAmpereValues == null || inProgressSetting == Setting.CombinedChargeCurrent) 31 | { 32 |
33 | } 34 | else 35 | { 36 | 55 | } 56 |
57 |
58 | 59 |
60 |
61 | Max Grid Charge Current: 62 |
63 |
64 | 65 | @if (chargeAmpereValues == null || inProgressSetting == Setting.UtilityChargeCurrent) 66 | { 67 |
68 | } 69 | else 70 | { 71 | 90 | } 91 |
92 |
93 | 94 |
95 |
96 | Output Source Priority: 97 |
98 |
99 | 104 | 109 | 114 |
115 |
116 | 117 |
118 |
119 | Battery Charging Priority: 120 |
121 |
122 | 127 | 132 | 137 | 142 |
143 |
144 |
145 | 146 |
147 |
148 |
149 | Bulk Charge Voltage: 150 |
151 |
152 |
153 |
154 | 155 | 159 |
160 |
161 |
162 |
163 |
164 |
165 | Float Charge Voltage: 166 |
167 |
168 |
169 |
170 | 171 | 175 |
176 |
177 |
178 |
179 |
180 |
181 | Discharge Cutoff Voltage: 182 |
183 |
184 |
185 |
186 | 187 | 191 |
192 |
193 |
194 |
195 |
196 |
197 | Back To Grid Voltage: 198 |
199 |
200 |
201 |
202 | 203 | 207 |
208 |
209 |
210 |
211 |
212 |
213 | Back To Battery Voltage: 214 |
215 |
216 |
217 |
218 | 219 | 223 |
224 |
225 |
226 |
227 |
228 | 229 |
230 |
231 |
232 | Max PV Generation Capacity: 233 |
234 |
235 |
236 |
237 | 238 |
239 |
Watts
240 |
241 |
242 |
243 |
244 |
245 | Battery Capacity: 246 |
247 |
248 |
249 |
250 | 251 |
252 |
Ah
253 |
254 |
255 |
256 |
257 |
258 | Battery Voltage: 259 |
260 |
261 |
262 |
263 | 264 |
265 |
V
266 |
267 |
268 |
269 |
270 |
271 | Daylight Start (24hr format): 272 |
273 |
274 |
275 |
276 | 277 |
278 |
Hrs
279 |
280 |
281 |
282 |
283 |
284 | Daylight End (24hr format): 285 |
286 |
287 |
288 |
289 | 290 |
291 |
Hrs
292 |
293 |
294 |
295 |
296 |
297 | 301 |
302 |
303 |
304 |
305 | } 306 | 307 | @code{ 308 | private static ChargeAmpereValues? chargeAmpereValues; 309 | private bool isLoadingChargeValues = false; 310 | private CurrentSettings? settings; 311 | private Button currentButton = Button.None; 312 | private bool isSuccess; 313 | private string inProgressSetting = ""; 314 | 315 | protected override async Task OnInitializedAsync() 316 | { 317 | settings = await Http.GetFromJsonAsync("api/settings/get-setting-values"); 318 | StateHasChanged(); 319 | 320 | _ = Task.Run(async () => 321 | { 322 | if (chargeAmpereValues is null) 323 | { 324 | isLoadingChargeValues = true; 325 | StateHasChanged(); 326 | 327 | using var client = new HttpClient 328 | { 329 | BaseAddress = new Uri(Http.BaseAddress?.ToString() ?? "/"), 330 | Timeout = TimeSpan.FromSeconds(10) 331 | }; 332 | 333 | chargeAmpereValues = await client.GetFromJsonAsync("api/settings/get-charge-ampere-values"); 334 | 335 | // some inverters only seem to support one of the two commands over usb 336 | if (chargeAmpereValues?.CombinedAmpereValues.Any() is true && 337 | chargeAmpereValues?.UtilityAmpereValues.Any() is false) 338 | { 339 | chargeAmpereValues.UtilityAmpereValues = chargeAmpereValues.CombinedAmpereValues; 340 | } 341 | if (chargeAmpereValues?.CombinedAmpereValues.Any() is false && 342 | chargeAmpereValues?.UtilityAmpereValues.Any() is true) 343 | { 344 | chargeAmpereValues.CombinedAmpereValues = chargeAmpereValues.UtilityAmpereValues; 345 | } 346 | 347 | isLoadingChargeValues = false; 348 | StateHasChanged(); 349 | } 350 | }); 351 | } 352 | 353 | private async Task SetChargePriority(string priority) 354 | { 355 | isSuccess = false; 356 | 357 | switch (priority) 358 | { 359 | case ChargePriority.OnlySolar: 360 | currentButton = Button.ChOnlySolar; 361 | break; 362 | case ChargePriority.SolarFirst: 363 | currentButton = Button.ChSolarFirst; 364 | break; 365 | case ChargePriority.SolarAndUtility: 366 | currentButton = Button.ChSolarAndUtility; 367 | break; 368 | case ChargePriority.UtilityFirst: 369 | currentButton = Button.ChUtilityFirst; 370 | break; 371 | default: 372 | currentButton = Button.None; 373 | break; 374 | }; 375 | 376 | if (await Http.GetStringAsync($"api/settings/set-setting/{Setting.ChargePriority}/{priority}") == "true") 377 | { 378 | isSuccess = true; 379 | UpdateLocalSetting(Setting.ChargePriority, priority); 380 | } 381 | } 382 | 383 | private async Task SetOutputPriority(string priority) 384 | { 385 | isSuccess = false; 386 | 387 | switch (priority) 388 | { 389 | case OutputPriority.SolarFirst: 390 | currentButton = Button.OpSolarFirst; 391 | break; 392 | case OutputPriority.SolarBatteryUtility: 393 | currentButton = Button.OpSolarBatteryUtility; 394 | break; 395 | case OutputPriority.UtilityFirst: 396 | currentButton = Button.OpUtilityFirst; 397 | break; 398 | default: 399 | currentButton = Button.None; 400 | break; 401 | }; 402 | 403 | if (await Http.GetStringAsync($"api/settings/set-setting/{Setting.OutputPriority}/{priority}") == "true") 404 | { 405 | isSuccess = true; 406 | UpdateLocalSetting(Setting.OutputPriority,priority); 407 | } 408 | } 409 | 410 | private async Task SetVoltage(string setting) 411 | { 412 | isSuccess = false; 413 | decimal value = 0; 414 | 415 | switch (setting) 416 | { 417 | case Setting.BulkVoltage: 418 | currentButton = Button.BulkVoltage; 419 | value = settings!.BulkChargeVoltage; 420 | break; 421 | case Setting.FloatVoltage: 422 | currentButton = Button.FloatVoltage; 423 | value = settings!.FloatChargeVoltage; 424 | break; 425 | case Setting.DischargeCutOff: 426 | currentButton = Button.DischargeCutOff; 427 | value = settings!.DischargeCuttOffVoltage; 428 | break; 429 | case Setting.BackToGrid: 430 | currentButton = Button.BackToGridVoltage; 431 | value = settings!.BackToGridVoltage; 432 | break; 433 | case Setting.BackToBattery: 434 | currentButton = Button.BackToBattery; 435 | value = settings!.BackToBatteryVoltage; 436 | break; 437 | default: 438 | currentButton = Button.None; 439 | break; 440 | }; 441 | 442 | if (await Http.GetStringAsync($"api/settings/set-setting/{setting}/{value:00.0}") == "true") 443 | { 444 | isSuccess = true; 445 | } 446 | } 447 | 448 | private async Task SetSetting(string settingName, string value) 449 | { 450 | inProgressSetting = settingName; 451 | if (await Http.GetStringAsync($"api/settings/set-setting/{settingName}/{value}") == "true") 452 | { 453 | UpdateLocalSetting(settingName, value); 454 | inProgressSetting = ""; 455 | } 456 | } 457 | 458 | private void UpdateLocalSetting(string settingName, string value) 459 | { 460 | switch (settingName) 461 | { 462 | case Setting.OutputPriority: 463 | settings!.OutputPriority = value; 464 | break; 465 | case Setting.ChargePriority: 466 | settings!.ChargePriority = value; 467 | break; 468 | case Setting.CombinedChargeCurrent: 469 | settings!.MaxCombinedChargeCurrent = value; 470 | break; 471 | case Setting.UtilityChargeCurrent: 472 | settings!.MaxACChargeCurrent = value; 473 | break; 474 | default: 475 | break; 476 | } 477 | } 478 | 479 | private async Task UpdateUserSettings() 480 | { 481 | currentButton = Button.UpdateUserSettings; 482 | isSuccess = false; 483 | await Http.PostAsJsonAsync("api/settings/set-system-spec", settings!.SystemSpec); 484 | isSuccess = true; 485 | currentButton = Button.None; 486 | } 487 | 488 | private string Spinner(Button button) 489 | => currentButton == button && !isSuccess 490 | ? "spinner-border" 491 | : ""; 492 | 493 | private string Hidden(Button button) 494 | => currentButton == button && !isSuccess 495 | ? "visually-hidden" 496 | : ""; 497 | 498 | private string Success(Button button, string currentValue, string settingValue) 499 | => (currentButton == button && isSuccess) || currentValue == settingValue 500 | ? "oi oi-circle-check text-success" 501 | : ""; 502 | 503 | private string Sanitize(string value) 504 | => value.StartsWith("0") ? value[1..] : value; 505 | 506 | private enum Button 507 | { 508 | None = 0, 509 | ChOnlySolar = 1, 510 | ChSolarFirst = 2, 511 | ChSolarAndUtility = 3, 512 | ChUtilityFirst = 4, 513 | OpUtilityFirst = 5, 514 | OpSolarFirst = 6, 515 | OpSolarBatteryUtility = 7, 516 | UpdateUserSettings = 8, 517 | BackToGridVoltage = 9, 518 | BackToBattery= 10, 519 | DischargeCutOff = 11, 520 | BulkVoltage = 12, 521 | FloatVoltage = 13 522 | } 523 | 524 | private static class Setting 525 | { 526 | public const string ChargePriority = "PCP"; 527 | public const string OutputPriority = "POP"; 528 | public const string CombinedChargeCurrent = "MNCHGC"; 529 | public const string UtilityChargeCurrent = "MUCHGC"; 530 | 531 | public const string BulkVoltage = "PCVV"; 532 | public const string FloatVoltage = "PBFT"; 533 | public const string DischargeCutOff = "PSDV"; 534 | public const string BackToGrid = "PBCV"; 535 | public const string BackToBattery = "PBDV"; 536 | } 537 | } --------------------------------------------------------------------------------