This commit is contained in:
Sina Blattmann 2023-06-19 17:24:19 +02:00
commit b649abb7f8
20 changed files with 542 additions and 129 deletions

Binary file not shown.

View File

@ -7,7 +7,7 @@ public static class Ssh
{
const String ConfigFile = "/data/innovenergy/openvpn/installation-name";
public static Task<Int32> Interactive(String host, String installationName, String user, String port)
public static Task<Int32> Interactive(String host, String? installationName, String user, String port)
{
var fixInstallationName = $"echo '{installationName}' > {ConfigFile}; exec bash -l";

View File

@ -1,45 +1,72 @@
using System.Web;
using CliWrap;
using CliWrap.Buffered;
using HandlebarsDotNet;
using InnovEnergy.App.RemoteSupportConsole;
using InnovEnergy.App.VrmGrabber.Database;
using InnovEnergy.Lib.Victron.VictronVRM;
using InnovEnergy.App.VrmGrabber.DataTypes;
using InnovEnergy.Lib.Utils;
using Microsoft.AspNetCore.Mvc;
using FILE=System.IO.File;
using VrmInstallation = InnovEnergy.Lib.Victron.VictronVRM.Installation;
namespace InnovEnergy.App.VrmGrabber;
public record Install(
public record InstallationToHtmlInterface(
String Name,
String Ip,
UInt64 Vrm,
Int64 Vrm,
String Identifier,
String Serial,
String EscapedName
String EscapedName,
String Online,
String LastSeen,
String NumBatteries,
String BatteryVersion,
String ServerIp = "10.2.0.1", //TODO MAKE ME DYNAMIC
String FirmwareVersion = "AF09" //Todo automatically grab newest version?
);
[Controller]
public class Controller : ControllerBase
{
//Todo automatically grab newest version?
private const String FirmwareVersion = "AF09";
[HttpGet]
[Route("/")]
[Produces("text/html")]
public ActionResult Index()
{
var instList = Db.InstallationsAndDetails.Keys.ToList();
if (instList.Count == 0) return new ContentResult
{
ContentType = "text/html",
Content = "<p>Please wait page is still loading</p>"
};
String source = @"<head>
const String source = @"<head>
<style>
tbody {
background-color: #e4f0f5;
}
tbody tr:nth-child(odd) {
background-color: #ECE9E9;
}
th, td { /* cell */
padding: 0.75rem;
font-size: 0.9375rem;
}
th { /* header cell */
font-weight: 700;
text-align: left;
color: #272838;
border-bottom: 2px solid #EB9486;
position: sticky;
top: 0;
background-color: #F9F8F8;
}
table {
border-collapse: collapse;
width: 100%;
border: 2px solid rgb(200, 200, 200);
letter-spacing: 1px;
font-family: sans-serif;
@ -47,43 +74,83 @@ table {
position: absolute; top: 0; bottom: 0; left: 0; right: 0;
}
td,
th {
thead th {
border: 1px solid rgb(190, 190, 190);
padding: 5px 10px;
position: sticky;
position: -webkit-sticky;
top: 0px;
background: white;
z-index: 999;
}
td {
text-align: left;
}
#managerTable {
overflow: hidden;
}</style></head>
<div id='managerTable'>
<table>
<tbody>
<tr>
<th>Name</th>
<th>Gui</th>
<th>VRM</th>
<th>Grafana</th>
<th>Identifier</th>
<th>Last Seen</th>
<th>Serial</th>
<th>#Batteries</th>
<th>Firmware-Version</th>
<th>Update</th>
</tr>
{{#inst}}
{{> installations}}
{{/inst}}
</tbody>
</table>";
</table>
<div id='managerTable'>";
String partialSource =
@"<tr><td>{{Name}}</td>
<td><a target='_blank' href=http://{{Ip}}>{{Ip}}</a></td>
const String partialSource = @"<tr><td>{{Name}}</td>
<td><a target='_blank' href=http://{{Ip}}>{{online}} {{Ip}}</a></td>
<td><a target='_blank' href=https://vrm.victronenergy.com/installation/{{Vrm}}/dashboard>VRM</a></td>
<td><a target='_blank' href='https://salidomo.innovenergy.ch/d/ENkNRQXmk/installation?refresh=5s&orgId=1&var-Installation={{EscapedName}}&kiosk=tv'>Grafana</a></td>
<td>{{Identifier}}</td>
<td>{{LastSeen}}</td>
<td>{{Serial}}</td>
<td><a target='_blank' href='https://salidomo.innovenergy.ch/d/ENkNRQXmk/installation?refresh=5s&orgId=1&var-Installation={{EscapedName}}&kiosk=tv'>Grafana</a></td></tr>";
<td>{{NumBatteries}}</td>
<td>{{BatteryVersion}}</td>
<td><a target='_blank' href=http://{{ServerIp}}/UpdateBatteryFirmware/{{Ip}}/{{NumBatteries}}>⬆️{{FirmwareVersion}}</a></td>
</tr>";
var installationsInDb = Db.Installations.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList();
if (installationsInDb.Count == 0) return new ContentResult
{
ContentType = "text/html",
Content = "<p>Please wait page is still loading</p>"
};
Handlebars.RegisterTemplate("installations", partialSource);
var template = Handlebars.Compile(source);
var insts = instList.Select(i =>
{
return new Install(i.Name, Ip(i), i.IdSite, i.Identifier, Serial(i), HttpUtility.UrlEncode(i.Name));
});
var installsForHtml = installationsInDb.Select(i => new InstallationToHtmlInterface(
i.Name,
i.Ip,
i.Vrm,
i.Identifier,
i.Serial,
i.EscapedName,
i.Online,
DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(i.LastSeen)).ToString(),
i.NumberOfBatteries,
i.BatteryFirmwareVersion));
var data = new
{
inst = insts
inst = installsForHtml,
};
var result = template(data);
@ -95,17 +162,68 @@ td {
};
}
private String Ip(Installation installation)
{
return VpnInfo.LookUpIp(installation.Identifier, Serial(installation)).Result ?? "Unknown";
[HttpGet("updatebatteryfirmware/{installationIp}/{numberOfBatteries}")]
public async Task<String> UpdateBatteryFirmware(String installationIp, String numberOfBatteries)
{
//We need the DeviceName of the battery (ttyUSB?)
var pathToBattery = await Db.ExecuteBufferedAsyncCommandOnIp(installationIp, "dbus-send --system --dest=com.victronenergy.system --type=method_call --print-reply /ServiceMapping/com_victronenergy_battery_1 com.victronenergy.BusItem.GetText");
var split = pathToBattery.Split('"');
var split2 = pathToBattery.Split(' ');
if (split.Length < 2 || split2.Length < 1)
{
Console.WriteLine(pathToBattery + " Split failed ");
return "Update failed";
}
if (split[1] == "Failed" || split2[0] == "Error") return "Update failed";
await UpdateVrmTagsToNewFirmware(installationIp);
SendNewBatteryFirmware(installationIp);
for (var batteryId = 2; batteryId <= Int64.Parse(numberOfBatteries) + 1; batteryId++)
{
var batteryTtyName = split[1].Split(".").Last();
var localCommand = $"/opt/innovenergy/scripts/upload-bms-firmware {batteryTtyName} {batteryId} /opt/innovenergy/bms-firmware/{FirmwareVersion}.bin";
#pragma warning disable CS4014
Db.ExecuteBufferedAsyncCommandOnIp(installationIp, localCommand);
#pragma warning restore CS4014
// Console.WriteLine(remoteUpdateCommandResult);
}
return "Battery update is successfully initiated! You can close this page now.";
}
private String Serial(Installation installation)
private static async Task UpdateVrmTagsToNewFirmware(String installationIp)
{
return Db.InstallationsAndDetails[installation].MachineSerial() ?? "Unknown";
var vrmInstallation = await FindVrmInstallationByIp(installationIp);
var tags = await vrmInstallation.GetTags();
async void RemoveTag(String t) => await vrmInstallation.RemoveTags(t);
tags.Where(tag => tag.StartsWith("FM-"))
.Do(RemoveTag);
await vrmInstallation.AddTags("FM-" + FirmwareVersion);
}
private static async Task<VrmInstallation> FindVrmInstallationByIp(String installationIp)
{
var installationId = Db.Installations.Where(i => i.Ip == installationIp).Select(i => i.Vrm).First();
var vrmAccount = await Db.GetVrmAccount();
return await vrmAccount.GetInstallation(installationId!);
}
private static void SendNewBatteryFirmware(String installationIp)
{
Cli.Wrap("scp")
.WithArguments($@"{FirmwareVersion}.bin")
.AppendArgument($@"root@{installationIp}:/opt/innovenergy/bms-firmware/{FirmwareVersion}.bin")
.WithValidation(CommandResultValidation.None).ExecuteBufferedAsync();
}
// [HttpGet(nameof(GetInstallation))]
// [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
// public Object GetInstallation(UInt64 serialNumber)
@ -123,9 +241,3 @@ td {
// }
}
// installation Name, ip (link uf gui), idSite (vrm link), identifier , machineserial (HQ...)

View File

@ -1,17 +1,42 @@
using InnovEnergy.Lib.Victron.VictronVRM;
using SQLite;
namespace InnovEnergy.App.VrmGrabber.DataTypes;
public class Installation : TreeNode
public class Installation
{
public Installation(String? argName, String? argIp, Int64 argVrm, String? argIdentifier, String? serial, String? urlEncode, String? online, String? lastSeen, String details, String numberOfBatteries, String batteryFirmwareVersion)
{
Name = argName;
Ip = argIp;
Vrm = argVrm;
Identifier = argIdentifier;
Serial = serial;
EscapedName = urlEncode;
Online = online;
LastSeen = lastSeen;
Details = details;
NumberOfBatteries = numberOfBatteries;
BatteryFirmwareVersion = batteryFirmwareVersion;
}
public Installation()
{
}
public String Name { get; set; }
// Settings
public IReadOnlyList<Detail> Notes { get; set; }
public String? Name { get; set;}
public String? Ip { get; set;}
public Int64 Vrm { get; set;}
[PrimaryKey]
public String? Identifier { get; set;}
public String? Serial { get; set;}
public String? EscapedName { get; set;}
public String? Online { get; set;}
public String? LastSeen { get; set;}
public String? NumberOfBatteries { get; set;}
public String? BatteryFirmwareVersion { get; set;}
public String? Details { get; set; } //JSON
}

View File

@ -1,5 +1,6 @@
using InnovEnergy.App.VrmGrabber.DataTypes;
namespace InnovEnergy.App.VrmGrabber.Database;

View File

@ -1,62 +1,264 @@
using System.Diagnostics.CodeAnalysis;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Runtime.InteropServices;
using System.Web;
using CliWrap;
using CliWrap.Buffered;
using InnovEnergy.App.RemoteSupportConsole;
using InnovEnergy.Lib.Utils;
using InnovEnergy.Lib.Victron.VictronVRM;
using SQLite;
using static System.Text.Json.JsonSerializer;
using static InnovEnergy.App.VrmGrabber.Database.Systemd;
using Installation = InnovEnergy.App.VrmGrabber.DataTypes.Installation;
using VrmInstallation = InnovEnergy.Lib.Victron.VictronVRM.Installation;
using FILE=System.IO.File;
namespace InnovEnergy.App.VrmGrabber.Database;
public static class Systemd
{
[DllImport("libsystemd.so.0", CharSet = CharSet.Unicode)]
public static extern Int32 sd_notify(Int32 unsetEnvironment, String state);
}
public class InstallationDetails
{
public InstallationDetails(String? ip, IReadOnlyList<Detail> details)
{
Details = details;
Ip = ip;
}
public IReadOnlyList<Detail>? Details { get; set; }
public String? Ip { get; set; }
}
public static partial class Db
{
public static Dictionary<Lib.Victron.VictronVRM.Installation, IReadOnlyList<Detail>> InstallationsAndDetails;
internal const String DbPath = "./db.sqlite";
public static Dictionary<Installation, InstallationDetails> InstallationsAndDetails;
private const String DbPath = "./db.sqlite";
private static SQLiteConnection Connection { get; } = new SQLiteConnection(DbPath);
public static TableQuery<Installation> Installations => Connection.Table<Installation>();
public static void Init()
{
// used to force static constructor
}
static Db()
{
// on startup create/migrate tables
Connection.RunInTransaction(() =>
Connection.RunInTransaction(() => { Connection.CreateTable<Installation>(); });
}
public static async Task UpdateDetailsAndInstallations()
{
// Connection.CreateTable<Installation>();
});
InstallationsAndDetails = new Dictionary<Lib.Victron.VictronVRM.Installation, IReadOnlyList<Detail>>();
Observable.Interval(TimeSpan.FromMinutes(5))
.ObserveOn(TaskPoolScheduler.Default)
.SubscribeOn(TaskPoolScheduler.Default)
.StartWith(0) // Do it right away (on startup)
.Select(UpdateInstallationsAndDetailsFromVrm)
.Select(t => t.ToObservable())
.Concat()
.Subscribe(d => InstallationsAndDetails = d, exception => exception.WriteLine());
sd_notify(0, "READY=1");
do {
await UpdateInstallationsAndDetailsFromVrm(0);
}
while (true) ;
}
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
private static async Task<Dictionary<Lib.Victron.VictronVRM.Installation, IReadOnlyList<Detail>>> UpdateInstallationsAndDetailsFromVrm(Int64 _)
private static async Task UpdateInstallationsAndDetailsFromVrm(Int32 _)
{
var user = await GetVrmAccount();
var readOnlyInstallations = await user.GetInstallations();
var installations = readOnlyInstallations.ToList();
installations.Shuffle();
foreach (var installation in installations)
{
Console.WriteLine(installation.Name);
sd_notify(0, "WATCHDOG=1");
var details = await GetInstallationDetails(installation);
var ip = Ip(details);
var updatedInstallation = new Installation(
installation.Name,
ip[0],
(Int64)installation.IdSite,
installation.Identifier,
details.Details.MachineSerial() ?? "Unknown",
HttpUtility.UrlEncode(installation.Name),
ip[1],
details.Details.Last().Json["timestamp"].ToString(),
Serialize(details.Details),
await NumberOfBatteries(ip[0], ip[1]),
await BatteryFirmwareVersion(ip[0], ip[1]));
if (ip[0] != "Unknown")
await UpdateInstallationName(installation, ip[0]);
if (GetInstallationByIdentifier(installation.Identifier) == null)
{
Create(updatedInstallation);
}
else
{
Update(updatedInstallation);
}
}
}
public static async Task<VrmAccount> GetVrmAccount()
{
var fileContent = await File.ReadAllTextAsync("./token.json");
var acc = Deserialize<AccToken>(fileContent);
var user = VrmAccount.Token(acc.idUser, acc.token);
var installations = await user.GetInstallations();
return installations
.Do(i=>i.Name.WriteLine())
.ToDictionary(i => i, i => i.GetDetails().Result);
var user = VrmAccount.Token(acc!.idUser, acc.token);
return user;
}
private static async Task<String> BatteryFirmwareVersion(String? ip, String? online)
{
if (ip is null or "Unknown" || online == "❌") return "Unknown";
var pathToBattery = await ExecuteBufferedAsyncCommandOnIp(ip, "dbus-send --system --dest=com.victronenergy.system --type=method_call --print-reply /ServiceMapping/com_victronenergy_battery_1 com.victronenergy.BusItem.GetText");
if (pathToBattery.Split('"')[1].StartsWith("Error")) return "Unknown";
var command = $"dbus-send --system --dest={pathToBattery.Split('"')[1]} --type=method_call --print-reply /FirmwareVersion com.victronenergy.BusItem.GetText";
var returnString = await ExecuteBufferedAsyncCommandOnIp(ip, command);
var returnStringShortened = returnString.Split('"')[1];
return returnStringShortened.Length > 5 ? "Unknown" : returnStringShortened;
}
private static async Task<String> NumberOfBatteries(String? ip, String? online)
{
if (ip is null or "Unknown" || online == "❌") return "Failed";
var pathToBattery = await ExecuteBufferedAsyncCommandOnIp(ip, "dbus-send --system --dest=com.victronenergy.system --type=method_call --print-reply /ServiceMapping/com_victronenergy_battery_1 com.victronenergy.BusItem.GetText");
if (pathToBattery.Split('"')[1].StartsWith("Error")) return "Failed";
var cmd = await ExecuteBufferedAsyncCommandOnIp(ip,$"dbus-send --system --dest={pathToBattery.Split('"')[1]} --type=method_call --print-reply /NbOfBatteries com.victronenergy.BusItem.GetText" );
var cmdResult = cmd.Split('"')[1];
return cmdResult; //No Batteries can be found
}
public static async Task<String> ExecuteBufferedAsyncCommandOnIp(String? ip, String command)
{
if (ip is null or "Unknown") return "Failed";
var cmd = await Cli.Wrap("ssh")
.WithArguments($@"root@{ip}")
.AppendArgument("-o StrictHostKeyChecking=no")
.AppendArgument(command)
.WithValidation(CommandResultValidation.None).ExecuteBufferedAsync();
return cmd.ExitCode == 0 ? cmd.StandardOutput : cmd.StandardError;
}
private static String?[] Ip(InstallationDetails details)
{
var online = "❌";
var lookup = details.Ip;
if (lookup == "Unknown")
{
var serial = details.Details.MachineSerial() ?? "Unknown";
if (serial != "Unknown" && FILE.Exists($@"/etc/openvpn/server/Salino/ccd/{serial}"))
lookup = FILE.ReadAllText($@"/etc/openvpn/server/Salino/ccd/{serial}").Split(' ')[1];
}
else
{
online = "✅";
}
return new[] { lookup, online };
}
private static async Task<InstallationDetails> GetInstallationDetails(VrmInstallation i)
{
await Task.Delay(1000);
try
{
var details = await i.GetDetails();
var ip = await VpnInfo.LookUpIp(i.Identifier, details.MachineSerial()) ?? "Unknown";
var installationDetails = new InstallationDetails(ip, details);
return installationDetails;
}
catch (Exception e)
{
Console.WriteLine(e);
}
return new InstallationDetails("Unknown", Array.Empty<Detail>());
}
private static async Task UpdateInstallationName(VrmInstallation installation, String? ip)
{
var oldNameInFileRequest = await Cli.Wrap("ssh")
.WithArguments($@"root@{ip}")
.AppendArgument("-o StrictHostKeyChecking=accept-new")
.AppendArgument("cat /data/innovenergy/openvpn/installation-name")
.WithValidation(CommandResultValidation.None).ExecuteBufferedAsync();
var oldNameInFileWithoutNewLine = oldNameInFileRequest.StandardOutput.TrimEnd();
if (oldNameInFileRequest.ExitCode == 0 && oldNameInFileWithoutNewLine != installation.Name)
{
var overwriteNameCommand = Cli.Wrap("ssh")
.WithArguments($@"root@{ip}")
.AppendArgument($"echo '{installation.Name}' > /data/innovenergy/openvpn/installation-name");
var overwriteNameResponse = await overwriteNameCommand
.WithValidation(CommandResultValidation.None).ExecuteBufferedAsync();
if (overwriteNameResponse.ExitCode != 0)
{
Console.WriteLine(overwriteNameResponse.StandardError);
Console.WriteLine("Renaming did not work");
}
else
{
var rebootAfterRename = await Cli.Wrap("ssh")
.WithArguments($@"root@{ip}")
.AppendArgument("reboot")
.WithValidation(CommandResultValidation.None).ExecuteBufferedAsync();
if (rebootAfterRename.ExitCode != 0)
{
Console.WriteLine(overwriteNameResponse.StandardError);
Console.WriteLine("Rebooting did not work");
}
else
{
Console.WriteLine($"Renamed and rebooted {installation.Name}");
}
}
}
}
private static Boolean RunTransaction(Func<Boolean> func)
{
var savepoint = Connection.SaveTransactionPoint();
var success = false;
try
{
success = func();
}
finally
{
if (success)
Connection.Release(savepoint);
else
Connection.RollbackTo(savepoint);
}
return success;
}
}
@ -65,3 +267,9 @@ public class AccToken
public UInt64 idUser { get; init;}
public String token { get; init;}
}
/*
dbus-send --system --dest=com.victronenergy.battery.ttyUSB1 --print-reply /FirmwareVersion \
org.freedesktop.DBus.Properties.Get string:com.victronenergy.battery.ttyUSB1
*/

View File

@ -1,13 +1,18 @@
using InnovEnergy.App.VrmGrabber.DataTypes;
namespace InnovEnergy.App.VrmGrabber.Database;
public static partial class Db
{
public static Boolean Delete(Installation installation)
{
return Installations.Delete(i => i.Id == installation.Id) > 0;
return RunTransaction(DeleteInstallationAndItsDependencies);
Boolean DeleteInstallationAndItsDependencies()
{
return Installations.Delete(i => i.Identifier == installation.Identifier) > 0;
}
}
}

View File

@ -6,9 +6,9 @@ namespace InnovEnergy.App.VrmGrabber.Database;
public static partial class Db
{
public static Installation? GetInstallationById(Int64? id)
public static Installation? GetInstallationByIdentifier(String? identifier)
{
return Installations
.FirstOrDefault(i => i.Id == id);
.FirstOrDefault(i => i.Identifier == identifier);
}
}

View File

@ -0,0 +1,13 @@
using InnovEnergy.App.VrmGrabber.DataTypes;
namespace InnovEnergy.App.VrmGrabber.Database;
public static partial class Db
{
public static Boolean Update(Installation installation)
{
return Connection.Update(installation) > 0;
}
}

View File

@ -5,26 +5,26 @@ namespace InnovEnergy.App.VrmGrabber;
public static class Program
{
public static void Main(String[] args)
public static async Task Main(String[] args)
{
var updateTask = Db.UpdateDetailsAndInstallations();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", OpenApiInfo);
c.UseAllOfToExtendReferenceSchemas();
c.SupportNonNullableReferenceTypes();
});
// builder.Services.AddSwaggerGen(c =>
// {
// c.UseAllOfToExtendReferenceSchemas();
// c.SupportNonNullableReferenceTypes();
// });
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
// app.UseSwagger();
// app.UseSwaggerUI();
// app.UseHttpsRedirection();
app.MapControllers();
// app.MapGet("/", () => Controller.Index());
app.Run();
var webTask = app.RunAsync();
await Task.WhenAll(webTask, updateTask);
}
private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo

View File

@ -0,0 +1,17 @@
namespace InnovEnergy.App.VrmGrabber;
public static class ShuffleClass
{
private static readonly Random Rng = new Random();
public static void Shuffle<T>(this IList<T> list)
{
var n = list.Count;
while (n > 1) {
n--;
var k = Rng.Next(n + 1);
(list[k], list[n]) = (list[n], list[k]);
}
}
}

View File

@ -38,4 +38,10 @@
<_ContentIncludedByDefault Remove="wwwroot\index.html" />
</ItemGroup>
<ItemGroup>
<None Update="db.sqlite">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

Binary file not shown.

View File

@ -188,8 +188,8 @@ Global
{B816BB44-E97E-4E02-B80A-BEDB5B923A96}.Release|Any CPU.Build.0 = Release|Any CPU
{4F9BB20B-8030-48AB-A37B-23796459D516}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F9BB20B-8030-48AB-A37B-23796459D516}.Release|Any CPU.Build.0 = Release|Any CPU
{4F9BB20B-8030-48AB-A37B-23796459D516}.Debug|Any CPU.ActiveCfg = Release-Server|linux-arm
{4F9BB20B-8030-48AB-A37B-23796459D516}.Debug|Any CPU.Build.0 = Release-Server|linux-arm
{4F9BB20B-8030-48AB-A37B-23796459D516}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F9BB20B-8030-48AB-A37B-23796459D516}.Debug|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CF4834CB-91B7-4172-AC13-ECDA8613CD17} = {145597B4-3E30-45E6-9F72-4DD43194539A}

View File

@ -11,7 +11,7 @@ public static class JsonNodeAccessors
public static T Get<T>(this JsonNode n, String propName) => n[propName]!.GetValue<T>();
public static String GetString(this JsonNode n, String propName) => n.Get<String>(propName);
public static String? GetString(this JsonNode n, String propName) => n.TryGetString(propName);
public static Boolean GetBoolean(this JsonNode n, String propName) => n.Get<Boolean>(propName);
public static UInt32 GetUInt32(this JsonNode n, String propName) => n.Get<UInt32>(propName);
@ -43,7 +43,7 @@ public static class JsonNodeAccessors
{
try
{
return n.TryGet<String>(propName);
return n.TryGet<String>(propName) ?? "";
}
catch (Exception e)
{

View File

@ -118,7 +118,7 @@ public static class StringUtils
return String.Join("", strings);
}
public static String JoinWith(this IEnumerable<String> strings, String separator)
public static String JoinWith(this IEnumerable<String?> strings, String separator)
{
return String.Join(separator, strings);
}

View File

@ -13,6 +13,7 @@ public readonly partial record struct Installation(VrmAccount VrmAccount, JsonNo
public UnixTime Created => Json.GetUInt32("syscreated").Apply(UnixTime.FromTicks);
// Settings
public String Name => Json.GetString("name");
public String Notes => Json.GetString("notes");
public String PhoneNumber => Json.GetString("phonenumber");

View File

@ -31,6 +31,16 @@ public static class Requests
.WithHeader("X-Authorization", account.Auth);
}
public static IFlurlRequest SpecificInstallationRequest(this VrmAccount account, UInt64 installationId)
{
return ApiRoot
.AppendPathSegment("users")
.AppendPathSegment(account.UserId)
.AppendPathSegment("installations")
.AppendPathSegment(installationId)
.WithHeader("X-Authorization", account.Auth);
}
public static IFlurlRequest AllInstallationsRequest(this VrmAccount account)
{
return ApiRoot

View File

@ -50,6 +50,20 @@ public class VrmAccount : IDisposable
.ToArray(vrmReply.Records.Count);
}
public async Task<Installation> GetInstallation(Int64 installationId)
{
var reply = await this.SpecificInstallationRequest((UInt64)installationId).TryGetJson();
var vrmReply = new Reply(reply);
if (!vrmReply.Success)
throw new Exception(nameof(GetInstallations) + " failed");
return vrmReply
.Records
.Select(r => new Installation(this, r!))
.First(i => i.IdSite == (UInt64)installationId);
}
public void Dispose()
{
if (IsLoggedIn)

View File

@ -84,6 +84,7 @@ def init_modbus(tty):
def failed(response):
# type: (ModbusResponse) -> bool
# Todo 'ModbusIOException' object has no attribute 'function_code'
return response.function_code > 0x80