Add project files.

alphons <alphons@heijden.com> 20 Jul 2026, 18:01
4bc3d686144805c667022d2934594044515aad56
0 files changed
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1ff0c42
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,63 @@
+###############################################################################
+# Set default behavior to automatically normalize line endings.
+###############################################################################
+* text=auto
+
+###############################################################################
+# Set default behavior for command prompt diff.
+#
+# This is need for earlier builds of msysgit that does not have it on by
+# default for csharp files.
+# Note: This is only used by command line
+###############################################################################
+#*.cs diff=csharp
+
+###############################################################################
+# Set the merge driver for project and solution files
+#
+# Merging from the command prompt will add diff markers to the files if there
+# are conflicts (Merging from VS is not affected by the settings below, in VS
+# the diff markers are never inserted). Diff markers may cause the following
+# file extensions to fail to load in VS. An alternative would be to treat
+# these files as binary and thus will always conflict and require user
+# intervention with every merge. To do so, just uncomment the entries below
+###############################################################################
+#*.sln merge=binary
+#*.csproj merge=binary
+#*.vbproj merge=binary
+#*.vcxproj merge=binary
+#*.vcproj merge=binary
+#*.dbproj merge=binary
+#*.fsproj merge=binary
+#*.lsproj merge=binary
+#*.wixproj merge=binary
+#*.modelproj merge=binary
+#*.sqlproj merge=binary
+#*.wwaproj merge=binary
+
+###############################################################################
+# behavior for image files
+#
+# image files are treated as binary by default.
+###############################################################################
+#*.jpg binary
+#*.png binary
+#*.gif binary
+
+###############################################################################
+# diff behavior for common document formats
+#
+# Convert binary document formats to text before diffing them. This feature
+# is only available from the command line. Turn it on by uncommenting the
+# entries below.
+###############################################################################
+#*.doc diff=astextplain
+#*.DOC diff=astextplain
+#*.docx diff=astextplain
+#*.DOCX diff=astextplain
+#*.dot diff=astextplain
+#*.DOT diff=astextplain
+#*.pdf diff=astextplain
+#*.PDF diff=astextplain
+#*.rtf diff=astextplain
+#*.RTF diff=astextplain
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9491a2f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,363 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Oo]ut/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
\ No newline at end of file
diff --git a/GoodWe.Console/GoodWe.Console.csproj b/GoodWe.Console/GoodWe.Console.csproj
new file mode 100644
index 0000000..2a40bfe
--- /dev/null
+++ b/GoodWe.Console/GoodWe.Console.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net8.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <RootNamespace>GoodWe.ConsoleApp</RootNamespace>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\GoodWe\GoodWe.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/GoodWe.Console/Program.cs b/GoodWe.Console/Program.cs
new file mode 100644
index 0000000..fed808e
--- /dev/null
+++ b/GoodWe.Console/Program.cs
@@ -0,0 +1,110 @@
+using GoodWe;
+
+if (args.Length == 0)
+{
+ Console.WriteLine("Usage: GoodWe.Console <inverter-ip> [udp|tcp] [family]");
+ Console.WriteLine(" family: ET (default), ES, DT");
+ Console.WriteLine();
+ Console.WriteLine("Example: GoodWe.Console 192.168.1.100");
+ Console.WriteLine(" GoodWe.Console 192.168.1.100 udp DT");
+ Console.WriteLine(" GoodWe.Console 192.168.1.100 tcp ET");
+ return 1;
+}
+
+string host = args[0];
+string transport = args.Length > 1 ? args[1].ToLower() : "udp";
+string family = args.Length > 2 ? args[2] : "Unknown";
+
+Console.WriteLine($"Connecting to {host} ({transport.ToUpper()}{(family != null ? $" / {family}" : "")}) ...");
+
+using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+
+try
+{
+ bool tcp = transport == "tcp";
+
+ await using var inverter = await GoodWeClient.ConnectAsync(host,
+ tcp: tcp, family: Enum.Parse<FamilyEnum>(family!), ct: cts.Token);
+
+ if (inverter == null)
+ return 1;
+
+ // ── Device info ────────────────────────────────────────────────────────
+ Console.WriteLine();
+ PrintHeader("Device Info");
+ PrintRow("Model", inverter.ModelName);
+ PrintRow("Serial", inverter.SerialNumber);
+ PrintRow("Rated Power", $"{inverter.RatedPower} W");
+ PrintRow("Firmware", inverter.Firmware);
+ PrintRow("ARM Firmware", inverter.ArmFirmware);
+
+ // ── Runtime data ───────────────────────────────────────────────────────
+ Console.WriteLine();
+ PrintHeader("Runtime Data");
+ var data = await inverter.ReadRuntimeDataAsync(cts.Token);
+ PrintAllData(data);
+
+ // ── Settings ───────────────────────────────────────────────────────────
+ Console.WriteLine();
+ PrintHeader("Settings");
+
+ var opMode = await inverter.GetOperationModeAsync(cts.Token);
+ PrintRow("Operation Mode", opMode.ToString());
+
+ var dictSettings = await inverter.ReadSettingsDataAsync(CancellationToken.None);
+
+ PrintRow("Grid Export Limit", $"{dictSettings["grid_export_limit"]}");
+ PrintRow("Grid Export Limit Value", $"{dictSettings["grid_export_limit_value"]} %");
+
+ Console.WriteLine();
+ Console.WriteLine("Press any key to exit...");
+ Console.ReadKey(intercept: true);
+ return 0;
+}
+catch (MaxRetriesException)
+{
+ Console.Error.WriteLine($"Error: no response from {host} after retries. Check IP and network.");
+ return 2;
+}
+catch (InverterError ex)
+{
+ Console.Error.WriteLine($"Inverter error: {ex.Message}");
+ return 3;
+}
+catch (OperationCanceledException)
+{
+ Console.Error.WriteLine("Timed out.");
+ return 4;
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+static void PrintHeader(string title)
+{
+ Console.WriteLine($"=== {title} ===");
+}
+
+static void PrintRow(string label, string? value)
+{
+ Console.WriteLine($" {label,-30}: {value ?? "n/a"}");
+}
+
+static void PrintAllData(Dictionary<string, object?> data)
+{
+ // Separate into measurements (have numeric/typed values) and labels/codes
+ // Sort by key so output is predictable
+ foreach (var kv in data.OrderBy(k => k.Key))
+ {
+ if (kv.Value is null) continue;
+
+ string display = kv.Value switch
+ {
+ double d => $"{d:F2}",
+ float f => $"{f:F2}",
+ DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss"),
+ _ => kv.Value.ToString() ?? ""
+ };
+
+ Console.WriteLine($" {kv.Key,-35}: {display}");
+ }
+}
diff --git a/GoodWe.Console/Properties/launchSettings.json b/GoodWe.Console/Properties/launchSettings.json
new file mode 100644
index 0000000..8b7cda3
--- /dev/null
+++ b/GoodWe.Console/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "GoodWe.Console": {
+ "commandName": "Project",
+ "commandLineArgs": "192.168.74.33 udp DT"
+ }
+ }
+}
\ No newline at end of file
diff --git a/GoodWe.Console/doc.txt b/GoodWe.Console/doc.txt
new file mode 100644
index 0000000..632db9a
--- /dev/null
+++ b/GoodWe.Console/doc.txt
@@ -0,0 +1,68 @@
+Connecting to 192.168.74.33 (UDP / DT) ...
+
+=== Device Info ===
+ Model : GW5K-DT
+ Serial : 55000DTS207W0886
+ Rated Power : 0 W
+ Firmware : DSP:808
+ ARM Firmware : 6
+
+=== Runtime Data ===
+ derating_mode : 0
+ derating_mode_label : OK
+ e_day : 19,70
+ e_total : 177,80
+ error_codes : 0
+ errors : OK
+ fgrid1 : 49,97
+ fgrid2 : 49,98
+ fgrid3 : 50,00
+ funbit : 0
+ h_total : 106
+ house_consumption : 954
+ igrid1 : 1,40
+ igrid2 : 1,40
+ igrid3 : 1,40
+ ipv1 : 1,50
+ ipv2 : 1,40
+ ipv3 : 0,00
+ ipv4 : 0,00
+ leakage_current : -0,00
+ meter_active_power : 0
+ meter_comm_label : Unknown(65535)
+ meter_comm_status : 65535
+ meter_e_total_exp : 4294967,29
+ meter_e_total_imp : 4294967,29
+ pgrid1 : 327
+ pgrid2 : 327
+ pgrid3 : 325
+ ppv : 954
+ rssi : 78
+ safety_country : 32
+ safety_country_label : Montenegro
+ temperature : 49,70
+ timestamp : 2026-06-06 16:37:07
+ total_input_power : 0
+ total_inverter_power : 994
+ vbus : 610,70
+ vgrid1 : 233,70
+ vgrid2 : 233,50
+ vgrid3 : 232,30
+ vline1 : 404,00
+ vline2 : 403,20
+ vline3 : 404,50
+ vnbus : 313,80
+ vpv1 : 391,60
+ vpv2 : 262,00
+ vpv3 : 0,00
+ vpv4 : 0,00
+ warning_code : 0
+ work_mode : 1
+ work_mode_label : On-Grid
+
+=== Settings ===
+ Operation Mode : General
+ Grid Export Limit : Disabled
+ Grid Export Limit Value : 0 %
+
+Press any key to exit...
diff --git a/GoodWe.WinForm/Form1.Designer.cs b/GoodWe.WinForm/Form1.Designer.cs
new file mode 100644
index 0000000..f4501d0
--- /dev/null
+++ b/GoodWe.WinForm/Form1.Designer.cs
@@ -0,0 +1,57 @@
+namespace GoodWe.WinForm
+{
+ partial class Form1
+ {
+ /// <summary>
+ /// Required designer variable.
+ /// </summary>
+ private System.ComponentModel.IContainer components = null;
+
+ /// <summary>
+ /// Clean up any resources being used.
+ /// </summary>
+ /// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ /// <summary>
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ /// </summary>
+ private void InitializeComponent()
+ {
+ userControl11 = new UserControl1();
+ SuspendLayout();
+ //
+ // userControl11
+ //
+ userControl11.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left;
+ userControl11.Location = new Point(2, 12);
+ userControl11.Name = "userControl11";
+ userControl11.Size = new Size(384, 549);
+ userControl11.TabIndex = 0;
+ //
+ // Form1
+ //
+ AutoScaleDimensions = new SizeF(7F, 15F);
+ AutoScaleMode = AutoScaleMode.Font;
+ ClientSize = new Size(398, 568);
+ Controls.Add(userControl11);
+ Name = "Form1";
+ Text = "GoodWe 1.0";
+ ResumeLayout(false);
+ }
+
+ #endregion
+
+ private UserControl1 userControl11;
+ }
+}
diff --git a/GoodWe.WinForm/Form1.cs b/GoodWe.WinForm/Form1.cs
new file mode 100644
index 0000000..fe5d0c2
--- /dev/null
+++ b/GoodWe.WinForm/Form1.cs
@@ -0,0 +1,10 @@
+namespace GoodWe.WinForm
+{
+ public partial class Form1 : Form
+ {
+ public Form1()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/GoodWe.WinForm/Form1.resx b/GoodWe.WinForm/Form1.resx
new file mode 100644
index 0000000..8b2ff64
--- /dev/null
+++ b/GoodWe.WinForm/Form1.resx
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+ <!--
+ Microsoft ResX Schema
+
+ Version 2.0
+
+ The primary goals of this format is to allow a simple XML format
+ that is mostly human readable. The generation and parsing of the
+ various data types are done through the TypeConverter classes
+ associated with the data types.
+
+ Example:
+
+ ... ado.net/XML headers & schema ...
+ <resheader name="resmimetype">text/microsoft-resx</resheader>
+ <resheader name="version">2.0</resheader>
+ <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+ <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+ <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+ <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+ <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+ <value>[base64 mime encoded serialized .NET Framework object]</value>
+ </data>
+ <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+ <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+ <comment>This is a comment</comment>
+ </data>
+
+ There are any number of "resheader" rows that contain simple
+ name/value pairs.
+
+ Each data row contains a name, and value. The row also contains a
+ type or mimetype. Type corresponds to a .NET class that support
+ text/value conversion through the TypeConverter architecture.
+ Classes that don't support this are serialized and stored with the
+ mimetype set.
+
+ The mimetype is used for serialized objects, and tells the
+ ResXResourceReader how to depersist the object. This is currently not
+ extensible. For a given mimetype the value must be set accordingly:
+
+ Note - application/x-microsoft.net.object.binary.base64 is the format
+ that the ResXResourceWriter will generate, however the reader can
+ read any of the formats listed below.
+
+ mimetype: application/x-microsoft.net.object.binary.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.soap.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.bytearray.base64
+ value : The object must be serialized into a byte array
+ : using a System.ComponentModel.TypeConverter
+ : and then encoded with base64 encoding.
+ -->
+ <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+ <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+ <xsd:element name="root" msdata:IsDataSet="true">
+ <xsd:complexType>
+ <xsd:choice maxOccurs="unbounded">
+ <xsd:element name="metadata">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" />
+ </xsd:sequence>
+ <xsd:attribute name="name" use="required" type="xsd:string" />
+ <xsd:attribute name="type" type="xsd:string" />
+ <xsd:attribute name="mimetype" type="xsd:string" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="assembly">
+ <xsd:complexType>
+ <xsd:attribute name="alias" type="xsd:string" />
+ <xsd:attribute name="name" type="xsd:string" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="data">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+ <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+ <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="resheader">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" />
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:choice>
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ <resheader name="resmimetype">
+ <value>text/microsoft-resx</value>
+ </resheader>
+ <resheader name="version">
+ <value>2.0</value>
+ </resheader>
+ <resheader name="reader">
+ <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <resheader name="writer">
+ <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+</root>
\ No newline at end of file
diff --git a/GoodWe.WinForm/GoodWe.WinForm.csproj b/GoodWe.WinForm/GoodWe.WinForm.csproj
new file mode 100644
index 0000000..7644d39
--- /dev/null
+++ b/GoodWe.WinForm/GoodWe.WinForm.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>WinExe</OutputType>
+ <TargetFramework>net10.0-windows</TargetFramework>
+ <Nullable>enable</Nullable>
+ <UseWindowsForms>true</UseWindowsForms>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\GoodWe\GoodWe.csproj" />
+ </ItemGroup>
+
+</Project>
\ No newline at end of file
diff --git a/GoodWe.WinForm/Program.cs b/GoodWe.WinForm/Program.cs
new file mode 100644
index 0000000..df8843a
--- /dev/null
+++ b/GoodWe.WinForm/Program.cs
@@ -0,0 +1,17 @@
+namespace GoodWe.WinForm
+{
+ internal static class Program
+ {
+ /// <summary>
+ /// The main entry point for the application.
+ /// </summary>
+ [STAThread]
+ static void Main()
+ {
+ // To customize application configuration such as set high DPI settings or default font,
+ // see https://aka.ms/applicationconfiguration.
+ ApplicationConfiguration.Initialize();
+ Application.Run(new Form1());
+ }
+ }
+}
\ No newline at end of file
diff --git a/GoodWe.WinForm/UserControl1.Designer.cs b/GoodWe.WinForm/UserControl1.Designer.cs
new file mode 100644
index 0000000..2d7c3c3
--- /dev/null
+++ b/GoodWe.WinForm/UserControl1.Designer.cs
@@ -0,0 +1,272 @@
+namespace GoodWe.WinForm
+{
+ partial class UserControl1
+ {
+ /// <summary>
+ /// Required designer variable.
+ /// </summary>
+ private System.ComponentModel.IContainer components = null;
+
+ /// <summary>
+ /// Clean up any resources being used.
+ /// </summary>
+ /// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Component Designer generated code
+
+ /// <summary>
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ /// </summary>
+ private void InitializeComponent()
+ {
+ ListViewItem listViewItem1 = new ListViewItem(new string[] { "Model", "-" }, -1);
+ ListViewItem listViewItem2 = new ListViewItem(new string[] { "Serial", "-" }, -1);
+ ListViewItem listViewItem3 = new ListViewItem(new string[] { "Firmware", "-" }, -1);
+ ListViewItem listViewItem4 = new ListViewItem(new string[] { "ARM", "-" }, -1);
+ ListViewItem listViewItem5 = new ListViewItem(new string[] { "Rated Power", "-" }, -1);
+ cmbProtocol = new ComboBox();
+ textBox1 = new TextBox();
+ label1 = new Label();
+ button1 = new Button();
+ button2 = new Button();
+ groupBox1 = new GroupBox();
+ label2 = new Label();
+ cmbFamily = new ComboBox();
+ groupBox2 = new GroupBox();
+ listView1 = new ListView();
+ columnHeader1 = new ColumnHeader();
+ columnHeader2 = new ColumnHeader();
+ groupBox3 = new GroupBox();
+ listViewData = new ListView();
+ columnHeader3 = new ColumnHeader();
+ columnHeader4 = new ColumnHeader();
+ labelStatus = new Label();
+ checkBox1 = new CheckBox();
+ groupBox1.SuspendLayout();
+ groupBox2.SuspendLayout();
+ groupBox3.SuspendLayout();
+ SuspendLayout();
+ //
+ // cmbProtocol
+ //
+ cmbProtocol.DropDownStyle = ComboBoxStyle.DropDownList;
+ cmbProtocol.FormattingEnabled = true;
+ cmbProtocol.Items.AddRange(new object[] { "TCP (502)", "UDP (8899)" });
+ cmbProtocol.Location = new Point(134, 25);
+ cmbProtocol.Name = "cmbProtocol";
+ cmbProtocol.Size = new Size(94, 23);
+ cmbProtocol.TabIndex = 7;
+ //
+ // textBox1
+ //
+ textBox1.Location = new Point(28, 25);
+ textBox1.Name = "textBox1";
+ textBox1.Size = new Size(100, 23);
+ textBox1.TabIndex = 5;
+ textBox1.Text = "192.168.74.33";
+ //
+ // label1
+ //
+ label1.AutoSize = true;
+ label1.Location = new Point(5, 28);
+ label1.Name = "label1";
+ label1.Size = new Size(17, 15);
+ label1.TabIndex = 4;
+ label1.Text = "IP";
+ //
+ // button1
+ //
+ button1.AllowDrop = true;
+ button1.Location = new Point(11, 68);
+ button1.Name = "button1";
+ button1.Size = new Size(75, 23);
+ button1.TabIndex = 8;
+ button1.Text = "Start";
+ button1.UseVisualStyleBackColor = true;
+ button1.Click += Start_Click;
+ //
+ // button2
+ //
+ button2.Enabled = false;
+ button2.Location = new Point(92, 68);
+ button2.Name = "button2";
+ button2.Size = new Size(75, 23);
+ button2.TabIndex = 9;
+ button2.Text = "Stop";
+ button2.UseVisualStyleBackColor = true;
+ button2.Click += Stop_Click;
+ //
+ // groupBox1
+ //
+ groupBox1.Controls.Add(label2);
+ groupBox1.Controls.Add(cmbFamily);
+ groupBox1.Controls.Add(textBox1);
+ groupBox1.Controls.Add(label1);
+ groupBox1.Controls.Add(cmbProtocol);
+ groupBox1.Location = new Point(4, 3);
+ groupBox1.Name = "groupBox1";
+ groupBox1.Size = new Size(373, 59);
+ groupBox1.TabIndex = 10;
+ groupBox1.TabStop = false;
+ groupBox1.Text = "Connection Settings";
+ //
+ // label2
+ //
+ label2.AutoSize = true;
+ label2.Location = new Point(234, 28);
+ label2.Name = "label2";
+ label2.Size = new Size(42, 15);
+ label2.TabIndex = 9;
+ label2.Text = "Family";
+ //
+ // cmbFamily
+ //
+ cmbFamily.DropDownStyle = ComboBoxStyle.DropDownList;
+ cmbFamily.FormattingEnabled = true;
+ cmbFamily.Items.AddRange(new object[] { "Unknown", "ET", "EH", "BT", "BH", "ES", "EM", "BP", "DT", "MS", "XS" });
+ cmbFamily.Location = new Point(282, 25);
+ cmbFamily.Name = "cmbFamily";
+ cmbFamily.Size = new Size(79, 23);
+ cmbFamily.TabIndex = 8;
+ //
+ // groupBox2
+ //
+ groupBox2.Controls.Add(listView1);
+ groupBox2.Location = new Point(4, 97);
+ groupBox2.Name = "groupBox2";
+ groupBox2.Size = new Size(373, 140);
+ groupBox2.TabIndex = 11;
+ groupBox2.TabStop = false;
+ groupBox2.Text = "Device Information";
+ //
+ // listView1
+ //
+ listView1.Columns.AddRange(new ColumnHeader[] { columnHeader1, columnHeader2 });
+ listView1.FullRowSelect = true;
+ listView1.GridLines = true;
+ listView1.HeaderStyle = ColumnHeaderStyle.None;
+ listView1.Items.AddRange(new ListViewItem[] { listViewItem1, listViewItem2, listViewItem3, listViewItem4, listViewItem5 });
+ listView1.Location = new Point(21, 27);
+ listView1.Name = "listView1";
+ listView1.Size = new Size(340, 104);
+ listView1.TabIndex = 0;
+ listView1.UseCompatibleStateImageBehavior = false;
+ listView1.View = View.Details;
+ //
+ // columnHeader1
+ //
+ columnHeader1.Width = 150;
+ //
+ // columnHeader2
+ //
+ columnHeader2.TextAlign = HorizontalAlignment.Right;
+ columnHeader2.Width = 150;
+ //
+ // groupBox3
+ //
+ groupBox3.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left;
+ groupBox3.Controls.Add(listViewData);
+ groupBox3.Location = new Point(4, 275);
+ groupBox3.Name = "groupBox3";
+ groupBox3.Size = new Size(373, 271);
+ groupBox3.TabIndex = 12;
+ groupBox3.TabStop = false;
+ groupBox3.Text = "Data";
+ //
+ // listViewData
+ //
+ listViewData.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left;
+ listViewData.Columns.AddRange(new ColumnHeader[] { columnHeader3, columnHeader4 });
+ listViewData.FullRowSelect = true;
+ listViewData.GridLines = true;
+ listViewData.HeaderStyle = ColumnHeaderStyle.None;
+ listViewData.Location = new Point(21, 27);
+ listViewData.Name = "listViewData";
+ listViewData.Size = new Size(340, 238);
+ listViewData.TabIndex = 0;
+ listViewData.UseCompatibleStateImageBehavior = false;
+ listViewData.View = View.Details;
+ //
+ // columnHeader3
+ //
+ columnHeader3.Text = "Sensor";
+ columnHeader3.Width = 150;
+ //
+ // columnHeader4
+ //
+ columnHeader4.Text = "Data";
+ columnHeader4.TextAlign = HorizontalAlignment.Right;
+ columnHeader4.Width = 150;
+ //
+ // labelStatus
+ //
+ labelStatus.AutoSize = true;
+ labelStatus.Location = new Point(188, 72);
+ labelStatus.Name = "labelStatus";
+ labelStatus.Size = new Size(51, 15);
+ labelStatus.TabIndex = 13;
+ labelStatus.Text = "Stopped";
+ //
+ // checkBox1
+ //
+ checkBox1.AutoSize = true;
+ checkBox1.Location = new Point(26, 243);
+ checkBox1.Name = "checkBox1";
+ checkBox1.Size = new Size(124, 19);
+ checkBox1.TabIndex = 14;
+ checkBox1.Text = "Power limit enable";
+ checkBox1.UseVisualStyleBackColor = true;
+ checkBox1.CheckedChanged += PowerLimit_CheckedChanged;
+ //
+ // UserControl1
+ //
+ AutoScaleDimensions = new SizeF(7F, 15F);
+ AutoScaleMode = AutoScaleMode.Font;
+ Controls.Add(checkBox1);
+ Controls.Add(labelStatus);
+ Controls.Add(groupBox3);
+ Controls.Add(groupBox2);
+ Controls.Add(groupBox1);
+ Controls.Add(button2);
+ Controls.Add(button1);
+ Name = "UserControl1";
+ Size = new Size(384, 549);
+ groupBox1.ResumeLayout(false);
+ groupBox1.PerformLayout();
+ groupBox2.ResumeLayout(false);
+ groupBox3.ResumeLayout(false);
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private ComboBox cmbProtocol;
+ private TextBox textBox1;
+ private Label label1;
+ private Button button1;
+ private Button button2;
+ private GroupBox groupBox1;
+ private GroupBox groupBox2;
+ private ListView listView1;
+ private ColumnHeader columnHeader1;
+ private ColumnHeader columnHeader2;
+ private GroupBox groupBox3;
+ private ListView listViewData;
+ private ColumnHeader columnHeader3;
+ private ColumnHeader columnHeader4;
+ private Label labelStatus;
+ private ComboBox cmbFamily;
+ private Label label2;
+ private CheckBox checkBox1;
+ }
+}
diff --git a/GoodWe.WinForm/UserControl1.cs b/GoodWe.WinForm/UserControl1.cs
new file mode 100644
index 0000000..eb6a4c4
--- /dev/null
+++ b/GoodWe.WinForm/UserControl1.cs
@@ -0,0 +1,210 @@
+namespace GoodWe.WinForm;
+
+public partial class UserControl1 : UserControl
+{
+ private Inverter? inverter;
+ private System.Windows.Forms.Timer? pollTimer;
+
+ public UserControl1()
+ {
+ InitializeComponent();
+
+ // SearchInvertersAsync does not work
+ // await GoodWeClient.SearchInvertersAsync(30000, CancellationToken.None);
+
+ cmbProtocol.SelectedIndex = 1; // UDP default
+ cmbFamily.SelectedIndex = 8; // DT default
+ }
+
+ // ── Start ─────────────────────────────────────────────────────────────────
+
+ private async void Start_Click(object sender, EventArgs e)
+ {
+ groupBox1.Enabled = false;
+ button1.Enabled = false;
+ button2.Enabled = true;
+
+ try
+ {
+ bool tcp = cmbProtocol.SelectedIndex == 0;
+ string host = textBox1.Text.Trim();
+ FamilyEnum family = Enum.Parse<FamilyEnum>(this.cmbFamily.Text);
+
+ inverter = await GoodWeClient.ConnectAsync(host: host, tcp: tcp, family: family);
+
+ if (inverter == null)
+ return;
+
+ UpdateDeviceInfo();
+ StartPolling();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Connection failed:\n{ex.Message}", "Error",
+ MessageBoxButtons.OK, MessageBoxIcon.Error);
+ ResetUi();
+ }
+ }
+
+ // ── Stop ─────────────────────────────────────────────────────────────────
+
+ private async void Stop_Click(object sender, EventArgs e)
+ {
+ StopPolling();
+
+ if (inverter is not null)
+ {
+ await inverter.DisposeAsync();
+ inverter = null;
+ }
+
+ ResetUi();
+ }
+
+ // ── Polling ───────────────────────────────────────────────────────────────
+
+ private void StartPolling()
+ {
+ pollTimer = new System.Windows.Forms.Timer { Interval = 5000 };
+ pollTimer.Tick += async (_, _) => await PollAsync();
+ pollTimer.Start();
+
+ _ = PollAsync(); // eerste keer meteen
+ }
+
+ private void StopPolling()
+ {
+ pollTimer?.Stop();
+ pollTimer?.Dispose();
+ pollTimer = null;
+ }
+
+ private async Task PollAsync()
+ {
+ if (inverter is null) return;
+ try
+ {
+ var data = await inverter.ReadRuntimeDataAsync();
+ var dictPowerLimit = await inverter.ReadSettingsDataAsync();
+ data.Add("grid_export_limit", dictPowerLimit["grid_export_limit"]);
+ data.Add("grid_export_limit_value", dictPowerLimit["grid_export_limit_value"]);
+
+ Invoke(() => UpdateData(data));
+ }
+ catch (Exception ex)
+ {
+ Invoke((Delegate)(() => labelStatus.Text = $"Poll error: {ex.Message}"));
+ }
+ }
+
+ // ── UI-updates ────────────────────────────────────────────────────────────
+
+ private void UpdateDeviceInfo()
+ {
+ if (inverter is null)
+ return;
+ var items = this.listView1.Items;
+
+ items[0].SubItems[1].Text = inverter.ModelName ?? "–";
+ items[1].SubItems[1].Text = inverter.SerialNumber ?? "–";
+ items[2].SubItems[1].Text = $"{inverter.Firmware}";
+ items[3].SubItems[1].Text = $"{inverter.ArmVersion}";
+ items[4].SubItems[1].Text = $"{inverter.RatedPower} W";
+ }
+
+ private static string? ToMyString(object? value)
+ {
+ return value switch
+ {
+ null => null,
+ double d => $"{d:F2}",
+ float f => $"{f:F2}",
+ DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss"),
+ _ => value.ToString() ?? "",
+ };
+ }
+
+ private static void Add(ListView lv, string name, object? value)
+ {
+ if (value == null)
+ return;
+ var item = new ListViewItem(name);
+ item.SubItems.Add(ToMyString(value));
+ lv.Items.Add(item);
+ }
+
+ private void UpdateData(Dictionary<string, object?> data)
+ {
+ this.labelStatus.Text = $"Updated: {ToMyString(data["timestamp"])}";
+
+ this.listViewData.BeginUpdate();
+ this.listViewData.Items.Clear();
+
+ Add(listViewData, "Total Power",
+ $"{ToMyString(data["e_total"])} kWh");
+
+ Add(listViewData, "Daily Power",
+ $"{ToMyString(data["e_day"])} kWh");
+
+ Add(listViewData, "Grid Code",
+ $"{ToMyString(data["safety_country"])}");
+
+ Add(listViewData, "PV1",
+ $"{ToMyString(data["ipv1"])} A {ToMyString(data["vpv1"])} V");
+ Add(listViewData, "PV2",
+ $"{ToMyString(data["ipv2"])} A {ToMyString(data["vpv2"])} V");
+
+ Add(listViewData, "AC Current L1/L2/L3",
+ $"{ToMyString(data["igrid1"])}/{ToMyString(data["igrid2"])}/{ToMyString(data["igrid3"])} A");
+ Add(listViewData, "AC Voltage L1/L2/L3",
+ $"{ToMyString(data["vgrid1"])}/{ToMyString(data["vgrid2"])}/{ToMyString(data["vgrid3"])} V");
+
+ Add(listViewData, "AC Power",
+ $"{ToMyString(data["total_inverter_power"])} W");
+
+ Add(listViewData, "AC Frequency",
+ $"{ToMyString(data["fgrid1"])} Hz");
+
+ Add(listViewData, "Power Limit",
+ $"{ToMyString(data["grid_export_limit"])}");
+
+ Add(listViewData, "Power Limit Value",
+ $"{ToMyString(data["grid_export_limit_value"])} %");
+
+ //foreach (var kv in data.OrderBy(k => k.Key))
+ // Add(listViewData, kv.Key, kv.Value);
+
+ this.listViewData.EndUpdate();
+ }
+
+ private void ResetUi()
+ {
+ groupBox1.Enabled = true;
+ button1.Enabled = true;
+ button2.Enabled = false;
+ labelStatus.Text = "Stopped";
+ listViewData.Items.Clear();
+ }
+
+ // ── Cleanup ───────────────────────────────────────────────────────────────
+
+ protected override async void OnHandleDestroyed(EventArgs e)
+ {
+ StopPolling();
+ if (inverter is not null)
+ {
+ await inverter.DisposeAsync();
+ inverter = null;
+ }
+ base.OnHandleDestroyed(e);
+ }
+
+ private async void PowerLimit_CheckedChanged(object sender, EventArgs e)
+ {
+ bool powerLimitEnabled = this.checkBox1.Checked;
+ if (inverter is not null)
+ {
+ await inverter.SetGridExportLimitAsync(powerLimitEnabled);
+ }
+ }
+}
diff --git a/GoodWe.WinForm/UserControl1.resx b/GoodWe.WinForm/UserControl1.resx
new file mode 100644
index 0000000..8b2ff64
--- /dev/null
+++ b/GoodWe.WinForm/UserControl1.resx
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+ <!--
+ Microsoft ResX Schema
+
+ Version 2.0
+
+ The primary goals of this format is to allow a simple XML format
+ that is mostly human readable. The generation and parsing of the
+ various data types are done through the TypeConverter classes
+ associated with the data types.
+
+ Example:
+
+ ... ado.net/XML headers & schema ...
+ <resheader name="resmimetype">text/microsoft-resx</resheader>
+ <resheader name="version">2.0</resheader>
+ <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+ <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+ <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+ <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+ <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+ <value>[base64 mime encoded serialized .NET Framework object]</value>
+ </data>
+ <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+ <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+ <comment>This is a comment</comment>
+ </data>
+
+ There are any number of "resheader" rows that contain simple
+ name/value pairs.
+
+ Each data row contains a name, and value. The row also contains a
+ type or mimetype. Type corresponds to a .NET class that support
+ text/value conversion through the TypeConverter architecture.
+ Classes that don't support this are serialized and stored with the
+ mimetype set.
+
+ The mimetype is used for serialized objects, and tells the
+ ResXResourceReader how to depersist the object. This is currently not
+ extensible. For a given mimetype the value must be set accordingly:
+
+ Note - application/x-microsoft.net.object.binary.base64 is the format
+ that the ResXResourceWriter will generate, however the reader can
+ read any of the formats listed below.
+
+ mimetype: application/x-microsoft.net.object.binary.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.soap.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.bytearray.base64
+ value : The object must be serialized into a byte array
+ : using a System.ComponentModel.TypeConverter
+ : and then encoded with base64 encoding.
+ -->
+ <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+ <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+ <xsd:element name="root" msdata:IsDataSet="true">
+ <xsd:complexType>
+ <xsd:choice maxOccurs="unbounded">
+ <xsd:element name="metadata">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" />
+ </xsd:sequence>
+ <xsd:attribute name="name" use="required" type="xsd:string" />
+ <xsd:attribute name="type" type="xsd:string" />
+ <xsd:attribute name="mimetype" type="xsd:string" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="assembly">
+ <xsd:complexType>
+ <xsd:attribute name="alias" type="xsd:string" />
+ <xsd:attribute name="name" type="xsd:string" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="data">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+ <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+ <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="resheader">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" />
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:choice>
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ <resheader name="resmimetype">
+ <value>text/microsoft-resx</value>
+ </resheader>
+ <resheader name="version">
+ <value>2.0</value>
+ </resheader>
+ <resheader name="reader">
+ <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <resheader name="writer">
+ <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+</root>
\ No newline at end of file
diff --git a/GoodWe.sln b/GoodWe.sln
new file mode 100644
index 0000000..2599a77
--- /dev/null
+++ b/GoodWe.sln
@@ -0,0 +1,36 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 18
+VisualStudioVersion = 18.6.11822.322 stable
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoodWe", "GoodWe\GoodWe.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoodWe.Console", "GoodWe.Console\GoodWe.Console.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoodWe.WinForm", "GoodWe.WinForm\GoodWe.WinForm.csproj", "{0ADEB204-FD1E-4683-96C2-74915816C04F}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0ADEB204-FD1E-4683-96C2-74915816C04F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0ADEB204-FD1E-4683-96C2-74915816C04F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0ADEB204-FD1E-4683-96C2-74915816C04F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0ADEB204-FD1E-4683-96C2-74915816C04F}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {8878BCF8-11CB-4452-AF58-8093DAB22960}
+ EndGlobalSection
+EndGlobal
diff --git a/GoodWe/Constants.cs b/GoodWe/Constants.cs
new file mode 100644
index 0000000..ab3f703
--- /dev/null
+++ b/GoodWe/Constants.cs
@@ -0,0 +1,102 @@
+namespace GoodWe;
+
+public static class Constants
+{
+ public const int GoodWeUdpPort = 8899;
+ public const int GoodWeTcpPort = 502;
+
+ public static readonly IReadOnlyDictionary<int, string> BatteryModes = new Dictionary<int, string>
+ {
+ { 0, "No battery" }, { 1, "Standby" }, { 2, "Discharge" }, { 3, "Charge" },
+ { 4, "To be charged" }, { 5, "To be discharged" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> EnergyModes = new Dictionary<int, string>
+ {
+ { 0, "Check" }, { 1, "Wait" }, { 2, "On-Grid" }, { 3, "Off-Grid" },
+ { 4, "Flash" }, { 5, "Fault" }, { 6, "PV Power Off" }, { 7, "Close" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> GridModes = new Dictionary<int, string>
+ {
+ { 0, "Not connected" }, { 1, "Connected" }, { 2, "Fault" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> GridInOutModes = new Dictionary<int, string>
+ {
+ { 0, "Idle" }, { -1, "Exporting" }, { 1, "Importing" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> PvModes = new Dictionary<int, string>
+ {
+ { 0, "Not connected" }, { 1, "Connected, no power" }, { 2, "Connected, producing" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> WorkModesET = new Dictionary<int, string>
+ {
+ { 0, "Check" }, { 1, "Wait" }, { 2, "On-Grid" }, { 3, "Off-Grid" },
+ { 4, "Bypass" }, { 5, "Fault" }, { 6, "Flash" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> SafetyCountries = new Dictionary<int, string>
+ {
+ { 0, "Italy" }, { 1, "Czech" }, { 2, "Germany" }, { 3, "Spain" },
+ { 4, "Denmark" }, { 5, "Belgium" }, { 6, "Romania" }, { 7, "G98/G99 UK" },
+ { 8, "Australia" }, { 9, "Greece" }, { 10, "Netherlands" }, { 11, "Austria" },
+ { 12, "Switzerland" }, { 13, "Poland" }, { 14, "Sweden" }, { 15, "Slovakia" },
+ { 16, "Ukraine" }, { 17, "Finland" }, { 18, "Hungary" }, { 19, "Bulgaria" },
+ { 20, "G83/G59 UK" }, { 21, "Norway" }, { 22, "Portugal" }, { 23, "Turkey" },
+ { 24, "Europe General" }, { 25, "Croatia" }, { 26, "Lithuania" }, { 27, "Estonia" },
+ { 28, "Latvia" }, { 29, "Macedonia" }, { 30, "Bosnia" }, { 31, "Serbia" },
+ { 32, "Montenegro" }, { 33, "Albania" }, { 34, "Slovenia" }, { 35, "Belarus" },
+ { 255, "User defined" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> ErrorCodes = new Dictionary<int, string>
+ {
+ { 0, "Utility Loss" }, { 1, "Grid Voltage Fault" }, { 2, "Grid Frequency Fault" },
+ { 3, "DC Injection Fault" }, { 4, "Temperature Fault" }, { 5, "Fan Fault" },
+ { 6, "Battery Voltage too High" }, { 7, "Battery Voltage too Low" },
+ { 8, "Battery Open Circuit" }, { 9, "BMS Communication Fault" },
+ { 10, "Battery Overload" }, { 11, "Battery Short Circuit" },
+ { 12, "AFCI Arc Fault" }, { 13, "Ground Fault" }, { 14, "PV Over Voltage" },
+ { 15, "PV Over Current" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> DiagStatusCodes = new Dictionary<int, string>
+ {
+ { 0, "Battery SOC low (<10%)" }, { 1, "Battery SOC critical (<5%)" },
+ { 2, "Battery not connected" }, { 4, "Upload to Sems failed" },
+ { 7, "Meter communication fault" }, { 8, "CT not installed" },
+ { 16, "Charge limited by battery" }, { 18, "Discharge limited by battery" },
+ { 22, "Export power limited" }, { 29, "Self-use off" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> DeratingModeCodes = new Dictionary<int, string>
+ {
+ { 0, "Over temperature" }, { 1, "Low AC voltage" }, { 2, "High AC voltage" },
+ { 3, "Low PV voltage" }, { 4, "High DC bus voltage" }, { 5, "Low DC bus voltage" },
+ { 6, "Grid frequency out of range" }, { 7, "Over output current" },
+ { 8, "Over input current" }, { 9, "Power grid" }, { 10, "Overload" },
+ { 11, "Over VA" }, { 12, "Grid monitoring" }, { 13, "MPPT" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> MeterCommStatus = new Dictionary<int, string>
+ {
+ { 1, "Normal" }, { 2, "Disconnected" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> WorkModes = new Dictionary<int, string>
+ {
+ { 0, "Wait" }, { 1, "On-Grid" }, { 2, "Off-Grid" }, { 3, "Fault" },
+ { 4, "Flash" }, { 5, "PV Power Off" },
+ };
+
+ public static readonly IReadOnlyDictionary<int, string> BmsAlarmCodes = new Dictionary<int, string>
+ {
+ { 0, "Cell voltage too high" }, { 1, "Cell voltage too low" },
+ { 2, "Module temperature too high" }, { 3, "Module temperature too low" },
+ { 4, "Discharging overcurrent" }, { 5, "Charging overcurrent" },
+ { 6, "Short circuit" }, { 7, "Cell voltage difference too large" },
+ };
+}
diff --git a/GoodWe/DtInverter.cs b/GoodWe/DtInverter.cs
new file mode 100644
index 0000000..8432a07
--- /dev/null
+++ b/GoodWe/DtInverter.cs
@@ -0,0 +1,202 @@
+using System.Diagnostics;
+
+namespace GoodWe;
+
+/// <summary>
+/// DT/MS/D-NS/XS family — grid-only (no battery) 3-phase inverter.
+/// comm_addr = 0x7F, runtime data at 0x7594 (30100), device info at 0x7531 (30001).
+/// </summary>
+public class DtInverter(InverterProtocol protocol) : Inverter(protocol)
+{
+ // 0x7531 = 30001, count 0x28 = 40
+ private const ushort DevInfoOffset = 0x7531;
+ private const ushort DevInfoCount = 0x28;
+
+ // 0x7594 = 30100, count 0x49 = 73
+ private const ushort RuntimeOffset = 0x7594;
+ private const ushort RuntimeCount = 0x49;
+
+ // 0x75F3 = 30195, count 0x10 = 16 (through 30210 leakage_current)
+ private const ushort MeterOffset = 0x75F3;
+ private const ushort MeterCount = 0x10;
+
+ private static readonly Sensor[] RuntimeSensors =
+ [
+ new TimestampSensorDT("timestamp", 30100, "Timestamp"),
+ new VoltageSensor("vpv1", 30103, "PV1 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv1", 30104, "PV1 Current", SensorKind.PV),
+ new VoltageSensor("vpv2", 30105, "PV2 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv2", 30106, "PV2 Current", SensorKind.PV),
+ new VoltageSensor("vpv3", 30107, "PV3 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv3", 30108, "PV3 Current", SensorKind.PV),
+ new VoltageSensor("vpv4", 30109, "PV4 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv4", 30110, "PV4 Current", SensorKind.PV),
+ new VoltageSensor("vline1", 30115, "On-grid L1-L2 Voltage", SensorKind.AC),
+ new VoltageSensor("vline2", 30116, "On-grid L2-L3 Voltage", SensorKind.AC),
+ new VoltageSensor("vline3", 30117, "On-grid L3-L1 Voltage", SensorKind.AC),
+ new VoltageSensor("vgrid1", 30118, "On-grid L1 Voltage", SensorKind.AC),
+ new VoltageSensor("vgrid2", 30119, "On-grid L2 Voltage", SensorKind.AC),
+ new VoltageSensor("vgrid3", 30120, "On-grid L3 Voltage", SensorKind.AC),
+ new CurrentSensor("igrid1", 30121, "On-grid L1 Current", SensorKind.AC),
+ new CurrentSensor("igrid2", 30122, "On-grid L2 Current", SensorKind.AC),
+ new CurrentSensor("igrid3", 30123, "On-grid L3 Current", SensorKind.AC),
+ new FrequencySensor("fgrid1", 30124, "On-grid L1 Frequency", SensorKind.AC),
+ new FrequencySensor("fgrid2", 30125, "On-grid L2 Frequency", SensorKind.AC),
+ new FrequencySensor("fgrid3", 30126, "On-grid L3 Frequency", SensorKind.AC),
+ new Power4Sensor("total_inverter_power", 30127, "Total Power", SensorKind.AC),
+ new IntegerSensor("work_mode", 30129, "Work Mode code"),
+ new EnumSensor("work_mode_label", 30129, Constants.WorkModes, "Work Mode"),
+ new LongSensor("error_codes", 30130, "Error Codes"),
+ new BitmapSensor("errors", 30130, Constants.ErrorCodes, "Errors"),
+ new IntegerSensor("warning_code", 30132, "Warning code"),
+ new PowerSignedSensor("total_input_power", 30137, "Total Input Power", SensorKind.PV),
+ new TempSensor("temperature", 30141, "Inverter Temperature", SensorKind.AC),
+ new TempSensor("temperature_heatsink", 30142, "Heatsink Temperature", SensorKind.AC),
+ new EnergySensor("e_day", 30144, "Today's PV Generation", SensorKind.PV),
+ new Energy4Sensor("e_total", 30145, "Total PV Generation", SensorKind.PV),
+ new LongSensor("h_total", 30147, "Hours Total", "h", SensorKind.PV),
+ new IntegerSensor("safety_country", 30149, "Safety Country code", "", SensorKind.AC),
+ new EnumSensor("safety_country_label", 30149, Constants.SafetyCountries, "Safety Country", SensorKind.AC),
+ new IntegerSensor("funbit", 30162, "Function Bit", "", SensorKind.PV),
+ new VoltageSensor("vbus", 30163, "Bus Voltage", SensorKind.PV),
+ new VoltageSensor("vnbus", 30164, "NBus Voltage", SensorKind.PV),
+ new LongSensor("derating_mode", 30165, "Derating Mode code"),
+ new BitmapSensor("derating_mode_label", 30165, Constants.DeratingModeCodes, "Derating Mode"),
+ new IntegerSensor("rssi", 30172, "RSSI"),
+ ];
+
+ private static readonly Sensor[] MeterSensors =
+ [
+ new Power4SignedSensor("meter_active_power", 30195, "Meter Active Power", SensorKind.GRID),
+ new Energy4WSensor("meter_e_total_exp", 30197, "Meter Total Energy (export)", SensorKind.GRID),
+ new Energy4WSensor("meter_e_total_imp", 30199, "Meter Total Energy (import)", SensorKind.GRID),
+ new IntegerSensor("meter_comm_status", 30209, "Meter Comm Status code", "", SensorKind.GRID),
+ new EnumSensor("meter_comm_label", 30209, Constants.MeterCommStatus, "Meter Comm Status", SensorKind.GRID),
+ new CurrentSmASensor("leakage_current", 30210, "Leakage Current", SensorKind.PV),
+ ];
+
+ public override async Task ReadDeviceInfoAsync(CancellationToken ct = default)
+ {
+ // Device info response data (raw payload after header strip):
+ // [6:22] = serial number (ASCII)
+ // [22:32] = model name (ASCII)
+ // [66:68] = DSP1 version
+ // [68:70] = DSP2 version
+ // [70:72] = ARM version
+ var resp = await SendReadAsync(DevInfoOffset, DevInfoCount, ct);
+ var data = resp.ResponseData;
+
+ SerialNumber = DecodeAscii(data, 6, 16);
+ ModelName = DecodeAscii(data, 22, 10);
+ DspVersion = data.Length > 67 ? (data[66] << 8) | data[67] : 0;
+ ArmVersion = data.Length > 71 ? (data[70] << 8) | data[71] : 0;
+ Firmware = $"DSP:{DspVersion}";
+ ArmFirmware = $"{ArmVersion}";
+ }
+
+ private static string DecodeAscii(byte[] data, int start, int length)
+ {
+ if (start >= data.Length) return string.Empty;
+ int end = Math.Min(start + length, data.Length);
+ return System.Text.Encoding.ASCII.GetString(data, start, end - start)
+ .Trim('\0', ' ');
+ }
+
+ public override async Task<Dictionary<string, object?>> ReadRuntimeDataAsync(CancellationToken ct = default)
+ {
+ var result = new Dictionary<string, object?>();
+
+ var resp = await SendReadAsync(RuntimeOffset, RuntimeCount, ct);
+ var raw = resp.ResponseData;
+
+ foreach (var sensor in RuntimeSensors)
+ {
+ try { result[sensor.Id] = sensor.Read(resp); }
+ catch { result[sensor.Id] = null; }
+ }
+
+ // Calculated: ppv = sum of V*I per string
+ double ppv = 0;
+ foreach (var (vReg, iReg) in new[] { (30103, 30104), (30105, 30106), (30107, 30108), (30109, 30110) })
+ {
+ double v = SensorHelper.PeekU16At(raw, vReg, RuntimeOffset) / 10.0;
+ if (v > 6500)
+ continue;
+ double i = SensorHelper.PeekU16At(raw, iReg, RuntimeOffset) / 10.0;
+ ppv += Math.Round(v * i);
+ }
+ result["ppv"] = (long)ppv;
+
+ // Calculated grid powers
+ foreach (var (vReg, iReg, key, name) in new[]
+ {
+ (30118, 30121, "pgrid1", "On-grid L1 Power"),
+ (30119, 30122, "pgrid2", "On-grid L2 Power"),
+ (30120, 30123, "pgrid3", "On-grid L3 Power"),
+ })
+ {
+ double v = SensorHelper.PeekU16At(raw, vReg, RuntimeOffset) / 10.0;
+ double i = SensorHelper.PeekU16At(raw, iReg, RuntimeOffset) / 10.0;
+ result[key] = (long)Math.Round(v * i);
+ }
+
+ // Meter data
+ try
+ {
+ var meterResp = await SendReadAsync(MeterOffset, MeterCount, ct);
+ foreach (var sensor in MeterSensors)
+ {
+ try { result[sensor.Id] = sensor.Read(meterResp); }
+ catch { result[sensor.Id] = null; }
+ }
+ if (result.TryGetValue("meter_active_power", out var map))
+ result["house_consumption"] = Math.Abs((long)ppv - Convert.ToInt64(map ?? 0L));
+ }
+ catch (RequestFailedException) { /* meter is optional */ }
+
+ return result;
+ }
+
+ public override async Task<Dictionary<string, object?>> ReadSettingsDataAsync(CancellationToken ct = default)
+ {
+ var result = new Dictionary<string, object?>();
+ try
+ {
+ var resp = await SendReadAsync(40326, 20, ct);
+ resp.Seek(40327);
+ if (SensorHelper.ReadU16(resp) == 1)
+ result["grid_export_limit"] = "Enabled";
+ else
+ result["grid_export_limit"] = "Disabled";
+
+ resp.Seek(40336);
+ result["grid_export_limit_value"] = SensorHelper.ReadU16(resp);
+ }
+ catch { /* optional */ }
+ return result;
+ }
+
+ public override async Task<int> GetGridExportLimitAsync(CancellationToken ct = default)
+ {
+ var resp = await SendReadAsync(40336, 2, ct);
+ resp.Seek(40328);
+ return SensorHelper.ReadU16(resp);
+ }
+
+ public override Task SetGridExportLimitAsync(bool exportEnabled, CancellationToken ct = default) =>
+ SendWriteAsync(40327, exportEnabled ? (ushort)1 : (ushort) 0, ct);
+
+ public override Task SetGridExportLimitValueAsync(int exportLimitPercentage, CancellationToken ct = default) =>
+ SendWriteAsync(40336, (ushort)exportLimitPercentage, ct);
+
+ public override Task<OperationMode> GetOperationModeAsync(CancellationToken ct = default) =>
+ Task.FromResult(OperationMode.General);
+
+ public override Task SetOperationModeAsync(OperationMode mode, CancellationToken ct = default) =>
+ Task.CompletedTask;
+
+ public override Task<int> GetBatterySocAsync(CancellationToken ct = default) =>
+ Task.FromResult(0);
+
+ public const byte CommAddr = 0x7F;
+}
diff --git a/GoodWe/Enums.cs b/GoodWe/Enums.cs
new file mode 100644
index 0000000..5fe9acd
--- /dev/null
+++ b/GoodWe/Enums.cs
@@ -0,0 +1,54 @@
+namespace GoodWe;
+
+public enum FamilyEnum
+{
+ Unknown,
+ ET,
+ EH,
+ BT,
+ BH,
+ ES,
+ EM,
+ BP,
+ DT,
+ MS,
+ XS
+}
+
+public enum SensorKind
+{
+ PV = 1,
+ AC = 2,
+ UPS = 3,
+ BAT = 4,
+ GRID = 5,
+ BMS = 6,
+}
+
+public enum OperationMode
+{
+ General = 0,
+ OffGrid = 1,
+ Backup = 2,
+ Eco = 3,
+ PeakShaving = 4,
+ SelfUse = 5,
+ EcoCharge = 98,
+ EcoDischarge = 99,
+}
+
+public enum EMSMode
+{
+ Auto = 1,
+ ChargePV = 2,
+ DischargePV = 3,
+ ImportAC = 4,
+ ExportAC = 5,
+ Conserve = 6,
+ OffGrid = 7,
+ BatteryStandby = 8,
+ BuyPower = 9,
+ SellPower = 10,
+ ChargeBattery = 11,
+ DischargeBattery = 12,
+}
diff --git a/GoodWe/EsInverter.cs b/GoodWe/EsInverter.cs
new file mode 100644
index 0000000..5c6747a
--- /dev/null
+++ b/GoodWe/EsInverter.cs
@@ -0,0 +1,126 @@
+namespace GoodWe;
+
+/// <summary>
+/// ES/EM/BP family (platform 105) single-phase hybrid inverter.
+/// </summary>
+public class EsInverter(InverterProtocol protocol) : Inverter(protocol)
+{
+ private const ushort RuntimeOffset = 25700;
+ private const ushort RuntimeCount = 125;
+
+ private static readonly Sensor[] RuntimeSensors =
+ [
+ new TimestampSensorET("timestamp", 25700, "Timestamp"),
+ new VoltageSensor("vpv1", 25703, "PV1 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv1", 25704, "PV1 Current", SensorKind.PV),
+ new Power4Sensor("ppv1", 25705, "PV1 Power", SensorKind.PV),
+ new VoltageSensor("vpv2", 25707, "PV2 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv2", 25708, "PV2 Current", SensorKind.PV),
+ new Power4Sensor("ppv2", 25709, "PV2 Power", SensorKind.PV),
+ new VoltageSensor("vgrid", 25721, "On-grid Voltage", SensorKind.AC),
+ new CurrentSensor("igrid", 25722, "On-grid Current", SensorKind.AC),
+ new FrequencySensor("fgrid", 25723, "On-grid Frequency", SensorKind.AC),
+ new PowerSignedSensor("pgrid", 25724, "On-grid Power", SensorKind.AC),
+ new PowerSignedSensor("active_power", 25725, "Active Power", SensorKind.GRID),
+ new VoltageSensor("vbattery1", 25740, "Battery Voltage", SensorKind.BAT),
+ new CurrentSignedSensor("ibattery1", 25741, "Battery Current", SensorKind.BAT),
+ new Power4SignedSensor("pbattery1", 25742, "Battery Power", SensorKind.BAT),
+ new IntegerSensor("battery_mode", 25744, "Battery Mode code", "", SensorKind.BAT),
+ new EnumSensor("battery_mode_label", 25744, Constants.BatteryModes, "Battery Mode", SensorKind.BAT),
+ new TempSensor("temperature", 25752, "Inverter Temperature", SensorKind.AC),
+ new IntegerSensor("battery_soc", 25754, "Battery State of Charge", "%", SensorKind.BAT),
+ new Energy4Sensor("e_total", 25761, "Total PV Generation", SensorKind.PV),
+ new Energy4Sensor("e_day", 25763, "Today's PV Generation", SensorKind.PV),
+ ];
+
+ public override async Task ReadDeviceInfoAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var resp = await SendReadAsync(25000, 100, ct);
+ var data = resp.ResponseData;
+ SerialNumber = ReadAscii(data, 25001, 25000, 16);
+ ModelName = ReadAscii(data, 25021, 25000, 16).Trim('\0', ' ');
+ RatedPower = SensorHelper.PeekU16At(data, 25041, 25000);
+ DspVersion = SensorHelper.PeekU16At(data, 25051, 25000);
+ ArmVersion = SensorHelper.PeekU16At(data, 25056, 25000);
+ Firmware = $"DSP:{DspVersion}";
+ ArmFirmware = $"ARM:{ArmVersion}";
+ }
+ catch
+ {
+ ModelName ??= "Unknown ES";
+ SerialNumber ??= "Unknown";
+ }
+ }
+
+ private static string ReadAscii(byte[] data, int startReg, int baseReg, int charCount)
+ {
+ var chars = new char[charCount];
+ for (int i = 0; i < charCount / 2; i++)
+ {
+ int off = (startReg + i - baseReg) * 2;
+ if (off + 1 >= data.Length) break;
+ chars[i * 2] = (char)data[off];
+ chars[i * 2 + 1] = (char)data[off + 1];
+ }
+ return new string(chars).Trim('\0', ' ');
+ }
+
+ public override async Task<Dictionary<string, object?>> ReadRuntimeDataAsync(CancellationToken ct = default)
+ {
+ var result = new Dictionary<string, object?>();
+ var resp = await SendReadAsync(RuntimeOffset, RuntimeCount, ct);
+ foreach (var sensor in RuntimeSensors)
+ {
+ try { result[sensor.Id] = sensor.Read(resp); }
+ catch { result[sensor.Id] = null; }
+ }
+
+ var raw = resp.ResponseData;
+ result["ppv"] = Math.Max(0, SensorHelper.PeekS32At(raw, 25705, RuntimeOffset))
+ + Math.Max(0, SensorHelper.PeekS32At(raw, 25709, RuntimeOffset));
+ return result;
+ }
+
+ public override async Task<Dictionary<string, object?>> ReadSettingsDataAsync(CancellationToken ct = default)
+ {
+ var result = new Dictionary<string, object?>();
+ var resp = await SendReadAsync(25200, 30, ct);
+ resp.Seek(25200);
+ result["operation_mode"] = SensorHelper.ReadU16(resp);
+ return result;
+ }
+
+ public override async Task<int> GetGridExportLimitAsync(CancellationToken ct = default)
+ {
+ var resp = await SendReadAsync(25025, 1, ct);
+ resp.Seek(25025);
+ return SensorHelper.ReadU16(resp);
+ }
+
+ public override async Task SetGridExportLimitAsync(bool limitEnabled, CancellationToken ct = default)
+ {
+ return;
+ }
+
+ public override Task SetGridExportLimitValueAsync(int exportLimitValue, CancellationToken ct = default) =>
+ SendWriteAsync(25025, (ushort)exportLimitValue, ct);
+
+ public override async Task<OperationMode> GetOperationModeAsync(CancellationToken ct = default)
+ {
+ var resp = await SendReadAsync(25200, 1, ct);
+ resp.Seek(25200);
+ return (OperationMode)SensorHelper.ReadU16(resp);
+ }
+
+ public override Task SetOperationModeAsync(OperationMode mode, CancellationToken ct = default) =>
+ SendWriteAsync(25200, (ushort)mode, ct);
+
+ public override async Task<int> GetBatterySocAsync(CancellationToken ct = default)
+ {
+ var resp = await SendReadAsync(25754, 1, ct);
+ resp.Seek(25754);
+ return SensorHelper.ReadU16(resp);
+ }
+}
diff --git a/GoodWe/EtInverter.cs b/GoodWe/EtInverter.cs
new file mode 100644
index 0000000..c737acf
--- /dev/null
+++ b/GoodWe/EtInverter.cs
@@ -0,0 +1,234 @@
+namespace GoodWe;
+
+/// <summary>
+/// ET/EH/BT/BH family (platform 205 / 745 / 753) hybrid battery inverter.
+/// </summary>
+public class EtInverter(InverterProtocol protocol) : Inverter(protocol)
+{
+ // 0x88B8 = 35000, count 0x21 = 33
+ private const ushort DeviceInfoOffset = 0x88B8;
+ private const ushort DeviceInfoCount = 0x21;
+
+ // 0x891C = 35100, count 0x7D = 125
+ private const ushort RuntimeOffset = 0x891C;
+ private const ushort RuntimeCount = 0x7D;
+
+ // 0x9088 = 37000, count 0x18 = 24
+ private const ushort BatteryOffset = 0x9088;
+ private const ushort BatteryCount = 0x18;
+
+ private const ushort SettingsOffset = 47500;
+ private const ushort SettingsCount = 60;
+
+ private static readonly Sensor[] RuntimeSensors =
+ [
+ new TimestampSensorET("timestamp", 35100, "Timestamp"),
+ new VoltageSensor("vpv1", 35103, "PV1 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv1", 35104, "PV1 Current", SensorKind.PV),
+ new Power4Sensor("ppv1", 35105, "PV1 Power", SensorKind.PV),
+ new VoltageSensor("vpv2", 35107, "PV2 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv2", 35108, "PV2 Current", SensorKind.PV),
+ new Power4Sensor("ppv2", 35109, "PV2 Power", SensorKind.PV),
+ new VoltageSensor("vpv3", 35111, "PV3 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv3", 35112, "PV3 Current", SensorKind.PV),
+ new Power4Sensor("ppv3", 35113, "PV3 Power", SensorKind.PV),
+ new VoltageSensor("vpv4", 35115, "PV4 Voltage", SensorKind.PV),
+ new CurrentSensor("ipv4", 35116, "PV4 Current", SensorKind.PV),
+ new Power4Sensor("ppv4", 35117, "PV4 Power", SensorKind.PV),
+ new VoltageSensor("vgrid", 35121, "On-grid L1 Voltage", SensorKind.AC),
+ new CurrentSensor("igrid", 35122, "On-grid L1 Current", SensorKind.AC),
+ new FrequencySensor("fgrid", 35123, "On-grid L1 Frequency", SensorKind.AC),
+ new PowerSignedSensor("pgrid", 35125, "On-grid L1 Power", SensorKind.AC),
+ new VoltageSensor("vgrid2", 35126, "On-grid L2 Voltage", SensorKind.AC),
+ new CurrentSensor("igrid2", 35127, "On-grid L2 Current", SensorKind.AC),
+ new FrequencySensor("fgrid2", 35128, "On-grid L2 Frequency", SensorKind.AC),
+ new PowerSignedSensor("pgrid2", 35130, "On-grid L2 Power", SensorKind.AC),
+ new VoltageSensor("vgrid3", 35131, "On-grid L3 Voltage", SensorKind.AC),
+ new CurrentSensor("igrid3", 35132, "On-grid L3 Current", SensorKind.AC),
+ new FrequencySensor("fgrid3", 35133, "On-grid L3 Frequency", SensorKind.AC),
+ new PowerSignedSensor("pgrid3", 35135, "On-grid L3 Power", SensorKind.AC),
+ new IntegerSensor("grid_mode", 35136, "Grid Mode code", "", SensorKind.PV),
+ new EnumSensor("grid_mode_label", 35136, Constants.GridModes, "Grid Mode", SensorKind.PV),
+ new PowerSignedSensor("total_inverter_power", 35138, "Total Power", SensorKind.AC),
+ new PowerSignedSensor("active_power", 35140, "Active Power", SensorKind.GRID),
+ new VoltageSensor("backup_v1", 35145, "Back-up L1 Voltage", SensorKind.UPS),
+ new CurrentSensor("backup_i1", 35146, "Back-up L1 Current", SensorKind.UPS),
+ new FrequencySensor("backup_f1", 35147, "Back-up L1 Frequency", SensorKind.UPS),
+ new PowerSignedSensor("backup_p1", 35150, "Back-up L1 Power", SensorKind.UPS),
+ new PowerSignedSensor("load_ptotal", 35172, "Load", SensorKind.AC),
+ new IntegerSensor("ups_load", 35173, "UPS Load", "%", SensorKind.UPS),
+ new TempSensor("temperature_air", 35174, "Inverter Temperature (Air)", SensorKind.AC),
+ new TempSensor("temperature", 35176, "Inverter Temperature (Radiator)", SensorKind.AC),
+ new VoltageSensor("vbattery1", 35180, "Battery Voltage", SensorKind.BAT),
+ new CurrentSignedSensor("ibattery1", 35181, "Battery Current", SensorKind.BAT),
+ new Power4SignedSensor("pbattery1", 35182, "Battery Power", SensorKind.BAT),
+ new IntegerSensor("battery_mode", 35184, "Battery Mode code", "", SensorKind.BAT),
+ new EnumSensor("battery_mode_label", 35184, Constants.BatteryModes, "Battery Mode", SensorKind.BAT),
+ new IntegerSensor("warning_code", 35185, "Warning code"),
+ new IntegerSensor("safety_country", 35186, "Safety Country code", "", SensorKind.AC),
+ new EnumSensor("safety_country_label", 35186, Constants.SafetyCountries, "Safety Country", SensorKind.AC),
+ new IntegerSensor("work_mode", 35187, "Work Mode code"),
+ new EnumSensor("work_mode_label", 35187, Constants.WorkModesET, "Work Mode"),
+ new IntegerSensor("operation_mode", 35188, "Operation Mode code"),
+ new LongSensor("error_codes", 35189, "Error Codes"),
+ new BitmapSensor("errors", 35189, Constants.ErrorCodes, "Errors"),
+ new Energy4Sensor("e_total", 35191, "Total PV Generation", SensorKind.PV),
+ new Energy4Sensor("e_day", 35193, "Today's PV Generation", SensorKind.PV),
+ new Energy4Sensor("e_total_exp", 35195, "Total Energy (export)", SensorKind.AC),
+ new LongSensor("h_total", 35197, "Hours Total", "h", SensorKind.PV),
+ new EnergySensor("e_day_exp", 35199, "Today Energy (export)", SensorKind.AC),
+ new Energy4Sensor("e_total_imp", 35200, "Total Energy (import)", SensorKind.AC),
+ new EnergySensor("e_day_imp", 35202, "Today Energy (import)", SensorKind.AC),
+ new Energy4Sensor("e_load_total", 35203, "Total Load", SensorKind.AC),
+ new EnergySensor("e_load_day", 35205, "Today Load", SensorKind.AC),
+ new Energy4Sensor("e_bat_charge_total", 35206, "Total Battery Charge", SensorKind.BAT),
+ new EnergySensor("e_bat_charge_day", 35208, "Today Battery Charge", SensorKind.BAT),
+ new Energy4Sensor("e_bat_discharge_total", 35209, "Total Battery Discharge", SensorKind.BAT),
+ new EnergySensor("e_bat_discharge_day", 35211, "Today Battery Discharge", SensorKind.BAT),
+ new BitmapSensor("diagnose_result_label", 35220, Constants.DiagStatusCodes, "Diag Status"),
+ ];
+
+ private static readonly Sensor[] BatterySensors =
+ [
+ new IntegerSensor("battery_bms", 37000, "Battery BMS", "", SensorKind.BAT),
+ new IntegerSensor("battery_status", 37002, "Battery Status", "", SensorKind.BAT),
+ new TempSensor("battery_temperature", 37003, "Battery Temperature", SensorKind.BAT),
+ new IntegerSensor("battery_charge_limit", 37004, "Battery Charge Limit", "A", SensorKind.BAT),
+ new IntegerSensor("battery_discharge_limit", 37005, "Battery Discharge Limit", "A", SensorKind.BAT),
+ new IntegerSensor("battery_soc", 37007, "Battery State of Charge", "%", SensorKind.BAT),
+ new IntegerSensor("battery_soh", 37008, "Battery State of Health", "%", SensorKind.BAT),
+ new BitmapSensor("battery_error", 37012, Constants.BmsAlarmCodes, "Battery Error", SensorKind.BAT),
+ ];
+
+ public override async Task ReadDeviceInfoAsync(CancellationToken ct = default)
+ {
+ // Response payload byte offsets (Python source: et.py read_device_info):
+ // [0:2] modbus_version (reg 35000)
+ // [2:4] rated_power (reg 35001)
+ // [4:6] ac_output_type (reg 35002)
+ // [6:22] serial_number (regs 35003-35010, 16 ASCII chars)
+ // [22:32] model_name (regs 35011-35015, 10 ASCII chars)
+ // [32:34] dsp1_version (reg 35016)
+ // [34:36] dsp2_version (reg 35017)
+ // [36:38] dsp_svn_version (reg 35018)
+ // [38:40] arm_version (reg 35019)
+ var resp = await SendReadAsync(DeviceInfoOffset, DeviceInfoCount, ct);
+ var d = resp.ResponseData;
+
+ RatedPower = d.Length >= 4 ? (d[2] << 8) | d[3] : 0;
+ SerialNumber = DecodeAscii(d, 6, 16);
+ ModelName = DecodeAscii(d, 22, 10);
+ int dsp1 = d.Length >= 34 ? (d[32] << 8) | d[33] : 0;
+ int dsp2 = d.Length >= 36 ? (d[34] << 8) | d[35] : 0;
+ int arm = d.Length >= 40 ? (d[38] << 8) | d[39] : 0;
+ DspVersion = dsp1;
+ ArmVersion = arm;
+ Firmware = $"DSP:{dsp1}.{dsp2}";
+ ArmFirmware = $"ARM:{arm:X4}";
+ }
+
+ private static string DecodeAscii(byte[] data, int start, int length)
+ {
+ if (start >= data.Length) return string.Empty;
+ int end = Math.Min(start + length, data.Length);
+ return System.Text.Encoding.ASCII.GetString(data, start, end - start)
+ .Trim('\0', ' ');
+ }
+
+ public override async Task<Dictionary<string, object?>> ReadRuntimeDataAsync(CancellationToken ct = default)
+ {
+ var result = new Dictionary<string, object?>();
+
+ var runtimeResp = await SendReadAsync(RuntimeOffset, RuntimeCount, ct);
+ ReadSensors(RuntimeSensors, runtimeResp, result);
+
+ // Add calculated total PV power
+ var raw = runtimeResp.ResponseData;
+ long ppv = Math.Max(0, SensorHelper.PeekS32At(raw, 35105, RuntimeOffset))
+ + Math.Max(0, SensorHelper.PeekS32At(raw, 35109, RuntimeOffset))
+ + Math.Max(0, SensorHelper.PeekS32At(raw, 35113, RuntimeOffset))
+ + Math.Max(0, SensorHelper.PeekS32At(raw, 35117, RuntimeOffset));
+ result["ppv"] = ppv;
+ result["ppv_name"] = "PV Power";
+ result["ppv_unit"] = "W";
+
+ // House consumption = ppv + pbattery1 - active_power
+ if (result.TryGetValue("pbattery1", out var bat) && result.TryGetValue("active_power", out var ap))
+ {
+ long pbat = Convert.ToInt64(bat ?? 0);
+ long pact = Convert.ToInt64(ap ?? 0);
+ result["house_consumption"] = ppv + pbat - pact;
+ }
+
+ // Battery sensors (separate register block)
+ try
+ {
+ var batResp = await SendReadAsync(BatteryOffset, BatteryCount, ct);
+ ReadSensors(BatterySensors, batResp, result);
+ }
+ catch (RequestFailedException) { /* battery block optional */ }
+
+ return result;
+ }
+
+ private static void ReadSensors(Sensor[] sensors, ProtocolResponse resp, Dictionary<string, object?> result)
+ {
+ foreach (var sensor in sensors)
+ {
+ try { result[sensor.Id] = sensor.Read(resp); }
+ catch { result[sensor.Id] = null; }
+ }
+ }
+
+ public override async Task<Dictionary<string, object?>> ReadSettingsDataAsync(CancellationToken ct = default)
+ {
+ var result = new Dictionary<string, object?>();
+ var resp = await SendReadAsync(SettingsOffset, SettingsCount, ct);
+ // operation mode at 47500 offset 0
+ resp.Seek(SettingsOffset);
+ result["operation_mode"] = SensorHelper.ReadU16(resp);
+ resp.Seek(47509);
+ result["grid_export_limit"] = (int)SensorHelper.ReadU16(resp);
+ resp.Seek(47515);
+ result["battery_dod"] = (int)SensorHelper.ReadU16(resp);
+ return result;
+ }
+
+ public override async Task<int> GetGridExportLimitAsync(CancellationToken ct = default)
+ {
+ var resp = await SendReadAsync(47509, 1, ct);
+ resp.Seek(47509);
+ return SensorHelper.ReadU16(resp);
+ }
+
+ public override async Task SetGridExportLimitAsync(bool limitEnabled, CancellationToken ct = default)
+ {
+ return;
+ }
+
+ public override async Task SetGridExportLimitValueAsync(int exportLimitValue, CancellationToken ct = default)
+ {
+ await SendWriteAsync(47509, (ushort)exportLimitValue, ct);
+ }
+
+ public override async Task<OperationMode> GetOperationModeAsync(CancellationToken ct = default)
+ {
+ var resp = await SendReadAsync(47500, 1, ct);
+ resp.Seek(47500);
+ return (OperationMode)SensorHelper.ReadU16(resp);
+ }
+
+ public override async Task SetOperationModeAsync(OperationMode mode, CancellationToken ct = default)
+ {
+ await SendWriteAsync(47500, (ushort)mode, ct);
+ }
+
+ public override async Task<int> GetBatterySocAsync(CancellationToken ct = default)
+ {
+ var resp = await SendReadAsync(37007, 1, ct);
+ resp.Seek(37007);
+ return SensorHelper.ReadU16(resp);
+ }
+
+ public static string[] ModelTags => ["ET", "EH", "BT", "BH", "GEH", "GE-", "HYD"];
+}
diff --git a/GoodWe/Exceptions.cs b/GoodWe/Exceptions.cs
new file mode 100644
index 0000000..ce881ea
--- /dev/null
+++ b/GoodWe/Exceptions.cs
@@ -0,0 +1,36 @@
+namespace GoodWe;
+
+public class InverterError : Exception
+{
+ public InverterError(string message) : base(message) { }
+ public InverterError(string message, Exception inner) : base(message, inner) { }
+}
+
+public class RequestFailedException : InverterError
+{
+ public int ConsecutiveFailuresCount { get; }
+ public RequestFailedException(string message, int consecutiveFailuresCount = 0)
+ : base(message) => ConsecutiveFailuresCount = consecutiveFailuresCount;
+}
+
+public class RequestRejectedException : InverterError
+{
+ public RequestRejectedException(string message = "Request rejected by inverter") : base(message) { }
+}
+
+public class PartialResponseException : InverterError
+{
+ public int Length { get; }
+ public int Expected { get; }
+ public PartialResponseException(int length, int expected)
+ : base($"Partial response: got {length}, expected {expected}")
+ {
+ Length = length;
+ Expected = expected;
+ }
+}
+
+public class MaxRetriesException : InverterError
+{
+ public MaxRetriesException() : base("Maximum retries exceeded") { }
+}
diff --git a/GoodWe/GoodWe.csproj b/GoodWe/GoodWe.csproj
new file mode 100644
index 0000000..8de2c47
--- /dev/null
+++ b/GoodWe/GoodWe.csproj
@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <RootNamespace>GoodWe</RootNamespace>
+ </PropertyGroup>
+</Project>
diff --git a/GoodWe/GoodWeClient.cs b/GoodWe/GoodWeClient.cs
new file mode 100644
index 0000000..50ef91d
--- /dev/null
+++ b/GoodWe/GoodWeClient.cs
@@ -0,0 +1,152 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace GoodWe;
+
+public static class GoodWeClient
+{
+ public static async Task<Inverter?> ConnectAsync(string host,
+ bool tcp = true,
+ FamilyEnum family = FamilyEnum.Unknown,
+ byte commAddr = 0,
+ int timeout = 5,
+ int retries = 3,
+ CancellationToken ct = default)
+ {
+ if (tcp)
+ return await ConnectTcpAsync(host, Constants.GoodWeTcpPort,
+ family, commAddr, timeout, retries, ct);
+ else
+ return await ConnectUdpAsync(host, Constants.GoodWeUdpPort,
+ family, commAddr, timeout, retries, ct);
+ }
+ /// <summary>
+ /// Connect to a GoodWe inverter. Auto-detects the inverter family.
+ /// </summary>
+ private static async Task<Inverter> ConnectUdpAsync(
+ string host,
+ int port = Constants.GoodWeUdpPort,
+ FamilyEnum family = FamilyEnum.Unknown,
+ byte commAddr = 0,
+ int timeout = 5,
+ int retries = 3,
+ CancellationToken ct = default)
+ {
+ if (family != FamilyEnum.Unknown)
+ {
+ using CancellationTokenSource cts = new(1000);
+ byte addr = commAddr != 0 ? commAddr : DefaultCommAddr(family);
+ var proto = new UdpInverterProtocol(host, port, addr, timeout, retries);
+ var inv = CreateInverter(family, proto);
+ await inv.ReadDeviceInfoAsync(cts.Token);
+ return inv;
+ }
+
+ return await DiscoverFamilyAsync(host, port, commAddr, timeout, retries, tcp: false);
+ }
+
+ /// <summary>
+ /// Connect via Modbus/TCP (port 502).
+ /// </summary>
+ private static async Task<Inverter?> ConnectTcpAsync(
+ string host,
+ int port = Constants.GoodWeTcpPort,
+ FamilyEnum family = FamilyEnum.Unknown,
+ byte commAddr = 0x01,
+ int timeout = 5,
+ int retries = 3,
+ CancellationToken ct = default)
+ {
+ try
+ {
+ if (family != FamilyEnum.Unknown)
+ {
+ using CancellationTokenSource cts = new(1000);
+ byte addr = commAddr != 0x01 ? commAddr : DefaultCommAddr(family);
+ var proto = new TcpInverterProtocol(host, port, addr, timeout, retries);
+ var inv = CreateInverter(family, proto);
+ await inv.ReadDeviceInfoAsync(cts.Token);
+ return inv;
+ }
+
+ return await DiscoverFamilyAsync(host, port, commAddr, timeout, retries, tcp: true);
+ }
+ catch
+ {
+ return default;
+ }
+ }
+
+ /// <summary>
+ /// Broadcast scan to find inverters on the local network.
+ /// Returns the IP address of the first responding inverter, or null.
+ /// </summary>
+ public static async Task<string?> SearchInvertersAsync(int timeoutMs = 3000, CancellationToken ct = default)
+ {
+ using var udp = new UdpClient();
+ udp.EnableBroadcast = true;
+ udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
+
+ // GoodWe discovery packet
+ var payload = Convert.FromHexString("F7030001000121e3");
+ await udp.SendAsync(payload, payload.Length, new IPEndPoint(IPAddress.Broadcast, Constants.GoodWeUdpPort));
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(timeoutMs);
+
+ try
+ {
+ var result = await udp.ReceiveAsync(cts.Token);
+ return result.RemoteEndPoint.Address.ToString();
+ }
+ catch (OperationCanceledException) { return null; }
+ }
+
+ private static async Task<Inverter> DiscoverFamilyAsync(
+ string host, int port, byte commAddr, int timeout, int retries,
+ bool tcp = false)
+ {
+ foreach (var family in new[] { FamilyEnum.ET, FamilyEnum.ES, FamilyEnum.DT })
+ {
+ byte addr = commAddr != 0 ? commAddr : DefaultCommAddr(family);
+ InverterProtocol proto = tcp
+ ? new TcpInverterProtocol(host, port, addr, timeout, retries)
+ : new UdpInverterProtocol(host, port, addr, timeout, retries);
+ try
+ {
+ using CancellationTokenSource cts = new (1000);
+ var inv = CreateInverter(family, proto);
+ await inv.ReadDeviceInfoAsync(cts.Token);
+ if (inv.ModelName!.Contains("Unknown"))
+ throw new RequestFailedException("Unknown");
+ return inv;
+ }
+ catch (Exception ex) when (ex is RequestFailedException or MaxRetriesException or OperationCanceledException)
+ {
+ await proto.DisposeAsync();
+ // try next family
+ }
+ }
+ throw new InverterError("Could not identify inverter family. Check the host and network connection.");
+ }
+
+ private static byte DefaultCommAddr(FamilyEnum family) =>
+ family switch
+ {
+ FamilyEnum.DT or FamilyEnum.MS or FamilyEnum.XS
+ => DtInverter.CommAddr, // 0x7F
+ _ => 0xF7, // ET, ES
+ };
+
+ private static Inverter CreateInverter(FamilyEnum family, InverterProtocol protocol) =>
+ family switch
+ {
+ FamilyEnum.ET or FamilyEnum.EH or FamilyEnum.BT or FamilyEnum.BH
+ => new EtInverter(protocol),
+ FamilyEnum.ES or FamilyEnum.EM or FamilyEnum.BP
+ => new EsInverter(protocol),
+ FamilyEnum.DT or FamilyEnum.MS or FamilyEnum.XS
+ => new DtInverter(protocol),
+ _ => throw new ArgumentException($"Unknown inverter family: {family}")
+ };
+}
diff --git a/GoodWe/Inverter.cs b/GoodWe/Inverter.cs
new file mode 100644
index 0000000..7f10865
--- /dev/null
+++ b/GoodWe/Inverter.cs
@@ -0,0 +1,43 @@
+namespace GoodWe;
+
+public abstract class Inverter : IAsyncDisposable
+{
+ protected readonly InverterProtocol Protocol;
+
+ public string? ModelName { get; protected set; }
+ public string? SerialNumber { get; protected set; }
+ public int RatedPower { get; protected set; }
+ public string? Firmware { get; protected set; }
+ public string? ArmFirmware { get; protected set; }
+ public int DspVersion { get; protected set; }
+ public int ArmVersion { get; protected set; }
+
+ protected Inverter(InverterProtocol protocol)
+ {
+ Protocol = protocol;
+ }
+
+ public abstract Task ReadDeviceInfoAsync(CancellationToken ct = default);
+ public abstract Task<Dictionary<string, object?>> ReadRuntimeDataAsync(CancellationToken ct = default);
+ public abstract Task<Dictionary<string, object?>> ReadSettingsDataAsync(CancellationToken ct = default);
+ public abstract Task<int> GetGridExportLimitAsync(CancellationToken ct = default);
+ public abstract Task SetGridExportLimitAsync(bool exportEnabled, CancellationToken ct = default);
+ public abstract Task SetGridExportLimitValueAsync(int exportLimitValue, CancellationToken ct = default);
+ public abstract Task<OperationMode> GetOperationModeAsync(CancellationToken ct = default);
+ public abstract Task SetOperationModeAsync(OperationMode mode, CancellationToken ct = default);
+ public abstract Task<int> GetBatterySocAsync(CancellationToken ct = default);
+
+ protected async Task<ProtocolResponse> SendReadAsync(ushort offset, ushort count, CancellationToken ct)
+ {
+ var cmd = Protocol.ReadCommand(offset, count);
+ return await Protocol.SendAsync(cmd, ct);
+ }
+
+ protected async Task SendWriteAsync(ushort register, ushort value, CancellationToken ct)
+ {
+ var cmd = Protocol.WriteCommand(register, value);
+ await Protocol.SendAsync(cmd, ct);
+ }
+
+ public ValueTask DisposeAsync() => Protocol.DisposeAsync();
+}
diff --git a/GoodWe/Modbus.cs b/GoodWe/Modbus.cs
new file mode 100644
index 0000000..9f7a952
--- /dev/null
+++ b/GoodWe/Modbus.cs
@@ -0,0 +1,171 @@
+namespace GoodWe;
+
+public static class Modbus
+{
+ public const byte ReadCmd = 0x03;
+ public const byte WriteCmd = 0x06;
+ public const byte WriteMultiCmd = 0x10;
+
+ private static readonly ushort[] Crc16Table = BuildCrc16Table();
+
+ private static ushort[] BuildCrc16Table()
+ {
+ var table = new ushort[256];
+ for (int i = 0; i < 256; i++)
+ {
+ int buffer = i << 1;
+ ushort crc = 0;
+ for (int j = 8; j > 0; j--)
+ {
+ buffer >>= 1;
+ if (((buffer ^ crc) & 0x0001) != 0)
+ crc = (ushort)((crc >> 1) ^ 0xA001);
+ else
+ crc >>= 1;
+ }
+ table[i] = crc;
+ }
+ return table;
+ }
+
+ public static ushort Checksum(ReadOnlySpan<byte> data)
+ {
+ ushort crc = 0xFFFF;
+ foreach (byte b in data)
+ crc = (ushort)((crc >> 8) ^ Crc16Table[(crc ^ b) & 0xFF]);
+ return crc;
+ }
+
+ public static byte[] CreateRtuRequest(byte commAddr, byte cmd, ushort offset, ushort value)
+ {
+ var data = new byte[8];
+ data[0] = commAddr;
+ data[1] = cmd;
+ data[2] = (byte)(offset >> 8);
+ data[3] = (byte)(offset & 0xFF);
+ data[4] = (byte)(value >> 8);
+ data[5] = (byte)(value & 0xFF);
+ ushort checksum = Checksum(data.AsSpan(0, 6));
+ data[6] = (byte)(checksum & 0xFF);
+ data[7] = (byte)(checksum >> 8);
+ return data;
+ }
+
+ public static byte[] CreateRtuMultiRequest(byte commAddr, byte cmd, ushort offset, byte[] values)
+ {
+ var data = new byte[7 + values.Length + 2];
+ data[0] = commAddr;
+ data[1] = cmd;
+ data[2] = (byte)(offset >> 8);
+ data[3] = (byte)(offset & 0xFF);
+ data[4] = 0;
+ data[5] = (byte)(values.Length / 2);
+ data[6] = (byte)values.Length;
+ values.CopyTo(data, 7);
+ ushort checksum = Checksum(data.AsSpan(0, 7 + values.Length));
+ data[7 + values.Length] = (byte)(checksum & 0xFF);
+ data[8 + values.Length] = (byte)(checksum >> 8);
+ return data;
+ }
+
+ public static byte[] CreateTcpRequest(byte commAddr, byte cmd, ushort offset, ushort value)
+ {
+ var data = new byte[12];
+ data[0] = 0; data[1] = 1; // transaction id (updated per-request)
+ data[2] = 0; data[3] = 0; // protocol id
+ data[4] = 0; data[5] = 6; // length
+ data[6] = commAddr;
+ data[7] = cmd;
+ data[8] = (byte)(offset >> 8);
+ data[9] = (byte)(offset & 0xFF);
+ data[10] = (byte)(value >> 8);
+ data[11] = (byte)(value & 0xFF);
+ return data;
+ }
+
+ public static byte[] CreateTcpMultiRequest(byte commAddr, byte cmd, ushort offset, byte[] values)
+ {
+ var data = new byte[13 + values.Length];
+ data[0] = 0; data[1] = 1;
+ data[2] = 0; data[3] = 0;
+ data[4] = 0; data[5] = (byte)(7 + values.Length);
+ data[6] = commAddr;
+ data[7] = cmd;
+ data[8] = (byte)(offset >> 8);
+ data[9] = (byte)(offset & 0xFF);
+ data[10] = 0;
+ data[11] = (byte)(values.Length / 2);
+ data[12] = (byte)values.Length;
+ values.CopyTo(data, 13);
+ return data;
+ }
+
+ private static readonly IReadOnlyDictionary<byte, string> FailureCodes = new Dictionary<byte, string>
+ {
+ { 1, "ILLEGAL FUNCTION" }, { 2, "ILLEGAL DATA ADDRESS" }, { 3, "ILLEGAL DATA VALUE" },
+ { 4, "SLAVE DEVICE FAILURE" }, { 6, "SLAVE DEVICE BUSY" }, { 11, "GATEWAY TARGET FAILED" },
+ };
+
+ public static bool ValidateRtuResponse(byte[] data, byte cmd, ushort offset, ushort value)
+ {
+ if (data.Length <= 4) return false;
+
+ if (data[3] == ReadCmd)
+ {
+ if (data[4] != value * 2) return false;
+ int expected = data[4] + 7;
+ if (data.Length < expected)
+ throw new PartialResponseException(data.Length, expected);
+ }
+ else if (data[3] == WriteCmd || data[3] == WriteMultiCmd)
+ {
+ if (data.Length < 10) return false;
+ ushort respOffset = (ushort)((data[4] << 8) | data[5]);
+ if (respOffset != offset) return false;
+ short respValue = (short)((data[6] << 8) | data[7]);
+ if (respValue != (short)value) return false;
+ }
+
+ int checksumOffset = data.Length - 2;
+ ushort calcCrc = Checksum(data.AsSpan(2, checksumOffset - 2));
+ ushort respCrc = (ushort)((data[checksumOffset + 1] << 8) | data[checksumOffset]);
+ if (calcCrc != respCrc) return false;
+
+ if (data[3] != cmd)
+ {
+ string msg = FailureCodes.TryGetValue(data[4], out var m) ? m : "UNKNOWN";
+ throw new RequestRejectedException(msg);
+ }
+
+ return true;
+ }
+
+ public static bool ValidateTcpResponse(byte[] data, byte cmd, ushort offset, ushort value)
+ {
+ if (data.Length <= 8) return false;
+
+ if (data[7] == ReadCmd)
+ {
+ int expected = data[8] + 9;
+ if (data.Length < expected)
+ throw new PartialResponseException(data.Length, expected);
+ if (data[8] != value * 2) return false;
+ }
+ else if (data[7] == WriteCmd || data[7] == WriteMultiCmd)
+ {
+ if (data.Length < 12) return false;
+ ushort respOffset = (ushort)((data[8] << 8) | data[9]);
+ if (respOffset != offset) return false;
+ short respValue = (short)((data[10] << 8) | data[11]);
+ if (respValue != (short)value) return false;
+ }
+
+ if (data[7] != cmd)
+ {
+ string msg = FailureCodes.TryGetValue(data[8], out var m) ? m : "UNKNOWN";
+ throw new RequestRejectedException(msg);
+ }
+
+ return true;
+ }
+}
diff --git a/GoodWe/Protocol.cs b/GoodWe/Protocol.cs
new file mode 100644
index 0000000..ece7b39
--- /dev/null
+++ b/GoodWe/Protocol.cs
@@ -0,0 +1,344 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace GoodWe;
+
+public class ProtocolResponse
+{
+ public byte[] RawData { get; }
+ private readonly ProtocolCommand? _command;
+ private int _position;
+ private readonly byte[] _responseData;
+
+ public ProtocolResponse(byte[] rawData, ProtocolCommand? command)
+ {
+ RawData = rawData;
+ _command = command;
+ _responseData = command?.TrimResponse(rawData) ?? rawData;
+ }
+
+ public void Seek(int address)
+ {
+ _position = _command?.GetOffset(address) ?? address;
+ }
+
+ public byte[] Read(int size)
+ {
+ var result = new byte[size];
+ Array.Copy(_responseData, _position, result, 0, size);
+ _position += size;
+ return result;
+ }
+
+ public byte[] ResponseData => _responseData;
+}
+
+public abstract class ProtocolCommand
+{
+ protected byte[] Request { get; set; }
+ public int FirstAddress { get; protected set; }
+ public int Value { get; protected set; }
+ public Func<byte[], bool> Validator { get; protected set; }
+
+ protected ProtocolCommand(byte[] request, Func<byte[], bool> validator)
+ {
+ Request = request;
+ Validator = validator;
+ }
+
+ public virtual byte[] RequestBytes() => Request;
+
+ public abstract byte[] TrimResponse(byte[] raw);
+ public abstract int GetOffset(int address);
+
+ public override string ToString() => Convert.ToHexString(Request).ToLower();
+}
+
+public class ModbusRtuCommand : ProtocolCommand
+{
+ public ModbusRtuCommand(byte[] request, byte cmd, ushort offset, ushort value)
+ : base(request, data => Modbus.ValidateRtuResponse(data, cmd, offset, value))
+ {
+ FirstAddress = offset;
+ Value = value;
+ }
+
+ public override byte[] TrimResponse(byte[] raw) => raw[5..^2];
+ public override int GetOffset(int address) => (address - FirstAddress) * 2;
+}
+
+public class ModbusRtuReadCommand : ModbusRtuCommand
+{
+ public ModbusRtuReadCommand(byte commAddr, ushort offset, ushort count)
+ : base(Modbus.CreateRtuRequest(commAddr, Modbus.ReadCmd, offset, count), Modbus.ReadCmd, offset, count) { }
+}
+
+public class ModbusRtuWriteCommand : ModbusRtuCommand
+{
+ public ModbusRtuWriteCommand(byte commAddr, ushort register, ushort value)
+ : base(Modbus.CreateRtuRequest(commAddr, Modbus.WriteCmd, register, value), Modbus.WriteCmd, register, value) { }
+}
+
+public class ModbusRtuWriteMultiCommand : ModbusRtuCommand
+{
+ public ModbusRtuWriteMultiCommand(byte commAddr, ushort offset, byte[] values)
+ : base(Modbus.CreateRtuMultiRequest(commAddr, Modbus.WriteMultiCmd, offset, values),
+ Modbus.WriteMultiCmd, offset, (ushort)(values.Length / 2)) { }
+}
+
+public class ModbusTcpCommand : ProtocolCommand
+{
+ private static int _tcpTx = 0;
+ public ModbusTcpCommand(byte[] request, byte cmd, ushort offset, ushort value)
+ : base(request, data => Modbus.ValidateTcpResponse(data, cmd, offset, value))
+ {
+ FirstAddress = offset;
+ Value = value;
+ }
+
+ public override byte[] RequestBytes()
+ {
+ int tx = Interlocked.Increment(ref _tcpTx) & 0xFFFF;
+ if (tx == 0) tx = 1;
+ Request[0] = (byte)(tx >> 8);
+ Request[1] = (byte)(tx & 0xFF);
+ return Request;
+ }
+
+ public override byte[] TrimResponse(byte[] raw) => raw[9..];
+ public override int GetOffset(int address) => (address - FirstAddress) * 2;
+}
+
+public class ModbusTcpReadCommand : ModbusTcpCommand
+{
+ public ModbusTcpReadCommand(byte commAddr, ushort offset, ushort count)
+ : base(Modbus.CreateTcpRequest(commAddr, Modbus.ReadCmd, offset, count), Modbus.ReadCmd, offset, count) { }
+}
+
+public class ModbusTcpWriteCommand : ModbusTcpCommand
+{
+ public ModbusTcpWriteCommand(byte commAddr, ushort register, ushort value)
+ : base(Modbus.CreateTcpRequest(commAddr, Modbus.WriteCmd, register, value), Modbus.WriteCmd, register, value) { }
+}
+
+public abstract class InverterProtocol : IAsyncDisposable
+{
+ protected readonly string Host;
+ protected readonly int Port;
+ protected readonly byte CommAddr;
+ public int Timeout { get; set; }
+ public int Retries { get; set; }
+
+ protected InverterProtocol(string host, int port, byte commAddr, int timeout, int retries)
+ {
+ Host = host;
+ Port = port;
+ CommAddr = commAddr;
+ Timeout = timeout;
+ Retries = retries;
+ }
+
+ public abstract Task<ProtocolResponse> SendAsync(ProtocolCommand command, CancellationToken ct = default);
+ public abstract ModbusRtuReadCommand ReadCommand(ushort offset, ushort count);
+ public abstract ModbusRtuWriteCommand WriteCommand(ushort register, ushort value);
+
+ public abstract ValueTask DisposeAsync();
+}
+
+public class UdpInverterProtocol : InverterProtocol
+{
+ private UdpClient? _udp;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+
+ public UdpInverterProtocol(string host, int port, byte commAddr, int timeout = 1, int retries = 3)
+ : base(host, port, commAddr, timeout, retries) { }
+
+ public override ModbusRtuReadCommand ReadCommand(ushort offset, ushort count) =>
+ new(CommAddr, offset, count);
+
+ public override ModbusRtuWriteCommand WriteCommand(ushort register, ushort value) =>
+ new(CommAddr, register, value);
+
+ private UdpClient GetOrCreateUdp()
+ {
+ if (_udp == null)
+ {
+ _udp = new UdpClient();
+ _udp.Connect(Host, Port);
+ }
+ return _udp;
+ }
+
+ public override async Task<ProtocolResponse> SendAsync(ProtocolCommand command, CancellationToken ct = default)
+ {
+ await _lock.WaitAsync(ct);
+ try
+ {
+ byte[]? partialData = null;
+ int partialMissing = 0;
+
+ for (int attempt = 0; attempt <= Retries; attempt++)
+ {
+ var udp = GetOrCreateUdp();
+ var payload = command.RequestBytes();
+ await udp.SendAsync(payload, payload.Length);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(Timeout));
+
+ try
+ {
+ while (true)
+ {
+ var result = await udp.ReceiveAsync(cts.Token);
+ var data = result.Buffer;
+
+ if (partialData != null && partialMissing == data.Length)
+ {
+ var combined = new byte[partialData.Length + data.Length];
+ partialData.CopyTo(combined, 0);
+ data.CopyTo(combined, partialData.Length);
+ data = combined;
+ partialData = null;
+ partialMissing = 0;
+ }
+
+ try
+ {
+ if (command.Validator(data))
+ return new ProtocolResponse(data, command);
+ }
+ catch (PartialResponseException ex)
+ {
+ partialData = data;
+ partialMissing = ex.Expected - ex.Length;
+ cts.CancelAfter(TimeSpan.FromSeconds(Timeout));
+ }
+ catch (RequestRejectedException)
+ {
+ throw;
+ }
+ }
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // timeout — retry
+ }
+ }
+
+ throw new MaxRetriesException();
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public override ValueTask DisposeAsync()
+ {
+ _udp?.Dispose();
+ _udp = null;
+ return ValueTask.CompletedTask;
+ }
+}
+
+public class TcpInverterProtocol : InverterProtocol
+{
+ private TcpClient? _tcp;
+ private NetworkStream? _stream;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+
+ public TcpInverterProtocol(string host, int port, byte commAddr, int timeout = 5, int retries = 3)
+ : base(host, port, commAddr, timeout, retries) { }
+
+ public override ModbusRtuReadCommand ReadCommand(ushort offset, ushort count) =>
+ new(CommAddr, offset, count);
+
+ public override ModbusRtuWriteCommand WriteCommand(ushort register, ushort value) =>
+ new(CommAddr, register, value);
+
+ private async Task EnsureConnectedAsync(CancellationToken ct)
+ {
+ if (_tcp?.Connected == true) return;
+ _tcp?.Dispose();
+ _tcp = new TcpClient();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+ await _tcp.ConnectAsync(Host, Port, cts.Token);
+ _stream = _tcp.GetStream();
+ }
+
+ public override async Task<ProtocolResponse> SendAsync(ProtocolCommand command, CancellationToken ct = default)
+ {
+ await _lock.WaitAsync(ct);
+ try
+ {
+ for (int attempt = 0; attempt <= Retries; attempt++)
+ {
+ try
+ {
+ await EnsureConnectedAsync(ct);
+ var payload = command.RequestBytes();
+ await _stream!.WriteAsync(payload, ct);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(Timeout));
+
+ var buffer = new byte[1024];
+ byte[]? partialData = null;
+ int partialMissing = 0;
+
+ while (true)
+ {
+ int read = await _stream.ReadAsync(buffer, cts.Token);
+ if (read == 0) throw new RequestFailedException("Connection closed");
+
+ byte[] data = buffer[..read];
+ if (partialData != null && partialMissing == data.Length)
+ {
+ var combined = new byte[partialData.Length + data.Length];
+ partialData.CopyTo(combined, 0);
+ data.CopyTo(combined, partialData.Length);
+ data = combined;
+ partialData = null;
+ }
+
+ try
+ {
+ if (command.Validator(data))
+ return new ProtocolResponse(data, command);
+ }
+ catch (PartialResponseException ex)
+ {
+ partialData = data;
+ partialMissing = ex.Expected - ex.Length;
+ cts.CancelAfter(TimeSpan.FromSeconds(Timeout));
+ }
+ }
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ _tcp?.Dispose();
+ _tcp = null;
+ }
+ catch (IOException)
+ {
+ _tcp?.Dispose();
+ _tcp = null;
+ }
+ }
+
+ throw new MaxRetriesException();
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public override ValueTask DisposeAsync()
+ {
+ _tcp?.Dispose();
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/GoodWe/Sensor.cs b/GoodWe/Sensor.cs
new file mode 100644
index 0000000..6b06a92
--- /dev/null
+++ b/GoodWe/Sensor.cs
@@ -0,0 +1,278 @@
+namespace GoodWe;
+
+public abstract class Sensor
+{
+ public string Id { get; }
+ public int Offset { get; }
+ public string Name { get; }
+ public string Unit { get; }
+ public SensorKind? Kind { get; }
+
+ protected Sensor(string id, int offset, string name, string unit, SensorKind? kind)
+ {
+ Id = id;
+ Offset = offset;
+ Name = name;
+ Unit = unit;
+ Kind = kind;
+ }
+
+ public object? Read(ProtocolResponse data)
+ {
+ data.Seek(Offset);
+ return ReadValue(data);
+ }
+
+ protected abstract object? ReadValue(ProtocolResponse data);
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+public static class SensorHelper
+{
+ public static ushort ReadU16(ProtocolResponse d) =>
+ (ushort)(d.Read(2) is var b ? (b[0] << 8) | b[1] : 0);
+
+ public static short ReadS16(ProtocolResponse d) =>
+ (short)ReadU16(d);
+
+ public static uint ReadU32(ProtocolResponse d) =>
+ (uint)(d.Read(4) is var b ? (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3] : 0);
+
+ public static int ReadS32(ProtocolResponse d) =>
+ (int)ReadU32(d);
+
+ public static ulong ReadU64(ProtocolResponse d) =>
+ (ulong)ReadU32(d) << 32 | ReadU32(d);
+
+ // Read raw 2 bytes at absolute register address without moving the stream cursor
+ public static ushort PeekU16At(byte[] responseData, int register, int firstAddress) =>
+ (ushort)((responseData[(register - firstAddress) * 2] << 8) |
+ responseData[(register - firstAddress) * 2 + 1]);
+
+ public static int PeekS16At(byte[] responseData, int register, int firstAddress) =>
+ (short)PeekU16At(responseData, register, firstAddress);
+
+ public static uint PeekU32At(byte[] responseData, int register, int firstAddress)
+ {
+ int off = (register - firstAddress) * 2;
+ return (uint)((responseData[off] << 24) | (responseData[off + 1] << 16) |
+ (responseData[off + 2] << 8) | responseData[off + 3]);
+ }
+
+ public static int PeekS32At(byte[] responseData, int register, int firstAddress) =>
+ (int)PeekU32At(responseData, register, firstAddress);
+}
+
+// ── concrete sensor types ─────────────────────────────────────────────────────
+
+public class VoltageSensor : Sensor
+{
+ public VoltageSensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "V", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ SensorHelper.ReadU16(d) is var a && a != 0xffff ? a / 10.0 : 0.0;
+}
+
+public class CurrentSensor : Sensor
+{
+ public CurrentSensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "A", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ SensorHelper.ReadU16(d) is var a && a != 0xffff ? a / 10.0 : 0.0;
+}
+
+public class CurrentSignedSensor : Sensor
+{
+ public CurrentSignedSensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "A", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ SensorHelper.ReadS16(d) / 10.0;
+}
+
+public class FrequencySensor : Sensor
+{
+ public FrequencySensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "Hz", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ SensorHelper.ReadU16(d) / 100.0;
+}
+
+public class PowerSensor : Sensor
+{
+ public PowerSensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "W", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ (int)SensorHelper.ReadU16(d);
+}
+
+public class PowerSignedSensor : Sensor
+{
+ public PowerSignedSensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "W", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ (int)SensorHelper.ReadS16(d);
+}
+
+public class Power4Sensor : Sensor
+{
+ public Power4Sensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "W", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ (long)SensorHelper.ReadU32(d);
+}
+
+public class Power4SignedSensor : Sensor
+{
+ public Power4SignedSensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "W", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ (long)SensorHelper.ReadS32(d);
+}
+
+public class EnergySensor : Sensor
+{
+ public EnergySensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "kWh", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ SensorHelper.ReadU16(d) / 10.0;
+}
+
+public class Energy4Sensor : Sensor
+{
+ public Energy4Sensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "kWh", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ SensorHelper.ReadU32(d) / 10.0;
+}
+
+// Energy4W: 4-byte unsigned, divide by 1000 (smart-meter kWh)
+public class Energy4WSensor : Sensor
+{
+ public Energy4WSensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "kWh", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ SensorHelper.ReadU32(d) / 1000.0;
+}
+
+// CurrentSmA: 2-byte signed, value in mA → display in A
+public class CurrentSmASensor : Sensor
+{
+ public CurrentSmASensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "A", kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ SensorHelper.ReadS16(d) / 1000.0;
+}
+
+public class TempSensor : Sensor
+{
+ public TempSensor(string id, int offset, string name, SensorKind? kind = null)
+ : base(id, offset, name, "°C", kind) { }
+ protected override object? ReadValue(ProtocolResponse d)
+ {
+ short raw = SensorHelper.ReadS16(d);
+ return raw is -1 or short.MinValue ? null : (object)(raw / 10.0);
+ }
+}
+
+public class IntegerSensor : Sensor
+{
+ public IntegerSensor(string id, int offset, string name, string unit = "", SensorKind? kind = null)
+ : base(id, offset, name, unit, kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ (int)SensorHelper.ReadU16(d);
+
+ //SensorHelper.ReadU16(d) is var a && a == 0xffff ? "" : $"{(int)a}";
+}
+
+public class IntegerSignedSensor : Sensor
+{
+ public IntegerSignedSensor(string id, int offset, string name, string unit = "", SensorKind? kind = null)
+ : base(id, offset, name, unit, kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ (int)SensorHelper.ReadS16(d);
+}
+
+public class LongSensor : Sensor
+{
+ public LongSensor(string id, int offset, string name, string unit = "", SensorKind? kind = null)
+ : base(id, offset, name, unit, kind) { }
+ protected override object? ReadValue(ProtocolResponse d) =>
+ (long)SensorHelper.ReadU32(d);
+}
+
+public class EnumSensor : Sensor
+{
+ private readonly IReadOnlyDictionary<int, string> _labels;
+ public EnumSensor(string id, int offset, IReadOnlyDictionary<int, string> labels, string name, SensorKind? kind = null)
+ : base(id, offset, name, "", kind) => _labels = labels;
+ protected override object? ReadValue(ProtocolResponse d)
+ {
+ int v = SensorHelper.ReadU16(d);
+ return _labels.TryGetValue(v, out var label) ? label : $"Unknown({v})";
+ }
+}
+
+public class TimestampSensorET : Sensor
+{
+ public TimestampSensorET(string id, int offset, string name)
+ : base(id, offset, name, "", null) { }
+ protected override object? ReadValue(ProtocolResponse d)
+ {
+ var b = d.Read(12);
+ try
+ {
+ return new DateTime(2000 + b[0], b[1], b[2], b[4], b[6], b[8]);
+ }
+ catch { return null; }
+ }
+}
+
+public class TimestampSensorDT : Sensor
+{
+ public TimestampSensorDT(string id, int offset, string name)
+ : base(id, offset, name, "", null) { }
+ protected override object? ReadValue(ProtocolResponse d)
+ {
+ var b = d.Read(6);
+ try
+ {
+ return new DateTime(2000 + b[0], b[1], b[2], b[3], b[4], b[5]);
+ }
+ catch { return null; }
+ }
+}
+
+// Bitmap sensor — decodes bit flags to comma-separated label string
+public class BitmapSensor : Sensor
+{
+ private readonly IReadOnlyDictionary<int, string> _labels;
+ public BitmapSensor(string id, int offset, IReadOnlyDictionary<int, string> labels, string name, SensorKind? kind = null)
+ : base(id, offset, name, "", kind) => _labels = labels;
+ protected override object? ReadValue(ProtocolResponse d)
+ {
+ uint val = SensorHelper.ReadU32(d);
+ var flags = new List<string>();
+ for (int bit = 0; bit < 32; bit++)
+ if ((val & (1u << bit)) != 0 && _labels.TryGetValue(bit, out var lbl))
+ flags.Add(lbl);
+ return flags.Count > 0 ? string.Join(", ", flags) : "OK";
+ }
+}
+
+// Calculated sensor with a delegate
+public class CalculatedSensor : Sensor
+{
+ private readonly Func<byte[], int, object?> _getter;
+ private readonly int _firstAddress;
+
+ public CalculatedSensor(string id, Func<byte[], int, object?> getter, string name, string unit, SensorKind? kind, int firstAddress)
+ : base(id, 0, name, unit, kind)
+ {
+ _getter = getter;
+ _firstAddress = firstAddress;
+ }
+
+ public object? Compute(byte[] responseData) => _getter(responseData, _firstAddress);
+ protected override object? ReadValue(ProtocolResponse d) => null; // not used directly
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a25c342
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# GoodWe Librabry
+
+## Test WinForm
+
+<img width="392" height="593" alt="image" src="https://github.com/user-attachments/assets/8be9b788-5748-4c68-b26b-87c11076d77f" />
+
+Tested on Wifi adapter (no LAN) version: V1.1.1.2 for UDP port 8899
+