Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e700239764 | |||
| e8153e2953 | |||
| d72e88ab7b | |||
| 7d0ce52c27 | |||
| d3df73ae0f | |||
| afc71dc507 | |||
| 22a42bfef0 | |||
| ff0c806efa | |||
| ed326eb8ef | |||
| 1b6989b5d9 | |||
| e995a252e1 | |||
| e17e4d1a0a | |||
| ee403d1e35 | |||
| bc49047499 | |||
| 1c15ff5911 | |||
| bdcb8e6f73 | |||
| 5cc8a0d7db | |||
| a31a0b4829 | |||
|
|
959d1e3c1f | ||
|
|
bda5aa9238 | ||
|
|
4e5da3f322 | ||
|
|
826576516f | ||
|
|
933c7cc307 | ||
|
|
9471f0b5ac | ||
|
|
4d774937df | ||
|
|
3702654ec3 | ||
|
|
7d63422555 | ||
|
|
fa6fc5fb34 | ||
|
|
86d865ce1b | ||
|
|
516dd6b611 | ||
|
|
df421fcccb | ||
|
|
a050189e12 | ||
|
|
7bcdc3276f | ||
|
|
9f9e25a796 | ||
|
|
c4a095a094 | ||
|
|
bf4716e8df | ||
|
|
574e832152 | ||
|
|
2a84799832 | ||
|
|
8adf3c8261 | ||
|
|
b3245aba9b | ||
|
|
dac2149fbd | ||
|
|
e501f6d125 | ||
|
|
341f26f197 | ||
|
|
5e28643a76 | ||
|
|
b35132451c | ||
|
|
6b0aeae504 | ||
|
|
950c77886a | ||
|
|
51221d37d0 | ||
|
|
d02baa4e8d | ||
|
|
41b3db0457 | ||
|
|
b6c488b7d4 | ||
|
|
c4b09b7a14 | ||
|
|
e201779341 | ||
|
|
321d19c5c2 | ||
|
|
56c417fd0e | ||
|
|
d3576031bb | ||
|
|
f6c3b38976 | ||
|
|
cc8fa9d611 | ||
|
|
49be089a23 | ||
|
|
157736a99d | ||
|
|
86f3f0c8e3 | ||
|
|
c991503e33 | ||
|
|
55ae241d92 | ||
|
|
ab346bcf90 | ||
|
|
4c6df96051 | ||
|
|
2a56dd24e4 | ||
|
|
65d9429a12 | ||
|
|
5fb5ce5f12 | ||
|
|
3f783fabf8 | ||
|
|
4f428d6bda | ||
|
|
eb737b6527 | ||
|
|
1840fae1aa | ||
|
|
934c629a41 | ||
|
|
3798783154 | ||
|
|
6ab917d35a | ||
|
|
c459fb22aa | ||
|
|
64ae21da53 | ||
|
|
67ba53fff4 | ||
|
|
4a72d24cdd | ||
|
|
92daf9191b | ||
|
|
fd49891632 | ||
|
|
e74b0518e9 | ||
|
|
0b324458f0 | ||
|
|
8b0b4f64f1 | ||
|
|
47e73a4eff | ||
|
|
d02de285d9 | ||
|
|
9236d6fa86 | ||
|
|
acdaa019cb | ||
|
|
01ce2da533 |
202
.drone.yml
Normal file
202
.drone.yml
Normal 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
6
.gitignore
vendored
@@ -22,3 +22,9 @@ _testmain.go
|
|||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.prof
|
*.prof
|
||||||
|
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Python cache files (for Home Assistant custom component)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -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
|
|
||||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
32
Dockerfile
32
Dockerfile
@@ -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
9
Dockerfile.publish.amd64
Normal 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
9
Dockerfile.publish.arm64
Normal 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
|
||||||
33
Makefile
33
Makefile
@@ -26,40 +26,37 @@
|
|||||||
#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 guimock invertergui invertercli build
|
.PHONY: test test-race vet install gofmt docker statik lint clean invertergui vendor
|
||||||
|
|
||||||
.DEFAULT_GOAL = build
|
.DEFAULT_GOAL = invertergui
|
||||||
|
|
||||||
guimock:
|
|
||||||
go build ./cmd/guimock/
|
|
||||||
|
|
||||||
invertergui:
|
invertergui:
|
||||||
go build ./cmd/invertergui/
|
go build ./cmd/invertergui/
|
||||||
|
|
||||||
invertercli:
|
|
||||||
go build ./cmd/invertercli/
|
|
||||||
|
|
||||||
build: guimock invertergui invertercli
|
|
||||||
|
|
||||||
all: build gofmt test
|
all: build gofmt test
|
||||||
|
|
||||||
gofmt:
|
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 ./guimock ./invertercli ./invertergui
|
rm ./invertergui
|
||||||
|
|
||||||
|
vendor:
|
||||||
|
go mod tidy
|
||||||
|
go mod vendor
|
||||||
|
|
||||||
|
.PHONY: upgrade-vendor
|
||||||
|
upgrade-vendor:
|
||||||
|
go get -u ./...
|
||||||
|
|||||||
479
README.md
479
README.md
@@ -1,37 +1,195 @@
|
|||||||
# Inverter GUI
|
# Inverter GUI
|
||||||
|
|
||||||
|
[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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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:
|
||||||
@@ -67,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).
|
||||||
@@ -257,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:
|
||||||
|
[](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.
|
||||||
@@ -318,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.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/diebietse/invertergui/frontend"
|
|
||||||
"github.com/diebietse/invertergui/mk2if"
|
|
||||||
"github.com/diebietse/invertergui/webgui"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
addr := flag.String("addr", ":8080", "TCP address to listen on.")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
mk2 := mk2if.NewMk2Mock()
|
|
||||||
gui := webgui.NewWebGui(mk2)
|
|
||||||
|
|
||||||
http.Handle("/", frontend.NewStatic())
|
|
||||||
http.Handle("/ws", http.HandlerFunc(gui.ServeHub))
|
|
||||||
|
|
||||||
http.Handle("/munin", http.HandlerFunc(gui.ServeMuninHTTP))
|
|
||||||
http.Handle("/muninconfig", http.HandlerFunc(gui.ServeMuninConfigHTTP))
|
|
||||||
http.Handle("/metrics", promhttp.Handler())
|
|
||||||
log.Fatal(http.ListenAndServe(*addr, nil))
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/diebietse/invertergui/mk2if"
|
|
||||||
"github.com/tarm/serial"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Basic CLI to serve as example lib usage
|
|
||||||
func main() {
|
|
||||||
//Info = log.New()
|
|
||||||
|
|
||||||
tcp := flag.Bool("tcp", false, "Use TCP instead of TTY")
|
|
||||||
ip := flag.String("ip", "localhost:8139", "IP to connect when using tcp connection.")
|
|
||||||
dev := flag.String("dev", "/dev/ttyUSB0", "TTY device to use.")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
var p io.ReadWriteCloser
|
|
||||||
var err error
|
|
||||||
var tcpAddr *net.TCPAddr
|
|
||||||
|
|
||||||
if *tcp {
|
|
||||||
tcpAddr, err = net.ResolveTCPAddr("tcp", *ip)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
p, err = net.DialTCP("tcp", nil, tcpAddr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
serialConfig := &serial.Config{Name: *dev, Baud: 2400}
|
|
||||||
p, err = serial.OpenPort(serialConfig)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer p.Close()
|
|
||||||
mk2, err := mk2if.NewMk2Connection(p)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer mk2.Close()
|
|
||||||
|
|
||||||
c := mk2.C()
|
|
||||||
sigterm := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigterm, syscall.SIGTERM, os.Interrupt)
|
|
||||||
mainloop:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case tmp := <-c:
|
|
||||||
if tmp.Valid {
|
|
||||||
PrintInfo(tmp)
|
|
||||||
}
|
|
||||||
case <-sigterm:
|
|
||||||
break mainloop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("Closing connection")
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrintInfo(info *mk2if.Mk2Info) {
|
|
||||||
out := fmt.Sprintf("Version: %v\n", info.Version)
|
|
||||||
out += fmt.Sprintf("Bat Volt: %.2fV Bat Cur: %.2fA \n", info.BatVoltage, info.BatCurrent)
|
|
||||||
out += fmt.Sprintf("In Volt: %.2fV In Cur: %.2fA In Freq %.2fHz\n", 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)
|
|
||||||
out += fmt.Sprintf("In Power %.2fW Out Power %.2fW\n", info.InVoltage*info.InCurrent, info.OutVoltage*info.OutCurrent)
|
|
||||||
out += fmt.Sprintf("Charge State: %.2f%%\n", info.ChargeState*100)
|
|
||||||
out += "LEDs state:"
|
|
||||||
for k, v := range info.LEDs {
|
|
||||||
out += fmt.Sprintf(" %s %s", mk2if.LedNames[k], mk2if.StateNames[v])
|
|
||||||
}
|
|
||||||
|
|
||||||
out += "\nErrors:"
|
|
||||||
for _, v := range info.Errors {
|
|
||||||
out += " " + v.Error()
|
|
||||||
}
|
|
||||||
out += "\n"
|
|
||||||
log.Printf("System Info: \n%v", out)
|
|
||||||
}
|
|
||||||
98
cmd/invertergui/config.go
Normal file
98
cmd/invertergui/config.go
Normal 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
|
||||||
|
}
|
||||||
107
cmd/invertergui/config_test.go
Normal file
107
cmd/invertergui/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,60 +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"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/diebietse/invertergui/frontend"
|
"git.coadcorp.com/nathan/invertergui/mk2core"
|
||||||
"github.com/diebietse/invertergui/mk2if"
|
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||||
"github.com/diebietse/invertergui/webgui"
|
"git.coadcorp.com/nathan/invertergui/plugins/cli"
|
||||||
|
"git.coadcorp.com/nathan/invertergui/plugins/mqttclient"
|
||||||
|
"git.coadcorp.com/nathan/invertergui/plugins/munin"
|
||||||
|
"git.coadcorp.com/nathan/invertergui/plugins/prometheus"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var log = logrus.WithField("ctx", "inverter-gui")
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := flag.String("addr", ":8080", "TCP address to listen on.")
|
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")
|
||||||
|
|
||||||
tcp := flag.Bool("tcp", false, "Use TCP instead of TTY")
|
mk2, err := getMk2Device(conf.Data.Source, conf.Data.Host, conf.Data.Device)
|
||||||
ip := flag.String("ip", "localhost:8139", "IP to connect when using tcp connection.")
|
if err != nil {
|
||||||
dev := flag.String("dev", "/dev/ttyUSB0", "TTY device to use.")
|
log.Fatalf("Could not open data source: %v", err)
|
||||||
flag.Parse()
|
}
|
||||||
|
defer mk2.Close()
|
||||||
|
log.Info("MK2 device connection established")
|
||||||
|
|
||||||
|
core := mk2core.NewCore(mk2)
|
||||||
|
|
||||||
|
if conf.Cli.Enabled {
|
||||||
|
log.Info("CLI plugin enabled")
|
||||||
|
cli.NewCli(core.NewSubscription())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webgui
|
||||||
|
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("/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
|
||||||
|
mu := munin.NewMunin(core.NewSubscription())
|
||||||
|
http.Handle("/munin", http.HandlerFunc(mu.ServeMuninHTTP))
|
||||||
|
http.Handle("/muninconfig", http.HandlerFunc(mu.ServeMuninConfigHTTP))
|
||||||
|
log.Info("Munin routes registered")
|
||||||
|
|
||||||
|
// Prometheus
|
||||||
|
prometheus.NewPrometheus(core.NewSubscription())
|
||||||
|
http.Handle("/metrics", promhttp.Handler())
|
||||||
|
log.Info("Prometheus route registered")
|
||||||
|
|
||||||
|
// 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, error) {
|
||||||
var p io.ReadWriteCloser
|
var p io.ReadWriteCloser
|
||||||
var err error
|
var err error
|
||||||
var tcpAddr *net.TCPAddr
|
var tcpAddr *net.TCPAddr
|
||||||
|
|
||||||
if *tcp {
|
switch source {
|
||||||
tcpAddr, err = net.ResolveTCPAddr("tcp", *ip)
|
case "serial":
|
||||||
|
log.WithField("device", dev).Info("Opening serial MK2 source")
|
||||||
|
serialConfig := &serial.Config{Name: dev, Baud: 2400}
|
||||||
|
p, err = serial.OpenPort(serialConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
return nil, err
|
||||||
|
}
|
||||||
|
case "tcp":
|
||||||
|
log.WithField("host", ip).Info("Opening TCP MK2 source")
|
||||||
|
tcpAddr, err = net.ResolveTCPAddr("tcp", ip)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
} else {
|
case "mock":
|
||||||
serialConfig := &serial.Config{Name: *dev, Baud: 2400}
|
log.Info("Using mock MK2 data source")
|
||||||
p, err = serial.OpenPort(serialConfig)
|
return mk2driver.NewMk2Mock(), nil
|
||||||
if err != nil {
|
default:
|
||||||
panic(err)
|
return nil, fmt.Errorf("Invalid source selection: %v\nUse \"serial\", \"tcp\" or \"mock\"", source)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
defer p.Close()
|
|
||||||
mk2, err := mk2if.NewMk2Connection(p)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer mk2.Close()
|
|
||||||
|
|
||||||
gui := webgui.NewWebGui(mk2)
|
mk2, err := mk2driver.NewMk2Connection(p)
|
||||||
|
if err != nil {
|
||||||
http.Handle("/", frontend.NewStatic())
|
return nil, err
|
||||||
http.Handle("/ws", http.HandlerFunc(gui.ServeHub))
|
}
|
||||||
http.Handle("/munin", http.HandlerFunc(gui.ServeMuninHTTP))
|
log.WithField("source", source).Info("MK2 connection ready")
|
||||||
http.Handle("/muninconfig", http.HandlerFunc(gui.ServeMuninConfigHTTP))
|
|
||||||
http.Handle("/metrics", promhttp.Handler())
|
return mk2, nil
|
||||||
log.Fatal(http.ListenAndServe(*addr, nil))
|
}
|
||||||
|
|
||||||
|
func parseAllowedPanelModes(raw string) (map[mk2driver.PanelSwitchState]struct{}, []string, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
46
cmd/invertergui/main_test.go
Normal file
46
cmd/invertergui/main_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
186
custom_components/victron_mk2_mqtt/__init__.py
Normal file
186
custom_components/victron_mk2_mqtt/__init__.py
Normal 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,
|
||||||
|
)
|
||||||
48
custom_components/victron_mk2_mqtt/binary_sensor.py
Normal file
48
custom_components/victron_mk2_mqtt/binary_sensor.py
Normal 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
|
||||||
48
custom_components/victron_mk2_mqtt/const.py
Normal file
48
custom_components/victron_mk2_mqtt/const.py
Normal 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,
|
||||||
|
)
|
||||||
333
custom_components/victron_mk2_mqtt/coordinator.py
Normal file
333
custom_components/victron_mk2_mqtt/coordinator.py
Normal 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
|
||||||
29
custom_components/victron_mk2_mqtt/entity.py
Normal file
29
custom_components/victron_mk2_mqtt/entity.py
Normal 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()
|
||||||
14
custom_components/victron_mk2_mqtt/manifest.json
Normal file
14
custom_components/victron_mk2_mqtt/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
135
custom_components/victron_mk2_mqtt/number.py
Normal file
135
custom_components/victron_mk2_mqtt/number.py
Normal 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)}
|
||||||
|
)
|
||||||
46
custom_components/victron_mk2_mqtt/select.py
Normal file
46
custom_components/victron_mk2_mqtt/select.py
Normal 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})
|
||||||
148
custom_components/victron_mk2_mqtt/sensor.py
Normal file
148
custom_components/victron_mk2_mqtt/sensor.py
Normal 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)
|
||||||
75
custom_components/victron_mk2_mqtt/services.yaml
Normal file
75
custom_components/victron_mk2_mqtt/services.yaml
Normal 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
|
||||||
76
custom_components/victron_mk2_mqtt/switch.py
Normal file
76
custom_components/victron_mk2_mqtt/switch.py
Normal 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})
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package frontend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/rakyll/statik/fs"
|
|
||||||
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewStatic() http.Handler {
|
|
||||||
statikFs, err := fs.New()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
return http.FileServer(statikFs)
|
|
||||||
}
|
|
||||||
12
frontend/root/css/bootstrap.min.css
vendored
12
frontend/root/css/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -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 · 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> —
|
|
||||||
<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>
|
|
||||||
@@ -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
33
go.mod
33
go.mod
@@ -1,9 +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/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
|
||||||
)
|
)
|
||||||
|
|||||||
81
go.sum
81
go.sum
@@ -1,25 +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/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||||
github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740=
|
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||||
github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
|
||||||
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE=
|
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
|
||||||
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/rakyll/statik v0.1.5 h1:Ly2UjURzxnsSYS0zI50fZ+srA+Fu7EbpV5hglvJvJG0=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/rakyll/statik v0.1.5/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs=
|
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
|
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=
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||||
"full": false,
|
"full": false,
|
||||||
"lineColor": "rgb(31, 120, 193)",
|
"lineColor": "rgb(31, 120, 193)",
|
||||||
"show": false
|
"show": true
|
||||||
},
|
},
|
||||||
"tableColumn": "",
|
"tableColumn": "",
|
||||||
"targets": [
|
"targets": [
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||||
"full": false,
|
"full": false,
|
||||||
"lineColor": "rgb(31, 120, 193)",
|
"lineColor": "rgb(31, 120, 193)",
|
||||||
"show": false
|
"show": true
|
||||||
},
|
},
|
||||||
"tableColumn": "",
|
"tableColumn": "",
|
||||||
"targets": [
|
"targets": [
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||||
"full": false,
|
"full": false,
|
||||||
"lineColor": "rgb(31, 120, 193)",
|
"lineColor": "rgb(31, 120, 193)",
|
||||||
"show": false
|
"show": true
|
||||||
},
|
},
|
||||||
"tableColumn": "",
|
"tableColumn": "",
|
||||||
"targets": [
|
"targets": [
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||||
"full": false,
|
"full": false,
|
||||||
"lineColor": "rgb(31, 120, 193)",
|
"lineColor": "rgb(31, 120, 193)",
|
||||||
"show": false
|
"show": true
|
||||||
},
|
},
|
||||||
"tableColumn": "",
|
"tableColumn": "",
|
||||||
"targets": [
|
"targets": [
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||||
"full": false,
|
"full": false,
|
||||||
"lineColor": "rgb(31, 120, 193)",
|
"lineColor": "rgb(31, 120, 193)",
|
||||||
"show": false
|
"show": true
|
||||||
},
|
},
|
||||||
"tableColumn": "",
|
"tableColumn": "",
|
||||||
"targets": [
|
"targets": [
|
||||||
@@ -501,7 +501,7 @@
|
|||||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||||
"full": false,
|
"full": false,
|
||||||
"lineColor": "rgb(31, 120, 193)",
|
"lineColor": "rgb(31, 120, 193)",
|
||||||
"show": false
|
"show": true
|
||||||
},
|
},
|
||||||
"tableColumn": "",
|
"tableColumn": "",
|
||||||
"targets": [
|
"targets": [
|
||||||
@@ -1345,5 +1345,5 @@
|
|||||||
"timezone": "",
|
"timezone": "",
|
||||||
"title": "Victron",
|
"title": "Victron",
|
||||||
"uid": "000000004",
|
"uid": "000000004",
|
||||||
"version": 1
|
"version": 2
|
||||||
}
|
}
|
||||||
132
homeassistant/dashboards/invertergui_mqtt_dashboard.yaml
Normal file
132
homeassistant/dashboards/invertergui_mqtt_dashboard.yaml
Normal 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
|
||||||
401
homeassistant/packages/invertergui_mqtt.yaml
Normal file
401
homeassistant/packages/invertergui_mqtt.yaml
Normal 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
|
||||||
64
mk2core/core.go
Normal file
64
mk2core/core.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package mk2core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = logrus.WithField("ctx", "inverter-gui-core")
|
||||||
|
|
||||||
|
type Core struct {
|
||||||
|
mk2driver.Mk2
|
||||||
|
plugins map[*subscription]bool
|
||||||
|
register chan *subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCore(m mk2driver.Mk2) *Core {
|
||||||
|
core := &Core{
|
||||||
|
Mk2: m,
|
||||||
|
register: make(chan *subscription, 255),
|
||||||
|
plugins: map[*subscription]bool{},
|
||||||
|
}
|
||||||
|
log.Info("Core initialized")
|
||||||
|
go core.run()
|
||||||
|
return core
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Core) NewSubscription() mk2driver.Mk2 {
|
||||||
|
sub := &subscription{
|
||||||
|
send: make(chan *mk2driver.Mk2Info),
|
||||||
|
}
|
||||||
|
c.register <- sub
|
||||||
|
log.Debug("New plugin subscription registered")
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Core) run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case r := <-c.register:
|
||||||
|
c.plugins[r] = true
|
||||||
|
log.WithField("subscribers", len(c.plugins)).Debug("Subscription added")
|
||||||
|
case e := <-c.C():
|
||||||
|
for plugin := range c.plugins {
|
||||||
|
select {
|
||||||
|
case plugin.send <- e:
|
||||||
|
default:
|
||||||
|
log.WithField("subscribers", len(c.plugins)).Warn("Dropping update for a slow subscriber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscription struct {
|
||||||
|
send chan *mk2driver.Mk2Info
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subscription) C() chan *mk2driver.Mk2Info {
|
||||||
|
return s.send
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subscription) Close() {
|
||||||
|
close(s.send)
|
||||||
|
}
|
||||||
232
mk2driver/managed_writer.go
Normal file
232
mk2driver/managed_writer.go
Normal 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)
|
||||||
|
}
|
||||||
97
mk2driver/managed_writer_test.go
Normal file
97
mk2driver/managed_writer_test.go
Normal 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
332
mk2driver/metadata.go
Normal 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
|
||||||
|
}
|
||||||
1788
mk2driver/mk2.go
Normal file
1788
mk2driver/mk2.go
Normal file
File diff suppressed because it is too large
Load Diff
1025
mk2driver/mk2_test.go
Normal file
1025
mk2driver/mk2_test.go
Normal file
File diff suppressed because it is too large
Load Diff
352
mk2driver/mk2interface.go
Normal file
352
mk2driver/mk2interface.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package mk2driver
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Led int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LedMain Led = iota
|
||||||
|
LedAbsorption
|
||||||
|
LedBulk
|
||||||
|
LedFloat
|
||||||
|
LedInverter
|
||||||
|
LedOverload
|
||||||
|
LedLowBattery
|
||||||
|
LedTemperature
|
||||||
|
)
|
||||||
|
|
||||||
|
var LedNames = map[Led]string{
|
||||||
|
LedTemperature: "led_over_temp",
|
||||||
|
LedLowBattery: "led_bat_low",
|
||||||
|
LedOverload: "led_overload",
|
||||||
|
LedInverter: "led_inverter",
|
||||||
|
LedFloat: "led_float",
|
||||||
|
LedBulk: "led_bulk",
|
||||||
|
LedAbsorption: "led_absorb",
|
||||||
|
LedMain: "led_mains",
|
||||||
|
}
|
||||||
|
|
||||||
|
type LEDstate int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LedOff LEDstate = iota
|
||||||
|
LedOn
|
||||||
|
LedBlink
|
||||||
|
)
|
||||||
|
|
||||||
|
var StateNames = map[LEDstate]string{
|
||||||
|
LedOff: "off",
|
||||||
|
LedOn: "on",
|
||||||
|
LedBlink: "blink",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mk2Info struct {
|
||||||
|
// Will be marked as false if an error is detected.
|
||||||
|
Valid bool
|
||||||
|
|
||||||
|
Version uint32
|
||||||
|
|
||||||
|
BatVoltage float64
|
||||||
|
// Positive current == charging
|
||||||
|
// Negative current == discharging
|
||||||
|
BatCurrent float64
|
||||||
|
|
||||||
|
// Input AC parameters
|
||||||
|
InVoltage float64
|
||||||
|
InCurrent float64
|
||||||
|
InFrequency float64
|
||||||
|
|
||||||
|
// Output AC parameters
|
||||||
|
OutVoltage float64
|
||||||
|
OutCurrent float64
|
||||||
|
OutFrequency float64
|
||||||
|
|
||||||
|
// Charge state 0.0 to 1.0
|
||||||
|
ChargeState float64
|
||||||
|
|
||||||
|
// List LEDs
|
||||||
|
LEDs map[Led]LEDstate
|
||||||
|
|
||||||
|
Errors []error
|
||||||
|
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mk2 interface {
|
||||||
|
C() chan *Mk2Info
|
||||||
|
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
|
||||||
|
}
|
||||||
142
mk2driver/mockmk2.go
Normal file
142
mk2driver/mockmk2.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package mk2driver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mock struct {
|
||||||
|
c chan *Mk2Info
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ProtocolControl = (*mock)(nil)
|
||||||
|
|
||||||
|
func NewMk2Mock() Mk2 {
|
||||||
|
tmp := &mock{
|
||||||
|
c: make(chan *Mk2Info, 1),
|
||||||
|
}
|
||||||
|
go tmp.genMockValues()
|
||||||
|
return tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
func genBaseLeds(state LEDstate) map[Led]LEDstate {
|
||||||
|
return map[Led]LEDstate{
|
||||||
|
LedMain: state,
|
||||||
|
LedAbsorption: state,
|
||||||
|
LedBulk: state,
|
||||||
|
LedFloat: state,
|
||||||
|
LedInverter: state,
|
||||||
|
LedOverload: state,
|
||||||
|
LedLowBattery: state,
|
||||||
|
LedTemperature: state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mock) C() chan *Mk2Info {
|
||||||
|
return m.c
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
mult := 1.0
|
||||||
|
ledState := LedOff
|
||||||
|
for {
|
||||||
|
input := &Mk2Info{
|
||||||
|
OutCurrent: 2.0 * mult,
|
||||||
|
InCurrent: 2.3 * mult,
|
||||||
|
OutVoltage: 230.0 * mult,
|
||||||
|
InVoltage: 230.1 * mult,
|
||||||
|
BatVoltage: 25 * mult,
|
||||||
|
BatCurrent: -10 * mult,
|
||||||
|
InFrequency: 50 * mult,
|
||||||
|
OutFrequency: 50 * mult,
|
||||||
|
ChargeState: 1 * mult,
|
||||||
|
Errors: nil,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Valid: true,
|
||||||
|
LEDs: genBaseLeds(ledState),
|
||||||
|
}
|
||||||
|
|
||||||
|
ledState = (ledState + 1) % 3
|
||||||
|
|
||||||
|
mult = mult - 0.1
|
||||||
|
if mult < 0 {
|
||||||
|
mult = 1.0
|
||||||
|
}
|
||||||
|
m.c <- input
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
336
mk2if/mk2.go
336
mk2if/mk2.go
@@ -1,336 +0,0 @@
|
|||||||
package mk2if
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type scaling struct {
|
|
||||||
scale float64
|
|
||||||
offset float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type mk2Ser struct {
|
|
||||||
info *Mk2Info
|
|
||||||
p io.ReadWriter
|
|
||||||
scales []scaling
|
|
||||||
scaleCount int
|
|
||||||
run chan struct{}
|
|
||||||
frameLock bool
|
|
||||||
infochan chan *Mk2Info
|
|
||||||
wg sync.WaitGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMk2Connection(dev io.ReadWriter) (Mk2If, error) {
|
|
||||||
mk2 := &mk2Ser{}
|
|
||||||
mk2.p = dev
|
|
||||||
mk2.info = &Mk2Info{}
|
|
||||||
mk2.scaleCount = 0
|
|
||||||
mk2.frameLock = false
|
|
||||||
mk2.scales = make([]scaling, 0, 14)
|
|
||||||
mk2.setTarget()
|
|
||||||
mk2.run = make(chan struct{})
|
|
||||||
mk2.infochan = make(chan *Mk2Info)
|
|
||||||
mk2.wg.Add(1)
|
|
||||||
go mk2.frameLocker()
|
|
||||||
return mk2, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locks to incoming frame.
|
|
||||||
func (m *mk2Ser) frameLocker() {
|
|
||||||
|
|
||||||
frame := make([]byte, 256)
|
|
||||||
var size byte
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.run:
|
|
||||||
m.wg.Done()
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if m.frameLock {
|
|
||||||
size = m.readByte()
|
|
||||||
l, err := io.ReadFull(m.p, frame[0:int(size)+1])
|
|
||||||
if err != nil {
|
|
||||||
m.addError(fmt.Errorf("Read Error: %v", err))
|
|
||||||
m.frameLock = false
|
|
||||||
} else if l != int(size)+1 {
|
|
||||||
m.addError(errors.New("Read Length Error"))
|
|
||||||
m.frameLock = false
|
|
||||||
} else {
|
|
||||||
m.handleFrame(size, frame[0:int(size+1)])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tmp := m.readByte()
|
|
||||||
if tmp == 0xff || tmp == 0x20 {
|
|
||||||
l, err := io.ReadFull(m.p, frame[0:int(size)])
|
|
||||||
if err != nil {
|
|
||||||
m.addError(fmt.Errorf("Read Error: %v", err))
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
} else if l != int(size) {
|
|
||||||
m.addError(errors.New("Read Length Error"))
|
|
||||||
} else {
|
|
||||||
if checkChecksum(size, tmp, frame[0:int(size)]) {
|
|
||||||
m.frameLock = true
|
|
||||||
log.Printf("Locked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
size = tmp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close Mk2
|
|
||||||
func (m *mk2Ser) Close() {
|
|
||||||
close(m.run)
|
|
||||||
m.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mk2Ser) C() chan *Mk2Info {
|
|
||||||
return m.infochan
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mk2Ser) readByte() byte {
|
|
||||||
buffer := make([]byte, 1)
|
|
||||||
_, err := io.ReadFull(m.p, buffer)
|
|
||||||
if err != nil {
|
|
||||||
m.addError(fmt.Errorf("Read error: %v", err))
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return buffer[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds error to error slice.
|
|
||||||
func (m *mk2Ser) addError(err error) {
|
|
||||||
if m.info.Errors == nil {
|
|
||||||
m.info.Errors = make([]error, 0)
|
|
||||||
}
|
|
||||||
m.info.Errors = append(m.info.Errors, err)
|
|
||||||
m.info.Valid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates report.
|
|
||||||
func (m *mk2Ser) updateReport() {
|
|
||||||
m.info.Timestamp = time.Now()
|
|
||||||
select {
|
|
||||||
case m.infochan <- m.info:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
m.info = &Mk2Info{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks for valid frame and chooses decoding.
|
|
||||||
func (m *mk2Ser) handleFrame(l byte, frame []byte) {
|
|
||||||
if checkChecksum(l, frame[0], frame[1:]) {
|
|
||||||
switch frame[0] {
|
|
||||||
case 0xff:
|
|
||||||
switch frame[1] {
|
|
||||||
case 0x56: // V
|
|
||||||
m.versionDecode(frame[2:])
|
|
||||||
case 0x57:
|
|
||||||
switch frame[2] {
|
|
||||||
case 0x8e:
|
|
||||||
m.scaleDecode(frame[2:])
|
|
||||||
case 0x85:
|
|
||||||
m.stateDecode(frame[2:])
|
|
||||||
}
|
|
||||||
|
|
||||||
case 0x4C: // L
|
|
||||||
m.ledDecode(frame[2:])
|
|
||||||
}
|
|
||||||
|
|
||||||
case 0x20:
|
|
||||||
switch frame[5] {
|
|
||||||
case 0x0C:
|
|
||||||
m.dcDecode(frame[1:])
|
|
||||||
case 0x08:
|
|
||||||
m.acDecode(frame[1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Printf("Invalid incoming frame checksum: %x", frame)
|
|
||||||
m.frameLock = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the target VBus device.
|
|
||||||
func (m *mk2Ser) setTarget() {
|
|
||||||
cmd := make([]byte, 3)
|
|
||||||
cmd[0] = 0x41 // A
|
|
||||||
cmd[1] = 0x01
|
|
||||||
cmd[2] = 0x00
|
|
||||||
m.sendCommand(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request the scaling factor for entry 'in'.
|
|
||||||
func (m *mk2Ser) reqScaleFactor(in byte) {
|
|
||||||
cmd := make([]byte, 4)
|
|
||||||
cmd[0] = 0x57 // W
|
|
||||||
cmd[1] = 0x36
|
|
||||||
cmd[2] = in
|
|
||||||
m.sendCommand(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode the scale factor frame.
|
|
||||||
func (m *mk2Ser) scaleDecode(frame []byte) {
|
|
||||||
scl := uint16(frame[2])<<8 + uint16(frame[1])
|
|
||||||
ofs := int16(uint16(frame[5])<<8 + uint16(frame[4]))
|
|
||||||
|
|
||||||
tmp := scaling{}
|
|
||||||
tmp.offset = float64(ofs)
|
|
||||||
if scl >= 0x4000 {
|
|
||||||
tmp.scale = math.Abs(1 / (0x8000 - float64(scl)))
|
|
||||||
} else {
|
|
||||||
tmp.scale = math.Abs(float64(scl))
|
|
||||||
}
|
|
||||||
m.scales = append(m.scales, tmp)
|
|
||||||
|
|
||||||
m.scaleCount++
|
|
||||||
if m.scaleCount < 14 {
|
|
||||||
m.reqScaleFactor(byte(m.scaleCount))
|
|
||||||
} else {
|
|
||||||
log.Print("Monitoring starting.")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode the version number
|
|
||||||
func (m *mk2Ser) versionDecode(frame []byte) {
|
|
||||||
m.info.Version = 0
|
|
||||||
m.info.Valid = true
|
|
||||||
for i := 0; i < 4; i++ {
|
|
||||||
m.info.Version += uint32(frame[i]) << uint(i) * 8
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.scaleCount < 14 {
|
|
||||||
log.Print("Get scaling factors.")
|
|
||||||
m.reqScaleFactor(byte(m.scaleCount))
|
|
||||||
} else {
|
|
||||||
// Send DC status request
|
|
||||||
cmd := make([]byte, 2)
|
|
||||||
cmd[0] = 0x46 //F
|
|
||||||
cmd[1] = 0
|
|
||||||
m.sendCommand(cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply scaling to float
|
|
||||||
func (m *mk2Ser) applyScale(value float64, scale int) float64 {
|
|
||||||
return m.scales[scale].scale * (value + m.scales[scale].offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert bytes->int16->float
|
|
||||||
func getSigned(data []byte) float64 {
|
|
||||||
return float64(int16(data[0]) + int16(data[1])<<8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert bytes->uint32->float
|
|
||||||
func getUnsigned(data []byte) float64 {
|
|
||||||
return float64(uint32(data[0]) + uint32(data[1])<<8 + uint32(data[2])<<16)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decodes DC frame.
|
|
||||||
func (m *mk2Ser) dcDecode(frame []byte) {
|
|
||||||
m.info.BatVoltage = m.applyScale(getSigned(frame[5:7]), 4)
|
|
||||||
|
|
||||||
usedC := m.applyScale(getUnsigned(frame[7:10]), 5)
|
|
||||||
chargeC := m.applyScale(getUnsigned(frame[10:13]), 5)
|
|
||||||
m.info.BatCurrent = usedC - chargeC
|
|
||||||
|
|
||||||
m.info.OutFrequency = 10 / (m.applyScale(float64(frame[13]), 7))
|
|
||||||
|
|
||||||
// Send L1 status request
|
|
||||||
cmd := make([]byte, 2)
|
|
||||||
cmd[0] = 0x46 //F
|
|
||||||
cmd[1] = 1
|
|
||||||
m.sendCommand(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decodes AC frame.
|
|
||||||
func (m *mk2Ser) acDecode(frame []byte) {
|
|
||||||
m.info.InVoltage = m.applyScale(getSigned(frame[5:7]), 0)
|
|
||||||
m.info.InCurrent = m.applyScale(getSigned(frame[7:9]), 1)
|
|
||||||
m.info.OutVoltage = m.applyScale(getSigned(frame[9:11]), 2)
|
|
||||||
m.info.OutCurrent = m.applyScale(getSigned(frame[11:13]), 3)
|
|
||||||
|
|
||||||
if frame[13] == 0xff {
|
|
||||||
m.info.InFrequency = 0
|
|
||||||
} else {
|
|
||||||
m.info.InFrequency = 10 / (m.applyScale(float64(frame[13]), 8))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send status request
|
|
||||||
cmd := make([]byte, 1)
|
|
||||||
cmd[0] = 0x4C //F
|
|
||||||
m.sendCommand(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode charge state of battery.
|
|
||||||
func (m *mk2Ser) stateDecode(frame []byte) {
|
|
||||||
m.info.ChargeState = m.applyScale(getSigned(frame[1:3]), 13)
|
|
||||||
m.updateReport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode the LED state frame.
|
|
||||||
func (m *mk2Ser) ledDecode(frame []byte) {
|
|
||||||
|
|
||||||
m.info.LEDs = getLEDs(frame[0], frame[1])
|
|
||||||
// Send charge state request
|
|
||||||
cmd := make([]byte, 4)
|
|
||||||
cmd[0] = 0x57 //W
|
|
||||||
cmd[1] = 0x30
|
|
||||||
cmd[2] = 13
|
|
||||||
m.sendCommand(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds active LEDs to list.
|
|
||||||
func getLEDs(ledsOn, ledsBlink byte) map[Led]LEDstate {
|
|
||||||
|
|
||||||
leds := map[Led]LEDstate{}
|
|
||||||
for i := 0; i < 8; i++ {
|
|
||||||
on := (ledsOn >> uint(i)) & 1
|
|
||||||
blink := (ledsBlink >> uint(i)) & 1
|
|
||||||
if on == 1 {
|
|
||||||
leds[Led(i)] = LedOn
|
|
||||||
} else if blink == 1 {
|
|
||||||
leds[Led(i)] = LedBlink
|
|
||||||
} else {
|
|
||||||
leds[Led(i)] = LedOff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return leds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds header and trailing crc for frame to send.
|
|
||||||
func (m *mk2Ser) sendCommand(data []byte) {
|
|
||||||
l := len(data)
|
|
||||||
dataOut := make([]byte, l+3)
|
|
||||||
dataOut[0] = byte(l + 1)
|
|
||||||
dataOut[1] = 0xff
|
|
||||||
cr := -dataOut[0] - dataOut[1]
|
|
||||||
for i := 0; i < len(data); i++ {
|
|
||||||
cr = cr - data[i]
|
|
||||||
dataOut[i+2] = data[i]
|
|
||||||
}
|
|
||||||
dataOut[l+2] = cr
|
|
||||||
|
|
||||||
_, err := m.p.Write(dataOut)
|
|
||||||
if err != nil {
|
|
||||||
m.addError(fmt.Errorf("Write error: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks the frame crc.
|
|
||||||
func checkChecksum(l, t byte, d []byte) bool {
|
|
||||||
cr := (uint16(l) + uint16(t)) % 256
|
|
||||||
for i := 0; i < len(d); i++ {
|
|
||||||
cr = (cr + uint16(d[i])) % 256
|
|
||||||
}
|
|
||||||
return cr == 0
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package mk2if
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Led int
|
|
||||||
|
|
||||||
const (
|
|
||||||
LedMain Led = iota
|
|
||||||
LedAbsorption
|
|
||||||
LedBulk
|
|
||||||
LedFloat
|
|
||||||
LedInverter
|
|
||||||
LedOverload
|
|
||||||
LedLowBattery
|
|
||||||
LedTemperature
|
|
||||||
)
|
|
||||||
|
|
||||||
var LedNames = map[Led]string{
|
|
||||||
LedTemperature: "led_over_temp",
|
|
||||||
LedLowBattery: "led_bat_low",
|
|
||||||
LedOverload: "led_overload",
|
|
||||||
LedInverter: "led_inverter",
|
|
||||||
LedFloat: "led_float",
|
|
||||||
LedBulk: "led_bulk",
|
|
||||||
LedAbsorption: "led_absorb",
|
|
||||||
LedMain: "led_mains",
|
|
||||||
}
|
|
||||||
|
|
||||||
type LEDstate int
|
|
||||||
|
|
||||||
const (
|
|
||||||
LedOff LEDstate = iota
|
|
||||||
LedOn
|
|
||||||
LedBlink
|
|
||||||
)
|
|
||||||
|
|
||||||
var StateNames = map[LEDstate]string{
|
|
||||||
LedOff: "off",
|
|
||||||
LedOn: "on",
|
|
||||||
LedBlink: "blink",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mk2Info struct {
|
|
||||||
// Will be marked as false if an error is detected.
|
|
||||||
Valid bool
|
|
||||||
|
|
||||||
Version uint32
|
|
||||||
|
|
||||||
BatVoltage float64
|
|
||||||
// Positive current == charging
|
|
||||||
// Negative current == discharging
|
|
||||||
BatCurrent float64
|
|
||||||
|
|
||||||
// Input AC parameters
|
|
||||||
InVoltage float64
|
|
||||||
InCurrent float64
|
|
||||||
InFrequency float64
|
|
||||||
|
|
||||||
// Output AC parameters
|
|
||||||
OutVoltage float64
|
|
||||||
OutCurrent float64
|
|
||||||
OutFrequency float64
|
|
||||||
|
|
||||||
// Charge state 0.0 to 1.0
|
|
||||||
ChargeState float64
|
|
||||||
|
|
||||||
// List LEDs
|
|
||||||
LEDs map[Led]LEDstate
|
|
||||||
|
|
||||||
Errors []error
|
|
||||||
|
|
||||||
Timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mk2If interface {
|
|
||||||
C() chan *Mk2Info
|
|
||||||
Close()
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package mk2if
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mock struct {
|
|
||||||
c chan *Mk2Info
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMk2Mock() Mk2If {
|
|
||||||
tmp := &mock{
|
|
||||||
c: make(chan *Mk2Info, 1),
|
|
||||||
}
|
|
||||||
go tmp.genMockValues()
|
|
||||||
return tmp
|
|
||||||
}
|
|
||||||
|
|
||||||
func genBaseLeds(state LEDstate) map[Led]LEDstate {
|
|
||||||
return map[Led]LEDstate{
|
|
||||||
LedMain: state,
|
|
||||||
LedAbsorption: state,
|
|
||||||
LedBulk: state,
|
|
||||||
LedFloat: state,
|
|
||||||
LedInverter: state,
|
|
||||||
LedOverload: state,
|
|
||||||
LedLowBattery: state,
|
|
||||||
LedTemperature: state,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mock) C() chan *Mk2Info {
|
|
||||||
return m.c
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mock) Close() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mock) genMockValues() {
|
|
||||||
mult := 1.0
|
|
||||||
ledState := LedOff
|
|
||||||
for {
|
|
||||||
input := &Mk2Info{
|
|
||||||
OutCurrent: 2.0 * mult,
|
|
||||||
InCurrent: 2.3 * mult,
|
|
||||||
OutVoltage: 230.0 * mult,
|
|
||||||
InVoltage: 230.1 * mult,
|
|
||||||
BatVoltage: 25 * mult,
|
|
||||||
BatCurrent: -10 * mult,
|
|
||||||
InFrequency: 50 * mult,
|
|
||||||
OutFrequency: 50 * mult,
|
|
||||||
ChargeState: 1 * mult,
|
|
||||||
Errors: nil,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Valid: true,
|
|
||||||
LEDs: genBaseLeds(ledState),
|
|
||||||
}
|
|
||||||
|
|
||||||
ledState = (ledState + 1) % 3
|
|
||||||
|
|
||||||
mult = mult - 0.1
|
|
||||||
if mult < 0 {
|
|
||||||
mult = 1.0
|
|
||||||
}
|
|
||||||
fmt.Printf("Sending\n")
|
|
||||||
m.c <- input
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
47
plugins/cli/cli.go
Normal file
47
plugins/cli/cli.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = logrus.WithField("ctx", "inverter-gui-cli")
|
||||||
|
|
||||||
|
type Cli struct {
|
||||||
|
mk2driver.Mk2
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCli(mk2 mk2driver.Mk2) {
|
||||||
|
newCli := &Cli{
|
||||||
|
Mk2: mk2,
|
||||||
|
}
|
||||||
|
go newCli.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cli) run() {
|
||||||
|
for e := range c.C() {
|
||||||
|
if e.Valid {
|
||||||
|
printInfo(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printInfo(info *mk2driver.Mk2Info) {
|
||||||
|
log.Infof("Version: %v", info.Version)
|
||||||
|
log.Infof("Bat Volt: %.2fV Bat Cur: %.2fA", info.BatVoltage, info.BatCurrent)
|
||||||
|
log.Infof("In Volt: %.2fV In Cur: %.2fA In Freq %.2fHz", info.InVoltage, info.InCurrent, info.InFrequency)
|
||||||
|
log.Infof("Out Volt: %.2fV Out Cur: %.2fA Out Freq %.2fHz", info.OutVoltage, info.OutCurrent, info.OutFrequency)
|
||||||
|
log.Infof("In Power %.2fW Out Power %.2fW", info.InVoltage*info.InCurrent, info.OutVoltage*info.OutCurrent)
|
||||||
|
log.Infof("Charge State: %.2f%%", info.ChargeState*100)
|
||||||
|
log.Info("LEDs state:")
|
||||||
|
for k, v := range info.LEDs {
|
||||||
|
log.Infof(" %s %s", mk2driver.LedNames[k], mk2driver.StateNames[v])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(info.Errors) != 0 {
|
||||||
|
log.Info("Errors:")
|
||||||
|
for _, err := range info.Errors {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2480
plugins/mqttclient/mqtt.go
Normal file
2480
plugins/mqttclient/mqtt.go
Normal file
File diff suppressed because it is too large
Load Diff
503
plugins/mqttclient/mqtt_test.go
Normal file
503
plugins/mqttclient/mqtt_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
218
plugins/munin/munin.go
Normal file
218
plugins/munin/munin.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2015, Hendrik van Wyk
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of invertergui nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package munin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = logrus.WithField("ctx", "inverter-gui-munin")
|
||||||
|
|
||||||
|
type Munin struct {
|
||||||
|
mk2driver.Mk2
|
||||||
|
muninResponse chan muninData
|
||||||
|
}
|
||||||
|
|
||||||
|
type muninData struct {
|
||||||
|
status mk2driver.Mk2Info
|
||||||
|
timesUpdated int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMunin(mk2 mk2driver.Mk2) *Munin {
|
||||||
|
m := &Munin{
|
||||||
|
Mk2: mk2,
|
||||||
|
muninResponse: make(chan muninData),
|
||||||
|
}
|
||||||
|
|
||||||
|
go m.run()
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Munin) ServeMuninHTTP(rw http.ResponseWriter, _ *http.Request) {
|
||||||
|
muninDat := <-m.muninResponse
|
||||||
|
if muninDat.timesUpdated == 0 {
|
||||||
|
log.Error("No data returned")
|
||||||
|
rw.WriteHeader(500)
|
||||||
|
_, _ = rw.Write([]byte("No data to return.\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
calcMuninAverages(&muninDat)
|
||||||
|
|
||||||
|
status := muninDat.status
|
||||||
|
tmpInput := buildTemplateInput(&status)
|
||||||
|
outputBuf := &bytes.Buffer{}
|
||||||
|
fmt.Fprintf(outputBuf, "multigraph in_batvolt\n")
|
||||||
|
fmt.Fprintf(outputBuf, "volt.value %s\n", tmpInput.BatVoltage)
|
||||||
|
fmt.Fprintf(outputBuf, "multigraph in_batcharge\n")
|
||||||
|
fmt.Fprintf(outputBuf, "charge.value %s\n", tmpInput.BatCharge)
|
||||||
|
fmt.Fprintf(outputBuf, "multigraph in_batcurrent\n")
|
||||||
|
fmt.Fprintf(outputBuf, "current.value %s\n", tmpInput.BatCurrent)
|
||||||
|
fmt.Fprintf(outputBuf, "multigraph in_batpower\n")
|
||||||
|
fmt.Fprintf(outputBuf, "power.value %s\n", tmpInput.BatPower)
|
||||||
|
fmt.Fprintf(outputBuf, "multigraph in_mainscurrent\n")
|
||||||
|
fmt.Fprintf(outputBuf, "currentin.value %s\n", tmpInput.InCurrent)
|
||||||
|
fmt.Fprintf(outputBuf, "currentout.value %s\n", tmpInput.OutCurrent)
|
||||||
|
fmt.Fprintf(outputBuf, "multigraph in_mainsvoltage\n")
|
||||||
|
fmt.Fprintf(outputBuf, "voltagein.value %s\n", tmpInput.InVoltage)
|
||||||
|
fmt.Fprintf(outputBuf, "voltageout.value %s\n", tmpInput.OutVoltage)
|
||||||
|
fmt.Fprintf(outputBuf, "multigraph in_mainspower\n")
|
||||||
|
fmt.Fprintf(outputBuf, "powerin.value %s\n", tmpInput.InPower)
|
||||||
|
fmt.Fprintf(outputBuf, "powerout.value %s\n", tmpInput.OutPower)
|
||||||
|
fmt.Fprintf(outputBuf, "multigraph in_mainsfreq\n")
|
||||||
|
fmt.Fprintf(outputBuf, "freqin.value %s\n", tmpInput.InFreq)
|
||||||
|
fmt.Fprintf(outputBuf, "freqout.value %s\n", tmpInput.OutFreq)
|
||||||
|
|
||||||
|
_, err := rw.Write(outputBuf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Could not write data response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Munin) ServeMuninConfigHTTP(rw http.ResponseWriter, _ *http.Request) {
|
||||||
|
output := muninConfig
|
||||||
|
_, err := rw.Write([]byte(output))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Could not write config response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Munin) run() {
|
||||||
|
muninValues := &muninData{
|
||||||
|
status: mk2driver.Mk2Info{},
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case e := <-m.C():
|
||||||
|
if e.Valid {
|
||||||
|
calcMuninValues(muninValues, e)
|
||||||
|
}
|
||||||
|
case m.muninResponse <- *muninValues:
|
||||||
|
zeroMuninValues(muninValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Munin only samples once every 5 minutes so averages have to be calculated for some values.
|
||||||
|
func calcMuninValues(m *muninData, newStatus *mk2driver.Mk2Info) {
|
||||||
|
m.timesUpdated++
|
||||||
|
m.status.OutCurrent += newStatus.OutCurrent
|
||||||
|
m.status.InCurrent += newStatus.InCurrent
|
||||||
|
m.status.BatCurrent += newStatus.BatCurrent
|
||||||
|
|
||||||
|
m.status.OutVoltage += newStatus.OutVoltage
|
||||||
|
m.status.InVoltage += newStatus.InVoltage
|
||||||
|
m.status.BatVoltage += newStatus.BatVoltage
|
||||||
|
|
||||||
|
m.status.InFrequency = newStatus.InFrequency
|
||||||
|
m.status.OutFrequency = newStatus.OutFrequency
|
||||||
|
|
||||||
|
m.status.ChargeState = newStatus.ChargeState
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcMuninAverages(m *muninData) {
|
||||||
|
m.status.OutCurrent /= float64(m.timesUpdated)
|
||||||
|
m.status.InCurrent /= float64(m.timesUpdated)
|
||||||
|
m.status.BatCurrent /= float64(m.timesUpdated)
|
||||||
|
|
||||||
|
m.status.OutVoltage /= float64(m.timesUpdated)
|
||||||
|
m.status.InVoltage /= float64(m.timesUpdated)
|
||||||
|
m.status.BatVoltage /= float64(m.timesUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func zeroMuninValues(m *muninData) {
|
||||||
|
m.timesUpdated = 0
|
||||||
|
m.status.OutCurrent = 0
|
||||||
|
m.status.InCurrent = 0
|
||||||
|
m.status.BatCurrent = 0
|
||||||
|
|
||||||
|
m.status.OutVoltage = 0
|
||||||
|
m.status.InVoltage = 0
|
||||||
|
m.status.BatVoltage = 0
|
||||||
|
|
||||||
|
m.status.InFrequency = 0
|
||||||
|
m.status.OutFrequency = 0
|
||||||
|
|
||||||
|
m.status.ChargeState = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateInput struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
|
||||||
|
OutCurrent string `json:"output_current"`
|
||||||
|
OutVoltage string `json:"output_voltage"`
|
||||||
|
OutPower string `json:"output_power"`
|
||||||
|
|
||||||
|
InCurrent string `json:"input_current"`
|
||||||
|
InVoltage string `json:"input_voltage"`
|
||||||
|
InPower string `json:"input_power"`
|
||||||
|
|
||||||
|
InMinOut string
|
||||||
|
|
||||||
|
BatVoltage string `json:"battery_voltage"`
|
||||||
|
BatCurrent string `json:"battery_current"`
|
||||||
|
BatPower string `json:"battery_power"`
|
||||||
|
BatCharge string `json:"battery_charge"`
|
||||||
|
|
||||||
|
InFreq string `json:"input_frequency"`
|
||||||
|
OutFreq string `json:"output_frequency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTemplateInput(status *mk2driver.Mk2Info) *templateInput {
|
||||||
|
outPower := status.OutVoltage * status.OutCurrent
|
||||||
|
inPower := status.InCurrent * status.InVoltage
|
||||||
|
|
||||||
|
newInput := &templateInput{
|
||||||
|
Date: status.Timestamp.Format(time.RFC1123Z),
|
||||||
|
OutCurrent: fmt.Sprintf("%.2f", status.OutCurrent),
|
||||||
|
OutVoltage: fmt.Sprintf("%.2f", status.OutVoltage),
|
||||||
|
OutPower: fmt.Sprintf("%.2f", outPower),
|
||||||
|
InCurrent: fmt.Sprintf("%.2f", status.InCurrent),
|
||||||
|
InVoltage: fmt.Sprintf("%.2f", status.InVoltage),
|
||||||
|
InFreq: fmt.Sprintf("%.2f", status.InFrequency),
|
||||||
|
OutFreq: fmt.Sprintf("%.2f", status.OutFrequency),
|
||||||
|
InPower: fmt.Sprintf("%.2f", inPower),
|
||||||
|
|
||||||
|
InMinOut: fmt.Sprintf("%.2f", inPower-outPower),
|
||||||
|
|
||||||
|
BatCurrent: fmt.Sprintf("%.2f", status.BatCurrent),
|
||||||
|
BatVoltage: fmt.Sprintf("%.2f", status.BatVoltage),
|
||||||
|
BatPower: fmt.Sprintf("%.2f", status.BatVoltage*status.BatCurrent),
|
||||||
|
BatCharge: fmt.Sprintf("%.2f", status.ChargeState*100),
|
||||||
|
}
|
||||||
|
return newInput
|
||||||
|
}
|
||||||
29
plugins/munin/munin_test.go
Normal file
29
plugins/munin/munin_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
plugins/munin/muninconfig.go
Normal file
82
plugins/munin/muninconfig.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package munin
|
||||||
|
|
||||||
|
const muninConfig = `multigraph in_batvolt
|
||||||
|
graph_title Battery Voltage
|
||||||
|
graph_vlabel Voltage (V)
|
||||||
|
graph_category inverter
|
||||||
|
graph_info Battery voltage
|
||||||
|
|
||||||
|
volt.info Voltage of battery
|
||||||
|
volt.label Voltage of battery (V)
|
||||||
|
|
||||||
|
multigraph in_batcharge
|
||||||
|
graph_title Battery Charge
|
||||||
|
graph_vlabel Charge (%)
|
||||||
|
graph_category inverter
|
||||||
|
graph_info Battery charge
|
||||||
|
|
||||||
|
charge.info Estimated charge of battery
|
||||||
|
charge.label Battery charge (%)
|
||||||
|
|
||||||
|
multigraph in_batcurrent
|
||||||
|
graph_title Battery Current
|
||||||
|
graph_vlabel Current (A)
|
||||||
|
graph_category inverter
|
||||||
|
graph_info Battery current
|
||||||
|
|
||||||
|
current.info Battery current
|
||||||
|
current.label Battery current (A)
|
||||||
|
|
||||||
|
multigraph in_batpower
|
||||||
|
graph_title Battery Power
|
||||||
|
graph_vlabel Power (W)
|
||||||
|
graph_category inverter
|
||||||
|
graph_info Battery power
|
||||||
|
|
||||||
|
power.info Battery power
|
||||||
|
power.label Battery power (W)
|
||||||
|
|
||||||
|
multigraph in_mainscurrent
|
||||||
|
graph_title Mains Current
|
||||||
|
graph_vlabel Current (A)
|
||||||
|
graph_category inverter
|
||||||
|
graph_info Mains current
|
||||||
|
|
||||||
|
currentin.info Input current
|
||||||
|
currentin.label Input current (A)
|
||||||
|
currentout.info Output current
|
||||||
|
currentout.label Output current (A)
|
||||||
|
|
||||||
|
multigraph in_mainsvoltage
|
||||||
|
graph_title Mains Voltage
|
||||||
|
graph_vlabel Voltage (V)
|
||||||
|
graph_category inverter
|
||||||
|
graph_info Mains voltage
|
||||||
|
|
||||||
|
voltagein.info Input voltage
|
||||||
|
voltagein.label Input voltage (V)
|
||||||
|
voltageout.info Output voltage
|
||||||
|
voltageout.label Output voltage (V)
|
||||||
|
|
||||||
|
multigraph in_mainspower
|
||||||
|
graph_title Mains Power
|
||||||
|
graph_vlabel Power (VA)
|
||||||
|
graph_category inverter
|
||||||
|
graph_info Mains power
|
||||||
|
|
||||||
|
powerin.info Input power
|
||||||
|
powerin.label Input power (VA)
|
||||||
|
powerout.info Output power
|
||||||
|
powerout.label Output power (VA)
|
||||||
|
|
||||||
|
multigraph in_mainsfreq
|
||||||
|
graph_title Mains frequency
|
||||||
|
graph_vlabel Frequency (Hz)
|
||||||
|
graph_category inverter
|
||||||
|
graph_info Mains frequency
|
||||||
|
|
||||||
|
freqin.info In frequency
|
||||||
|
freqin.label In frequency (Hz)
|
||||||
|
freqout.info Out frequency
|
||||||
|
freqout.label Out frequency (Hz)
|
||||||
|
`
|
||||||
@@ -28,14 +28,15 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package webgui
|
package prometheus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/diebietse/invertergui/mk2if"
|
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type prometheusUpdater struct {
|
type Prometheus struct {
|
||||||
|
mk2driver.Mk2
|
||||||
batteryVoltage prometheus.Gauge
|
batteryVoltage prometheus.Gauge
|
||||||
batteryCharge prometheus.Gauge
|
batteryCharge prometheus.Gauge
|
||||||
batteryCurrent prometheus.Gauge
|
batteryCurrent prometheus.Gauge
|
||||||
@@ -50,8 +51,9 @@ type prometheusUpdater struct {
|
|||||||
mainsFreqOut prometheus.Gauge
|
mainsFreqOut prometheus.Gauge
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPrometheusUpdater() *prometheusUpdater {
|
func NewPrometheus(mk2 mk2driver.Mk2) {
|
||||||
tmp := &prometheusUpdater{
|
tmp := &Prometheus{
|
||||||
|
Mk2: mk2,
|
||||||
batteryVoltage: prometheus.NewGauge(prometheus.GaugeOpts{
|
batteryVoltage: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "battery_voltage_v",
|
Name: "battery_voltage_v",
|
||||||
Help: "Voltage of the battery.",
|
Help: "Voltage of the battery.",
|
||||||
@@ -101,7 +103,8 @@ func newPrometheusUpdater() *prometheusUpdater {
|
|||||||
Help: "Mains frequency at inverter output",
|
Help: "Mains frequency at inverter output",
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
prometheus.MustRegister(tmp.batteryVoltage,
|
prometheus.MustRegister(
|
||||||
|
tmp.batteryVoltage,
|
||||||
tmp.batteryCharge,
|
tmp.batteryCharge,
|
||||||
tmp.batteryCurrent,
|
tmp.batteryCurrent,
|
||||||
tmp.batteryPower,
|
tmp.batteryPower,
|
||||||
@@ -114,21 +117,30 @@ func newPrometheusUpdater() *prometheusUpdater {
|
|||||||
tmp.mainsFreqIn,
|
tmp.mainsFreqIn,
|
||||||
tmp.mainsFreqOut,
|
tmp.mainsFreqOut,
|
||||||
)
|
)
|
||||||
return tmp
|
|
||||||
|
go tmp.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pu *prometheusUpdater) updatePrometheus(newStatus *mk2if.Mk2Info) {
|
func (p *Prometheus) run() {
|
||||||
s := newStatus
|
for e := range p.C() {
|
||||||
pu.batteryVoltage.Set(s.BatVoltage)
|
if e.Valid {
|
||||||
pu.batteryCharge.Set(newStatus.ChargeState * 100)
|
p.updatePrometheus(e)
|
||||||
pu.batteryCurrent.Set(s.BatCurrent)
|
}
|
||||||
pu.batteryPower.Set(s.BatVoltage * s.BatCurrent)
|
}
|
||||||
pu.mainsCurrentIn.Set(s.InCurrent)
|
}
|
||||||
pu.mainsCurrentOut.Set(s.OutCurrent)
|
|
||||||
pu.mainsVoltageIn.Set(s.InVoltage)
|
func (p *Prometheus) updatePrometheus(newStatus *mk2driver.Mk2Info) {
|
||||||
pu.mainsVoltageOut.Set(s.OutVoltage)
|
s := newStatus
|
||||||
pu.mainsPowerIn.Set(s.InVoltage * s.InCurrent)
|
p.batteryVoltage.Set(s.BatVoltage)
|
||||||
pu.mainsPowerOut.Set(s.OutVoltage * s.OutCurrent)
|
p.batteryCharge.Set(newStatus.ChargeState * 100)
|
||||||
pu.mainsFreqIn.Set(s.InFrequency)
|
p.batteryCurrent.Set(s.BatCurrent)
|
||||||
pu.mainsFreqOut.Set(s.OutFrequency)
|
p.batteryPower.Set(s.BatVoltage * s.BatCurrent)
|
||||||
|
p.mainsCurrentIn.Set(s.InCurrent)
|
||||||
|
p.mainsCurrentOut.Set(s.OutCurrent)
|
||||||
|
p.mainsVoltageIn.Set(s.InVoltage)
|
||||||
|
p.mainsVoltageOut.Set(s.OutVoltage)
|
||||||
|
p.mainsPowerIn.Set(s.InVoltage * s.InCurrent)
|
||||||
|
p.mainsPowerOut.Set(s.OutVoltage * s.OutCurrent)
|
||||||
|
p.mainsFreqIn.Set(s.InFrequency)
|
||||||
|
p.mainsFreqOut.Set(s.OutFrequency)
|
||||||
}
|
}
|
||||||
14
plugins/webui/static/binary.go
Normal file
14
plugins/webui/static/binary.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//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 {
|
||||||
|
return http.FileServer(http.FS(content))
|
||||||
|
}
|
||||||
12
plugins/webui/static/css/bootstrap.min.css
vendored
Normal file
12
plugins/webui/static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@@ -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 />
|
||||||
280
plugins/webui/static/js/controller.js
Normal file
280
plugins/webui/static/js/controller.js
Normal 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(/^\/+/, "");
|
||||||
|
}
|
||||||
6
plugins/webui/static/js/vue.js
Normal file
6
plugins/webui/static/js/vue.js
Normal file
File diff suppressed because one or more lines are too long
479
plugins/webui/webgui.go
Normal file
479
plugins/webui/webgui.go
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2015, 2017 Hendrik van Wyk
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of invertergui nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||||
|
"git.coadcorp.com/nathan/invertergui/websocket"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = logrus.WithField("ctx", "inverter-gui-webgui")
|
||||||
|
|
||||||
|
const (
|
||||||
|
LedOff = "dot-off"
|
||||||
|
LedRed = "dot-red"
|
||||||
|
BlinkRed = "blink-red"
|
||||||
|
LedGreen = "dot-green"
|
||||||
|
BlinkGreen = "blink-green"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeChargerOnly = "charger_only"
|
||||||
|
modeInverterOnly = "inverter_only"
|
||||||
|
modeOn = "on"
|
||||||
|
modeOff = "off"
|
||||||
|
modeUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebGui struct {
|
||||||
|
mk2driver.Mk2
|
||||||
|
writer mk2driver.SettingsWriter
|
||||||
|
stopChan chan struct{}
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
hub *websocket.Hub
|
||||||
|
|
||||||
|
stateMu sync.RWMutex
|
||||||
|
latest *templateInput
|
||||||
|
remote remotePanelState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebGui(source mk2driver.Mk2, writer mk2driver.SettingsWriter) *WebGui {
|
||||||
|
w := &WebGui{
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
Mk2: source,
|
||||||
|
writer: writer,
|
||||||
|
hub: websocket.NewHub(),
|
||||||
|
remote: remotePanelState{
|
||||||
|
Writable: writer != nil,
|
||||||
|
Mode: modeUnknown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
log.WithField("remote_writable", writer != nil).Info("Web UI initialized")
|
||||||
|
w.wg.Add(1)
|
||||||
|
go w.dataPoll()
|
||||||
|
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 {
|
||||||
|
Error []error `json:"errors"`
|
||||||
|
|
||||||
|
Date string `json:"date"`
|
||||||
|
|
||||||
|
OutCurrent string `json:"output_current"`
|
||||||
|
OutVoltage string `json:"output_voltage"`
|
||||||
|
OutPower string `json:"output_power"`
|
||||||
|
|
||||||
|
InCurrent string `json:"input_current"`
|
||||||
|
InVoltage string `json:"input_voltage"`
|
||||||
|
InPower string `json:"input_power"`
|
||||||
|
|
||||||
|
InMinOut string
|
||||||
|
|
||||||
|
BatVoltage string `json:"battery_voltage"`
|
||||||
|
BatCurrent string `json:"battery_current"`
|
||||||
|
BatPower string `json:"battery_power"`
|
||||||
|
BatCharge string `json:"battery_charge"`
|
||||||
|
|
||||||
|
InFreq string `json:"input_frequency"`
|
||||||
|
OutFreq string `json:"output_frequency"`
|
||||||
|
|
||||||
|
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) {
|
||||||
|
log.WithFields(logrus.Fields{
|
||||||
|
"remote": r.RemoteAddr,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
}).Debug("WebSocket hub request")
|
||||||
|
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 {
|
||||||
|
name, ok := mk2driver.LedNames[led]
|
||||||
|
if !ok {
|
||||||
|
return "Unknown led"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTemplateInput(status *mk2driver.Mk2Info) *templateInput {
|
||||||
|
outPower := status.OutVoltage * status.OutCurrent
|
||||||
|
inPower := status.InCurrent * status.InVoltage
|
||||||
|
|
||||||
|
tmpInput := &templateInput{
|
||||||
|
Error: status.Errors,
|
||||||
|
Date: status.Timestamp.Format(time.RFC1123Z),
|
||||||
|
OutCurrent: fmt.Sprintf("%.2f", status.OutCurrent),
|
||||||
|
OutVoltage: fmt.Sprintf("%.2f", status.OutVoltage),
|
||||||
|
OutPower: fmt.Sprintf("%.2f", outPower),
|
||||||
|
InCurrent: fmt.Sprintf("%.2f", status.InCurrent),
|
||||||
|
InVoltage: fmt.Sprintf("%.2f", status.InVoltage),
|
||||||
|
InFreq: fmt.Sprintf("%.2f", status.InFrequency),
|
||||||
|
OutFreq: fmt.Sprintf("%.2f", status.OutFrequency),
|
||||||
|
InPower: fmt.Sprintf("%.2f", inPower),
|
||||||
|
|
||||||
|
InMinOut: fmt.Sprintf("%.2f", inPower-outPower),
|
||||||
|
|
||||||
|
BatCurrent: fmt.Sprintf("%.2f", status.BatCurrent),
|
||||||
|
BatVoltage: fmt.Sprintf("%.2f", status.BatVoltage),
|
||||||
|
BatPower: fmt.Sprintf("%.2f", status.BatVoltage*status.BatCurrent),
|
||||||
|
BatCharge: fmt.Sprintf("%.2f", status.ChargeState*100),
|
||||||
|
|
||||||
|
LedMap: map[string]string{},
|
||||||
|
}
|
||||||
|
for k, v := range status.LEDs {
|
||||||
|
if k == mk2driver.LedOverload || k == mk2driver.LedTemperature || k == mk2driver.LedLowBattery {
|
||||||
|
switch v {
|
||||||
|
case mk2driver.LedOn:
|
||||||
|
tmpInput.LedMap[ledName(k)] = LedRed
|
||||||
|
case mk2driver.LedBlink:
|
||||||
|
tmpInput.LedMap[ledName(k)] = BlinkRed
|
||||||
|
default:
|
||||||
|
tmpInput.LedMap[ledName(k)] = LedOff
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch v {
|
||||||
|
case mk2driver.LedOn:
|
||||||
|
tmpInput.LedMap[ledName(k)] = LedGreen
|
||||||
|
case mk2driver.LedBlink:
|
||||||
|
tmpInput.LedMap[ledName(k)] = BlinkGreen
|
||||||
|
default:
|
||||||
|
tmpInput.LedMap[ledName(k)] = LedOff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmpInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebGui) Stop() {
|
||||||
|
log.Info("Stopping Web UI polling")
|
||||||
|
close(w.stopChan)
|
||||||
|
w.wg.Wait()
|
||||||
|
log.Info("Web UI polling stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebGui) dataPoll() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case s := <-w.C():
|
||||||
|
if s == nil {
|
||||||
|
log.Debug("Skipping nil MK2 update in Web UI poller")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
w.wg.Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package webgui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -36,23 +36,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/diebietse/invertergui/mk2if"
|
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebGui(t *testing.T) {
|
|
||||||
t.Skip("Not yet implimented")
|
|
||||||
//TODO figure out how to test template output.
|
|
||||||
}
|
|
||||||
|
|
||||||
type templateTest struct {
|
type templateTest struct {
|
||||||
input *mk2if.Mk2Info
|
input *mk2driver.Mk2Info
|
||||||
output *templateInput
|
output *templateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
var fakenow = time.Date(2017, 1, 2, 3, 4, 5, 6, time.UTC)
|
var fakenow = time.Date(2017, 1, 2, 3, 4, 5, 6, time.UTC)
|
||||||
var templateInputTests = []templateTest{
|
var templateInputTests = []templateTest{
|
||||||
{
|
{
|
||||||
input: &mk2if.Mk2Info{
|
input: &mk2driver.Mk2Info{
|
||||||
OutCurrent: 2.0,
|
OutCurrent: 2.0,
|
||||||
InCurrent: 2.3,
|
InCurrent: 2.3,
|
||||||
OutVoltage: 230.0,
|
OutVoltage: 230.0,
|
||||||
@@ -62,7 +57,7 @@ var templateInputTests = []templateTest{
|
|||||||
InFrequency: 50,
|
InFrequency: 50,
|
||||||
OutFrequency: 50,
|
OutFrequency: 50,
|
||||||
ChargeState: 1,
|
ChargeState: 1,
|
||||||
LEDs: map[mk2if.Led]mk2if.LEDstate{mk2if.LedMain: mk2if.LedOn},
|
LEDs: map[mk2driver.Led]mk2driver.LEDstate{mk2driver.LedMain: mk2driver.LedOn},
|
||||||
Errors: nil,
|
Errors: nil,
|
||||||
Timestamp: fakenow,
|
Timestamp: fakenow,
|
||||||
},
|
},
|
||||||
@@ -96,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
217
webgui/munin.go
217
webgui/munin.go
@@ -1,217 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (c) 2015, Hendrik van Wyk
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
* Redistributions of source code must retain the above copyright notice, this
|
|
||||||
list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
* Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer in the documentation
|
|
||||||
and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
* Neither the name of invertergui nor the names of its
|
|
||||||
contributors may be used to endorse or promote products derived from
|
|
||||||
this software without specific prior written permission.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
||||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
||||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
||||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
||||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
||||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package webgui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/diebietse/invertergui/mk2if"
|
|
||||||
)
|
|
||||||
|
|
||||||
type muninData struct {
|
|
||||||
status mk2if.Mk2Info
|
|
||||||
timesUpdated int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebGui) ServeMuninHTTP(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
muninDat := <-w.muninRespChan
|
|
||||||
if muninDat.timesUpdated == 0 {
|
|
||||||
rw.WriteHeader(500)
|
|
||||||
_, _ = rw.Write([]byte("No data to return.\n"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
calcMuninAverages(&muninDat)
|
|
||||||
|
|
||||||
status := muninDat.status
|
|
||||||
tmpInput := buildTemplateInput(&status)
|
|
||||||
outputBuf := &bytes.Buffer{}
|
|
||||||
fmt.Fprintf(outputBuf, "multigraph in_batvolt\n")
|
|
||||||
fmt.Fprintf(outputBuf, "volt.value %s\n", tmpInput.BatVoltage)
|
|
||||||
fmt.Fprintf(outputBuf, "multigraph in_batcharge\n")
|
|
||||||
fmt.Fprintf(outputBuf, "charge.value %s\n", tmpInput.BatCharge)
|
|
||||||
fmt.Fprintf(outputBuf, "multigraph in_batcurrent\n")
|
|
||||||
fmt.Fprintf(outputBuf, "current.value %s\n", tmpInput.BatCurrent)
|
|
||||||
fmt.Fprintf(outputBuf, "multigraph in_batpower\n")
|
|
||||||
fmt.Fprintf(outputBuf, "power.value %s\n", tmpInput.BatPower)
|
|
||||||
fmt.Fprintf(outputBuf, "multigraph in_mainscurrent\n")
|
|
||||||
fmt.Fprintf(outputBuf, "currentin.value %s\n", tmpInput.InCurrent)
|
|
||||||
fmt.Fprintf(outputBuf, "currentout.value %s\n", tmpInput.OutCurrent)
|
|
||||||
fmt.Fprintf(outputBuf, "multigraph in_mainsvoltage\n")
|
|
||||||
fmt.Fprintf(outputBuf, "voltagein.value %s\n", tmpInput.InVoltage)
|
|
||||||
fmt.Fprintf(outputBuf, "voltageout.value %s\n", tmpInput.OutVoltage)
|
|
||||||
fmt.Fprintf(outputBuf, "multigraph in_mainspower\n")
|
|
||||||
fmt.Fprintf(outputBuf, "powerin.value %s\n", tmpInput.InPower)
|
|
||||||
fmt.Fprintf(outputBuf, "powerout.value %s\n", tmpInput.OutPower)
|
|
||||||
fmt.Fprintf(outputBuf, "multigraph in_mainsfreq\n")
|
|
||||||
fmt.Fprintf(outputBuf, "freqin.value %s\n", tmpInput.InFreq)
|
|
||||||
fmt.Fprintf(outputBuf, "freqout.value %s\n", tmpInput.OutFreq)
|
|
||||||
|
|
||||||
_, err := rw.Write(outputBuf.Bytes())
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("%v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebGui) ServeMuninConfigHTTP(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
output := `multigraph in_batvolt
|
|
||||||
graph_title Battery Voltage
|
|
||||||
graph_vlabel Voltage (V)
|
|
||||||
graph_category inverter
|
|
||||||
graph_info Battery voltage
|
|
||||||
|
|
||||||
volt.info Voltage of battery
|
|
||||||
volt.label Voltage of battery (V)
|
|
||||||
|
|
||||||
multigraph in_batcharge
|
|
||||||
graph_title Battery Charge
|
|
||||||
graph_vlabel Charge (%)
|
|
||||||
graph_category inverter
|
|
||||||
graph_info Battery charge
|
|
||||||
|
|
||||||
charge.info Estimated charge of battery
|
|
||||||
charge.label Battery charge (%)
|
|
||||||
|
|
||||||
multigraph in_batcurrent
|
|
||||||
graph_title Battery Current
|
|
||||||
graph_vlabel Current (A)
|
|
||||||
graph_category inverter
|
|
||||||
graph_info Battery current
|
|
||||||
|
|
||||||
current.info Battery current
|
|
||||||
current.label Battery current (A)
|
|
||||||
|
|
||||||
multigraph in_batpower
|
|
||||||
graph_title Battery Power
|
|
||||||
graph_vlabel Power (W)
|
|
||||||
graph_category inverter
|
|
||||||
graph_info Battery power
|
|
||||||
|
|
||||||
power.info Battery power
|
|
||||||
power.label Battery power (W)
|
|
||||||
|
|
||||||
multigraph in_mainscurrent
|
|
||||||
graph_title Mains Current
|
|
||||||
graph_vlabel Current (A)
|
|
||||||
graph_category inverter
|
|
||||||
graph_info Mains current
|
|
||||||
|
|
||||||
currentin.info Input current
|
|
||||||
currentin.label Input current (A)
|
|
||||||
currentout.info Output current
|
|
||||||
currentout.label Output current (A)
|
|
||||||
|
|
||||||
multigraph in_mainsvoltage
|
|
||||||
graph_title Mains Voltage
|
|
||||||
graph_vlabel Voltage (V)
|
|
||||||
graph_category inverter
|
|
||||||
graph_info Mains voltage
|
|
||||||
|
|
||||||
voltagein.info Input voltage
|
|
||||||
voltagein.label Input voltage (V)
|
|
||||||
voltageout.info Output voltage
|
|
||||||
voltageout.label Output voltage (V)
|
|
||||||
|
|
||||||
multigraph in_mainspower
|
|
||||||
graph_title Mains Power
|
|
||||||
graph_vlabel Power (VA)
|
|
||||||
graph_category inverter
|
|
||||||
graph_info Mains power
|
|
||||||
|
|
||||||
powerin.info Input power
|
|
||||||
powerin.label Input power (VA)
|
|
||||||
powerout.info Output power
|
|
||||||
powerout.label Output power (VA)
|
|
||||||
|
|
||||||
multigraph in_mainsfreq
|
|
||||||
graph_title Mains frequency
|
|
||||||
graph_vlabel Frequency (Hz)
|
|
||||||
graph_category inverter
|
|
||||||
graph_info Mains frequency
|
|
||||||
|
|
||||||
freqin.info In frequency
|
|
||||||
freqin.label In frequency (Hz)
|
|
||||||
freqout.info Out frequency
|
|
||||||
freqout.label Out frequency (Hz)
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err := rw.Write([]byte(output))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("%v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//Munin only samples once every 5 minutes so averages have to be calculated for some values.
|
|
||||||
func calcMuninValues(muninDat *muninData, newStatus *mk2if.Mk2Info) {
|
|
||||||
muninDat.timesUpdated++
|
|
||||||
muninVal := &muninDat.status
|
|
||||||
muninVal.OutCurrent += newStatus.OutCurrent
|
|
||||||
muninVal.InCurrent += newStatus.InCurrent
|
|
||||||
muninVal.BatCurrent += newStatus.BatCurrent
|
|
||||||
|
|
||||||
muninVal.OutVoltage += newStatus.OutVoltage
|
|
||||||
muninVal.InVoltage += newStatus.InVoltage
|
|
||||||
muninVal.BatVoltage += newStatus.BatVoltage
|
|
||||||
|
|
||||||
muninVal.InFrequency = newStatus.InFrequency
|
|
||||||
muninVal.OutFrequency = newStatus.OutFrequency
|
|
||||||
|
|
||||||
muninVal.ChargeState = newStatus.ChargeState
|
|
||||||
}
|
|
||||||
|
|
||||||
func calcMuninAverages(muninDat *muninData) {
|
|
||||||
muninVal := &muninDat.status
|
|
||||||
muninVal.OutCurrent /= float64(muninDat.timesUpdated)
|
|
||||||
muninVal.InCurrent /= float64(muninDat.timesUpdated)
|
|
||||||
muninVal.BatCurrent /= float64(muninDat.timesUpdated)
|
|
||||||
|
|
||||||
muninVal.OutVoltage /= float64(muninDat.timesUpdated)
|
|
||||||
muninVal.InVoltage /= float64(muninDat.timesUpdated)
|
|
||||||
muninVal.BatVoltage /= float64(muninDat.timesUpdated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func zeroMuninValues(muninDat *muninData) {
|
|
||||||
muninDat.timesUpdated = 0
|
|
||||||
muninVal := &muninDat.status
|
|
||||||
muninVal.OutCurrent = 0
|
|
||||||
muninVal.InCurrent = 0
|
|
||||||
muninVal.BatCurrent = 0
|
|
||||||
|
|
||||||
muninVal.OutVoltage = 0
|
|
||||||
muninVal.InVoltage = 0
|
|
||||||
muninVal.BatVoltage = 0
|
|
||||||
|
|
||||||
muninVal.InFrequency = 0
|
|
||||||
muninVal.OutFrequency = 0
|
|
||||||
|
|
||||||
muninVal.ChargeState = 0
|
|
||||||
}
|
|
||||||
199
webgui/webgui.go
199
webgui/webgui.go
@@ -1,199 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (c) 2015, 2017 Hendrik van Wyk
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
* Redistributions of source code must retain the above copyright notice, this
|
|
||||||
list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
* Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer in the documentation
|
|
||||||
and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
* Neither the name of invertergui nor the names of its
|
|
||||||
contributors may be used to endorse or promote products derived from
|
|
||||||
this software without specific prior written permission.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
||||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
||||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
||||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
||||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
||||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package webgui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/diebietse/invertergui/mk2if"
|
|
||||||
"github.com/diebietse/invertergui/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
LedOff = "dot-off"
|
|
||||||
LedRed = "dot-red"
|
|
||||||
BlinkRed = "blink-red"
|
|
||||||
LedGreen = "dot-green"
|
|
||||||
BlinkGreen = "blink-green"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WebGui struct {
|
|
||||||
stopChan chan struct{}
|
|
||||||
|
|
||||||
muninRespChan chan muninData
|
|
||||||
poller mk2if.Mk2If
|
|
||||||
wg sync.WaitGroup
|
|
||||||
hub *websocket.Hub
|
|
||||||
|
|
||||||
pu *prometheusUpdater
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWebGui(source mk2if.Mk2If) *WebGui {
|
|
||||||
w := new(WebGui)
|
|
||||||
w.muninRespChan = make(chan muninData)
|
|
||||||
w.stopChan = make(chan struct{})
|
|
||||||
w.poller = source
|
|
||||||
w.pu = newPrometheusUpdater()
|
|
||||||
w.hub = websocket.NewHub()
|
|
||||||
|
|
||||||
w.wg.Add(1)
|
|
||||||
go w.dataPoll()
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
type templateInput struct {
|
|
||||||
Error []error `json:"errors"`
|
|
||||||
|
|
||||||
Date string `json:"date"`
|
|
||||||
|
|
||||||
OutCurrent string `json:"output_current"`
|
|
||||||
OutVoltage string `json:"output_voltage"`
|
|
||||||
OutPower string `json:"output_power"`
|
|
||||||
|
|
||||||
InCurrent string `json:"input_current"`
|
|
||||||
InVoltage string `json:"input_voltage"`
|
|
||||||
InPower string `json:"input_power"`
|
|
||||||
|
|
||||||
InMinOut string
|
|
||||||
|
|
||||||
BatVoltage string `json:"battery_voltage"`
|
|
||||||
BatCurrent string `json:"battery_current"`
|
|
||||||
BatPower string `json:"battery_power"`
|
|
||||||
BatCharge string `json:"battery_charge"`
|
|
||||||
|
|
||||||
InFreq string `json:"input_frequency"`
|
|
||||||
OutFreq string `json:"output_frequency"`
|
|
||||||
|
|
||||||
LedMap map[string]string `json:"led_map"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebGui) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
http.ServeFile(rw, r, "./frontend/index.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebGui) ServeJS(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
http.ServeFile(rw, r, "./frontend/js/controller.js")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebGui) ServeHub(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
w.hub.ServeHTTP(rw, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ledName(led mk2if.Led) string {
|
|
||||||
name, ok := mk2if.LedNames[led]
|
|
||||||
if !ok {
|
|
||||||
return "Unknown led"
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTemplateInput(status *mk2if.Mk2Info) *templateInput {
|
|
||||||
outPower := status.OutVoltage * status.OutCurrent
|
|
||||||
inPower := status.InCurrent * status.InVoltage
|
|
||||||
|
|
||||||
tmpInput := &templateInput{
|
|
||||||
Error: status.Errors,
|
|
||||||
Date: status.Timestamp.Format(time.RFC1123Z),
|
|
||||||
OutCurrent: fmt.Sprintf("%.2f", status.OutCurrent),
|
|
||||||
OutVoltage: fmt.Sprintf("%.2f", status.OutVoltage),
|
|
||||||
OutPower: fmt.Sprintf("%.2f", outPower),
|
|
||||||
InCurrent: fmt.Sprintf("%.2f", status.InCurrent),
|
|
||||||
InVoltage: fmt.Sprintf("%.2f", status.InVoltage),
|
|
||||||
InFreq: fmt.Sprintf("%.2f", status.InFrequency),
|
|
||||||
OutFreq: fmt.Sprintf("%.2f", status.OutFrequency),
|
|
||||||
InPower: fmt.Sprintf("%.2f", inPower),
|
|
||||||
|
|
||||||
InMinOut: fmt.Sprintf("%.2f", inPower-outPower),
|
|
||||||
|
|
||||||
BatCurrent: fmt.Sprintf("%.2f", status.BatCurrent),
|
|
||||||
BatVoltage: fmt.Sprintf("%.2f", status.BatVoltage),
|
|
||||||
BatPower: fmt.Sprintf("%.2f", status.BatVoltage*status.BatCurrent),
|
|
||||||
BatCharge: fmt.Sprintf("%.2f", status.ChargeState*100),
|
|
||||||
|
|
||||||
LedMap: map[string]string{},
|
|
||||||
}
|
|
||||||
for k, v := range status.LEDs {
|
|
||||||
if k == mk2if.LedOverload || k == mk2if.LedTemperature || k == mk2if.LedLowBattery {
|
|
||||||
switch v {
|
|
||||||
case mk2if.LedOn:
|
|
||||||
tmpInput.LedMap[ledName(k)] = LedRed
|
|
||||||
case mk2if.LedBlink:
|
|
||||||
tmpInput.LedMap[ledName(k)] = BlinkRed
|
|
||||||
default:
|
|
||||||
tmpInput.LedMap[ledName(k)] = LedOff
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch v {
|
|
||||||
case mk2if.LedOn:
|
|
||||||
tmpInput.LedMap[ledName(k)] = LedGreen
|
|
||||||
case mk2if.LedBlink:
|
|
||||||
tmpInput.LedMap[ledName(k)] = BlinkGreen
|
|
||||||
default:
|
|
||||||
tmpInput.LedMap[ledName(k)] = LedOff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tmpInput
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebGui) Stop() {
|
|
||||||
close(w.stopChan)
|
|
||||||
w.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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() {
|
|
||||||
pollChan := w.poller.C()
|
|
||||||
var muninValues muninData
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case s := <-pollChan:
|
|
||||||
if s.Valid {
|
|
||||||
calcMuninValues(&muninValues, s)
|
|
||||||
w.pu.updatePrometheus(s)
|
|
||||||
err := w.hub.Broadcast(buildTemplateInput(s))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Could not send update to clients: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case w.muninRespChan <- muninValues:
|
|
||||||
zeroMuninValues(&muninValues)
|
|
||||||
case <-w.stopChan:
|
|
||||||
w.wg.Done()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user