78 Commits

Author SHA1 Message Date
e700239764 add capability to restrict remote panel modes
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 15:55:20 +11:00
e8153e2953 implement some features of Venus OS
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 15:37:41 +11:00
d72e88ab7b [ci skip] home assistant integration 2026-02-19 14:34:58 +11:00
7d0ce52c27 fix: Remove platform specifications for Docker builds in CI configuration
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 13:42:04 +11:00
d3df73ae0f feat: Add multi-platform build support for Linux binaries and Dockerfiles
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 13:38:45 +11:00
afc71dc507 feat: Enable multi-platform builds in Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 13:34:55 +11:00
22a42bfef0 try again
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 13:32:04 +11:00
ff0c806efa retry
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 13:29:32 +11:00
ed326eb8ef fix CI pipeline for multi-platform Docker builds and add manifest support
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-19 13:27:27 +11:00
1b6989b5d9 feat: Add multi-platform support and experimental features to Docker build
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 13:21:18 +11:00
e995a252e1 feat: Enhance MK2 driver with device state management and improved command handling
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 13:13:19 +11:00
e17e4d1a0a fix docker builder
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 12:48:51 +11:00
ee403d1e35 remove vendor directory
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 12:43:14 +11:00
bc49047499 Update CI pipeline: standardize images and upgrade Kaniko executor version
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 12:40:43 +11:00
1c15ff5911 Add read-only mode support and enhance logging throughout the application
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 12:36:52 +11:00
bdcb8e6f73 Refactor import paths to use the full repository URL and remove obsolete GitHub Actions workflow file 2026-02-19 12:30:21 +11:00
5cc8a0d7db Set module path to git.coadcorp.com/nathan/invertergui
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
2026-02-19 12:04:18 +11:00
a31a0b4829 Modernize invertergui: MQTT write support, HA integration, UI updates
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
2026-02-19 12:03:52 +11:00
Nicholas Thompson
959d1e3c1f Merge pull request #45 from bclermont/patch-1
Improve MK2 frames handling
2026-02-17 08:39:20 +02:00
Nicholas Thompson
bda5aa9238 Merge branch 'master' into patch-1 2026-02-17 08:24:24 +02:00
Nicholas Thompson
4e5da3f322 Merge pull request #47 from serhii-vasylkiv/mqtt-password-file
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
Add support for reading MQTT password from file
2026-02-17 08:23:37 +02:00
Zergus
826576516f Add support for reading MQTT password from file
Add `--mqtt.password-file` flag and `MQTT_PASSWORD_FILE` env var to allow
reading the MQTT password from a file (e.g. Docker secrets). When set,
the file contents overrides the --mqtt.password value.

Addresses diebietse/invertergui#46
2026-02-15 23:41:11 +02:00
Jean-Luc Mongrain
933c7cc307 Improve logging and handle Victron reboot 2024-07-22 15:54:48 -04:00
Nicholas Thompson
9471f0b5ac Merge pull request #42 from diebietse/vendor-upgrades
Vendor upgrades
2024-03-18 21:45:11 +02:00
Nicholas Thompson
4d774937df Vendor updates 2024-03-18 21:09:54 +02:00
Nicholas Thompson
3702654ec3 Upgrade libraries
Upgrade libraries to fix CVEs
2024-03-18 21:09:39 +02:00
Nicholas Thompson
7d63422555 Merge pull request #40 from diebietse/vendor-upgrades
Upgrade Go to v1.22 and libraries
2024-02-20 14:45:17 +02:00
Nicholas Thompson
fa6fc5fb34 Fix linter issues
Fix all new linter issues that are raised by golanglint-ci 1.56
2024-02-19 20:08:46 +02:00
Nicholas Thompson
86d865ce1b Update golanglint-ci to 1.56 2024-02-19 20:06:54 +02:00
Nicholas Thompson
516dd6b611 Go vendor update 2024-02-19 20:06:50 +02:00
Nicholas Thompson
df421fcccb Upgrade library versions 2024-02-19 20:06:47 +02:00
Nicholas Thompson
a050189e12 Go version to 1.22 2024-02-19 20:06:43 +02:00
Nicholas Thompson
7bcdc3276f Add upgrade-vendor helper to Makefile 2024-02-19 19:51:13 +02:00
Jedri Visser
9f9e25a796 Merge pull request #36 from diebietse/dev
Update go, dependencies and CI
2023-03-05 19:15:58 +02:00
Jedri Visser
c4a095a094 Update vendor 2023-03-03 21:53:10 +02:00
Jedri Visser
bf4716e8df Update go, dependencies and ci 2023-03-03 21:53:08 +02:00
Nicholas Thompson
574e832152 Merge pull request #32 from diebietse/fix_frequency
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
Add frequency clip check
2022-01-26 20:22:17 +02:00
Nicholas Thompson
2a84799832 Add frequency clip check
Add check to ensure frequency value is set to 0 when period counter hits maximum or minimum
Closes #31
2022-01-26 20:11:15 +02:00
Nicholas Thompson
8adf3c8261 Merge pull request #29 from diebietse/fix-signedness
No longer use RAM value scale signedness for info frames.
2022-01-25 10:32:56 +02:00
Hendrik van Wyk
b3245aba9b No longer use RAM value scale signedness for info frames.
The signedness calculated along with the RAM value scale and offset was
incorrectly applied to the info frame value. This caused some values to
be interpreted as unsigned instead of signed leading to negative values
reporting as very large positive values.
2022-01-11 20:36:56 +02:00
Nicholas Thompson
dac2149fbd Merge pull request #28 from Banshee1221/master
github ci: add arm v6 docker image builds
2021-12-09 12:30:55 +02:00
Eugene de Beste
e501f6d125 github ci: add arm v6 docker image builds 2021-12-09 09:09:11 +02:00
Jedri Visser
341f26f197 Merge pull request #26 from diebietse/update
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
Update Dockerfile, vuejs and bootstrap
2021-05-07 23:30:55 +02:00
Jedri Visser
5e28643a76 Update vuejs and bootstrap 2021-05-07 23:25:07 +02:00
Jedri Visser
b35132451c Clean up Dockerfile 2021-05-07 22:57:45 +02:00
Nicholas Thompson
6b0aeae504 Merge pull request #25 from diebietse/readme-updates
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
Update README
2021-05-06 23:11:02 +02:00
Jedri Visser
950c77886a Update README 2021-05-06 23:05:40 +02:00
Jedri Visser
51221d37d0 Merge pull request #24 from diebietse/github-builds
Move to github builds
2021-05-06 22:54:59 +02:00
Nicholas Thompson
d02baa4e8d Add codecov support 2021-05-06 22:50:23 +02:00
Nicholas Thompson
41b3db0457 Update workspace dictonary 2021-05-06 22:46:43 +02:00
Nicholas Thompson
b6c488b7d4 Move to github builds
Co-authored-by: Jedri Visser <git@jedrivisser.com>
2021-05-06 22:46:27 +02:00
Nicholas Thompson
c4b09b7a14 Merge pull request #22 from diebietse/go-embed
Use Go embed to serve static files
2021-04-27 14:23:25 +02:00
Nicholas Thompson
e201779341 Update vendor directory 2021-04-23 22:33:15 +02:00
Nicholas Thompson
321d19c5c2 Upgrade golangci version 2021-04-23 22:33:14 +02:00
Nicholas Thompson
56c417fd0e Upgrade dependencies 2021-04-23 22:33:14 +02:00
Nicholas Thompson
d3576031bb Use go native embed lib for static assets
Use built in embed library to serve static files instead of the statik library
2021-04-23 22:33:14 +02:00
Nicholas Thompson
f6c3b38976 Upgrade to go 1.16 2021-04-23 22:33:14 +02:00
Hendrik van Wyk
cc8fa9d611 Merge pull request #20 from diebietse/scalefixes
Fix scaling decoding and munin race condition
2020-10-08 12:29:58 +02:00
Hendrik van Wyk
49be089a23 Fix race condition in munin output.
The munin server used the same structure in two goroutines at once causing
possible data corruption. A copy of the structure is now used by the second
goroutine instead.
2020-10-08 12:25:39 +02:00
Hendrik van Wyk
157736a99d Add optional debug logging for frame decoding. 2020-10-08 12:25:33 +02:00
Hendrik van Wyk
86f3f0c8e3 Fix scaling to more closely match the Victron documentation.
We were decoding the scale as unsigned while it is signed. We were also
ignoring the fact that the sign of the scale determines the signedness of
the value it scales.
2020-09-25 15:03:26 +02:00
Nicholas Thompson
c991503e33 Add mode-2 to scale factors 2020-09-19 18:38:00 +02:00
Nicholas Thompson
55ae241d92 Merge pull request #17 from diebietse/scale-factor-fixes
Fix scale factor issue #16
2020-09-13 21:58:57 +02:00
Nicholas Thompson
ab346bcf90 Disable dead code check for RAM IDs 2020-09-13 21:14:24 +02:00
Nicholas Thompson
4c6df96051 Add unit test to scaleDecode 2020-09-13 20:56:26 +02:00
Nicholas Thompson
2a56dd24e4 Cleanup mk2 logging 2020-09-13 20:56:22 +02:00
Nicholas Thompson
65d9429a12 Add constants to frame decoder 2020-09-13 20:56:15 +02:00
Nicholas Thompson
5fb5ce5f12 Add check to scaling factors
Fix scaling append
2020-09-13 20:52:39 +02:00
Nicholas Thompson
3f783fabf8 Merge pull request #15 from diebietse/mqtt-plugin
Add n MQTT plugin to invertergui
2020-06-15 17:14:42 +02:00
Nicholas Thompson
4f428d6bda Update codecov limits to current coverage 2020-06-15 16:40:39 +02:00
Nicholas Thompson
eb737b6527 Update Go and lint version for travis CI 2020-06-15 14:27:10 +02:00
Nicholas Thompson
1840fae1aa Update vendor for all new features 2020-06-15 14:05:59 +02:00
Nicholas Thompson
934c629a41 Update readme to reflect new usage 2020-06-15 14:00:30 +02:00
Nicholas Thompson
3798783154 change Mkefile to use docker source for linting 2020-06-15 13:28:50 +02:00
Nicholas Thompson
6ab917d35a Update and improve logging library to logrus 2020-06-15 13:28:03 +02:00
Nicholas Thompson
c459fb22aa Add vendoring to makefile 2020-06-15 12:49:23 +02:00
Nicholas Thompson
64ae21da53 Update config to be passed in as environment variables 2020-06-15 12:47:27 +02:00
Nicholas Thompson
67ba53fff4 Create an MQTT client plugin 2020-06-15 12:46:02 +02:00
56 changed files with 10338 additions and 619 deletions

202
.drone.yml Normal file
View File

@@ -0,0 +1,202 @@
kind: pipeline
type: docker
name: ci
trigger:
event:
- pull_request
- push
- tag
steps:
# - name: lint
# image: cache.coadcorp.com/library/golang:1.26
# environment:
# GOFLAGS: -mod=mod
# GOBIN: /usr/local/bin
# commands:
# - go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8
# - golangci-lint version
# - golangci-lint run --timeout=5m
# - name: test
# image: cache.coadcorp.com/library/golang:1.26
# environment:
# GOFLAGS: -mod=mod
# commands:
# - go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: build
image: cache.coadcorp.com/library/golang:1.26
environment:
GOFLAGS: -mod=mod
commands:
- CGO_ENABLED=0 go build -v ./cmd/invertergui
- name: build-linux-binaries
image: cache.coadcorp.com/library/golang:1.26
environment:
GOFLAGS: -mod=mod
commands:
- mkdir -p dist
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v -o dist/invertergui-linux-amd64 ./cmd/invertergui
- CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -v -o dist/invertergui-linux-arm64 ./cmd/invertergui
when:
event:
- push
- tag
- name: docker-build-validate
image: gcr.io/kaniko-project/executor:v1.24.0
commands:
- /kaniko/executor --context "${DRONE_WORKSPACE}" --dockerfile "${DRONE_WORKSPACE}/Dockerfile" --no-push --destination registry.coadcorp.com/nathan/invertergui:pr-${DRONE_BUILD_NUMBER}
when:
event:
- pull_request
- name: docker-publish-commit-amd64
image: plugins/docker
settings:
registry: registry.coadcorp.com
repo: "registry.coadcorp.com/nathan/invertergui"
dockerfile: Dockerfile.publish.amd64
username: nathan
password:
from_secret: registry_password
tags:
- "${DRONE_COMMIT_SHA}-linux-amd64"
when:
event:
- push
- name: docker-publish-commit-arm64
image: plugins/docker
settings:
registry: registry.coadcorp.com
repo: "registry.coadcorp.com/nathan/invertergui"
dockerfile: Dockerfile.publish.arm64
username: nathan
password:
from_secret: registry_password
tags:
- "${DRONE_COMMIT_SHA}-linux-arm64"
when:
event:
- push
- name: docker-manifest-commit
image: plugins/manifest
settings:
registry: registry.coadcorp.com
username: nathan
password:
from_secret: registry_password
target: "registry.coadcorp.com/nathan/invertergui:${DRONE_COMMIT_SHA}"
template: "registry.coadcorp.com/nathan/invertergui:${DRONE_COMMIT_SHA}-OS-ARCH"
platforms:
- "linux/amd64"
- "linux/arm64"
when:
event:
- push
- name: docker-publish-latest-amd64
image: plugins/docker
settings:
registry: registry.coadcorp.com
repo: "registry.coadcorp.com/nathan/invertergui"
dockerfile: Dockerfile.publish.amd64
username: nathan
password:
from_secret: registry_password
tags:
- "latest-linux-amd64"
when:
event:
- push
branch:
- main
- master
- name: docker-publish-latest-arm64
image: plugins/docker
settings:
registry: registry.coadcorp.com
repo: "registry.coadcorp.com/nathan/invertergui"
dockerfile: Dockerfile.publish.arm64
username: nathan
password:
from_secret: registry_password
tags:
- "latest-linux-arm64"
when:
event:
- push
branch:
- main
- master
- name: docker-manifest-latest
image: plugins/manifest
settings:
registry: registry.coadcorp.com
username: nathan
password:
from_secret: registry_password
target: "registry.coadcorp.com/nathan/invertergui:latest"
template: "registry.coadcorp.com/nathan/invertergui:latest-OS-ARCH"
platforms:
- "linux/amd64"
- "linux/arm64"
when:
event:
- push
branch:
- main
- master
- name: docker-publish-release-amd64
image: plugins/docker
settings:
registry: registry.coadcorp.com
repo: "registry.coadcorp.com/nathan/invertergui"
dockerfile: Dockerfile.publish.amd64
username: nathan
password:
from_secret: registry_password
tags:
- "${DRONE_TAG}-linux-amd64"
when:
event:
- tag
- name: docker-publish-release-arm64
image: plugins/docker
settings:
registry: registry.coadcorp.com
repo: "registry.coadcorp.com/nathan/invertergui"
dockerfile: Dockerfile.publish.arm64
username: nathan
password:
from_secret: registry_password
tags:
- "${DRONE_TAG}-linux-arm64"
when:
event:
- tag
- name: docker-manifest-release
image: plugins/manifest
settings:
registry: registry.coadcorp.com
username: nathan
password:
from_secret: registry_password
target: "registry.coadcorp.com/nathan/invertergui:${DRONE_TAG}"
template: "registry.coadcorp.com/nathan/invertergui:${DRONE_TAG}-OS-ARCH"
platforms:
- "linux/amd64"
- "linux/arm64"
when:
event:
- tag

6
.gitignore vendored
View File

@@ -22,3 +22,9 @@ _testmain.go
*.exe *.exe
*.test *.test
*.prof *.prof
vendor/
# Python cache files (for Home Assistant custom component)
__pycache__/
*.pyc

View File

@@ -1,29 +0,0 @@
run:
deadline: 10m
linters:
enable-all: false
enable:
# Enabled by default in golangci-lint v1.13.2
- deadcode
- errcheck
- govet
- ineffassign
- typecheck
- varcheck
# Disabled by default in golangci-lint v1.13.2
- dupl
- goconst
- gofmt
- golint
- unconvert
# The following result in high memory usage (>1GB)
# https://github.com/golangci/golangci-lint/issues/337
- staticcheck
- structcheck
- gosimple
- unused
issues:
max-per-linter: 0
max-same-issues: 0

View File

@@ -1,28 +0,0 @@
sudo: false
language: go
env:
- GO111MODULE=on
go:
- 1.12.x
git:
depth: 1
install: true
notifications:
email: false
before_script:
- curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin v1.15.0
- go mod vendor
script:
- golangci-lint run
- go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
after_success:
- bash <(curl -s https://codecov.io/bash)

10
.vscode/settings.json vendored
View File

@@ -10,5 +10,13 @@
"[go]": { "[go]": {
"editor.insertSpaces": false, "editor.insertSpaces": false,
"editor.tabSize": 4 "editor.tabSize": 4
} },
"cSpell.words": [
"diebietse",
"ghaction",
"ghcr",
"golangci",
"invertergui",
"semver"
]
} }

View File

@@ -1,14 +1,24 @@
FROM golang:alpine as builder ARG BUILDPLATFORM=linux/amd64
RUN apk add git FROM --platform=${BUILDPLATFORM} golang:1.26-alpine as builder
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ARG TARGETVARIANT
RUN mkdir /build RUN mkdir /build
COPY . /build/ COPY . /build/
WORKDIR /build WORKDIR /build
RUN go build -o invertergui ./cmd/invertergui RUN set -eux; \
FROM alpine GOARM=""; \
RUN adduser -S -D -H -h /app inverteruser if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT:-}" ]; then GOARM="${TARGETVARIANT#v}"; fi; \
RUN addgroup inverteruser dialout CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" GOARM="${GOARM}" go build -o invertergui ./cmd/invertergui
USER inverteruser
COPY --from=builder /build/invertergui /app/ FROM scratch
WORKDIR /app
ENTRYPOINT [ "./invertergui" ] # Group ID 20 is dialout, needed for tty read/write access
CMD [] USER 3000:20
ENV READ_ONLY=false
ENV CONTROL_ALLOWED_PANEL_MODES=""
COPY --from=builder /build/invertergui /bin/
ENTRYPOINT [ "/bin/invertergui" ]
EXPOSE 8080

9
Dockerfile.publish.amd64 Normal file
View File

@@ -0,0 +1,9 @@
FROM scratch
# Group ID 20 is dialout, needed for tty read/write access
USER 3000:20
ENV READ_ONLY=false
ENV CONTROL_ALLOWED_PANEL_MODES=""
COPY dist/invertergui-linux-amd64 /bin/invertergui
ENTRYPOINT ["/bin/invertergui"]
EXPOSE 8080

9
Dockerfile.publish.arm64 Normal file
View File

@@ -0,0 +1,9 @@
FROM scratch
# Group ID 20 is dialout, needed for tty read/write access
USER 3000:20
ENV READ_ONLY=false
ENV CONTROL_ALLOWED_PANEL_MODES=""
COPY dist/invertergui-linux-arm64 /bin/invertergui
ENTRYPOINT ["/bin/invertergui"]
EXPOSE 8080

View File

@@ -26,7 +26,7 @@
#OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE #OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
#OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
.PHONY: test test-race vet install gofmt docker statik lint clean invertergui .PHONY: test test-race vet install gofmt docker statik lint clean invertergui vendor
.DEFAULT_GOAL = invertergui .DEFAULT_GOAL = invertergui
@@ -39,19 +39,24 @@ gofmt:
gofmt -l -s -w . gofmt -l -s -w .
test: test:
go test -v ./...
test-race:
go test -v -race ./... go test -v -race ./...
docker: docker:
docker build --tag invertergui . docker build --tag invertergui .
statik:
statik -f -p=frontend -src=./frontend/root
lint: lint:
golangci-lint run docker run --rm -it \
-w /src -v $(shell pwd):/src \
golangci/golangci-lint:v1.56 golangci-lint run \
-v -c .golangci.yml
clean: clean:
rm ./invertergui rm ./invertergui
vendor:
go mod tidy
go mod vendor
.PHONY: upgrade-vendor
upgrade-vendor:
go get -u ./...

479
README.md
View File

@@ -1,39 +1,195 @@
# Inverter GUI # Inverter GUI
[![Build Status](https://travis-ci.org/diebietse/invertergui.svg?branch=master)](https://travis-ci.org/diebietse/invertergui) [Repository](https://git.coadcorp.com/nathan/invertergui) | [Container Image](https://registry.coadcorp.com/nathan/invertergui)
The invertergui allows the monitoring of a [Victron Multiplus](https://www.victronenergy.com/inverters-chargers/multiplus-12v-24v-48v-800va-3kva) via the [MK3/MK2 USB](https://www.victronenergy.com/accessories/interface-mk3-usb) or the MK2 RS232. The invertergui allows the monitoring of a [Victron Multiplus](https://www.victronenergy.com/inverters-chargers/multiplus-12v-24v-48v-800va-3kva) via the [MK3/MK2 USB](https://www.victronenergy.com/accessories/interface-mk3-usb) or the MK2 RS232.
The [`diebietse/invertergui`](https://hub.docker.com/r/diebietse/invertergui) docker image is a build of this repository. The [`registry.coadcorp.com/nathan/invertergui`](https://registry.coadcorp.com/nathan/invertergui) container image is a build of this repository.
The code has been updated to support more of the protocol published by Victron at https://www.victronenergy.com/upload/documents/Technical-Information-Interfacing-with-VE-Bus-products-MK2-Protocol-3-14.pdf
## Acknowledgements
This project is based on the original open source `invertergui` project by Hendrik van Wyk and contributors:
- Original repository: https://github.com/diebietse/invertergui
- Home Assistant `victron-mk3-hass` inspiration: https://github.com/j9brown/victron-mk3-hass
## Demo ## Demo
![demo](https://rawcdn.githack.com/diebietse/invertergui/c856c451cd5c926b588914583bc4ab1498b7da99/invertergui_demo.gif "Invertergui Demo") ![demo](./invertergui_demo.gif "Invertergui Demo")
## Quick Start ## Quick Start
```console ```console
docker run --name invertergui --device /dev/ttyUSB0:/dev/ttyUSB0 -p 8080:8080 diebietse/invertergui docker run --name invertergui --device /dev/ttyUSB0:/dev/ttyUSB0 -p 8080:8080 registry.coadcorp.com/nathan/invertergui:latest
``` ```
## Requirements ## Requirements
This project makes use of [Go Modules](https://github.com/golang/go/wiki/Modules). The minimum version for Go is 1.11.x. This project makes use of [Go Modules](https://github.com/golang/go/wiki/Modules). The minimum supported version for Go is 1.22
## Driver API: Metadata + Safe Transactions
The MK2 driver now includes a metadata and transaction safety layer via the
`mk2driver.MetadataControl` interface:
- Register metadata lookup (`RegisterMetadata`, `ListRegisterMetadata`)
- Generic register reads by kind/id (`ReadRegister`)
- Transactional writes with retry and verify (`WriteRegister`)
`WriteRegister` supports:
- `ReadBeforeWrite`
- `VerifyAfterWrite`
- configurable retry count and delay
This layer is additive and does not replace existing `WriteSetting`, `WriteRAMVar`,
or panel control APIs.
## Advanced Control + Orchestration Features
The codebase now includes:
- Full MK2 command-path coverage in driver APIs for:
- `0x0E` device state read/write
- register read by id (`0x30`, `0x31`)
- selected read/write flows (`0x32`, `0x33`, `0x34`, `0x35`)
- RAM var metadata/info (`0x36`)
- write-by-id flows (`0x37`, `0x38`)
- Register metadata with `unit`, `min/max`, `scale`, `writable`, and `safety_class`.
- Transaction-safe writes with read-before-write, verify-after-write, retry/backoff, and timeout classes.
- Snapshot/diff/restore register workflows with rollback on partial restore failure.
- Alarm engine with LED/state alarms + command-failure alarms, including debounce and clear behavior.
- Venus-like derived operating state model: `Off`, `Inverter`, `Charger`, `Passthru`, `Fault`.
- Historical counters for energy and availability:
- `energy_in_wh`, `energy_out_wh`
- battery charge/discharge Wh
- uptime seconds
- fault count + last fault timestamp
- Multi-device orchestration fields and topics:
- `device_id`, `instance_id`, `phase`, `phase_group`
- per-device topics and phase-group fanout topics
- Command arbitration/policy layer:
- single serialized write path
- lockout windows
- source tagging (`ui`, `mqtt`, `automation`)
- max current guardrail + mode rate limit + maintenance/read-only profiles
- Venus-compatible MQTT mode (`N/...` + optional `W/...`) for HA/Node-RED/VRM-style workflows.
- Guide-style Victron ESS MQTT paths (`settings/0/Settings/CGwacs/*`) with `victron/N/...` and `victron/W/...` prefix compatibility.
- Structured diagnostics bundle topics with protocol traces, recent command history, and health score.
## Getting started ## Getting started
```bash ```bash
Usage of ./invertergui: Usage:
-addr string invertergui [OPTIONS]
TCP address to listen on. (default ":8080")
-dev string Application Options:
TTY device to use. (default "/dev/ttyUSB0") --address= The IP/DNS and port of the machine that the application is running on. (default: :8080) [$ADDRESS]
-ip string --read_only Disable all write operations and run in monitoring-only mode. [$READ_ONLY]
IP to connect when using tcp connection. (default "localhost:8139") --data.source= Set the source of data for the inverter gui. "serial", "tcp" or "mock" (default: serial) [$DATA_SOURCE]
-tcp --data.host= Host to connect when source is set to tcp. (default: localhost:8139) [$DATA_HOST]
Use TCP instead of TTY --data.device= TTY device to use when source is set to serial. (default: /dev/ttyUSB0) [$DATA_DEVICE]
--cli.enabled Enable CLI output. [$CLI_ENABLED]
--mqtt.enabled Enable MQTT publishing. [$MQTT_ENABLED]
--mqtt.broker= Set the host port and scheme of the MQTT broker. (default: tcp://localhost:1883) [$MQTT_BROKER]
--mqtt.client_id= Set the client ID for the MQTT connection. (default: inverter-gui) [$MQTT_CLIENT_ID]
--mqtt.topic= Set the MQTT topic updates published to. (default: invertergui/updates) [$MQTT_TOPIC]
--mqtt.command_topic= Set the MQTT topic that receives write commands for Victron settings/RAM variables. (default: invertergui/settings/set) [$MQTT_COMMAND_TOPIC]
--mqtt.status_topic= Set the MQTT topic where write command status updates are published. (default: invertergui/settings/status) [$MQTT_STATUS_TOPIC]
--mqtt.device_id= Set logical device ID used for per-device orchestration topics. (default: invertergui) [$MQTT_DEVICE_ID]
--mqtt.history_size= Number of samples retained for rolling history summaries. (default: 120) [$MQTT_HISTORY_SIZE]
--mqtt.instance_id= Device instance ID for multi-device orchestration and Venus compatibility. (default: 0) [$MQTT_INSTANCE_ID]
--mqtt.phase= Electrical phase label for this instance (L1/L2/L3). (default: L1) [$MQTT_PHASE]
--mqtt.phase_group= Grouping key for parallel/3-phase system aggregation topics. (default: default) [$MQTT_PHASE_GROUP]
--mqtt.ha.enabled Enable Home Assistant MQTT discovery integration. [$MQTT_HA_ENABLED]
--mqtt.ha.discovery_prefix= Set Home Assistant MQTT discovery prefix. (default: homeassistant) [$MQTT_HA_DISCOVERY_PREFIX]
--mqtt.ha.node_id= Set Home Assistant node ID used for discovery topics and unique IDs. (default: invertergui) [$MQTT_HA_NODE_ID]
--mqtt.ha.device_name= Set Home Assistant device display name. (default: Victron Inverter) [$MQTT_HA_DEVICE_NAME]
--mqtt.venus.enabled Enable Venus-style MQTT compatibility topics (N/W model). [$MQTT_VENUS_ENABLED]
--mqtt.venus.portal_id= Set Venus portal ID segment used in N/W topics. (default: invertergui) [$MQTT_VENUS_PORTAL_ID]
--mqtt.venus.service= Set Venus service segment used in N/W topics. (default: vebus/257) [$MQTT_VENUS_SERVICE]
--mqtt.venus.subscribe_writes Subscribe to Venus W/... topics and map to MK2 commands. [$MQTT_VENUS_SUBSCRIBE_WRITES]
--mqtt.venus.topic_prefix= Optional topic prefix before Venus N/W topics, e.g. victron. [$MQTT_VENUS_TOPIC_PREFIX]
--mqtt.venus.guide_compat Enable guide-style settings/0/Settings/CGwacs compatibility paths. [$MQTT_VENUS_GUIDE_COMPAT]
--mqtt.username= Set the MQTT username [$MQTT_USERNAME]
--mqtt.password= Set the MQTT password [$MQTT_PASSWORD]
--mqtt.password-file= Path to a file containing the MQTT password [$MQTT_PASSWORD_FILE]
--control.profile= Write policy profile: normal, maintenance, or read_only. (default: normal) [$CONTROL_PROFILE]
--control.max_current_limit= Optional max AC current limit guardrail in amps (0 disables). (default: 0) [$CONTROL_MAX_CURRENT_LIMIT]
--control.mode_change_min_interval= Minimum time between mode changes. (default: 3s) [$CONTROL_MODE_CHANGE_MIN_INTERVAL]
--control.lockout_window= Post-command lockout window for command arbitration. (default: 0s) [$CONTROL_LOCKOUT_WINDOW]
--control.allowed_panel_modes= Comma-separated allowlist of remote panel modes. Supported values: on, off, charger_only, inverter_only. Empty allows all modes. [$CONTROL_ALLOWED_PANEL_MODES]
--loglevel= The log level to generate logs at. ("panic", "fatal", "error", "warn", "info", "debug", "trace") (default: info) [$LOGLEVEL]
Help Options:
-h, --help Show this help message
``` ```
### Read-Only Mode
Set `READ_ONLY=true` (or `--read_only`) to disable all write operations.
When read-only mode is enabled, the app still monitors and publishes telemetry, but it will not send commands to the Victron device.
This affects:
- MQTT command handling (`--mqtt.command_topic` commands are ignored)
- Web UI control actions (`POST /api/remote-panel/state` and `POST /api/remote-panel/standby`)
### Remote Panel Mode Allowlist
Set `CONTROL_ALLOWED_PANEL_MODES` (or `--control.allowed_panel_modes`) to restrict which
remote panel modes can be selected from Web UI, MQTT, Home Assistant, and Venus-compatible
write paths.
Supported values:
- `on`
- `off`
- `charger_only`
- `inverter_only`
Use a comma-separated list. Empty means all modes are allowed.
Example:
- `CONTROL_ALLOWED_PANEL_MODES=off,charger_only`
Example `docker-compose.yml` snippet:
```yaml
services:
invertergui:
image: registry.coadcorp.com/nathan/invertergui:latest
environment:
READ_ONLY: "true"
CONTROL_ALLOWED_PANEL_MODES: "off,charger_only"
devices:
- "/dev/ttyUSB0:/dev/ttyUSB0"
command: ["--mqtt.enabled", "--mqtt.broker=tcp://192.168.1.1:1883", "--loglevel=info"]
```
### Home Assistant Guide-Style ESS Control
To mimic the Victron community ESS control approach (MQTT `N/...` state + `W/...` writes under `settings/0/Settings/CGwacs/...`):
1. Start invertergui with:
- `MQTT_VENUS_ENABLED=true`
- `MQTT_VENUS_GUIDE_COMPAT=true`
- `MQTT_VENUS_TOPIC_PREFIX=victron`
- `MQTT_VENUS_PORTAL_ID=invertergui` (or your chosen portal id)
2. In Home Assistant:
- Use the included custom integration (`custom_components/victron_mk2_mqtt`) which now exposes ESS-style entities and service calls.
- Or use `homeassistant/packages/invertergui_mqtt.yaml`, which includes guide-style MQTT entities:
- `number.victron_ess_grid_setpoint`
- `number.victron_ess_max_charge_power`
- `number.victron_ess_max_discharge_power`
- `switch.victron_ess_optimized_mode`
Compatibility note:
- MK2/VE.Bus does not expose every Venus ESS feature one-to-one. This project maps ESS-style commands onto available MK2 controls (mode/current-limit/policy-safe behavior) to provide similar Home Assistant control flow.
## Port 8080 ## Port 8080
The default HTTP server port is hosted on port 8080. This exposes the HTTP server that hosts the: The default HTTP server port is hosted on port 8080. This exposes the HTTP server that hosts the:
@@ -69,6 +225,20 @@ Battery Power: -0.659 W
Battery Charge: 100.000 % Battery Charge: 100.000 %
``` ```
The web UI also includes a **Remote Panel Control** section for:
- Remote Panel Mode (`on`, `off`, `charger_only`, `inverter_only`)
- Remote Panel Current Limit (AC input current limit in amps)
- Remote Panel Standby (prevent sleep while turned off)
The combined mode + current limit action maps to the same behavior as
`set_remote_panel_state` in `victron-mk3`.
The backing HTTP API endpoints are:
- `GET/POST /api/remote-panel/state`
- `GET/POST /api/remote-panel/standby`
### Munin ### Munin
The Munin plugin location is at /munin (http://localhost:8080/munin). The Munin plugin location is at /munin (http://localhost:8080/munin).
@@ -259,6 +429,285 @@ process_start_time_seconds 1.54506833485e+09
process_virtual_memory_bytes 1.15101696e+08 process_virtual_memory_bytes 1.15101696e+08
``` ```
### MQTT
The MQTT client will publish updates to the given broker at the set topic.
#### MQTT Configuration Options
```bash
--mqtt.enabled Enable MQTT publishing. [$MQTT_ENABLED]
--mqtt.broker= Set the host port and scheme of the MQTT broker. (default: tcp://localhost:1883) [$MQTT_BROKER]
--mqtt.client_id= Set the client ID for the MQTT connection. (default: inverter-gui) [$MQTT_CLIENT_ID]
--mqtt.topic= Set the MQTT topic updates published to. (default: invertergui/updates) [$MQTT_TOPIC]
--mqtt.command_topic= Set the MQTT topic that receives write commands for Victron settings/RAM variables. (default: invertergui/settings/set) [$MQTT_COMMAND_TOPIC]
--mqtt.status_topic= Set the MQTT topic where write command status updates are published. (default: invertergui/settings/status) [$MQTT_STATUS_TOPIC]
--mqtt.device_id= Set logical device ID used for per-device orchestration topics. (default: invertergui) [$MQTT_DEVICE_ID]
--mqtt.history_size= Number of samples retained for rolling history summaries. (default: 120) [$MQTT_HISTORY_SIZE]
--mqtt.ha.enabled Enable Home Assistant MQTT discovery integration. [$MQTT_HA_ENABLED]
--mqtt.ha.discovery_prefix= Set Home Assistant MQTT discovery prefix. (default: homeassistant) [$MQTT_HA_DISCOVERY_PREFIX]
--mqtt.ha.node_id= Set Home Assistant node ID used for discovery topics and unique IDs. (default: invertergui) [$MQTT_HA_NODE_ID]
--mqtt.ha.device_name= Set Home Assistant device display name. (default: Victron Inverter) [$MQTT_HA_DEVICE_NAME]
--mqtt.venus.enabled Enable Venus-style MQTT compatibility topics (N/W model). [$MQTT_VENUS_ENABLED]
--mqtt.venus.portal_id= Set Venus portal ID segment used in N/W topics. (default: invertergui) [$MQTT_VENUS_PORTAL_ID]
--mqtt.venus.service= Set Venus service segment used in N/W topics. (default: vebus/257) [$MQTT_VENUS_SERVICE]
--mqtt.venus.subscribe_writes Subscribe to Venus W/... topics and map to MK2 commands. [$MQTT_VENUS_SUBSCRIBE_WRITES]
--mqtt.username= Set the MQTT username [$MQTT_USERNAME]
--mqtt.password= Set the MQTT password [$MQTT_PASSWORD]
--mqtt.password-file= Path to a file containing the MQTT password [$MQTT_PASSWORD_FILE]
```
Related global option:
```bash
--read_only Disable all write operations and run in monitoring-only mode. [$READ_ONLY]
```
The MQTT client can be enabled by setting the environment variable `MQTT_ENABLED=true` or flag `--mqtt.enabled`.
All MQTT configuration can be done via flags or as environment variables.
The URI for the broker can be configured format should be `scheme://host:port`, where "scheme" is one of "tcp", "ssl", or "ws".
When `--mqtt.command_topic` is configured, the application subscribes to that topic and accepts JSON write commands.
The recommended command for inverter control follows the same model used by `victron-mk3`:
```json
{
"request_id": "optional-correlation-id",
"kind": "panel_state",
"switch": "on",
"current_limit": 16.5
}
```
`switch` supports `charger_only`, `inverter_only`, `on`, and `off` (or numeric values `1..4`).
`current_limit` is in amps and optional. If omitted, only the switch state is changed.
To update only the current limit (while preserving the last known mode), send:
```json
{
"kind": "panel_state",
"current_limit": 12.0
}
```
If no prior mode is known (for example on a fresh broker state), this command is rejected until a mode command is sent once.
Standby can be controlled with:
```json
{
"kind": "standby",
"standby": true
}
```
Low-level writes are still supported:
```json
{
"kind": "setting",
"id": 15,
"value": 1
}
```
`kind` supports `panel_state`, `setting`, and `ram_var` (with aliases for each).
The result is published to `--mqtt.status_topic` with status `ok` or `error`.
### MQTT Device Orchestration Topics
For multi-device deployments on one MQTT broker, `invertergui` now also publishes
device-scoped orchestration topics under:
- `{topic-root}/devices/{device_id}/state`
- `{topic-root}/devices/{device_id}/history/summary`
- `{topic-root}/devices/{device_id}/alarms/active`
Set `--mqtt.device_id` (or `MQTT_DEVICE_ID`) per inverter instance so each instance
publishes to a unique device path.
Rolling history depth for the summary window is set by `--mqtt.history_size`.
### Venus-Style MQTT Compatibility
Enable Venus-compatible topics with:
```bash
--mqtt.venus.enabled
```
When enabled, `invertergui` publishes Venus-style notifications to:
- `N/{portal_id}/{service}/...`
Defaults:
- `portal_id`: `invertergui` (`--mqtt.venus.portal_id`)
- `service`: `vebus/257` (`--mqtt.venus.service`)
Published paths include common VE.Bus style values such as:
- `Dc/0/Voltage`, `Dc/0/Current`, `Dc/0/Power`, `Soc`
- `Ac/ActiveIn/L1/V`, `Ac/ActiveIn/L1/I`, `Ac/ActiveIn/L1/F`, `Ac/ActiveIn/L1/P`
- `Ac/Out/L1/V`, `Ac/Out/L1/I`, `Ac/Out/L1/F`, `Ac/Out/L1/P`
- alarm paths (`Alarms/LowBattery`, `Alarms/HighTemperature`, `Alarms/Overload`, `Alarms/Communication`)
Optional write compatibility can be enabled with:
```bash
--mqtt.venus.subscribe_writes
```
This subscribes to:
- `W/{portal_id}/{service}/#`
and maps supported write paths to MK2 control operations:
- `Mode` -> remote panel mode
- `Ac/ActiveIn/CurrentLimit` -> remote panel current limit
- `Settings/Standby` / `RemotePanel/Standby` -> standby control
### Home Assistant
Enable Home Assistant auto-discovery with:
```bash
--mqtt.ha.enabled
```
When enabled, `invertergui` publishes retained discovery payloads and availability under:
- `{topic-root}/homeassistant/availability` (`online`/`offline`)
- `{discovery_prefix}/sensor/{node_id}/.../config`
- `{discovery_prefix}/binary_sensor/{node_id}/.../config`
- `{discovery_prefix}/select/{node_id}/remote_panel_mode/config` (if command topic is configured)
- `{discovery_prefix}/number/{node_id}/remote_panel_current_limit/config` (if command topic is configured)
- `{discovery_prefix}/switch/{node_id}/remote_panel_standby/config` (if command topic is configured)
The discovered entities include battery/input/output sensors, a data-valid diagnostic binary sensor,
plus remote panel controls for:
- `Remote Panel Mode` (`on`, `off`, `charger_only`, `inverter_only`)
- `Remote Panel Current Limit` (AC input current limit in amps)
- `Remote Panel Standby` (prevent device sleep while off)
The combined mode + current limit behavior is provided through the `panel_state` MQTT command kind,
which mirrors `victron_mk3.set_remote_panel_state`.
### Home Assistant Custom Component (MQTT)
This repository also includes a custom Home Assistant integration at:
- `custom_components/victron_mk2_mqtt`
This component is useful if you want HA entities/services that are explicitly tied to
`invertergui` MQTT topics, instead of relying only on MQTT auto-discovery entities.
If you use this custom component, you can disable `--mqtt.ha.enabled` in `invertergui`
to avoid duplicate entities created by MQTT discovery.
Install via HACS:
1. Add the integration repository in Home Assistant:
[![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=nathan&repository=invertergui&category=integration)
2. Install `Victron MK2 MQTT` from HACS.
3. Restart Home Assistant.
4. Add the YAML configuration shown below.
If you are not mirroring this repo to GitHub, use the manual install method below.
Manual install (alternative):
```text
<home-assistant-config>/custom_components/victron_mk2_mqtt
```
Then add YAML config:
```yaml
victron_mk2_mqtt:
name: Victron Inverter
state_topic: invertergui/updates
command_topic: invertergui/settings/set
status_topic: invertergui/settings/status
# topic_root is optional; defaults to state_topic root (for example "invertergui")
# topic_root: invertergui
```
Provided entities include:
- Telemetry sensors (battery/input/output voltage/current/frequency and derived power)
- `Remote Panel Mode` (`charger_only`, `inverter_only`, `on`, `off`)
- `Remote Panel Current Limit` (A)
- `Remote Panel Standby`
- Diagnostic entities (`Data Valid`, `Last Command Error`)
Service exposed by the integration:
- `victron_mk2_mqtt.set_remote_panel_state`
Example service call:
```yaml
service: victron_mk2_mqtt.set_remote_panel_state
data:
mode: on
current_limit: 16.0
```
### Home Assistant MQTT-Only Dashboard (No Duplicate Entities)
If you want the same control/telemetry experience but only via MQTT (without duplicate
entities from discovery/custom integrations), use the packaged Home Assistant files:
- MQTT entity + control package: `homeassistant/packages/invertergui_mqtt.yaml`
- Lovelace dashboard: `homeassistant/dashboards/invertergui_mqtt_dashboard.yaml`
The package assumes default topics (`invertergui/updates`, `invertergui/settings/set`,
`invertergui/settings/status`). If you use custom MQTT topics, update those values in
`homeassistant/packages/invertergui_mqtt.yaml`.
Recommended for this mode:
- Disable MQTT discovery output from `invertergui` (`--mqtt.ha.enabled=false`)
- Do not enable the `victron_mk2_mqtt` custom component at the same time
1. Ensure HA packages are enabled (if not already):
```yaml
homeassistant:
packages: !include_dir_named packages
```
2. Copy package file to your HA config:
```text
<home-assistant-config>/packages/invertergui_mqtt.yaml
```
3. Copy dashboard file to your HA config:
```text
<home-assistant-config>/dashboards/invertergui_mqtt_dashboard.yaml
```
4. Register the dashboard (YAML mode example):
```yaml
lovelace:
mode: storage
dashboards:
invertergui-victron:
mode: yaml
title: Victron MQTT
icon: mdi:flash
show_in_sidebar: true
filename: dashboards/invertergui_mqtt_dashboard.yaml
```
5. Restart Home Assistant.
## TTY Device ## TTY Device
The intertergui application makes use of a serial tty device to monitor the Multiplus. The intertergui application makes use of a serial tty device to monitor the Multiplus.
@@ -320,4 +769,4 @@ The last four lines are optional, but is useful when debugging and logging conne
This repos includes a [Grafana](https://grafana.com/) dashboard in the [grafana folder](./grafana/prometheus-dashboard.json) that you can import. This is useful if you are using prometheus to log your data and want to display it in a nice way. This repos includes a [Grafana](https://grafana.com/) dashboard in the [grafana folder](./grafana/prometheus-dashboard.json) that you can import. This is useful if you are using prometheus to log your data and want to display it in a nice way.
![grafana](https://rawcdn.githack.com/diebietse/invertergui/e20f8fb9161758cd12de95d675aee0ed2e044d8e/grafana/dashboard.png "Grafana Dashboard") ![grafana](./grafana/dashboard.png "Grafana Dashboard")

98
cmd/invertergui/config.go Normal file
View File

@@ -0,0 +1,98 @@
package main
import (
"fmt"
"os"
"strings"
"time"
"github.com/jessevdk/go-flags"
)
type config struct {
Address string `long:"address" env:"ADDRESS" default:":8080" description:"The IP/DNS and port of the machine that the application is running on."`
ReadOnly bool `long:"read_only" env:"READ_ONLY" description:"Disable all write operations and run in monitoring-only mode."`
Control struct {
Profile string `long:"control.profile" env:"CONTROL_PROFILE" default:"normal" description:"Write policy profile: normal, maintenance, or read_only."`
MaxCurrentLimit float64 `long:"control.max_current_limit" env:"CONTROL_MAX_CURRENT_LIMIT" default:"0" description:"Optional max AC current limit guardrail in amps (0 disables)."`
ModeChangeMinInterval time.Duration `long:"control.mode_change_min_interval" env:"CONTROL_MODE_CHANGE_MIN_INTERVAL" default:"3s" description:"Minimum time between mode changes."`
LockoutWindow time.Duration `long:"control.lockout_window" env:"CONTROL_LOCKOUT_WINDOW" default:"0s" description:"Post-command lockout window for command arbitration."`
AllowedPanelModes string `long:"control.allowed_panel_modes" env:"CONTROL_ALLOWED_PANEL_MODES" default:"" description:"Comma-separated allowlist of remote panel modes. Supported values: on, off, charger_only, inverter_only. Empty allows all modes."`
}
Data struct {
Source string `long:"data.source" env:"DATA_SOURCE" default:"serial" description:"Set the source of data for the inverter gui. \"serial\", \"tcp\" or \"mock\""`
Host string `long:"data.host" env:"DATA_HOST" default:"localhost:8139" description:"Host to connect when source is set to tcp."`
Device string `long:"data.device" env:"DATA_DEVICE" default:"/dev/ttyUSB0" description:"TTY device to use when source is set to serial."`
}
Cli struct {
Enabled bool `long:"cli.enabled" env:"CLI_ENABLED" description:"Enable CLI output."`
}
MQTT struct {
Enabled bool `long:"mqtt.enabled" env:"MQTT_ENABLED" description:"Enable MQTT publishing."`
Broker string `long:"mqtt.broker" env:"MQTT_BROKER" default:"tcp://localhost:1883" description:"Set the host port and scheme of the MQTT broker."`
ClientID string `long:"mqtt.client_id" env:"MQTT_CLIENT_ID" default:"inverter-gui" description:"Set the client ID for the MQTT connection."`
Topic string `long:"mqtt.topic" env:"MQTT_TOPIC" default:"invertergui/updates" description:"Set the MQTT topic updates published to."`
CommandTopic string `long:"mqtt.command_topic" env:"MQTT_COMMAND_TOPIC" default:"invertergui/settings/set" description:"Set the MQTT topic that receives write commands for Victron settings/RAM variables."`
StatusTopic string `long:"mqtt.status_topic" env:"MQTT_STATUS_TOPIC" default:"invertergui/settings/status" description:"Set the MQTT topic where write command status updates are published."`
DeviceID string `long:"mqtt.device_id" env:"MQTT_DEVICE_ID" default:"invertergui" description:"Set the logical device ID used for per-device orchestration topics."`
HistorySize int `long:"mqtt.history_size" env:"MQTT_HISTORY_SIZE" default:"120" description:"Number of telemetry samples retained for rolling history summaries."`
InstanceID int `long:"mqtt.instance_id" env:"MQTT_INSTANCE_ID" default:"0" description:"Device instance ID for multi-device orchestration and Venus compatibility."`
Phase string `long:"mqtt.phase" env:"MQTT_PHASE" default:"L1" description:"Electrical phase label for this instance (L1/L2/L3)."`
PhaseGroup string `long:"mqtt.phase_group" env:"MQTT_PHASE_GROUP" default:"default" description:"Grouping key for parallel/3-phase system aggregation topics."`
HA struct {
Enabled bool `long:"mqtt.ha.enabled" env:"MQTT_HA_ENABLED" description:"Enable Home Assistant MQTT discovery integration."`
DiscoveryPrefix string `long:"mqtt.ha.discovery_prefix" env:"MQTT_HA_DISCOVERY_PREFIX" default:"homeassistant" description:"Set Home Assistant MQTT discovery prefix."`
NodeID string `long:"mqtt.ha.node_id" env:"MQTT_HA_NODE_ID" default:"invertergui" description:"Set Home Assistant node ID used for discovery topics and unique IDs."`
DeviceName string `long:"mqtt.ha.device_name" env:"MQTT_HA_DEVICE_NAME" default:"Victron Inverter" description:"Set Home Assistant device display name."`
}
Venus struct {
Enabled bool `long:"mqtt.venus.enabled" env:"MQTT_VENUS_ENABLED" description:"Enable Venus-style MQTT compatibility topics (N/W topic model)."`
PortalID string `long:"mqtt.venus.portal_id" env:"MQTT_VENUS_PORTAL_ID" default:"invertergui" description:"Set Venus portal ID segment used in N/W topics."`
Service string `long:"mqtt.venus.service" env:"MQTT_VENUS_SERVICE" default:"vebus/257" description:"Set Venus service segment used in N/W topics."`
SubscribeWrites bool `long:"mqtt.venus.subscribe_writes" env:"MQTT_VENUS_SUBSCRIBE_WRITES" description:"Subscribe to Venus write topics and map them to MK2 control commands."`
TopicPrefix string `long:"mqtt.venus.topic_prefix" env:"MQTT_VENUS_TOPIC_PREFIX" default:"" description:"Optional topic prefix before Venus N/W topics, for example 'victron'."`
GuideCompat bool `long:"mqtt.venus.guide_compat" env:"MQTT_VENUS_GUIDE_COMPAT" description:"Enable guide-style settings/0/Settings/CGwacs compatibility paths for Home Assistant controls."`
}
Username string `long:"mqtt.username" env:"MQTT_USERNAME" default:"" description:"Set the MQTT username"`
Password string `long:"mqtt.password" env:"MQTT_PASSWORD" default:"" description:"Set the MQTT password"`
PasswordFile string `long:"mqtt.password-file" env:"MQTT_PASSWORD_FILE" default:"" description:"Path to a file containing the MQTT password"`
}
Loglevel string `long:"loglevel" env:"LOGLEVEL" default:"info" description:"The log level to generate logs at. (\"panic\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\")"`
}
func parseConfig() (*config, error) {
conf := &config{}
// go-flags bool options cannot use struct-tag defaults; keep intended behavior via struct initialization.
conf.MQTT.Venus.SubscribeWrites = true
conf.MQTT.Venus.GuideCompat = true
parser := flags.NewParser(conf, flags.Default)
if _, err := parser.Parse(); err != nil {
return nil, err
}
if err := resolvePasswordFile(conf); err != nil {
return nil, err
}
return conf, nil
}
func resolvePasswordFile(conf *config) error {
if conf.MQTT.PasswordFile != "" && conf.MQTT.Password != "" {
return fmt.Errorf("mqtt.password and mqtt.password-file are mutually exclusive")
}
if conf.MQTT.PasswordFile != "" {
password, err := readPasswordFile(conf.MQTT.PasswordFile)
if err != nil {
return err
}
conf.MQTT.Password = password
}
return nil
}
func readPasswordFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("could not read MQTT password file: %w", err)
}
return strings.TrimRight(string(data), "\n\r"), nil
}

View File

@@ -0,0 +1,107 @@
package main
import (
"os"
"path/filepath"
"testing"
)
const testInlineSecret = "inline-secret"
func TestReadPasswordFile(t *testing.T) {
tests := []struct {
name string
content string
expected string
}{
{
name: "plain password",
content: "secret",
expected: "secret",
},
{
name: "password with trailing newline",
content: "secret\n",
expected: "secret",
},
{
name: "password with trailing carriage return and newline",
content: "secret\r\n",
expected: "secret",
},
{
name: "empty file",
content: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "password")
if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
got, err := readPasswordFile(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.expected {
t.Errorf("got %q, want %q", got, tt.expected)
}
})
}
}
func TestReadPasswordFile_NotFound(t *testing.T) {
_, err := readPasswordFile("/nonexistent/path/password")
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
func TestResolvePassword_MutuallyExclusive(t *testing.T) {
path := filepath.Join(t.TempDir(), "password")
if err := os.WriteFile(path, []byte("secret"), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
conf := &config{}
conf.MQTT.Password = testInlineSecret
conf.MQTT.PasswordFile = path
err := resolvePasswordFile(conf)
if err == nil {
t.Fatal("expected error when both mqtt.password and mqtt.password-file are set, got nil")
}
}
func TestResolvePassword_FromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "password")
if err := os.WriteFile(path, []byte("file-secret\n"), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
conf := &config{}
conf.MQTT.PasswordFile = path
if err := resolvePasswordFile(conf); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conf.MQTT.Password != "file-secret" {
t.Errorf("got %q, want %q", conf.MQTT.Password, "file-secret")
}
}
func TestResolvePassword_NoFile(t *testing.T) {
conf := &config{}
conf.MQTT.Password = testInlineSecret
if err := resolvePasswordFile(conf); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conf.MQTT.Password != testInlineSecret {
t.Errorf("got %q, want %q", conf.MQTT.Password, testInlineSecret)
}
}

View File

@@ -31,90 +31,287 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package main package main
import ( import (
"flag" "fmt"
"io" "io"
"log"
"net" "net"
"net/http" "net/http"
"os" "os"
"sort"
"strings"
"github.com/diebietse/invertergui/mk2core" "git.coadcorp.com/nathan/invertergui/mk2core"
"github.com/diebietse/invertergui/mk2driver" "git.coadcorp.com/nathan/invertergui/mk2driver"
"github.com/diebietse/invertergui/plugins/cli" "git.coadcorp.com/nathan/invertergui/plugins/cli"
"github.com/diebietse/invertergui/plugins/munin" "git.coadcorp.com/nathan/invertergui/plugins/mqttclient"
"github.com/diebietse/invertergui/plugins/prometheus" "git.coadcorp.com/nathan/invertergui/plugins/munin"
"github.com/diebietse/invertergui/plugins/webui" "git.coadcorp.com/nathan/invertergui/plugins/prometheus"
"github.com/diebietse/invertergui/plugins/webui/static" "git.coadcorp.com/nathan/invertergui/plugins/webui"
"git.coadcorp.com/nathan/invertergui/plugins/webui/static"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/sirupsen/logrus"
"github.com/tarm/serial" "github.com/tarm/serial"
) )
func main() { var log = logrus.WithField("ctx", "inverter-gui")
source := flag.String("source", "serial", "Set the source of data for the inverter gui. \"serial\", \"tcp\" or \"mock\"")
addr := flag.String("addr", ":8080", "TCP address to listen on.")
ip := flag.String("ip", "localhost:8139", "IP to connect when using tcp connection.")
dev := flag.String("dev", "/dev/ttyUSB0", "TTY device to use.")
cliEnable := flag.Bool("cli", false, "Enable CLI output")
flag.Parse()
mk2 := getMk2Device(*source, *ip, *dev) func main() {
conf, err := parseConfig()
if err != nil {
log.WithError(err).Error("Could not parse configuration")
os.Exit(1)
}
log.Info("Starting invertergui")
logLevel, err := logrus.ParseLevel(conf.Loglevel)
if err != nil {
log.Fatalf("Could not parse log level: %v", err)
}
logrus.SetLevel(logLevel)
log.WithFields(logrus.Fields{
"loglevel": conf.Loglevel,
"address": conf.Address,
"read_only": conf.ReadOnly,
"data_source": conf.Data.Source,
"data_host": conf.Data.Host,
"data_device": conf.Data.Device,
"cli_enabled": conf.Cli.Enabled,
"mqtt_enabled": conf.MQTT.Enabled,
"mqtt_broker": conf.MQTT.Broker,
"mqtt_topic": conf.MQTT.Topic,
"mqtt_command_topic": conf.MQTT.CommandTopic,
"mqtt_status_topic": conf.MQTT.StatusTopic,
"mqtt_device_id": conf.MQTT.DeviceID,
"mqtt_history_size": conf.MQTT.HistorySize,
"mqtt_ha_enabled": conf.MQTT.HA.Enabled,
"mqtt_venus_enabled": conf.MQTT.Venus.Enabled,
"mqtt_venus_portal": conf.MQTT.Venus.PortalID,
"mqtt_venus_service": conf.MQTT.Venus.Service,
"mqtt_venus_prefix": conf.MQTT.Venus.TopicPrefix,
"mqtt_venus_guide": conf.MQTT.Venus.GuideCompat,
"control_profile": conf.Control.Profile,
"control_modes": conf.Control.AllowedPanelModes,
}).Info("Configuration loaded")
mk2, err := getMk2Device(conf.Data.Source, conf.Data.Host, conf.Data.Device)
if err != nil {
log.Fatalf("Could not open data source: %v", err)
}
defer mk2.Close() defer mk2.Close()
log.Info("MK2 device connection established")
core := mk2core.NewCore(mk2) core := mk2core.NewCore(mk2)
if *cliEnable { if conf.Cli.Enabled {
log.Info("CLI plugin enabled")
cli.NewCli(core.NewSubscription()) cli.NewCli(core.NewSubscription())
} }
// Webgui // Webgui
gui := webui.NewWebGui(core.NewSubscription()) var writer mk2driver.SettingsWriter
if w, ok := mk2.(mk2driver.SettingsWriter); ok {
writer = w
log.Info("MK2 data source supports settings writes")
} else {
log.Warn("MK2 data source does not support settings writes")
}
if conf.ReadOnly {
if writer != nil {
log.Warn("READ_ONLY enabled; disabling all write operations")
} else {
log.Info("READ_ONLY enabled")
}
writer = nil
} else if writer != nil {
allowedPanelStates, allowedPanelModeNames, parseErr := parseAllowedPanelModes(conf.Control.AllowedPanelModes)
if parseErr != nil {
log.WithError(parseErr).Fatal("Invalid control.allowed_panel_modes configuration")
}
policyProfile := mk2driver.WriterProfile(strings.ToLower(strings.TrimSpace(conf.Control.Profile)))
if policyProfile == "" {
policyProfile = mk2driver.WriterProfileNormal
}
if policyProfile != mk2driver.WriterProfileNormal &&
policyProfile != mk2driver.WriterProfileMaintenance &&
policyProfile != mk2driver.WriterProfileReadOnly {
log.WithField("profile", conf.Control.Profile).Warn("Unknown control profile; defaulting to normal")
policyProfile = mk2driver.WriterProfileNormal
}
var maxCurrentLimit *float64
if conf.Control.MaxCurrentLimit > 0 {
limit := conf.Control.MaxCurrentLimit
maxCurrentLimit = &limit
}
writer = mk2driver.NewManagedWriter(writer, mk2driver.WriterPolicy{
Profile: policyProfile,
MaxCurrentLimitA: maxCurrentLimit,
ModeChangeMinInterval: conf.Control.ModeChangeMinInterval,
LockoutWindow: conf.Control.LockoutWindow,
AllowedPanelStates: allowedPanelStates,
})
allowedModes := "all"
if len(allowedPanelModeNames) > 0 {
allowedModes = strings.Join(allowedPanelModeNames, ",")
}
log.WithFields(logrus.Fields{
"profile": policyProfile,
"max_current_limit": conf.Control.MaxCurrentLimit,
"mode_change_min_interval": conf.Control.ModeChangeMinInterval,
"lockout_window": conf.Control.LockoutWindow,
"allowed_panel_modes": allowedModes,
}).Info("Write policy/arbitration layer enabled")
}
gui := webui.NewWebGui(core.NewSubscription(), writer)
http.Handle("/", static.New()) http.Handle("/", static.New())
http.Handle("/ws", http.HandlerFunc(gui.ServeHub)) http.Handle("/ws", http.HandlerFunc(gui.ServeHub))
http.Handle("/api/remote-panel/state", http.HandlerFunc(gui.ServeRemotePanelState))
http.Handle("/api/remote-panel/standby", http.HandlerFunc(gui.ServeRemotePanelStandby))
log.Info("Web UI routes registered")
// Munin // Munin
mu := munin.NewMunin(core.NewSubscription()) mu := munin.NewMunin(core.NewSubscription())
http.Handle("/munin", http.HandlerFunc(mu.ServeMuninHTTP)) http.Handle("/munin", http.HandlerFunc(mu.ServeMuninHTTP))
http.Handle("/muninconfig", http.HandlerFunc(mu.ServeMuninConfigHTTP)) http.Handle("/muninconfig", http.HandlerFunc(mu.ServeMuninConfigHTTP))
log.Info("Munin routes registered")
// Prometheus // Prometheus
prometheus.NewPrometheus(core.NewSubscription()) prometheus.NewPrometheus(core.NewSubscription())
http.Handle("/metrics", promhttp.Handler()) http.Handle("/metrics", promhttp.Handler())
log.Info("Prometheus route registered")
log.Fatal(http.ListenAndServe(*addr, nil)) // MQTT
if conf.MQTT.Enabled {
mqttConf := mqttclient.Config{
Broker: conf.MQTT.Broker,
Topic: conf.MQTT.Topic,
CommandTopic: conf.MQTT.CommandTopic,
StatusTopic: conf.MQTT.StatusTopic,
ClientID: conf.MQTT.ClientID,
DeviceID: conf.MQTT.DeviceID,
HistorySize: conf.MQTT.HistorySize,
InstanceID: conf.MQTT.InstanceID,
Phase: conf.MQTT.Phase,
PhaseGroup: conf.MQTT.PhaseGroup,
HomeAssistant: mqttclient.HomeAssistantConfig{
Enabled: conf.MQTT.HA.Enabled,
DiscoveryPrefix: conf.MQTT.HA.DiscoveryPrefix,
NodeID: conf.MQTT.HA.NodeID,
DeviceName: conf.MQTT.HA.DeviceName,
},
Venus: mqttclient.VenusConfig{
Enabled: conf.MQTT.Venus.Enabled,
PortalID: conf.MQTT.Venus.PortalID,
Service: conf.MQTT.Venus.Service,
SubscribeWrites: conf.MQTT.Venus.SubscribeWrites,
TopicPrefix: conf.MQTT.Venus.TopicPrefix,
GuideCompat: conf.MQTT.Venus.GuideCompat,
},
Username: conf.MQTT.Username,
Password: conf.MQTT.Password,
}
if writer == nil {
log.Warn("MK2 data source does not support write commands; MQTT command topic will be ignored")
}
if err := mqttclient.New(core.NewSubscription(), writer, mqttConf); err != nil {
log.Fatalf("Could not setup MQTT client: %v", err)
}
log.Info("MQTT client initialized")
}
log.Infof("Invertergui web server starting on: %v", conf.Address)
if err := http.ListenAndServe(conf.Address, nil); err != nil {
log.Fatal(err)
}
} }
func getMk2Device(source, ip, dev string) mk2driver.Mk2 { func getMk2Device(source, ip, dev string) (mk2driver.Mk2, error) {
var p io.ReadWriteCloser var p io.ReadWriteCloser
var err error var err error
var tcpAddr *net.TCPAddr var tcpAddr *net.TCPAddr
switch source { switch source {
case "serial": case "serial":
log.WithField("device", dev).Info("Opening serial MK2 source")
serialConfig := &serial.Config{Name: dev, Baud: 2400} serialConfig := &serial.Config{Name: dev, Baud: 2400}
p, err = serial.OpenPort(serialConfig) p, err = serial.OpenPort(serialConfig)
if err != nil { if err != nil {
panic(err) return nil, err
} }
case "tcp": case "tcp":
log.WithField("host", ip).Info("Opening TCP MK2 source")
tcpAddr, err = net.ResolveTCPAddr("tcp", ip) tcpAddr, err = net.ResolveTCPAddr("tcp", ip)
if err != nil { if err != nil {
panic(err) return nil, err
} }
p, err = net.DialTCP("tcp", nil, tcpAddr) p, err = net.DialTCP("tcp", nil, tcpAddr)
if err != nil { if err != nil {
panic(err) return nil, err
} }
case "mock": case "mock":
return mk2driver.NewMk2Mock() log.Info("Using mock MK2 data source")
return mk2driver.NewMk2Mock(), nil
default: default:
log.Printf("Invalid source selection: %v\nUse \"serial\", \"tcp\" or \"mock\"", source) return nil, fmt.Errorf("Invalid source selection: %v\nUse \"serial\", \"tcp\" or \"mock\"", source)
os.Exit(1)
} }
mk2, err := mk2driver.NewMk2Connection(p) mk2, err := mk2driver.NewMk2Connection(p)
if err != nil { if err != nil {
panic(err) return nil, err
}
log.WithField("source", source).Info("MK2 connection ready")
return mk2, nil
}
func parseAllowedPanelModes(raw string) (map[mk2driver.PanelSwitchState]struct{}, []string, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil, nil
} }
return mk2 out := make(map[mk2driver.PanelSwitchState]struct{})
names := make([]string, 0, 4)
for _, token := range strings.Split(raw, ",") {
mode := strings.ToLower(strings.TrimSpace(token))
if mode == "" {
continue
}
var state mk2driver.PanelSwitchState
var canonical string
switch mode {
case "on":
state = mk2driver.PanelSwitchOn
canonical = "on"
case "off":
state = mk2driver.PanelSwitchOff
canonical = "off"
case "charger_only", "charger-only":
state = mk2driver.PanelSwitchChargerOnly
canonical = "charger_only"
case "inverter_only", "inverter-only":
state = mk2driver.PanelSwitchInverterOnly
canonical = "inverter_only"
default:
return nil, nil, fmt.Errorf(
"unsupported panel mode %q in control.allowed_panel_modes; supported values: on, off, charger_only, inverter_only",
token,
)
}
if _, exists := out[state]; !exists {
out[state] = struct{}{}
names = append(names, canonical)
}
}
if len(out) == 0 {
return nil, nil, fmt.Errorf("control.allowed_panel_modes is set but no valid modes were provided")
}
sort.Strings(names)
return out, names, nil
} }

View File

@@ -0,0 +1,46 @@
package main
import (
"testing"
"git.coadcorp.com/nathan/invertergui/mk2driver"
)
func TestParseAllowedPanelModesEmpty(t *testing.T) {
states, names, err := parseAllowedPanelModes("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if states != nil {
t.Fatalf("expected nil state map for empty input, got %#v", states)
}
if names != nil {
t.Fatalf("expected nil names for empty input, got %#v", names)
}
}
func TestParseAllowedPanelModesValid(t *testing.T) {
states, names, err := parseAllowedPanelModes("off, charger-only,off")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(states) != 2 {
t.Fatalf("expected 2 allowed states, got %d", len(states))
}
if _, ok := states[mk2driver.PanelSwitchOff]; !ok {
t.Fatalf("off should be allowed")
}
if _, ok := states[mk2driver.PanelSwitchChargerOnly]; !ok {
t.Fatalf("charger_only should be allowed")
}
if len(names) != 2 {
t.Fatalf("expected 2 names, got %d", len(names))
}
}
func TestParseAllowedPanelModesInvalid(t *testing.T) {
_, _, err := parseAllowedPanelModes("off,invalid_mode")
if err == nil {
t.Fatal("expected error for invalid mode")
}
}

View File

@@ -0,0 +1,186 @@
"""Home Assistant integration for invertergui MQTT topics."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_ESS_MAX_CHARGE_POWER,
ATTR_ESS_MAX_DISCHARGE_POWER,
ATTR_ESS_MODE,
ATTR_ESS_SETPOINT,
ATTR_CURRENT_LIMIT,
ATTR_MODE,
CONF_COMMAND_TOPIC,
CONF_STATE_TOPIC,
CONF_STATUS_TOPIC,
CONF_TOPIC_ROOT,
CONF_VENUS_GUIDE_COMPAT,
CONF_VENUS_PORTAL_ID,
CONF_VENUS_TOPIC_PREFIX,
DATA_BRIDGE,
DEFAULT_COMMAND_TOPIC,
DEFAULT_NAME,
DEFAULT_STATE_TOPIC,
DEFAULT_STATUS_TOPIC,
DEFAULT_TOPIC_ROOT,
DEFAULT_VENUS_GUIDE_COMPAT,
DEFAULT_VENUS_PORTAL_ID,
DEFAULT_VENUS_TOPIC_PREFIX,
DOMAIN,
PANEL_MODES,
PLATFORMS,
SERVICE_SET_ESS_CONTROL,
SERVICE_SET_REMOTE_PANEL_STATE,
)
from .coordinator import VictronMqttBridge
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_STATE_TOPIC, default=DEFAULT_STATE_TOPIC): cv.string,
vol.Optional(
CONF_COMMAND_TOPIC, default=DEFAULT_COMMAND_TOPIC
): cv.string,
vol.Optional(CONF_STATUS_TOPIC, default=DEFAULT_STATUS_TOPIC): cv.string,
vol.Optional(CONF_TOPIC_ROOT): cv.string,
vol.Optional(CONF_VENUS_PORTAL_ID, default=DEFAULT_VENUS_PORTAL_ID): cv.string,
vol.Optional(CONF_VENUS_TOPIC_PREFIX, default=DEFAULT_VENUS_TOPIC_PREFIX): cv.string,
vol.Optional(CONF_VENUS_GUIDE_COMPAT, default=DEFAULT_VENUS_GUIDE_COMPAT): cv.boolean,
}
)
},
extra=vol.ALLOW_EXTRA,
)
SERVICE_SET_REMOTE_PANEL_STATE_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_MODE): vol.In(PANEL_MODES),
vol.Optional(ATTR_CURRENT_LIMIT): vol.Coerce(float),
},
extra=vol.PREVENT_EXTRA,
)
SERVICE_SET_ESS_CONTROL_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_ESS_SETPOINT): vol.Coerce(float),
vol.Optional(ATTR_ESS_MAX_CHARGE_POWER): vol.Coerce(float),
vol.Optional(ATTR_ESS_MAX_DISCHARGE_POWER): vol.Coerce(float),
vol.Optional(ATTR_ESS_MODE): vol.Coerce(int),
},
extra=vol.PREVENT_EXTRA,
)
def mqtt_topic_root(topic: str) -> str:
"""Match invertergui MQTT root behavior."""
cleaned = topic.strip().strip("/")
if not cleaned:
return DEFAULT_TOPIC_ROOT
if cleaned.endswith("/updates"):
root = cleaned[: -len("/updates")]
if root:
return root
return cleaned
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Victron MK2 MQTT integration from YAML."""
conf = config.get(DOMAIN)
if conf is None:
return True
setup_conf: dict[str, Any] = dict(conf)
if not setup_conf.get(CONF_TOPIC_ROOT):
setup_conf[CONF_TOPIC_ROOT] = mqtt_topic_root(setup_conf[CONF_STATE_TOPIC])
bridge = VictronMqttBridge(hass, setup_conf)
await bridge.async_setup()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][DATA_BRIDGE] = bridge
await _register_services(hass, bridge)
for platform in PLATFORMS:
hass.async_create_task(
discovery.async_load_platform(hass, platform, DOMAIN, {}, config)
)
return True
async def _register_services(hass: HomeAssistant, bridge: VictronMqttBridge) -> None:
"""Register integration services."""
if hass.services.has_service(DOMAIN, SERVICE_SET_REMOTE_PANEL_STATE):
return
async def handle_set_remote_panel_state(call: ServiceCall) -> None:
mode = call.data.get(ATTR_MODE)
current_limit = call.data.get(ATTR_CURRENT_LIMIT)
if mode is None and current_limit is None:
raise HomeAssistantError("Provide at least one of mode or current_limit")
if current_limit is not None and current_limit < 0:
raise HomeAssistantError("current_limit must be >= 0")
payload: dict[str, Any] = {"kind": "panel_state"}
if mode is not None:
payload["switch"] = mode
if current_limit is not None:
payload["current_limit"] = float(current_limit)
await bridge.async_publish_command(payload)
hass.services.async_register(
DOMAIN,
SERVICE_SET_REMOTE_PANEL_STATE,
handle_set_remote_panel_state,
schema=SERVICE_SET_REMOTE_PANEL_STATE_SCHEMA,
)
async def handle_set_ess_control(call: ServiceCall) -> None:
setpoint = call.data.get(ATTR_ESS_SETPOINT)
max_charge = call.data.get(ATTR_ESS_MAX_CHARGE_POWER)
max_discharge = call.data.get(ATTR_ESS_MAX_DISCHARGE_POWER)
ess_mode = call.data.get(ATTR_ESS_MODE)
if all(value is None for value in (setpoint, max_charge, max_discharge, ess_mode)):
raise HomeAssistantError(
"Provide at least one of ess_setpoint, ess_max_charge_power, ess_max_discharge_power, or ess_mode"
)
if max_charge is not None and max_charge < 0:
raise HomeAssistantError("ess_max_charge_power must be >= 0")
if max_discharge is not None and max_discharge < 0:
raise HomeAssistantError("ess_max_discharge_power must be >= 0")
if ess_mode is not None and ess_mode not in (9, 10):
raise HomeAssistantError("ess_mode must be 9 or 10")
commands: list[dict[str, Any]] = []
if setpoint is not None:
commands.append({"kind": "ess_setpoint", "value": float(setpoint)})
if max_charge is not None:
commands.append({"kind": "ess_max_charge_power", "value": float(max_charge)})
if max_discharge is not None:
commands.append({"kind": "ess_max_discharge_power", "value": float(max_discharge)})
if ess_mode is not None:
commands.append({"kind": "ess_mode", "value": int(ess_mode)})
for payload in commands:
await bridge.async_publish_command(payload)
hass.services.async_register(
DOMAIN,
SERVICE_SET_ESS_CONTROL,
handle_set_ess_control,
schema=SERVICE_SET_ESS_CONTROL_SCHEMA,
)

View File

@@ -0,0 +1,48 @@
"""Binary sensors for Victron MK2 MQTT integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from .const import DATA_BRIDGE, DOMAIN
from .coordinator import VictronMqttBridge
from .entity import VictronMqttEntity
async def async_setup_platform(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities,
discovery_info: dict[str, Any] | None = None,
) -> None:
"""Set up Victron binary sensors."""
bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE]
async_add_entities([VictronDataValidBinarySensor(bridge)])
class VictronDataValidBinarySensor(VictronMqttEntity, BinarySensorEntity):
"""MQTT data validity sensor."""
_attr_name = "Data Valid"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_icon = "mdi:check-network-outline"
def __init__(self, bridge: VictronMqttBridge) -> None:
super().__init__(bridge)
self._attr_unique_id = f"{bridge.topic_root}_data_valid"
@property
def is_on(self) -> bool:
value = self.bridge.metric("Valid")
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
normalized = value.strip().lower()
return normalized in {"1", "true", "on", "yes"}
return False

View File

@@ -0,0 +1,48 @@
"""Constants for the Victron MK2 MQTT integration."""
from __future__ import annotations
DOMAIN = "victron_mk2_mqtt"
CONF_STATE_TOPIC = "state_topic"
CONF_COMMAND_TOPIC = "command_topic"
CONF_STATUS_TOPIC = "status_topic"
CONF_TOPIC_ROOT = "topic_root"
CONF_NAME = "name"
CONF_VENUS_PORTAL_ID = "venus_portal_id"
CONF_VENUS_TOPIC_PREFIX = "venus_topic_prefix"
CONF_VENUS_GUIDE_COMPAT = "venus_guide_compat"
DEFAULT_STATE_TOPIC = "invertergui/updates"
DEFAULT_COMMAND_TOPIC = "invertergui/settings/set"
DEFAULT_STATUS_TOPIC = "invertergui/settings/status"
DEFAULT_TOPIC_ROOT = "invertergui"
DEFAULT_NAME = "Victron Inverter"
DEFAULT_VENUS_PORTAL_ID = "invertergui"
DEFAULT_VENUS_TOPIC_PREFIX = ""
DEFAULT_VENUS_GUIDE_COMPAT = True
PLATFORMS = ("sensor", "binary_sensor", "select", "number", "switch")
DATA_BRIDGE = "bridge"
ATTR_MODE = "mode"
ATTR_CURRENT_LIMIT = "current_limit"
ATTR_ESS_SETPOINT = "ess_setpoint"
ATTR_ESS_MAX_CHARGE_POWER = "ess_max_charge_power"
ATTR_ESS_MAX_DISCHARGE_POWER = "ess_max_discharge_power"
ATTR_ESS_MODE = "ess_mode"
SERVICE_SET_REMOTE_PANEL_STATE = "set_remote_panel_state"
SERVICE_SET_ESS_CONTROL = "set_ess_control"
PANEL_MODE_CHARGER_ONLY = "charger_only"
PANEL_MODE_INVERTER_ONLY = "inverter_only"
PANEL_MODE_ON = "on"
PANEL_MODE_OFF = "off"
PANEL_MODES = (
PANEL_MODE_CHARGER_ONLY,
PANEL_MODE_INVERTER_ONLY,
PANEL_MODE_ON,
PANEL_MODE_OFF,
)

View File

@@ -0,0 +1,333 @@
"""MQTT bridge for Victron MK2 integration."""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from typing import Any
from homeassistant.components import mqtt
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from .const import (
CONF_COMMAND_TOPIC,
CONF_NAME,
CONF_STATE_TOPIC,
CONF_STATUS_TOPIC,
CONF_TOPIC_ROOT,
CONF_VENUS_GUIDE_COMPAT,
CONF_VENUS_PORTAL_ID,
CONF_VENUS_TOPIC_PREFIX,
DOMAIN,
PANEL_MODES,
)
_LOGGER = logging.getLogger(__name__)
class VictronMqttBridge:
"""Maintain MQTT state and command publishing for Victron entities."""
def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
self.hass = hass
self.name: str = config[CONF_NAME]
self.state_topic: str = config[CONF_STATE_TOPIC]
self.command_topic: str = config[CONF_COMMAND_TOPIC]
self.status_topic: str = config[CONF_STATUS_TOPIC]
self.topic_root: str = config[CONF_TOPIC_ROOT]
self.venus_portal_id: str = config[CONF_VENUS_PORTAL_ID]
self.venus_topic_prefix: str = config[CONF_VENUS_TOPIC_PREFIX]
self.venus_guide_compat: bool = bool(config[CONF_VENUS_GUIDE_COMPAT])
self.panel_mode_state_topic = f"{self.topic_root}/homeassistant/remote_panel_mode/state"
self.current_limit_state_topic = (
f"{self.topic_root}/homeassistant/remote_panel_current_limit/state"
)
self.standby_state_topic = f"{self.topic_root}/homeassistant/remote_panel_standby/state"
self.telemetry: dict[str, Any] = {}
self.panel_mode: str | None = None
self.current_limit: float | None = None
self.standby: bool | None = None
self.last_error: str | None = None
self.ess_setpoint: float | None = None
self.ess_max_charge_power: float | None = None
self.ess_max_discharge_power: float | None = None
self.ess_mode: int | None = None
self._listeners: set[Callable[[], None]] = set()
self._unsubscribers: list[Callable[[], None]] = []
venus_base = f"N/{self.venus_portal_id}/settings/0/Settings/CGwacs"
prefix = self.venus_topic_prefix.strip().strip("/")
if prefix:
venus_base = f"{prefix}/{venus_base}"
self.ess_setpoint_state_topic = f"{venus_base}/AcPowerSetPoint"
self.ess_max_charge_state_topic = f"{venus_base}/MaxChargePower"
self.ess_max_discharge_state_topic = f"{venus_base}/MaxDischargePower"
self.ess_mode_state_topic = f"{venus_base}/BatteryLife/State"
@property
def device_info(self) -> DeviceInfo:
"""Return shared Home Assistant device metadata."""
return DeviceInfo(
identifiers={(DOMAIN, self.topic_root)},
name=self.name,
manufacturer="Victron Energy",
model="VE.Bus via invertergui MQTT",
)
async def async_setup(self) -> None:
"""Subscribe to required MQTT topics."""
_LOGGER.info(
"Subscribing Victron MQTT bridge topics state=%s command=%s status=%s",
self.state_topic,
self.command_topic,
self.status_topic,
)
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass, self.state_topic, self._handle_state_message, qos=1
)
)
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass,
self.panel_mode_state_topic,
self._handle_panel_mode_message,
qos=1,
)
)
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass,
self.current_limit_state_topic,
self._handle_current_limit_message,
qos=1,
)
)
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass, self.standby_state_topic, self._handle_standby_message, qos=1
)
)
if self.venus_guide_compat:
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass,
self.ess_setpoint_state_topic,
self._handle_ess_setpoint_message,
qos=1,
)
)
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass,
self.ess_max_charge_state_topic,
self._handle_ess_max_charge_message,
qos=1,
)
)
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass,
self.ess_max_discharge_state_topic,
self._handle_ess_max_discharge_message,
qos=1,
)
)
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass,
self.ess_mode_state_topic,
self._handle_ess_mode_message,
qos=1,
)
)
if self.status_topic:
self._unsubscribers.append(
await mqtt.async_subscribe(
self.hass, self.status_topic, self._handle_status_message, qos=1
)
)
async def async_shutdown(self) -> None:
"""Unsubscribe all MQTT subscriptions."""
while self._unsubscribers:
unsub = self._unsubscribers.pop()
unsub()
@callback
def async_add_listener(self, listener: Callable[[], None]) -> Callable[[], None]:
"""Register a state listener."""
self._listeners.add(listener)
def remove() -> None:
self._listeners.discard(listener)
return remove
@callback
def _notify_listeners(self) -> None:
"""Notify all entities that state changed."""
for listener in tuple(self._listeners):
listener()
@staticmethod
def _payload_text(payload: Any) -> str:
if isinstance(payload, bytes):
return payload.decode("utf-8", errors="ignore")
if isinstance(payload, str):
return payload
return str(payload)
@callback
def _handle_state_message(self, msg: Any) -> None:
raw_payload = self._payload_text(msg.payload)
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError as err:
_LOGGER.warning("Ignoring invalid state JSON from %s: %s", msg.topic, err)
return
if not isinstance(payload, dict):
_LOGGER.warning("Ignoring state payload from %s: expected object", msg.topic)
return
self.telemetry = payload
self._notify_listeners()
@callback
def _handle_panel_mode_message(self, msg: Any) -> None:
mode = self._payload_text(msg.payload).strip().lower()
if mode not in PANEL_MODES:
_LOGGER.debug("Ignoring unknown panel mode payload %r", msg.payload)
return
self.panel_mode = mode
self._notify_listeners()
@callback
def _handle_current_limit_message(self, msg: Any) -> None:
payload = self._payload_text(msg.payload).strip()
if not payload:
self.current_limit = None
self._notify_listeners()
return
try:
self.current_limit = float(payload)
except ValueError:
_LOGGER.debug("Ignoring invalid current limit payload %r", msg.payload)
return
self._notify_listeners()
@callback
def _handle_standby_message(self, msg: Any) -> None:
value = self._payload_text(msg.payload).strip().lower()
if value in {"on", "1", "true"}:
self.standby = True
elif value in {"off", "0", "false"}:
self.standby = False
else:
_LOGGER.debug("Ignoring invalid standby payload %r", msg.payload)
return
self._notify_listeners()
@callback
def _handle_status_message(self, msg: Any) -> None:
raw_payload = self._payload_text(msg.payload)
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError:
return
if not isinstance(payload, dict):
return
if payload.get("status") == "error":
err = payload.get("error")
self.last_error = str(err) if err is not None else "unknown error"
else:
self.last_error = None
self._notify_listeners()
@callback
def _handle_ess_setpoint_message(self, msg: Any) -> None:
value = self._decode_venus_numeric(msg.payload)
if value is None:
return
self.ess_setpoint = value
self._notify_listeners()
@callback
def _handle_ess_max_charge_message(self, msg: Any) -> None:
value = self._decode_venus_numeric(msg.payload)
if value is None:
return
self.ess_max_charge_power = value
self._notify_listeners()
@callback
def _handle_ess_max_discharge_message(self, msg: Any) -> None:
value = self._decode_venus_numeric(msg.payload)
if value is None:
return
self.ess_max_discharge_power = value
self._notify_listeners()
@callback
def _handle_ess_mode_message(self, msg: Any) -> None:
value = self._decode_venus_numeric(msg.payload)
if value is None:
return
self.ess_mode = int(value)
self._notify_listeners()
def _decode_venus_numeric(self, payload: Any) -> float | None:
raw_payload = self._payload_text(payload)
try:
data = json.loads(raw_payload)
except json.JSONDecodeError:
_LOGGER.debug("Ignoring invalid Venus payload %r", raw_payload)
return None
value = data.get("value") if isinstance(data, dict) else None
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
_LOGGER.debug("Ignoring non-numeric Venus payload value %r", value)
return None
async def async_publish_command(self, payload: dict[str, Any]) -> None:
"""Publish a control command payload to invertergui command topic."""
if not self.command_topic:
raise HomeAssistantError("MQTT command topic is not configured")
mqtt.async_publish(
self.hass,
self.command_topic,
json.dumps(payload, separators=(",", ":")),
qos=1,
retain=False,
)
def metric(self, key: str) -> Any:
"""Read a telemetry key."""
return self.telemetry.get(key)
def metric_float(self, key: str) -> float | None:
"""Read and coerce telemetry value to float."""
value = self.metric(key)
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None

View File

@@ -0,0 +1,29 @@
"""Shared entity base for Victron MK2 MQTT."""
from __future__ import annotations
from homeassistant.helpers.entity import Entity
from .coordinator import VictronMqttBridge
class VictronMqttEntity(Entity):
"""Base entity bound to shared MQTT bridge."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, bridge: VictronMqttBridge) -> None:
self.bridge = bridge
@property
def device_info(self):
"""Return the shared device info."""
return self.bridge.device_info
async def async_added_to_hass(self) -> None:
"""Register for coordinator updates."""
self.async_on_remove(self.bridge.async_add_listener(self._handle_bridge_update))
def _handle_bridge_update(self) -> None:
self.async_write_ha_state()

View File

@@ -0,0 +1,14 @@
{
"domain": "victron_mk2_mqtt",
"name": "Victron MK2 MQTT",
"version": "0.1.0",
"documentation": "https://git.coadcorp.com/nathan/invertergui",
"issue_tracker": "https://git.coadcorp.com/nathan/invertergui/issues",
"dependencies": [
"mqtt"
],
"codeowners": [
"@nathan"
],
"iot_class": "local_push"
}

View File

@@ -0,0 +1,135 @@
"""Number entities for Victron MK2 MQTT integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.const import UnitOfElectricCurrent
from homeassistant.core import HomeAssistant
from .const import DATA_BRIDGE, DOMAIN
from .coordinator import VictronMqttBridge
from .entity import VictronMqttEntity
async def async_setup_platform(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities,
discovery_info: dict[str, Any] | None = None,
) -> None:
"""Set up Victron number entities."""
bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE]
entities: list[NumberEntity] = [VictronRemotePanelCurrentLimitNumber(bridge)]
if bridge.venus_guide_compat:
entities.extend(
[
VictronESSGridSetpointNumber(bridge),
VictronESSMaxChargePowerNumber(bridge),
VictronESSMaxDischargePowerNumber(bridge),
]
)
async_add_entities(entities)
class VictronRemotePanelCurrentLimitNumber(VictronMqttEntity, NumberEntity):
"""Remote panel AC input current limit."""
_attr_name = "Remote Panel Current Limit"
_attr_icon = "mdi:current-ac"
_attr_native_min_value = 0.0
_attr_native_max_value = 100.0
_attr_native_step = 0.1
_attr_mode = NumberMode.BOX
_attr_device_class = NumberDeviceClass.CURRENT
_attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE
def __init__(self, bridge: VictronMqttBridge) -> None:
super().__init__(bridge)
self._attr_unique_id = f"{bridge.topic_root}_remote_panel_current_limit"
@property
def native_value(self) -> float | None:
return self.bridge.current_limit
@property
def available(self) -> bool:
return bool(self.bridge.command_topic)
async def async_set_native_value(self, value: float) -> None:
await self.bridge.async_publish_command(
{"kind": "panel_state", "current_limit": float(value)}
)
class _VictronESSNumberBase(VictronMqttEntity, NumberEntity):
"""Base class for ESS compatibility numbers."""
_attr_mode = NumberMode.BOX
_attr_native_step = 1.0
_attr_native_min_value = -20000.0
_attr_native_max_value = 20000.0
_attr_native_unit_of_measurement = "W"
_attr_icon = "mdi:transmission-tower-export"
@property
def available(self) -> bool:
return bool(self.bridge.command_topic and self.bridge.venus_guide_compat)
class VictronESSGridSetpointNumber(_VictronESSNumberBase):
"""Guide-compatible ESS AC power setpoint."""
_attr_name = "ESS Grid Setpoint"
def __init__(self, bridge: VictronMqttBridge) -> None:
super().__init__(bridge)
self._attr_unique_id = f"{bridge.topic_root}_ess_grid_setpoint"
@property
def native_value(self) -> float | None:
return self.bridge.ess_setpoint
async def async_set_native_value(self, value: float) -> None:
await self.bridge.async_publish_command({"kind": "ess_setpoint", "value": float(value)})
class VictronESSMaxChargePowerNumber(_VictronESSNumberBase):
"""Guide-compatible ESS max charge power."""
_attr_name = "ESS Max Charge Power"
_attr_native_min_value = 0.0
def __init__(self, bridge: VictronMqttBridge) -> None:
super().__init__(bridge)
self._attr_unique_id = f"{bridge.topic_root}_ess_max_charge_power"
@property
def native_value(self) -> float | None:
return self.bridge.ess_max_charge_power
async def async_set_native_value(self, value: float) -> None:
await self.bridge.async_publish_command(
{"kind": "ess_max_charge_power", "value": float(value)}
)
class VictronESSMaxDischargePowerNumber(_VictronESSNumberBase):
"""Guide-compatible ESS max discharge power."""
_attr_name = "ESS Max Discharge Power"
_attr_native_min_value = 0.0
def __init__(self, bridge: VictronMqttBridge) -> None:
super().__init__(bridge)
self._attr_unique_id = f"{bridge.topic_root}_ess_max_discharge_power"
@property
def native_value(self) -> float | None:
return self.bridge.ess_max_discharge_power
async def async_set_native_value(self, value: float) -> None:
await self.bridge.async_publish_command(
{"kind": "ess_max_discharge_power", "value": float(value)}
)

View File

@@ -0,0 +1,46 @@
"""Select entities for Victron MK2 MQTT integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from .const import DATA_BRIDGE, DOMAIN, PANEL_MODES
from .coordinator import VictronMqttBridge
from .entity import VictronMqttEntity
async def async_setup_platform(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities,
discovery_info: dict[str, Any] | None = None,
) -> None:
"""Set up Victron select entities."""
bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE]
async_add_entities([VictronRemotePanelModeSelect(bridge)])
class VictronRemotePanelModeSelect(VictronMqttEntity, SelectEntity):
"""Remote panel mode select."""
_attr_name = "Remote Panel Mode"
_attr_options = list(PANEL_MODES)
_attr_icon = "mdi:transmission-tower-export"
def __init__(self, bridge: VictronMqttBridge) -> None:
super().__init__(bridge)
self._attr_unique_id = f"{bridge.topic_root}_remote_panel_mode"
@property
def current_option(self) -> str | None:
return self.bridge.panel_mode
@property
def available(self) -> bool:
return bool(self.bridge.command_topic)
async def async_select_option(self, option: str) -> None:
await self.bridge.async_publish_command({"kind": "panel_state", "switch": option})

View File

@@ -0,0 +1,148 @@
"""Sensor entities for Victron MK2 MQTT integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.const import (
EntityCategory,
PERCENTAGE,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfFrequency,
)
from homeassistant.core import HomeAssistant
from .const import DATA_BRIDGE, DOMAIN
from .coordinator import VictronMqttBridge
from .entity import VictronMqttEntity
@dataclass(frozen=True)
class MetricDescription:
"""Description for a telemetry-backed sensor."""
key: str
name: str
value_fn: Callable[[VictronMqttBridge], Any]
unit: str | None = None
state_class: SensorStateClass | None = SensorStateClass.MEASUREMENT
entity_category: EntityCategory | None = None
METRICS: tuple[MetricDescription, ...] = (
MetricDescription(
key="battery_voltage",
name="Battery Voltage",
value_fn=lambda bridge: bridge.metric_float("BatVoltage"),
unit=UnitOfElectricPotential.VOLT,
),
MetricDescription(
key="battery_current",
name="Battery Current",
value_fn=lambda bridge: bridge.metric_float("BatCurrent"),
unit=UnitOfElectricCurrent.AMPERE,
),
MetricDescription(
key="battery_charge",
name="Battery Charge",
value_fn=lambda bridge: (
bridge.metric_float("ChargeState") * 100.0
if bridge.metric_float("ChargeState") is not None
else None
),
unit=PERCENTAGE,
),
MetricDescription(
key="input_voltage",
name="Input Voltage",
value_fn=lambda bridge: bridge.metric_float("InVoltage"),
unit=UnitOfElectricPotential.VOLT,
),
MetricDescription(
key="input_current",
name="Input Current",
value_fn=lambda bridge: bridge.metric_float("InCurrent"),
unit=UnitOfElectricCurrent.AMPERE,
),
MetricDescription(
key="input_frequency",
name="Input Frequency",
value_fn=lambda bridge: bridge.metric_float("InFrequency"),
unit=UnitOfFrequency.HERTZ,
),
MetricDescription(
key="output_voltage",
name="Output Voltage",
value_fn=lambda bridge: bridge.metric_float("OutVoltage"),
unit=UnitOfElectricPotential.VOLT,
),
MetricDescription(
key="output_current",
name="Output Current",
value_fn=lambda bridge: bridge.metric_float("OutCurrent"),
unit=UnitOfElectricCurrent.AMPERE,
),
MetricDescription(
key="output_frequency",
name="Output Frequency",
value_fn=lambda bridge: bridge.metric_float("OutFrequency"),
unit=UnitOfFrequency.HERTZ,
),
MetricDescription(
key="input_power",
name="Input Power",
value_fn=lambda bridge: _product(bridge.metric_float("InVoltage"), bridge.metric_float("InCurrent")),
unit="VA",
),
MetricDescription(
key="output_power",
name="Output Power",
value_fn=lambda bridge: _product(bridge.metric_float("OutVoltage"), bridge.metric_float("OutCurrent")),
unit="VA",
),
MetricDescription(
key="last_command_error",
name="Last Command Error",
value_fn=lambda bridge: bridge.last_error,
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
def _product(a: float | None, b: float | None) -> float | None:
if a is None or b is None:
return None
return a * b
async def async_setup_platform(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities,
discovery_info: dict[str, Any] | None = None,
) -> None:
"""Set up Victron telemetry sensors."""
bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE]
async_add_entities(VictronMetricSensor(bridge, metric) for metric in METRICS)
class VictronMetricSensor(VictronMqttEntity, SensorEntity):
"""Generic telemetry sensor."""
def __init__(self, bridge: VictronMqttBridge, description: MetricDescription) -> None:
super().__init__(bridge)
self.entity_description = description
self._attr_unique_id = f"{bridge.topic_root}_{description.key}"
self._attr_name = description.name
self._attr_native_unit_of_measurement = description.unit
self._attr_state_class = description.state_class
self._attr_entity_category = description.entity_category
@property
def native_value(self):
return self.entity_description.value_fn(self.bridge)

View File

@@ -0,0 +1,75 @@
set_remote_panel_state:
name: Set Remote Panel State
description: Set the remote panel mode and/or AC input current limit over MQTT.
fields:
mode:
name: Mode
description: Remote panel mode.
required: false
selector:
select:
mode: dropdown
options:
- charger_only
- inverter_only
- on
- off
current_limit:
name: Current Limit
description: AC input current limit in amps.
required: false
selector:
number:
min: 0
max: 100
step: 0.1
unit_of_measurement: A
mode: box
set_ess_control:
name: Set ESS Control
description: Set ESS-style control values compatible with guide CGwacs paths.
fields:
ess_setpoint:
name: ESS Setpoint
description: AC power setpoint in watts. Positive charges/imports, negative discharges/exports.
required: false
selector:
number:
min: -20000
max: 20000
step: 1
unit_of_measurement: W
mode: box
ess_max_charge_power:
name: ESS Max Charge Power
description: Maximum allowed charge/import power in watts.
required: false
selector:
number:
min: 0
max: 20000
step: 1
unit_of_measurement: W
mode: box
ess_max_discharge_power:
name: ESS Max Discharge Power
description: Maximum allowed discharge/export power in watts.
required: false
selector:
number:
min: 0
max: 20000
step: 1
unit_of_measurement: W
mode: box
ess_mode:
name: ESS Mode
description: ESS battery life mode value (10 optimized, 9 keep charged).
required: false
selector:
number:
min: 9
max: 10
step: 1
mode: box

View File

@@ -0,0 +1,76 @@
"""Switch entities for Victron MK2 MQTT integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from .const import DATA_BRIDGE, DOMAIN
from .coordinator import VictronMqttBridge
from .entity import VictronMqttEntity
async def async_setup_platform(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities,
discovery_info: dict[str, Any] | None = None,
) -> None:
"""Set up Victron switch entities."""
bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE]
entities: list[SwitchEntity] = [VictronRemotePanelStandbySwitch(bridge)]
if bridge.venus_guide_compat:
entities.append(VictronESSOptimizedModeSwitch(bridge))
async_add_entities(entities)
class VictronRemotePanelStandbySwitch(VictronMqttEntity, SwitchEntity):
"""Remote panel standby switch."""
_attr_name = "Remote Panel Standby"
_attr_icon = "mdi:power-sleep"
def __init__(self, bridge: VictronMqttBridge) -> None:
super().__init__(bridge)
self._attr_unique_id = f"{bridge.topic_root}_remote_panel_standby"
@property
def is_on(self) -> bool:
return bool(self.bridge.standby)
@property
def available(self) -> bool:
return bool(self.bridge.command_topic)
async def async_turn_on(self, **kwargs: Any) -> None:
await self.bridge.async_publish_command({"kind": "standby", "standby": True})
async def async_turn_off(self, **kwargs: Any) -> None:
await self.bridge.async_publish_command({"kind": "standby", "standby": False})
class VictronESSOptimizedModeSwitch(VictronMqttEntity, SwitchEntity):
"""Guide-compatible ESS optimized mode switch."""
_attr_name = "ESS Optimized Mode"
_attr_icon = "mdi:battery-sync"
def __init__(self, bridge: VictronMqttBridge) -> None:
super().__init__(bridge)
self._attr_unique_id = f"{bridge.topic_root}_ess_optimized_mode"
@property
def is_on(self) -> bool:
return self.bridge.ess_mode == 10
@property
def available(self) -> bool:
return bool(self.bridge.command_topic and self.bridge.venus_guide_compat)
async def async_turn_on(self, **kwargs: Any) -> None:
await self.bridge.async_publish_command({"kind": "ess_mode", "value": 10})
async def async_turn_off(self, **kwargs: Any) -> None:
await self.bridge.async_publish_command({"kind": "ess_mode", "value": 9})

34
go.mod
View File

@@ -1,10 +1,32 @@
module github.com/diebietse/invertergui module git.coadcorp.com/nathan/invertergui
go 1.26
require ( require (
github.com/gorilla/websocket v1.4.0 github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/prometheus/client_golang v0.9.2 github.com/gorilla/websocket v1.5.3
github.com/rakyll/statik v0.1.5 github.com/jessevdk/go-flags v1.6.1
github.com/stretchr/testify v1.3.0 github.com/prometheus/client_golang v1.23.2
github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
golang.org/x/sys v0.0.0-20190312061237-fead79001313 // indirect )
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

84
go.sum
View File

@@ -1,32 +1,60 @@
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rakyll/statik v0.1.5 h1:Ly2UjURzxnsSYS0zI50fZ+srA+Fu7EbpV5hglvJvJG0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rakyll/statik v0.1.5/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
golang.org/x/sys v0.0.0-20190312061237-fead79001313 h1:pczuHS43Cp2ktBEEmLwScxgjWsBSzdaQiKzUyf3DTTc= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,132 @@
title: Victron Inverter MQTT
views:
- title: Inverter
path: victron-inverter
icon: mdi:flash
badges:
- entity: binary_sensor.victron_online
- entity: binary_sensor.victron_data_valid
- entity: sensor.victron_last_command_status
cards:
- type: vertical-stack
cards:
- type: markdown
content: |
## Remote Panel Control
Mode and current limit are published together over MQTT, matching `set_remote_panel_state`.
- type: entities
title: Current Remote State
state_color: true
show_header_toggle: false
entities:
- entity: select.victron_remote_panel_mode
name: Mode
- entity: number.victron_remote_panel_current_limit
name: AC Input Current Limit
- entity: switch.victron_remote_panel_standby
name: Prevent Sleep While Off
- entity: sensor.victron_last_command_error
name: Last Command Error
- type: entities
title: Apply Mode + Current Limit
show_header_toggle: false
entities:
- entity: input_select.victron_remote_panel_mode_target
name: Target Mode
- entity: input_number.victron_remote_panel_current_limit_target
name: Target Current Limit
- entity: script.victron_mqtt_set_remote_panel_state
name: Apply Mode + Current Limit
- type: entities
title: Apply Standby
show_header_toggle: false
entities:
- entity: input_boolean.victron_remote_panel_standby_target
name: Target Standby
- entity: script.victron_mqtt_set_remote_panel_standby
name: Apply Standby
- type: entities
title: ESS Guide-Style Control (MQTT)
show_header_toggle: false
entities:
- entity: number.victron_ess_grid_setpoint
name: Grid Setpoint (W)
- entity: number.victron_ess_max_charge_power
name: Max Charge Power (W)
- entity: number.victron_ess_max_discharge_power
name: Max Discharge Power (W)
- entity: switch.victron_ess_optimized_mode
name: Optimized Mode (10 on / 9 off)
- type: grid
columns: 3
square: false
cards:
- type: entities
title: Output
show_header_toggle: false
entities:
- entity: sensor.victron_output_current
name: Output Current
- entity: sensor.victron_output_voltage
name: Output Voltage
- entity: sensor.victron_output_frequency
name: Output Frequency
- entity: sensor.victron_output_power
name: Output Power
- type: entities
title: Input
show_header_toggle: false
entities:
- entity: sensor.victron_input_current
name: Input Current
- entity: sensor.victron_input_voltage
name: Input Voltage
- entity: sensor.victron_input_frequency
name: Input Frequency
- entity: sensor.victron_input_power
name: Input Power
- entity: sensor.victron_input_minus_output_power
name: Input - Output Power
- type: entities
title: Battery
show_header_toggle: false
entities:
- entity: sensor.victron_battery_current
name: Battery Current
- entity: sensor.victron_battery_voltage
name: Battery Voltage
- entity: sensor.victron_battery_power
name: Battery Power
- entity: sensor.victron_battery_charge
name: Battery Charge
- type: entities
title: LED Status
show_header_toggle: false
entities:
- entity: sensor.victron_led_mains
name: Mains
- entity: sensor.victron_led_absorb
name: Absorb
- entity: sensor.victron_led_bulk
name: Bulk
- entity: sensor.victron_led_float
name: Float
- entity: sensor.victron_led_inverter
name: Inverter
- entity: sensor.victron_led_overload
name: Overload
- entity: sensor.victron_led_low_battery
name: Low Battery
- entity: sensor.victron_led_over_temp
name: Over Temperature
- type: history-graph
title: Power (Last 2 Hours)
hours_to_show: 2
refresh_interval: 60
entities:
- entity: sensor.victron_input_power
- entity: sensor.victron_output_power
- entity: sensor.victron_battery_power

View File

@@ -0,0 +1,401 @@
# MQTT-only Home Assistant package for invertergui.
# This avoids duplicate entities from MQTT auto-discovery/custom integration.
#
# Requirements:
# - invertergui started with MQTT publishing enabled.
# - invertergui MQTT discovery disabled (`--mqtt.ha.enabled=false`) when using this package.
# - for guide-style ESS controls below, enable:
# `MQTT_VENUS_ENABLED=true`
# `MQTT_VENUS_GUIDE_COMPAT=true`
# `MQTT_VENUS_TOPIC_PREFIX=victron`
# `MQTT_VENUS_PORTAL_ID=invertergui`
mqtt:
sensor:
- name: Victron Battery Voltage
unique_id: invertergui_mqtt_battery_voltage
state_topic: invertergui/updates
value_template: "{{ value_json.BatVoltage }}"
unit_of_measurement: V
device_class: voltage
state_class: measurement
- name: Victron Battery Current
unique_id: invertergui_mqtt_battery_current
state_topic: invertergui/updates
value_template: "{{ value_json.BatCurrent }}"
unit_of_measurement: A
device_class: current
state_class: measurement
- name: Victron Battery Charge
unique_id: invertergui_mqtt_battery_charge
state_topic: invertergui/updates
value_template: "{{ ((value_json.ChargeState | float(0)) * 100) | round(1) }}"
unit_of_measurement: "%"
device_class: battery
state_class: measurement
- name: Victron Input Voltage
unique_id: invertergui_mqtt_input_voltage
state_topic: invertergui/updates
value_template: "{{ value_json.InVoltage }}"
unit_of_measurement: V
device_class: voltage
state_class: measurement
- name: Victron Input Current
unique_id: invertergui_mqtt_input_current
state_topic: invertergui/updates
value_template: "{{ value_json.InCurrent }}"
unit_of_measurement: A
device_class: current
state_class: measurement
- name: Victron Input Frequency
unique_id: invertergui_mqtt_input_frequency
state_topic: invertergui/updates
value_template: "{{ value_json.InFrequency }}"
unit_of_measurement: Hz
device_class: frequency
state_class: measurement
- name: Victron Output Voltage
unique_id: invertergui_mqtt_output_voltage
state_topic: invertergui/updates
value_template: "{{ value_json.OutVoltage }}"
unit_of_measurement: V
device_class: voltage
state_class: measurement
- name: Victron Output Current
unique_id: invertergui_mqtt_output_current
state_topic: invertergui/updates
value_template: "{{ value_json.OutCurrent }}"
unit_of_measurement: A
device_class: current
state_class: measurement
- name: Victron Output Frequency
unique_id: invertergui_mqtt_output_frequency
state_topic: invertergui/updates
value_template: "{{ value_json.OutFrequency }}"
unit_of_measurement: Hz
device_class: frequency
state_class: measurement
- name: Victron Input Power
unique_id: invertergui_mqtt_input_power
state_topic: invertergui/updates
value_template: "{{ ((value_json.InVoltage | float(0)) * (value_json.InCurrent | float(0))) | round(1) }}"
unit_of_measurement: VA
state_class: measurement
- name: Victron Output Power
unique_id: invertergui_mqtt_output_power
state_topic: invertergui/updates
value_template: "{{ ((value_json.OutVoltage | float(0)) * (value_json.OutCurrent | float(0))) | round(1) }}"
unit_of_measurement: VA
state_class: measurement
- name: Victron Last Command Status
unique_id: invertergui_mqtt_last_command_status
state_topic: invertergui/settings/status
value_template: "{{ value_json.status | default('unknown') }}"
icon: mdi:message-alert-outline
- name: Victron Last Command Error
unique_id: invertergui_mqtt_last_command_error
state_topic: invertergui/settings/status
value_template: >-
{% if value_json.status == 'error' %}
{{ value_json.error | default('unknown error') }}
{% else %}
none
{% endif %}
icon: mdi:alert-circle-outline
- name: Victron LED Mains
unique_id: invertergui_mqtt_led_mains
state_topic: invertergui/updates
value_template: >-
{% set leds = value_json.LEDs | default({}) %}
{% set v = leds['0'] | default(0) | int(0) %}
{% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %}
icon: mdi:transmission-tower
- name: Victron LED Absorb
unique_id: invertergui_mqtt_led_absorb
state_topic: invertergui/updates
value_template: >-
{% set leds = value_json.LEDs | default({}) %}
{% set v = leds['1'] | default(0) | int(0) %}
{% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %}
icon: mdi:water-outline
- name: Victron LED Bulk
unique_id: invertergui_mqtt_led_bulk
state_topic: invertergui/updates
value_template: >-
{% set leds = value_json.LEDs | default({}) %}
{% set v = leds['2'] | default(0) | int(0) %}
{% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %}
icon: mdi:battery-plus
- name: Victron LED Float
unique_id: invertergui_mqtt_led_float
state_topic: invertergui/updates
value_template: >-
{% set leds = value_json.LEDs | default({}) %}
{% set v = leds['3'] | default(0) | int(0) %}
{% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %}
icon: mdi:battery-heart-variant
- name: Victron LED Inverter
unique_id: invertergui_mqtt_led_inverter
state_topic: invertergui/updates
value_template: >-
{% set leds = value_json.LEDs | default({}) %}
{% set v = leds['4'] | default(0) | int(0) %}
{% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %}
icon: mdi:power-plug
- name: Victron LED Overload
unique_id: invertergui_mqtt_led_overload
state_topic: invertergui/updates
value_template: >-
{% set leds = value_json.LEDs | default({}) %}
{% set v = leds['5'] | default(0) | int(0) %}
{% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %}
icon: mdi:alert-octagon
- name: Victron LED Low Battery
unique_id: invertergui_mqtt_led_low_battery
state_topic: invertergui/updates
value_template: >-
{% set leds = value_json.LEDs | default({}) %}
{% set v = leds['6'] | default(0) | int(0) %}
{% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %}
icon: mdi:battery-alert-variant-outline
- name: Victron LED Over Temp
unique_id: invertergui_mqtt_led_over_temp
state_topic: invertergui/updates
value_template: >-
{% set leds = value_json.LEDs | default({}) %}
{% set v = leds['7'] | default(0) | int(0) %}
{% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %}
icon: mdi:thermometer-alert
binary_sensor:
- name: Victron Online
unique_id: invertergui_mqtt_online
state_topic: invertergui/updates
value_template: "ON"
payload_on: "ON"
payload_off: "OFF"
expire_after: 120
device_class: connectivity
- name: Victron Data Valid
unique_id: invertergui_mqtt_data_valid
state_topic: invertergui/updates
value_template: "{{ value_json.Valid }}"
payload_on: "true"
payload_off: "false"
entity_category: diagnostic
select:
- name: Victron Remote Panel Mode
unique_id: invertergui_mqtt_remote_panel_mode
state_topic: invertergui/homeassistant/remote_panel_mode/state
command_topic: invertergui/settings/set
command_template: '{"kind":"panel_state","switch":"{{ value }}"}'
options:
- charger_only
- inverter_only
- on
- off
icon: mdi:transmission-tower-export
number:
- name: Victron Remote Panel Current Limit
unique_id: invertergui_mqtt_remote_panel_current_limit
state_topic: invertergui/homeassistant/remote_panel_current_limit/state
command_topic: invertergui/settings/set
command_template: '{"kind":"panel_state","current_limit":{{ value | float(0) }}}'
unit_of_measurement: A
device_class: current
mode: box
min: 0
max: 100
step: 0.1
icon: mdi:current-ac
- name: Victron ESS Grid Setpoint
unique_id: invertergui_mqtt_ess_grid_setpoint
state_topic: victron/N/invertergui/settings/0/Settings/CGwacs/AcPowerSetPoint
value_template: "{{ value_json.value | float(0) }}"
command_topic: victron/W/invertergui/settings/0/Settings/CGwacs/AcPowerSetPoint
command_template: '{"value":{{ value | float(0) | round(0) }}}'
unit_of_measurement: W
mode: box
min: -20000
max: 20000
step: 1
icon: mdi:transmission-tower-export
- name: Victron ESS Max Charge Power
unique_id: invertergui_mqtt_ess_max_charge_power
state_topic: victron/N/invertergui/settings/0/Settings/CGwacs/MaxChargePower
value_template: "{{ value_json.value | float(0) }}"
command_topic: victron/W/invertergui/settings/0/Settings/CGwacs/MaxChargePower
command_template: '{"value":{{ value | float(0) | round(0) }}}'
unit_of_measurement: W
mode: box
min: 0
max: 20000
step: 1
icon: mdi:battery-plus
- name: Victron ESS Max Discharge Power
unique_id: invertergui_mqtt_ess_max_discharge_power
state_topic: victron/N/invertergui/settings/0/Settings/CGwacs/MaxDischargePower
value_template: "{{ value_json.value | float(0) }}"
command_topic: victron/W/invertergui/settings/0/Settings/CGwacs/MaxDischargePower
command_template: '{"value":{{ value | float(0) | round(0) }}}'
unit_of_measurement: W
mode: box
min: 0
max: 20000
step: 1
icon: mdi:battery-minus
switch:
- name: Victron Remote Panel Standby
unique_id: invertergui_mqtt_remote_panel_standby
state_topic: invertergui/homeassistant/remote_panel_standby/state
command_topic: invertergui/settings/set
payload_on: '{"kind":"standby","standby":true}'
payload_off: '{"kind":"standby","standby":false}'
state_on: "ON"
state_off: "OFF"
icon: mdi:power-sleep
- name: Victron ESS Optimized Mode
unique_id: invertergui_mqtt_ess_optimized_mode
state_topic: victron/N/invertergui/settings/0/Settings/CGwacs/BatteryLife/State
value_template: "{{ value_json.value | int(9) }}"
command_topic: victron/W/invertergui/settings/0/Settings/CGwacs/BatteryLife/State
payload_on: '{"value":10}'
payload_off: '{"value":9}'
state_on: "10"
state_off: "9"
icon: mdi:battery-sync
input_select:
victron_remote_panel_mode_target:
name: Victron Target Mode
options:
- charger_only
- inverter_only
- on
- off
icon: mdi:transmission-tower-export
input_number:
victron_remote_panel_current_limit_target:
name: Victron Target Current Limit
min: 0
max: 100
step: 0.1
unit_of_measurement: A
mode: box
icon: mdi:current-ac
input_boolean:
victron_remote_panel_standby_target:
name: Victron Target Standby
icon: mdi:power-sleep
script:
victron_mqtt_set_remote_panel_state:
alias: Victron MQTT Set Remote Panel State
description: Set panel mode and current limit in one MQTT command.
mode: single
icon: mdi:send
sequence:
- service: mqtt.publish
data:
topic: invertergui/settings/set
qos: 1
payload: >-
{"kind":"panel_state","switch":"{{ states('input_select.victron_remote_panel_mode_target') }}","current_limit":{{ states('input_number.victron_remote_panel_current_limit_target') | float(0) | round(1) }}}
victron_mqtt_set_remote_panel_standby:
alias: Victron MQTT Set Remote Panel Standby
description: Set standby state from helper input.
mode: single
icon: mdi:send-circle
sequence:
- service: mqtt.publish
data:
topic: invertergui/settings/set
qos: 1
payload: >-
{"kind":"standby","standby":{% if is_state('input_boolean.victron_remote_panel_standby_target', 'on') %}true{% else %}false{% endif %}}
template:
- sensor:
- name: Victron Battery Power
unique_id: invertergui_mqtt_battery_power
unit_of_measurement: W
state_class: measurement
icon: mdi:battery-charging
state: >-
{{ ((states('sensor.victron_battery_voltage') | float(0)) * (states('sensor.victron_battery_current') | float(0))) | round(1) }}
- name: Victron Input Minus Output Power
unique_id: invertergui_mqtt_input_minus_output_power
unit_of_measurement: VA
state_class: measurement
icon: mdi:flash-triangle
state: >-
{{ (states('sensor.victron_input_power') | float(0) - states('sensor.victron_output_power') | float(0)) | round(1) }}
automation:
- id: victron_mqtt_sync_target_mode
alias: Victron MQTT Sync Mode Target
trigger:
- platform: state
entity_id: select.victron_remote_panel_mode
- platform: homeassistant
event: start
condition:
- condition: template
value_template: >-
{{ states('select.victron_remote_panel_mode') in ['charger_only', 'inverter_only', 'on', 'off'] }}
action:
- service: input_select.select_option
target:
entity_id: input_select.victron_remote_panel_mode_target
data:
option: "{{ states('select.victron_remote_panel_mode') }}"
- id: victron_mqtt_sync_target_current_limit
alias: Victron MQTT Sync Current Limit Target
trigger:
- platform: state
entity_id: number.victron_remote_panel_current_limit
- platform: homeassistant
event: start
condition:
- condition: template
value_template: >-
{{ states('number.victron_remote_panel_current_limit') not in ['unknown', 'unavailable'] }}
action:
- service: input_number.set_value
target:
entity_id: input_number.victron_remote_panel_current_limit_target
data:
value: "{{ states('number.victron_remote_panel_current_limit') | float(0) }}"
- id: victron_mqtt_sync_target_standby
alias: Victron MQTT Sync Standby Target
trigger:
- platform: state
entity_id: switch.victron_remote_panel_standby
- platform: homeassistant
event: start
action:
- choose:
- conditions:
- condition: state
entity_id: switch.victron_remote_panel_standby
state: "on"
sequence:
- service: input_boolean.turn_on
target:
entity_id: input_boolean.victron_remote_panel_standby_target
- conditions:
- condition: state
entity_id: switch.victron_remote_panel_standby
state: "off"
sequence:
- service: input_boolean.turn_off
target:
entity_id: input_boolean.victron_remote_panel_standby_target

View File

@@ -1,9 +1,12 @@
package mk2core package mk2core
import ( import (
"github.com/diebietse/invertergui/mk2driver" "git.coadcorp.com/nathan/invertergui/mk2driver"
"github.com/sirupsen/logrus"
) )
var log = logrus.WithField("ctx", "inverter-gui-core")
type Core struct { type Core struct {
mk2driver.Mk2 mk2driver.Mk2
plugins map[*subscription]bool plugins map[*subscription]bool
@@ -16,6 +19,7 @@ func NewCore(m mk2driver.Mk2) *Core {
register: make(chan *subscription, 255), register: make(chan *subscription, 255),
plugins: map[*subscription]bool{}, plugins: map[*subscription]bool{},
} }
log.Info("Core initialized")
go core.run() go core.run()
return core return core
} }
@@ -25,6 +29,7 @@ func (c *Core) NewSubscription() mk2driver.Mk2 {
send: make(chan *mk2driver.Mk2Info), send: make(chan *mk2driver.Mk2Info),
} }
c.register <- sub c.register <- sub
log.Debug("New plugin subscription registered")
return sub return sub
} }
@@ -33,11 +38,13 @@ func (c *Core) run() {
select { select {
case r := <-c.register: case r := <-c.register:
c.plugins[r] = true c.plugins[r] = true
log.WithField("subscribers", len(c.plugins)).Debug("Subscription added")
case e := <-c.C(): case e := <-c.C():
for plugin := range c.plugins { for plugin := range c.plugins {
select { select {
case plugin.send <- e: case plugin.send <- e:
default: default:
log.WithField("subscribers", len(c.plugins)).Warn("Dropping update for a slow subscriber")
} }
} }
} }

232
mk2driver/managed_writer.go Normal file
View File

@@ -0,0 +1,232 @@
package mk2driver
import (
"errors"
"fmt"
"sort"
"sync"
"time"
)
type WriterProfile string
const (
WriterProfileNormal WriterProfile = "normal"
WriterProfileMaintenance WriterProfile = "maintenance"
WriterProfileReadOnly WriterProfile = "read_only"
)
type WriterPolicy struct {
Profile WriterProfile
MaxCurrentLimitA *float64
ModeChangeMinInterval time.Duration
LockoutWindow time.Duration
AllowedPanelStates map[PanelSwitchState]struct{}
}
type CommandEvent struct {
Timestamp time.Time `json:"timestamp"`
Source CommandSource `json:"source"`
Kind string `json:"kind"`
Allowed bool `json:"allowed"`
Error string `json:"error,omitempty"`
}
type ManagedWriter struct {
writer SettingsWriter
policy WriterPolicy
mu sync.Mutex
lastModeChange time.Time
lockoutUntil time.Time
events []CommandEvent
}
var _ SettingsWriter = (*ManagedWriter)(nil)
var _ SourceAwareSettingsWriter = (*ManagedWriter)(nil)
func NewManagedWriter(writer SettingsWriter, policy WriterPolicy) *ManagedWriter {
if policy.Profile == "" {
policy.Profile = WriterProfileNormal
}
return &ManagedWriter{
writer: writer,
policy: policy,
events: make([]CommandEvent, 0, 100),
}
}
func (m *ManagedWriter) WriteRAMVar(id uint16, value int16) error {
return m.WriteRAMVarWithSource(CommandSourceUnknown, id, value)
}
func (m *ManagedWriter) WriteSetting(id uint16, value int16) error {
return m.WriteSettingWithSource(CommandSourceUnknown, id, value)
}
func (m *ManagedWriter) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error {
return m.SetPanelStateWithSource(CommandSourceUnknown, switchState, currentLimitA)
}
func (m *ManagedWriter) SetStandby(enabled bool) error {
return m.SetStandbyWithSource(CommandSourceUnknown, enabled)
}
func (m *ManagedWriter) WriteRAMVarWithSource(source CommandSource, id uint16, value int16) error {
return m.apply(source, "write_ram_var", func() error {
if err := m.ensureProfileAllows("write_ram_var"); err != nil {
return err
}
return m.baseWriter().WriteRAMVar(id, value)
})
}
func (m *ManagedWriter) WriteSettingWithSource(source CommandSource, id uint16, value int16) error {
return m.apply(source, "write_setting", func() error {
if err := m.ensureProfileAllows("write_setting"); err != nil {
return err
}
return m.baseWriter().WriteSetting(id, value)
})
}
func (m *ManagedWriter) SetPanelStateWithSource(source CommandSource, switchState PanelSwitchState, currentLimitA *float64) error {
return m.apply(source, "set_panel_state", func() error {
if err := m.ensureProfileAllows("set_panel_state"); err != nil {
return err
}
if len(m.policy.AllowedPanelStates) > 0 {
if _, ok := m.policy.AllowedPanelStates[switchState]; !ok {
return fmt.Errorf(
"panel switch mode %s denied by allowlist policy; allowed modes: %s",
panelStateLabel(switchState),
stringsForAllowedPanelStates(m.policy.AllowedPanelStates),
)
}
}
if m.policy.MaxCurrentLimitA != nil && currentLimitA != nil && *currentLimitA > *m.policy.MaxCurrentLimitA {
return fmt.Errorf("current limit %.2fA exceeds configured policy max %.2fA", *currentLimitA, *m.policy.MaxCurrentLimitA)
}
if m.policy.Profile == WriterProfileMaintenance && switchState != PanelSwitchOff {
return errors.New("maintenance profile only allows panel switch off")
}
if m.policy.ModeChangeMinInterval > 0 && !m.lastModeChange.IsZero() && time.Since(m.lastModeChange) < m.policy.ModeChangeMinInterval {
return fmt.Errorf("mode change denied due to rate limit; wait %s", m.policy.ModeChangeMinInterval-time.Since(m.lastModeChange))
}
if err := m.baseWriter().SetPanelState(switchState, currentLimitA); err != nil {
return err
}
m.lastModeChange = time.Now().UTC()
return nil
})
}
func (m *ManagedWriter) SetStandbyWithSource(source CommandSource, enabled bool) error {
return m.apply(source, "set_standby", func() error {
if err := m.ensureProfileAllows("set_standby"); err != nil {
return err
}
return m.baseWriter().SetStandby(enabled)
})
}
func (m *ManagedWriter) History(limit int) []CommandEvent {
m.mu.Lock()
defer m.mu.Unlock()
if limit <= 0 || limit > len(m.events) {
limit = len(m.events)
}
out := make([]CommandEvent, limit)
if limit > 0 {
copy(out, m.events[len(m.events)-limit:])
}
return out
}
func (m *ManagedWriter) apply(source CommandSource, kind string, fn func() error) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.writer == nil {
err := errors.New("settings writer is not available")
m.recordLocked(source, kind, false, err)
return err
}
if m.policy.LockoutWindow > 0 && time.Now().UTC().Before(m.lockoutUntil) {
err := fmt.Errorf("command denied during lockout window until %s", m.lockoutUntil.Format(time.RFC3339))
m.recordLocked(source, kind, false, err)
return err
}
if err := fn(); err != nil {
m.recordLocked(source, kind, false, err)
return err
}
if m.policy.LockoutWindow > 0 {
m.lockoutUntil = time.Now().UTC().Add(m.policy.LockoutWindow)
}
m.recordLocked(source, kind, true, nil)
return nil
}
func (m *ManagedWriter) ensureProfileAllows(kind string) error {
switch m.policy.Profile {
case WriterProfileReadOnly:
return errors.New("write denied by read-only profile")
case WriterProfileMaintenance:
if kind == "set_standby" || kind == "set_panel_state" {
return nil
}
return fmt.Errorf("maintenance profile blocks %s", kind)
default:
return nil
}
}
func (m *ManagedWriter) recordLocked(source CommandSource, kind string, allowed bool, err error) {
event := CommandEvent{
Timestamp: time.Now().UTC(),
Source: source,
Kind: kind,
Allowed: allowed,
}
if err != nil {
event.Error = err.Error()
}
m.events = append(m.events, event)
if len(m.events) > 100 {
m.events = m.events[len(m.events)-100:]
}
}
func (m *ManagedWriter) baseWriter() SettingsWriter {
return m.writer
}
func panelStateLabel(state PanelSwitchState) string {
switch state {
case PanelSwitchOn:
return "on"
case PanelSwitchOff:
return "off"
case PanelSwitchChargerOnly:
return "charger_only"
case PanelSwitchInverterOnly:
return "inverter_only"
default:
return fmt.Sprintf("unknown(0x%02x)", byte(state))
}
}
func stringsForAllowedPanelStates(states map[PanelSwitchState]struct{}) string {
if len(states) == 0 {
return "<none>"
}
labels := make([]string, 0, len(states))
for state := range states {
labels = append(labels, panelStateLabel(state))
}
sort.Strings(labels)
return fmt.Sprintf("%v", labels)
}

View File

@@ -0,0 +1,97 @@
package mk2driver
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
type writerStub struct {
settingWrites int
ramWrites int
panelWrites int
standbyWrites int
}
func (w *writerStub) WriteRAMVar(id uint16, value int16) error {
w.ramWrites++
return nil
}
func (w *writerStub) WriteSetting(id uint16, value int16) error {
w.settingWrites++
return nil
}
func (w *writerStub) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error {
w.panelWrites++
return nil
}
func (w *writerStub) SetStandby(enabled bool) error {
w.standbyWrites++
return nil
}
func TestManagedWriterReadOnlyProfile(t *testing.T) {
base := &writerStub{}
managed := NewManagedWriter(base, WriterPolicy{
Profile: WriterProfileReadOnly,
})
err := managed.WriteSettingWithSource(CommandSourceMQTT, 1, 1)
assert.Error(t, err)
assert.Equal(t, 0, base.settingWrites)
history := managed.History(10)
if assert.Len(t, history, 1) {
assert.False(t, history[0].Allowed)
assert.Equal(t, CommandSourceMQTT, history[0].Source)
}
}
func TestManagedWriterCurrentLimitGuard(t *testing.T) {
base := &writerStub{}
max := 16.0
managed := NewManagedWriter(base, WriterPolicy{
Profile: WriterProfileNormal,
MaxCurrentLimitA: &max,
})
limit := 20.0
err := managed.SetPanelStateWithSource(CommandSourceUI, PanelSwitchOn, &limit)
assert.Error(t, err)
assert.Equal(t, 0, base.panelWrites)
}
func TestManagedWriterModeRateLimit(t *testing.T) {
base := &writerStub{}
managed := NewManagedWriter(base, WriterPolicy{
Profile: WriterProfileNormal,
ModeChangeMinInterval: 10 * time.Second,
})
err := managed.SetPanelStateWithSource(CommandSourceAutomation, PanelSwitchOn, nil)
assert.NoError(t, err)
err = managed.SetPanelStateWithSource(CommandSourceAutomation, PanelSwitchOff, nil)
assert.Error(t, err)
assert.Equal(t, 1, base.panelWrites)
}
func TestManagedWriterPanelModeAllowlist(t *testing.T) {
base := &writerStub{}
managed := NewManagedWriter(base, WriterPolicy{
Profile: WriterProfileNormal,
AllowedPanelStates: map[PanelSwitchState]struct{}{
PanelSwitchOff: {},
},
})
err := managed.SetPanelStateWithSource(CommandSourceMQTT, PanelSwitchOn, nil)
assert.Error(t, err)
assert.Equal(t, 0, base.panelWrites)
err = managed.SetPanelStateWithSource(CommandSourceMQTT, PanelSwitchOff, nil)
assert.NoError(t, err)
assert.Equal(t, 1, base.panelWrites)
}

332
mk2driver/metadata.go Normal file
View File

@@ -0,0 +1,332 @@
package mk2driver
import (
"fmt"
"math"
"sort"
"time"
)
const (
defaultTransactionRetries = 2
defaultTransactionRetryDelay = 200 * time.Millisecond
defaultTransactionBackoff = 1.5
fastCommandTimeout = 1500 * time.Millisecond
standardCommandTimeout = 3 * time.Second
slowCommandTimeout = 6 * time.Second
)
type registerKey struct {
kind RegisterKind
id uint16
}
func int16Ptr(v int16) *int16 {
return &v
}
var knownRegisterMetadata = map[registerKey]RegisterMetadata{
{kind: RegisterKindRAMVar, id: ramVarVMains}: {
Kind: RegisterKindRAMVar,
ID: ramVarVMains,
Name: "mains_voltage",
Description: "AC input mains voltage (scaled)",
Unit: "V",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarIMains}: {
Kind: RegisterKindRAMVar,
ID: ramVarIMains,
Name: "mains_current",
Description: "AC input mains current (scaled)",
Unit: "A",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarVInverter}: {
Kind: RegisterKindRAMVar,
ID: ramVarVInverter,
Name: "inverter_voltage",
Description: "AC output inverter voltage (scaled)",
Unit: "V",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarIInverter}: {
Kind: RegisterKindRAMVar,
ID: ramVarIInverter,
Name: "inverter_current",
Description: "AC output inverter current (scaled)",
Unit: "A",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarVBat}: {
Kind: RegisterKindRAMVar,
ID: ramVarVBat,
Name: "battery_voltage",
Description: "Battery voltage (scaled)",
Unit: "V",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarIBat}: {
Kind: RegisterKindRAMVar,
ID: ramVarIBat,
Name: "battery_current",
Description: "Battery current (scaled)",
Unit: "A",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarVBatRipple}: {
Kind: RegisterKindRAMVar,
ID: ramVarVBatRipple,
Name: "battery_voltage_ripple",
Description: "Battery ripple voltage (scaled)",
Unit: "V",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarInverterPeriod}: {
Kind: RegisterKindRAMVar,
ID: ramVarInverterPeriod,
Name: "inverter_period",
Description: "Inverter period source value",
Unit: "ticks",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarMainPeriod}: {
Kind: RegisterKindRAMVar,
ID: ramVarMainPeriod,
Name: "mains_period",
Description: "Mains period source value",
Unit: "ticks",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarIACLoad}: {
Kind: RegisterKindRAMVar,
ID: ramVarIACLoad,
Name: "ac_load_current",
Description: "AC load current (scaled)",
Unit: "A",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarVirSwitchPos}: {
Kind: RegisterKindRAMVar,
ID: ramVarVirSwitchPos,
Name: "virtual_switch_position",
Description: "Virtual switch position",
Unit: "state",
Scale: 1,
Writable: true,
Signed: false,
MinValue: int16Ptr(0),
MaxValue: int16Ptr(255),
SafetyClass: RegisterSafetyGuarded,
},
{kind: RegisterKindRAMVar, id: ramVarIgnACInState}: {
Kind: RegisterKindRAMVar,
ID: ramVarIgnACInState,
Name: "ignored_ac_input_state",
Description: "AC input state as seen by firmware",
Unit: "state",
Scale: 1,
Writable: false,
Signed: false,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarMultiFuncRelay}: {
Kind: RegisterKindRAMVar,
ID: ramVarMultiFuncRelay,
Name: "multifunction_relay",
Description: "Multifunction relay state",
Unit: "state",
Scale: 1,
Writable: true,
Signed: false,
MinValue: int16Ptr(0),
MaxValue: int16Ptr(1),
SafetyClass: RegisterSafetyOperational,
},
{kind: RegisterKindRAMVar, id: ramVarChargeState}: {
Kind: RegisterKindRAMVar,
ID: ramVarChargeState,
Name: "battery_charge_state",
Description: "Battery charge state fraction",
Unit: "fraction",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarInverterPower1}: {
Kind: RegisterKindRAMVar,
ID: ramVarInverterPower1,
Name: "inverter_power_1",
Description: "Inverter power source register 1",
Unit: "W",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarInverterPower2}: {
Kind: RegisterKindRAMVar,
ID: ramVarInverterPower2,
Name: "inverter_power_2",
Description: "Inverter power source register 2",
Unit: "W",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarOutPower}: {
Kind: RegisterKindRAMVar,
ID: ramVarOutPower,
Name: "output_power",
Description: "Output power source register",
Unit: "W",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
}
func normalizeTransactionOptions(opts TransactionOptions) TransactionOptions {
if opts.Retries < 0 {
opts.Retries = 0
}
if opts.RetryDelay < 0 {
opts.RetryDelay = 0
}
if opts.BackoffFactor <= 0 {
opts.BackoffFactor = defaultTransactionBackoff
}
if opts.Retries == 0 && opts.RetryDelay == 0 && !opts.ReadBeforeWrite && !opts.VerifyAfterWrite {
opts.Retries = defaultTransactionRetries
opts.RetryDelay = defaultTransactionRetryDelay
opts.BackoffFactor = defaultTransactionBackoff
opts.ReadBeforeWrite = true
opts.VerifyAfterWrite = true
opts.TimeoutClass = TimeoutClassStandard
return opts
}
if opts.RetryDelay == 0 {
opts.RetryDelay = defaultTransactionRetryDelay
}
if opts.TimeoutClass == "" {
opts.TimeoutClass = TimeoutClassStandard
}
return opts
}
func lookupRegisterMetadata(kind RegisterKind, id uint16) (RegisterMetadata, bool) {
meta, ok := knownRegisterMetadata[registerKey{kind: kind, id: id}]
if ok {
meta = withMetadataDefaults(meta)
}
return meta, ok
}
func listRegisterMetadata() []RegisterMetadata {
out := make([]RegisterMetadata, 0, len(knownRegisterMetadata))
for _, meta := range knownRegisterMetadata {
out = append(out, withMetadataDefaults(meta))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Kind != out[j].Kind {
return out[i].Kind < out[j].Kind
}
return out[i].ID < out[j].ID
})
return out
}
func validateValueAgainstMetadata(meta RegisterMetadata, value int16) error {
if meta.MinValue != nil && value < *meta.MinValue {
return fmt.Errorf("value %d is below minimum %d for %s:%d", value, *meta.MinValue, meta.Kind, meta.ID)
}
if meta.MaxValue != nil && value > *meta.MaxValue {
return fmt.Errorf("value %d is above maximum %d for %s:%d", value, *meta.MaxValue, meta.Kind, meta.ID)
}
return nil
}
func withMetadataDefaults(meta RegisterMetadata) RegisterMetadata {
if meta.Scale == 0 {
meta.Scale = 1
}
if meta.SafetyClass == "" {
if meta.Writable {
meta.SafetyClass = RegisterSafetyGuarded
} else {
meta.SafetyClass = RegisterSafetyReadOnly
}
}
return meta
}
func resolveCommandTimeout(opts TransactionOptions) time.Duration {
if opts.CommandTimeout > 0 {
return opts.CommandTimeout
}
switch opts.TimeoutClass {
case TimeoutClassFast:
return fastCommandTimeout
case TimeoutClassSlow:
return slowCommandTimeout
default:
return standardCommandTimeout
}
}
func retryDelayForAttempt(opts TransactionOptions, attempt int) time.Duration {
if opts.RetryDelay <= 0 || attempt <= 1 {
return opts.RetryDelay
}
factor := math.Pow(opts.BackoffFactor, float64(attempt-1))
delay := float64(opts.RetryDelay) * factor
return time.Duration(delay)
}
func defaultWritableRegisterAddresses() []RegisterAddress {
metas := listRegisterMetadata()
out := make([]RegisterAddress, 0, len(metas))
for _, meta := range metas {
if !meta.Writable {
continue
}
out = append(out, RegisterAddress{
Kind: meta.Kind,
ID: meta.ID,
})
}
return out
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -76,3 +76,277 @@ type Mk2 interface {
C() chan *Mk2Info C() chan *Mk2Info
Close() Close()
} }
type PanelSwitchState byte
const (
// PanelSwitchChargerOnly enables charging only.
PanelSwitchChargerOnly PanelSwitchState = 0x01
// PanelSwitchInverterOnly enables inverter output and disables charging.
PanelSwitchInverterOnly PanelSwitchState = 0x02
// PanelSwitchOn enables both inverter and charger.
PanelSwitchOn PanelSwitchState = 0x03
// PanelSwitchOff disables inverter and charger.
PanelSwitchOff PanelSwitchState = 0x04
)
type SettingsWriter interface {
// WriteRAMVar writes a signed 16-bit value to a RAM variable id.
WriteRAMVar(id uint16, value int16) error
// WriteSetting writes a signed 16-bit value to a setting id.
WriteSetting(id uint16, value int16) error
// SetPanelState sends the MK2 "S" command using a virtual panel switch state.
// If currentLimitA is nil, the command does not update the AC current limit.
SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error
// SetStandby configures the remote panel standby line.
// When enabled, the inverter is prevented from sleeping while switched off.
SetStandby(enabled bool) error
}
type DeviceState byte
const (
// DeviceStateChargerOnly enables charging only.
DeviceStateChargerOnly DeviceState = 0x02
// DeviceStateInverterOnly enables inverter output and disables charging.
DeviceStateInverterOnly DeviceState = 0x03
// DeviceStateOn enables both inverter and charger.
DeviceStateOn DeviceState = 0x04
// DeviceStateOff disables inverter and charger.
DeviceStateOff DeviceState = 0x05
)
var DeviceStateNames = map[DeviceState]string{
DeviceStateChargerOnly: "charger_only",
DeviceStateInverterOnly: "inverter_only",
DeviceStateOn: "on",
DeviceStateOff: "off",
}
type RAMVarInfo struct {
ID uint16
Scale int16
Offset int16
Factor float64
Signed bool
Supported bool
}
// ProtocolControl exposes protocol 3.14 command paths for direct MK2 control.
type ProtocolControl interface {
SettingsWriter
// GetDeviceState returns the current VE.Bus state using command 0x0E.
GetDeviceState() (DeviceState, error)
// SetDeviceState sets the VE.Bus state using command 0x0E.
SetDeviceState(state DeviceState) error
// ReadRAMVarByID reads a RAM variable via command 0x30.
ReadRAMVarByID(id uint16) (int16, error)
// ReadSettingByID reads a setting via command 0x31.
ReadSettingByID(id uint16) (int16, error)
// SelectRAMVar selects a RAM variable for follow-up read-selected/write-selected paths.
SelectRAMVar(id uint16) error
// SelectSetting selects a setting for follow-up read-selected/write-selected paths.
SelectSetting(id uint16) error
// ReadSelected reads the currently selected value via command 0x35.
ReadSelected() (int16, error)
// ReadRAMVarInfo reads RAM variable metadata via command 0x36.
ReadRAMVarInfo(id uint16) (RAMVarInfo, error)
// WriteSelectedData writes to the currently selected register via command 0x34.
WriteSelectedData(value int16) error
// WriteSettingBySelection performs 0x33 (select setting) followed by 0x34 (write data).
WriteSettingBySelection(id uint16, value int16) error
// WriteRAMVarBySelection performs 0x32 (select RAM var) followed by 0x34 (write data).
WriteRAMVarBySelection(id uint16, value int16) error
// WriteSettingByID writes a setting via command 0x37.
WriteSettingByID(id uint16, value int16) error
// WriteRAMVarByID writes a RAM variable via command 0x38.
WriteRAMVarByID(id uint16, value int16) error
}
type RegisterKind string
const (
RegisterKindSetting RegisterKind = "setting"
RegisterKindRAMVar RegisterKind = "ram_var"
)
type RegisterSafetyClass string
const (
// RegisterSafetyReadOnly indicates no write path should be exposed.
RegisterSafetyReadOnly RegisterSafetyClass = "read_only"
// RegisterSafetyOperational indicates normal runtime write usage is expected.
RegisterSafetyOperational RegisterSafetyClass = "operational"
// RegisterSafetyGuarded indicates writes should be policy-guarded.
RegisterSafetyGuarded RegisterSafetyClass = "guarded"
// RegisterSafetyCritical indicates high-impact settings that need stricter controls.
RegisterSafetyCritical RegisterSafetyClass = "critical"
)
type TimeoutClass string
const (
TimeoutClassFast TimeoutClass = "fast"
TimeoutClassStandard TimeoutClass = "standard"
TimeoutClassSlow TimeoutClass = "slow"
)
// RegisterMetadata documents known MK2 register IDs and expected value behavior.
type RegisterMetadata struct {
Kind RegisterKind
ID uint16
Name string
Description string
Unit string
Scale float64
Writable bool
Signed bool
MinValue *int16
MaxValue *int16
SafetyClass RegisterSafetyClass
}
// TransactionOptions controls retry and verification semantics for safe writes.
type TransactionOptions struct {
// Retries is the number of additional write attempts after the first try.
Retries int
// RetryDelay is slept between retries. Zero uses a sensible default.
RetryDelay time.Duration
// BackoffFactor multiplies retry delay for each additional attempt (1 disables backoff).
BackoffFactor float64
// ReadBeforeWrite captures previous value before writing when possible.
ReadBeforeWrite bool
// VerifyAfterWrite reads the register back and compares with written value.
VerifyAfterWrite bool
// TimeoutClass applies standard timeout buckets when CommandTimeout is not set.
TimeoutClass TimeoutClass
// CommandTimeout overrides timeout class for each protocol command inside the transaction.
CommandTimeout time.Duration
}
// RegisterTransactionResult reports details about a transactional register write.
type RegisterTransactionResult struct {
Kind RegisterKind
ID uint16
TargetValue int16
PreviousValue *int16
VerifiedValue *int16
Attempts int
Timeout time.Duration
Duration time.Duration
}
// MetadataControl adds register metadata and transactional safety helpers.
type MetadataControl interface {
ProtocolControl
// RegisterMetadata returns metadata for a known register.
RegisterMetadata(kind RegisterKind, id uint16) (RegisterMetadata, bool)
// ListRegisterMetadata returns all known register metadata.
ListRegisterMetadata() []RegisterMetadata
// ReadRegister reads a setting or RAM var by kind and id.
ReadRegister(kind RegisterKind, id uint16) (int16, error)
// WriteRegister performs a safe transactional write with optional retry/verify.
WriteRegister(kind RegisterKind, id uint16, value int16, opts TransactionOptions) (RegisterTransactionResult, error)
}
type RegisterAddress struct {
Kind RegisterKind `json:"kind"`
ID uint16 `json:"id"`
}
type RegisterSnapshotEntry struct {
Kind RegisterKind `json:"kind"`
ID uint16 `json:"id"`
Name string `json:"name,omitempty"`
Value int16 `json:"value"`
Writable bool `json:"writable"`
Safety RegisterSafetyClass `json:"safety_class,omitempty"`
CapturedAt time.Time `json:"captured_at"`
}
type RegisterSnapshot struct {
CapturedAt time.Time `json:"captured_at"`
Entries []RegisterSnapshotEntry `json:"entries"`
}
type SnapshotDiff struct {
Kind RegisterKind `json:"kind"`
ID uint16 `json:"id"`
Name string `json:"name,omitempty"`
Current int16 `json:"current"`
Target int16 `json:"target"`
Changed bool `json:"changed"`
Writable bool `json:"writable"`
Safety RegisterSafetyClass `json:"safety_class,omitempty"`
DiffValue int32 `json:"diff_value"`
}
type SnapshotRestoreResult struct {
Applied []RegisterTransactionResult `json:"applied"`
RolledBack bool `json:"rolled_back"`
RollbackErrors []string `json:"rollback_errors,omitempty"`
}
// SnapshotControl provides register snapshot, diff preview, and rollback-aware restore.
type SnapshotControl interface {
MetadataControl
// CaptureSnapshot reads the provided register list. Empty addresses captures known writable registers.
CaptureSnapshot(addresses []RegisterAddress) (RegisterSnapshot, error)
// DiffSnapshot compares current values against a snapshot.
DiffSnapshot(snapshot RegisterSnapshot) ([]SnapshotDiff, error)
// RestoreSnapshot applies snapshot target values; if restore fails mid-way it attempts rollback.
RestoreSnapshot(snapshot RegisterSnapshot, opts TransactionOptions) (SnapshotRestoreResult, error)
}
type TraceDirection string
const (
TraceDirectionTX TraceDirection = "tx"
TraceDirectionRX TraceDirection = "rx"
)
type ProtocolTrace struct {
Timestamp time.Time `json:"timestamp"`
Direction TraceDirection `json:"direction"`
Frame string `json:"frame"`
Command string `json:"command,omitempty"`
BytesHex string `json:"bytes_hex"`
}
type DriverDiagnostics struct {
GeneratedAt time.Time `json:"generated_at"`
HealthScore int `json:"health_score"`
LastFrameAt *time.Time `json:"last_frame_at,omitempty"`
CommandTimeouts uint64 `json:"command_timeouts"`
CommandFailures uint64 `json:"command_failures"`
ChecksumFailures uint64 `json:"checksum_failures"`
RecentErrors []string `json:"recent_errors,omitempty"`
Traces []ProtocolTrace `json:"traces"`
}
// DiagnosticsControl exposes recent protocol traces and health information for troubleshooting bundles.
type DiagnosticsControl interface {
DriverDiagnostics(limit int) DriverDiagnostics
}
type CommandSource string
const (
CommandSourceUnknown CommandSource = "unknown"
CommandSourceUI CommandSource = "ui"
CommandSourceMQTT CommandSource = "mqtt"
CommandSourceAutomation CommandSource = "automation"
)
// SourceAwareSettingsWriter accepts source tags for arbitration and diagnostics.
type SourceAwareSettingsWriter interface {
SettingsWriter
WriteRAMVarWithSource(source CommandSource, id uint16, value int16) error
WriteSettingWithSource(source CommandSource, id uint16, value int16) error
SetPanelStateWithSource(source CommandSource, switchState PanelSwitchState, currentLimitA *float64) error
SetStandbyWithSource(source CommandSource, enabled bool) error
}
type CommandHistoryProvider interface {
History(limit int) []CommandEvent
}

View File

@@ -8,6 +8,8 @@ type mock struct {
c chan *Mk2Info c chan *Mk2Info
} }
var _ ProtocolControl = (*mock)(nil)
func NewMk2Mock() Mk2 { func NewMk2Mock() Mk2 {
tmp := &mock{ tmp := &mock{
c: make(chan *Mk2Info, 1), c: make(chan *Mk2Info, 1),
@@ -37,6 +39,77 @@ func (m *mock) Close() {
} }
func (m *mock) WriteRAMVar(_ uint16, _ int16) error {
return nil
}
func (m *mock) WriteSetting(_ uint16, _ int16) error {
return nil
}
func (m *mock) SetPanelState(_ PanelSwitchState, _ *float64) error {
return nil
}
func (m *mock) SetStandby(_ bool) error {
return nil
}
func (m *mock) GetDeviceState() (DeviceState, error) {
return DeviceStateOn, nil
}
func (m *mock) SetDeviceState(_ DeviceState) error {
return nil
}
func (m *mock) ReadRAMVarByID(_ uint16) (int16, error) {
return 0, nil
}
func (m *mock) ReadSettingByID(_ uint16) (int16, error) {
return 0, nil
}
func (m *mock) SelectRAMVar(_ uint16) error {
return nil
}
func (m *mock) SelectSetting(_ uint16) error {
return nil
}
func (m *mock) ReadSelected() (int16, error) {
return 0, nil
}
func (m *mock) ReadRAMVarInfo(id uint16) (RAMVarInfo, error) {
return RAMVarInfo{
ID: id,
Supported: false,
}, nil
}
func (m *mock) WriteSettingByID(_ uint16, _ int16) error {
return nil
}
func (m *mock) WriteRAMVarByID(_ uint16, _ int16) error {
return nil
}
func (m *mock) WriteSelectedData(_ int16) error {
return nil
}
func (m *mock) WriteSettingBySelection(_ uint16, _ int16) error {
return nil
}
func (m *mock) WriteRAMVarBySelection(_ uint16, _ int16) error {
return nil
}
func (m *mock) genMockValues() { func (m *mock) genMockValues() {
mult := 1.0 mult := 1.0
ledState := LedOff ledState := LedOff

View File

@@ -1,12 +1,12 @@
package cli package cli
import ( import (
"fmt" "git.coadcorp.com/nathan/invertergui/mk2driver"
"log" "github.com/sirupsen/logrus"
"github.com/diebietse/invertergui/mk2driver"
) )
var log = logrus.WithField("ctx", "inverter-gui-cli")
type Cli struct { type Cli struct {
mk2driver.Mk2 mk2driver.Mk2
} }
@@ -27,21 +27,21 @@ func (c *Cli) run() {
} }
func printInfo(info *mk2driver.Mk2Info) { func printInfo(info *mk2driver.Mk2Info) {
out := fmt.Sprintf("Version: %v\n", info.Version) log.Infof("Version: %v", info.Version)
out += fmt.Sprintf("Bat Volt: %.2fV Bat Cur: %.2fA \n", info.BatVoltage, info.BatCurrent) log.Infof("Bat Volt: %.2fV Bat Cur: %.2fA", info.BatVoltage, info.BatCurrent)
out += fmt.Sprintf("In Volt: %.2fV In Cur: %.2fA In Freq %.2fHz\n", info.InVoltage, info.InCurrent, info.InFrequency) log.Infof("In Volt: %.2fV In Cur: %.2fA In Freq %.2fHz", info.InVoltage, info.InCurrent, info.InFrequency)
out += fmt.Sprintf("Out Volt: %.2fV Out Cur: %.2fA Out Freq %.2fHz\n", info.OutVoltage, info.OutCurrent, info.OutFrequency) log.Infof("Out Volt: %.2fV Out Cur: %.2fA Out Freq %.2fHz", info.OutVoltage, info.OutCurrent, info.OutFrequency)
out += fmt.Sprintf("In Power %.2fW Out Power %.2fW\n", info.InVoltage*info.InCurrent, info.OutVoltage*info.OutCurrent) log.Infof("In Power %.2fW Out Power %.2fW", info.InVoltage*info.InCurrent, info.OutVoltage*info.OutCurrent)
out += fmt.Sprintf("Charge State: %.2f%%\n", info.ChargeState*100) log.Infof("Charge State: %.2f%%", info.ChargeState*100)
out += "LEDs state:" log.Info("LEDs state:")
for k, v := range info.LEDs { for k, v := range info.LEDs {
out += fmt.Sprintf(" %s %s", mk2driver.LedNames[k], mk2driver.StateNames[v]) log.Infof(" %s %s", mk2driver.LedNames[k], mk2driver.StateNames[v])
} }
out += "\nErrors:" if len(info.Errors) != 0 {
for _, v := range info.Errors { log.Info("Errors:")
out += " " + v.Error() for _, err := range info.Errors {
log.Error(err)
}
} }
out += "\n"
log.Printf("System Info: \n%v", out)
} }

2480
plugins/mqttclient/mqtt.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,503 @@
package mqttclient
import (
"testing"
"time"
"git.coadcorp.com/nathan/invertergui/mk2driver"
"github.com/stretchr/testify/assert"
)
type fakeWriter struct {
lastKind string
lastID uint16
lastValue int16
lastSwitchState mk2driver.PanelSwitchState
lastCurrentLimit *float64
lastStandby *bool
err error
}
func (f *fakeWriter) WriteRAMVar(id uint16, value int16) error {
f.lastKind = commandKindRAMVar
f.lastID = id
f.lastValue = value
return f.err
}
func (f *fakeWriter) WriteSetting(id uint16, value int16) error {
f.lastKind = commandKindSetting
f.lastID = id
f.lastValue = value
return f.err
}
func (f *fakeWriter) SetPanelState(switchState mk2driver.PanelSwitchState, currentLimitA *float64) error {
f.lastKind = commandKindPanel
f.lastSwitchState = switchState
f.lastCurrentLimit = currentLimitA
return f.err
}
func (f *fakeWriter) SetStandby(standby bool) error {
f.lastKind = commandKindStandby
f.lastStandby = &standby
return f.err
}
func Test_decodeWriteCommand(t *testing.T) {
currentLimit := 16.5
tests := []struct {
name string
payload string
check func(*testing.T, writeCommand)
wantErr string
}{
{
name: "setting",
payload: `{"request_id":"abc","kind":"setting","id":15,"value":-5}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, writeCommand{
Source: mk2driver.CommandSourceMQTT,
RequestID: "abc",
Kind: commandKindSetting,
ID: 15,
Value: -5,
}, got)
},
},
{
name: "ram_var alias from type",
payload: `{"type":"ramvar","id":2,"value":7}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, writeCommand{
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindRAMVar,
ID: 2,
Value: 7,
}, got)
},
},
{
name: "panel state",
payload: `{"kind":"panel_state","switch":"on","current_limit":16.5}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, commandKindPanel, got.Kind)
assert.True(t, got.HasSwitch)
assert.Equal(t, mk2driver.PanelSwitchOn, got.SwitchState)
assert.Equal(t, "on", got.SwitchName)
if assert.NotNil(t, got.CurrentLimitA) {
assert.Equal(t, currentLimit, *got.CurrentLimitA)
}
},
},
{
name: "panel current limit only",
payload: `{"kind":"panel_state","current_limit":12.0}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, commandKindPanel, got.Kind)
assert.False(t, got.HasSwitch)
assert.Nil(t, got.Standby)
if assert.NotNil(t, got.CurrentLimitA) {
assert.Equal(t, 12.0, *got.CurrentLimitA)
}
},
},
{
name: "standby bool",
payload: `{"kind":"standby","standby":true}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, commandKindStandby, got.Kind)
if assert.NotNil(t, got.Standby) {
assert.True(t, *got.Standby)
}
},
},
{
name: "standby using value string",
payload: `{"kind":"standby","value":"OFF"}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, commandKindStandby, got.Kind)
if assert.NotNil(t, got.Standby) {
assert.False(t, *got.Standby)
}
},
},
{
name: "missing id",
payload: `{"kind":"setting","value":1}`,
wantErr: `missing required field "id"`,
},
{
name: "missing panel switch and current limit",
payload: `{"kind":"panel_state"}`,
wantErr: `missing required field "switch"`,
},
{
name: "invalid standby",
payload: `{"kind":"standby","value":"banana"}`,
wantErr: `field "standby" must be true/false`,
},
{
name: "invalid kind",
payload: `{"kind":"unknown","id":1,"value":1}`,
wantErr: `unsupported write command kind "unknown"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := decodeWriteCommand([]byte(tt.payload))
if tt.wantErr != "" {
assert.Error(t, err)
assert.ErrorContains(t, err, tt.wantErr)
return
}
assert.NoError(t, err)
tt.check(t, got)
})
}
}
func Test_executeWriteCommand(t *testing.T) {
limit := 8.0
standby := true
tests := []struct {
name string
cmd writeCommand
want string
}{
{
name: "setting",
cmd: writeCommand{
Kind: commandKindSetting,
ID: 9,
Value: 2,
},
want: commandKindSetting,
},
{
name: "ram_var",
cmd: writeCommand{
Kind: commandKindRAMVar,
ID: 3,
Value: -1,
},
want: commandKindRAMVar,
},
{
name: "panel_state",
cmd: writeCommand{
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: mk2driver.PanelSwitchInverterOnly,
CurrentLimitA: &limit,
},
want: commandKindPanel,
},
{
name: "standby",
cmd: writeCommand{
Kind: commandKindStandby,
Standby: &standby,
},
want: commandKindStandby,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer := &fakeWriter{}
err := executeWriteCommand(writer, tt.cmd)
assert.NoError(t, err)
assert.Equal(t, tt.want, writer.lastKind)
switch tt.want {
case commandKindPanel:
assert.Equal(t, tt.cmd.SwitchState, writer.lastSwitchState)
if assert.NotNil(t, writer.lastCurrentLimit) {
assert.Equal(t, *tt.cmd.CurrentLimitA, *writer.lastCurrentLimit)
}
case commandKindStandby:
if assert.NotNil(t, writer.lastStandby) {
assert.Equal(t, *tt.cmd.Standby, *writer.lastStandby)
}
default:
assert.Equal(t, tt.cmd.ID, writer.lastID)
assert.Equal(t, tt.cmd.Value, writer.lastValue)
}
})
}
}
func Test_buildHADiscoveryDefinitions(t *testing.T) {
cfg := Config{
Topic: "invertergui/updates",
CommandTopic: "invertergui/settings/set",
HomeAssistant: HomeAssistantConfig{
Enabled: true,
DiscoveryPrefix: "homeassistant",
NodeID: "victron_main",
DeviceName: "Shed Victron",
},
}
definitions := buildHADiscoveryDefinitions(cfg)
assert.NotEmpty(t, definitions)
var panelMode *haDiscoveryDefinition
var panelCurrentLimit *haDiscoveryDefinition
var panelStandby *haDiscoveryDefinition
var batteryVoltage *haDiscoveryDefinition
for i := range definitions {
def := &definitions[i]
if def.Component == "select" && def.ObjectID == "remote_panel_mode" {
panelMode = def
}
if def.Component == "number" && def.ObjectID == "remote_panel_current_limit" {
panelCurrentLimit = def
}
if def.Component == "switch" && def.ObjectID == "remote_panel_standby" {
panelStandby = def
}
if def.Component == "sensor" && def.ObjectID == "battery_voltage" {
batteryVoltage = def
}
}
if assert.NotNil(t, panelMode) {
assert.Equal(t, cfg.CommandTopic, panelMode.Config["command_topic"])
assert.Equal(t, haPanelSwitchStateTopic(cfg), panelMode.Config["state_topic"])
}
if assert.NotNil(t, panelCurrentLimit) {
assert.Equal(t, cfg.CommandTopic, panelCurrentLimit.Config["command_topic"])
assert.Equal(t, haCurrentLimitStateTopic(cfg), panelCurrentLimit.Config["state_topic"])
}
if assert.NotNil(t, panelStandby) {
assert.Equal(t, cfg.CommandTopic, panelStandby.Config["command_topic"])
assert.Equal(t, haStandbyStateTopic(cfg), panelStandby.Config["state_topic"])
}
if assert.NotNil(t, batteryVoltage) {
assert.Equal(t, cfg.Topic, batteryVoltage.Config["state_topic"])
}
}
func Test_panelStateCacheResolvePanelCommand(t *testing.T) {
cache := &panelStateCache{}
_, err := cache.resolvePanelCommand(writeCommand{
Kind: commandKindPanel,
CurrentLimitA: float64Ptr(12),
})
assert.Error(t, err)
cache.remember(writeCommand{
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: mk2driver.PanelSwitchOn,
SwitchName: "on",
})
resolved, err := cache.resolvePanelCommand(writeCommand{
Kind: commandKindPanel,
CurrentLimitA: float64Ptr(10),
})
assert.NoError(t, err)
assert.True(t, resolved.HasSwitch)
assert.Equal(t, mk2driver.PanelSwitchOn, resolved.SwitchState)
assert.Equal(t, "on", resolved.SwitchName)
}
func Test_normalizeID(t *testing.T) {
assert.Equal(t, "victron_main_01", normalizeID("Victron Main #01"))
assert.Equal(t, "inverter-gui", normalizeID(" inverter-gui "))
assert.Equal(t, "", normalizeID(" "))
}
func Test_decodeVenusWriteCommand(t *testing.T) {
cfg := Config{
ClientID: "inverter-gui",
Venus: VenusConfig{
Enabled: true,
PortalID: "site01",
Service: "vebus/257",
GuideCompat: true,
},
}
tests := []struct {
name string
topic string
payload string
assertion func(*testing.T, writeCommand)
wantErr string
}{
{
name: "mode numeric",
topic: "W/site01/vebus/257/Mode",
payload: `{"value":3}`,
assertion: func(t *testing.T, cmd writeCommand) {
assert.Equal(t, commandKindPanel, cmd.Kind)
assert.True(t, cmd.HasSwitch)
assert.Equal(t, mk2driver.PanelSwitchOn, cmd.SwitchState)
assert.Equal(t, "on", cmd.SwitchName)
},
},
{
name: "current limit",
topic: "W/site01/vebus/257/Ac/ActiveIn/CurrentLimit",
payload: `{"value":16.5}`,
assertion: func(t *testing.T, cmd writeCommand) {
assert.Equal(t, commandKindPanel, cmd.Kind)
if assert.NotNil(t, cmd.CurrentLimitA) {
assert.Equal(t, 16.5, *cmd.CurrentLimitA)
}
},
},
{
name: "standby",
topic: "W/site01/vebus/257/Settings/Standby",
payload: `{"value":true}`,
assertion: func(t *testing.T, cmd writeCommand) {
assert.Equal(t, commandKindStandby, cmd.Kind)
if assert.NotNil(t, cmd.Standby) {
assert.True(t, *cmd.Standby)
}
},
},
{
name: "invalid topic",
topic: "W/site01/vebus/257/Unknown",
payload: `{"value":1}`,
wantErr: "unsupported Venus write path",
},
{
name: "guide ess setpoint",
topic: "W/site01/settings/0/Settings/CGwacs/AcPowerSetPoint",
payload: `{"value":-1200}`,
assertion: func(t *testing.T, cmd writeCommand) {
assert.Equal(t, commandKindESSSet, cmd.Kind)
if assert.NotNil(t, cmd.FloatValue) {
assert.InDelta(t, -1200, *cmd.FloatValue, 0.01)
}
},
},
{
name: "guide ess mode with prefix",
topic: "victron/W/site01/settings/0/Settings/CGwacs/BatteryLife/State",
payload: `{"value":10}`,
assertion: func(t *testing.T, cmd writeCommand) {
assert.Equal(t, commandKindESSMode, cmd.Kind)
assert.Equal(t, int16(10), cmd.Value)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testCfg := cfg
if tt.name == "guide ess mode with prefix" {
testCfg.Venus.TopicPrefix = "victron"
}
cmd, err := decodeVenusWriteCommand(testCfg, tt.topic, []byte(tt.payload))
if tt.wantErr != "" {
assert.Error(t, err)
assert.ErrorContains(t, err, tt.wantErr)
return
}
assert.NoError(t, err)
tt.assertion(t, cmd)
})
}
}
func Test_panelStateCacheRememberTracksFields(t *testing.T) {
cache := &panelStateCache{}
limit := 12.0
standby := true
cache.remember(writeCommand{
Kind: commandKindPanel,
HasSwitch: true,
SwitchName: "on",
SwitchState: mk2driver.PanelSwitchOn,
CurrentLimitA: &limit,
})
cache.remember(writeCommand{
Kind: commandKindStandby,
Standby: &standby,
})
s := cache.snapshot()
assert.True(t, s.HasSwitch)
assert.Equal(t, "on", s.Switch)
assert.True(t, s.HasCurrent)
assert.InDelta(t, 12.0, s.CurrentLimit, 0.001)
assert.True(t, s.HasStandby)
assert.True(t, s.Standby)
}
func Test_historyTrackerSummary(t *testing.T) {
h := newHistoryTracker(2)
now := time.Now().UTC()
h.Add(telemetrySnapshot{
Timestamp: now,
InputPower: 100,
OutputPower: 90,
BatteryPower: -10,
BatteryVoltage: 25.0,
}, operatingStatePassthru, 0, nil)
summary := h.Add(telemetrySnapshot{
Timestamp: now.Add(1 * time.Second),
InputPower: 200,
OutputPower: 180,
BatteryPower: -20,
BatteryVoltage: 24.5,
}, operatingStateInverter, 2, &now)
assert.Equal(t, 2, summary.Samples)
assert.InDelta(t, 150, summary.AverageInputPower, 0.01)
assert.InDelta(t, 135, summary.AverageOutputPower, 0.01)
assert.InDelta(t, 180, summary.MaxOutputPower, 0.01)
assert.InDelta(t, 24.5, summary.MinBatteryVoltage, 0.01)
assert.Equal(t, uint64(2), summary.FaultCount)
}
func Test_resolveESSWriteCommand(t *testing.T) {
ess := newESSControlCache()
telemetry := &telemetryCache{}
telemetry.set(telemetrySnapshot{InputVoltage: 230})
setpoint := 920.0
mapped, err := resolveESSWriteCommand(writeCommand{
Kind: commandKindESSSet,
FloatValue: &setpoint,
}, ess, telemetry)
assert.NoError(t, err)
if assert.NotNil(t, mapped) {
assert.Equal(t, commandKindPanel, mapped.Kind)
assert.Equal(t, "charger_only", mapped.SwitchName)
if assert.NotNil(t, mapped.CurrentLimitA) {
assert.InDelta(t, 4.0, *mapped.CurrentLimitA, 0.01)
}
}
maxDischarge := 1000.0
_, err = resolveESSWriteCommand(writeCommand{
Kind: commandKindESSMaxD,
FloatValue: &maxDischarge,
}, ess, telemetry)
assert.NoError(t, err)
dischargeSetpoint := -2000.0
mapped, err = resolveESSWriteCommand(writeCommand{
Kind: commandKindESSSet,
FloatValue: &dischargeSetpoint,
}, ess, telemetry)
assert.NoError(t, err)
if assert.NotNil(t, mapped) {
assert.Equal(t, commandKindPanel, mapped.Kind)
assert.Equal(t, "inverter_only", mapped.SwitchName)
}
}

View File

@@ -36,23 +36,26 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/diebietse/invertergui/mk2driver" "git.coadcorp.com/nathan/invertergui/mk2driver"
"github.com/sirupsen/logrus"
) )
var log = logrus.WithField("ctx", "inverter-gui-munin")
type Munin struct { type Munin struct {
mk2driver.Mk2 mk2driver.Mk2
muninResponse chan *muninData muninResponse chan muninData
} }
type muninData struct { type muninData struct {
status *mk2driver.Mk2Info status mk2driver.Mk2Info
timesUpdated int timesUpdated int
} }
func NewMunin(mk2 mk2driver.Mk2) *Munin { func NewMunin(mk2 mk2driver.Mk2) *Munin {
m := &Munin{ m := &Munin{
Mk2: mk2, Mk2: mk2,
muninResponse: make(chan *muninData), muninResponse: make(chan muninData),
} }
go m.run() go m.run()
@@ -60,17 +63,18 @@ func NewMunin(mk2 mk2driver.Mk2) *Munin {
return m return m
} }
func (m *Munin) ServeMuninHTTP(rw http.ResponseWriter, r *http.Request) { func (m *Munin) ServeMuninHTTP(rw http.ResponseWriter, _ *http.Request) {
muninDat := <-m.muninResponse muninDat := <-m.muninResponse
if muninDat.timesUpdated == 0 { if muninDat.timesUpdated == 0 {
log.Error("No data returned")
rw.WriteHeader(500) rw.WriteHeader(500)
_, _ = rw.Write([]byte("No data to return.\n")) _, _ = rw.Write([]byte("No data to return.\n"))
return return
} }
calcMuninAverages(muninDat) calcMuninAverages(&muninDat)
status := muninDat.status status := muninDat.status
tmpInput := buildTemplateInput(status) tmpInput := buildTemplateInput(&status)
outputBuf := &bytes.Buffer{} outputBuf := &bytes.Buffer{}
fmt.Fprintf(outputBuf, "multigraph in_batvolt\n") fmt.Fprintf(outputBuf, "multigraph in_batvolt\n")
fmt.Fprintf(outputBuf, "volt.value %s\n", tmpInput.BatVoltage) fmt.Fprintf(outputBuf, "volt.value %s\n", tmpInput.BatVoltage)
@@ -95,79 +99,75 @@ func (m *Munin) ServeMuninHTTP(rw http.ResponseWriter, r *http.Request) {
_, err := rw.Write(outputBuf.Bytes()) _, err := rw.Write(outputBuf.Bytes())
if err != nil { if err != nil {
fmt.Printf("%v\n", err) log.Errorf("Could not write data response: %v", err)
} }
} }
func (m *Munin) ServeMuninConfigHTTP(rw http.ResponseWriter, r *http.Request) { func (m *Munin) ServeMuninConfigHTTP(rw http.ResponseWriter, _ *http.Request) {
output := muninConfig output := muninConfig
_, err := rw.Write([]byte(output)) _, err := rw.Write([]byte(output))
if err != nil { if err != nil {
fmt.Printf("%v\n", err) log.Errorf("Could not write config response: %v", err)
} }
} }
func (m *Munin) run() { func (m *Munin) run() {
muninValues := &muninData{ muninValues := &muninData{
status: &mk2driver.Mk2Info{}, status: mk2driver.Mk2Info{},
} }
for { for {
select { select {
case e := <-m.C(): case e := <-m.C():
if e.Valid { if e.Valid {
calcMuninValues(muninValues, e) calcMuninValues(muninValues, e)
} }
case m.muninResponse <- muninValues: case m.muninResponse <- *muninValues:
zeroMuninValues(muninValues) zeroMuninValues(muninValues)
} }
} }
} }
//Munin only samples once every 5 minutes so averages have to be calculated for some values. // Munin only samples once every 5 minutes so averages have to be calculated for some values.
func calcMuninValues(muninDat *muninData, newStatus *mk2driver.Mk2Info) { func calcMuninValues(m *muninData, newStatus *mk2driver.Mk2Info) {
muninDat.timesUpdated++ m.timesUpdated++
muninVal := muninDat.status m.status.OutCurrent += newStatus.OutCurrent
muninVal.OutCurrent += newStatus.OutCurrent m.status.InCurrent += newStatus.InCurrent
muninVal.InCurrent += newStatus.InCurrent m.status.BatCurrent += newStatus.BatCurrent
muninVal.BatCurrent += newStatus.BatCurrent
muninVal.OutVoltage += newStatus.OutVoltage m.status.OutVoltage += newStatus.OutVoltage
muninVal.InVoltage += newStatus.InVoltage m.status.InVoltage += newStatus.InVoltage
muninVal.BatVoltage += newStatus.BatVoltage m.status.BatVoltage += newStatus.BatVoltage
muninVal.InFrequency = newStatus.InFrequency m.status.InFrequency = newStatus.InFrequency
muninVal.OutFrequency = newStatus.OutFrequency m.status.OutFrequency = newStatus.OutFrequency
muninVal.ChargeState = newStatus.ChargeState m.status.ChargeState = newStatus.ChargeState
} }
func calcMuninAverages(muninDat *muninData) { func calcMuninAverages(m *muninData) {
muninVal := muninDat.status m.status.OutCurrent /= float64(m.timesUpdated)
muninVal.OutCurrent /= float64(muninDat.timesUpdated) m.status.InCurrent /= float64(m.timesUpdated)
muninVal.InCurrent /= float64(muninDat.timesUpdated) m.status.BatCurrent /= float64(m.timesUpdated)
muninVal.BatCurrent /= float64(muninDat.timesUpdated)
muninVal.OutVoltage /= float64(muninDat.timesUpdated) m.status.OutVoltage /= float64(m.timesUpdated)
muninVal.InVoltage /= float64(muninDat.timesUpdated) m.status.InVoltage /= float64(m.timesUpdated)
muninVal.BatVoltage /= float64(muninDat.timesUpdated) m.status.BatVoltage /= float64(m.timesUpdated)
} }
func zeroMuninValues(muninDat *muninData) { func zeroMuninValues(m *muninData) {
muninDat.timesUpdated = 0 m.timesUpdated = 0
muninVal := muninDat.status m.status.OutCurrent = 0
muninVal.OutCurrent = 0 m.status.InCurrent = 0
muninVal.InCurrent = 0 m.status.BatCurrent = 0
muninVal.BatCurrent = 0
muninVal.OutVoltage = 0 m.status.OutVoltage = 0
muninVal.InVoltage = 0 m.status.InVoltage = 0
muninVal.BatVoltage = 0 m.status.BatVoltage = 0
muninVal.InFrequency = 0 m.status.InFrequency = 0
muninVal.OutFrequency = 0 m.status.OutFrequency = 0
muninVal.ChargeState = 0 m.status.ChargeState = 0
} }
type templateInput struct { type templateInput struct {

View File

@@ -0,0 +1,29 @@
package munin
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"git.coadcorp.com/nathan/invertergui/mk2driver"
)
func TestServer(_ *testing.T) {
mockMk2 := mk2driver.NewMk2Mock()
muninServer := NewMunin(mockMk2)
ts := httptest.NewServer(http.HandlerFunc(muninServer.ServeMuninHTTP))
defer ts.Close()
res, err := http.Get(ts.URL)
if err != nil {
log.Fatal(err)
}
_, err = io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
log.Fatal(err)
}
}

View File

@@ -31,7 +31,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package prometheus package prometheus
import ( import (
"github.com/diebietse/invertergui/mk2driver" "git.coadcorp.com/nathan/invertergui/mk2driver"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
) )

View File

@@ -1,17 +1,14 @@
package static package static
import ( import (
"github.com/rakyll/statik/fs" "embed"
"log"
"net/http" "net/http"
) )
// New exports the static part of the webgui that is served via statik //go:embed css js index.html favicon.ico
var content embed.FS
// New exports the static part of the webgui that is served via embed
func New() http.Handler { func New() http.Handler {
statikFs, err := fs.New() return http.FileServer(http.FS(content))
if err != nil {
log.Fatal(err)
}
return http.FileServer(statikFs)
} }

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -98,6 +98,117 @@
<div class="alert alert-danger" role="alert" v-if="error.has_error"> <div class="alert alert-danger" role="alert" v-if="error.has_error">
{{ error.error_message }} {{ error.error_message }}
</div> </div>
<div
class="alert"
v-if="control.message !== ''"
v-bind:class="[control.has_error ? 'alert-danger' : 'alert-success']"
>
{{ control.message }}
</div>
<div class="row">
<div class="col">
<div class="card mb-3">
<div class="card-body">
<h4 class="card-title">Remote Panel Control</h4>
<p class="text-muted mb-2">
Mode and current limit are applied together, equivalent to
<code>set_remote_panel_state</code>.
</p>
<p class="mb-1">
<strong>Current Mode:</strong>
{{ remoteModeLabel(state.remote_panel) }}
</p>
<p class="mb-1">
<strong>Current Limit:</strong>
{{ state.remote_panel.current_limit === null || state.remote_panel.current_limit === undefined ? 'Unknown' : state.remote_panel.current_limit + ' A' }}
</p>
<p class="mb-3">
<strong>Standby:</strong>
{{ remoteStandbyLabel(state.remote_panel) }}
</p>
<div class="row">
<div class="col-md-8">
<form v-on:submit.prevent="applyRemotePanelState">
<div class="form-row">
<div class="form-group col-md-6">
<label for="modeSelect">Remote Panel Mode</label>
<select
class="form-control"
id="modeSelect"
v-model="remote_form.mode"
v-bind:disabled="!state.remote_panel.writable || control.busy"
>
<option value="on">on</option>
<option value="off">off</option>
<option value="charger_only">charger_only</option>
<option value="inverter_only">inverter_only</option>
</select>
</div>
<div class="form-group col-md-6">
<label for="currentLimitInput">AC Input Current Limit (A)</label>
<input
id="currentLimitInput"
type="number"
min="0"
step="0.1"
class="form-control"
v-model="remote_form.current_limit"
placeholder="leave blank to keep current"
v-bind:disabled="!state.remote_panel.writable || control.busy"
/>
</div>
</div>
<button
type="submit"
class="btn btn-primary"
v-bind:disabled="!state.remote_panel.writable || control.busy"
>
Apply Mode + Current Limit
</button>
</form>
</div>
<div class="col-md-4">
<form v-on:submit.prevent="applyStandby">
<div class="form-group">
<div class="form-check mt-4">
<input
id="standbySwitch"
type="checkbox"
class="form-check-input"
v-model="remote_form.standby"
v-bind:disabled="!state.remote_panel.writable || control.busy"
/>
<label class="form-check-label" for="standbySwitch">
Prevent sleep while off
</label>
</div>
</div>
<button
type="submit"
class="btn btn-secondary"
v-bind:disabled="!state.remote_panel.writable || control.busy"
>
Apply Standby
</button>
</form>
</div>
</div>
<div class="mt-3 text-muted" v-if="state.remote_panel.last_updated">
Last update {{ state.remote_panel.last_updated }}
<span v-if="state.remote_panel.last_command">
({{ state.remote_panel.last_command }})
</span>
</div>
<div class="mt-2 text-danger" v-if="state.remote_panel.last_error">
{{ state.remote_panel.last_error }}
</div>
<div class="mt-2 text-warning" v-if="!state.remote_panel.writable">
Remote control is unavailable for this data source.
</div>
</div>
</div>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<hr /> <hr />

View File

@@ -0,0 +1,280 @@
var app;
const timeoutMax = 30000;
const timeoutMin = 1000;
var timeout = timeoutMin;
function defaultRemotePanelState() {
return {
writable: false,
mode: "unknown",
current_limit: null,
standby: null,
last_command: "",
last_error: "",
last_updated: ""
};
}
function defaultState() {
return {
output_current: null,
output_voltage: 0,
output_frequency: 0,
output_power: 0,
input_current: 0,
input_voltage: 0,
input_frequency: 0,
input_power: 0,
battery_current: 0,
battery_voltage: 0,
battery_charge: 0,
battery_power: 0,
led_map: {
led_mains: "dot-off",
led_absorb: "dot-off",
led_bulk: "dot-off",
led_float: "dot-off",
led_inverter: "dot-off",
led_overload: "dot-off",
led_bat_low: "dot-off",
led_over_temp: "dot-off"
},
remote_panel: defaultRemotePanelState()
};
}
function loadContent() {
app = new Vue({
el: "#app",
data: {
error: {
has_error: false,
error_message: ""
},
control: {
busy: false,
has_error: false,
message: ""
},
remote_form: {
mode: "on",
current_limit: "",
standby: false
},
state: defaultState()
},
methods: {
syncRemoteFormFromState: function(remoteState) {
if (!remoteState) {
return;
}
if (remoteState.mode && remoteState.mode !== "unknown") {
this.remote_form.mode = remoteState.mode;
}
if (remoteState.current_limit === null || remoteState.current_limit === undefined) {
this.remote_form.current_limit = "";
} else {
this.remote_form.current_limit = String(remoteState.current_limit);
}
if (remoteState.standby === null || remoteState.standby === undefined) {
this.remote_form.standby = false;
} else {
this.remote_form.standby = !!remoteState.standby;
}
},
remoteModeLabel: function(remoteState) {
var mode = (remoteState && remoteState.mode) || "unknown";
if (mode === "charger_only") {
return "Charger Only";
}
if (mode === "inverter_only") {
return "Inverter Only";
}
if (mode === "on") {
return "On";
}
if (mode === "off") {
return "Off";
}
return "Unknown";
},
remoteStandbyLabel: function(remoteState) {
if (!remoteState || remoteState.standby === null || remoteState.standby === undefined) {
return "Unknown";
}
return remoteState.standby ? "Enabled" : "Disabled";
},
refreshRemoteState: function() {
var self = this;
fetch(getAPIURI("api/remote-panel/state"))
.then(function(resp) {
if (!resp.ok) {
throw new Error("Could not load remote panel state.");
}
return resp.json();
})
.then(function(payload) {
self.state.remote_panel = payload;
self.syncRemoteFormFromState(payload);
})
.catch(function(err) {
self.control.has_error = true;
self.control.message = err.message;
});
},
applyRemotePanelState: function() {
var self = this;
if (!self.state.remote_panel.writable) {
return;
}
var body = {
mode: self.remote_form.mode
};
if (self.remote_form.current_limit !== "") {
var parsed = parseFloat(self.remote_form.current_limit);
if (isNaN(parsed)) {
self.control.has_error = true;
self.control.message = "Current limit must be numeric.";
return;
}
body.current_limit = parsed;
}
self.control.busy = true;
self.control.has_error = false;
self.control.message = "";
fetch(getAPIURI("api/remote-panel/state"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(function(resp) {
if (!resp.ok) {
return resp.text().then(function(text) {
throw new Error(text || "Failed to set remote panel mode/current limit.");
});
}
return resp.json();
})
.then(function(payload) {
self.state.remote_panel = payload;
self.syncRemoteFormFromState(payload);
self.control.has_error = false;
self.control.message = "Remote panel state updated.";
})
.catch(function(err) {
self.control.has_error = true;
self.control.message = err.message;
})
.finally(function() {
self.control.busy = false;
});
},
applyStandby: function() {
var self = this;
if (!self.state.remote_panel.writable) {
return;
}
self.control.busy = true;
self.control.has_error = false;
self.control.message = "";
fetch(getAPIURI("api/remote-panel/standby"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
standby: !!self.remote_form.standby
})
})
.then(function(resp) {
if (!resp.ok) {
return resp.text().then(function(text) {
throw new Error(text || "Failed to set standby mode.");
});
}
return resp.json();
})
.then(function(payload) {
self.state.remote_panel = payload;
self.syncRemoteFormFromState(payload);
self.control.has_error = false;
self.control.message = "Standby mode updated.";
})
.catch(function(err) {
self.control.has_error = true;
self.control.message = err.message;
})
.finally(function() {
self.control.busy = false;
});
}
}
});
app.refreshRemoteState();
connect();
}
function connect() {
if (window["WebSocket"]) {
var conn = new WebSocket(getURI());
conn.onclose = function(evt) {
app.error.has_error = true;
app.error.error_message =
"Server not reachable. Trying to reconnect in " +
timeout / 1000 +
" second(s).";
console.log(app.error.error_message, evt.reason);
setTimeout(function() {
connect();
}, timeout);
timeout = timeout * 2;
if (timeout > timeoutMax) {
timeout = timeoutMax;
}
};
conn.onopen = function() {
timeout = timeoutMin;
app.error.has_error = false;
};
conn.onmessage = function(evt) {
var update = JSON.parse(evt.data);
app.state = update;
if (!app.control.busy) {
app.syncRemoteFormFromState(update.remote_panel);
}
};
} else {
app.error.has_error = true;
app.error.error_message = "Our browser does not support WebSockets.";
}
}
function getURI() {
var loc = window.location,
new_uri;
if (loc.protocol === "https:") {
new_uri = "wss:";
} else {
new_uri = "ws:";
}
new_uri += "//" + loc.host;
new_uri += loc.pathname + "ws";
return new_uri;
}
function getAPIURI(path) {
var base = window.location.pathname;
if (base.slice(-1) !== "/") {
base += "/";
}
return base + path.replace(/^\/+/, "");
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,88 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'self'">
<title>Page not found &middot; GitHub Pages</title>
<style type="text/css" media="screen">
body {
background-color: #f1f1f1;
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.container { margin: 50px auto 40px auto; width: 600px; text-align: center; }
a { color: #4183c4; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { width: 800px; position:relative; left: -100px; letter-spacing: -1px; line-height: 60px; font-size: 60px; font-weight: 100; margin: 0px 0 50px 0; text-shadow: 0 1px 0 #fff; }
p { color: rgba(0, 0, 0, 0.5); margin: 20px 0; line-height: 1.6; }
ul { list-style: none; margin: 25px 0; padding: 0; }
li { display: table-cell; font-weight: bold; width: 1%; }
.logo { display: inline-block; margin-top: 35px; }
.logo-img-2x { display: none; }
@media
only screen and (-webkit-min-device-pixel-ratio: 2),
only screen and ( min--moz-device-pixel-ratio: 2),
only screen and ( -o-min-device-pixel-ratio: 2/1),
only screen and ( min-device-pixel-ratio: 2),
only screen and ( min-resolution: 192dpi),
only screen and ( min-resolution: 2dppx) {
.logo-img-1x { display: none; }
.logo-img-2x { display: inline-block; }
}
#suggestions {
margin-top: 35px;
color: #ccc;
}
#suggestions a {
color: #666666;
font-weight: 200;
font-size: 14px;
margin: 0 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>404</h1>
<p><strong>File not found</strong></p>
<p>
The site configured at this address does not
contain the requested file.
</p>
<p>
If this is your site, make sure that the filename case matches the URL.<br>
For root URLs (like <code>http://example.com/</code>) you must provide an
<code>index.html</code> file.
</p>
<p>
<a href="https://help.github.com/pages/">Read the full documentation</a>
for more information about using <strong>GitHub Pages</strong>.
</p>
<div id="suggestions">
<a href="https://githubstatus.com">GitHub Status</a> &mdash;
<a href="https://twitter.com/githubstatus">@githubstatus</a>
</div>
<a href="/" class="logo logo-img-1x">
<img width="32" height="32" title="" alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpFMTZCRDY3REIzRjAxMUUyQUQzREIxQzRENUFFNUM5NiIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpFMTZCRDY3RUIzRjAxMUUyQUQzREIxQzRENUFFNUM5NiI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkUxNkJENjdCQjNGMDExRTJBRDNEQjFDNEQ1QUU1Qzk2IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkUxNkJENjdDQjNGMDExRTJBRDNEQjFDNEQ1QUU1Qzk2Ii8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+SM9MCAAAA+5JREFUeNrEV11Ik1EY3s4+ddOp29Q5b0opCgKFsoKoi5Kg6CIhuwi6zLJLoYLopq4qsKKgi4i6CYIoU/q5iDAKs6syoS76IRWtyJ+p7cdt7sf1PGOD+e0c3dygAx/67ZzzPM95/877GYdHRg3ZjMXFxepQKNS6sLCwJxqNNuFpiMfjVs4ZjUa/pmmjeD6VlJS8NpvNT4QQ7mxwjSsJiEQim/1+/9lgMHgIr5ohuxG1WCw9Vqv1clFR0dCqBODElV6v90ogEDjGdYbVjXhpaendioqK07CIR7ZAqE49PT09BPL2PMgTByQGsYiZlQD4uMXtdr+JxWINhgINYhGT2MsKgMrm2dnZXgRXhaHAg5jEJodUAHxux4LudHJE9RdEdA+i3Juz7bGHe4mhE9FNrgwBCLirMFV9Okh5eflFh8PR5nK5nDabrR2BNJlKO0T35+Li4n4+/J+/JQCxhmu5h3uJoXNHPbmWZAHMshWB8l5/ipqammaAf0zPDDx1ONV3vurdidqwAQL+pEc8sLcAe1CCvQ3YHxIW8Pl85xSWNC1hADDIv0rIE/o4J0k3kww4xSlwIhcq3EFFOm7KN/hUGOQkt0CFa5WpNJlMvxBEz/IVQAxg/ZRZl9wiHA63yDYieM7DnLP5CiAGsC7I5sgtYKJGWe2A8seFqgFJrJjEPY1Cn3pJ8/9W1e5VWsFDTEmFrBcoDhZJEQkXuhICMyKpjhahqN21hRYATKfUOlDmkygrR4o4C0VOLGJKrOITKB4jijzdXygBKixyC5TDQdnk/Pz8qRw6oOWGlsTKGOQW6OH6FBWsyePxdOXLTgxiyebILZCjz+GLgMIKnXNzc49YMlcRdHXcSwxFVgTInQhC9G33UhNoJLuqq6t345p9y3eUy8OTk5PjAHuI9uo4b07FBaOhsu0A4Unc+T1TU1Nj3KsSSE5yJ65jqF2DDd8QqWYmAZrIM2VlZTdnZmb6AbpdV9V6ec9znf5Q7HjYumdRE0JOp3MjitO4SFa+cZz8Umqe3TCbSLvdfkR/kWDdNQl5InuTcysOcpFT35ZrbBxx4p3JAHlZVVW1D/634VRt+FvLBgK/v5LV9WS+10xMTEwtRw7XvqOL+e2Q8V3AYIOIAXQ26/heWVnZCVfcyKHg2CBgTpmPmjYM8l24GyaUHyaIh7XwfR9ErE8qHoDfn2LTNAVC0HX6MFcBIP8Bi+6F6cdW/DICkANRfx99fEYFQ7Nph5i/uQiA214gno7K+guhaiKg9gC62+M8eR7XsBsYJ4ilam60Fb7r7uAj8wFyuwM1oIOWgfmDy6RXEEQzJMPe23DXrVS7rtyD3Df8z/FPgAEAzWU5Ku59ZAUAAAAASUVORK5CYII=">
</a>
<a href="/" class="logo logo-img-2x">
<img width="32" height="32" title="" alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpEQUM1QkUxRUI0MUMxMUUyQUQzREIxQzRENUFFNUM5NiIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpEQUM1QkUxRkI0MUMxMUUyQUQzREIxQzRENUFFNUM5NiI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkUxNkJENjdGQjNGMDExRTJBRDNEQjFDNEQ1QUU1Qzk2IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkUxNkJENjgwQjNGMDExRTJBRDNEQjFDNEQ1QUU1Qzk2Ii8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+hfPRaQAAB6lJREFUeNrsW2mME2UYbodtt+2222u35QheoCCYGBQligIJgkZJNPzgigoaTEj8AdFEMfADfyABkgWiiWcieK4S+QOiHAYUj2hMNKgYlEujpNttu9vttbvdw+chU1K6M535pt3ubHCSyezR+b73eb73+t7vrfXsufOW4bz6+vom9/b23ovnNNw34b5xYGAgODg46Mbt4mesVmsWd1qSpHhdXd2fuP/Afcput5/A88xwymcdBgLqenp6FuRyuWV4zu/v759QyWBjxoz5t76+/gun09mK5xFyakoCAPSaTCazNpvNPoYVbh6O1YKGRF0u13sNDQ27QMzfpiAAKj0lnU6/gBVfAZW2WWpwwVzy0IgP3G73FpjI6REhAGA9qVRqA1b9mVoBVyIC2tDi8Xg24+dUzQiAbS/s7Ox8G2o/3mKCC+Zw0efzPQEfcVjYrARX3dbV1bUtHo8fMgt42f+Mp0yUTVQbdWsAHVsikdiHkHaPxcQXQufXgUBgMRxme9U0AAxfH4vFvjM7eF6UkbJS5qoQwEQGA57Ac5JllFyUVZZ5ckUEgMVxsK2jlSYzI+QXJsiyjzNEAJyJAzb/KQa41jJKL8pODMQiTEAymXw5n8/P0IjD3bh7Rgog59aanxiIRTVvV/oj0tnHca/WMrVwODwB3raTGxzkBg/gnZVapFV62Wy2n5AO70HM/5wbJ0QnXyQSaVPDIuNZzY0V3ntHMwxiwHA0Gj2Np7ecIBDgaDAYXKCQJM1DhrgJ3nhulcPbl8j4NmHe46X/g60fwbz3aewjkqFQaAqebWU1AOqyQwt8Id6qEHMc97zu7u7FGGsn7HAiVuosVw7P35C1nccdgSCxop1dHeZswmfHMnxBo6ZTk+jN8dl/vF7vWofDsa+MLN9oEUBMxOb3+1eoEsBVw6Zmua49r8YmhAKDiEPcMwBsxMiqQ+ixzPFxZyqRpXARG/YOr1ObFJ0gUskXBbamcR1OKmMUvDxHRAu8/LmY3jFLMUpFqz9HxG65smYJdyKyECOxDiEAe/p1gjF2oonivZAsxVgl2daa4EQWCW6J55qFAFFZiJWYLxNQy2qOSUzGRsyXCUDIeliwAHEO4WSlWQBRFoZakXcKmCXmyXAKs0Ve9vl8q42WoIYpJU4hV3hKcNs8m9gl7p/xQ73eF5kB4j5mNrWmTJRNwAzqiV1CxjVTZCIkEq+Z1bZFZSN2CenmVAFVy4Plz8xKAGWjjAKFk6lCBMDR/MJjLLMSQNm43xAiQKTaA+9/wewhDjL+JVI1kkTSSOTcKbMTwPqESAot6dn6Fr1gHwVJju6IRuyiByPuUUBAg5DGkAgBmxlvdgIEK9gDkohdY/BJo4CAG0R8miRSsGABkgVQs4KXu098IgUXSSRsFAoKZiVAVDY2WUiiPTjYRi41KwGisrGsLtlsth8Fiwnz2fBkQvWfRtlE3iF2yW63/yCacXZ1dW02GwGyTFaRd4idJnCKHRaCxYRHoG5LTKT6SyiToP1fJHbmAYPYRR0UnZQtMnA6s0zg+GZBlt0Gdo7EPHgpE3Q6nZ8YyLhc8Xj8MJh/aKTAY+5FPAKHLE7RdwuYJZmNwzyCMkBCYyKROJBMJl9B/PXXCjjmCmDOVzH3fiPpObEWGqoKe4EBl8v1hlqsdLvd23mkxHM9pc9kMpmno9HoeTii7ewbHEZPPx1ztLS1tV3AnGuMjiNjvbQFuHw6zDo5By7dTPAQNBgMLrRarTkSls1mnwT7uwp9virx9QzbW/HuV/j5d/b+6jniKlllP8lkeONJDk+dq9GsQTnC4fB1heO0K47Hwe7WdDr9nAKgXwOBwHI+C45Htj1d6sd429TUNEcmUdc+PRaLHcvn87dXW4ugzdsaGxufL94NFv9zi1J7GVbhlvb2dnaJ3SVrxfc+n2+NTsZ7/H7/Mr3g5XdSIHyJSH1PZ+7fToyl2+ErqilgZ4NaLYB9goVGaHjR93Hv1ZrU4XDsFT20kH3PObzbWk0CgG1jacVIUnAQb9F+VexyLMzkpcLv0IJV7AHQIOCAUYHx7v5qgScmYHtTqSAyZLEJTK22Bie4iq3xsqpm4SAf9Hq9a2DnJ4uLK3SEULcdRvp3i3zHySqpficxEdsQc1NrlYXXvR+O7qASSezXB+h1SuUomgg9LL8BUoV4749EIolKh+EiqWmqVEZlDgHks2pxHw7xTqUQw9J5NcAXOK10AGIoZ6Zli6JY6Z1Q461KoZ4NiKLHarW+KDsxlDUPHZ5zPQZqUVDPJsTqb5n9malbpAh8C2XXDLl62+WZIDFRUlNVOiwencnNU3aQEkL+cDMSoLvZo2fQB7AJssNAuFuvorlDVVkkg2I87+jo2K2QAVphDrfyViK5VqtO34OkaxXCp+7drdDBCAdubm6eidX+2WwqT5komwh4YQLk+H4aE93h8Xg2gvHekQZOGSgLZTLyDTLJ4Lx9/KZWKBSainT4Iy3FqQBfnUZR42PKQFksBr9QKVXCPusD3OiA/RkQ5kP8qV/Jl1WywAp/6+dcmPM2zL1UrUahe4JqfnWWKXIul3uUbfP8njAFLW1OFr3gdFtZ72cNH+PtQT7/brW+NXqJAHh0y9V8/U/A1U7AfwIMAD7mS3pCbuWJAAAAAElFTkSuQmCC">
</a>
</div>
</body>
</html>

View File

@@ -1,90 +0,0 @@
var app;
const timeoutMax = 30000;
const timeoutMin = 1000;
var timeout = timeoutMin;
function loadContent() {
app = new Vue({
el: "#app",
data: {
error: {
has_error: false,
error_message: ""
},
state: {
output_current: null,
output_voltage: 0,
output_frequency: 0,
output_power: 0,
input_current: 0,
input_voltage: 0,
input_frequency: 0,
input_power: 0,
battery_current: 0,
battery_voltage: 0,
battery_charge: 0,
battery_power: 0,
led_map: [
{ led_mains: "dot-off" },
{ led_absorb: "dot-off" },
{ led_bulk: "dot-off" },
{ led_float: "dot-off" },
{ led_inverter: "dot-off" },
{ led_overload: "dot-off" },
{ led_bat_low: "dot-off" },
{ led_over_temp: "dot-off" }
]
}
}
});
connect();
}
function connect() {
if (window["WebSocket"]) {
var conn = new WebSocket(getURI());
conn.onclose = function(evt) {
app.error.has_error = true;
app.error.error_message =
"Server not reachable. Trying to reconnect in " +
timeout / 1000 +
" second(s).";
console.log(app.error.error_message, evt.reason);
setTimeout(function() {
connect();
}, timeout);
timeout = timeout * 2;
if (timeout > timeoutMax) {
timeout = timeoutMax;
}
};
conn.onopen = function(evt) {
timeout = timeoutMin;
app.error.has_error = false;
};
conn.onmessage = function(evt) {
var update = JSON.parse(evt.data);
app.state = update;
};
} else {
app.error.has_error = true;
app.error.error_message = "Our browser does not support WebSockets.";
}
}
function getURI() {
var loc = window.location,
new_uri;
if (loc.protocol === "https:") {
new_uri = "wss:";
} else {
new_uri = "ws:";
}
new_uri += "//" + loc.host;
new_uri += loc.pathname + "ws";
return new_uri;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -31,16 +31,20 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package webui package webui
import ( import (
"encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
"github.com/diebietse/invertergui/mk2driver" "git.coadcorp.com/nathan/invertergui/mk2driver"
"github.com/diebietse/invertergui/websocket" "git.coadcorp.com/nathan/invertergui/websocket"
"github.com/sirupsen/logrus"
) )
var log = logrus.WithField("ctx", "inverter-gui-webgui")
const ( const (
LedOff = "dot-off" LedOff = "dot-off"
LedRed = "dot-red" LedRed = "dot-red"
@@ -49,25 +53,54 @@ const (
BlinkGreen = "blink-green" BlinkGreen = "blink-green"
) )
const (
modeChargerOnly = "charger_only"
modeInverterOnly = "inverter_only"
modeOn = "on"
modeOff = "off"
modeUnknown = "unknown"
)
type WebGui struct { type WebGui struct {
mk2driver.Mk2 mk2driver.Mk2
writer mk2driver.SettingsWriter
stopChan chan struct{} stopChan chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
hub *websocket.Hub hub *websocket.Hub
stateMu sync.RWMutex
latest *templateInput
remote remotePanelState
} }
func NewWebGui(source mk2driver.Mk2) *WebGui { func NewWebGui(source mk2driver.Mk2, writer mk2driver.SettingsWriter) *WebGui {
w := &WebGui{ w := &WebGui{
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
Mk2: source, Mk2: source,
writer: writer,
hub: websocket.NewHub(), hub: websocket.NewHub(),
remote: remotePanelState{
Writable: writer != nil,
Mode: modeUnknown,
},
} }
log.WithField("remote_writable", writer != nil).Info("Web UI initialized")
w.wg.Add(1) w.wg.Add(1)
go w.dataPoll() go w.dataPoll()
return w return w
} }
type remotePanelState struct {
Writable bool `json:"writable"`
Mode string `json:"mode"`
CurrentLimit *float64 `json:"current_limit,omitempty"`
Standby *bool `json:"standby,omitempty"`
LastCommand string `json:"last_command,omitempty"`
LastError string `json:"last_error,omitempty"`
LastUpdated string `json:"last_updated,omitempty"`
}
type templateInput struct { type templateInput struct {
Error []error `json:"errors"` Error []error `json:"errors"`
@@ -92,12 +125,170 @@ type templateInput struct {
OutFreq string `json:"output_frequency"` OutFreq string `json:"output_frequency"`
LedMap map[string]string `json:"led_map"` LedMap map[string]string `json:"led_map"`
RemotePanel remotePanelState `json:"remote_panel"`
}
type setRemotePanelStateRequest struct {
Mode string `json:"mode"`
CurrentLimit *float64 `json:"current_limit"`
}
type setRemotePanelStandbyRequest struct {
Standby bool `json:"standby"`
} }
func (w *WebGui) ServeHub(rw http.ResponseWriter, r *http.Request) { func (w *WebGui) ServeHub(rw http.ResponseWriter, r *http.Request) {
log.WithFields(logrus.Fields{
"remote": r.RemoteAddr,
"path": r.URL.Path,
}).Debug("WebSocket hub request")
w.hub.ServeHTTP(rw, r) w.hub.ServeHTTP(rw, r)
} }
func (w *WebGui) ServeRemotePanelState(rw http.ResponseWriter, r *http.Request) {
log.WithFields(logrus.Fields{
"method": r.Method,
"path": r.URL.Path,
"remote": r.RemoteAddr,
}).Debug("Remote panel state API request")
switch r.Method {
case http.MethodGet:
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
case http.MethodPost:
w.handleSetRemotePanelState(rw, r)
default:
log.WithField("method", r.Method).Warn("Remote panel state API received unsupported method")
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (w *WebGui) ServeRemotePanelStandby(rw http.ResponseWriter, r *http.Request) {
log.WithFields(logrus.Fields{
"method": r.Method,
"path": r.URL.Path,
"remote": r.RemoteAddr,
}).Debug("Remote panel standby API request")
switch r.Method {
case http.MethodGet:
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
case http.MethodPost:
w.handleSetRemotePanelStandby(rw, r)
default:
log.WithField("method", r.Method).Warn("Remote panel standby API received unsupported method")
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (w *WebGui) handleSetRemotePanelState(rw http.ResponseWriter, r *http.Request) {
if w.writer == nil {
log.Warn("Remote panel state write requested, but writer is unavailable")
http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented)
return
}
req := setRemotePanelStateRequest{}
if err := decodeJSONBody(r, &req); err != nil {
log.WithError(err).Warn("Invalid remote panel state request body")
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
logEntry := log.WithField("mode", req.Mode)
if req.CurrentLimit != nil {
logEntry = logEntry.WithField("current_limit_a", *req.CurrentLimit)
}
logEntry.Info("Applying remote panel state from API")
switchState, normalizedMode, err := parsePanelMode(req.Mode)
if err != nil {
logEntry.WithError(err).Warn("Unsupported remote panel mode")
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
var setErr error
if sourceAware, ok := w.writer.(mk2driver.SourceAwareSettingsWriter); ok {
setErr = sourceAware.SetPanelStateWithSource(mk2driver.CommandSourceUI, switchState, req.CurrentLimit)
} else {
setErr = w.writer.SetPanelState(switchState, req.CurrentLimit)
}
if setErr != nil {
logEntry.WithError(setErr).Error("Failed to apply remote panel state")
w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_state"
state.LastError = setErr.Error()
})
http.Error(rw, setErr.Error(), http.StatusBadGateway)
return
}
w.updateRemotePanelState(func(state *remotePanelState) {
state.Mode = normalizedMode
state.CurrentLimit = copyFloat64Ptr(req.CurrentLimit)
state.LastCommand = "set_remote_panel_state"
state.LastError = ""
})
logEntry.WithField("normalized_mode", normalizedMode).Info("Remote panel state applied")
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
}
func (w *WebGui) handleSetRemotePanelStandby(rw http.ResponseWriter, r *http.Request) {
if w.writer == nil {
log.Warn("Remote panel standby write requested, but writer is unavailable")
http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented)
return
}
req := setRemotePanelStandbyRequest{}
if err := decodeJSONBody(r, &req); err != nil {
log.WithError(err).Warn("Invalid remote panel standby request body")
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
log.WithField("standby", req.Standby).Info("Applying standby state from API")
var err error
if sourceAware, ok := w.writer.(mk2driver.SourceAwareSettingsWriter); ok {
err = sourceAware.SetStandbyWithSource(mk2driver.CommandSourceUI, req.Standby)
} else {
err = w.writer.SetStandby(req.Standby)
}
if err != nil {
log.WithError(err).WithField("standby", req.Standby).Error("Failed to apply standby state")
w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_standby"
state.LastError = err.Error()
})
http.Error(rw, err.Error(), http.StatusBadGateway)
return
}
w.updateRemotePanelState(func(state *remotePanelState) {
state.Standby = copyBoolPtr(&req.Standby)
state.LastCommand = "set_remote_panel_standby"
state.LastError = ""
})
log.WithField("standby", req.Standby).Info("Standby state applied")
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
}
func parsePanelMode(raw string) (mk2driver.PanelSwitchState, string, error) {
switch strings.TrimSpace(strings.ToLower(raw)) {
case modeChargerOnly:
return mk2driver.PanelSwitchChargerOnly, modeChargerOnly, nil
case modeInverterOnly:
return mk2driver.PanelSwitchInverterOnly, modeInverterOnly, nil
case modeOn:
return mk2driver.PanelSwitchOn, modeOn, nil
case modeOff:
return mk2driver.PanelSwitchOff, modeOff, nil
default:
return 0, "", fmt.Errorf("unsupported panel mode %q", raw)
}
}
func ledName(led mk2driver.Led) string { func ledName(led mk2driver.Led) string {
name, ok := mk2driver.LedNames[led] name, ok := mk2driver.LedNames[led]
if !ok { if !ok {
@@ -156,21 +347,32 @@ func buildTemplateInput(status *mk2driver.Mk2Info) *templateInput {
} }
func (w *WebGui) Stop() { func (w *WebGui) Stop() {
log.Info("Stopping Web UI polling")
close(w.stopChan) close(w.stopChan)
w.wg.Wait() w.wg.Wait()
log.Info("Web UI polling stopped")
} }
// dataPoll waits for data from the w.poller channel. It will send its currently stored status
// to respChan if anything reads from it.
func (w *WebGui) dataPoll() { func (w *WebGui) dataPoll() {
for { for {
select { select {
case s := <-w.C(): case s := <-w.C():
if s.Valid { if s == nil {
err := w.hub.Broadcast(buildTemplateInput(s)) log.Debug("Skipping nil MK2 update in Web UI poller")
if err != nil { continue
log.Printf("Could not send update to clients: %v", err) }
} if !s.Valid {
log.WithField("errors", len(s.Errors)).Debug("Skipping invalid MK2 update in Web UI poller")
continue
}
payload := buildTemplateInput(s)
w.stateMu.Lock()
payload.RemotePanel = w.remote
w.latest = payload
w.stateMu.Unlock()
if err := w.hub.Broadcast(payload); err != nil {
log.Errorf("Could not send update to clients: %v", err)
} }
case <-w.stopChan: case <-w.stopChan:
w.wg.Done() w.wg.Done()
@@ -178,3 +380,100 @@ func (w *WebGui) dataPoll() {
} }
} }
} }
func (w *WebGui) getRemotePanelState() remotePanelState {
w.stateMu.RLock()
defer w.stateMu.RUnlock()
return copyRemotePanelState(w.remote)
}
func (w *WebGui) updateRemotePanelState(update func(state *remotePanelState)) {
w.stateMu.Lock()
update(&w.remote)
w.remote.LastUpdated = time.Now().UTC().Format(time.RFC3339)
log.WithFields(logrus.Fields{
"mode": w.remote.Mode,
"has_limit": w.remote.CurrentLimit != nil,
"has_standby": w.remote.Standby != nil,
"last_command": w.remote.LastCommand,
"last_error": w.remote.LastError,
}).Debug("Updated remote panel state cache")
snapshot := w.snapshotLocked()
w.stateMu.Unlock()
if snapshot != nil {
if err := w.hub.Broadcast(snapshot); err != nil {
log.Errorf("Could not send control update to clients: %v", err)
}
}
}
func (w *WebGui) snapshotLocked() *templateInput {
if w.latest == nil {
return nil
}
snapshot := cloneTemplateInput(w.latest)
snapshot.RemotePanel = copyRemotePanelState(w.remote)
return snapshot
}
func cloneTemplateInput(in *templateInput) *templateInput {
if in == nil {
return nil
}
out := *in
if in.Error != nil {
out.Error = append([]error(nil), in.Error...)
}
if in.LedMap != nil {
out.LedMap = make(map[string]string, len(in.LedMap))
for k, v := range in.LedMap {
out.LedMap[k] = v
}
}
out.RemotePanel = copyRemotePanelState(in.RemotePanel)
return &out
}
func copyRemotePanelState(in remotePanelState) remotePanelState {
in.CurrentLimit = copyFloat64Ptr(in.CurrentLimit)
in.Standby = copyBoolPtr(in.Standby)
return in
}
func copyFloat64Ptr(in *float64) *float64 {
if in == nil {
return nil
}
value := *in
return &value
}
func copyBoolPtr(in *bool) *bool {
if in == nil {
return nil
}
value := *in
return &value
}
func decodeJSONBody(r *http.Request, destination any) error {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
func writeJSON(rw http.ResponseWriter, statusCode int, payload any) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(statusCode)
if err := json.NewEncoder(rw).Encode(payload); err != nil {
log.Errorf("Could not encode webui API response: %v", err)
}
}

View File

@@ -36,7 +36,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/diebietse/invertergui/mk2driver" "git.coadcorp.com/nathan/invertergui/mk2driver"
) )
type templateTest struct { type templateTest struct {
@@ -91,3 +91,53 @@ func TestTemplateInput(t *testing.T) {
} }
} }
} }
func TestParsePanelMode(t *testing.T) {
tests := []struct {
name string
input string
want mk2driver.PanelSwitchState
wantRaw string
wantErr bool
}{
{
name: "on",
input: "on",
want: mk2driver.PanelSwitchOn,
wantRaw: "on",
},
{
name: "charger_only",
input: "charger_only",
want: mk2driver.PanelSwitchChargerOnly,
wantRaw: "charger_only",
},
{
name: "invalid",
input: "banana",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotRaw, err := parsePanelMode(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got switch %d, want %d", got, tt.want)
}
if gotRaw != tt.wantRaw {
t.Fatalf("got mode %q, want %q", gotRaw, tt.wantRaw)
}
})
}
}