From 6e67325deb7e3e5c32ffc42539e2e9ca7a4e7f58 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 21 May 2025 10:39:31 +0200 Subject: [PATCH 01/30] Adapted to NEXA --- internal/growatt_app/client.go | 36 +++++++++++++------- internal/growatt_app/models.go | 60 ++++++++++++++++++++++----------- internal/growatt_app/payload.go | 4 +-- internal/growatt_app/service.go | 2 +- internal/growatt_web/models.go | 23 ++++++++++++- internal/growatt_web/service.go | 2 +- 6 files changed, 89 insertions(+), 38 deletions(-) diff --git a/internal/growatt_app/client.go b/internal/growatt_app/client.go index 31ed4b9..54b4b53 100644 --- a/internal/growatt_app/client.go +++ b/internal/growatt_app/client.go @@ -1,8 +1,8 @@ package growatt_app import ( + "errors" "fmt" - "github.com/google/uuid" "log/slog" "math" "net/http" @@ -10,6 +10,8 @@ import ( "net/url" "noah-mqtt/internal/misc" "time" + + "github.com/google/uuid" ) type Client struct { @@ -72,11 +74,11 @@ func (h *Client) Login() error { "password": {h.password}, "newLogin": {"1"}, "phoneType": {"android"}, - "shinephoneVersion": {"8.2.6.0"}, + "shinephoneVersion": {"8.3.0.2"}, "phoneSn": {uuid.New().String()}, "ipvcpc": {ipvcpc(h.username)}, "language": {"1"}, - "systemVersion": {"9"}, + "systemVersion": {"15"}, "phoneModel": {"Mi A1"}, "loginTime": {time.Now().Format(time.DateTime)}, "appType": {"ShinePhone"}, @@ -114,12 +116,19 @@ func (h *Client) GetNoahPlantInfo(plantId string) (*NoahPlantInfo, error) { }, &data); err != nil { return nil, err } + + if !data.Obj.IsPlantHaveNexa { + err := errors.New("No NEXA device") + slog.Error(err.Error()) + misc.Panic(err) + } + return &data, nil } func (h *Client) GetNoahStatus(serialNumber string) (*NoahStatus, error) { var data NoahStatus - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/noah/getSystemStatus", url.Values{ + if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getSystemStatus", url.Values{ "deviceSn": {serialNumber}, }, &data); err != nil { return nil, err @@ -127,9 +136,9 @@ func (h *Client) GetNoahStatus(serialNumber string) (*NoahStatus, error) { return &data, nil } -func (h *Client) GetNoahInfo(serialNumber string) (*NoahInfo, error) { - var data NoahInfo - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/noah/getNoahInfoBySn", url.Values{ +func (h *Client) GetNoahInfo(serialNumber string) (*NexaInfo, error) { + var data NexaInfo + if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getNexaInfoBySn", url.Values{ "deviceSn": {serialNumber}, }, &data); err != nil { return nil, err @@ -140,7 +149,7 @@ func (h *Client) GetNoahInfo(serialNumber string) (*NoahInfo, error) { func (h *Client) GetBatteryData(serialNumber string) (*BatteryInfo, error) { var data BatteryInfo - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/noah/getBatteryData", url.Values{ + if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getBatteryData", url.Values{ "deviceSn": {serialNumber}, }, &data); err != nil { return nil, err @@ -149,13 +158,14 @@ func (h *Client) GetBatteryData(serialNumber string) (*BatteryInfo, error) { return &data, nil } -func (h *Client) SetDefaultPower(serialNumber string, power float64) error { +func (h *Client) SetSystemOutputPower(serialNumber string, mode int, power float64) error { p := math.Max(0, math.Min(800, power)) var data map[string]any - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/noah/set", url.Values{ + if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, - "type": {"default_power"}, - "param1": {fmt.Sprintf("%.0f", p)}, + "type": {"system_out_put_power"}, + "param1": {fmt.Sprintf("%d", mode)}, + "param2": {fmt.Sprintf("%.0f", p)}, }, &data); err != nil { return err } @@ -167,7 +177,7 @@ func (h *Client) SetSocLimit(serialNumber string, chargingLimit float64, dischar c := math.Max(70, math.Min(100, chargingLimit)) d := math.Max(0, math.Min(30, dischargeLimit)) var data map[string]any - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/noah/set", url.Values{ + if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"charging_soc"}, "param1": {fmt.Sprintf("%.0f", c)}, diff --git a/internal/growatt_app/models.go b/internal/growatt_app/models.go index fa1375e..5bd7fdb 100644 --- a/internal/growatt_app/models.go +++ b/internal/growatt_app/models.go @@ -33,6 +33,7 @@ type NoahPlantInfo struct { IsPlantNoahSystem bool `json:"isPlantNoahSystem"` PlantID string `json:"plantId"` IsPlantHaveNoah bool `json:"isPlantHaveNoah"` + IsPlantHaveNexa bool `json:"isPlantHaveNexa"` DeviceSn string `json:"deviceSn"` PlantName string `json:"plantName"` }] @@ -40,9 +41,13 @@ type NoahPlantInfo struct { type NoahStatus struct { ResponseContainerV2[struct { + LoadPower string `json:"loadPower"` // new + GridPower string `json:"gridPower"` // new ChargePower string `json:"chargePower"` + GroplugPower string `json:"groplugPower"` // new WorkMode string `json:"workMode"` Soc string `json:"soc"` + EastronStatus string `json:"eastronStatus"` // new AssociatedInvSn string `json:"associatedInvSn"` BatteryNum string `json:"batteryNum"` ProfitToday string `json:"profitToday"` @@ -50,42 +55,57 @@ type NoahStatus struct { DisChargePower string `json:"disChargePower"` EacTotal string `json:"eacTotal"` EacToday string `json:"eacToday"` + IsHaveCt string `json:"isHaveCt"` // new + OnOffGrid string `json:"onOffGrid"` // new Pac string `json:"pac"` Ppv string `json:"ppv"` Alias string `json:"alias"` ProfitTotal string `json:"profitTotal"` MoneyUnit string `json:"moneyUnit"` - Status string `json:"status"` // 1 = online, -1 = offline, 5 = heating + GroplugNum string `json:"groplugNum"` // new + OtherPower string `json:"otherPower"` // new + Status string `json:"status"` // 1 = online, -1 = offline, 5 = heating }] } -type NoahInfo struct { +type NexaInfo struct { ResponseContainerV2[struct { Noah struct { - TimeSegment []map[string]string `json:"time_segment"` - BatSns []string `json:"batSns"` - ManName string `json:"manName"` - AssociatedInvSn string `json:"associatedInvSn"` - PlantID string `json:"plantId"` - ChargingSocHighLimit string `json:"chargingSocHighLimit"` - DefaultPower string `json:"defaultPower"` - Version string `json:"version"` - DeviceSn string `json:"deviceSn"` - ChargingSocLowLimit string `json:"chargingSocLowLimit"` - FormulaMoney string `json:"formulaMoney"` - ModelName string `json:"modelName"` - Alias string `json:"alias"` - Model string `json:"model"` - PlantName string `json:"plantName"` - AssociatedInvManAndModel int `json:"associatedInvManAndModel"` - TempType string `json:"tempType"` - MoneyUnitText string `json:"moneyUnitText"` + TimeSegment []map[string]string `json:"time_segment"` + AntiBackflowEnable string `json:"antiBackflowEnable"` // new + AcCouplePowerControl string `json:"acCouplePowerControl"` // new + AmmeterModel string `json:"ammeterModel"` // new + AmmeterSn string `json:"ammeterSn"` // new + ShellyList []interface{} `json:"shellyList"` // new + GridSet string `json:"gridSet"` // new + AntiBackflowPowerPercentage string `json:"antiBackflowPowerPercentage"` // new + BatSns []string `json:"batSns"` + ManName string `json:"manName"` + AssociatedInvSn string `json:"associatedInvSn"` + PlantID string `json:"plantId"` + ChargingSocHighLimit string `json:"chargingSocHighLimit"` + DefaultMode string `json:"defaultMode"` // new + DefaultACCouplePower string `json:"defaultACCouplePower"` // new + Version string `json:"version"` + DeviceSn string `json:"deviceSn"` + ChargingSocLowLimit string `json:"chargingSocLowLimit"` + FormulaMoney string `json:"formulaMoney"` + Alias string `json:"alias"` + Model string `json:"model"` + CtType string `json:"ctType"` // new + AllowGridCharging string `json:"allowGridCharging"` // new + GridConnectionControl string `json:"gridConnectionControl"` // new + PlantName string `json:"plantName"` + AssociatedInvManAndModel int `json:"associatedInvManAndModel"` + TempType string `json:"tempType"` + MoneyUnitText string `json:"moneyUnitText"` } `json:"noah"` PlantList []struct { PlantID string `json:"plantId"` PlantImgName interface{} `json:"plantImgName"` PlantName string `json:"plantName"` } `json:"plantList"` + UnitList map[string]string `json:"unitList"` // new }] } diff --git a/internal/growatt_app/payload.go b/internal/growatt_app/payload.go index b6066a7..f922e6d 100644 --- a/internal/growatt_app/payload.go +++ b/internal/growatt_app/payload.go @@ -28,10 +28,10 @@ func batteryPayload(n *BatteryDetails) models.BatteryPayload { } } -func parameterPayload(n *NoahInfo) models.ParameterPayload { +func parameterPayload(n *NexaInfo) models.ParameterPayload { chargingLimit := misc.ParseFloat(n.Obj.Noah.ChargingSocHighLimit) dischargeLimit := misc.ParseFloat(n.Obj.Noah.ChargingSocLowLimit) - outputPower := misc.ParseFloat(n.Obj.Noah.DefaultPower) + outputPower := misc.ParseFloat(n.Obj.Noah.DefaultACCouplePower) return models.ParameterPayload{ ChargingLimit: &chargingLimit, diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index ba809e5..fe69631 100644 --- a/internal/growatt_app/service.go +++ b/internal/growatt_app/service.go @@ -134,7 +134,7 @@ func (g *GrowattAppService) SetOutputPowerW(device models.NoahDevicePayload, pow slog.Error("unable to set default power (app)", slog.String("device", device.Serial)) return false } - if err := g.client.SetDefaultPower(device.Serial, power); err != nil { + if err := g.client.SetSystemOutputPower(device.Serial, 0, power); err != nil { slog.Error("unable to set default power (app)", slog.String("error", err.Error()), slog.String("device", device.Serial)) return false } else { diff --git a/internal/growatt_web/models.go b/internal/growatt_web/models.go index ada54e0..5aa3d46 100644 --- a/internal/growatt_web/models.go +++ b/internal/growatt_web/models.go @@ -58,6 +58,7 @@ type GrowattNoahList struct { Time5Mode string `json:"time5Mode"` Time7Enable string `json:"time7Enable"` Soc string `json:"soc"` + Time3Repeat string `json:"time3Repeat"` Time4Start string `json:"time4Start"` Time2End string `json:"time2End"` ShellyFlag string `json:"shellyFlag"` @@ -74,13 +75,14 @@ type GrowattNoahList struct { Time6Start string `json:"time6Start"` Time1Power string `json:"time1Power"` Time7Mode string `json:"time7Mode"` + Time7Repeat string `json:"time7Repeat"` Time6Enable string `json:"time6Enable"` Time1End string `json:"time1End"` ChargingSocHighLimit string `json:"chargingSocHighLimit"` Time5End string `json:"time5End"` Time9Start string `json:"time9Start"` - DefaultPower string `json:"defaultPower"` Version string `json:"version"` + Time4Repeat string `json:"time4Repeat"` Time3Power string `json:"time3Power"` ChargingSocLowLimit string `json:"chargingSocLowLimit"` NominalPower string `json:"nominalPower"` @@ -92,9 +94,11 @@ type GrowattNoahList struct { GridConnectionControl string `json:"gridConnectionControl"` Status string `json:"status"` LastUpdateTime string `json:"lastUpdateTime"` + Time1Repeat string `json:"time1Repeat"` Time2Enable string `json:"time2Enable"` WorkMode string `json:"workMode"` AccountName string `json:"accountName"` + ManName string `json:"manName"` Timezone string `json:"timezone"` AntiBackflowEnable string `json:"antiBackflowEnable"` Time5Power string `json:"time5Power"` @@ -103,10 +107,12 @@ type GrowattNoahList struct { Time9Mode string `json:"time9Mode"` Time4End string `json:"time4End"` Time1Start string `json:"time1Start"` + Time8Repeat string `json:"time8Repeat"` Time7End string `json:"time7End"` EMonth string `json:"eMonth"` Dtc string `json:"dtc"` Time1Mode string `json:"time1Mode"` + Time5Repeat string `json:"time5Repeat"` Time9Enable string `json:"time9Enable"` Alias string `json:"alias"` DatalogSn string `json:"datalogSn"` @@ -115,10 +121,13 @@ type GrowattNoahList struct { Sn string `json:"sn"` Time4Power string `json:"time4Power"` AntiBackflowPowerPercentage string `json:"antiBackflowPowerPercentage"` + AssociatedInvManAndModel string `json:"associatedInvManAndModel"` Time1Enable string `json:"time1Enable"` Address string `json:"address"` + Time2Repeat string `json:"time2Repeat"` DatalogType string `json:"datalogType"` PlantID string `json:"plantId"` + Time9Repeat string `json:"time9Repeat"` Time2Mode string `json:"time2Mode"` Time3End string `json:"time3End"` Time8End string `json:"time8End"` @@ -136,6 +145,8 @@ type GrowattNoahList struct { Time5Start string `json:"time5Start"` DefaultACCouplePower string `json:"defaultACCouplePower"` PlantName string `json:"plantName"` + ManAddress string `json:"manAddress"` + Time6Repeat string `json:"time6Repeat"` Time8Enable string `json:"time8Enable"` } `json:"datas"` NotPager bool `json:"notPager"` @@ -268,11 +279,21 @@ type GrowattNoahStatus struct { Result int `json:"result"` Msg interface{} `json:"msg"` Obj struct { + SmartSocketPower string `json:"smartSocketPower"` // new + CtSelfPower string `json:"ctSelfPower"` // new + GroplugFlag string `json:"groplugFlag"` // new + HouseholdLoadApartFromGroplug string `json:"householdLoadApartFromGroplug"` // new + ShellyFlag string `json:"shellyFlag"` // new + TotalHouseholdLoad string `json:"totalHouseholdLoad"` // new TotalBatteryPackSoc string `json:"totalBatteryPackSoc"` Pac string `json:"pac"` WorkMode string `json:"workMode"` + EastronFlag string `json:"eastronFlag"` // new + BatteryPackageQuantity string `json:"batteryPackageQuantity"` // new Ppv string `json:"ppv"` + GroplugNum string `json:"groplugNum"` // new TotalBatteryPackChargingPower string `json:"totalBatteryPackChargingPower"` + OtherPower string `json:"otherPower"` // new Status string `json:"status"` } `json:"obj"` Request interface{} `json:"request"` diff --git a/internal/growatt_web/service.go b/internal/growatt_web/service.go index 31bbad9..6268a40 100644 --- a/internal/growatt_web/service.go +++ b/internal/growatt_web/service.go @@ -172,7 +172,7 @@ func (g *GrowattService) pollHistory(device models.NoahDevicePayload) { detailsData := details.Datas[0] cl := misc.ParseFloat(detailsData.ChargingSocHighLimit) dl := misc.ParseFloat(detailsData.ChargingSocLowLimit) - op := misc.ParseFloat(detailsData.DefaultPower) + op := misc.ParseFloat(detailsData.DefaultACCouplePower) paramPayload := models.ParameterPayload{ ChargingLimit: &cl, DischargeLimit: &dl, From 4fe5a565037baaa2de32d2d52d1e3c4cfe42c7ea Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 May 2025 12:58:53 +0200 Subject: [PATCH 02/30] Reworked thread structure and MQTT connection handling --- cmd/noah-mqtt/main.go | 9 +++++---- internal/growatt_app/service.go | 30 ++++++++++++++++-------------- internal/growatt_web/service.go | 22 +++++++++++++--------- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/cmd/noah-mqtt/main.go b/cmd/noah-mqtt/main.go index b88fee9..8f33dc8 100644 --- a/cmd/noah-mqtt/main.go +++ b/cmd/noah-mqtt/main.go @@ -2,7 +2,6 @@ package main import ( "fmt" - mqtt "github.com/eclipse/paho.mqtt.golang" "log/slog" "noah-mqtt/internal/config" "noah-mqtt/internal/endpoint_mqtt" @@ -16,6 +15,8 @@ import ( "os/user" "strings" "syscall" + + mqtt "github.com/eclipse/paho.mqtt.golang" ) var ( @@ -137,12 +138,10 @@ func connectMqtt(mqttCfg config.Mqtt, onConnected func(client mqtt.Client)) { opts.OnConnect = func(client mqtt.Client) { slog.Info("connected to mqtt broker") - onConnected(client) } opts.OnConnectionLost = func(client mqtt.Client, err error) { - slog.Error("lost connection to mqtt broker", slog.String("error", err.Error())) - misc.Panic(err) + slog.Warn("lost connection to mqtt broker", slog.String("error", err.Error())) } c := mqtt.NewClient(opts) @@ -150,5 +149,7 @@ func connectMqtt(mqttCfg config.Mqtt, onConnected func(client mqtt.Client)) { if token := c.Connect(); token.Wait() && token.Error() != nil { slog.Error("could not connect to mqtt broker", slog.String("error", token.Error().Error())) misc.Panic(token.Error()) + } else { + onConnected(c) } } diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index fe69631..3aa8fe3 100644 --- a/internal/growatt_app/service.go +++ b/internal/growatt_app/service.go @@ -197,30 +197,32 @@ func (g *GrowattAppService) poll() { slog.Int("battery-details-interval", int(g.opts.BatteryDetailsPollingInterval/time.Second)), slog.Int("parameter-interval", int(g.opts.ParameterPollingInterval/time.Second))) - go func() { - for { + tickerPolling := time.NewTicker(g.opts.PollingInterval) + tickerBatteryDetails := time.NewTicker(g.opts.BatteryDetailsPollingInterval) + tickerParameter := time.NewTicker(g.opts.ParameterPollingInterval) + + for _, device := range g.devices { + g.pollStatus(device) + g.pollBatteryDetails(device) + g.pollParameterData(device) + } + + for { + select { + case <-tickerPolling.C: for _, device := range g.devices { g.pollStatus(device) } - <-time.After(g.opts.PollingInterval) - } - }() - go func() { - for { + case <-tickerBatteryDetails.C: for _, device := range g.devices { g.pollBatteryDetails(device) } - <-time.After(g.opts.BatteryDetailsPollingInterval) - } - }() - go func() { - for { + case <-tickerParameter.C: for _, device := range g.devices { g.pollParameterData(device) } - <-time.After(g.opts.ParameterPollingInterval) } - }() + } } diff --git a/internal/growatt_web/service.go b/internal/growatt_web/service.go index 6268a40..0fa1810 100644 --- a/internal/growatt_web/service.go +++ b/internal/growatt_web/service.go @@ -105,23 +105,27 @@ func (g *GrowattService) poll() { slog.Int("interval", int(g.opts.PollingInterval/time.Second)), slog.Int("history-interval", int(historyInterval/time.Second))) - go func() { - for { + tickerPolling := time.NewTicker(g.opts.PollingInterval) + tickerHistory := time.NewTicker(historyInterval) + + for _, device := range g.devices { + g.pollStatus(device) + g.pollHistory(device) + } + + for { + select { + case <-tickerPolling.C: for _, device := range g.devices { g.pollStatus(device) } - <-time.After(g.opts.PollingInterval) - } - }() - go func() { - for { + case <-tickerHistory.C: for _, device := range g.devices { g.pollHistory(device) } - <-time.After(historyInterval) } - }() + } } func (g *GrowattService) pollStatus(device models.NoahDevicePayload) { From bf0324b1d9f8190474920017d286f260498c6316 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 16 Jun 2025 14:15:21 +0200 Subject: [PATCH 03/30] Added some (so far unused) client parameter setters --- internal/growatt_app/client.go | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/internal/growatt_app/client.go b/internal/growatt_app/client.go index 54b4b53..c580459 100644 --- a/internal/growatt_app/client.go +++ b/internal/growatt_app/client.go @@ -188,3 +188,42 @@ func (h *Client) SetSocLimit(serialNumber string, chargingLimit float64, dischar return nil } + +func (h *Client) SetAllowGridCharging(serialNumber string, allow int) error { + var data map[string]any + if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ + "serialNum": {serialNumber}, + "type": {"allow_grid_charging"}, + "param1": {fmt.Sprintf("%d", allow)}, + }, &data); err != nil { + return err + } + + return nil +} + +func (h *Client) SetGridConnectionControl(serialNumber string, offlineEnable int) error { + var data map[string]any + if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ + "serialNum": {serialNumber}, + "type": {"grid_connection_control"}, + "param1": {fmt.Sprintf("%d", offlineEnable)}, + }, &data); err != nil { + return err + } + + return nil +} + +func (h *Client) SetACCouplePowerControl(serialNumber string, _1000WEnable int) error { + var data map[string]any + if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ + "serialNum": {serialNumber}, + "type": {"ac_couple_power_control"}, + "param1": {fmt.Sprintf("%d", _1000WEnable)}, + }, &data); err != nil { + return err + } + + return nil +} From d453a2b56307af358c4810bf1cee98ec1ed6b09e Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 16 Jun 2025 17:03:10 +0200 Subject: [PATCH 04/30] Added script for creating Debian packages --- build_package.sh | 38 +++++++++++++++++++ package/DEBIAN/conffiles | 1 + package/DEBIAN/config | 9 +++++ package/DEBIAN/control | 6 +++ package/DEBIAN/postinst | 37 ++++++++++++++++++ package/DEBIAN/postrm | 8 ++++ package/DEBIAN/prerm | 8 ++++ package/DEBIAN/templates | 14 +++++++ package/etc/systemd/system/nexa-mqtt.service | 13 +++++++ .../system/nexa-mqtt.service.d/override.conf | 0 10 files changed, 134 insertions(+) create mode 100644 build_package.sh create mode 100644 package/DEBIAN/conffiles create mode 100644 package/DEBIAN/config create mode 100644 package/DEBIAN/control create mode 100644 package/DEBIAN/postinst create mode 100644 package/DEBIAN/postrm create mode 100644 package/DEBIAN/prerm create mode 100644 package/DEBIAN/templates create mode 100644 package/etc/systemd/system/nexa-mqtt.service create mode 100644 package/etc/systemd/system/nexa-mqtt.service.d/override.conf diff --git a/build_package.sh b/build_package.sh new file mode 100644 index 0000000..16d434c --- /dev/null +++ b/build_package.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +APP_NAME=nexa-mqtt + +ARCHS="amd64 arm" +LDFLAGS="-s -w" + +GITVERSION=$(git describe --tags --long) +# replace v1.2.3-4-gxxxxx with 1.2.3.4 or v1.2-3-gxx with 1.2.3 +VERSION=$(echo $GITVERSION | sed -E 's/v([0-9]+\.[0-9]+\.?[0-9]*)-([0-9]+)-g.*/\1.\2/') + +BUILD_DIR=$(pwd)/build +DEB_DIR=$BUILD_DIR/deb + +for arch in $ARCHS; do + rm -rf $DEB_DIR + mkdir -p $DEB_DIR/usr/bin; + + echo "Building for $arch..."; + GOOS=linux GOARCH=$arch go build -o $DEB_DIR/usr/bin/${APP_NAME} -ldflags "$LDFLAGS -X main.version=$GITVERSION" cmd/noah-mqtt/main.go; + + if [ "$arch" = "arm" ]; then + deb_arch="armhf"; + else + deb_arch="$arch"; + fi; + echo "Creating DEB package for $arch (DEB arch: $deb_arch)..."; + mkdir -p $DEB_DIR/DEBIAN; + cp -r package/* $DEB_DIR/; + echo "Version: $VERSION" >> $DEB_DIR/DEBIAN/control; + echo "Architecture: $deb_arch" >> $DEB_DIR/DEBIAN/control; + chmod 755 $DEB_DIR/DEBIAN/config; + chmod 755 $DEB_DIR/DEBIAN/postinst; + chmod 755 $DEB_DIR/DEBIAN/prerm; + chmod 755 $DEB_DIR/DEBIAN/postrm; + echo "Creating $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb"; + fakeroot dpkg-deb --build $DEB_DIR $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb; +done \ No newline at end of file diff --git a/package/DEBIAN/conffiles b/package/DEBIAN/conffiles new file mode 100644 index 0000000..5eb00ff --- /dev/null +++ b/package/DEBIAN/conffiles @@ -0,0 +1 @@ +/etc/systemd/system/nexa-mqtt.service.d/override.conf diff --git a/package/DEBIAN/config b/package/DEBIAN/config new file mode 100644 index 0000000..a59239e --- /dev/null +++ b/package/DEBIAN/config @@ -0,0 +1,9 @@ +#!/bin/bash +set -e +. /usr/share/debconf/confmodule + +db_input high nexa-mqtt/growatt_username || true +db_input high nexa-mqtt/growatt_password || true +db_input high nexa-mqtt/mqtt_host || true + +db_go || true diff --git a/package/DEBIAN/control b/package/DEBIAN/control new file mode 100644 index 0000000..1b18453 --- /dev/null +++ b/package/DEBIAN/control @@ -0,0 +1,6 @@ +Package: nexa-mqtt +Depends: systemd, debconf +Maintainer: Martin Gerczuk +Description: NEXA 2000 MQTT Publisher +Section: admin +Priority: optional diff --git a/package/DEBIAN/postinst b/package/DEBIAN/postinst new file mode 100644 index 0000000..39720b8 --- /dev/null +++ b/package/DEBIAN/postinst @@ -0,0 +1,37 @@ +#!/bin/bash +set -e +. /usr/share/debconf/confmodule + +echo "postinst '$1' '$2'" + +OVERRIDE_FILE="/etc/systemd/system/nexa-mqtt.service.d/override.conf" + +# Only if not yet existing or empty +if [ ! -s "$OVERRIDE_FILE" ]; then + + db_get nexa-mqtt/growatt_username + GROWATT_USERNAME="$RET" + + db_get nexa-mqtt/growatt_password + GROWATT_PASSWORD="$RET" + + db_get nexa-mqtt/mqtt_host + MQTT_HOST="$RET" + + # create service override file + mkdir -p $(dirname "${OVERRIDE_FILE}") + cat < "$OVERRIDE_FILE" +[Service] +Environment="GROWATT_USERNAME=$GROWATT_USERNAME" +Environment="GROWATT_PASSWORD=$GROWATT_PASSWORD" +Environment="MQTT_HOST=$MQTT_HOST" +EOF + echo "Override file created: /etc/systemd/system/nexa-mqtt.service.d/override.conf" +fi + +# Start service +if [ "$1" = "configure" ]; then + systemctl daemon-reload + systemctl enable nexa-mqtt.service + systemctl restart nexa-mqtt.service +fi diff --git a/package/DEBIAN/postrm b/package/DEBIAN/postrm new file mode 100644 index 0000000..764b981 --- /dev/null +++ b/package/DEBIAN/postrm @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "postrm '$1' '$2'" + +if [ "$1" = "purge" ]; then + systemctl disable nexa-mqtt || true +fi diff --git a/package/DEBIAN/prerm b/package/DEBIAN/prerm new file mode 100644 index 0000000..e079808 --- /dev/null +++ b/package/DEBIAN/prerm @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "prerm '$1' '$2'" + +if [ "$1" = "remove" ] || [ "$1" = "upgrade" ]; then + systemctl stop nexa-mqtt || true +fi diff --git a/package/DEBIAN/templates b/package/DEBIAN/templates new file mode 100644 index 0000000..cd7a393 --- /dev/null +++ b/package/DEBIAN/templates @@ -0,0 +1,14 @@ +Template: nexa-mqtt/growatt_username +Type: string +Default: none +Description: Enter openapi.growatt.com user name + +Template: nexa-mqtt/growatt_password +Type: string +Default: secret +Description: Enter openapi.growatt.com user password + +Template: nexa-mqtt/mqtt_host +Type: string +Default: localhost +Description: Enter hostname of MQTT broker diff --git a/package/etc/systemd/system/nexa-mqtt.service b/package/etc/systemd/system/nexa-mqtt.service new file mode 100644 index 0000000..1635f25 --- /dev/null +++ b/package/etc/systemd/system/nexa-mqtt.service @@ -0,0 +1,13 @@ +[Unit] +Description=Growatt NEXA 2000 MQTT + +[Service] +Type=simple +Restart=always +ExecStart=/usr/bin/nexa-mqtt +StandardOutput=journal +StandardError=journal +SyslogIdentifier=nexa-mqtt + +[Install] +WantedBy=multi-user.target diff --git a/package/etc/systemd/system/nexa-mqtt.service.d/override.conf b/package/etc/systemd/system/nexa-mqtt.service.d/override.conf new file mode 100644 index 0000000..e69de29 From a76829506147556abd5119b237406478c2402658 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 16 Jun 2025 17:22:05 +0200 Subject: [PATCH 05/30] Added Gitlab CI --- .gitlab-ci.yml | 17 +++++++++++++++++ build_package.sh | 6 +++--- internal/growatt_web/service.go | 4 ++-- package/DEBIAN/postinst | 2 +- package/DEBIAN/postrm | 2 +- package/DEBIAN/prerm | 2 +- package/DEBIAN/templates | 2 +- 7 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..dba4ae6 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,17 @@ +image: golang:1.24.0 + +stages: + - build + +build-job: + rules: + - if: '$CI_COMMIT_REF_NAME == "main"' + stage: build + before_script: + - apt-get update -y -qq + - apt-get install fakeroot -y + script: + - build_package.sh + artifacts: + paths: + - build/*.deb diff --git a/build_package.sh b/build_package.sh index 16d434c..7fc4558 100644 --- a/build_package.sh +++ b/build_package.sh @@ -13,7 +13,6 @@ BUILD_DIR=$(pwd)/build DEB_DIR=$BUILD_DIR/deb for arch in $ARCHS; do - rm -rf $DEB_DIR mkdir -p $DEB_DIR/usr/bin; echo "Building for $arch..."; @@ -26,13 +25,14 @@ for arch in $ARCHS; do fi; echo "Creating DEB package for $arch (DEB arch: $deb_arch)..."; mkdir -p $DEB_DIR/DEBIAN; - cp -r package/* $DEB_DIR/; + cp -r package/* $DEB_DIR/; echo "Version: $VERSION" >> $DEB_DIR/DEBIAN/control; echo "Architecture: $deb_arch" >> $DEB_DIR/DEBIAN/control; chmod 755 $DEB_DIR/DEBIAN/config; chmod 755 $DEB_DIR/DEBIAN/postinst; chmod 755 $DEB_DIR/DEBIAN/prerm; chmod 755 $DEB_DIR/DEBIAN/postrm; - echo "Creating $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb"; + echo "Creating $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb"; fakeroot dpkg-deb --build $DEB_DIR $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb; + rm -rf $DEB_DIR done \ No newline at end of file diff --git a/internal/growatt_web/service.go b/internal/growatt_web/service.go index 0fa1810..e411271 100644 --- a/internal/growatt_web/service.go +++ b/internal/growatt_web/service.go @@ -69,7 +69,7 @@ func (g *GrowattService) enumerateDevices() []models.NoahDevicePayload { slog.Error("could not get device history", slog.String("device", dev.Sn), slog.String("error", err.Error())) } else { if len(history.Obj.Datas) == 0 { - slog.Error("could not get device history, data empty", slog.String("device", dev.Sn)) + slog.Info("could not get device history, data empty", slog.String("device", dev.Sn)) } else { var batCount = history.Obj.Datas[0].BatteryPackageQuantity var batteries []models.NoahDeviceBatteryPayload @@ -193,7 +193,7 @@ func (g *GrowattService) pollHistory(device models.NoahDevicePayload) { slog.Error("could not get device history", slog.String("error", err.Error()), slog.String("device", device.Serial)) } else { if len(history.Obj.Datas) == 0 { - slog.Error("could not get device history, data empty", slog.String("device", device.Serial)) + slog.Info("could not get device history, data empty", slog.String("device", device.Serial)) } else { historyData := history.Obj.Datas[0] diff --git a/package/DEBIAN/postinst b/package/DEBIAN/postinst index 39720b8..ce5a6a2 100644 --- a/package/DEBIAN/postinst +++ b/package/DEBIAN/postinst @@ -2,7 +2,7 @@ set -e . /usr/share/debconf/confmodule -echo "postinst '$1' '$2'" +#echo "postinst '$1' '$2'" OVERRIDE_FILE="/etc/systemd/system/nexa-mqtt.service.d/override.conf" diff --git a/package/DEBIAN/postrm b/package/DEBIAN/postrm index 764b981..4faebdc 100644 --- a/package/DEBIAN/postrm +++ b/package/DEBIAN/postrm @@ -1,7 +1,7 @@ #!/bin/bash set -e -echo "postrm '$1' '$2'" +#echo "postrm '$1' '$2'" if [ "$1" = "purge" ]; then systemctl disable nexa-mqtt || true diff --git a/package/DEBIAN/prerm b/package/DEBIAN/prerm index e079808..315f269 100644 --- a/package/DEBIAN/prerm +++ b/package/DEBIAN/prerm @@ -1,7 +1,7 @@ #!/bin/bash set -e -echo "prerm '$1' '$2'" +#echo "prerm '$1' '$2'" if [ "$1" = "remove" ] || [ "$1" = "upgrade" ]; then systemctl stop nexa-mqtt || true diff --git a/package/DEBIAN/templates b/package/DEBIAN/templates index cd7a393..cf2cd6f 100644 --- a/package/DEBIAN/templates +++ b/package/DEBIAN/templates @@ -4,7 +4,7 @@ Default: none Description: Enter openapi.growatt.com user name Template: nexa-mqtt/growatt_password -Type: string +Type: password Default: secret Description: Enter openapi.growatt.com user password From cc548e0e46972e5ef987dd6bc3ff76dab806d51c Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 16 Jun 2025 17:23:45 +0200 Subject: [PATCH 06/30] - --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dba4ae6..5beecc2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,7 +5,7 @@ stages: build-job: rules: - - if: '$CI_COMMIT_REF_NAME == "main"' + - if: '$CI_COMMIT_REF_NAME == "nexa"' stage: build before_script: - apt-get update -y -qq From ca22056bc900b31d2db80b93ede0f5c61183f21a Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 16 Jun 2025 17:27:12 +0200 Subject: [PATCH 07/30] - --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5beecc2..e060231 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,7 +11,7 @@ build-job: - apt-get update -y -qq - apt-get install fakeroot -y script: - - build_package.sh + - bash ./build_package.sh artifacts: paths: - build/*.deb From 8b9ad76390dee58bb0fb8a576591714f57fa603e Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 17 Jun 2025 17:21:32 +0200 Subject: [PATCH 08/30] Added working mode --- build_package.sh | 3 ++- internal/homeassistant/discovery_sensors.go | 9 +++++++ pkg/models/payload.go | 28 +++++++++++++++++---- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/build_package.sh b/build_package.sh index 7fc4558..4342884 100644 --- a/build_package.sh +++ b/build_package.sh @@ -5,6 +5,7 @@ APP_NAME=nexa-mqtt ARCHS="amd64 arm" LDFLAGS="-s -w" +GITCOMMIT=$(git rev-parse HEAD) GITVERSION=$(git describe --tags --long) # replace v1.2.3-4-gxxxxx with 1.2.3.4 or v1.2-3-gxx with 1.2.3 VERSION=$(echo $GITVERSION | sed -E 's/v([0-9]+\.[0-9]+\.?[0-9]*)-([0-9]+)-g.*/\1.\2/') @@ -16,7 +17,7 @@ for arch in $ARCHS; do mkdir -p $DEB_DIR/usr/bin; echo "Building for $arch..."; - GOOS=linux GOARCH=$arch go build -o $DEB_DIR/usr/bin/${APP_NAME} -ldflags "$LDFLAGS -X main.version=$GITVERSION" cmd/noah-mqtt/main.go; + GOOS=linux GOARCH=$arch go build -o $DEB_DIR/usr/bin/${APP_NAME} -ldflags "$LDFLAGS -X main.version=$GITVERSION -X main.commit=$GITCOMMIT" cmd/noah-mqtt/main.go; if [ "$arch" = "arm" ]; then deb_arch="armhf"; diff --git a/internal/homeassistant/discovery_sensors.go b/internal/homeassistant/discovery_sensors.go index 7cc9a95..6c402b4 100644 --- a/internal/homeassistant/discovery_sensors.go +++ b/internal/homeassistant/discovery_sensors.go @@ -97,6 +97,15 @@ func generateSensorDiscoveryPayload(appVersion string, info DeviceInfo) []Sensor Device: device, Origin: origin, }, + { + Name: "Working Mode", + StateClass: StateClassMeasurement, + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.work_mode }}", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "work_mode"), + Device: device, + Origin: origin, + }, } for _, b := range info.Batteries { diff --git a/pkg/models/payload.go b/pkg/models/payload.go index cd78f15..ba28445 100644 --- a/pkg/models/payload.go +++ b/pkg/models/payload.go @@ -1,5 +1,7 @@ package models +import "fmt" + type WorkMode string const ( @@ -8,16 +10,32 @@ const ( Online = "online" Offline = "offline" Heating = "heating" + SmartSelfUse = "smart_self_use" + Fault = "fault" + OnGrid = "on_grid" + OffGrid = "off_grid" ) func StatusFromString(s string) string { - if s == "1" { - return Online - } - if s == "5" { + switch s { + case "-1": + return Offline + case "0": + return WorkModeLoadFirst + case "1": + return WorkModeBatteryFirst + case "2": + return SmartSelfUse + case "4": + return Fault + case "5": return Heating + case "6": + return OnGrid + case "7": + return OffGrid } - return Offline + return fmt.Sprintf("invalid_%s", s) } func WorkModeFromString(s string) WorkMode { From 7de2eaadbe69bf9e56fcc91af01f90b4be4830c9 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 18 Jun 2025 09:44:28 +0200 Subject: [PATCH 09/30] work_mode and status enums added to Home Assistant Discovery --- internal/homeassistant/discovery_sensors.go | 18 ++++++++++++++++-- internal/homeassistant/models.go | 2 ++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/homeassistant/discovery_sensors.go b/internal/homeassistant/discovery_sensors.go index 6c402b4..a35fe30 100644 --- a/internal/homeassistant/discovery_sensors.go +++ b/internal/homeassistant/discovery_sensors.go @@ -1,6 +1,9 @@ package homeassistant -import "fmt" +import ( + "fmt" + "noah-mqtt/pkg/models" +) func generateSensorDiscoveryPayload(appVersion string, info DeviceInfo) []Sensor { device := generateDevice(info) @@ -99,13 +102,24 @@ func generateSensorDiscoveryPayload(appVersion string, info DeviceInfo) []Sensor }, { Name: "Working Mode", - StateClass: StateClassMeasurement, + DeviceClass: DeviceClassEnum, + Options: []string{models.WorkModeLoadFirst, models.WorkModeBatteryFirst}, StateTopic: info.StateTopic, ValueTemplate: "{{ value_json.work_mode }}", UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "work_mode"), Device: device, Origin: origin, }, + { + Name: "Status", + DeviceClass: DeviceClassEnum, + Options: []string{models.Offline, models.WorkModeLoadFirst, models.WorkModeBatteryFirst, models.SmartSelfUse, models.Fault, models.Heating, models.OnGrid, models.OffGrid}, + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.status }}", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "status"), + Device: device, + Origin: origin, + }, } for _, b := range info.Batteries { diff --git a/internal/homeassistant/models.go b/internal/homeassistant/models.go index ab29966..c5bd872 100644 --- a/internal/homeassistant/models.go +++ b/internal/homeassistant/models.go @@ -9,6 +9,7 @@ const ( DeviceClassTemperature DeviceClass = "temperature" DeviceClassPower DeviceClass = "power" DeviceClassConnectivity DeviceClass = "connectivity" + DeviceClassEnum DeviceClass = "enum" ) type StateClass string @@ -63,6 +64,7 @@ type Sensor struct { UniqueId string `json:"unique_id,omitempty"` Device Device `json:"device,omitempty"` Origin Origin `json:"origin,omitempty"` + Options []string `json:"options,omitempty"` } type Device struct { From 6b9d593ef0a13860dda6172407621e3673a3314e Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 18 Jun 2025 15:05:15 +0200 Subject: [PATCH 10/30] Parameters: renamed 'output_power_w' to 'default_output_w', added 'default_mode' --- README.md | 4 ++-- internal/endpoint_mqtt/endpoint_mqtt.go | 7 ++++--- internal/growatt_app/payload.go | 7 ++++--- internal/growatt_web/service.go | 7 ++++--- internal/homeassistant/discovery_numbers.go | 6 +++--- pkg/models/payload.go | 7 ++++--- 6 files changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 69f38aa..6ef2ad7 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The following MQTT topics are used by `noah-mqtt` to publish data: { "charging_limit": 100, // battery charging limit in percent, between 70 and 100 "discharge_limit": 9, // battery discharge limit in percent, between 0 and 30 - "output_power_w": 800 // system output power in watts, between 0 and 800 + "default_output_w": 800 // system output power in watts, between 0 and 800 } ``` @@ -121,7 +121,7 @@ You can update the device's parameter settings by posting a message to the follo { "charging_limit": 100, // battery charging limit in percent, between 70 and 100 "discharge_limit": 9, // battery discharge limit in percent, between 0 and 30 - "output_power_w": 800 // system output power in watts, between 0 and 800 + "default_output_w": 800 // system output power in watts, between 0 and 800 } ``` diff --git a/internal/endpoint_mqtt/endpoint_mqtt.go b/internal/endpoint_mqtt/endpoint_mqtt.go index e0210d4..a8cba3f 100644 --- a/internal/endpoint_mqtt/endpoint_mqtt.go +++ b/internal/endpoint_mqtt/endpoint_mqtt.go @@ -3,11 +3,12 @@ package endpoint_mqtt import ( "encoding/json" "fmt" - mqtt "github.com/eclipse/paho.mqtt.golang" "log/slog" "noah-mqtt/internal/endpoint" "noah-mqtt/internal/homeassistant" "noah-mqtt/pkg/models" + + mqtt "github.com/eclipse/paho.mqtt.golang" ) type Options struct { @@ -111,8 +112,8 @@ func (e *Endpoint) parametersSubscription(dev models.NoahDevicePayload) func(cli slog.Error("unable to unmarshal parameter command payload", slog.String("error", err.Error())) } - if payload.OutputPower != nil { - e.param_applier.SetOutputPowerW(dev, *payload.OutputPower) + if payload.DefaultACCouplePower != nil { + e.param_applier.SetOutputPowerW(dev, *payload.DefaultACCouplePower) } if payload.ChargingLimit != nil { diff --git a/internal/growatt_app/payload.go b/internal/growatt_app/payload.go index f922e6d..e965b54 100644 --- a/internal/growatt_app/payload.go +++ b/internal/growatt_app/payload.go @@ -34,8 +34,9 @@ func parameterPayload(n *NexaInfo) models.ParameterPayload { outputPower := misc.ParseFloat(n.Obj.Noah.DefaultACCouplePower) return models.ParameterPayload{ - ChargingLimit: &chargingLimit, - DischargeLimit: &dischargeLimit, - OutputPower: &outputPower, + ChargingLimit: &chargingLimit, + DischargeLimit: &dischargeLimit, + DefaultACCouplePower: &outputPower, + DefaultMode: models.WorkModeFromString(n.Obj.Noah.DefaultMode), } } diff --git a/internal/growatt_web/service.go b/internal/growatt_web/service.go index e411271..56bed55 100644 --- a/internal/growatt_web/service.go +++ b/internal/growatt_web/service.go @@ -178,9 +178,10 @@ func (g *GrowattService) pollHistory(device models.NoahDevicePayload) { dl := misc.ParseFloat(detailsData.ChargingSocLowLimit) op := misc.ParseFloat(detailsData.DefaultACCouplePower) paramPayload := models.ParameterPayload{ - ChargingLimit: &cl, - DischargeLimit: &dl, - OutputPower: &op, + ChargingLimit: &cl, + DischargeLimit: &dl, + DefaultACCouplePower: &op, + DefaultMode: models.WorkModeFromString(detailsData.DefaultMode), } for _, e := range g.endpoints { diff --git a/internal/homeassistant/discovery_numbers.go b/internal/homeassistant/discovery_numbers.go index 4938087..1250450 100644 --- a/internal/homeassistant/discovery_numbers.go +++ b/internal/homeassistant/discovery_numbers.go @@ -9,8 +9,8 @@ func generateNumberDiscoveryPayload(appVersion string, info DeviceInfo) []Number numbers := []Number{ { Name: "System Output Power", - UniqueId: fmt.Sprintf("%s_system_output_power", info.SerialNumber), - CommandTemplate: "{\"output_power_w\": {{ value }}}", + UniqueId: fmt.Sprintf("%s_default_output_w", info.SerialNumber), + CommandTemplate: "{\"default_output_w\": {{ value }}}", CommandTopic: info.ParameterCommandTopic, Device: device, Origin: origin, @@ -23,7 +23,7 @@ func generateNumberDiscoveryPayload(appVersion string, info DeviceInfo) []Number Min: 0, Max: 800, UnitOfMeasurement: UnitWatt, - ValueTemplate: "{{ value_json.output_power_w }}", + ValueTemplate: "{{ value_json.default_output_w }}", }, { Name: "Charging Limit", diff --git a/pkg/models/payload.go b/pkg/models/payload.go index ba28445..51c78fa 100644 --- a/pkg/models/payload.go +++ b/pkg/models/payload.go @@ -65,9 +65,10 @@ type BatteryPayload struct { } type ParameterPayload struct { - ChargingLimit *float64 `json:"charging_limit,omitempty"` - DischargeLimit *float64 `json:"discharge_limit,omitempty"` - OutputPower *float64 `json:"output_power_w,omitempty"` + ChargingLimit *float64 `json:"charging_limit,omitempty"` + DischargeLimit *float64 `json:"discharge_limit,omitempty"` + DefaultACCouplePower *float64 `json:"default_output_w,omitempty"` + DefaultMode WorkMode `json:"default_mode,omitempty"` } type NoahDevicePayload struct { From 47b53be8ae305461e2648b74f5a19a896305685b Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 20 Jun 2025 08:32:08 +0200 Subject: [PATCH 11/30] Refactored setting parameters --- cmd/noah-mqtt/main.go | 1 + internal/endpoint/parameter_applier.go | 5 +- internal/endpoint_mqtt/endpoint_mqtt.go | 12 ++-- internal/growatt_app/client.go | 2 +- internal/growatt_app/payload.go | 3 +- internal/growatt_app/service.go | 82 +++++++++++++------------ internal/growatt_web/service.go | 3 +- pkg/models/payload.go | 18 ++++-- 8 files changed, 70 insertions(+), 56 deletions(-) diff --git a/cmd/noah-mqtt/main.go b/cmd/noah-mqtt/main.go index 8f33dc8..46079c1 100644 --- a/cmd/noah-mqtt/main.go +++ b/cmd/noah-mqtt/main.go @@ -121,6 +121,7 @@ func runApp(cfg config.Config, client mqtt.Client) { BatteryDetailsPollingInterval: cfg.BatteryDetailsPollingInterval, ParameterPollingInterval: cfg.ParameterPollingInterval, }) + growattApp.AddEndpoint(mqttEndpoint) mqttEndpoint.SetParameterApplier(growattApp) growattService.StartPolling() diff --git a/internal/endpoint/parameter_applier.go b/internal/endpoint/parameter_applier.go index 1225508..b27ec68 100644 --- a/internal/endpoint/parameter_applier.go +++ b/internal/endpoint/parameter_applier.go @@ -3,7 +3,6 @@ package endpoint import "noah-mqtt/pkg/models" type ParameterApplier interface { - SetOutputPowerW(device models.NoahDevicePayload, power float64) bool - SetChargingLimit(device models.NoahDevicePayload, limit float64) bool - SetDischargeLimit(device models.NoahDevicePayload, limit float64) bool + SetOutputPowerW(device models.NoahDevicePayload, mode *models.WorkMode, power *float64) bool + SetChargingLimits(device models.NoahDevicePayload, chargingLimit *float64, dischargeLimit *float64) bool } diff --git a/internal/endpoint_mqtt/endpoint_mqtt.go b/internal/endpoint_mqtt/endpoint_mqtt.go index a8cba3f..69f583f 100644 --- a/internal/endpoint_mqtt/endpoint_mqtt.go +++ b/internal/endpoint_mqtt/endpoint_mqtt.go @@ -112,16 +112,12 @@ func (e *Endpoint) parametersSubscription(dev models.NoahDevicePayload) func(cli slog.Error("unable to unmarshal parameter command payload", slog.String("error", err.Error())) } - if payload.DefaultACCouplePower != nil { - e.param_applier.SetOutputPowerW(dev, *payload.DefaultACCouplePower) + if payload.DefaultACCouplePower != nil || payload.DefaultMode != nil { + e.param_applier.SetOutputPowerW(dev, payload.DefaultMode, payload.DefaultACCouplePower) } - if payload.ChargingLimit != nil { - e.param_applier.SetChargingLimit(dev, *payload.ChargingLimit) - } - - if payload.DischargeLimit != nil { - e.param_applier.SetDischargeLimit(dev, *payload.DischargeLimit) + if payload.ChargingLimit != nil || payload.DischargeLimit != nil { + e.param_applier.SetChargingLimits(dev, payload.ChargingLimit, payload.DischargeLimit) } } } diff --git a/internal/growatt_app/client.go b/internal/growatt_app/client.go index c580459..a68a7ed 100644 --- a/internal/growatt_app/client.go +++ b/internal/growatt_app/client.go @@ -173,7 +173,7 @@ func (h *Client) SetSystemOutputPower(serialNumber string, mode int, power float return nil } -func (h *Client) SetSocLimit(serialNumber string, chargingLimit float64, dischargeLimit float64) error { +func (h *Client) SetChargingSoc(serialNumber string, chargingLimit float64, dischargeLimit float64) error { c := math.Max(70, math.Min(100, chargingLimit)) d := math.Max(0, math.Min(30, dischargeLimit)) var data map[string]any diff --git a/internal/growatt_app/payload.go b/internal/growatt_app/payload.go index e965b54..3161a5f 100644 --- a/internal/growatt_app/payload.go +++ b/internal/growatt_app/payload.go @@ -32,11 +32,12 @@ func parameterPayload(n *NexaInfo) models.ParameterPayload { chargingLimit := misc.ParseFloat(n.Obj.Noah.ChargingSocHighLimit) dischargeLimit := misc.ParseFloat(n.Obj.Noah.ChargingSocLowLimit) outputPower := misc.ParseFloat(n.Obj.Noah.DefaultACCouplePower) + defaultMode := models.WorkModeFromString(n.Obj.Noah.DefaultMode) return models.ParameterPayload{ ChargingLimit: &chargingLimit, DischargeLimit: &dischargeLimit, DefaultACCouplePower: &outputPower, - DefaultMode: models.WorkModeFromString(n.Obj.Noah.DefaultMode), + DefaultMode: &defaultMode, } } diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index 3aa8fe3..825a223 100644 --- a/internal/growatt_app/service.go +++ b/internal/growatt_app/service.go @@ -128,66 +128,72 @@ func (g *GrowattAppService) ensureParameterLogin() bool { return true } -func (g *GrowattAppService) SetOutputPowerW(device models.NoahDevicePayload, power float64) bool { - slog.Info("trying to set default power (app)", slog.String("device", device.Serial), slog.Int("power", int(power))) +func (g *GrowattAppService) SetOutputPowerW(device models.NoahDevicePayload, mode *models.WorkMode, power *float64) bool { + slog.Info("trying to set default power (app)", slog.String("device", device.Serial)) if !g.ensureParameterLogin() { slog.Error("unable to set default power (app)", slog.String("device", device.Serial)) return false } - if err := g.client.SetSystemOutputPower(device.Serial, 0, power); err != nil { + + if mode == nil || power == nil { + if data, err := g.client.GetNoahInfo(device.Serial); err != nil { + slog.Error("unable to get parameter status (app)", slog.String("error", err.Error())) + return false + } else { + if mode == nil { + mode = (*models.WorkMode)(&data.Obj.Noah.DefaultMode) + } + if power == nil { + p := misc.ParseFloat(data.Obj.Noah.DefaultACCouplePower) + power = &p + } + } + } + + modeAsInt := models.IntFromWorkMode(*mode) + + slog.Info("trying to set default power (app)", slog.String("device", device.Serial), slog.Int("mode", modeAsInt), slog.Float64("power", *power)) + if err := g.client.SetSystemOutputPower(device.Serial, modeAsInt, *power); err != nil { slog.Error("unable to set default power (app)", slog.String("error", err.Error()), slog.String("device", device.Serial)) return false } else { go g.pollParameterData(device) - slog.Info("set default power (app)", slog.String("device", device.Serial), slog.Int("power", int(power))) + slog.Info("set default power (app)", slog.String("device", device.Serial), slog.Int("mode", modeAsInt), slog.Float64("power", *power)) return true } } -func (g *GrowattAppService) SetChargingLimit(device models.NoahDevicePayload, limit float64) bool { - slog.Info("trying to set charging limit (app)", slog.String("device", device.Serial), slog.Float64("limit", limit)) + +func (g *GrowattAppService) SetChargingLimits(device models.NoahDevicePayload, chargingLimit *float64, dischargeLimit *float64) bool { + slog.Info("trying to set charging limit (app)", slog.String("device", device.Serial)) if !g.ensureParameterLogin() { - slog.Error("unable to set charging limit (app)", slog.String("device", device.Serial)) + slog.Error("unable to set charging limits (app)", slog.String("device", device.Serial)) return false } - if data, err := g.client.GetNoahInfo(device.Serial); err != nil { - slog.Error("unable to get parameter status (app)", slog.String("error", err.Error())) - return false - } else { - dl := misc.ParseFloat(data.Obj.Noah.ChargingSocLowLimit) - slog.Info("trying to set charging limit (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", limit), slog.Float64("dischargeLimit", dl)) - if err := g.client.SetSocLimit(device.Serial, limit, dl); err != nil { - slog.Error("unable to set charging limit (app)", slog.String("error", err.Error())) + if chargingLimit == nil || dischargeLimit == nil { + if data, err := g.client.GetNoahInfo(device.Serial); err != nil { + slog.Error("unable to get parameter status (app)", slog.String("error", err.Error())) return false } else { - go g.pollParameterData(device) - slog.Info("set charging limit (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", limit), slog.Float64("dischargeLimit", dl)) - return true + if chargingLimit == nil { + cl := misc.ParseFloat(data.Obj.Noah.ChargingSocHighLimit) + chargingLimit = &cl + } + if dischargeLimit == nil { + dl := misc.ParseFloat(data.Obj.Noah.ChargingSocLowLimit) + dischargeLimit = &dl + } } } -} -func (g *GrowattAppService) SetDischargeLimit(device models.NoahDevicePayload, limit float64) bool { - slog.Info("trying to set discharge limit (app)", slog.String("device", device.Serial), slog.Float64("limit", limit)) - if !g.ensureParameterLogin() { - slog.Error("unable to set discharge limit (app)", slog.String("device", device.Serial)) - return false - } - if data, err := g.client.GetNoahInfo(device.Serial); err != nil { - slog.Error("unable to get parameter status (app)", slog.String("error", err.Error())) + slog.Info("trying to set charging limit (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", *chargingLimit), slog.Float64("dischargeLimit", *dischargeLimit)) + if err := g.client.SetChargingSoc(device.Serial, *chargingLimit, *dischargeLimit); err != nil { + slog.Error("unable to set charging limits (app)", slog.String("error", err.Error())) return false } else { - cl := misc.ParseFloat(data.Obj.Noah.ChargingSocHighLimit) - - slog.Info("trying to set discharge limit (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", cl), slog.Float64("dischargeLimit", limit)) - if err := g.client.SetSocLimit(device.Serial, cl, limit); err != nil { - slog.Error("unable to set discharge limit (app)", slog.String("error", err.Error())) - return false - } else { - slog.Info("set discharge limit (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", cl), slog.Float64("dischargeLimit", limit)) - go g.pollParameterData(device) - return true - } + go g.pollParameterData(device) + slog.Info("set charging limits (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", *chargingLimit), slog.Float64("dischargeLimit", *dischargeLimit)) + return true } } diff --git a/internal/growatt_web/service.go b/internal/growatt_web/service.go index 56bed55..d92425a 100644 --- a/internal/growatt_web/service.go +++ b/internal/growatt_web/service.go @@ -177,11 +177,12 @@ func (g *GrowattService) pollHistory(device models.NoahDevicePayload) { cl := misc.ParseFloat(detailsData.ChargingSocHighLimit) dl := misc.ParseFloat(detailsData.ChargingSocLowLimit) op := misc.ParseFloat(detailsData.DefaultACCouplePower) + mode := models.WorkModeFromString(detailsData.DefaultMode) paramPayload := models.ParameterPayload{ ChargingLimit: &cl, DischargeLimit: &dl, DefaultACCouplePower: &op, - DefaultMode: models.WorkModeFromString(detailsData.DefaultMode), + DefaultMode: &mode, } for _, e := range g.endpoints { diff --git a/pkg/models/payload.go b/pkg/models/payload.go index 51c78fa..d0cc4c8 100644 --- a/pkg/models/payload.go +++ b/pkg/models/payload.go @@ -45,6 +45,16 @@ func WorkModeFromString(s string) WorkMode { return WorkModeBatteryFirst } +func IntFromWorkMode(s WorkMode) int { + if s == WorkModeLoadFirst { + return 0 + } + if s == WorkModeBatteryFirst { + return 1 + } + return -1 +} + type DevicePayload struct { OutputPower float64 `json:"output_w"` SolarPower float64 `json:"solar_w"` @@ -65,10 +75,10 @@ type BatteryPayload struct { } type ParameterPayload struct { - ChargingLimit *float64 `json:"charging_limit,omitempty"` - DischargeLimit *float64 `json:"discharge_limit,omitempty"` - DefaultACCouplePower *float64 `json:"default_output_w,omitempty"` - DefaultMode WorkMode `json:"default_mode,omitempty"` + ChargingLimit *float64 `json:"charging_limit,omitempty"` + DischargeLimit *float64 `json:"discharge_limit,omitempty"` + DefaultACCouplePower *float64 `json:"default_output_w,omitempty"` + DefaultMode *WorkMode `json:"default_mode,omitempty"` } type NoahDevicePayload struct { From 1c406cb5680429b0bf1593aacd146347b4759a4c Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 20 Jun 2025 14:26:52 +0200 Subject: [PATCH 12/30] Default mode can now be set --- .gitignore | 3 +++ .vscode/launch.json | 25 ++++++++++++++++++ README.md | 8 +++--- build_package.sh | 2 +- internal/growatt_app/service.go | 7 ++++- internal/homeassistant/discovery_numbers.go | 2 +- internal/homeassistant/discovery_selects.go | 29 +++++++++++++++++++++ internal/homeassistant/models.go | 17 ++++++++++++ internal/homeassistant/service.go | 17 +++++++++++- 9 files changed, 103 insertions(+), 7 deletions(-) create mode 100755 .vscode/launch.json mode change 100644 => 100755 build_package.sh create mode 100644 internal/homeassistant/discovery_selects.go diff --git a/.gitignore b/.gitignore index 5d36269..4fdd64d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ go.work.sum .idea dist /noah-mqtt + +build/ +cmd/noah-mqtt/__debug_bin* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100755 index 0000000..ab981af --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch main", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "cmd/noah-mqtt/main.go", + "env": { + "GROWATT_USERNAME": "nexa_user", + "GROWATT_PASSWORD": "secret", + "MQTT_HOST": "localhost", + "GROWATT_API_MODE": "web+app", + "MQTT_TOPIC_PREFIX": "nexa_test", + "POLLING_INTERVAL": "30", + "GROWATT_SERVER_URL_WEB": "http://localhost:8080", + "GROWATT_SERVER_URL_APP": "http://localhost:8081" + } + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 6ef2ad7..4a50fde 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The following MQTT topics are used by `noah-mqtt` to publish data: "generation_total_kwh": 319.8, // total energy generation "generation_today_kwh": 3.1, // engery generation today "work_mode": "load_first", // current work mode: load_first or battery_first - "status": "online" // connectivity status: online or offline + "status": "on_grid" // connectivity status: offline, smart_self_use, fault, on_grid or off_grid } ``` @@ -105,7 +105,8 @@ The following MQTT topics are used by `noah-mqtt` to publish data: { "charging_limit": 100, // battery charging limit in percent, between 70 and 100 "discharge_limit": 9, // battery discharge limit in percent, between 0 and 30 - "default_output_w": 800 // system output power in watts, between 0 and 800 + "default_output_w": 800, // system output power in watts, between 0 and 800 + "default_mode": "load_first" // or battery_first } ``` @@ -121,7 +122,8 @@ You can update the device's parameter settings by posting a message to the follo { "charging_limit": 100, // battery charging limit in percent, between 70 and 100 "discharge_limit": 9, // battery discharge limit in percent, between 0 and 30 - "default_output_w": 800 // system output power in watts, between 0 and 800 + "default_output_w": 800, // system output power in watts, between 0 and 800 + "default_mode": "load_first" // or battery_first } ``` diff --git a/build_package.sh b/build_package.sh old mode 100644 new mode 100755 index 4342884..09eb34b --- a/build_package.sh +++ b/build_package.sh @@ -36,4 +36,4 @@ for arch in $ARCHS; do echo "Creating $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb"; fakeroot dpkg-deb --build $DEB_DIR $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb; rm -rf $DEB_DIR -done \ No newline at end of file +done diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index 825a223..d2c0d3a 100644 --- a/internal/growatt_app/service.go +++ b/internal/growatt_app/service.go @@ -141,7 +141,8 @@ func (g *GrowattAppService) SetOutputPowerW(device models.NoahDevicePayload, mod return false } else { if mode == nil { - mode = (*models.WorkMode)(&data.Obj.Noah.DefaultMode) + m := models.WorkModeFromString(data.Obj.Noah.DefaultMode) + mode = &m } if power == nil { p := misc.ParseFloat(data.Obj.Noah.DefaultACCouplePower) @@ -151,6 +152,10 @@ func (g *GrowattAppService) SetOutputPowerW(device models.NoahDevicePayload, mod } modeAsInt := models.IntFromWorkMode(*mode) + if modeAsInt < 0 { + slog.Error("unable to set default power (app). Invalid mode", slog.String("device", device.Serial), slog.String("mode", (string)(*mode))) + return false + } slog.Info("trying to set default power (app)", slog.String("device", device.Serial), slog.Int("mode", modeAsInt), slog.Float64("power", *power)) if err := g.client.SetSystemOutputPower(device.Serial, modeAsInt, *power); err != nil { diff --git a/internal/homeassistant/discovery_numbers.go b/internal/homeassistant/discovery_numbers.go index 1250450..735d4e7 100644 --- a/internal/homeassistant/discovery_numbers.go +++ b/internal/homeassistant/discovery_numbers.go @@ -8,7 +8,7 @@ func generateNumberDiscoveryPayload(appVersion string, info DeviceInfo) []Number numbers := []Number{ { - Name: "System Output Power", + Name: "Default AC Output Power", UniqueId: fmt.Sprintf("%s_default_output_w", info.SerialNumber), CommandTemplate: "{\"default_output_w\": {{ value }}}", CommandTopic: info.ParameterCommandTopic, diff --git a/internal/homeassistant/discovery_selects.go b/internal/homeassistant/discovery_selects.go new file mode 100644 index 0000000..cc7b94f --- /dev/null +++ b/internal/homeassistant/discovery_selects.go @@ -0,0 +1,29 @@ +package homeassistant + +import ( + "fmt" + "noah-mqtt/pkg/models" +) + +func generateSelectDiscoveryPayload(appVersion string, info DeviceInfo) []Select { + device := generateDevice(info) + origin := generateOrigin(appVersion) + + selects := []Select{ + { + Name: "Default Mode", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "default_mode"), + CommandTemplate: "{\"default_mode\": \"{{ value }}\"}", + CommandTopic: info.ParameterCommandTopic, + Device: device, + Origin: origin, + DeviceClass: DeviceClassEnum, + Options: []string{models.WorkModeLoadFirst, models.WorkModeBatteryFirst}, + StateTopic: info.ParameterStateTopic, + ValueTemplate: "{{ value_json.default_mode }}", + Component: "select", + }, + } + + return selects +} diff --git a/internal/homeassistant/models.go b/internal/homeassistant/models.go index c5bd872..4e2cec0 100644 --- a/internal/homeassistant/models.go +++ b/internal/homeassistant/models.go @@ -67,6 +67,23 @@ type Sensor struct { Options []string `json:"options,omitempty"` } +type Select struct { + Name string `json:"name"` + Icon Icon `json:"icon,omitempty"` + DeviceClass DeviceClass `json:"device_class,omitempty"` + StateTopic string `json:"state_topic"` + StateClass StateClass `json:"state_class,omitempty"` + UnitOfMeasurement Unit `json:"unit_of_measurement,omitempty"` + ValueTemplate string `json:"value_template,omitempty"` + UniqueId string `json:"unique_id,omitempty"` + Device Device `json:"device,omitempty"` + Origin Origin `json:"origin,omitempty"` + Options []string `json:"options,omitempty"` + CommandTemplate string `json:"command_template,omitempty"` + CommandTopic string `json:"command_topic,omitempty"` + Component string `json:"component,omitempty"` +} + type Device struct { Identifiers []string `json:"identifiers,omitempty"` Name string `json:"name,omitempty"` diff --git a/internal/homeassistant/service.go b/internal/homeassistant/service.go index eec6e29..af4f12d 100644 --- a/internal/homeassistant/service.go +++ b/internal/homeassistant/service.go @@ -3,10 +3,11 @@ package homeassistant import ( "encoding/json" "fmt" - mqtt "github.com/eclipse/paho.mqtt.golang" "log/slog" "strings" "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" ) type Options struct { @@ -61,6 +62,16 @@ func (s *Service) sendDiscovery() { } } + selects := generateSelectDiscoveryPayload(s.options.Version, d) + for _, sel := range selects { + if b, err := json.Marshal(sel); err != nil { + slog.Error("could not marshal select discovery payload", slog.Any("select", sel)) + } else { + topic := s.selectTopic(sel) + s.options.MqttClient.Publish(topic, 0, false, string(b)) + } + } + numbers := generateNumberDiscoveryPayload(s.options.Version, d) for _, number := range numbers { if b, err := json.Marshal(number); err != nil { @@ -87,6 +98,10 @@ func (s *Service) sensorTopic(sensor Sensor) string { return fmt.Sprintf("%s/sensor/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("noah_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) } +func (s *Service) selectTopic(sensor Select) string { + return fmt.Sprintf("%s/select/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("noah_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) +} + func (s *Service) binarySensorTopic(sensor BinarySensor) string { return fmt.Sprintf("%s/binary_sensor/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("noah_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) } From 72c89d6b4e8a194b98aacdac34a485ca71332689 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 23 Jun 2025 15:44:42 +0200 Subject: [PATCH 13/30] Refactored Home Assistant discovery --- .../homeassistant/discovery_binarysensors.go | 48 ++-- internal/homeassistant/discovery_numbers.go | 74 +++--- internal/homeassistant/discovery_selects.go | 28 ++- internal/homeassistant/discovery_sensors.go | 236 +++++++++++------- internal/homeassistant/models.go | 119 +++++---- 5 files changed, 293 insertions(+), 212 deletions(-) diff --git a/internal/homeassistant/discovery_binarysensors.go b/internal/homeassistant/discovery_binarysensors.go index 15eda8f..97e7fd2 100644 --- a/internal/homeassistant/discovery_binarysensors.go +++ b/internal/homeassistant/discovery_binarysensors.go @@ -11,28 +11,36 @@ func generateBinarySensorDiscoveryPayload(appVersion string, info DeviceInfo) [] binarySensors := []BinarySensor{ { - Name: "Connectivity", - Icon: "", - DeviceClass: DeviceClassConnectivity, - ValueTemplate: fmt.Sprintf("{{ 'offline' if value_json.status == '%s' else 'online' }}", models.Offline), - PayloadOff: "offline", - PayloadOn: "online", - UniqueId: fmt.Sprintf("%s_connectivity", info.SerialNumber), - StateTopic: info.StateTopic, - Device: device, - Origin: origin, + CommonConfig: CommonConfig{ + Name: "Connectivity", + UniqueId: fmt.Sprintf("%s_connectivity", info.SerialNumber), + Icon: "", + DeviceClass: DeviceClassConnectivity, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: fmt.Sprintf("{{ 'offline' if value_json.status == '%s' else 'online' }}", models.Offline), + }, + PayloadOff: "offline", + PayloadOn: "online", }, { - Name: "Heating", - Icon: IconHeatWave, - DeviceClass: DeviceClassNone, - ValueTemplate: fmt.Sprintf("{{ 'heating' if value_json.status == '%s' else 'not-heating' }}", models.Heating), - PayloadOff: "not-heating", - PayloadOn: "heating", - UniqueId: fmt.Sprintf("%s_heating", info.SerialNumber), - StateTopic: info.StateTopic, - Device: device, - Origin: origin, + CommonConfig: CommonConfig{ + Name: "Heating", + UniqueId: fmt.Sprintf("%s_heating", info.SerialNumber), + Icon: IconHeatWave, + DeviceClass: DeviceClassNone, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: fmt.Sprintf("{{ 'heating' if value_json.status == '%s' else 'not-heating' }}", models.Heating), + }, + PayloadOff: "not-heating", + PayloadOn: "heating", }, } diff --git a/internal/homeassistant/discovery_numbers.go b/internal/homeassistant/discovery_numbers.go index 735d4e7..b6cede3 100644 --- a/internal/homeassistant/discovery_numbers.go +++ b/internal/homeassistant/discovery_numbers.go @@ -8,56 +8,74 @@ func generateNumberDiscoveryPayload(appVersion string, info DeviceInfo) []Number numbers := []Number{ { - Name: "Default AC Output Power", - UniqueId: fmt.Sprintf("%s_default_output_w", info.SerialNumber), - CommandTemplate: "{\"default_output_w\": {{ value }}}", - CommandTopic: info.ParameterCommandTopic, - Device: device, - Origin: origin, - Icon: "", - DeviceClass: DeviceClassPower, - StateTopic: info.ParameterStateTopic, + CommonConfig: CommonConfig{ + Name: "Default AC Output Power", + UniqueId: fmt.Sprintf("%s_default_output_w", info.SerialNumber), + Icon: "", + DeviceClass: DeviceClassPower, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.ParameterStateTopic, + ValueTemplate: "{{ value_json.default_output_w }}", + }, + CommandConfig: CommandConfig{ + CommandTopic: info.ParameterCommandTopic, + CommandTemplate: "{\"default_output_w\": {{ value }}}", + }, StateClass: StateClassMeasurement, Mode: ModeSlider, Step: 1, Min: 0, Max: 800, UnitOfMeasurement: UnitWatt, - ValueTemplate: "{{ value_json.default_output_w }}", }, { - Name: "Charging Limit", - UniqueId: fmt.Sprintf("%s_charging_limit", info.SerialNumber), - CommandTemplate: "{\"charging_limit\": {{ value }}}", - CommandTopic: info.ParameterCommandTopic, - Device: device, - Origin: origin, - Icon: IconBatteryArrowUpOutline, - StateTopic: info.ParameterStateTopic, + CommonConfig: CommonConfig{ + Name: "Charging Limit", + UniqueId: fmt.Sprintf("%s_charging_limit", info.SerialNumber), + Icon: IconBatteryArrowUpOutline, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.ParameterStateTopic, + ValueTemplate: "{{ value_json.charging_limit }}", + }, + CommandConfig: CommandConfig{ + CommandTopic: info.ParameterCommandTopic, + CommandTemplate: "{\"charging_limit\": {{ value }}}", + }, StateClass: StateClassMeasurement, Mode: ModeSlider, Step: 1, Min: 70, Max: 100, UnitOfMeasurement: UnitPercent, - ValueTemplate: "{{ value_json.charging_limit }}", }, { - Name: "Discharge Limit", - UniqueId: fmt.Sprintf("%s_discharge_limit", info.SerialNumber), - CommandTemplate: "{\"discharge_limit\": {{ value }}}", - CommandTopic: info.ParameterCommandTopic, - Device: device, - Origin: origin, - Icon: IconBatteryArrowDownOutline, - StateTopic: info.ParameterStateTopic, + CommonConfig: CommonConfig{ + Name: "Discharge Limit", + UniqueId: fmt.Sprintf("%s_discharge_limit", info.SerialNumber), + Icon: IconBatteryArrowDownOutline, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.ParameterStateTopic, + ValueTemplate: "{{ value_json.discharge_limit }}", + }, + CommandConfig: CommandConfig{ + CommandTopic: info.ParameterCommandTopic, + CommandTemplate: "{\"discharge_limit\": {{ value }}}", + }, StateClass: StateClassMeasurement, Mode: ModeSlider, Step: 1, Min: 0, Max: 30, UnitOfMeasurement: UnitPercent, - ValueTemplate: "{{ value_json.discharge_limit }}", }, } diff --git a/internal/homeassistant/discovery_selects.go b/internal/homeassistant/discovery_selects.go index cc7b94f..f0be8e3 100644 --- a/internal/homeassistant/discovery_selects.go +++ b/internal/homeassistant/discovery_selects.go @@ -11,17 +11,23 @@ func generateSelectDiscoveryPayload(appVersion string, info DeviceInfo) []Select selects := []Select{ { - Name: "Default Mode", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "default_mode"), - CommandTemplate: "{\"default_mode\": \"{{ value }}\"}", - CommandTopic: info.ParameterCommandTopic, - Device: device, - Origin: origin, - DeviceClass: DeviceClassEnum, - Options: []string{models.WorkModeLoadFirst, models.WorkModeBatteryFirst}, - StateTopic: info.ParameterStateTopic, - ValueTemplate: "{{ value_json.default_mode }}", - Component: "select", + CommonConfig: CommonConfig{ + Name: "Default Mode", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "default_mode"), + DeviceClass: DeviceClassEnum, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.ParameterStateTopic, + ValueTemplate: "{{ value_json.default_mode }}", + }, + CommandConfig: CommandConfig{ + CommandTopic: info.ParameterCommandTopic, + CommandTemplate: "{\"default_mode\": \"{{ value }}\"}", + }, + Options: []string{models.WorkModeLoadFirst, models.WorkModeBatteryFirst}, + Component: "select", }, } diff --git a/internal/homeassistant/discovery_sensors.go b/internal/homeassistant/discovery_sensors.go index a35fe30..e66d26b 100644 --- a/internal/homeassistant/discovery_sensors.go +++ b/internal/homeassistant/discovery_sensors.go @@ -11,140 +11,196 @@ func generateSensorDiscoveryPayload(appVersion string, info DeviceInfo) []Sensor sensors := []Sensor{ { - Name: "Output Power", - DeviceClass: DeviceClassPower, + CommonConfig: CommonConfig{ + Name: "Output Power", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "output_power"), + DeviceClass: DeviceClassPower, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.output_w }}", + }, StateClass: StateClassMeasurement, - StateTopic: info.StateTopic, UnitOfMeasurement: UnitWatt, - ValueTemplate: "{{ value_json.output_w }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "output_power"), - Device: device, - Origin: origin, }, { - Name: "Solar Power", - Icon: IconSolarPower, - DeviceClass: DeviceClassPower, + CommonConfig: CommonConfig{ + Name: "Solar Power", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "solar_power"), + Icon: IconSolarPower, + DeviceClass: DeviceClassPower, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.solar_w }}", + }, StateClass: StateClassMeasurement, - StateTopic: info.StateTopic, UnitOfMeasurement: UnitWatt, - ValueTemplate: "{{ value_json.solar_w }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "solar_power"), - Device: device, - Origin: origin, }, { - Name: "Charging Power", - Icon: IconBatteryPlus, - DeviceClass: DeviceClassPower, + CommonConfig: CommonConfig{ + Name: "Charging Power", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "charging_power"), + Icon: IconBatteryPlus, + DeviceClass: DeviceClassPower, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.charge_w }}", + }, StateClass: StateClassMeasurement, - StateTopic: info.StateTopic, UnitOfMeasurement: UnitWatt, - ValueTemplate: "{{ value_json.charge_w }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "charging_power"), - Device: device, - Origin: origin, }, { - Name: "Discharge Power", - Icon: IconBatteryMinus, - DeviceClass: DeviceClassPower, + CommonConfig: CommonConfig{ + Name: "Discharge Power", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "discharge_power"), + Icon: IconBatteryMinus, + DeviceClass: DeviceClassPower, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.discharge_w }}", + }, StateClass: StateClassMeasurement, - StateTopic: info.StateTopic, UnitOfMeasurement: UnitWatt, - ValueTemplate: "{{ value_json.discharge_w }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "discharge_power"), - Device: device, - Origin: origin, }, { - Name: "Generation Total", - DeviceClass: DeviceClassEnergy, + CommonConfig: CommonConfig{ + Name: "Generation Total", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "generation_total"), + DeviceClass: DeviceClassEnergy, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.generation_total_kwh }}", + }, StateClass: StateClassTotalIncreasing, - StateTopic: info.StateTopic, UnitOfMeasurement: UnitKilowattHours, - ValueTemplate: "{{ value_json.generation_total_kwh }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "generation_total"), - Device: device, - Origin: origin, }, { - Name: "Generation Today", - DeviceClass: DeviceClassEnergy, + CommonConfig: CommonConfig{ + Name: "Generation Today", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "generation_today"), + DeviceClass: DeviceClassEnergy, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.generation_today_kwh }}", + }, StateClass: StateClassTotalIncreasing, - StateTopic: info.StateTopic, UnitOfMeasurement: UnitKilowattHours, - ValueTemplate: "{{ value_json.generation_today_kwh }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "generation_today"), - Device: device, - Origin: origin, }, { - Name: "SoC", - DeviceClass: DeviceClassBattery, + CommonConfig: CommonConfig{ + Name: "SoC", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "soc"), + DeviceClass: DeviceClassBattery, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.soc }}", + }, StateClass: StateClassMeasurement, - StateTopic: info.StateTopic, UnitOfMeasurement: UnitPercent, - ValueTemplate: "{{ value_json.soc }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "soc"), - Device: device, - Origin: origin, }, { - Name: "Number Of Batteries", - StateClass: StateClassMeasurement, - StateTopic: info.StateTopic, - Icon: IconCarBattery, - ValueTemplate: "{{ value_json.battery_num }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "battery_num"), - Device: device, - Origin: origin, + CommonConfig: CommonConfig{ + Name: "Number Of Batteries", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "battery_num"), + Icon: IconCarBattery, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.battery_num }}", + }, + StateClass: StateClassMeasurement, }, { - Name: "Working Mode", - DeviceClass: DeviceClassEnum, - Options: []string{models.WorkModeLoadFirst, models.WorkModeBatteryFirst}, - StateTopic: info.StateTopic, - ValueTemplate: "{{ value_json.work_mode }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "work_mode"), - Device: device, - Origin: origin, + CommonConfig: CommonConfig{ + Name: "Working Mode", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "work_mode"), + DeviceClass: DeviceClassEnum, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.work_mode }}", + }, + Options: []string{models.WorkModeLoadFirst, models.WorkModeBatteryFirst}, }, { - Name: "Status", - DeviceClass: DeviceClassEnum, - Options: []string{models.Offline, models.WorkModeLoadFirst, models.WorkModeBatteryFirst, models.SmartSelfUse, models.Fault, models.Heating, models.OnGrid, models.OffGrid}, - StateTopic: info.StateTopic, - ValueTemplate: "{{ value_json.status }}", - UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "status"), - Device: device, - Origin: origin, + CommonConfig: CommonConfig{ + Name: "Status", + UniqueId: fmt.Sprintf("%s_%s", info.SerialNumber, "status"), + DeviceClass: DeviceClassEnum, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: info.StateTopic, + ValueTemplate: "{{ value_json.status }}", + }, + Options: []string{ + models.Offline, + models.WorkModeLoadFirst, + models.WorkModeBatteryFirst, + models.SmartSelfUse, + models.Fault, + models.Heating, + models.OnGrid, + models.OffGrid}, }, } for _, b := range info.Batteries { sensors = append(sensors, []Sensor{ { - Name: fmt.Sprintf("%s SoC", b.Alias), - DeviceClass: DeviceClassBattery, + CommonConfig: CommonConfig{ + Name: fmt.Sprintf("%s SoC", b.Alias), + UniqueId: fmt.Sprintf("%s_%s_%s", info.SerialNumber, b.Alias, "soc"), + DeviceClass: DeviceClassBattery, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: b.StateTopic, + ValueTemplate: "{{ value_json.soc }}", + }, StateClass: StateClassMeasurement, - StateTopic: b.StateTopic, UnitOfMeasurement: UnitPercent, - ValueTemplate: "{{ value_json.soc }}", - UniqueId: fmt.Sprintf("%s_%s_%s", info.SerialNumber, b.Alias, "soc"), - Device: device, - Origin: origin, }, { - Name: fmt.Sprintf("%s Temperature", b.Alias), - DeviceClass: DeviceClassTemperature, + CommonConfig: CommonConfig{ + Name: fmt.Sprintf("%s Temperature", b.Alias), + UniqueId: fmt.Sprintf("%s_%s_%s", info.SerialNumber, b.Alias, "temp"), + DeviceClass: DeviceClassTemperature, + Device: device, + Origin: origin, + }, + StateConfig: StateConfig{ + StateTopic: b.StateTopic, + ValueTemplate: "{{ value_json.temp }}", + }, StateClass: StateClassMeasurement, - StateTopic: b.StateTopic, UnitOfMeasurement: UnitCelsius, - ValueTemplate: "{{ value_json.temp }}", - UniqueId: fmt.Sprintf("%s_%s_%s", info.SerialNumber, b.Alias, "temp"), - Device: device, - Origin: origin, }, }...) } diff --git a/internal/homeassistant/models.go b/internal/homeassistant/models.go index 4e2cec0..6223c1f 100644 --- a/internal/homeassistant/models.go +++ b/internal/homeassistant/models.go @@ -40,50 +40,6 @@ const ( IconBatteryArrowDownOutline Icon = "mdi:battery-arrow-down-outline" ) -type BinarySensor struct { - Name string `json:"name"` - Icon Icon `json:"icon,omitempty"` - DeviceClass DeviceClass `json:"device_class,omitempty"` - ValueTemplate string `json:"value_template,omitempty"` - UniqueId string `json:"unique_id,omitempty"` - PayloadOff string `json:"payload_off,omitempty"` - PayloadOn string `json:"payload_on,omitempty"` - StateTopic string `json:"state_topic"` - Device Device `json:"device,omitempty"` - Origin Origin `json:"origin,omitempty"` -} - -type Sensor struct { - Name string `json:"name"` - Icon Icon `json:"icon,omitempty"` - DeviceClass DeviceClass `json:"device_class,omitempty"` - StateTopic string `json:"state_topic"` - StateClass StateClass `json:"state_class,omitempty"` - UnitOfMeasurement Unit `json:"unit_of_measurement,omitempty"` - ValueTemplate string `json:"value_template,omitempty"` - UniqueId string `json:"unique_id,omitempty"` - Device Device `json:"device,omitempty"` - Origin Origin `json:"origin,omitempty"` - Options []string `json:"options,omitempty"` -} - -type Select struct { - Name string `json:"name"` - Icon Icon `json:"icon,omitempty"` - DeviceClass DeviceClass `json:"device_class,omitempty"` - StateTopic string `json:"state_topic"` - StateClass StateClass `json:"state_class,omitempty"` - UnitOfMeasurement Unit `json:"unit_of_measurement,omitempty"` - ValueTemplate string `json:"value_template,omitempty"` - UniqueId string `json:"unique_id,omitempty"` - Device Device `json:"device,omitempty"` - Origin Origin `json:"origin,omitempty"` - Options []string `json:"options,omitempty"` - CommandTemplate string `json:"command_template,omitempty"` - CommandTopic string `json:"command_topic,omitempty"` - Component string `json:"component,omitempty"` -} - type Device struct { Identifiers []string `json:"identifiers,omitempty"` Name string `json:"name,omitempty"` @@ -100,28 +56,65 @@ type Origin struct { SupportUrl string `json:"support_url,omitempty"` } -type Number struct { - Name string `json:"name"` - UniqueId string `json:"unique_id,omitempty"` - CommandTemplate string `json:"command_template,omitempty"` - CommandTopic string `json:"command_topic"` - Device Device `json:"device,omitempty"` - Origin Origin `json:"origin,omitempty"` - Icon Icon `json:"icon,omitempty"` - DeviceClass DeviceClass `json:"device_class,omitempty"` - StateTopic string `json:"state_topic"` - StateClass StateClass `json:"state_class,omitempty"` - Mode Mode `json:"mode,omitempty"` - Step float64 `json:"step,omitempty"` - Min float64 `json:"min,omitempty"` - Max float64 `json:"max,omitempty"` - UnitOfMeasurement Unit `json:"unit_of_measurement,omitempty"` - ValueTemplate string `json:"value_template,omitempty"` -} - type Mode string const ( ModeBox Mode = "box" ModeSlider Mode = "slider" ) + +type CommonConfig struct { + Name string `json:"name"` + UniqueId string `json:"unique_id,omitempty"` + Icon Icon `json:"icon,omitempty"` + DeviceClass DeviceClass `json:"device_class,omitempty"` + Device Device `json:"device,omitempty"` + Origin Origin `json:"origin,omitempty"` +} + +type StateConfig struct { + StateTopic string `json:"state_topic"` + ValueTemplate string `json:"value_template,omitempty"` +} + +type CommandConfig struct { + CommandTopic string `json:"command_topic,omitempty"` + CommandTemplate string `json:"command_template,omitempty"` +} + +type BinarySensor struct { + CommonConfig + StateConfig + PayloadOff string `json:"payload_off,omitempty"` + PayloadOn string `json:"payload_on,omitempty"` +} + +type Sensor struct { + CommonConfig + StateConfig + StateClass StateClass `json:"state_class,omitempty"` + UnitOfMeasurement Unit `json:"unit_of_measurement,omitempty"` + Options []string `json:"options,omitempty"` +} + +type Select struct { + CommonConfig + StateConfig + CommandConfig + StateClass StateClass `json:"state_class,omitempty"` + UnitOfMeasurement Unit `json:"unit_of_measurement,omitempty"` + Options []string `json:"options,omitempty"` + Component string `json:"component,omitempty"` +} + +type Number struct { + CommonConfig + StateConfig + CommandConfig + StateClass StateClass `json:"state_class,omitempty"` + UnitOfMeasurement Unit `json:"unit_of_measurement,omitempty"` + Mode Mode `json:"mode,omitempty"` + Step float64 `json:"step,omitempty"` + Min float64 `json:"min,omitempty"` + Max float64 `json:"max,omitempty"` +} From d8256c2819462ec0df879bd58c9d94f051af91e9 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 23 Jun 2025 16:45:56 +0200 Subject: [PATCH 14/30] Rename 'noah' to 'nexa' --- internal/config/config.go | 2 +- internal/growatt_app/service.go | 4 ++-- internal/homeassistant/discovery_base.go | 6 +++--- internal/homeassistant/service.go | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index eff0972..55f5052 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,7 +59,7 @@ func Get() Config { Mqtt: Mqtt{ Host: getEnv("MQTT_HOST", ""), Port: s2i(getEnv("MQTT_PORT", "1883")), - ClientId: getEnv("MQTT_CLIENT_ID", "noah-mqtt"), + ClientId: getEnv("MQTT_CLIENT_ID", "nexa-mqtt"), Username: getEnv("MQTT_USERNAME", ""), Password: getEnv("MQTT_PASSWORD", ""), TopicPrefix: getEnv("MQTT_TOPIC_PREFIX", "noah2mqtt"), diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index d2c0d3a..ad7a8f3 100644 --- a/internal/growatt_app/service.go +++ b/internal/growatt_app/service.go @@ -76,7 +76,7 @@ func (g *GrowattAppService) fetchDevices() []models.NoahDevicePayload { } if len(devices) == 0 { - slog.Info("no noah devices found") + slog.Info("no nexa devices found") <-time.After(60 * time.Second) os.Exit(0) } @@ -89,7 +89,7 @@ func (g *GrowattAppService) enumerateDevices() { for i, device := range devices { if data, err := g.client.GetNoahInfo(device.Serial); err != nil { - slog.Error("could not noah status", slog.String("error", err.Error()), slog.String("serialNumber", device.Serial)) + slog.Error("could not get nexa status", slog.String("error", err.Error()), slog.String("serialNumber", device.Serial)) } else { batCount := len(data.Obj.Noah.BatSns) var batteries []models.NoahDeviceBatteryPayload diff --git a/internal/homeassistant/discovery_base.go b/internal/homeassistant/discovery_base.go index 00c0917..ee06cde 100644 --- a/internal/homeassistant/discovery_base.go +++ b/internal/homeassistant/discovery_base.go @@ -4,7 +4,7 @@ import "fmt" func generateDevice(info DeviceInfo) Device { return Device{ - Identifiers: []string{fmt.Sprintf("noah_%s", info.SerialNumber)}, + Identifiers: []string{fmt.Sprintf("nexa_%s", info.SerialNumber)}, Name: info.Alias, Manufacturer: "Growatt", SwVersion: info.Version, @@ -15,8 +15,8 @@ func generateDevice(info DeviceInfo) Device { func generateOrigin(appVersion string) Origin { return Origin{ - Name: "noah-mqtt", + Name: "nexa-mqtt", SwVersion: appVersion, - SupportUrl: "https://github.com/mtrossbach/noah-mqtt", + SupportUrl: "https://github.com/mgerczuk/nexa-mqtt", } } diff --git a/internal/homeassistant/service.go b/internal/homeassistant/service.go index af4f12d..2bd6407 100644 --- a/internal/homeassistant/service.go +++ b/internal/homeassistant/service.go @@ -95,18 +95,18 @@ func (s *Service) sendDiscovery() { } func (s *Service) sensorTopic(sensor Sensor) string { - return fmt.Sprintf("%s/sensor/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("noah_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) + return fmt.Sprintf("%s/sensor/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("nexa_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) } func (s *Service) selectTopic(sensor Select) string { - return fmt.Sprintf("%s/select/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("noah_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) + return fmt.Sprintf("%s/select/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("nexa_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) } func (s *Service) binarySensorTopic(sensor BinarySensor) string { - return fmt.Sprintf("%s/binary_sensor/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("noah_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) + return fmt.Sprintf("%s/binary_sensor/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("nexa_%s", sensor.Device.SerialNumber), strings.ReplaceAll(sensor.Name, " ", "")) } func (s *Service) numberTopic(number Number) string { - return fmt.Sprintf("%s/number/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("noah_%s", number.Device.SerialNumber), strings.ReplaceAll(number.Name, " ", "")) + return fmt.Sprintf("%s/number/%s/%s/config", s.options.TopicPrefix, fmt.Sprintf("nexa_%s", number.Device.SerialNumber), strings.ReplaceAll(number.Name, " ", "")) } From 5249ccdd34a209dd675e7561029712bc78ee8b29 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 23 Jun 2025 17:04:02 +0200 Subject: [PATCH 15/30] Rename module --- .gitattributes | 1 + cmd/noah-mqtt/main.go | 14 +++++++------- go.mod | 2 +- internal/endpoint/endpoint.go | 2 +- internal/endpoint/parameter_applier.go | 2 +- internal/endpoint_mqtt/endpoint_mqtt.go | 6 +++--- internal/growatt_app/client.go | 2 +- internal/growatt_app/client_http.go | 2 +- internal/growatt_app/payload.go | 4 ++-- internal/growatt_app/service.go | 6 +++--- internal/growatt_app/service_polling.go | 2 +- internal/growatt_web/client.go | 2 +- internal/growatt_web/client_http.go | 2 +- internal/growatt_web/service.go | 6 +++--- internal/homeassistant/discovery_binarysensors.go | 2 +- internal/homeassistant/discovery_selects.go | 2 +- internal/homeassistant/discovery_sensors.go | 2 +- 17 files changed, 30 insertions(+), 29 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/cmd/noah-mqtt/main.go b/cmd/noah-mqtt/main.go index 46079c1..1c2c13f 100644 --- a/cmd/noah-mqtt/main.go +++ b/cmd/noah-mqtt/main.go @@ -3,13 +3,13 @@ package main import ( "fmt" "log/slog" - "noah-mqtt/internal/config" - "noah-mqtt/internal/endpoint_mqtt" - "noah-mqtt/internal/growatt_app" - "noah-mqtt/internal/growatt_web" - "noah-mqtt/internal/homeassistant" - "noah-mqtt/internal/logging" - "noah-mqtt/internal/misc" + "nexa-mqtt/internal/config" + "nexa-mqtt/internal/endpoint_mqtt" + "nexa-mqtt/internal/growatt_app" + "nexa-mqtt/internal/growatt_web" + "nexa-mqtt/internal/homeassistant" + "nexa-mqtt/internal/logging" + "nexa-mqtt/internal/misc" "os" "os/signal" "os/user" diff --git a/go.mod b/go.mod index 8801766..1084a73 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module noah-mqtt +module nexa-mqtt go 1.24.0 diff --git a/internal/endpoint/endpoint.go b/internal/endpoint/endpoint.go index 07583e5..aeaa506 100644 --- a/internal/endpoint/endpoint.go +++ b/internal/endpoint/endpoint.go @@ -1,6 +1,6 @@ package endpoint -import "noah-mqtt/pkg/models" +import "nexa-mqtt/pkg/models" type Endpoint interface { SetParameterApplier(applier ParameterApplier) diff --git a/internal/endpoint/parameter_applier.go b/internal/endpoint/parameter_applier.go index b27ec68..0079865 100644 --- a/internal/endpoint/parameter_applier.go +++ b/internal/endpoint/parameter_applier.go @@ -1,6 +1,6 @@ package endpoint -import "noah-mqtt/pkg/models" +import "nexa-mqtt/pkg/models" type ParameterApplier interface { SetOutputPowerW(device models.NoahDevicePayload, mode *models.WorkMode, power *float64) bool diff --git a/internal/endpoint_mqtt/endpoint_mqtt.go b/internal/endpoint_mqtt/endpoint_mqtt.go index 69f583f..ef16169 100644 --- a/internal/endpoint_mqtt/endpoint_mqtt.go +++ b/internal/endpoint_mqtt/endpoint_mqtt.go @@ -4,9 +4,9 @@ import ( "encoding/json" "fmt" "log/slog" - "noah-mqtt/internal/endpoint" - "noah-mqtt/internal/homeassistant" - "noah-mqtt/pkg/models" + "nexa-mqtt/internal/endpoint" + "nexa-mqtt/internal/homeassistant" + "nexa-mqtt/pkg/models" mqtt "github.com/eclipse/paho.mqtt.golang" ) diff --git a/internal/growatt_app/client.go b/internal/growatt_app/client.go index a68a7ed..0682032 100644 --- a/internal/growatt_app/client.go +++ b/internal/growatt_app/client.go @@ -8,7 +8,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" - "noah-mqtt/internal/misc" + "nexa-mqtt/internal/misc" "time" "github.com/google/uuid" diff --git a/internal/growatt_app/client_http.go b/internal/growatt_app/client_http.go index ff66c91..13ee2b3 100644 --- a/internal/growatt_app/client_http.go +++ b/internal/growatt_app/client_http.go @@ -7,7 +7,7 @@ import ( "log/slog" "net/http" "net/url" - "noah-mqtt/internal/misc" + "nexa-mqtt/internal/misc" "strings" ) diff --git a/internal/growatt_app/payload.go b/internal/growatt_app/payload.go index 3161a5f..bd1c99a 100644 --- a/internal/growatt_app/payload.go +++ b/internal/growatt_app/payload.go @@ -1,8 +1,8 @@ package growatt_app import ( - "noah-mqtt/internal/misc" - "noah-mqtt/pkg/models" + "nexa-mqtt/internal/misc" + "nexa-mqtt/pkg/models" ) func devicePayload(n *NoahStatus) models.DevicePayload { diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index ad7a8f3..d4db4c3 100644 --- a/internal/growatt_app/service.go +++ b/internal/growatt_app/service.go @@ -3,9 +3,9 @@ package growatt_app import ( "fmt" "log/slog" - "noah-mqtt/internal/endpoint" - "noah-mqtt/internal/misc" - "noah-mqtt/pkg/models" + "nexa-mqtt/internal/endpoint" + "nexa-mqtt/internal/misc" + "nexa-mqtt/pkg/models" "os" "time" ) diff --git a/internal/growatt_app/service_polling.go b/internal/growatt_app/service_polling.go index 835621e..829bd52 100644 --- a/internal/growatt_app/service_polling.go +++ b/internal/growatt_app/service_polling.go @@ -2,7 +2,7 @@ package growatt_app import ( "log/slog" - "noah-mqtt/pkg/models" + "nexa-mqtt/pkg/models" ) func (g *GrowattAppService) pollStatus(device models.NoahDevicePayload) { diff --git a/internal/growatt_web/client.go b/internal/growatt_web/client.go index f9e390c..b0fc8f7 100644 --- a/internal/growatt_web/client.go +++ b/internal/growatt_web/client.go @@ -6,7 +6,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" - "noah-mqtt/internal/misc" + "nexa-mqtt/internal/misc" "time" ) diff --git a/internal/growatt_web/client_http.go b/internal/growatt_web/client_http.go index 722e718..d793f01 100644 --- a/internal/growatt_web/client_http.go +++ b/internal/growatt_web/client_http.go @@ -7,7 +7,7 @@ import ( "log/slog" "net/http" "net/url" - "noah-mqtt/internal/misc" + "nexa-mqtt/internal/misc" "strings" ) diff --git a/internal/growatt_web/service.go b/internal/growatt_web/service.go index d92425a..1451c3c 100644 --- a/internal/growatt_web/service.go +++ b/internal/growatt_web/service.go @@ -3,9 +3,9 @@ package growatt_web import ( "fmt" "log/slog" - "noah-mqtt/internal/endpoint" - "noah-mqtt/internal/misc" - "noah-mqtt/pkg/models" + "nexa-mqtt/internal/endpoint" + "nexa-mqtt/internal/misc" + "nexa-mqtt/pkg/models" "time" ) diff --git a/internal/homeassistant/discovery_binarysensors.go b/internal/homeassistant/discovery_binarysensors.go index 97e7fd2..b5d22ab 100644 --- a/internal/homeassistant/discovery_binarysensors.go +++ b/internal/homeassistant/discovery_binarysensors.go @@ -2,7 +2,7 @@ package homeassistant import ( "fmt" - "noah-mqtt/pkg/models" + "nexa-mqtt/pkg/models" ) func generateBinarySensorDiscoveryPayload(appVersion string, info DeviceInfo) []BinarySensor { diff --git a/internal/homeassistant/discovery_selects.go b/internal/homeassistant/discovery_selects.go index f0be8e3..f211e3b 100644 --- a/internal/homeassistant/discovery_selects.go +++ b/internal/homeassistant/discovery_selects.go @@ -2,7 +2,7 @@ package homeassistant import ( "fmt" - "noah-mqtt/pkg/models" + "nexa-mqtt/pkg/models" ) func generateSelectDiscoveryPayload(appVersion string, info DeviceInfo) []Select { diff --git a/internal/homeassistant/discovery_sensors.go b/internal/homeassistant/discovery_sensors.go index e66d26b..db4cadb 100644 --- a/internal/homeassistant/discovery_sensors.go +++ b/internal/homeassistant/discovery_sensors.go @@ -2,7 +2,7 @@ package homeassistant import ( "fmt" - "noah-mqtt/pkg/models" + "nexa-mqtt/pkg/models" ) func generateSensorDiscoveryPayload(appVersion string, info DeviceInfo) []Sensor { From 6af9644286a1afa4175ed2dca9f25f10370a96b8 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 23 Jun 2025 17:04:53 +0200 Subject: [PATCH 16/30] Renamed main path --- cmd/{noah-mqtt => nexa-mqtt}/main.go | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cmd/{noah-mqtt => nexa-mqtt}/main.go (100%) diff --git a/cmd/noah-mqtt/main.go b/cmd/nexa-mqtt/main.go similarity index 100% rename from cmd/noah-mqtt/main.go rename to cmd/nexa-mqtt/main.go From ff72bf0f3889dd34faf5bbf743e7f987369ae3ba Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 24 Jun 2025 09:46:49 +0200 Subject: [PATCH 17/30] More renames --- .gitignore | 4 +- .vscode/launch.json | 2 +- README.md | 100 ++++++++++++++++++-------------------- build_package.sh | 2 +- cmd/nexa-mqtt/main.go | 2 +- internal/config/config.go | 2 +- 6 files changed, 53 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 4fdd64d..fa1e1fa 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ go.work go.work.sum .idea dist -/noah-mqtt +/nexa-mqtt build/ -cmd/noah-mqtt/__debug_bin* \ No newline at end of file +cmd/nexa-mqtt/__debug_bin* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index ab981af..38fd763 100755 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "go", "request": "launch", "mode": "debug", - "program": "cmd/noah-mqtt/main.go", + "program": "cmd/nexa-mqtt/main.go", "env": { "GROWATT_USERNAME": "nexa_user", "GROWATT_PASSWORD": "secret", diff --git a/README.md b/README.md index 4a50fde..e041827 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,7 @@ -> [!IMPORTANT] -> TLDR; 🎉 noah-mqtt has been updated to (hopefully!) mitigate Growatt IP bans for most users with v0.0.29! A new `web` API mode using Growatt's website APIs is now available via the `GROWATT_API_MODE` configuration parameter. The default mode is `web+app` (web API for data, app API for parameters). The default fetch frequency has also been increased to 30 seconds. The default fetch frequency for detail data has been increased to 180 seconds. +# nexa-mqtt +![License](https://img.shields.io/github/license/mgerczuk/nexa-mqtt) ![GitHub last commit](https://img.shields.io/github/last-commit/mgerczuk/nexa-mqtt) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/mgerczuk/nexa-mqtt) -> [!NOTE] -> Previously, noah-mqtt relied solely on Growatt's App APIs for data retrieval and parameter settings. Growatt has implemented IP blocking measures that significantly impact access to these App APIs. -> -> This update (v0.0.29) introduces the web API mode, which fetches data through Growatt's website APIs. While these Web APIs are currently less strictly affected by IP blocking, it's important to understand that this update DOES NOT directly unblock existing IP bans. -> -> Crucially, parameter settings (like changing output power and SoC limits) are still performed via the App APIs, as these functionalities are not supported by the Web APIs. Therefore, if your IP is currently blocked, this update will not enable parameter changes until your IP block is lifted. -> -> In summary, this update offers a workaround for data retrieval in the face of IP blocks, but parameter settings remain dependent on the App APIs and will only function when your IP is not blocked. - -# noah-mqtt -![License](https://img.shields.io/github/license/mtrossbach/noah-mqtt) ![GitHub last commit](https://img.shields.io/github/last-commit/mtrossbach/noah-mqtt) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/mtrossbach/noah-mqtt) - -`noah-mqtt` is a standalone application designed to retrieve data and metrics from your Growatt NOAH 2000 home battery used in balcony power plants. It publishes this information to an MQTT broker, making it easily accessible for Home Assistant or other applications. +`nexa-mqtt` is a standalone application designed to retrieve data and metrics from your Growatt NOAH 2000 home battery used in balcony power plants. It publishes this information to an MQTT broker, making it easily accessible for Home Assistant or other applications. It is a fork of https://github.com/mtrossbach/noah-mqtt. The application features Home Assistant auto-discovery, allowing your NOAH devices to be automatically recognized and integrated with Home Assistant via the MQTT integration. @@ -25,14 +13,14 @@ The application features Home Assistant auto-discovery, allowing your NOAH devic # Configuration -`noah-mqtt` supports three API modes: +`nexa-mqtt` supports three API modes: * **`app`**: (previous default) This mode utilizes the Shine App APIs. These APIs offer faster data updates and support setting parameters. However, they are the least stable, as they are prone to change with new app updates. They are also subject to strict rate limits, which may result in IP bans. * **`web`**: This mode uses the Growatt Website APIs. These APIs provide a more stable way to fetch data. Setting parameters is not supported in this mode. * **`web+app`**: (current default) This mode combines the best of both worlds. It uses the Growatt Website APIs for data fetching (for stability) and the App APIs for setting parameters. -You can configure `noah-mqtt` using the following environment variables: +You can configure `nexa-mqtt` using the following environment variables: | Environment Variable | Description | Default | |:-----------------------------------|:----------------------------------------------------------------------------------------|:-------------------------------| @@ -47,26 +35,26 @@ You can configure `noah-mqtt` using the following environment variables: | `GROWATT_SERVER_URL_APP` | Growatt server url for app apis | https://server-api.growatt.com | | `MQTT_HOST` | Address of your MQTT broker (required) | - | | `MQTT_PORT` | Port number of your MQTT broker | 1883 | -| `MQTT_CLIENT_ID` | Identifier for the MQTT client | noah-mqtt | +| `MQTT_CLIENT_ID` | Identifier for the MQTT client | nexa-mqtt | | `MQTT_USERNAME` | Username for connecting to your MQTT broker | - | | `MQTT_PASSWORD` | Password for connecting to your MQTT broker | - | -| `MQTT_TOPIC_PREFIX` | Prefix for MQTT topics used by Noah-mqtt | noah2mqtt | +| `MQTT_TOPIC_PREFIX` | Prefix for MQTT topics used by nexa-mqtt | nexa2mqtt | | `HOMEASSISTANT_TOPIC_PREFIX` | Prefix for topics used by Home Assistant | homeassistant | Adjust these settings to fit your environment and requirements. --- -# Data provided by noah-mqtt +# Data provided by nexa-mqtt ## Published Topics -The following MQTT topics are used by `noah-mqtt` to publish data: +The following MQTT topics are used by `nexa-mqtt` to publish data: ### 1. General Device Data -- **Topic:** `noah2mqtt/{DEVICE_SERIAL}` +- **Topic:** `nexa2mqtt/{DEVICE_SERIAL}` - **Description:** This topic contains general data about the device. -- **Example:** `noah2mqtt/0ABC00AA15AA00AA` +- **Example:** `nexa2mqtt/0ABC00AA15AA00AA` - **Example Payload:** ```json { @@ -84,9 +72,9 @@ The following MQTT topics are used by `noah-mqtt` to publish data: ``` ### 2. Battery Information -- **Topic:** `noah2mqtt/{DEVICE_SERIAL}/BAT{BAT_NR}` +- **Topic:** `nexa2mqtt/{DEVICE_SERIAL}/BAT{BAT_NR}` - **Description:** This topic contains information about the device's batteries. Replace `{BAT_NR}` with the battery number (e.g., BAT0, BAT1, BAT2, etc.). -- **Example:** `noah2mqtt/0ABC00AA15AA00AA/BAT0` +- **Example:** `nexa2mqtt/0ABC00AA15AA00AA/BAT0` - **Example Payload:** ```json { @@ -97,9 +85,9 @@ The following MQTT topics are used by `noah-mqtt` to publish data: ``` ### 3. Device Configuration -- **Topic:** `noah2mqtt/{DEVICE_SERIAL}/parameters` +- **Topic:** `nexa2mqtt/{DEVICE_SERIAL}/parameters` - **Description:** This topic contains the current configuration parameters of the device. -- **Example:** `noah2mqtt/0ABC00AA15AA00AA/parameters` +- **Example:** `nexa2mqtt/0ABC00AA15AA00AA/parameters` - **Example Payload:** ```json { @@ -114,9 +102,9 @@ The following MQTT topics are used by `noah-mqtt` to publish data: You can update the device's parameter settings by posting a message to the following topic: -- **Topic:** `noah2mqtt/{DEVICE_SERIAL}/parameters/set` +- **Topic:** `nexa2mqtt/{DEVICE_SERIAL}/parameters/set` - **Description:** Send configuration settings to this topic to update the device's parameters. -- **Example:** `noah2mqtt/1234567890/parameters/set` +- **Example:** `nexa2mqtt/1234567890/parameters/set` - **Example Payload:** ```json { @@ -132,9 +120,11 @@ You can update the device's parameter settings by posting a message to the follo # Run the application standalone -## Option 1: Running `noah-mqtt` with Docker +## Option 1: Running `nexa-mqtt` with Docker + +_currently not working_ -To run the latest version of `noah-mqtt` using Docker, follow these steps: +To run the latest version of `nexa-mqtt` using Docker, follow these steps: 1. **Install Docker**: Ensure Docker is installed on your system. You can download Docker Desktop from [Docker’s official website](https://www.docker.com/products/docker-desktop). @@ -145,7 +135,7 @@ To run the latest version of `noah-mqtt` using Docker, follow these steps: 3. **Execute the Docker Command**: Run the following command, replacing the placeholders with your actual values: ``` - docker run --name noah-mqtt -e GROWATT_USERNAME=myusername -e GROWATT_PASSWORD=mypassword -e MQTT_HOST=localhost -e MQTT_PORT=1883 ghcr.io/mtrossbach/noah-mqtt:latest + docker run --name nexa-mqtt -e GROWATT_USERNAME=myusername -e GROWATT_PASSWORD=mypassword -e MQTT_HOST=localhost -e MQTT_PORT=1883 ghcr.io/mtrossbach/nexa-mqtt:latest ``` - Replace myusername with your Growatt username. @@ -157,9 +147,11 @@ The application will connect to your MQTT broker and retrieve all metrics and da ## Option 2: Downloading and running a prebuilt binary +_currently not working_ + If you prefer not to compile the binary yourself, you can download a prebuilt version: -1. **Download the Binary**: Go to the [Releases](https://github.com/mtrossbach/noah-mqtt/releases) page of the repository and download the prebuilt binary for your operating system and system architecture. +1. **Download the Binary**: Go to the [Releases](https://github.com/mtrossbach/nexa-mqtt/releases) page of the repository and download the prebuilt binary for your operating system and system architecture. 2. **Extract the Binary**: If the binary is compressed (e.g., in a zip or tar file), extract it to a directory of your choice. @@ -172,7 +164,7 @@ If you prefer not to compile the binary yourself, you can download a prebuilt ve set GROWATT_PASSWORD=mypassword set MQTT_HOST=localhost set MQTT_PORT=1883 - noah-mqtt.exe + nexa-mqtt.exe ``` - **Windows** (PowerShell): @@ -182,13 +174,13 @@ If you prefer not to compile the binary yourself, you can download a prebuilt ve $env:GROWATT_PASSWORD=„mypassword“ $env:MQTT_HOST=„localhost“ $env:MQTT_PORT=„1883“ - .\noah-mqtt.exe + .\nexa-mqtt.exe ``` - **Linux/macOS**: ```sh - GROWATT_USERNAME=myusername GROWATT_PASSWORD=mypassword MQTT_HOST=localhost MQTT_PORT=1883 ./noah-mqtt + GROWATT_USERNAME=myusername GROWATT_PASSWORD=mypassword MQTT_HOST=localhost MQTT_PORT=1883 ./nexa-mqtt ``` Again, replace `myusername`, `mypassword`, `localhost`, and `1883` with your actual Growatt account details and MQTT broker information. @@ -201,12 +193,12 @@ To compile the binary yourself, ensure you have Go installed on your machine: 2. **Clone the Repository**: Open a terminal and run the following command to clone the repository: - git clone https://github.com/mtrossbach/noah-mqtt.git - cd noah-mqtt + git clone https://github.com/mtrossbach/nexa-mqtt.git + cd nexa-mqtt 3. **Build the application**: - go build -o noah-mqtt cmd/noah-mqtt/main.go + go build -o nexa-mqtt cmd/nexa-mqtt/main.go Afterwards follow the instructions for running the application from option 2. @@ -214,18 +206,20 @@ Afterwards follow the instructions for running the application from option 2. # Integration into HomeAssistant +_currently not working_ + ## Run standalone (Home Assistant Container, Home Assistant Core) -`noah-mqtt` interacts with Home Assistant by publishing data from your Growatt NOAH 2000 home battery to an MQTT broker. This setup allows Home Assistant to subscribe to and integrate this data seamlessly into its ecosystem. +`nexa-mqtt` interacts with Home Assistant by publishing data from your Growatt NOAH 2000 home battery to an MQTT broker. This setup allows Home Assistant to subscribe to and integrate this data seamlessly into its ecosystem. -![Home Assistant Integration](./assets/noah-mqtt-ha-dark.drawio.png#gh-dark-mode-only) -![Home Assistant Integration](./assets/noah-mqtt-ha.drawio.png#gh-light-mode-only) +![Home Assistant Integration](./assets/nexa-mqtt-ha-dark.drawio.png#gh-dark-mode-only) +![Home Assistant Integration](./assets/nexa-mqtt-ha.drawio.png#gh-light-mode-only) -If you’re already using MQTT with other integrations like zigbee2mqtt or AhoyDTU, you already have the MQTT integration configured and active. In this case, you can skip step 1 and 2 as your existing setup should work with `noah-mqtt`. +If you’re already using MQTT with other integrations like zigbee2mqtt or AhoyDTU, you already have the MQTT integration configured and active. In this case, you can skip step 1 and 2 as your existing setup should work with `nexa-mqtt`. -The following integration process for `noah-mqtt` with Home Assistant works for all installation methods, regardless of how Home Assistant is installed—whether it’s through Home Assistant OS, Home Assistant Supervised, or Home Assistant Container. +The following integration process for `nexa-mqtt` with Home Assistant works for all installation methods, regardless of how Home Assistant is installed—whether it’s through Home Assistant OS, Home Assistant Supervised, or Home Assistant Container. 1. **Set Up an MQTT Broker**: - Ensure you have an MQTT broker running, such as [Mosquitto](https://mosquitto.org/), and that it’s accessible from both Noah-mqtt and Home Assistant. + Ensure you have an MQTT broker running, such as [Mosquitto](https://mosquitto.org/), and that it’s accessible from both nexa-mqtt and Home Assistant. 2. **Check MQTT Integration in Home Assistant**: - Navigate to **Settings** > **Devices & Services** in Home Assistant. @@ -233,18 +227,18 @@ The following integration process for `noah-mqtt` with Home Assistant works for - Enter your MQTT broker details (hostname, port, username, password). - Test the connection to ensure it’s working correctly. -3. **Run noah-mqtt**: - Start `noah-mqtt` using the appropriate configuration for your MQTT broker. +3. **Run nexa-mqtt**: + Start `nexa-mqtt` using the appropriate configuration for your MQTT broker. 4. **Verify Device Discovery**: Check **Devices** and **Entities** under **Settings** > **Devices & Services** in Home Assistant to confirm that your Noah devices are automatically discovered. -By following these steps, `noah-mqtt` will communicate with Home Assistant via your MQTT broker, also supporting automatic device discovery. If you already have MQTT set up, it should integrate seamlessly with your existing configuration. +By following these steps, `nexa-mqtt` will communicate with Home Assistant via your MQTT broker, also supporting automatic device discovery. If you already have MQTT set up, it should integrate seamlessly with your existing configuration. ## Run as Home Assistant add-on (Home Assistant OS, Home Assistant Supervised) -If you are using Home Assistant OS or Home Assistant Supervised you can run `noah-mqtt` as a Home Assistant add-on, which provides seamless integration with your Home Assistant setup. -This option leverages the add-on system to manage and run `noah-mqtt` directly on your Home Assistant instance. +If you are using Home Assistant OS or Home Assistant Supervised you can run `nexa-mqtt` as a Home Assistant add-on, which provides seamless integration with your Home Assistant setup. +This option leverages the add-on system to manage and run `nexa-mqtt` directly on your Home Assistant instance. #### Steps to Use the Home Assistant Add-on 0. **Prerequisite:** @@ -260,16 +254,16 @@ This option leverages the add-on system to manage and run `noah-mqtt` directly o [![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fmtrossbach%2Fhassio-addons) 2. **Install the Add-on:** - - Search for the `noah-mqtt` add-on within the Add-on Store. + - Search for the `nexa-mqtt` add-on within the Add-on Store. - Click on the add-on and select **Install**. 3. **Configure the Add-on:** - After installation, configure the add-on settings by providing your **Growatt username** and **Growatt password** and setup the other options as needed. - If you do not use the Mosquitto Add-on, please also define your MQTT settings 4. **Start the Add-on:** - - Click **Start** to launch the `noah-mqtt` add-on. + - Click **Start** to launch the `nexa-mqtt` add-on. -The Home Assistant add-on provides an easy and integrated way to run `noah-mqtt`, allowing you to manage it directly from the Home Assistant interface. +The Home Assistant add-on provides an easy and integrated way to run `nexa-mqtt`, allowing you to manage it directly from the Home Assistant interface. For more detailed information and updates, visit the [repository](https://github.com/mtrossbach/hassio-addons). diff --git a/build_package.sh b/build_package.sh index 09eb34b..6c2bd75 100755 --- a/build_package.sh +++ b/build_package.sh @@ -17,7 +17,7 @@ for arch in $ARCHS; do mkdir -p $DEB_DIR/usr/bin; echo "Building for $arch..."; - GOOS=linux GOARCH=$arch go build -o $DEB_DIR/usr/bin/${APP_NAME} -ldflags "$LDFLAGS -X main.version=$GITVERSION -X main.commit=$GITCOMMIT" cmd/noah-mqtt/main.go; + GOOS=linux GOARCH=$arch go build -o $DEB_DIR/usr/bin/${APP_NAME} -ldflags "$LDFLAGS -X main.version=$GITVERSION -X main.commit=$GITCOMMIT" cmd/nexa-mqtt/main.go; if [ "$arch" = "arm" ]; then deb_arch="armhf"; diff --git a/cmd/nexa-mqtt/main.go b/cmd/nexa-mqtt/main.go index 1c2c13f..4574542 100644 --- a/cmd/nexa-mqtt/main.go +++ b/cmd/nexa-mqtt/main.go @@ -32,7 +32,7 @@ func main() { misc.Panic(err) } - slog.Info("noah-mqtt started", slog.String("version", version), slog.String("commit", commit)) + slog.Info("nexa-mqtt started", slog.String("version", version), slog.String("commit", commit)) if currentUser, err := user.Current(); err == nil { slog.Info("running as", slog.String("username", currentUser.Username), slog.String("uid", currentUser.Uid)) diff --git a/internal/config/config.go b/internal/config/config.go index 55f5052..826aae7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,7 +62,7 @@ func Get() Config { ClientId: getEnv("MQTT_CLIENT_ID", "nexa-mqtt"), Username: getEnv("MQTT_USERNAME", ""), Password: getEnv("MQTT_PASSWORD", ""), - TopicPrefix: getEnv("MQTT_TOPIC_PREFIX", "noah2mqtt"), + TopicPrefix: getEnv("MQTT_TOPIC_PREFIX", "nexa2mqtt"), }, HomeAssistant: HomeAssistant{ TopicPrefix: getEnv("HOMEASSISTANT_TOPIC_PREFIX", "homeassistant"), From 03940da807f6357235a2490a4332efad3fed9940 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 24 Jun 2025 17:42:13 +0200 Subject: [PATCH 18/30] Fully reconnects after MQTT disconnect --- cmd/nexa-mqtt/main.go | 83 ++++++++++++++++++++----- internal/growatt_app/service.go | 31 +++++---- internal/growatt_app/service_polling.go | 12 +--- internal/growatt_web/service.go | 40 ++++++------ 4 files changed, 110 insertions(+), 56 deletions(-) diff --git a/cmd/nexa-mqtt/main.go b/cmd/nexa-mqtt/main.go index 4574542..25bc114 100644 --- a/cmd/nexa-mqtt/main.go +++ b/cmd/nexa-mqtt/main.go @@ -38,9 +38,8 @@ func main() { slog.Info("running as", slog.String("username", currentUser.Username), slog.String("uid", currentUser.Uid)) } - connectMqtt(cfg.Mqtt, func(client mqtt.Client) { - runApp(cfg, client) - }) + app := NewApp(cfg) + connectMqtt(cfg.Mqtt, app) cancelChan := make(chan os.Signal, 1) signal.Notify(cancelChan, syscall.SIGTERM, syscall.SIGINT) @@ -48,19 +47,58 @@ func main() { slog.Info("Caught signal", slog.Any("signal", sig)) } -func runApp(cfg config.Config, client mqtt.Client) { +type App struct { + mode string + cfg config.Config + growattService *growatt_web.GrowattService + growattApp *growatt_app.GrowattAppService +} + +func (a *App) onMqttDisconnect() { + if a.growattService != nil { + a.growattService.StopPolling() + a.growattService.SetEndpoint(nil) + } + if a.growattApp != nil { + a.growattApp.StopPolling() + a.growattApp.SetEndpoint(nil) + } +} + +func (a *App) onMqttConnect(client mqtt.Client) { haService := homeassistant.NewService(homeassistant.Options{ MqttClient: client, - TopicPrefix: cfg.HomeAssistant.TopicPrefix, + TopicPrefix: a.cfg.HomeAssistant.TopicPrefix, Version: version, }) mqttEndpoint := endpoint_mqtt.NewEndpoint(endpoint_mqtt.Options{ MqttClient: client, - TopicPrefix: cfg.Mqtt.TopicPrefix, + TopicPrefix: a.cfg.Mqtt.TopicPrefix, HaClient: haService, }) + switch a.mode { + case "app": + a.growattApp.SetEndpoint(mqttEndpoint) + mqttEndpoint.SetParameterApplier(a.growattApp) + a.growattApp.StartPolling() + + case "web": + a.growattService.SetEndpoint(mqttEndpoint) + a.growattService.StartPolling() + + case "web+app": + a.growattService.SetEndpoint(mqttEndpoint) + a.growattService.StartPolling() + + a.growattApp.SetEndpoint(mqttEndpoint) + mqttEndpoint.SetParameterApplier(a.growattApp) + } +} + +func NewApp(cfg config.Config) *App { + mode := strings.ToLower(strings.TrimSpace(cfg.Growatt.APIMode)) switch mode { case "app": @@ -74,12 +112,16 @@ func runApp(cfg config.Config, client mqtt.Client) { ParameterPollingInterval: cfg.ParameterPollingInterval, }) - growattApp.AddEndpoint(mqttEndpoint) if err := growattApp.Login(); err != nil { slog.Error("could not login to growatt account", slog.String("error", err.Error())) misc.Panic(err) } - growattApp.StartPolling() + return &App{ + mode: mode, + cfg: cfg, + growattApp: growattApp, + } + case "web": slog.Info("setting mode", slog.String("mode", mode)) growattService := growatt_web.NewGrowattService(growatt_web.Options{ @@ -89,14 +131,17 @@ func runApp(cfg config.Config, client mqtt.Client) { PollingInterval: cfg.PollingInterval, }) - growattService.AddEndpoint(mqttEndpoint) if err := growattService.Login(); err != nil { slog.Error("could not login to growatt account", slog.String("error", err.Error())) misc.Panic(err) } slog.Warn("web mode does not support setting parameters") - growattService.StartPolling() + return &App{ + mode: mode, + cfg: cfg, + growattService: growattService, + } case "web+app": slog.Info("setting mode", slog.String("mode", mode)) @@ -107,7 +152,6 @@ func runApp(cfg config.Config, client mqtt.Client) { PollingInterval: cfg.PollingInterval, }) - growattService.AddEndpoint(mqttEndpoint) if err := growattService.Login(); err != nil { slog.Error("could not login to growatt account", slog.String("error", err.Error())) misc.Panic(err) @@ -121,16 +165,21 @@ func runApp(cfg config.Config, client mqtt.Client) { BatteryDetailsPollingInterval: cfg.BatteryDetailsPollingInterval, ParameterPollingInterval: cfg.ParameterPollingInterval, }) - growattApp.AddEndpoint(mqttEndpoint) - mqttEndpoint.SetParameterApplier(growattApp) - growattService.StartPolling() + return &App{ + mode: mode, + cfg: cfg, + growattService: growattService, + growattApp: growattApp, + } + default: misc.Panic(fmt.Errorf("invalid growatt api type: %s", cfg.Growatt.APIMode)) + return nil } } -func connectMqtt(mqttCfg config.Mqtt, onConnected func(client mqtt.Client)) { +func connectMqtt(mqttCfg config.Mqtt, app *App) { opts := mqtt.NewClientOptions(). AddBroker(fmt.Sprintf("tcp://%s:%d", mqttCfg.Host, mqttCfg.Port)). SetClientID(mqttCfg.ClientId). @@ -139,10 +188,12 @@ func connectMqtt(mqttCfg config.Mqtt, onConnected func(client mqtt.Client)) { opts.OnConnect = func(client mqtt.Client) { slog.Info("connected to mqtt broker") + app.onMqttConnect(client) } opts.OnConnectionLost = func(client mqtt.Client, err error) { slog.Warn("lost connection to mqtt broker", slog.String("error", err.Error())) + app.onMqttDisconnect() } c := mqtt.NewClient(opts) @@ -150,7 +201,5 @@ func connectMqtt(mqttCfg config.Mqtt, onConnected func(client mqtt.Client)) { if token := c.Connect(); token.Wait() && token.Error() != nil { slog.Error("could not connect to mqtt broker", slog.String("error", token.Error().Error())) misc.Panic(token.Error()) - } else { - onConnected(c) } } diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index d4db4c3..a3e94c1 100644 --- a/internal/growatt_app/service.go +++ b/internal/growatt_app/service.go @@ -19,11 +19,12 @@ type Options struct { ParameterPollingInterval time.Duration } type GrowattAppService struct { - opts Options - client *Client - devices []models.NoahDevicePayload - endpoints []endpoint.Endpoint - loggedIn bool + opts Options + client *Client + devices []models.NoahDevicePayload + endpoint endpoint.Endpoint + loggedIn bool + stop chan bool } func NewGrowattAppService(options Options) *GrowattAppService { @@ -31,6 +32,7 @@ func NewGrowattAppService(options Options) *GrowattAppService { opts: options, client: newClient(options.ServerUrl, options.Username, options.Password), loggedIn: false, + stop: make(chan bool), } } @@ -49,6 +51,10 @@ func (g *GrowattAppService) StartPolling() { go g.poll() } +func (g *GrowattAppService) StopPolling() { + g.stop <- true +} + func (g *GrowattAppService) fetchDevices() []models.NoahDevicePayload { slog.Info("fetching plant list") list, err := g.client.GetPlantList() @@ -108,14 +114,11 @@ func (g *GrowattAppService) enumerateDevices() { g.devices = devices - for _, e := range g.endpoints { - e.SetDevices(devices) - } + g.endpoint.SetDevices(devices) } -func (g *GrowattAppService) AddEndpoint(e endpoint.Endpoint) { - g.endpoints = append(g.endpoints, e) - e.SetParameterApplier(g) +func (g *GrowattAppService) SetEndpoint(e endpoint.Endpoint) { + g.endpoint = e } func (g *GrowattAppService) ensureParameterLogin() bool { @@ -209,8 +212,11 @@ func (g *GrowattAppService) poll() { slog.Int("parameter-interval", int(g.opts.ParameterPollingInterval/time.Second))) tickerPolling := time.NewTicker(g.opts.PollingInterval) + defer tickerPolling.Stop() tickerBatteryDetails := time.NewTicker(g.opts.BatteryDetailsPollingInterval) + defer tickerBatteryDetails.Stop() tickerParameter := time.NewTicker(g.opts.ParameterPollingInterval) + defer tickerParameter.Stop() for _, device := range g.devices { g.pollStatus(device) @@ -234,6 +240,9 @@ func (g *GrowattAppService) poll() { for _, device := range g.devices { g.pollParameterData(device) } + case <-g.stop: + slog.Info("stop polling growatt (app)") + return } } } diff --git a/internal/growatt_app/service_polling.go b/internal/growatt_app/service_polling.go index 829bd52..2be59f7 100644 --- a/internal/growatt_app/service_polling.go +++ b/internal/growatt_app/service_polling.go @@ -10,9 +10,7 @@ func (g *GrowattAppService) pollStatus(device models.NoahDevicePayload) { slog.Error("could not get device data", slog.String("error", err.Error()), slog.String("device", device.Serial)) } else { payload := devicePayload(data) - for _, e := range g.endpoints { - e.PublishDeviceStatus(device, payload) - } + g.endpoint.PublishDeviceStatus(device, payload) } } @@ -26,9 +24,7 @@ func (g *GrowattAppService) pollBatteryDetails(device models.NoahDevicePayload) batteryPayloads = append(batteryPayloads, batteryPayload(&bat)) } - for _, e := range g.endpoints { - e.PublishBatteryDetails(device, batteryPayloads) - } + g.endpoint.PublishBatteryDetails(device, batteryPayloads) } } @@ -37,8 +33,6 @@ func (g *GrowattAppService) pollParameterData(device models.NoahDevicePayload) { slog.Error("could not get parameter data", slog.String("error", err.Error()), slog.String("device", device.Serial)) } else { payload := parameterPayload(data) - for _, e := range g.endpoints { - e.PublishParameterData(device, payload) - } + g.endpoint.PublishParameterData(device, payload) } } diff --git a/internal/growatt_web/service.go b/internal/growatt_web/service.go index 1451c3c..9b8db0d 100644 --- a/internal/growatt_web/service.go +++ b/internal/growatt_web/service.go @@ -16,16 +16,18 @@ type Options struct { PollingInterval time.Duration } type GrowattService struct { - opts Options - client *Client - devices []models.NoahDevicePayload - endpoints []endpoint.Endpoint + opts Options + client *Client + devices []models.NoahDevicePayload + endpoint endpoint.Endpoint + stop chan bool } func NewGrowattService(options Options) *GrowattService { return &GrowattService{ opts: options, client: newClient(options.ServerUrl, options.Username, options.Password), + stop: make(chan bool), } } @@ -39,15 +41,17 @@ func (g *GrowattService) Login() error { func (g *GrowattService) StartPolling() { g.devices = g.enumerateDevices() - for _, e := range g.endpoints { - e.SetDevices(g.devices) - } + g.endpoint.SetDevices(g.devices) go g.poll() } -func (g *GrowattService) AddEndpoint(e endpoint.Endpoint) { - g.endpoints = append(g.endpoints, e) +func (g *GrowattService) StopPolling() { + g.stop <- true +} + +func (g *GrowattService) SetEndpoint(e endpoint.Endpoint) { + g.endpoint = e } func (g *GrowattService) enumerateDevices() []models.NoahDevicePayload { @@ -106,7 +110,9 @@ func (g *GrowattService) poll() { slog.Int("history-interval", int(historyInterval/time.Second))) tickerPolling := time.NewTicker(g.opts.PollingInterval) + defer tickerPolling.Stop() tickerHistory := time.NewTicker(historyInterval) + defer tickerHistory.Stop() for _, device := range g.devices { g.pollStatus(device) @@ -124,6 +130,9 @@ func (g *GrowattService) poll() { for _, device := range g.devices { g.pollHistory(device) } + case <-g.stop: + slog.Info("stop polling growatt (web)") + return } } } @@ -158,10 +167,7 @@ func (g *GrowattService) pollStatus(device models.NoahDevicePayload) { Status: models.StatusFromString(status.Obj.Status), } - for _, e := range g.endpoints { - e.PublishDeviceStatus(device, payload) - } - + g.endpoint.PublishDeviceStatus(device, payload) } } } @@ -185,9 +191,7 @@ func (g *GrowattService) pollHistory(device models.NoahDevicePayload) { DefaultMode: &mode, } - for _, e := range g.endpoints { - e.PublishParameterData(device, paramPayload) - } + g.endpoint.PublishParameterData(device, paramPayload) } } @@ -229,9 +233,7 @@ func (g *GrowattService) pollHistory(device models.NoahDevicePayload) { } } - for _, e := range g.endpoints { - e.PublishBatteryDetails(device, batteries) - } + g.endpoint.PublishBatteryDetails(device, batteries) } } } From d435c1ed8282725e127e5ac0df01faf685b8e081 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 25 Jun 2025 15:07:34 +0200 Subject: [PATCH 19/30] Reworked setting of parameters --- cmd/nexa-mqtt/main.go | 4 +- internal/endpoint/parameter_applier.go | 4 +- internal/endpoint_mqtt/endpoint_mqtt.go | 51 ++++++++++++++++++--- internal/growatt_app/payload.go | 4 +- internal/growatt_app/service.go | 60 +++++-------------------- internal/growatt_web/models.go | 20 ++++----- pkg/models/payload.go | 29 ++++++++++++ 7 files changed, 101 insertions(+), 71 deletions(-) diff --git a/cmd/nexa-mqtt/main.go b/cmd/nexa-mqtt/main.go index 25bc114..23358e6 100644 --- a/cmd/nexa-mqtt/main.go +++ b/cmd/nexa-mqtt/main.go @@ -81,8 +81,8 @@ func (a *App) onMqttConnect(client mqtt.Client) { switch a.mode { case "app": a.growattApp.SetEndpoint(mqttEndpoint) - mqttEndpoint.SetParameterApplier(a.growattApp) a.growattApp.StartPolling() + mqttEndpoint.SetParameterApplier(a.growattApp) case "web": a.growattService.SetEndpoint(mqttEndpoint) @@ -91,8 +91,6 @@ func (a *App) onMqttConnect(client mqtt.Client) { case "web+app": a.growattService.SetEndpoint(mqttEndpoint) a.growattService.StartPolling() - - a.growattApp.SetEndpoint(mqttEndpoint) mqttEndpoint.SetParameterApplier(a.growattApp) } } diff --git a/internal/endpoint/parameter_applier.go b/internal/endpoint/parameter_applier.go index 0079865..201defc 100644 --- a/internal/endpoint/parameter_applier.go +++ b/internal/endpoint/parameter_applier.go @@ -3,6 +3,6 @@ package endpoint import "nexa-mqtt/pkg/models" type ParameterApplier interface { - SetOutputPowerW(device models.NoahDevicePayload, mode *models.WorkMode, power *float64) bool - SetChargingLimits(device models.NoahDevicePayload, chargingLimit *float64, dischargeLimit *float64) bool + SetOutputPowerW(device models.NoahDevicePayload, mode models.WorkMode, power float64) bool + SetChargingLimits(device models.NoahDevicePayload, chargingLimit float64, dischargeLimit float64) bool } diff --git a/internal/endpoint_mqtt/endpoint_mqtt.go b/internal/endpoint_mqtt/endpoint_mqtt.go index ef16169..26ab357 100644 --- a/internal/endpoint_mqtt/endpoint_mqtt.go +++ b/internal/endpoint_mqtt/endpoint_mqtt.go @@ -7,6 +7,8 @@ import ( "nexa-mqtt/internal/endpoint" "nexa-mqtt/internal/homeassistant" "nexa-mqtt/pkg/models" + "sync" + "time" mqtt "github.com/eclipse/paho.mqtt.golang" ) @@ -21,11 +23,16 @@ type Endpoint struct { opts Options devs []models.NoahDevicePayload param_applier endpoint.ParameterApplier + stateLock sync.Mutex + lastParameter models.ParameterPayload + newParameter models.ParameterPayload + publishTimer *time.Timer } func NewEndpoint(options Options) *Endpoint { return &Endpoint{ - opts: options, + opts: options, + lastParameter: models.EmptyParameterPayload(), } } @@ -97,9 +104,16 @@ func (e *Endpoint) PublishParameterData(device models.NoahDevicePayload, param m } else { e.opts.MqttClient.Publish(parameterStateTopic(e.opts.TopicPrefix, device.Serial), 0, false, string(b)) slog.Debug("parameter data sent to mqtt", slog.String("data", string(b)), slog.String("device", device.Serial)) + + e.stateLock.Lock() + defer e.stateLock.Unlock() + + e.lastParameter = param } } +const debounceDelay = 500 * time.Millisecond + func (e *Endpoint) parametersSubscription(dev models.NoahDevicePayload) func(client mqtt.Client, message mqtt.Message) { return func(client mqtt.Client, message mqtt.Message) { if e.param_applier == nil { @@ -112,12 +126,37 @@ func (e *Endpoint) parametersSubscription(dev models.NoahDevicePayload) func(cli slog.Error("unable to unmarshal parameter command payload", slog.String("error", err.Error())) } - if payload.DefaultACCouplePower != nil || payload.DefaultMode != nil { - e.param_applier.SetOutputPowerW(dev, payload.DefaultMode, payload.DefaultACCouplePower) - } + e.stateLock.Lock() + defer e.stateLock.Unlock() - if payload.ChargingLimit != nil || payload.DischargeLimit != nil { - e.param_applier.SetChargingLimits(dev, payload.ChargingLimit, payload.DischargeLimit) + e.newParameter.UpdateFrom(payload) + + if e.publishTimer != nil { + e.publishTimer.Stop() } + + e.publishTimer = time.AfterFunc(debounceDelay, func() { + e.debouncedParametersSubscription(dev) + }) + } +} + +func (e *Endpoint) debouncedParametersSubscription(dev models.NoahDevicePayload) { + e.stateLock.Lock() + defer e.stateLock.Unlock() + + e.lastParameter.UpdateFrom(e.newParameter) + + if e.newParameter.DefaultACCouplePower != nil || e.newParameter.DefaultMode != nil { + e.param_applier.SetOutputPowerW(dev, *e.lastParameter.DefaultMode, *e.lastParameter.DefaultACCouplePower) } + + if e.newParameter.ChargingLimit != nil || e.newParameter.DischargeLimit != nil { + e.param_applier.SetChargingLimits(dev, *e.lastParameter.ChargingLimit, *e.lastParameter.DischargeLimit) + } + + e.newParameter = models.ParameterPayload{} + e.publishTimer = nil + + go e.PublishParameterData(dev, e.lastParameter) } diff --git a/internal/growatt_app/payload.go b/internal/growatt_app/payload.go index bd1c99a..e90dfa7 100644 --- a/internal/growatt_app/payload.go +++ b/internal/growatt_app/payload.go @@ -31,13 +31,13 @@ func batteryPayload(n *BatteryDetails) models.BatteryPayload { func parameterPayload(n *NexaInfo) models.ParameterPayload { chargingLimit := misc.ParseFloat(n.Obj.Noah.ChargingSocHighLimit) dischargeLimit := misc.ParseFloat(n.Obj.Noah.ChargingSocLowLimit) - outputPower := misc.ParseFloat(n.Obj.Noah.DefaultACCouplePower) + defaultACCouplePower := misc.ParseFloat(n.Obj.Noah.DefaultACCouplePower) defaultMode := models.WorkModeFromString(n.Obj.Noah.DefaultMode) return models.ParameterPayload{ ChargingLimit: &chargingLimit, DischargeLimit: &dischargeLimit, - DefaultACCouplePower: &outputPower, + DefaultACCouplePower: &defaultACCouplePower, DefaultMode: &defaultMode, } } diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index a3e94c1..b1b1adf 100644 --- a/internal/growatt_app/service.go +++ b/internal/growatt_app/service.go @@ -131,76 +131,40 @@ func (g *GrowattAppService) ensureParameterLogin() bool { return true } -func (g *GrowattAppService) SetOutputPowerW(device models.NoahDevicePayload, mode *models.WorkMode, power *float64) bool { - slog.Info("trying to set default power (app)", slog.String("device", device.Serial)) +func (g *GrowattAppService) SetOutputPowerW(device models.NoahDevicePayload, mode models.WorkMode, power float64) bool { + slog.Info("trying to set default system output power (app)", slog.String("device", device.Serial), slog.String("mode", string(mode)), slog.Float64("power", power)) if !g.ensureParameterLogin() { - slog.Error("unable to set default power (app)", slog.String("device", device.Serial)) + slog.Error("unable to set default system output power (app)", slog.String("device", device.Serial)) return false } - if mode == nil || power == nil { - if data, err := g.client.GetNoahInfo(device.Serial); err != nil { - slog.Error("unable to get parameter status (app)", slog.String("error", err.Error())) - return false - } else { - if mode == nil { - m := models.WorkModeFromString(data.Obj.Noah.DefaultMode) - mode = &m - } - if power == nil { - p := misc.ParseFloat(data.Obj.Noah.DefaultACCouplePower) - power = &p - } - } - } - - modeAsInt := models.IntFromWorkMode(*mode) + modeAsInt := models.IntFromWorkMode(mode) if modeAsInt < 0 { - slog.Error("unable to set default power (app). Invalid mode", slog.String("device", device.Serial), slog.String("mode", (string)(*mode))) + slog.Error("unable to set default system output power (app). Invalid mode", slog.String("device", device.Serial), slog.String("mode", string(mode))) return false } - slog.Info("trying to set default power (app)", slog.String("device", device.Serial), slog.Int("mode", modeAsInt), slog.Float64("power", *power)) - if err := g.client.SetSystemOutputPower(device.Serial, modeAsInt, *power); err != nil { - slog.Error("unable to set default power (app)", slog.String("error", err.Error()), slog.String("device", device.Serial)) + slog.Info("set default system output power (app)", slog.String("device", device.Serial), slog.Int("mode", modeAsInt), slog.Float64("power", power)) + if err := g.client.SetSystemOutputPower(device.Serial, modeAsInt, power); err != nil { + slog.Error("unable to set default system output power (app)", slog.String("error", err.Error()), slog.String("device", device.Serial)) return false } else { - go g.pollParameterData(device) - slog.Info("set default power (app)", slog.String("device", device.Serial), slog.Int("mode", modeAsInt), slog.Float64("power", *power)) return true } } -func (g *GrowattAppService) SetChargingLimits(device models.NoahDevicePayload, chargingLimit *float64, dischargeLimit *float64) bool { - slog.Info("trying to set charging limit (app)", slog.String("device", device.Serial)) +func (g *GrowattAppService) SetChargingLimits(device models.NoahDevicePayload, chargingLimit float64, dischargeLimit float64) bool { + slog.Info("trying to set charging limits (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", chargingLimit), slog.Float64("dischargeLimit", dischargeLimit)) if !g.ensureParameterLogin() { slog.Error("unable to set charging limits (app)", slog.String("device", device.Serial)) return false } - if chargingLimit == nil || dischargeLimit == nil { - if data, err := g.client.GetNoahInfo(device.Serial); err != nil { - slog.Error("unable to get parameter status (app)", slog.String("error", err.Error())) - return false - } else { - if chargingLimit == nil { - cl := misc.ParseFloat(data.Obj.Noah.ChargingSocHighLimit) - chargingLimit = &cl - } - if dischargeLimit == nil { - dl := misc.ParseFloat(data.Obj.Noah.ChargingSocLowLimit) - dischargeLimit = &dl - } - } - } - - slog.Info("trying to set charging limit (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", *chargingLimit), slog.Float64("dischargeLimit", *dischargeLimit)) - if err := g.client.SetChargingSoc(device.Serial, *chargingLimit, *dischargeLimit); err != nil { + slog.Info("set charging limit (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", chargingLimit), slog.Float64("dischargeLimit", dischargeLimit)) + if err := g.client.SetChargingSoc(device.Serial, chargingLimit, dischargeLimit); err != nil { slog.Error("unable to set charging limits (app)", slog.String("error", err.Error())) return false } else { - go g.pollParameterData(device) - slog.Info("set charging limits (app)", slog.String("device", device.Serial), slog.Float64("chargingLimit", *chargingLimit), slog.Float64("dischargeLimit", *dischargeLimit)) return true } } diff --git a/internal/growatt_web/models.go b/internal/growatt_web/models.go index 5aa3d46..e3570ca 100644 --- a/internal/growatt_web/models.go +++ b/internal/growatt_web/models.go @@ -279,21 +279,21 @@ type GrowattNoahStatus struct { Result int `json:"result"` Msg interface{} `json:"msg"` Obj struct { - SmartSocketPower string `json:"smartSocketPower"` // new - CtSelfPower string `json:"ctSelfPower"` // new - GroplugFlag string `json:"groplugFlag"` // new - HouseholdLoadApartFromGroplug string `json:"householdLoadApartFromGroplug"` // new - ShellyFlag string `json:"shellyFlag"` // new - TotalHouseholdLoad string `json:"totalHouseholdLoad"` // new + SmartSocketPower string `json:"smartSocketPower"` + CtSelfPower string `json:"ctSelfPower"` + GroplugFlag string `json:"groplugFlag"` + HouseholdLoadApartFromGroplug string `json:"householdLoadApartFromGroplug"` + ShellyFlag string `json:"shellyFlag"` + TotalHouseholdLoad string `json:"totalHouseholdLoad"` TotalBatteryPackSoc string `json:"totalBatteryPackSoc"` Pac string `json:"pac"` WorkMode string `json:"workMode"` - EastronFlag string `json:"eastronFlag"` // new - BatteryPackageQuantity string `json:"batteryPackageQuantity"` // new + EastronFlag string `json:"eastronFlag"` + BatteryPackageQuantity string `json:"batteryPackageQuantity"` Ppv string `json:"ppv"` - GroplugNum string `json:"groplugNum"` // new + GroplugNum string `json:"groplugNum"` TotalBatteryPackChargingPower string `json:"totalBatteryPackChargingPower"` - OtherPower string `json:"otherPower"` // new + OtherPower string `json:"otherPower"` Status string `json:"status"` } `json:"obj"` Request interface{} `json:"request"` diff --git a/pkg/models/payload.go b/pkg/models/payload.go index d0cc4c8..d49119d 100644 --- a/pkg/models/payload.go +++ b/pkg/models/payload.go @@ -81,6 +81,35 @@ type ParameterPayload struct { DefaultMode *WorkMode `json:"default_mode,omitempty"` } +func (p *ParameterPayload) UpdateFrom(src ParameterPayload) { + if src.ChargingLimit != nil { + p.ChargingLimit = src.ChargingLimit + } + if src.DischargeLimit != nil { + p.DischargeLimit = src.DischargeLimit + } + if src.DefaultACCouplePower != nil { + p.DefaultACCouplePower = src.DefaultACCouplePower + } + if src.DefaultMode != nil { + p.DefaultMode = src.DefaultMode + } +} + +func EmptyParameterPayload() ParameterPayload { + chargingLimit := 100.0 + dischargeLimit := 10.0 + defaultACCouplePower := 150.0 + var defaultMode WorkMode = WorkModeLoadFirst + + return ParameterPayload{ + ChargingLimit: &chargingLimit, + DischargeLimit: &dischargeLimit, + DefaultACCouplePower: &defaultACCouplePower, + DefaultMode: &defaultMode, + } +} + type NoahDevicePayload struct { PlantId int `json:"plant_id"` Serial string `json:"serial"` From 8f53338a1ca18a7f1b2c0c0e6ee359b185e59c63 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 27 Jun 2025 10:59:31 +0200 Subject: [PATCH 20/30] Unit test package endpoint_mqtt --- go.mod | 8 + go.sum | 11 + internal/endpoint_mqtt/endpoint_mqtt.go | 3 +- internal/endpoint_mqtt/endpoint_mqtt_test.go | 570 +++++++++++++++++++ internal/homeassistant/service.go | 4 + 5 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 internal/endpoint_mqtt/endpoint_mqtt_test.go diff --git a/go.mod b/go.mod index 1084a73..2620eb6 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,16 @@ require ( github.com/google/uuid v1.6.0 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + require ( github.com/gorilla/websocket v1.5.3 // indirect + github.com/stretchr/testify v1.10.0 golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index 93f0453..469cf34 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,21 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o= github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/endpoint_mqtt/endpoint_mqtt.go b/internal/endpoint_mqtt/endpoint_mqtt.go index 26ab357..a90f481 100644 --- a/internal/endpoint_mqtt/endpoint_mqtt.go +++ b/internal/endpoint_mqtt/endpoint_mqtt.go @@ -16,7 +16,7 @@ import ( type Options struct { MqttClient mqtt.Client TopicPrefix string - HaClient *homeassistant.Service + HaClient homeassistant.HaClient } type Endpoint struct { @@ -124,6 +124,7 @@ func (e *Endpoint) parametersSubscription(dev models.NoahDevicePayload) func(cli var payload models.ParameterPayload if err := json.Unmarshal(message.Payload(), &payload); err != nil { slog.Error("unable to unmarshal parameter command payload", slog.String("error", err.Error())) + return } e.stateLock.Lock() diff --git a/internal/endpoint_mqtt/endpoint_mqtt_test.go b/internal/endpoint_mqtt/endpoint_mqtt_test.go new file mode 100644 index 0000000..897d991 --- /dev/null +++ b/internal/endpoint_mqtt/endpoint_mqtt_test.go @@ -0,0 +1,570 @@ +package endpoint_mqtt + +import ( + "encoding/json" + "fmt" + "math" + "nexa-mqtt/internal/homeassistant" + "nexa-mqtt/pkg/models" + "sync" + "testing" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// ----- Mocks -------------------------------------------------------------- + +// MockToken implements mqtt.Token +type MockToken struct { + mock.Mock + done chan struct{} +} + +func NewMockToken() *MockToken { + done := make(chan struct{}) + close(done) // sofort abgeschlossen + return &MockToken{done: done} +} + +func (m *MockToken) Wait() bool { return true } +func (m *MockToken) WaitTimeout(time.Duration) bool { return true } +func (t *MockToken) Done() <-chan struct{} { return t.done } +func (t *MockToken) Error() error { + args := t.Called("Error") + return args.Error(0) +} + +// MockMqttClient implements mqtt.Client +type MockMqttClient struct { + mock.Mock + mqtt.Client +} + +func (m *MockMqttClient) Publish(topic string, qos byte, retained bool, payload interface{}) mqtt.Token { + args := m.Called(topic, qos, retained, payload) + return args.Get(0).(mqtt.Token) +} + +func (m *MockMqttClient) Subscribe(topic string, qos byte, callback mqtt.MessageHandler) mqtt.Token { + args := m.Called(topic, qos, callback) + return args.Get(0).(mqtt.Token) +} + +func (m *MockMqttClient) Unsubscribe(topics ...string) mqtt.Token { + ifaceArgs := make([]interface{}, len(topics)) + for i, v := range topics { + ifaceArgs[i] = v + } + args := m.Called(ifaceArgs...) + return args.Get(0).(mqtt.Token) +} + +// MockMqttMessage implements mqtt.Message +type MockMqttMessage struct { + mock.Mock + mqtt.Message +} + +func (m *MockMqttMessage) Payload() []byte { + args := m.Called() + return args.Get(0).([]byte) +} + +// MockParameterApplier implements endpoint.ParameterApplier +type MockParameterApplier struct { + mock.Mock +} + +func (p *MockParameterApplier) SetOutputPowerW(device models.NoahDevicePayload, mode models.WorkMode, power float64) bool { + args := p.Called(device, mode, power) + return args.Get(0).(bool) +} + +func (p *MockParameterApplier) SetChargingLimits(device models.NoahDevicePayload, chargingLimit float64, dischargeLimit float64) bool { + args := p.Called(device, chargingLimit, dischargeLimit) + return args.Get(0).(bool) +} + +// MockHaClient implements homeassistant.HaClient +type MockHaClient struct { + mock.Mock +} + +func (s *MockHaClient) SetDevices(devices []homeassistant.DeviceInfo) { + s.Called(devices) +} + +// ----- Test functions ----------------------------------------------------- + +func TestNewEndpoint(t *testing.T) { + endpoint := NewEndpoint(Options{}) + + assert.Equal(t, models.EmptyParameterPayload(), endpoint.lastParameter) + assert.Equal(t, models.ParameterPayload{}, endpoint.newParameter) +} + +func TestSetDevices(t *testing.T) { + mockClient := new(MockMqttClient) + mockToken := NewMockToken() + haClient := &MockHaClient{} + endpoint := &Endpoint{ + opts: Options{ + MqttClient: mockClient, + TopicPrefix: "test", + HaClient: haClient, + }, + } + + devices1 := []models.NoahDevicePayload{ + {Serial: "device123", Batteries: []models.NoahDeviceBatteryPayload{{Alias: "A"}, {Alias: "B"}}}, + {Serial: "device234", Batteries: []models.NoahDeviceBatteryPayload{{Alias: "C"}}}, + } + + mockClient.On( + "Subscribe", + "test/device123/parameters/set", + byte(0), + mock.AnythingOfType("mqtt.MessageHandler"), + ).Return(mockToken) + mockClient.On( + "Subscribe", + "test/device234/parameters/set", + byte(0), + mock.AnythingOfType("mqtt.MessageHandler"), + ).Return(mockToken) + + haClient.On( + "SetDevices", + []homeassistant.DeviceInfo{ + { + SerialNumber: "device123", + StateTopic: "test/device123", + ParameterStateTopic: "test/device123/parameters", + ParameterCommandTopic: "test/device123/parameters/set", + Batteries: []homeassistant.BatteryInfo{ + { + Alias: "A", + StateTopic: "test/device123/BAT0", + }, + { + Alias: "B", + StateTopic: "test/device123/BAT1", + }, + }, + }, + { + SerialNumber: "device234", + StateTopic: "test/device234", + ParameterStateTopic: "test/device234/parameters", + ParameterCommandTopic: "test/device234/parameters/set", + Batteries: []homeassistant.BatteryInfo{ + { + Alias: "C", + StateTopic: "test/device234/BAT0", + }, + }, + }, + }, + ) + + endpoint.SetDevices(devices1) + + mockClient.AssertExpectations(t) + haClient.AssertExpectations(t) + + devices2 := []models.NoahDevicePayload{ + {Serial: "device345", Batteries: []models.NoahDeviceBatteryPayload{}}, + } + + mockClient.On( + "Unsubscribe", + "test/device123/parameters/set", + ).Return(mockToken) + mockClient.On( + "Unsubscribe", + "test/device234/parameters/set", + ).Return(mockToken) + mockClient.On( + "Subscribe", + "test/device345/parameters/set", + byte(0), + mock.AnythingOfType("mqtt.MessageHandler"), + ).Return(mockToken) + + haClient.On( + "SetDevices", + []homeassistant.DeviceInfo{ + { + SerialNumber: "device345", + StateTopic: "test/device345", + ParameterStateTopic: "test/device345/parameters", + ParameterCommandTopic: "test/device345/parameters/set", + Batteries: nil, + }, + }, + ) + + endpoint.SetDevices(devices2) + + mockClient.AssertExpectations(t) + haClient.AssertExpectations(t) +} + +func TestPublishDeviceStatus_Success(t *testing.T) { + mockClient := new(MockMqttClient) + mockToken := NewMockToken() + + mockClient.On( + "Publish", + "test/device123", + byte(0), + false, + `{"output_w":0,"solar_w":0,"soc":0,"charge_w":0,"discharge_w":0,"battery_num":0,"generation_total_kwh":0,"generation_today_kwh":0}`, + ).Return(mockToken) + + endpoint := &Endpoint{ + opts: Options{ + MqttClient: mockClient, + TopicPrefix: "test", + }, + } + + device := models.NoahDevicePayload{Serial: "device123"} + status := models.DevicePayload{} + + endpoint.PublishDeviceStatus(device, status) + + mockClient.AssertExpectations(t) +} + +func TestPublishDeviceStatus_Fail(t *testing.T) { + mockClient := new(MockMqttClient) + + endpoint := &Endpoint{ + opts: Options{ + MqttClient: mockClient, + TopicPrefix: "test", + }, + } + + device := models.NoahDevicePayload{Serial: "device123"} + status := models.DevicePayload{OutputPower: math.NaN()} + + endpoint.PublishDeviceStatus(device, status) + + mockClient.AssertExpectations(t) +} + +func TestPublishBatteryDetails_Success(t *testing.T) { + mockClient := new(MockMqttClient) + mockToken := NewMockToken() + + mockClient.On( + "Publish", + "test/device123/BAT0", + byte(0), + false, + `{"serial":"","soc":0,"temp":0}`, + ).Return(mockToken) + + endpoint := &Endpoint{ + opts: Options{ + MqttClient: mockClient, + TopicPrefix: "test", + }, + } + + device := models.NoahDevicePayload{Serial: "device123"} + details := []models.BatteryPayload{{}} + + endpoint.PublishBatteryDetails(device, details) + + mockClient.AssertExpectations(t) +} + +func TestPublishBatteryDetails_SuccessMult(t *testing.T) { + mockClient := new(MockMqttClient) + mockToken := NewMockToken() + + mockClient.On( + "Publish", + "test/device123/BAT0", + byte(0), + false, + `{"serial":"E","soc":20,"temp":0}`, + ).Return(mockToken) + mockClient.On( + "Publish", + "test/device123/BAT1", + byte(0), + false, + `{"serial":"F","soc":30,"temp":0}`, + ).Return(mockToken) + mockClient.On( + "Publish", + "test/device123/BAT2", + byte(0), + false, + `{"serial":"G","soc":40,"temp":0}`, + ).Return(mockToken) + + endpoint := &Endpoint{ + opts: Options{ + MqttClient: mockClient, + TopicPrefix: "test", + }, + } + + device := models.NoahDevicePayload{Serial: "device123"} + details := []models.BatteryPayload{{SerialNumber: "E", Soc: 20}, {SerialNumber: "F", Soc: 30}, {SerialNumber: "G", Soc: 40}} + + endpoint.PublishBatteryDetails(device, details) + + mockClient.AssertExpectations(t) +} + +func TestPublishBatteryDetails_Fail(t *testing.T) { + mockClient := new(MockMqttClient) + + endpoint := &Endpoint{ + opts: Options{ + MqttClient: mockClient, + TopicPrefix: "test", + }, + } + + device := models.NoahDevicePayload{Serial: "device123"} + details := []models.BatteryPayload{{Soc: math.NaN()}} + + endpoint.PublishBatteryDetails(device, details) + + mockClient.AssertExpectations(t) +} + +func TestPublishParameterData_Success(t *testing.T) { + mockClient := new(MockMqttClient) + mockToken := NewMockToken() + + param := models.EmptyParameterPayload() + json, err := json.Marshal(param) + assert.Nil(t, err) + + mockClient.On( + "Publish", + "test/device123/parameters", + byte(0), + false, + string(json), + ).Return(mockToken) + + endpoint := &Endpoint{ + opts: Options{ + MqttClient: mockClient, + TopicPrefix: "test", + }, + } + + device := models.NoahDevicePayload{Serial: "device123"} + + endpoint.PublishParameterData(device, param) + + mockClient.AssertExpectations(t) +} + +func TestPublishParameterData_Fail(t *testing.T) { + mockClient := new(MockMqttClient) + + endpoint := &Endpoint{ + opts: Options{ + MqttClient: mockClient, + TopicPrefix: "test", + }, + } + + device := models.NoahDevicePayload{Serial: "device123"} + param := models.EmptyParameterPayload() + *param.ChargingLimit = math.NaN() + + endpoint.PublishParameterData(device, param) + + mockClient.AssertExpectations(t) +} + +func Test_parametersSubscription_NoApplier(t *testing.T) { + mockClient := new(MockMqttClient) + endpoint := NewEndpoint(Options{MqttClient: mockClient, TopicPrefix: "test"}) + + device := models.NoahDevicePayload{Serial: "device123"} + f1 := endpoint.parametersSubscription(device) + + mockMqttMessage := MockMqttMessage{} + + f1(mockClient, &mockMqttMessage) + + mockMqttMessage.AssertExpectations(t) + mockClient.AssertExpectations(t) +} + +func setup_parametersSubscription() (*MockToken, *MockMqttClient, *MockParameterApplier, *Endpoint, models.NoahDevicePayload, func(client mqtt.Client, message mqtt.Message)) { + mockToken := NewMockToken() + mockClient := new(MockMqttClient) + mockApplier := MockParameterApplier{} + endpoint := NewEndpoint(Options{MqttClient: mockClient, TopicPrefix: "test"}) + endpoint.SetParameterApplier(&mockApplier) + device := models.NoahDevicePayload{Serial: "device123"} + f1 := endpoint.parametersSubscription(device) + return mockToken, mockClient, &mockApplier, endpoint, device, f1 +} + +func Test_parametersSubscription_InvalidPayload(t *testing.T) { + _, mockClient, mockApplier, _, _, f1 := setup_parametersSubscription() + + mockMqttMessage := MockMqttMessage{} + mockMqttMessage.On("Payload"). + Return([]byte(`{"charging_limit":"invalid string"}`)) + + f1(mockClient, &mockMqttMessage) + + mockMqttMessage.AssertExpectations(t) + mockApplier.AssertExpectations(t) + mockClient.AssertExpectations(t) +} + +func Test_parametersSubscription_ChargingLimit(t *testing.T) { + mockToken, mockClient, mockApplier, endpoint, device, f1 := setup_parametersSubscription() + empty := models.EmptyParameterPayload() + + mockMqttMessage := MockMqttMessage{} + mockMqttMessage.On("Payload"). + Return([]byte(`{"charging_limit":90}`)) + + var wg sync.WaitGroup + + mockApplier.On("SetChargingLimits", device, 90.0, *empty.DischargeLimit). + Run(func(args mock.Arguments) { + wg.Done() + }). + Return(true) + + mockClient.On( + "Publish", + "test/device123/parameters", + byte(0), + false, + fmt.Sprintf(`{"charging_limit":90,"discharge_limit":%v,"default_output_w":%v,"default_mode":"%v"}`, + *empty.DischargeLimit, *empty.DefaultACCouplePower, *empty.DefaultMode), + ).Run(func(args mock.Arguments) { + wg.Done() + }).Return(mockToken) + + wg.Add(2) + f1(mockClient, &mockMqttMessage) + wg.Wait() + + mockMqttMessage.AssertExpectations(t) + mockApplier.AssertExpectations(t) + mockClient.AssertExpectations(t) + + assert.Equal(t, models.ParameterPayload{}, endpoint.newParameter) +} + +func Test_parametersSubscription_ChargingAndDischargeLimit(t *testing.T) { + mockToken, mockClient, mockApplier, endpoint, device, call_parametersSubscription := setup_parametersSubscription() + empty := models.EmptyParameterPayload() + + mockMqttMessage1 := MockMqttMessage{} + mockMqttMessage1.On("Payload"). + Return([]byte(`{"charging_limit":95}`)) + + mockMqttMessage2 := MockMqttMessage{} + mockMqttMessage2.On("Payload"). + Return([]byte(`{"discharge_limit":5}`)) + + var wg sync.WaitGroup + + mockApplier.On("SetChargingLimits", device, 95.0, 5.0). + Run(func(args mock.Arguments) { + wg.Done() + }). + Return(true) + + mockClient.On( + "Publish", + "test/device123/parameters", + byte(0), + false, + fmt.Sprintf(`{"charging_limit":95,"discharge_limit":5,"default_output_w":%v,"default_mode":"%v"}`, + *empty.DefaultACCouplePower, *empty.DefaultMode), + ).Run(func(args mock.Arguments) { + wg.Done() + }).Return(mockToken) + + wg.Add(2) + call_parametersSubscription(mockClient, &mockMqttMessage1) + call_parametersSubscription(mockClient, &mockMqttMessage2) + wg.Wait() + + mockMqttMessage1.AssertExpectations(t) + mockApplier.AssertExpectations(t) + mockClient.AssertExpectations(t) + + assert.Nil(t, endpoint.newParameter.ChargingLimit) + assert.Nil(t, endpoint.newParameter.DischargeLimit) + assert.Nil(t, endpoint.newParameter.DefaultACCouplePower) + assert.Nil(t, endpoint.newParameter.DefaultMode) +} + +func Test_parametersSubscription_ChargingLimitAndMode(t *testing.T) { + mockToken, mockClient, mockApplier, endpoint, device, call_parametersSubscription := setup_parametersSubscription() + empty := models.EmptyParameterPayload() + + mockMqttMessage1 := MockMqttMessage{} + mockMqttMessage1.On("Payload"). + Return([]byte(`{"charging_limit":75}`)) + + mockMqttMessage2 := MockMqttMessage{} + mockMqttMessage2.On("Payload"). + Return([]byte(`{"default_mode":"battery_first"}`)) + + var wg sync.WaitGroup + + mockApplier.On("SetChargingLimits", device, 75.0, *empty.DischargeLimit). + Run(func(args mock.Arguments) { + wg.Done() + }). + Return(true) + + mockApplier.On("SetOutputPowerW", device, models.WorkMode("battery_first"), *empty.DefaultACCouplePower). + Run(func(args mock.Arguments) { + wg.Done() + }). + Return(true) + + mockClient.On( + "Publish", + "test/device123/parameters", + byte(0), + false, + fmt.Sprintf(`{"charging_limit":75,"discharge_limit":%v,"default_output_w":%v,"default_mode":"battery_first"}`, + *empty.DischargeLimit, *empty.DefaultACCouplePower), + ).Run(func(args mock.Arguments) { + wg.Done() + }).Return(mockToken) + + wg.Add(3) + call_parametersSubscription(mockClient, &mockMqttMessage1) + call_parametersSubscription(mockClient, &mockMqttMessage2) + wg.Wait() + + mockMqttMessage1.AssertExpectations(t) + mockApplier.AssertExpectations(t) + mockClient.AssertExpectations(t) + + assert.Nil(t, endpoint.newParameter.ChargingLimit) + assert.Nil(t, endpoint.newParameter.DischargeLimit) + assert.Nil(t, endpoint.newParameter.DefaultACCouplePower) + assert.Nil(t, endpoint.newParameter.DefaultMode) +} diff --git a/internal/homeassistant/service.go b/internal/homeassistant/service.go index 2bd6407..c0ae774 100644 --- a/internal/homeassistant/service.go +++ b/internal/homeassistant/service.go @@ -10,6 +10,10 @@ import ( mqtt "github.com/eclipse/paho.mqtt.golang" ) +type HaClient interface { + SetDevices(devices []DeviceInfo) +} + type Options struct { MqttClient mqtt.Client TopicPrefix string From d25f5cd581aaf13b2984eec884234bf93b8dcade Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 28 Jun 2025 13:24:58 +0200 Subject: [PATCH 21/30] Unit test package growatt_app (client_http.go) --- internal/endpoint_mqtt/endpoint_mqtt_test.go | 10 +- internal/growatt_app/client.go | 57 +++++++---- internal/growatt_app/client_http.go | 36 ++++--- internal/growatt_app/client_http_test.go | 100 +++++++++++++++++++ 4 files changed, 158 insertions(+), 45 deletions(-) create mode 100644 internal/growatt_app/client_http_test.go diff --git a/internal/endpoint_mqtt/endpoint_mqtt_test.go b/internal/endpoint_mqtt/endpoint_mqtt_test.go index 897d991..4336dfa 100644 --- a/internal/endpoint_mqtt/endpoint_mqtt_test.go +++ b/internal/endpoint_mqtt/endpoint_mqtt_test.go @@ -511,10 +511,7 @@ func Test_parametersSubscription_ChargingAndDischargeLimit(t *testing.T) { mockApplier.AssertExpectations(t) mockClient.AssertExpectations(t) - assert.Nil(t, endpoint.newParameter.ChargingLimit) - assert.Nil(t, endpoint.newParameter.DischargeLimit) - assert.Nil(t, endpoint.newParameter.DefaultACCouplePower) - assert.Nil(t, endpoint.newParameter.DefaultMode) + assert.Equal(t, models.ParameterPayload{}, endpoint.newParameter) } func Test_parametersSubscription_ChargingLimitAndMode(t *testing.T) { @@ -563,8 +560,5 @@ func Test_parametersSubscription_ChargingLimitAndMode(t *testing.T) { mockApplier.AssertExpectations(t) mockClient.AssertExpectations(t) - assert.Nil(t, endpoint.newParameter.ChargingLimit) - assert.Nil(t, endpoint.newParameter.DischargeLimit) - assert.Nil(t, endpoint.newParameter.DefaultACCouplePower) - assert.Nil(t, endpoint.newParameter.DefaultMode) + assert.Equal(t, models.ParameterPayload{}, endpoint.newParameter) } diff --git a/internal/growatt_app/client.go b/internal/growatt_app/client.go index 0682032..e19135e 100644 --- a/internal/growatt_app/client.go +++ b/internal/growatt_app/client.go @@ -9,13 +9,14 @@ import ( "net/http/cookiejar" "net/url" "nexa-mqtt/internal/misc" + "strings" "time" "github.com/google/uuid" ) type Client struct { - client *http.Client + client HttpClient serverUrl string username string password string @@ -35,11 +36,13 @@ func newClient(serverUrl string, username string, password string) *Client { slog.Info("setting server url (app)", slog.String("url", serverUrl)) return &Client{ - client: &http.Client{ - Transport: nil, - CheckRedirect: nil, - Jar: jar, - Timeout: 10 * time.Second, + client: &httpClient{ + client: &http.Client{ + Transport: nil, + CheckRedirect: nil, + Jar: jar, + Timeout: 10 * time.Second, + }, }, serverUrl: serverUrl, username: username, @@ -48,9 +51,27 @@ func newClient(serverUrl string, username string, password string) *Client { } } +func (h *Client) postForm(url string, data url.Values, responseBody any) error { + err := h.client.postForm(url, h.token, data, responseBody) + if err != nil { + if strings.Contains(err.Error(), "invalid character '<' looking for beginning of value") { + slog.Warn("JSON parse error - re-login", slog.String("error", err.Error())) + if err := h.Login(); err != nil { + slog.Error("could not re-login", slog.String("error", err.Error())) + misc.Panic(err) + } + return h.postForm(url, data, responseBody) + } else { + return err + } + } + + return nil +} + func (h *Client) loginGetToken() error { var data TokenResponse - if _, err := h.postForm("https://evcharge.growatt.com/ocpp/user", url.Values{ + if err := h.postForm("https://evcharge.growatt.com/ocpp/user", url.Values{ "cmd": {"shineLogin"}, "userId": {fmt.Sprintf("SHINE%s", h.username)}, "password": {h.password}, @@ -69,7 +90,7 @@ func (h *Client) Login() error { } var data LoginResult - if _, err := h.postForm(h.serverUrl+"/newTwoLoginAPIV2.do", url.Values{ + if err := h.postForm(h.serverUrl+"/newTwoLoginAPIV2.do", url.Values{ "userName": {h.username}, "password": {h.password}, "newLogin": {"1"}, @@ -97,7 +118,7 @@ func (h *Client) Login() error { func (h *Client) GetPlantList() (*PlantListV2, error) { var data PlantListV2 - if _, err := h.postForm(h.serverUrl+"/newTwoPlantAPI.do?op=getAllPlantListTwo", url.Values{ + if err := h.postForm(h.serverUrl+"/newTwoPlantAPI.do?op=getAllPlantListTwo", url.Values{ "plantStatus": {""}, "pageSize": {"20"}, "language": {"1"}, @@ -111,7 +132,7 @@ func (h *Client) GetPlantList() (*PlantListV2, error) { func (h *Client) GetNoahPlantInfo(plantId string) (*NoahPlantInfo, error) { var data NoahPlantInfo - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/noah/isPlantNoahSystem", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/noah/isPlantNoahSystem", url.Values{ "plantId": {plantId}, }, &data); err != nil { return nil, err @@ -128,7 +149,7 @@ func (h *Client) GetNoahPlantInfo(plantId string) (*NoahPlantInfo, error) { func (h *Client) GetNoahStatus(serialNumber string) (*NoahStatus, error) { var data NoahStatus - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getSystemStatus", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getSystemStatus", url.Values{ "deviceSn": {serialNumber}, }, &data); err != nil { return nil, err @@ -138,7 +159,7 @@ func (h *Client) GetNoahStatus(serialNumber string) (*NoahStatus, error) { func (h *Client) GetNoahInfo(serialNumber string) (*NexaInfo, error) { var data NexaInfo - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getNexaInfoBySn", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getNexaInfoBySn", url.Values{ "deviceSn": {serialNumber}, }, &data); err != nil { return nil, err @@ -149,7 +170,7 @@ func (h *Client) GetNoahInfo(serialNumber string) (*NexaInfo, error) { func (h *Client) GetBatteryData(serialNumber string) (*BatteryInfo, error) { var data BatteryInfo - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getBatteryData", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/getBatteryData", url.Values{ "deviceSn": {serialNumber}, }, &data); err != nil { return nil, err @@ -161,7 +182,7 @@ func (h *Client) GetBatteryData(serialNumber string) (*BatteryInfo, error) { func (h *Client) SetSystemOutputPower(serialNumber string, mode int, power float64) error { p := math.Max(0, math.Min(800, power)) var data map[string]any - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"system_out_put_power"}, "param1": {fmt.Sprintf("%d", mode)}, @@ -177,7 +198,7 @@ func (h *Client) SetChargingSoc(serialNumber string, chargingLimit float64, disc c := math.Max(70, math.Min(100, chargingLimit)) d := math.Max(0, math.Min(30, dischargeLimit)) var data map[string]any - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"charging_soc"}, "param1": {fmt.Sprintf("%.0f", c)}, @@ -191,7 +212,7 @@ func (h *Client) SetChargingSoc(serialNumber string, chargingLimit float64, disc func (h *Client) SetAllowGridCharging(serialNumber string, allow int) error { var data map[string]any - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"allow_grid_charging"}, "param1": {fmt.Sprintf("%d", allow)}, @@ -204,7 +225,7 @@ func (h *Client) SetAllowGridCharging(serialNumber string, allow int) error { func (h *Client) SetGridConnectionControl(serialNumber string, offlineEnable int) error { var data map[string]any - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"grid_connection_control"}, "param1": {fmt.Sprintf("%d", offlineEnable)}, @@ -217,7 +238,7 @@ func (h *Client) SetGridConnectionControl(serialNumber string, offlineEnable int func (h *Client) SetACCouplePowerControl(serialNumber string, _1000WEnable int) error { var data map[string]any - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"ac_couple_power_control"}, "param1": {fmt.Sprintf("%d", _1000WEnable)}, diff --git a/internal/growatt_app/client_http.go b/internal/growatt_app/client_http.go index 13ee2b3..f9b03dd 100644 --- a/internal/growatt_app/client_http.go +++ b/internal/growatt_app/client_http.go @@ -4,26 +4,32 @@ import ( "encoding/json" "fmt" "io" - "log/slog" "net/http" "net/url" - "nexa-mqtt/internal/misc" "strings" ) -func (h *Client) postForm(url string, data url.Values, responseBody any) (*http.Response, error) { +type httpClient struct { + client *http.Client +} + +type HttpClient interface { + postForm(url string, token string, data url.Values, responseBody any) error +} + +func (h *httpClient) postForm(url string, token string, data url.Values, responseBody any) error { req, err := http.NewRequest("POST", url, strings.NewReader(data.Encode())) if err != nil { - return nil, err + return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - if len(h.token) > 0 { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", h.token)) + if len(token) > 0 { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36") resp, err := h.client.Do(req) if err != nil { - return nil, err + return err } defer func(Body io.ReadCloser) { @@ -32,26 +38,18 @@ func (h *Client) postForm(url string, data url.Values, responseBody any) (*http. b, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return err } if resp.StatusCode != 200 { - return nil, fmt.Errorf("request failed: (HTTP %s) %s", resp.Status, string(b)) + return fmt.Errorf("request failed: (HTTP %s) %s", resp.Status, string(b)) } if responseBody != nil { if err := json.Unmarshal(b, &responseBody); err != nil { - if strings.Contains(err.Error(), "invalid character '<' looking for beginning of value") { - if err := h.Login(); err != nil { - slog.Error("could not re-login", slog.String("error", err.Error())) - misc.Panic(err) - } - return h.postForm(url, data, responseBody) - } else { - return nil, err - } + return err } } - return resp, nil + return nil } diff --git a/internal/growatt_app/client_http_test.go b/internal/growatt_app/client_http_test.go new file mode 100644 index 0000000..dbfde55 --- /dev/null +++ b/internal/growatt_app/client_http_test.go @@ -0,0 +1,100 @@ +package growatt_app + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +// ----- Test functions ----------------------------------------------------- + +type responseData struct { + AnInt int `json:"an_int"` + AString string `json:"a_string"` +} + +func Test_postRequest(t *testing.T) { + testData := []struct { + url string + token string + data url.Values + responseBody any + statusCode int + body string + errResult bool + }{ + { // invalid body + responseBody: responseData{}, + body: `{invalid json}`, + errResult: true, + }, + { // error statusCode + statusCode: 500, + errResult: true, + }, + // { // io.ReadAll() fails - how...? + // errResult: true, + // }, + // { // http.Client.Do() fails - how...? + // errResult: true, + // }, + { // request with token + token: "THETOKEN", + responseBody: responseData{}, + body: `{"an_int":33,"a_string":"another test"}`, + }, + { // http.NewRequest fails + url: "xyz://abc:8000f", + token: "", + errResult: true, + }, + { // ok with body + responseBody: responseData{}, + body: `{"an_int":22,"a_string":"test"}`, + }, + { // ok without body + body: "", + }, + } + + for inx, td := range testData { + t.Run(fmt.Sprintf("#%d", inx), func(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { + tokenExists := len(td.token) > 0 + hdr, headerExists := req.Header["Authorization"] + assert.Equal(t, tokenExists, headerExists) + if headerExists { + assert.Equal(t, fmt.Sprintf("Bearer %s", td.token), hdr[0]) + } + + ctHdr, ctHdrExists := req.Header["Content-Type"] + assert.True(t, ctHdrExists) + assert.Equal(t, "application/x-www-form-urlencoded", ctHdr[0]) + + _, uaHdrExists := req.Header["User-Agent"] + assert.True(t, uaHdrExists) + + wr.WriteHeader(td.statusCode) + wr.Write([]byte(td.body)) + })) + + client := httpClient{client: server.Client()} + if len(td.url) == 0 { + td.url = server.URL + } + if td.statusCode == 0 { + td.statusCode = 200 + } + + // var data TokenResponse + err := client.postForm(td.url, td.token, url.Values{}, td.responseBody) + assert.Equal(t, td.errResult, err != nil) + server.Close() + }) + } +} From 34ced8d5ab7c3e4d7ee68902d6f5315572ca6adf Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 28 Jun 2025 15:49:59 +0200 Subject: [PATCH 22/30] Unit test package growatt_app (client.go) --- internal/growatt_app/client.go | 14 +- internal/growatt_app/client_test.go | 869 ++++++++++++++++++++++++++++ internal/growatt_app/models.go | 166 +++--- 3 files changed, 964 insertions(+), 85 deletions(-) create mode 100644 internal/growatt_app/client_test.go diff --git a/internal/growatt_app/client.go b/internal/growatt_app/client.go index e19135e..0031a6f 100644 --- a/internal/growatt_app/client.go +++ b/internal/growatt_app/client.go @@ -139,9 +139,7 @@ func (h *Client) GetNoahPlantInfo(plantId string) (*NoahPlantInfo, error) { } if !data.Obj.IsPlantHaveNexa { - err := errors.New("No NEXA device") - slog.Error(err.Error()) - misc.Panic(err) + return nil, errors.New("No NEXA device") } return &data, nil @@ -181,7 +179,7 @@ func (h *Client) GetBatteryData(serialNumber string) (*BatteryInfo, error) { func (h *Client) SetSystemOutputPower(serialNumber string, mode int, power float64) error { p := math.Max(0, math.Min(800, power)) - var data map[string]any + var data SetResponse if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"system_out_put_power"}, @@ -197,7 +195,7 @@ func (h *Client) SetSystemOutputPower(serialNumber string, mode int, power float func (h *Client) SetChargingSoc(serialNumber string, chargingLimit float64, dischargeLimit float64) error { c := math.Max(70, math.Min(100, chargingLimit)) d := math.Max(0, math.Min(30, dischargeLimit)) - var data map[string]any + var data SetResponse if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"charging_soc"}, @@ -211,7 +209,7 @@ func (h *Client) SetChargingSoc(serialNumber string, chargingLimit float64, disc } func (h *Client) SetAllowGridCharging(serialNumber string, allow int) error { - var data map[string]any + var data SetResponse if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"allow_grid_charging"}, @@ -224,7 +222,7 @@ func (h *Client) SetAllowGridCharging(serialNumber string, allow int) error { } func (h *Client) SetGridConnectionControl(serialNumber string, offlineEnable int) error { - var data map[string]any + var data SetResponse if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"grid_connection_control"}, @@ -237,7 +235,7 @@ func (h *Client) SetGridConnectionControl(serialNumber string, offlineEnable int } func (h *Client) SetACCouplePowerControl(serialNumber string, _1000WEnable int) error { - var data map[string]any + var data SetResponse if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"ac_couple_power_control"}, diff --git a/internal/growatt_app/client_test.go b/internal/growatt_app/client_test.go new file mode 100644 index 0000000..44e5595 --- /dev/null +++ b/internal/growatt_app/client_test.go @@ -0,0 +1,869 @@ +package growatt_app + +import ( + "errors" + "net/http/cookiejar" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// ----- Mocks -------------------------------------------------------------- + +// MockHttpClient implements HttpClient +type MockHttpClient struct { + mock.Mock +} + +func (h *MockHttpClient) postForm(url string, token string, data url.Values, responseBody any) error { + args := h.Called(url, token, data, responseBody) + return args.Error(0) +} + +// ----- Test functions ----------------------------------------------------- + +func setupMocks(t *testing.T) (*MockHttpClient, *Client) { + mockHttpClient := MockHttpClient{} + jar, err := cookiejar.New(nil) + assert.Nil(t, err) + + client := Client{ + client: &mockHttpClient, + serverUrl: "https://server-api.growatt.com", + username: "user", + password: "secret", + jar: jar, + } + + return &mockHttpClient, &client +} + +func Test_postForm_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "http://someurl", + "", + url.Values{}, + nil, + ).Return(nil) + + err := client.postForm("http://someurl", url.Values{}, nil) + assert.NoError(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func Test_postForm_AnyError(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "http://someurl", + "", + url.Values{}, + nil, + ).Return(errors.New("some error")) + + err := client.postForm("http://someurl", url.Values{}, nil) + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func Test_postForm_RetryLogin(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "http://someurl", + "", + url.Values{}, + nil, + ).Return(errors.New("invalid character '<' looking for beginning of value")) + + mockHttpClient.On( + "postForm", + "https://evcharge.growatt.com/ocpp/user", + "", + url.Values{ + "cmd": {"shineLogin"}, + "userId": {"SHINEuser"}, + "password": {"secret"}, + "lan": {"1"}, + }, + &TokenResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*TokenResponse) + responseBody.Token = "THE_TOKEN" + }).Return(nil) + + expectedMatch := mock.MatchedBy(func(m url.Values) bool { + return m.Get("userName") == "user" && + m.Get("password") == "secret" && + m.Get("newLogin") == "1" && + m.Get("appType") == "ShinePhone" + }) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/newTwoLoginAPIV2.do", + "THE_TOKEN", + expectedMatch, + &LoginResult{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*LoginResult) + responseBody.Back.Success = true + }).Return(nil) + + mockHttpClient.On( + "postForm", + "http://someurl", + "THE_TOKEN", + url.Values{}, + nil, + ).Return(nil) + + err := client.postForm("http://someurl", url.Values{}, nil) + assert.NoError(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func Test_postForm_RetryLoginFails(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "http://someurl", + "", + url.Values{}, + nil, + ).Return(errors.New("invalid character '<' looking for beginning of value")) + + mockHttpClient.On( + "postForm", + "https://evcharge.growatt.com/ocpp/user", + "", + url.Values{ + "cmd": {"shineLogin"}, + "userId": {"SHINEuser"}, + "password": {"secret"}, + "lan": {"1"}, + }, + &TokenResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*TokenResponse) + responseBody.Token = "THE_TOKEN" + }).Return(nil) + + expectedMatch := mock.MatchedBy(func(m url.Values) bool { + return m.Get("userName") == "user" && + m.Get("password") == "secret" && + m.Get("newLogin") == "1" && + m.Get("appType") == "ShinePhone" + }) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/newTwoLoginAPIV2.do", + "THE_TOKEN", + expectedMatch, + &LoginResult{}, + ).Return(errors.New("login failed")) + + defer func() { + if r := recover(); r != nil { + mockHttpClient.AssertExpectations(t) + } + }() + + client.postForm("http://someurl", url.Values{}, nil) + t.Errorf("Test failed, panic was expected") +} + +func TestLogin_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://evcharge.growatt.com/ocpp/user", + "", + url.Values{ + "cmd": {"shineLogin"}, + "userId": {"SHINEuser"}, + "password": {"secret"}, + "lan": {"1"}, + }, + &TokenResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*TokenResponse) + responseBody.Token = "THE_TOKEN" + }).Return(nil) + + expectedMatch := mock.MatchedBy(func(m url.Values) bool { + return m.Get("userName") == "user" && + m.Get("password") == "secret" && + m.Get("newLogin") == "1" && + m.Get("appType") == "ShinePhone" + }) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/newTwoLoginAPIV2.do", + "THE_TOKEN", + expectedMatch, + &LoginResult{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*LoginResult) + responseBody.Back.Success = true + }).Return(nil) + + err := client.Login() + assert.NoError(t, err) + assert.Equal(t, "THE_TOKEN", client.token) + + mockHttpClient.AssertExpectations(t) +} + +func TestLogin_NoSuccess(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://evcharge.growatt.com/ocpp/user", + "", + url.Values{ + "cmd": {"shineLogin"}, + "userId": {"SHINEuser"}, + "password": {"secret"}, + "lan": {"1"}, + }, + &TokenResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*TokenResponse) + responseBody.Token = "THE_TOKEN" + }).Return(nil) + + expectedMatch := mock.MatchedBy(func(m url.Values) bool { + return m.Get("userName") == "user" && + m.Get("password") == "secret" && + m.Get("newLogin") == "1" && + m.Get("appType") == "ShinePhone" + }) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/newTwoLoginAPIV2.do", + "THE_TOKEN", + expectedMatch, + &LoginResult{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*LoginResult) + responseBody.Back.Success = false + }).Return(nil) + + err := client.Login() + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestLogin_loginGetTokenFail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://evcharge.growatt.com/ocpp/user", + "", + url.Values{ + "cmd": {"shineLogin"}, + "userId": {"SHINEuser"}, + "password": {"secret"}, + "lan": {"1"}, + }, + &TokenResponse{}, + ).Return(errors.New("login error")) + + err := client.Login() + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestLogin_newTwoLoginFail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://evcharge.growatt.com/ocpp/user", + "", + url.Values{ + "cmd": {"shineLogin"}, + "userId": {"SHINEuser"}, + "password": {"secret"}, + "lan": {"1"}, + }, + &TokenResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*TokenResponse) + responseBody.Token = "THE_TOKEN" + }).Return(nil) + + expectedMatch := mock.MatchedBy(func(m url.Values) bool { + return m.Get("userName") == "user" && + m.Get("password") == "secret" && + m.Get("newLogin") == "1" && + m.Get("appType") == "ShinePhone" + }) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/newTwoLoginAPIV2.do", + "THE_TOKEN", + expectedMatch, + &LoginResult{}, + ).Return(errors.New("newTwoLoginAPIV2 fail")) + + err := client.Login() + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetPlantList_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/newTwoPlantAPI.do?op=getAllPlantListTwo", + "", + url.Values{ + "plantStatus": {""}, + "pageSize": {"20"}, + "language": {"1"}, + "toPageNum": {"1"}, + "order": {"1"}, + }, + &PlantListV2{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*PlantListV2) + *responseBody = PlantListV2{ + PlantList: []struct { + ID int `json:"id"` + }{ + {ID: 1}, + {ID: 2}, + }, + } + }).Return(nil) + + data, err := client.GetPlantList() + + assert.NoError(t, err) + assert.Equal(t, PlantListV2{ + PlantList: []struct { + ID int `json:"id"` + }{ + {ID: 1}, + {ID: 2}, + }, + }, *data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetPlantList_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/newTwoPlantAPI.do?op=getAllPlantListTwo", + "", + url.Values{ + "plantStatus": {""}, + "pageSize": {"20"}, + "language": {"1"}, + "toPageNum": {"1"}, + "order": {"1"}, + }, + &PlantListV2{}, + ).Return(errors.New("newTwoPlantAPI fail")) + + data, err := client.GetPlantList() + assert.Error(t, err) + assert.Nil(t, data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetNoahPlantInfo_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/noah/isPlantNoahSystem", + "", + url.Values{ + "plantId": {"2"}, + }, + &NoahPlantInfo{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*NoahPlantInfo) + *responseBody = NoahPlantInfo{ + ResponseContainerV2: ResponseContainerV2[NoahPlantInfoObj]{ + Msg: "", + Result: 0, + Obj: NoahPlantInfoObj{ + IsPlantHaveNexa: true, + }, + }, + } + }).Return(nil) + + data, err := client.GetNoahPlantInfo("2") + + assert.NoError(t, err) + assert.Equal(t, NoahPlantInfo{ + ResponseContainerV2: ResponseContainerV2[NoahPlantInfoObj]{ + Msg: "", + Result: 0, + Obj: NoahPlantInfoObj{ + IsPlantHaveNexa: true, + }, + }, + }, *data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetNoahPlantInfo_NoNexa(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/noah/isPlantNoahSystem", + "", + url.Values{ + "plantId": {"2"}, + }, + &NoahPlantInfo{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*NoahPlantInfo) + *responseBody = NoahPlantInfo{ + ResponseContainerV2: ResponseContainerV2[NoahPlantInfoObj]{ + Msg: "", + Result: 0, + Obj: NoahPlantInfoObj{ + IsPlantHaveNexa: false, + }, + }, + } + }).Return(nil) + + data, err := client.GetNoahPlantInfo("2") + + assert.Error(t, err) + assert.Nil(t, data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetNoahPlantInfo_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/noah/isPlantNoahSystem", + "", + url.Values{ + "plantId": {"2"}, + }, + &NoahPlantInfo{}, + ).Return(errors.New("isPlantNoahSystem fail")) + + data, err := client.GetNoahPlantInfo("2") + + assert.Error(t, err) + assert.Nil(t, data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetNoahStatus_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/getSystemStatus", + "", + url.Values{ + "deviceSn": {"serial123"}, + }, + &NoahStatus{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*NoahStatus) + *responseBody = NoahStatus{} + }).Return(nil) + + data, err := client.GetNoahStatus("serial123") + + assert.NoError(t, err) + assert.Equal(t, NoahStatus{}, *data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetNoahStatus_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/getSystemStatus", + "", + url.Values{ + "deviceSn": {"serial123"}, + }, + &NoahStatus{}, + ).Return(errors.New("getSystemStatus fail")) + + data, err := client.GetNoahStatus("serial123") + + assert.Error(t, err) + assert.Nil(t, data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetNoahInfo_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/getNexaInfoBySn", + "", + url.Values{ + "deviceSn": {"serial123"}, + }, + &NexaInfo{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*NexaInfo) + *responseBody = NexaInfo{} + }).Return(nil) + + data, err := client.GetNoahInfo("serial123") + + assert.NoError(t, err) + assert.Equal(t, NexaInfo{}, *data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetNoahInfo_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/getNexaInfoBySn", + "", + url.Values{ + "deviceSn": {"serial123"}, + }, + &NexaInfo{}, + ).Return(errors.New("getSystemStatus fail")) + + data, err := client.GetNoahInfo("serial123") + + assert.Error(t, err) + assert.Nil(t, data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetBatteryData_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/getBatteryData", + "", + url.Values{ + "deviceSn": {"serial123"}, + }, + &BatteryInfo{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*BatteryInfo) + *responseBody = BatteryInfo{} + }).Return(nil) + + data, err := client.GetBatteryData("serial123") + + assert.NoError(t, err) + assert.Equal(t, BatteryInfo{}, *data) + + mockHttpClient.AssertExpectations(t) +} + +func TestGetBatteryData_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/getBatteryData", + "", + url.Values{ + "deviceSn": {"serial123"}, + }, + &BatteryInfo{}, + ).Return(errors.New("getBatteryData fail")) + + data, err := client.GetBatteryData("serial123") + + assert.Error(t, err) + assert.Nil(t, data) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetSystemOutputPower_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"system_out_put_power"}, + "param1": {"0"}, + "param2": {"200"}, + }, + &SetResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*SetResponse) + *responseBody = SetResponse{} + }).Return(nil) + + err := client.SetSystemOutputPower("serial123", 0, 200) + + assert.NoError(t, err) + // assert.Equal(t, BatteryInfo{}, *data) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetSystemOutputPower_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"system_out_put_power"}, + "param1": {"0"}, + "param2": {"200"}, + }, + &SetResponse{}, + ).Return(errors.New("noahDeviceApi/nexa/set system_out_put_power fail")) + + err := client.SetSystemOutputPower("serial123", 0, 200) + + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetChargingSoc_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"charging_soc"}, + "param1": {"85"}, + "param2": {"15"}, + }, + &SetResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*SetResponse) + *responseBody = SetResponse{} + }).Return(nil) + + err := client.SetChargingSoc("serial123", 85, 15) + + assert.NoError(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetChargingSoc_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"charging_soc"}, + "param1": {"85"}, + "param2": {"15"}, + }, + &SetResponse{}, + ).Return(errors.New("noahDeviceApi/nexa/set charging_soc fail")) + + err := client.SetChargingSoc("serial123", 85, 15) + + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetAllowGridCharging_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"allow_grid_charging"}, + "param1": {"1"}, + }, + &SetResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*SetResponse) + *responseBody = SetResponse{} + }).Return(nil) + + err := client.SetAllowGridCharging("serial123", 1) + + assert.NoError(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetAllowGridCharging_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"allow_grid_charging"}, + "param1": {"1"}, + }, + &SetResponse{}, + ).Return(errors.New("noahDeviceApi/nexa/set allow_grid_charging fail")) + + err := client.SetAllowGridCharging("serial123", 1) + + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetGridConnectionControl_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"grid_connection_control"}, + "param1": {"1"}, + }, + &SetResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*SetResponse) + *responseBody = SetResponse{} + }).Return(nil) + + err := client.SetGridConnectionControl("serial123", 1) + + assert.NoError(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetGridConnectionControl_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"grid_connection_control"}, + "param1": {"1"}, + }, + &SetResponse{}, + ).Return(errors.New("noahDeviceApi/nexa/set grid_connection_control fail")) + + err := client.SetGridConnectionControl("serial123", 1) + + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetACCouplePowerControl_Ok(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"ac_couple_power_control"}, + "param1": {"1"}, + }, + &SetResponse{}, + ).Run(func(args mock.Arguments) { + responseBody := args.Get(3).(*SetResponse) + *responseBody = SetResponse{} + }).Return(nil) + + err := client.SetACCouplePowerControl("serial123", 1) + + assert.NoError(t, err) + + mockHttpClient.AssertExpectations(t) +} + +func TestSetACCouplePowerControl_Fail(t *testing.T) { + mockHttpClient, client := setupMocks(t) + + mockHttpClient.On( + "postForm", + "https://server-api.growatt.com/noahDeviceApi/nexa/set", + "", + url.Values{ + "serialNum": {"serial123"}, + "type": {"ac_couple_power_control"}, + "param1": {"1"}, + }, + &SetResponse{}, + ).Return(errors.New("noahDeviceApi/nexa/set ac_couple_power_control fail")) + + err := client.SetACCouplePowerControl("serial123", 1) + + assert.Error(t, err) + + mockHttpClient.AssertExpectations(t) +} diff --git a/internal/growatt_app/models.go b/internal/growatt_app/models.go index 5bd7fdb..f8704de 100644 --- a/internal/growatt_app/models.go +++ b/internal/growatt_app/models.go @@ -28,93 +28,101 @@ type ResponseContainerV2[T any] struct { Obj T `json:"obj"` } +type NoahPlantInfoObj struct { + IsPlantNoahSystem bool `json:"isPlantNoahSystem"` + PlantID string `json:"plantId"` + IsPlantHaveNoah bool `json:"isPlantHaveNoah"` + IsPlantHaveNexa bool `json:"isPlantHaveNexa"` + DeviceSn string `json:"deviceSn"` + PlantName string `json:"plantName"` +} + type NoahPlantInfo struct { - ResponseContainerV2[struct { - IsPlantNoahSystem bool `json:"isPlantNoahSystem"` - PlantID string `json:"plantId"` - IsPlantHaveNoah bool `json:"isPlantHaveNoah"` - IsPlantHaveNexa bool `json:"isPlantHaveNexa"` - DeviceSn string `json:"deviceSn"` - PlantName string `json:"plantName"` - }] + ResponseContainerV2[NoahPlantInfoObj] +} + +type NoahStatusObj struct { + LoadPower string `json:"loadPower"` // new + GridPower string `json:"gridPower"` // new + ChargePower string `json:"chargePower"` + GroplugPower string `json:"groplugPower"` // new + WorkMode string `json:"workMode"` + Soc string `json:"soc"` + EastronStatus string `json:"eastronStatus"` // new + AssociatedInvSn string `json:"associatedInvSn"` + BatteryNum string `json:"batteryNum"` + ProfitToday string `json:"profitToday"` + PlantID string `json:"plantId"` + DisChargePower string `json:"disChargePower"` + EacTotal string `json:"eacTotal"` + EacToday string `json:"eacToday"` + IsHaveCt string `json:"isHaveCt"` // new + OnOffGrid string `json:"onOffGrid"` // new + Pac string `json:"pac"` + Ppv string `json:"ppv"` + Alias string `json:"alias"` + ProfitTotal string `json:"profitTotal"` + MoneyUnit string `json:"moneyUnit"` + GroplugNum string `json:"groplugNum"` // new + OtherPower string `json:"otherPower"` // new + Status string `json:"status"` // 1 = online, -1 = offline, 5 = heating } type NoahStatus struct { - ResponseContainerV2[struct { - LoadPower string `json:"loadPower"` // new - GridPower string `json:"gridPower"` // new - ChargePower string `json:"chargePower"` - GroplugPower string `json:"groplugPower"` // new - WorkMode string `json:"workMode"` - Soc string `json:"soc"` - EastronStatus string `json:"eastronStatus"` // new - AssociatedInvSn string `json:"associatedInvSn"` - BatteryNum string `json:"batteryNum"` - ProfitToday string `json:"profitToday"` - PlantID string `json:"plantId"` - DisChargePower string `json:"disChargePower"` - EacTotal string `json:"eacTotal"` - EacToday string `json:"eacToday"` - IsHaveCt string `json:"isHaveCt"` // new - OnOffGrid string `json:"onOffGrid"` // new - Pac string `json:"pac"` - Ppv string `json:"ppv"` - Alias string `json:"alias"` - ProfitTotal string `json:"profitTotal"` - MoneyUnit string `json:"moneyUnit"` - GroplugNum string `json:"groplugNum"` // new - OtherPower string `json:"otherPower"` // new - Status string `json:"status"` // 1 = online, -1 = offline, 5 = heating - }] + ResponseContainerV2[NoahStatusObj] +} + +type NexaInfoObj struct { + Noah struct { + TimeSegment []map[string]string `json:"time_segment"` + AntiBackflowEnable string `json:"antiBackflowEnable"` // new + AcCouplePowerControl string `json:"acCouplePowerControl"` // new + AmmeterModel string `json:"ammeterModel"` // new + AmmeterSn string `json:"ammeterSn"` // new + ShellyList []interface{} `json:"shellyList"` // new + GridSet string `json:"gridSet"` // new + AntiBackflowPowerPercentage string `json:"antiBackflowPowerPercentage"` // new + BatSns []string `json:"batSns"` + ManName string `json:"manName"` + AssociatedInvSn string `json:"associatedInvSn"` + PlantID string `json:"plantId"` + ChargingSocHighLimit string `json:"chargingSocHighLimit"` + DefaultMode string `json:"defaultMode"` // new + DefaultACCouplePower string `json:"defaultACCouplePower"` // new + Version string `json:"version"` + DeviceSn string `json:"deviceSn"` + ChargingSocLowLimit string `json:"chargingSocLowLimit"` + FormulaMoney string `json:"formulaMoney"` + Alias string `json:"alias"` + Model string `json:"model"` + CtType string `json:"ctType"` // new + AllowGridCharging string `json:"allowGridCharging"` // new + GridConnectionControl string `json:"gridConnectionControl"` // new + PlantName string `json:"plantName"` + AssociatedInvManAndModel int `json:"associatedInvManAndModel"` + TempType string `json:"tempType"` + MoneyUnitText string `json:"moneyUnitText"` + } `json:"noah"` + PlantList []struct { + PlantID string `json:"plantId"` + PlantImgName interface{} `json:"plantImgName"` + PlantName string `json:"plantName"` + } `json:"plantList"` + UnitList map[string]string `json:"unitList"` // new } type NexaInfo struct { - ResponseContainerV2[struct { - Noah struct { - TimeSegment []map[string]string `json:"time_segment"` - AntiBackflowEnable string `json:"antiBackflowEnable"` // new - AcCouplePowerControl string `json:"acCouplePowerControl"` // new - AmmeterModel string `json:"ammeterModel"` // new - AmmeterSn string `json:"ammeterSn"` // new - ShellyList []interface{} `json:"shellyList"` // new - GridSet string `json:"gridSet"` // new - AntiBackflowPowerPercentage string `json:"antiBackflowPowerPercentage"` // new - BatSns []string `json:"batSns"` - ManName string `json:"manName"` - AssociatedInvSn string `json:"associatedInvSn"` - PlantID string `json:"plantId"` - ChargingSocHighLimit string `json:"chargingSocHighLimit"` - DefaultMode string `json:"defaultMode"` // new - DefaultACCouplePower string `json:"defaultACCouplePower"` // new - Version string `json:"version"` - DeviceSn string `json:"deviceSn"` - ChargingSocLowLimit string `json:"chargingSocLowLimit"` - FormulaMoney string `json:"formulaMoney"` - Alias string `json:"alias"` - Model string `json:"model"` - CtType string `json:"ctType"` // new - AllowGridCharging string `json:"allowGridCharging"` // new - GridConnectionControl string `json:"gridConnectionControl"` // new - PlantName string `json:"plantName"` - AssociatedInvManAndModel int `json:"associatedInvManAndModel"` - TempType string `json:"tempType"` - MoneyUnitText string `json:"moneyUnitText"` - } `json:"noah"` - PlantList []struct { - PlantID string `json:"plantId"` - PlantImgName interface{} `json:"plantImgName"` - PlantName string `json:"plantName"` - } `json:"plantList"` - UnitList map[string]string `json:"unitList"` // new - }] + ResponseContainerV2[NexaInfoObj] +} + +type BatteryInfoObj struct { + Batter []BatteryDetails `json:"batter"` + TempType string `json:"tempType"` + Time string `json:"time"` } type BatteryInfo struct { - ResponseContainerV2[struct { - Batter []BatteryDetails `json:"batter"` - TempType string `json:"tempType"` - Time string `json:"time"` - }] + ResponseContainerV2[BatteryInfoObj] } type BatteryDetails struct { @@ -122,3 +130,7 @@ type BatteryDetails struct { Soc string `json:"soc"` Temp string `json:"temp"` } + +type SetResponse struct { + ResponseContainerV2[any] +} From 59489221253329a4acd5b4ce0042389aeca5bafa Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 30 Jun 2025 10:47:24 +0200 Subject: [PATCH 23/30] Unit test package growatt_app (payload.go) --- internal/growatt_app/payload_test.go | 81 ++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 internal/growatt_app/payload_test.go diff --git a/internal/growatt_app/payload_test.go b/internal/growatt_app/payload_test.go new file mode 100644 index 0000000..ecaaf32 --- /dev/null +++ b/internal/growatt_app/payload_test.go @@ -0,0 +1,81 @@ +package growatt_app + +import ( + "nexa-mqtt/pkg/models" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_devicePayload(t *testing.T) { + noahStatus := NoahStatus{ + ResponseContainerV2[NoahStatusObj]{ + Obj: NoahStatusObj{ + LoadPower: "400", + GridPower: "0", + ChargePower: "132", + GroplugPower: "0", + WorkMode: "0", + Soc: "93", + EastronStatus: "-1", + //AssociatedInvSn: nil, + BatteryNum: "1", + ProfitToday: "0", + PlantID: "10421077", + DisChargePower: "0", + EacTotal: "9.6", + EacToday: "3.3", + IsHaveCt: "false", + OnOffGrid: "0", + Pac: "-400", + Ppv: "538", + Alias: "NEXA 2000", + ProfitTotal: "0", + MoneyUnit: "â¬", + GroplugNum: "0", + OtherPower: "-400", + Status: "6", + }, + }, + } + + dp := devicePayload(&noahStatus) + + assert.Equal(t, -400.0, dp.OutputPower) + assert.Equal(t, 538.0, dp.SolarPower) + assert.Equal(t, 93.0, dp.Soc) + assert.Equal(t, 132.0, dp.ChargePower) + assert.Equal(t, 0.0, dp.DischargePower) + assert.Equal(t, 1, dp.BatteryNum) + assert.Equal(t, 9.6, dp.GenerationTotalEnergy) + assert.Equal(t, 3.3, dp.GenerationTodayEnergy) + assert.Equal(t, models.WorkMode("load_first"), dp.WorkMode) + assert.Equal(t, "on_grid", dp.Status) +} + +func Test_batteryPayload(t *testing.T) { + batteryDetails := BatteryDetails{ + Temp: "39", + SerialNum: "serial123", + Soc: "93", + } + + bp := batteryPayload(&batteryDetails) + + assert.Equal(t, "serial123", bp.SerialNumber) + assert.Equal(t, 93.0, bp.Soc) + assert.Equal(t, 39.0, bp.Temperature) +} + +func Test_parameterPayload(t *testing.T) { + nexaInfo := NexaInfo{} + + nexaInfo.Obj.Noah.ChargingSocHighLimit = "95" + nexaInfo.Obj.Noah.DefaultMode = "0" + nexaInfo.Obj.Noah.DefaultACCouplePower = "100" + nexaInfo.Obj.Noah.ChargingSocLowLimit = "11" + + pp := parameterPayload(&nexaInfo) + + assert.Equal(t, 95.0, pp.ChargingLimit) +} From 06bb1303b3b2dc282f17b6a1a25283f1a0f398e2 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 30 Jun 2025 11:34:57 +0200 Subject: [PATCH 24/30] Fix condition for re-login. More error logs. --- internal/growatt_app/client.go | 7 +++++-- internal/growatt_app/client_http.go | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/growatt_app/client.go b/internal/growatt_app/client.go index 0031a6f..a244a0a 100644 --- a/internal/growatt_app/client.go +++ b/internal/growatt_app/client.go @@ -54,8 +54,11 @@ func newClient(serverUrl string, username string, password string) *Client { func (h *Client) postForm(url string, data url.Values, responseBody any) error { err := h.client.postForm(url, h.token, data, responseBody) if err != nil { - if strings.Contains(err.Error(), "invalid character '<' looking for beginning of value") { - slog.Warn("JSON parse error - re-login", slog.String("error", err.Error())) + notLoggedIn := strings.Contains(err.Error(), "Dear user, you have not login to the system") || + strings.Contains(err.Error(), "invalid character '<' looking for beginning of value") + + if notLoggedIn { + slog.Warn("re-login", slog.String("error", err.Error())) if err := h.Login(); err != nil { slog.Error("could not re-login", slog.String("error", err.Error())) misc.Panic(err) diff --git a/internal/growatt_app/client_http.go b/internal/growatt_app/client_http.go index f9b03dd..38b6353 100644 --- a/internal/growatt_app/client_http.go +++ b/internal/growatt_app/client_http.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "net/url" "strings" @@ -20,6 +21,7 @@ type HttpClient interface { func (h *httpClient) postForm(url string, token string, data url.Values, responseBody any) error { req, err := http.NewRequest("POST", url, strings.NewReader(data.Encode())) if err != nil { + slog.Error("http.NewRequest failed (app)", slog.String("error", err.Error())) return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -29,6 +31,7 @@ func (h *httpClient) postForm(url string, token string, data url.Values, respons req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36") resp, err := h.client.Do(req) if err != nil { + slog.Error("http.Client.Do failed (app)", slog.String("error", err.Error())) return err } @@ -38,15 +41,19 @@ func (h *httpClient) postForm(url string, token string, data url.Values, respons b, err := io.ReadAll(resp.Body) if err != nil { + slog.Error("io.ReadAll failed (app)", slog.String("error", err.Error())) return err } if resp.StatusCode != 200 { - return fmt.Errorf("request failed: (HTTP %s) %s", resp.Status, string(b)) + err := fmt.Errorf("request failed: (HTTP %s) %s", resp.Status, string(b)) + slog.Error("StatusCode != 200 (app)", slog.String("error", err.Error())) + return err } if responseBody != nil { if err := json.Unmarshal(b, &responseBody); err != nil { + slog.Error("json.Unmarshal failed (app)", slog.String("error", err.Error())) return err } } From bc09e498962cec5dbbc947d0570fc67da966e6e0 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 30 Jun 2025 17:02:50 +0200 Subject: [PATCH 25/30] Update Dockerfile to nexa --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 362a02d..fe7d636 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,17 +13,18 @@ RUN go mod download # Build the application COPY . . -RUN go build -o noah-mqtt cmd/noah-mqtt/main.go +ENV CGO_ENABLED=0 +RUN go build -o nexa-mqtt cmd/nexa-mqtt/main.go # scratch image to run FROM scratch # Copy built binaries -COPY --from=builder /app/noah-mqtt /noah-mqtt +COPY --from=builder /app/nexa-mqtt /nexa-mqtt COPY LICENSE / COPY passwd /etc/passwd COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Set permissions and entry point USER gouser -ENTRYPOINT ["/noah-mqtt"] +ENTRYPOINT ["/nexa-mqtt"] From 3b1c9ec7a7496c0c4161cd62e70867a4af1e1cbc Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 1 Jul 2025 11:48:22 +0200 Subject: [PATCH 26/30] Github actions --- .gitattributes | 1 + .github/workflows/main.yml | 13 ------------- .gitignore | 1 + .goreleaser.yaml | 31 ++++++++++++++++--------------- Dockerfile_goreleaser | 4 ++-- 5 files changed, 20 insertions(+), 30 deletions(-) diff --git a/.gitattributes b/.gitattributes index fcadb2c..d0c0c4c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text eol=lf +*.png binary diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a3a1c0c..ad61034 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -36,16 +36,3 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: 'release: Trigger mtrossbach/hassio-addons build' - if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' - run: | - TAG=${GITHUB_REF#refs/*/} - echo "Triggering with tag '$TAG'" - curl \ - -X POST \ - -H "Authorization: token ${{ secrets.WORKFLOW_PAT }}" \ - -H "Accept: application/vnd.github.everest-preview+json" \ - -H "Content-Type: application/json" \ - https://api.github.com/repos/mtrossbach/hassio-addons/dispatches \ - --data "{\"event_type\": \"release\", \"client_payload\": { \"version\": \"${TAG:1}\"}}" \ No newline at end of file diff --git a/.gitignore b/.gitignore index fa1e1fa..0e1468c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ dist /nexa-mqtt build/ +tmp/ cmd/nexa-mqtt/__debug_bin* \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 02911ea..5e165ed 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,4 +1,5 @@ -project_name: noah-mqtt +project_name: nexa-mqtt +version: 2 before: hooks: - go mod tidy @@ -13,7 +14,7 @@ builds: - darwin_arm64 - windows_amd64 - windows_arm64 - main: ./cmd/noah-mqtt + main: ./cmd/nexa-mqtt archives: - files: - LICENSE @@ -29,14 +30,14 @@ dockers: - --platform=linux/arm64/v8 - --label=org.opencontainers.image.title={{ .ProjectName }} - --label=org.opencontainers.image.description={{ .ProjectName }} - - --label=org.opencontainers.image.url=https://github.com/mtrossbach/{{ .ProjectName }} - - --label=org.opencontainers.image.source=https://github.com/mtrossbach/{{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/mgerczuk/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/mgerczuk/{{ .ProjectName }} - --label=org.opencontainers.image.version={{ .Version }} - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} - --label=org.opencontainers.image.revision={{ .FullCommit }} - --label=org.opencontainers.image.licenses=Apache-2.0 image_templates: - - &arm64v8_image "ghcr.io/mtrossbach/{{ .ProjectName }}:{{ .Version }}-arm64v8" + - &arm64v8_image "ghcr.io/mgerczuk/{{ .ProjectName }}:{{ .Version }}-arm64v8" extra_files: - LICENSE - passwd @@ -49,14 +50,14 @@ dockers: - --platform=linux/arm/v6 - --label=org.opencontainers.image.title={{ .ProjectName }} - --label=org.opencontainers.image.description={{ .ProjectName }} - - --label=org.opencontainers.image.url=https://github.com/mtrossbach/{{ .ProjectName }} - - --label=org.opencontainers.image.source=https://github.com/mtrossbach/{{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/mgerczuk/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/mgerczuk/{{ .ProjectName }} - --label=org.opencontainers.image.version={{ .Version }} - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} - --label=org.opencontainers.image.revision={{ .FullCommit }} - --label=org.opencontainers.image.licenses=Apache-2.0 image_templates: - - &armv6_image "ghcr.io/mtrossbach/{{ .ProjectName }}:{{ .Version }}-armv6" + - &armv6_image "ghcr.io/mgerczuk/{{ .ProjectName }}:{{ .Version }}-armv6" extra_files: - LICENSE - passwd @@ -68,25 +69,25 @@ dockers: - --platform=linux/amd64 - --label=org.opencontainers.image.title={{ .ProjectName }} - --label=org.opencontainers.image.description={{ .ProjectName }} - - --label=org.opencontainers.image.url=https://github.com/mtrossbach/{{ .ProjectName }} - - --label=org.opencontainers.image.source=https://github.com/mtrossbach/{{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/mgerczuk/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/mgerczuk/{{ .ProjectName }} - --label=org.opencontainers.image.version={{ .Version }} - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} - --label=org.opencontainers.image.revision={{ .FullCommit }} - --label=org.opencontainers.image.licenses=Apache-2.0 image_templates: - - &amd64_image "ghcr.io/mtrossbach/{{ .ProjectName }}:{{ .Version }}-amd64" + - &amd64_image "ghcr.io/mgerczuk/{{ .ProjectName }}:{{ .Version }}-amd64" extra_files: - LICENSE - passwd dockerfile: Dockerfile_goreleaser docker_manifests: - - name_template: "ghcr.io/mtrossbach/{{ .ProjectName }}:{{ .Version }}" + - name_template: "ghcr.io/mgerczuk/{{ .ProjectName }}:{{ .Version }}" image_templates: - *arm64v8_image - *armv6_image - *amd64_image - - name_template: "ghcr.io/mtrossbach/{{ .ProjectName }}:latest" + - name_template: "ghcr.io/mgerczuk/{{ .ProjectName }}:latest" image_templates: - *arm64v8_image - *armv6_image @@ -94,6 +95,6 @@ docker_manifests: release: github: - owner: mtrossbach - name: noah-mqtt + owner: mgerczuk + name: nexa-mqtt draft: false diff --git a/Dockerfile_goreleaser b/Dockerfile_goreleaser index 752cb8c..3ed2d6c 100644 --- a/Dockerfile_goreleaser +++ b/Dockerfile_goreleaser @@ -1,9 +1,9 @@ FROM alpine AS builder FROM scratch -COPY noah-mqtt / +COPY nexa-mqtt / COPY LICENSE / COPY passwd /etc/passwd COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ USER gouser -ENTRYPOINT ["/noah-mqtt"] \ No newline at end of file +ENTRYPOINT ["/nexa-mqtt"] \ No newline at end of file From 04e9681b3cf12089310064e5c66aea124d4e8e92 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 1 Jul 2025 14:16:15 +0200 Subject: [PATCH 27/30] Updated README.md --- README.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e041827..abb30b6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # nexa-mqtt ![License](https://img.shields.io/github/license/mgerczuk/nexa-mqtt) ![GitHub last commit](https://img.shields.io/github/last-commit/mgerczuk/nexa-mqtt) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/mgerczuk/nexa-mqtt) -`nexa-mqtt` is a standalone application designed to retrieve data and metrics from your Growatt NOAH 2000 home battery used in balcony power plants. It publishes this information to an MQTT broker, making it easily accessible for Home Assistant or other applications. It is a fork of https://github.com/mtrossbach/noah-mqtt. +`nexa-mqtt` is a standalone application designed to retrieve data and metrics from your Growatt NEXA 2000 home battery used in balcony power plants. It publishes this information to an MQTT broker, making it easily accessible for Home Assistant or other applications. It is a fork of https://github.com/mtrossbach/noah-mqtt. -The application features Home Assistant auto-discovery, allowing your NOAH devices to be automatically recognized and integrated with Home Assistant via the MQTT integration. +The application features Home Assistant auto-discovery, allowing your NEXA devices to be automatically recognized and integrated with Home Assistant via the MQTT integration. # ![HomeAssistant screenshot](/assets/ha-screenshot.png) @@ -122,8 +122,6 @@ You can update the device's parameter settings by posting a message to the follo ## Option 1: Running `nexa-mqtt` with Docker -_currently not working_ - To run the latest version of `nexa-mqtt` using Docker, follow these steps: 1. **Install Docker**: Ensure Docker is installed on your system. You can download Docker Desktop from [Docker’s official website](https://www.docker.com/products/docker-desktop). @@ -135,7 +133,7 @@ To run the latest version of `nexa-mqtt` using Docker, follow these steps: 3. **Execute the Docker Command**: Run the following command, replacing the placeholders with your actual values: ``` - docker run --name nexa-mqtt -e GROWATT_USERNAME=myusername -e GROWATT_PASSWORD=mypassword -e MQTT_HOST=localhost -e MQTT_PORT=1883 ghcr.io/mtrossbach/nexa-mqtt:latest + docker run --name nexa-mqtt -e GROWATT_USERNAME=myusername -e GROWATT_PASSWORD=mypassword -e MQTT_HOST=localhost -e MQTT_PORT=1883 ghcr.io/mgerczuk/nexa-mqtt:latest ``` - Replace myusername with your Growatt username. @@ -143,15 +141,13 @@ To run the latest version of `nexa-mqtt` using Docker, follow these steps: - Replace localhost with the hostname or IP address of your MQTT broker. - Replace 1883 with the port number your MQTT broker uses (default is 1883). -The application will connect to your MQTT broker and retrieve all metrics and data for your NOAH devices. +The application will connect to your MQTT broker and retrieve all metrics and data for your NEXA devices. ## Option 2: Downloading and running a prebuilt binary -_currently not working_ - If you prefer not to compile the binary yourself, you can download a prebuilt version: -1. **Download the Binary**: Go to the [Releases](https://github.com/mtrossbach/nexa-mqtt/releases) page of the repository and download the prebuilt binary for your operating system and system architecture. +1. **Download the Binary**: Go to the [Releases](https://github.com/mgerczuk/nexa-mqtt/releases) page of the repository and download the prebuilt binary for your operating system and system architecture. 2. **Extract the Binary**: If the binary is compressed (e.g., in a zip or tar file), extract it to a directory of your choice. @@ -193,7 +189,7 @@ To compile the binary yourself, ensure you have Go installed on your machine: 2. **Clone the Repository**: Open a terminal and run the following command to clone the repository: - git clone https://github.com/mtrossbach/nexa-mqtt.git + git clone https://github.com/mgerczuk/nexa-mqtt.git cd nexa-mqtt 3. **Build the application**: @@ -209,7 +205,7 @@ Afterwards follow the instructions for running the application from option 2. _currently not working_ ## Run standalone (Home Assistant Container, Home Assistant Core) -`nexa-mqtt` interacts with Home Assistant by publishing data from your Growatt NOAH 2000 home battery to an MQTT broker. This setup allows Home Assistant to subscribe to and integrate this data seamlessly into its ecosystem. +`nexa-mqtt` interacts with Home Assistant by publishing data from your Growatt NEXA 2000 home battery to an MQTT broker. This setup allows Home Assistant to subscribe to and integrate this data seamlessly into its ecosystem. ![Home Assistant Integration](./assets/nexa-mqtt-ha-dark.drawio.png#gh-dark-mode-only) ![Home Assistant Integration](./assets/nexa-mqtt-ha.drawio.png#gh-light-mode-only) From 3a8662c5bfe4d6941c3bb836bdc7541f0925dc2e Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 2 Jul 2025 14:56:16 +0200 Subject: [PATCH 28/30] Create DEB package with goreleaser --- .goreleaser.yaml | 48 +++++++++++++++++++++++++++++++++------- go.sum | 1 + package/DEBIAN/conffiles | 1 - package/DEBIAN/config | 9 -------- package/DEBIAN/control | 6 ----- package/DEBIAN/postinst | 7 ++++++ 6 files changed, 48 insertions(+), 24 deletions(-) delete mode 100644 package/DEBIAN/conffiles delete mode 100644 package/DEBIAN/config delete mode 100644 package/DEBIAN/control diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5e165ed..3c89a9d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,11 +1,14 @@ project_name: nexa-mqtt version: 2 + before: hooks: - go mod tidy + builds: - env: - - CGO_ENABLED=0 + - CGO_ENABLED=0 + main: ./cmd/nexa-mqtt targets: - linux_arm_6 - linux_arm64 @@ -14,16 +17,18 @@ builds: - darwin_arm64 - windows_amd64 - windows_arm64 - main: ./cmd/nexa-mqtt + archives: - files: - - LICENSE - - passwd + - LICENSE + - passwd + checksum: name_template: 'checksums.txt' dockers: - use: buildx + dockerfile: Dockerfile_goreleaser goos: linux goarch: arm64 build_flag_templates: @@ -41,8 +46,9 @@ dockers: extra_files: - LICENSE - passwd - dockerfile: Dockerfile_goreleaser + - use: buildx + dockerfile: Dockerfile_goreleaser goos: linux goarch: arm goarm: 6 @@ -61,8 +67,9 @@ dockers: extra_files: - LICENSE - passwd - dockerfile: Dockerfile_goreleaser + - use: buildx + dockerfile: Dockerfile_goreleaser goos: linux goarch: amd64 build_flag_templates: @@ -80,7 +87,7 @@ dockers: extra_files: - LICENSE - passwd - dockerfile: Dockerfile_goreleaser + docker_manifests: - name_template: "ghcr.io/mgerczuk/{{ .ProjectName }}:{{ .Version }}" image_templates: @@ -92,7 +99,32 @@ docker_manifests: - *arm64v8_image - *armv6_image - *amd64_image - + +nfpms: + - id: default + package_name: nexa-mqtt + vendor: Martin Gerczuk + homepage: https://github.com/mgerczuk/nexa-mqtt + maintainer: Martin Gerczuk + description: NEXA 2000 MQTT Publisher + license: Apache-2.0 + formats: + - deb + bindir: /usr/bin + contents: + - src: ./package/etc/systemd/system/nexa-mqtt.service + dst: /etc/systemd/system/nexa-mqtt.service + - src: ./package/etc/systemd/system/nexa-mqtt.service.d/override.conf + dst: /etc/systemd/system/nexa-mqtt.service.d/override.conf + type: config|noreplace + scripts: + postinstall: ./package/DEBIAN/postinst + preremove: ./package/DEBIAN/prerm + postremove: ./package/DEBIAN/postrm + deb: + scripts: + templates: ./package/DEBIAN/templates + release: github: owner: mgerczuk diff --git a/go.sum b/go.sum index 469cf34..2a2f62b 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,7 @@ golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/package/DEBIAN/conffiles b/package/DEBIAN/conffiles deleted file mode 100644 index 5eb00ff..0000000 --- a/package/DEBIAN/conffiles +++ /dev/null @@ -1 +0,0 @@ -/etc/systemd/system/nexa-mqtt.service.d/override.conf diff --git a/package/DEBIAN/config b/package/DEBIAN/config deleted file mode 100644 index a59239e..0000000 --- a/package/DEBIAN/config +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -set -e -. /usr/share/debconf/confmodule - -db_input high nexa-mqtt/growatt_username || true -db_input high nexa-mqtt/growatt_password || true -db_input high nexa-mqtt/mqtt_host || true - -db_go || true diff --git a/package/DEBIAN/control b/package/DEBIAN/control deleted file mode 100644 index 1b18453..0000000 --- a/package/DEBIAN/control +++ /dev/null @@ -1,6 +0,0 @@ -Package: nexa-mqtt -Depends: systemd, debconf -Maintainer: Martin Gerczuk -Description: NEXA 2000 MQTT Publisher -Section: admin -Priority: optional diff --git a/package/DEBIAN/postinst b/package/DEBIAN/postinst index ce5a6a2..ed280b4 100644 --- a/package/DEBIAN/postinst +++ b/package/DEBIAN/postinst @@ -4,6 +4,13 @@ set -e #echo "postinst '$1' '$2'" + +db_input high nexa-mqtt/growatt_username || true +db_input high nexa-mqtt/growatt_password || true +db_input high nexa-mqtt/mqtt_host || true + +db_go || true + OVERRIDE_FILE="/etc/systemd/system/nexa-mqtt.service.d/override.conf" # Only if not yet existing or empty From 4315291efd22d4548dd752098a052693e44ef2d7 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 2 Jul 2025 17:55:33 +0200 Subject: [PATCH 29/30] Updated README.md --- README.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index abb30b6..d0d3573 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,30 @@ To run the latest version of `nexa-mqtt` using Docker, follow these steps: The application will connect to your MQTT broker and retrieve all metrics and data for your NEXA devices. -## Option 2: Downloading and running a prebuilt binary +## Option 2: Downloading and running a Debian package + +1. **Download the deb package file**: Go to the [Releases](https://github.com/mgerczuk/nexa-mqtt/releases) page of the repository and download the .deb file for your operating system and system architecture. + +2. **Install the package** + + ```sh + sudo apt install -f + ``` + +When there is an update simply download the new deb package file and install with the same install command. + +nexa-mqtt is started and will be started automatically after a reboot. Check with `journalctl -t nexa-mqtt` if there are any problems, e.g. user name or password errors. + +You can modify the environment variables by executing + + ```sh + sudo systemctl edit nexa-mqtt + sudo systemctl daemon-reload + sudo systemctl restart nexa-mqtt + ``` +To uninstall the package execute `sudo apt remove nexa-mqtt`. + +## Option 3: Downloading and running a prebuilt binary If you prefer not to compile the binary yourself, you can download a prebuilt version: @@ -181,7 +204,7 @@ If you prefer not to compile the binary yourself, you can download a prebuilt ve Again, replace `myusername`, `mypassword`, `localhost`, and `1883` with your actual Growatt account details and MQTT broker information. -## Option 3: Compiling the binary yourself +## Option 4: Compiling the binary yourself To compile the binary yourself, ensure you have Go installed on your machine: From 150a90c4ed367ef88af377e7b654d851b0205579 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 3 Jul 2025 09:32:55 +0200 Subject: [PATCH 30/30] Removed old build scripts --- .gitlab-ci.yml | 17 ----------------- .goreleaser.yaml | 14 +++++++------- Dockerfile | 1 + build_package.sh | 39 --------------------------------------- 4 files changed, 8 insertions(+), 63 deletions(-) delete mode 100644 .gitlab-ci.yml delete mode 100755 build_package.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index e060231..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,17 +0,0 @@ -image: golang:1.24.0 - -stages: - - build - -build-job: - rules: - - if: '$CI_COMMIT_REF_NAME == "nexa"' - stage: build - before_script: - - apt-get update -y -qq - - apt-get install fakeroot -y - script: - - bash ./build_package.sh - artifacts: - paths: - - build/*.deb diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 3c89a9d..432d02c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,8 +7,7 @@ before: builds: - env: - - CGO_ENABLED=0 - main: ./cmd/nexa-mqtt + - CGO_ENABLED=0 targets: - linux_arm_6 - linux_arm64 @@ -17,18 +16,18 @@ builds: - darwin_arm64 - windows_amd64 - windows_arm64 + main: ./cmd/nexa-mqtt archives: - files: - - LICENSE - - passwd + - LICENSE + - passwd checksum: name_template: 'checksums.txt' dockers: - use: buildx - dockerfile: Dockerfile_goreleaser goos: linux goarch: arm64 build_flag_templates: @@ -46,9 +45,9 @@ dockers: extra_files: - LICENSE - passwd + dockerfile: Dockerfile_goreleaser - use: buildx - dockerfile: Dockerfile_goreleaser goos: linux goarch: arm goarm: 6 @@ -67,9 +66,9 @@ dockers: extra_files: - LICENSE - passwd + dockerfile: Dockerfile_goreleaser - use: buildx - dockerfile: Dockerfile_goreleaser goos: linux goarch: amd64 build_flag_templates: @@ -87,6 +86,7 @@ dockers: extra_files: - LICENSE - passwd + dockerfile: Dockerfile_goreleaser docker_manifests: - name_template: "ghcr.io/mgerczuk/{{ .ProjectName }}:{{ .Version }}" diff --git a/Dockerfile b/Dockerfile index fe7d636..4dd2a48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# Dockerfile for local build # Alpine image to build FROM alpine:latest AS builder diff --git a/build_package.sh b/build_package.sh deleted file mode 100755 index 6c2bd75..0000000 --- a/build_package.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -APP_NAME=nexa-mqtt - -ARCHS="amd64 arm" -LDFLAGS="-s -w" - -GITCOMMIT=$(git rev-parse HEAD) -GITVERSION=$(git describe --tags --long) -# replace v1.2.3-4-gxxxxx with 1.2.3.4 or v1.2-3-gxx with 1.2.3 -VERSION=$(echo $GITVERSION | sed -E 's/v([0-9]+\.[0-9]+\.?[0-9]*)-([0-9]+)-g.*/\1.\2/') - -BUILD_DIR=$(pwd)/build -DEB_DIR=$BUILD_DIR/deb - -for arch in $ARCHS; do - mkdir -p $DEB_DIR/usr/bin; - - echo "Building for $arch..."; - GOOS=linux GOARCH=$arch go build -o $DEB_DIR/usr/bin/${APP_NAME} -ldflags "$LDFLAGS -X main.version=$GITVERSION -X main.commit=$GITCOMMIT" cmd/nexa-mqtt/main.go; - - if [ "$arch" = "arm" ]; then - deb_arch="armhf"; - else - deb_arch="$arch"; - fi; - echo "Creating DEB package for $arch (DEB arch: $deb_arch)..."; - mkdir -p $DEB_DIR/DEBIAN; - cp -r package/* $DEB_DIR/; - echo "Version: $VERSION" >> $DEB_DIR/DEBIAN/control; - echo "Architecture: $deb_arch" >> $DEB_DIR/DEBIAN/control; - chmod 755 $DEB_DIR/DEBIAN/config; - chmod 755 $DEB_DIR/DEBIAN/postinst; - chmod 755 $DEB_DIR/DEBIAN/prerm; - chmod 755 $DEB_DIR/DEBIAN/postrm; - echo "Creating $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb"; - fakeroot dpkg-deb --build $DEB_DIR $BUILD_DIR/${APP_NAME}_${VERSION}_$arch.deb; - rm -rf $DEB_DIR -done