diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0c0c4c --- /dev/null +++ b/.gitattributes @@ -0,0 +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 5d36269..0e1468c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,8 @@ go.work go.work.sum .idea dist -/noah-mqtt +/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..432d02c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,10 @@ -project_name: noah-mqtt +project_name: nexa-mqtt +version: 2 + before: hooks: - go mod tidy + builds: - env: - CGO_ENABLED=0 @@ -13,11 +16,13 @@ builds: - darwin_arm64 - windows_amd64 - windows_arm64 - main: ./cmd/noah-mqtt + main: ./cmd/nexa-mqtt + archives: - files: - LICENSE - passwd + checksum: name_template: 'checksums.txt' @@ -29,18 +34,19 @@ 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 dockerfile: Dockerfile_goreleaser + - use: buildx goos: linux goarch: arm @@ -49,18 +55,19 @@ 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 dockerfile: Dockerfile_goreleaser + - use: buildx goos: linux goarch: amd64 @@ -68,32 +75,58 @@ 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 - *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: mtrossbach - name: noah-mqtt + owner: mgerczuk + name: nexa-mqtt draft: false diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100755 index 0000000..38fd763 --- /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/nexa-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/Dockerfile b/Dockerfile index 362a02d..4dd2a48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# Dockerfile for local build # Alpine image to build FROM alpine:latest AS builder @@ -13,17 +14,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"] 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 diff --git a/README.md b/README.md index 69f38aa..d0d3573 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,9 @@ -> [!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. +`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. -# 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. - -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) @@ -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 { @@ -79,14 +67,14 @@ 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 } ``` ### 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,15 +85,16 @@ 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 { "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 + "default_mode": "load_first" // or battery_first } ``` @@ -113,15 +102,16 @@ 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 { "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 + "default_mode": "load_first" // or battery_first } ``` @@ -130,9 +120,9 @@ 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 -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). @@ -143,7 +133,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/mgerczuk/nexa-mqtt:latest ``` - Replace myusername with your Growatt username. @@ -151,13 +141,36 @@ To run the latest version of `noah-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 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 + ``` -## Option 2: Downloading and running a prebuilt binary +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: -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/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. @@ -170,7 +183,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): @@ -180,18 +193,18 @@ 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. -## 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: @@ -199,12 +212,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/mgerczuk/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. @@ -212,18 +225,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 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/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. @@ -231,18 +246,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:** @@ -258,16 +273,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/cmd/noah-mqtt/main.go b/cmd/nexa-mqtt/main.go similarity index 68% rename from cmd/noah-mqtt/main.go rename to cmd/nexa-mqtt/main.go index b88fee9..23358e6 100644 --- a/cmd/noah-mqtt/main.go +++ b/cmd/nexa-mqtt/main.go @@ -2,20 +2,21 @@ package main import ( "fmt" - mqtt "github.com/eclipse/paho.mqtt.golang" "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" "strings" "syscall" + + mqtt "github.com/eclipse/paho.mqtt.golang" ) var ( @@ -31,15 +32,14 @@ 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)) } - 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) @@ -47,19 +47,56 @@ 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) + a.growattApp.StartPolling() + mqttEndpoint.SetParameterApplier(a.growattApp) + + case "web": + a.growattService.SetEndpoint(mqttEndpoint) + a.growattService.StartPolling() + + case "web+app": + a.growattService.SetEndpoint(mqttEndpoint) + a.growattService.StartPolling() + mqttEndpoint.SetParameterApplier(a.growattApp) + } +} + +func NewApp(cfg config.Config) *App { + mode := strings.ToLower(strings.TrimSpace(cfg.Growatt.APIMode)) switch mode { case "app": @@ -73,12 +110,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{ @@ -88,14 +129,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)) @@ -106,7 +150,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,14 +164,20 @@ func runApp(cfg config.Config, client mqtt.Client) { ParameterPollingInterval: cfg.ParameterPollingInterval, }) - 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). @@ -137,12 +186,12 @@ func connectMqtt(mqttCfg config.Mqtt, onConnected func(client mqtt.Client)) { opts.OnConnect = func(client mqtt.Client) { slog.Info("connected to mqtt broker") - onConnected(client) + app.onMqttConnect(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())) + app.onMqttDisconnect() } c := mqtt.NewClient(opts) diff --git a/go.mod b/go.mod index 8801766..2620eb6 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module noah-mqtt +module nexa-mqtt go 1.24.0 @@ -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..2a2f62b 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,22 @@ +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 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/internal/config/config.go b/internal/config/config.go index eff0972..826aae7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,10 +59,10 @@ 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"), + TopicPrefix: getEnv("MQTT_TOPIC_PREFIX", "nexa2mqtt"), }, HomeAssistant: HomeAssistant{ TopicPrefix: getEnv("HOMEASSISTANT_TOPIC_PREFIX", "homeassistant"), 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 1225508..201defc 100644 --- a/internal/endpoint/parameter_applier.go +++ b/internal/endpoint/parameter_applier.go @@ -1,9 +1,8 @@ package endpoint -import "noah-mqtt/pkg/models" +import "nexa-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 e0210d4..a90f481 100644 --- a/internal/endpoint_mqtt/endpoint_mqtt.go +++ b/internal/endpoint_mqtt/endpoint_mqtt.go @@ -3,28 +3,36 @@ 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" + "nexa-mqtt/internal/endpoint" + "nexa-mqtt/internal/homeassistant" + "nexa-mqtt/pkg/models" + "sync" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" ) type Options struct { MqttClient mqtt.Client TopicPrefix string - HaClient *homeassistant.Service + HaClient homeassistant.HaClient } 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(), } } @@ -96,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 { @@ -109,18 +124,40 @@ 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 } - if payload.OutputPower != nil { - e.param_applier.SetOutputPowerW(dev, *payload.OutputPower) - } + e.stateLock.Lock() + defer e.stateLock.Unlock() - if payload.ChargingLimit != nil { - e.param_applier.SetChargingLimit(dev, *payload.ChargingLimit) - } + e.newParameter.UpdateFrom(payload) - if payload.DischargeLimit != nil { - e.param_applier.SetDischargeLimit(dev, *payload.DischargeLimit) + 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/endpoint_mqtt/endpoint_mqtt_test.go b/internal/endpoint_mqtt/endpoint_mqtt_test.go new file mode 100644 index 0000000..4336dfa --- /dev/null +++ b/internal/endpoint_mqtt/endpoint_mqtt_test.go @@ -0,0 +1,564 @@ +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.Equal(t, models.ParameterPayload{}, endpoint.newParameter) +} + +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.Equal(t, models.ParameterPayload{}, endpoint.newParameter) +} diff --git a/internal/growatt_app/client.go b/internal/growatt_app/client.go index 31ed4b9..a244a0a 100644 --- a/internal/growatt_app/client.go +++ b/internal/growatt_app/client.go @@ -1,19 +1,22 @@ package growatt_app import ( + "errors" "fmt" - "github.com/google/uuid" "log/slog" "math" "net/http" "net/http/cookiejar" "net/url" - "noah-mqtt/internal/misc" + "nexa-mqtt/internal/misc" + "strings" "time" + + "github.com/google/uuid" ) type Client struct { - client *http.Client + client HttpClient serverUrl string username string password string @@ -33,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, @@ -46,9 +51,30 @@ 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 { + 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) + } + 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}, @@ -67,16 +93,16 @@ 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"}, "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"}, @@ -95,7 +121,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"}, @@ -109,17 +135,22 @@ 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 } + + if !data.Obj.IsPlantHaveNexa { + return nil, errors.New("No NEXA device") + } + 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 +158,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 +171,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 +180,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{ + var data SetResponse + 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 } @@ -163,11 +195,11 @@ func (h *Client) SetDefaultPower(serialNumber string, power float64) error { 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 - if _, err := h.postForm(h.serverUrl+"/noahDeviceApi/noah/set", url.Values{ + var data SetResponse + if err := h.postForm(h.serverUrl+"/noahDeviceApi/nexa/set", url.Values{ "serialNum": {serialNumber}, "type": {"charging_soc"}, "param1": {fmt.Sprintf("%.0f", c)}, @@ -178,3 +210,42 @@ func (h *Client) SetSocLimit(serialNumber string, chargingLimit float64, dischar return nil } + +func (h *Client) SetAllowGridCharging(serialNumber string, allow int) error { + var data SetResponse + 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 SetResponse + 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 SetResponse + 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 +} diff --git a/internal/growatt_app/client_http.go b/internal/growatt_app/client_http.go index ff66c91..38b6353 100644 --- a/internal/growatt_app/client_http.go +++ b/internal/growatt_app/client_http.go @@ -7,23 +7,32 @@ import ( "log/slog" "net/http" "net/url" - "noah-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 + slog.Error("http.NewRequest failed (app)", slog.String("error", err.Error())) + 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 + slog.Error("http.Client.Do failed (app)", slog.String("error", err.Error())) + return err } defer func(Body io.ReadCloser) { @@ -32,26 +41,22 @@ func (h *Client) postForm(url string, data url.Values, responseBody any) (*http. b, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + slog.Error("io.ReadAll failed (app)", slog.String("error", err.Error())) + return err } if resp.StatusCode != 200 { - return nil, 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 { - 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 - } + slog.Error("json.Unmarshal failed (app)", slog.String("error", err.Error())) + 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() + }) + } +} 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 fa1375e..f8704de 100644 --- a/internal/growatt_app/models.go +++ b/internal/growatt_app/models.go @@ -28,73 +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"` - 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 { - ChargePower string `json:"chargePower"` - WorkMode string `json:"workMode"` - Soc string `json:"soc"` - 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"` - 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 - }] + ResponseContainerV2[NoahStatusObj] } -type NoahInfo 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"` - } `json:"noah"` - PlantList []struct { - PlantID string `json:"plantId"` - PlantImgName interface{} `json:"plantImgName"` - PlantName string `json:"plantName"` - } `json:"plantList"` - }] +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[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 { @@ -102,3 +130,7 @@ type BatteryDetails struct { Soc string `json:"soc"` Temp string `json:"temp"` } + +type SetResponse struct { + ResponseContainerV2[any] +} diff --git a/internal/growatt_app/payload.go b/internal/growatt_app/payload.go index b6066a7..e90dfa7 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 { @@ -28,14 +28,16 @@ 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) + defaultACCouplePower := misc.ParseFloat(n.Obj.Noah.DefaultACCouplePower) + defaultMode := models.WorkModeFromString(n.Obj.Noah.DefaultMode) return models.ParameterPayload{ - ChargingLimit: &chargingLimit, - DischargeLimit: &dischargeLimit, - OutputPower: &outputPower, + ChargingLimit: &chargingLimit, + DischargeLimit: &dischargeLimit, + DefaultACCouplePower: &defaultACCouplePower, + DefaultMode: &defaultMode, } } 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) +} diff --git a/internal/growatt_app/service.go b/internal/growatt_app/service.go index ba809e5..b1b1adf 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" ) @@ -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() @@ -76,7 +82,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 +95,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 @@ -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 { @@ -128,66 +131,41 @@ 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 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 err := g.client.SetDefaultPower(device.Serial, 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))) - 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)) - if !g.ensureParameterLogin() { - slog.Error("unable to set charging limit (app)", slog.String("device", device.Serial)) + + modeAsInt := models.IntFromWorkMode(mode) + if modeAsInt < 0 { + slog.Error("unable to set default system output power (app). Invalid mode", slog.String("device", device.Serial), slog.String("mode", string(mode))) 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("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 { - 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())) - 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 - } + return true } } -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)) +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 discharge 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())) + + 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 { - 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 - } + return true } } @@ -197,30 +175,38 @@ 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) + 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) + 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) + 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 835621e..2be59f7 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) { @@ -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/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/models.go b/internal/growatt_web/models.go index ada54e0..e3570ca 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"` + 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"` + BatteryPackageQuantity string `json:"batteryPackageQuantity"` Ppv string `json:"ppv"` + GroplugNum string `json:"groplugNum"` TotalBatteryPackChargingPower string `json:"totalBatteryPackChargingPower"` + OtherPower string `json:"otherPower"` 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..9b8db0d 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" ) @@ -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 { @@ -69,7 +73,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 @@ -105,23 +109,32 @@ 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) + defer tickerPolling.Stop() + tickerHistory := time.NewTicker(historyInterval) + defer tickerHistory.Stop() + + 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) + case <-g.stop: + slog.Info("stop polling growatt (web)") + return } - }() + } } func (g *GrowattService) pollStatus(device models.NoahDevicePayload) { @@ -154,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) } } } @@ -172,16 +182,16 @@ 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) + mode := models.WorkModeFromString(detailsData.DefaultMode) paramPayload := models.ParameterPayload{ - ChargingLimit: &cl, - DischargeLimit: &dl, - OutputPower: &op, + ChargingLimit: &cl, + DischargeLimit: &dl, + DefaultACCouplePower: &op, + DefaultMode: &mode, } - for _, e := range g.endpoints { - e.PublishParameterData(device, paramPayload) - } + g.endpoint.PublishParameterData(device, paramPayload) } } @@ -189,7 +199,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] @@ -223,9 +233,7 @@ func (g *GrowattService) pollHistory(device models.NoahDevicePayload) { } } - for _, e := range g.endpoints { - e.PublishBatteryDetails(device, batteries) - } + g.endpoint.PublishBatteryDetails(device, batteries) } } } 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/discovery_binarysensors.go b/internal/homeassistant/discovery_binarysensors.go index 15eda8f..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 { @@ -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 4938087..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: "System Output Power", - UniqueId: fmt.Sprintf("%s_system_output_power", info.SerialNumber), - CommandTemplate: "{\"output_power_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.output_power_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 new file mode 100644 index 0000000..f211e3b --- /dev/null +++ b/internal/homeassistant/discovery_selects.go @@ -0,0 +1,35 @@ +package homeassistant + +import ( + "fmt" + "nexa-mqtt/pkg/models" +) + +func generateSelectDiscoveryPayload(appVersion string, info DeviceInfo) []Select { + device := generateDevice(info) + origin := generateOrigin(appVersion) + + selects := []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", + }, + } + + return selects +} diff --git a/internal/homeassistant/discovery_sensors.go b/internal/homeassistant/discovery_sensors.go index 7cc9a95..db4cadb 100644 --- a/internal/homeassistant/discovery_sensors.go +++ b/internal/homeassistant/discovery_sensors.go @@ -1,6 +1,9 @@ package homeassistant -import "fmt" +import ( + "fmt" + "nexa-mqtt/pkg/models" +) func generateSensorDiscoveryPayload(appVersion string, info DeviceInfo) []Sensor { device := generateDevice(info) @@ -8,120 +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, + }, + { + 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}, + }, + { + 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 ab29966..6223c1f 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 @@ -39,32 +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"` -} - type Device struct { Identifiers []string `json:"identifiers,omitempty"` Name string `json:"name,omitempty"` @@ -81,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"` +} diff --git a/internal/homeassistant/service.go b/internal/homeassistant/service.go index eec6e29..c0ae774 100644 --- a/internal/homeassistant/service.go +++ b/internal/homeassistant/service.go @@ -3,12 +3,17 @@ 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 HaClient interface { + SetDevices(devices []DeviceInfo) +} + type Options struct { MqttClient mqtt.Client TopicPrefix string @@ -61,6 +66,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 { @@ -84,14 +99,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("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, " ", "")) } diff --git a/package/DEBIAN/postinst b/package/DEBIAN/postinst new file mode 100644 index 0000000..ed280b4 --- /dev/null +++ b/package/DEBIAN/postinst @@ -0,0 +1,44 @@ +#!/bin/bash +set -e +. /usr/share/debconf/confmodule + +#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 +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..4faebdc --- /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..315f269 --- /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..cf2cd6f --- /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: password +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 diff --git a/pkg/models/payload.go b/pkg/models/payload.go index cd78f15..d49119d 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 { @@ -27,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"` @@ -47,9 +75,39 @@ 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"` +} + +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 {