wip: before inverter service

This commit is contained in:
djnitehawk 2025-03-12 14:28:38 +05:30 committed by Dĵ ΝιΓΞΗΛψΚ
parent 806ceefe4b
commit 7fca1d1cfb
30 changed files with 174 additions and 794 deletions

View File

@ -26,33 +26,12 @@
Max Combined Charge Current:
</div>
<div class="col-6 p-2 bg-secondary">
@if (chargeAmpereValues == null || inProgressSetting == Setting.CombinedChargeCurrent)
{
<div class="spinner-border m-2"></div>
}
else
{
<div class="dropdown p-1">
<button class="btn btn-secondary dropdown-toggle" type="button" id="combinedcurrent" data-bs-toggle="dropdown">
@Sanitize(settings.MaxCombinedChargeCurrent) A
<input @bind-value=settings.MaxCombinedChargeCurrent class="form-control bg-light d-inline m-1" style="width:4rem;" type="text" maxlength="4">
<button type="button" class="btn btn-light d-inline m-1"
@onclick="()=>SetSetting(Setting.CombinedChargeCurrent,settings.MaxCombinedChargeCurrent)">
<span class="@Spinner(Button.MaxCombinedChargeCurrent)"></span>
<span class="@Hidden(Button.MaxCombinedChargeCurrent)">Save</span>
</button>
@if (chargeAmpereValues != null)
{
<ul class="dropdown-menu dropdown-menu">
@foreach (var val in chargeAmpereValues!.CombinedAmpereValues)
{
<li>
<a class="dropdown-item @(settings.MaxCombinedChargeCurrent == val ? "active" : "")"
@onclick="()=>SetSetting(Setting.CombinedChargeCurrent,val)">
@Sanitize(val) A
</a>
</li>
}
</ul>
}
</div>
}
</div>
</div>
@ -61,33 +40,11 @@
Max Grid Charge Current:
</div>
<div class="col-6 p-2 bg-secondary">
@if (chargeAmpereValues == null || inProgressSetting == Setting.UtilityChargeCurrent)
{
<div class="spinner-border m-2"></div>
}
else
{
<div class="dropdown p-1">
<button class="btn btn-secondary dropdown-toggle" type="button" id="gridcurrent" data-bs-toggle="dropdown">
@Sanitize(settings.MaxACChargeCurrent) A
<input @bind-value=settings.MaxACChargeCurrent class="form-control bg-light d-inline m-1" style="width:4rem;" type="text" maxlength="4">
<button type="button" class="btn btn-light d-inline m-1" @onclick="()=>SetSetting(Setting.UtilityChargeCurrent,settings.MaxACChargeCurrent)">
<span class="@Spinner(Button.MaxUtilityChargeCurrent)"></span>
<span class="@Hidden(Button.MaxUtilityChargeCurrent)">Save</span>
</button>
<ul class="dropdown-menu dropdown-menu">
@if (chargeAmpereValues != null)
{
@foreach (var val in chargeAmpereValues!.UtilityAmpereValues)
{
<li>
<a class="dropdown-item @(settings.MaxACChargeCurrent == val ? "active" : "")"
@onclick="()=>SetSetting(Setting.UtilityChargeCurrent,val)">
@Sanitize(val) A
</a>
</li>
}
}
</ul>
</div>
}
</div>
</div>
@ -96,17 +53,17 @@
Output Source Priority:
</div>
<div class="col-6 bg-secondary p-2">
<button disabled="@isLoadingChargeValues" type="button" class="btn btn-light d-block m-2" @onclick="()=>SetOutputPriority(OutputPriority.SolarFirst)">
<button type="button" class="btn btn-light d-block m-2" @onclick="()=>SetOutputPriority(OutputPriority.SolarFirst)">
<span class="@Spinner(Button.OpSolarFirst)"></span>
<span class="@Hidden(Button.OpSolarFirst)">Solar First</span>
<span class="@Success(Button.OpSolarFirst, OutputPriority.SolarFirst, settings.OutputPriority)"></span>
</button>
<button disabled="@isLoadingChargeValues" type="button" class="btn btn-light d-block m-2" @onclick="()=>SetOutputPriority(OutputPriority.SolarBatteryUtility)">
<button type="button" class="btn btn-light d-block m-2" @onclick="()=>SetOutputPriority(OutputPriority.SolarBatteryUtility)">
<span class="@Spinner(Button.OpSolarBatteryUtility)"></span>
<span class="@Hidden(Button.OpSolarBatteryUtility)">Solar > Battery > Utility</span>
<span class="@Success(Button.OpSolarBatteryUtility, OutputPriority.SolarBatteryUtility, settings.OutputPriority)"></span>
</button>
<button disabled="@isLoadingChargeValues" type="button" class="btn btn-light d-block m-2" @onclick="()=>SetOutputPriority(OutputPriority.UtilityFirst)">
<button type="button" class="btn btn-light d-block m-2" @onclick="()=>SetOutputPriority(OutputPriority.UtilityFirst)">
<span class="@Spinner(Button.OpUtilityFirst)"></span>
<span class="@Hidden(Button.OpUtilityFirst)">Utility First</span>
<span class="@Success(Button.OpUtilityFirst, OutputPriority.UtilityFirst, settings.OutputPriority)"></span>
@ -119,22 +76,22 @@
Battery Charging Priority:
</div>
<div class="col-6 bg-secondary p-2">
<button disabled="@isLoadingChargeValues" type="button" class="btn btn-light d-block m-2" @onclick="()=>SetChargePriority(ChargePriority.OnlySolar)">
<button type="button" class="btn btn-light d-block m-2" @onclick="()=>SetChargePriority(ChargePriority.OnlySolar)">
<span class="@Spinner(Button.ChOnlySolar)"></span>
<span class="@Hidden(Button.ChOnlySolar)">Solar Only</span>
<span class="@Success(Button.ChOnlySolar, ChargePriority.OnlySolar, settings.ChargePriority)"></span>
</button>
<button disabled="@isLoadingChargeValues" type="button" class="btn btn-light d-block m-2" @onclick="()=>SetChargePriority(ChargePriority.SolarFirst)">
<button type="button" class="btn btn-light d-block m-2" @onclick="()=>SetChargePriority(ChargePriority.SolarFirst)">
<span class="@Spinner(Button.ChSolarFirst)"></span>
<span class="@Hidden(Button.ChSolarFirst)">Solar First</span>
<span class="@Success(Button.ChSolarFirst, ChargePriority.SolarFirst, settings.ChargePriority)"></span>
</button>
<button disabled="@isLoadingChargeValues" type="button" class="btn btn-light d-block m-2" @onclick="()=>SetChargePriority(ChargePriority.SolarAndUtility)">
<button type="button" class="btn btn-light d-block m-2" @onclick="()=>SetChargePriority(ChargePriority.SolarAndUtility)">
<span class="@Spinner(Button.ChSolarAndUtility)"></span>
<span class="@Hidden(Button.ChSolarAndUtility)">Solar & Utility</span>
<span class="@Success(Button.ChSolarAndUtility, ChargePriority.SolarAndUtility, settings.ChargePriority)"></span>
</button>
<button disabled="@isLoadingChargeValues" type="button" class="btn btn-light d-block m-2" @onclick="()=>SetChargePriority(ChargePriority.UtilityFirst)">
<button type="button" class="btn btn-light d-block m-2" @onclick="()=>SetChargePriority(ChargePriority.UtilityFirst)">
<span class="@Spinner(Button.ChUtilityFirst)"></span>
<span class="@Hidden(Button.ChUtilityFirst)">Utility First</span>
<span class="@Success(Button.ChUtilityFirst, ChargePriority.UtilityFirst, settings.ChargePriority)"></span>
@ -183,7 +140,8 @@
<div class="col-6 bg-secondary p-1">
<div class="row">
<div>
<input @bind-value=settings.DischargeCuttOffVoltage class="form-control bg-light d-inline m-1" style="width:4rem;" type="text" maxlength="4">
<input @bind-value=settings.DischargeCuttOffVoltage class="form-control bg-light d-inline m-1" style="width:4rem;" type="text"
maxlength="4">
<button type="button" class="btn btn-light d-inline m-1" @onclick="()=>SetVoltage(Setting.DischargeCutOff)">
<span class="@Spinner(Button.DischargeCutOff)"></span>
<span class="@Hidden(Button.DischargeCutOff)">Save</span>
@ -215,7 +173,8 @@
<div class="col-6 bg-secondary p-1">
<div class="row">
<div>
<input @bind-value=settings.BackToBatteryVoltage class="form-control bg-light d-inline m-1" style="width:4rem;" type="text" maxlength="4">
<input @bind-value=settings.BackToBatteryVoltage class="form-control bg-light d-inline m-1" style="width:4rem;" type="text"
maxlength="4">
<button type="button" class="btn btn-light d-inline m-1" @onclick="()=>SetVoltage(Setting.BackToBattery)">
<span class="@Spinner(Button.BackToBattery)"></span>
<span class="@Hidden(Button.BackToBattery)">Save</span>
@ -305,49 +264,14 @@
}
@code{
private static ChargeAmpereValues? chargeAmpereValues;
private bool isLoadingChargeValues = false;
private CurrentSettings? settings;
private Button currentButton = Button.None;
private bool isSuccess;
private string inProgressSetting = "";
protected override async Task OnInitializedAsync()
{
settings = await Http.GetFromJsonAsync<CurrentSettings>("api/settings/get-setting-values");
StateHasChanged();
_ = Task.Run(async () =>
{
if (chargeAmpereValues is null)
{
isLoadingChargeValues = true;
StateHasChanged();
using var client = new HttpClient
{
BaseAddress = new Uri(Http.BaseAddress?.ToString() ?? "/"),
Timeout = TimeSpan.FromSeconds(10)
};
chargeAmpereValues = await client.GetFromJsonAsync<ChargeAmpereValues>("api/settings/get-charge-ampere-values");
// some inverters only seem to support one of the two commands over usb
if (chargeAmpereValues?.CombinedAmpereValues.Any() is true &&
chargeAmpereValues?.UtilityAmpereValues.Any() is false)
{
chargeAmpereValues.UtilityAmpereValues = chargeAmpereValues.CombinedAmpereValues;
}
if (chargeAmpereValues?.CombinedAmpereValues.Any() is false &&
chargeAmpereValues?.UtilityAmpereValues.Any() is true)
{
chargeAmpereValues.CombinedAmpereValues = chargeAmpereValues.UtilityAmpereValues;
}
isLoadingChargeValues = false;
StateHasChanged();
}
});
}
private async Task SetChargePriority(string priority)
@ -358,20 +282,26 @@
{
case ChargePriority.OnlySolar:
currentButton = Button.ChOnlySolar;
break;
case ChargePriority.SolarFirst:
currentButton = Button.ChSolarFirst;
break;
case ChargePriority.SolarAndUtility:
currentButton = Button.ChSolarAndUtility;
break;
case ChargePriority.UtilityFirst:
currentButton = Button.ChUtilityFirst;
break;
default:
currentButton = Button.None;
break;
};
}
;
if (await Http.GetStringAsync($"api/settings/set-setting/{Setting.ChargePriority}/{priority}") == "true")
{
@ -384,20 +314,12 @@
{
isSuccess = false;
switch (priority)
currentButton = priority switch
{
case OutputPriority.SolarFirst:
currentButton = Button.OpSolarFirst;
break;
case OutputPriority.SolarBatteryUtility:
currentButton = Button.OpSolarBatteryUtility;
break;
case OutputPriority.UtilityFirst:
currentButton = Button.OpUtilityFirst;
break;
default:
currentButton = Button.None;
break;
OutputPriority.SolarFirst => Button.OpSolarFirst,
OutputPriority.SolarBatteryUtility => Button.OpSolarBatteryUtility,
OutputPriority.UtilityFirst => Button.OpUtilityFirst,
_ => Button.None
};
if (await Http.GetStringAsync($"api/settings/set-setting/{Setting.OutputPriority}/{priority}") == "true")
@ -407,7 +329,7 @@
}
}
private async Task SetVoltage(string setting)
private async Task SetVoltage(Setting setting)
{
isSuccess = false;
decimal value = 0;
@ -417,27 +339,34 @@
case Setting.BulkVoltage:
currentButton = Button.BulkVoltage;
value = settings!.BulkChargeVoltage;
break;
case Setting.FloatVoltage:
currentButton = Button.FloatVoltage;
value = settings!.FloatChargeVoltage;
break;
case Setting.DischargeCutOff:
currentButton = Button.DischargeCutOff;
value = settings!.DischargeCuttOffVoltage;
break;
case Setting.BackToGrid:
currentButton = Button.BackToGridVoltage;
value = settings!.BackToGridVoltage;
break;
case Setting.BackToBattery:
currentButton = Button.BackToBattery;
value = settings!.BackToBatteryVoltage;
break;
default:
currentButton = Button.None;
break;
};
}
;
if (await Http.GetStringAsync($"api/settings/set-setting/{setting}/{value:00.0}") == "true")
{
@ -445,33 +374,33 @@
}
}
private async Task SetSetting(string settingName, string value)
private async Task SetSetting(Setting settingName, string value)
{
inProgressSetting = settingName;
if (await Http.GetStringAsync($"api/settings/set-setting/{settingName}/{value}") == "true")
{
UpdateLocalSetting(settingName, value);
inProgressSetting = "";
}
}
private void UpdateLocalSetting(string settingName, string value)
private void UpdateLocalSetting(Setting settingName, string value)
{
switch (settingName)
{
case Setting.OutputPriority:
settings!.OutputPriority = value;
break;
case Setting.ChargePriority:
settings!.ChargePriority = value;
break;
case Setting.CombinedChargeCurrent:
settings!.MaxCombinedChargeCurrent = value;
break;
case Setting.UtilityChargeCurrent:
settings!.MaxACChargeCurrent = value;
break;
default:
break;
}
}
@ -518,20 +447,9 @@
BackToBattery = 10,
DischargeCutOff = 11,
BulkVoltage = 12,
FloatVoltage = 13
FloatVoltage = 13,
MaxCombinedChargeCurrent = 14,
MaxUtilityChargeCurrent = 15
}
private static class Setting
{
public const string ChargePriority = "PCP";
public const string OutputPriority = "POP";
public const string CombinedChargeCurrent = "MNCHGC";
public const string UtilityChargeCurrent = "MUCHGC";
public const string BulkVoltage = "PCVV";
public const string FloatVoltage = "PBFT";
public const string DischargeCutOff = "PSDV";
public const string BackToGrid = "PBCV";
public const string BackToBattery = "PBDV";
}
}

View File

@ -1,4 +1,4 @@
using InverterMon.Server.Persistance.Settings;
using InverterMon.Server.Persistence.Settings;
using InverterMon.Shared.Models;
using SerialPortLib;

View File

@ -1,12 +1,12 @@
using InverterMon.Server.InverterService;
using InverterMon.Shared.Models;
using InverterMon.Shared.Models;
using System.Runtime.CompilerServices;
using InverterMon.Server.InverterService;
namespace InverterMon.Server.Endpoints.GetStatus;
public class Endpoint : EndpointWithoutRequest<object>
{
public CommandQueue Queue { get; set; }
public CurrentStatus CurrentStatus { get; set; }
public override void Configure()
{
@ -34,7 +34,7 @@ public class Endpoint : EndpointWithoutRequest<object>
{
if (Env.IsDevelopment())
{
var status = Queue.StatusCommand.Result;
var status = CurrentStatus.Result;
status.OutputVoltage = Random.Shared.Next(240);
status.LoadWatts = Random.Shared.Next(3500);
status.LoadPercentage = Random.Shared.Next(100);
@ -51,11 +51,8 @@ public class Endpoint : EndpointWithoutRequest<object>
yield return status;
}
else
{
yield return Queue.IsAcceptingCommands
? Queue.StatusCommand.Result
: blank;
}
yield return CurrentStatus.Result;
await Task.Delay(1000, c);
}
}

View File

@ -1,5 +1,5 @@
using InverterMon.Server.Persistance;
using InverterMon.Server.Persistance.Settings;
using InverterMon.Server.Persistence;
using InverterMon.Server.Persistence.Settings;
using InverterMon.Shared.Models;
namespace InverterMon.Server.Endpoints.PVLog.GetPVForDay;

View File

@ -1,53 +0,0 @@
using InverterMon.Server.InverterService;
using InverterMon.Server.Persistance.Settings;
using InverterMon.Shared.Models;
namespace InverterMon.Server.Endpoints.Settings.GetChargeAmpereValues;
public class Endpoint : EndpointWithoutRequest<ChargeAmpereValues>
{
public CommandQueue Queue { get; set; }
public UserSettings UserSettings { get; set; }
static ChargeAmpereValues? _ampereValues;
public override void Configure()
{
Get("settings/get-charge-ampere-values");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken c)
{
if (Env.IsDevelopment())
{
await SendAsync(
new()
{
CombinedAmpereValues = new[] { "010", "020", "030" },
UtilityAmpereValues = new[] { "04", "10", "20" }
});
return;
}
if (_ampereValues is null)
{
var cmd1 = new InverterService.Commands.GetChargeAmpereValues(false);
var cmd2 = new InverterService.Commands.GetChargeAmpereValues(true);
Queue.AddCommands(cmd1, cmd2);
await Task.WhenAll(
cmd1.WhileProcessing(c, 5000),
cmd2.WhileProcessing(c, 5000));
_ampereValues = new()
{
CombinedAmpereValues = cmd1.Result,
UtilityAmpereValues = cmd2.Result
};
}
await SendAsync(_ampereValues);
}
}

View File

@ -1,13 +1,10 @@
using InverterMon.Server.InverterService;
using InverterMon.Server.InverterService.Commands;
using InverterMon.Server.Persistance.Settings;
using InverterMon.Server.Persistence.Settings;
using InverterMon.Shared.Models;
namespace InverterMon.Server.Endpoints.Settings.GetSettingValues;
public class Endpoint : EndpointWithoutRequest<CurrentSettings>
{
public CommandQueue Queue { get; set; }
public UserSettings UserSettings { get; set; }
public override void Configure()
@ -18,27 +15,29 @@ public class Endpoint : EndpointWithoutRequest<CurrentSettings>
public override async Task HandleAsync(CancellationToken c)
{
var cmd = new GetSettings();
cmd.Result.SystemSpec = UserSettings.ToSystemSpec();
//todo: get values from inverter and send to client
if (Env.IsDevelopment())
{
cmd.Result.ChargePriority = "03";
cmd.Result.MaxACChargeCurrent = "10";
cmd.Result.MaxCombinedChargeCurrent = "020";
cmd.Result.OutputPriority = "02";
cmd.Result.BulkChargeVoltage = 27.1m;
await SendAsync(cmd.Result);
return;
}
Queue.AddCommands(cmd);
await cmd.WhileProcessing(c);
if (cmd.IsComplete)
await SendAsync(cmd.Result);
else
ThrowError("Unable to read settings in a timely manner!");
// var cmd = new GetSettings();
// cmd.Result.SystemSpec = UserSettings.ToSystemSpec();
//
// if (Env.IsDevelopment())
// {
// cmd.Result.ChargePriority = "03";
// cmd.Result.MaxACChargeCurrent = "10";
// cmd.Result.MaxCombinedChargeCurrent = "020";
// cmd.Result.OutputPriority = "02";
// cmd.Result.BulkChargeVoltage = 27.1m;
// await SendAsync(cmd.Result);
// return;
// }
//
// Queue.AddCommands(cmd);
//
// await cmd.WhileProcessing(c);
//
// if (cmd.IsComplete)
// await SendAsync(cmd.Result);
// else
// ThrowError("Unable to read settings in a timely manner!");
}
}

View File

@ -1,11 +1,7 @@
using InverterMon.Server.InverterService;
namespace InverterMon.Server.Endpoints.Settings.SetSettingValue;
namespace InverterMon.Server.Endpoints.Settings.SetSettingValue;
public class Endpoint : Endpoint<Shared.Models.SetSetting, bool>
{
public CommandQueue Queue { get; set; }
public override void Configure()
{
Get("settings/set-setting/{Command}/{Value}");
@ -14,9 +10,11 @@ public class Endpoint : Endpoint<Shared.Models.SetSetting, bool>
public override async Task HandleAsync(Shared.Models.SetSetting r, CancellationToken c)
{
var cmd = new InverterService.Commands.SetSetting(r.Command, r.Value);
Queue.AddCommands(cmd);
await cmd.WhileProcessing(c);
await SendAsync(cmd.Result);
//todo: set settings using inveter
// var cmd = new InverterService.Commands.SetSetting(r.Command, r.Value);
// Queue.AddCommands(cmd);
// await cmd.WhileProcessing(c);
// await SendAsync(cmd.Result);
}
}

View File

@ -1,5 +1,5 @@
using InverterMon.Server.Persistance;
using InverterMon.Server.Persistance.Settings;
using InverterMon.Server.Persistence;
using InverterMon.Server.Persistence.Settings;
namespace InverterMon.Server.Endpoints.Settings.SetSystemSpec;

View File

@ -1,102 +0,0 @@
using System.Diagnostics;
using ICommand = InverterMon.Server.InverterService.Commands.ICommand;
namespace InverterMon.Server.InverterService;
class CommandExecutor : BackgroundService
{
readonly CommandQueue queue;
readonly ILogger<CommandExecutor> logger;
readonly string _devPath = "/dev/hidraw0";
readonly bool _isTroubleMode;
readonly string _mppPath = "/usr/local/bin/mpp-solar";
public CommandExecutor(CommandQueue queue, IConfiguration config, ILogger<CommandExecutor> log)
{
this.queue = queue;
logger = log;
_devPath = config["LaunchSettings:DeviceAddress"] ?? _devPath;
_isTroubleMode = config["LaunchSettings:TroubleMode"] == "yes";
_mppPath = config["LaunchSettings:MppSolarPath"] ?? _mppPath;
log.LogInformation("connecting to the inverter...");
var sw = new Stopwatch();
sw.Start();
while (!Connect() && sw.Elapsed.TotalMinutes <= 5)
Thread.Sleep(10000);
if (sw.Elapsed.TotalMinutes >= 5)
log.LogInformation("inverter connecting timed out!");
}
bool Connect()
{
if (!Inverter.Connect(_devPath, logger))
return false;
logger.LogInformation("connected to inverter at: [{adr}]", _devPath);
return true;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var delay = 0;
var timeout = TimeSpan.FromMinutes(5);
while (!ct.IsCancellationRequested && delay <= timeout.TotalMilliseconds)
{
var cmd = queue.GetCommand();
if (cmd is not null)
{
try
{
await ExecuteCommand(cmd, ct);
queue.IsAcceptingCommands = true;
delay = 0;
queue.RemoveCommand();
}
catch (Exception x)
{
queue.IsAcceptingCommands = false;
logger.LogError("command [{cmd}] failed with reason [{msg}]", cmd.CommandString, x.Message);
await Task.Delay(delay += 1000);
}
}
else
await Task.Delay(500, ct);
}
logger.LogError("command execution halted due to excessive failures!");
}
async Task ExecuteCommand(ICommand command, CancellationToken ct)
{
if (_isTroubleMode && command.IsTroublesomeCmd)
{
Inverter.Disconnect();
using var process = new Process();
process.StartInfo.FileName = _mppPath;
process.StartInfo.Arguments = $"-p {_devPath} -o raw -c {command.CommandString}";
process.StartInfo.UseShellExecute = false;
process.StartInfo.RedirectStandardOutput = true;
process.Start();
command.Start();
var output = await process.StandardOutput.ReadToEndAsync(ct);
var result = output.ParseCli()[1..^1];
command.Parse(result);
command.End();
await process.WaitForExitAsync(ct);
Inverter.Connect(_devPath, logger);
}
else
{
command.Start();
await Inverter.Write(command.CommandString, ct);
command.Parse(await Inverter.Read(ct));
command.End();
}
}
}

View File

@ -1,32 +0,0 @@
using System.Collections.Concurrent;
using InverterMon.Server.InverterService.Commands;
using ICommand = InverterMon.Server.InverterService.Commands.ICommand;
namespace InverterMon.Server.InverterService;
public class CommandQueue
{
public bool IsAcceptingCommands { get; set; } = true;
public GetStatus StatusCommand { get; } = new();
readonly ConcurrentQueue<ICommand> _toProcess = new();
public bool AddCommands(params ICommand[] commands)
{
if (IsAcceptingCommands)
{
foreach (var cmd in commands)
_toProcess.Enqueue(cmd);
return true;
}
return false;
}
public ICommand? GetCommand()
=> _toProcess.TryPeek(out var command) ? command : null;
public void RemoveCommand()
=> _toProcess.TryDequeue(out _);
}

View File

@ -1,39 +0,0 @@
// ReSharper disable UnassignedGetOnlyAutoProperty
namespace InverterMon.Server.InverterService.Commands;
public interface ICommand
{
string CommandString { get; set; }
bool IsTroublesomeCmd { get; }
void Parse(string rawResponse);
void Start();
void End();
}
public abstract class Command<TResponseDto> : ICommand where TResponseDto : new()
{
public abstract string CommandString { get; set; }
public virtual bool IsTroublesomeCmd { get; }
public TResponseDto Result { get; protected set; } = new();
public bool IsComplete { get; private set; }
public abstract void Parse(string responseFromInverter);
protected DateTime startTime = DateTime.Now;
public void Start()
{
startTime = DateTime.Now;
IsComplete = false;
}
public void End()
=> IsComplete = true;
public async Task WhileProcessing(CancellationToken c, int timeoutMillis = Constants.StatusPollingFrequencyMillis)
{
while (!c.IsCancellationRequested && !IsComplete && DateTime.Now.Subtract(startTime).TotalMilliseconds <= timeoutMillis)
await Task.Delay(500, c);
}
}

View File

@ -1,32 +0,0 @@
// ReSharper disable VirtualMemberCallInConstructor
namespace InverterMon.Server.InverterService.Commands;
class GetChargeAmpereValues : Command<List<string>>
{
public override string CommandString { get; set; } = "QMCHGCR";
public override bool IsTroublesomeCmd { get; } = true;
public GetChargeAmpereValues(bool getUtilityValues)
{
Result.AddRange(new[] { "000" });
if (getUtilityValues)
CommandString = "QMUCHGCR";
}
public override void Parse(string responseFromInverter)
{
if (responseFromInverter.StartsWith("(NAK"))
return;
var parts = responseFromInverter[1..]
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x[..3]);
if (parts.Any())
Result.Clear(); //remove default values
Result.AddRange(parts);
}
}

View File

@ -1,52 +0,0 @@
using InverterMon.Shared.Models;
namespace InverterMon.Server.InverterService.Commands;
class GetSettings : Command<CurrentSettings>
{
public override string CommandString { get; set; } = "QPIRI";
public override void Parse(string responseFromInverter)
{
// 1) 230.0 - grid rating voltage
// 2) 15.2 - grid rating current
// 3) 230.0 - ac output rating voltage
// 4) 50.0 - ac output rating frequency
// 5) 15.2 - ac output rating current
// 6) 3500 - ac output rating apparant power
// 7) 3500 - ac output rating active power
// 8) 24.0 - batt rating voltage
// 9) 23.5 - batt back to grid voltage
// 10) 23.4 - batt discharge cut off voltage
// 11) 28.8 - batt bulk charging voltage
// 12) 27.0 - batt float charging voltage
// 13) 2 - battery type (0:agm / 1:flooded / 2: user)
// 14) 10 - max ac charging current
// 15) 020 - max combined charging current
// 16) 1 - input voltage range (0:appliance / 1:ups)
// 17) 1 - output source priority (0:utility first / 1:solar first / 2:solar>battery>utility)
// 18) 3 - charge priority (0:utility first /1:solar first / 2:solar & utility / 3:only solar)
// 19) 1 - parallel max number
// 20) 01 - machine type
// 21) 0 - topology
// 22) 0 - output mode
// 23) 28.5 - back to battery use voltage
// 24) 0 - pv ok for parallel
// 25) 1 - pv power balance
if (responseFromInverter.StartsWith("(NAK"))
return;
var parts = responseFromInverter[1..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
Result.BackToGridVoltage = decimal.Parse(parts[9 - 1]);
Result.DischargeCuttOffVoltage = decimal.Parse(parts[10 - 1]);
Result.BulkChargeVoltage = decimal.Parse(parts[11 - 1]);
Result.FloatChargeVoltage = decimal.Parse(parts[12 - 1]);
Result.MaxACChargeCurrent = parts[14 - 1];
Result.MaxCombinedChargeCurrent = parts[15 - 1];
Result.OutputPriority = $"0{parts[17 - 1]}";
Result.ChargePriority = $"0{parts[18 - 1]}";
Result.BackToBatteryVoltage = decimal.Parse(parts[23 - 1]);
}
}

View File

@ -1,31 +0,0 @@
using InverterMon.Shared.Models;
namespace InverterMon.Server.InverterService.Commands;
public class GetStatus : Command<InverterStatus>
{
public override string CommandString { get; set; } = "QPIGS";
public override void Parse(string responseFromInverter)
{
//(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
//(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
if (responseFromInverter.StartsWith("(NAK"))
return;
var parts = responseFromInverter[1..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
Result.GridVoltage = decimal.Parse(parts[0]);
Result.OutputVoltage = decimal.Parse(parts[2]);
Result.LoadWatts = int.Parse(parts[5]);
Result.LoadPercentage = decimal.Parse(parts[6]);
Result.BatteryVoltage = decimal.Parse(parts[8]);
Result.BatteryChargeCurrent = int.Parse(parts[9]);
Result.HeatSinkTemperature = int.Parse(parts[11]);
Result.PVInputCurrent = decimal.Parse(parts[12]);
Result.PVInputVoltage = decimal.Parse(parts[13]);
Result.BatteryDischargeCurrent = int.Parse(parts[15]);
Result.PVInputWatt = Result.PVInputVoltage == 00 ? 0 : Convert.ToInt32(int.Parse(parts[19]));
}
}

View File

@ -1,19 +0,0 @@
// ReSharper disable VirtualMemberCallInConstructor
namespace InverterMon.Server.InverterService.Commands;
class SetSetting : Command<bool>
{
public override string CommandString { get; set; }
public override bool IsTroublesomeCmd { get; } = true;
public SetSetting(string settingName, string settingValue)
{
CommandString = settingName + settingValue;
}
public override void Parse(string responseFromInverter)
{
Result = responseFromInverter[1..4] == "ACK";
}
}

View File

@ -1,6 +0,0 @@
namespace InverterMon.Server.InverterService;
public static class Constants
{
public const int StatusPollingFrequencyMillis = 2000;
}

View File

@ -0,0 +1,8 @@
using InverterMon.Shared.Models;
namespace InverterMon.Server.InverterService;
public class CurrentStatus
{
public InverterStatus Result { get; set; }
}

View File

@ -1,28 +0,0 @@
using System.Text.RegularExpressions;
namespace InverterMon.Server.InverterService;
public static partial class Extensions
{
[GeneratedRegex(@"[^\u0009\u000A\u000D\u0020-\u007E]")]
private static partial Regex StringSanitizer();
static readonly Regex _sanRx = StringSanitizer();
public static string Sanitize(this string input)
=> _sanRx.Replace(input, "");
[GeneratedRegex(@"'\((.*?)\\")]
private static partial Regex CLIParser();
static readonly Regex _cliRx = CLIParser();
public static string ParseCli(this string input)
{
var match = _cliRx.Match(input);
return match.Success
? match.Groups[0].Value
: "`(NAK\\";
}
}

View File

@ -1,143 +0,0 @@
using System.IO.Ports;
using System.Text;
namespace InverterMon.Server.InverterService;
public static class Inverter
{
static SerialPort? _serialPort;
static FileStream? _fileStream;
public static bool Connect(string devicePath, ILogger logger)
{
try
{
if (devicePath.Contains("/hidraw", StringComparison.OrdinalIgnoreCase))
{
_fileStream = new(devicePath, FileMode.Open, FileAccess.ReadWrite);
return true;
}
if (devicePath.Contains("/ttyUSB", StringComparison.OrdinalIgnoreCase) || devicePath.Contains("COM", StringComparison.OrdinalIgnoreCase))
{
_serialPort = new(devicePath)
{
BaudRate = 2400,
Parity = Parity.None,
DataBits = 8,
StopBits = StopBits.One,
Handshake = Handshake.None
};
_serialPort.Open();
return true;
}
logger.LogError("device path [{path}] is not acceptable!", devicePath);
}
catch (Exception x)
{
logger.LogError("connection error at [{path}]. reason: [{reason}]", devicePath, x.Message);
}
return false;
}
public static void Disconnect()
{
_serialPort?.Close();
_serialPort?.Dispose();
_fileStream?.Close();
_fileStream?.Dispose();
}
static readonly byte[] _writeBuffer = new byte[512];
public static Task Write(string command, CancellationToken ct)
{
var cmdBytes = Encoding.ASCII.GetBytes(command);
var crc = CalculateXmodemCrc16(command);
Buffer.BlockCopy(cmdBytes, 0, _writeBuffer, 0, cmdBytes.Length);
_writeBuffer[cmdBytes.Length] = (byte)(crc >> 8);
_writeBuffer[cmdBytes.Length + 1] = (byte)(crc & 0xff);
_writeBuffer[cmdBytes.Length + 2] = 0x0d;
if (_fileStream != null)
return _fileStream.WriteAsync(_writeBuffer, 0, cmdBytes.Length + 3, ct);
return _serialPort != null
? _serialPort.BaseStream.WriteAsync(_writeBuffer, 0, cmdBytes.Length + 3, ct)
: Task.CompletedTask;
}
static readonly byte[] _readBuffer = new byte[1024];
public static async Task<string> Read(CancellationToken ct)
{
var pos = 0;
const byte eol = 0x0d;
if (_fileStream != null)
{
do
{
var readCount = await _fileStream.ReadAsync(_readBuffer.AsMemory(pos, _readBuffer.Length - pos), ct);
if (readCount > 0)
{
pos += readCount;
for (var i = pos - readCount; i < pos; i++)
{
if (_readBuffer[i] == eol)
return Encoding.ASCII.GetString(_readBuffer, 0, i - 2).Sanitize();
}
}
} while (pos < _readBuffer.Length);
}
else if (_serialPort != null)
{
do
{
var readCount = await _serialPort.BaseStream.ReadAsync(_readBuffer.AsMemory(pos, _readBuffer.Length - pos), ct);
if (readCount > 0)
{
pos += readCount;
for (var i = pos - readCount; i < pos; i++)
{
if (_readBuffer[i] == eol)
return Encoding.ASCII.GetString(_readBuffer, 0, i - 2).Sanitize();
}
}
} while (pos < _readBuffer.Length);
}
else
throw new InvalidOperationException("inverter not connected.");
throw new InvalidOperationException("buffer overflow.");
}
static ushort CalculateXmodemCrc16(string data)
{
ushort crc = 0;
var length = data.Length;
for (var i = 0; i < length; i++)
{
crc ^= (ushort)(data[i] << 8);
for (var j = 0; j < 8; j++)
{
if ((crc & 0x8000) != 0)
crc = (ushort)((crc << 1) ^ 0x1021);
else
crc <<= 1;
}
}
return crc;
}
}

View File

@ -1,26 +1,22 @@
using InverterMon.Server.Persistance;
using InverterMon.Server.Persistance.Settings;
using InverterMon.Server.Persistence;
using InverterMon.Server.Persistence.Settings;
namespace InverterMon.Server.InverterService;
class StatusRetriever(CommandQueue queue, Database db, UserSettings userSettings) : BackgroundService
class StatusRetriever(Database db, CurrentStatus currentStatus, UserSettings userSettings) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken c)
{
var cmd = queue.StatusCommand;
while (!c.IsCancellationRequested)
{
if (queue.IsAcceptingCommands)
{
//feels hacky. find a better solution.
cmd.Result.BatteryCapacity = userSettings.BatteryCapacity;
cmd.Result.PV_MaxCapacity = userSettings.PV_MaxCapacity;
currentStatus.Result.BatteryCapacity = userSettings.BatteryCapacity;
currentStatus.Result.PV_MaxCapacity = userSettings.PV_MaxCapacity;
queue.AddCommands(cmd);
_ = db.UpdateTodaysPvGeneration(cmd, c);
}
await Task.Delay(Constants.StatusPollingFrequencyMillis);
//todo: get data from inverter and map to CurrentStatus.Result
_ = db.UpdateTodaysPvGeneration(currentStatus, c);
await Task.Delay(2000);
}
}
}

View File

@ -1,23 +1,22 @@
using InverterMon.Server.InverterService;
using InverterMon.Server.InverterService.Commands;
using InverterMon.Server.Persistance.PVGen;
using InverterMon.Server.Persistance.Settings;
using InverterMon.Server.Persistence.PVGen;
using InverterMon.Server.Persistence.Settings;
using LiteDB;
namespace InverterMon.Server.Persistance;
namespace InverterMon.Server.Persistence;
public class Database
{
readonly LiteDatabase _db;
readonly CommandQueue _queue;
readonly CurrentStatus _currentStatus;
readonly UserSettings _settings;
readonly ILiteCollection<PVGeneration> _pvGenCollection;
readonly ILiteCollection<UserSettings> _usrSettingsCollection;
PVGeneration? _today;
public Database(IHostApplicationLifetime lifetime, CommandQueue queue, UserSettings settings)
public Database(IHostApplicationLifetime lifetime, CurrentStatus status, UserSettings settings)
{
_queue = queue;
_currentStatus = status;
_settings = settings;
_db = new("InverterMon.db") { CheckpointSize = 0 };
lifetime.ApplicationStopping.Register(() => _db?.Dispose());
@ -27,8 +26,6 @@ public class Database
RestoreUserSettings();
}
//todo: break apart this class and put seperated logic in each vertical slice
public void RestoreTodaysPvWattHours()
{
var todayDayNumber = DateOnly.FromDateTime(DateTime.Now).DayNumber;
@ -39,26 +36,24 @@ public class Database
.SingleOrDefault();
if (_today is not null)
_queue.StatusCommand.Result.RestorePVWattHours(_today.TotalWattHours);
_currentStatus.Result.RestorePVWattHours(_today.TotalWattHours);
else
{
_today = new() { Id = todayDayNumber };
_today.SetTotalWattHours(0);
_queue.StatusCommand.Result.RestorePVWattHours(0);
_currentStatus.Result.RestorePVWattHours(0);
_pvGenCollection.Insert(_today);
_db.Checkpoint();
}
}
public async Task UpdateTodaysPvGeneration(GetStatus cmd, CancellationToken c)
public async Task UpdateTodaysPvGeneration(CurrentStatus cmd, CancellationToken c)
{
var hourNow = DateTime.Now.Hour;
if (hourNow < _settings.SunlightStartHour || hourNow >= _settings.SunlightEndHour)
return;
await cmd.WhileProcessing(c);
var todayDayNumber = DateOnly.FromDateTime(DateTime.Now).DayNumber;
if (_today?.Id == todayDayNumber)

View File

@ -1,4 +1,4 @@
namespace InverterMon.Server.Persistance.PVGen;
namespace InverterMon.Server.Persistence.PVGen;
public static class PVGenExtensions
{

View File

@ -1,4 +1,4 @@
namespace InverterMon.Server.Persistance.PVGen;
namespace InverterMon.Server.Persistence.PVGen;
public class PVGeneration
{

View File

@ -1,7 +1,7 @@
using InverterMon.Server.Persistance.PVGen;
using InverterMon.Server.Persistence.PVGen;
using InverterMon.Shared.Models;
namespace InverterMon.Server.Persistance.Settings;
namespace InverterMon.Server.Persistence.Settings;
public class UserSettings
{

View File

@ -4,8 +4,8 @@ using System.Net;
using InverterMon.Server;
using InverterMon.Server.BatteryService;
using InverterMon.Server.InverterService;
using InverterMon.Server.Persistance;
using InverterMon.Server.Persistance.Settings;
using InverterMon.Server.Persistence;
using InverterMon.Server.Persistence.Settings;
//avoid parsing issues with non-english cultures
var cultureInfo = new CultureInfo("en-US");
@ -18,15 +18,14 @@ _ = int.TryParse(bld.Configuration["LaunchSettings:WebPort"] ?? "80", out var po
bld.WebHost.ConfigureKestrel(o => o.Listen(IPAddress.Any, port));
bld.Services
.AddSingleton<CurrentStatus>()
.AddSingleton<UserSettings>()
.AddSingleton<CommandQueue>()
.AddSingleton<Database>()
.AddSingleton<JkBms>();
if (!bld.Environment.IsDevelopment())
{
bld.Services
.AddHostedService<CommandExecutor>()
.AddHostedService<StatusRetriever>();
}

View File

@ -1,7 +0,0 @@
namespace InverterMon.Shared.Models;
public class ChargeAmpereValues
{
public IEnumerable<string> CombinedAmpereValues { get; set; }
public IEnumerable<string> UtilityAmpereValues { get; set; }
}

View File

@ -2,8 +2,8 @@
public static class ChargePriority
{
public const string SolarFirst = "01";
public const string SolarAndUtility = "02";
public const string OnlySolar = "03";
public const string UtilityFirst = "00";
public const string SolarFirst = "1";
public const string SolarAndUtility = "2";
public const string OnlySolar = "3";
public const string UtilityFirst = "0";
}

View File

@ -0,0 +1,14 @@
namespace InverterMon.Shared.Models;
public enum Setting
{
ChargePriority = 1,
OutputPriority = 2,
CombinedChargeCurrent = 3,
UtilityChargeCurrent = 4,
BulkVoltage = 5,
FloatVoltage = 6,
DischargeCutOff = 7,
BackToGrid = 8,
BackToBattery = 9
}

View File

@ -2,7 +2,7 @@
public static class OutputPriority
{
public const string SolarFirst = "01";
public const string SolarBatteryUtility = "02";
public const string UtilityFirst = "00";
public const string SolarFirst = "1";
public const string SolarBatteryUtility = "2";
public const string UtilityFirst = "0";
}