Compare commits
78 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 |
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
|
||||
*.test
|
||||
*.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
|
||||
28
.travis.yml
28
.travis.yml
@@ -1,28 +0,0 @@
|
||||
sudo: false
|
||||
|
||||
language: go
|
||||
|
||||
env:
|
||||
- GO111MODULE=on
|
||||
|
||||
go:
|
||||
- 1.12.x
|
||||
|
||||
git:
|
||||
depth: 1
|
||||
|
||||
install: true
|
||||
|
||||
notifications:
|
||||
email: false
|
||||
|
||||
before_script:
|
||||
- curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin v1.15.0
|
||||
- go mod vendor
|
||||
|
||||
script:
|
||||
- golangci-lint run
|
||||
- go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -10,5 +10,13 @@
|
||||
"[go]": {
|
||||
"editor.insertSpaces": false,
|
||||
"editor.tabSize": 4
|
||||
}
|
||||
},
|
||||
"cSpell.words": [
|
||||
"diebietse",
|
||||
"ghaction",
|
||||
"ghcr",
|
||||
"golangci",
|
||||
"invertergui",
|
||||
"semver"
|
||||
]
|
||||
}
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -1,14 +1,24 @@
|
||||
FROM golang:alpine as builder
|
||||
RUN apk add git
|
||||
ARG BUILDPLATFORM=linux/amd64
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.26-alpine as builder
|
||||
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
ARG TARGETVARIANT
|
||||
|
||||
RUN mkdir /build
|
||||
COPY . /build/
|
||||
WORKDIR /build
|
||||
RUN go build -o invertergui ./cmd/invertergui
|
||||
FROM alpine
|
||||
RUN adduser -S -D -H -h /app inverteruser
|
||||
RUN addgroup inverteruser dialout
|
||||
USER inverteruser
|
||||
COPY --from=builder /build/invertergui /app/
|
||||
WORKDIR /app
|
||||
ENTRYPOINT [ "./invertergui" ]
|
||||
CMD []
|
||||
RUN set -eux; \
|
||||
GOARM=""; \
|
||||
if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT:-}" ]; then GOARM="${TARGETVARIANT#v}"; fi; \
|
||||
CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" GOARM="${GOARM}" go build -o invertergui ./cmd/invertergui
|
||||
|
||||
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 --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
|
||||
21
Makefile
21
Makefile
@@ -26,7 +26,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.
|
||||
|
||||
.PHONY: test test-race vet install gofmt docker statik lint clean invertergui
|
||||
.PHONY: test test-race vet install gofmt docker statik lint clean invertergui vendor
|
||||
|
||||
.DEFAULT_GOAL = invertergui
|
||||
|
||||
@@ -39,19 +39,24 @@ gofmt:
|
||||
gofmt -l -s -w .
|
||||
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
test-race:
|
||||
go test -v -race ./...
|
||||
|
||||
docker:
|
||||
docker build --tag invertergui .
|
||||
|
||||
statik:
|
||||
statik -f -p=frontend -src=./frontend/root
|
||||
|
||||
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:
|
||||
rm ./invertergui
|
||||
|
||||
vendor:
|
||||
go mod tidy
|
||||
go mod vendor
|
||||
|
||||
.PHONY: upgrade-vendor
|
||||
upgrade-vendor:
|
||||
go get -u ./...
|
||||
|
||||
479
README.md
479
README.md
@@ -1,39 +1,195 @@
|
||||
# Inverter GUI
|
||||
|
||||
[](https://travis-ci.org/diebietse/invertergui)
|
||||
[Repository](https://git.coadcorp.com/nathan/invertergui) | [Container Image](https://registry.coadcorp.com/nathan/invertergui)
|
||||
|
||||
The invertergui allows the monitoring of a [Victron Multiplus](https://www.victronenergy.com/inverters-chargers/multiplus-12v-24v-48v-800va-3kva) via the [MK3/MK2 USB](https://www.victronenergy.com/accessories/interface-mk3-usb) or the MK2 RS232.
|
||||
|
||||
The [`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
|
||||
|
||||

|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
Usage of ./invertergui:
|
||||
-addr string
|
||||
TCP address to listen on. (default ":8080")
|
||||
-dev string
|
||||
TTY device to use. (default "/dev/ttyUSB0")
|
||||
-ip string
|
||||
IP to connect when using tcp connection. (default "localhost:8139")
|
||||
-tcp
|
||||
Use TCP instead of TTY
|
||||
Usage:
|
||||
invertergui [OPTIONS]
|
||||
|
||||
Application Options:
|
||||
--address= The IP/DNS and port of the machine that the application is running on. (default: :8080) [$ADDRESS]
|
||||
--read_only Disable all write operations and run in monitoring-only mode. [$READ_ONLY]
|
||||
--data.source= Set the source of data for the inverter gui. "serial", "tcp" or "mock" (default: serial) [$DATA_SOURCE]
|
||||
--data.host= Host to connect when source is set to tcp. (default: localhost:8139) [$DATA_HOST]
|
||||
--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
|
||||
|
||||
The default HTTP server port is hosted on port 8080. This exposes the HTTP server that hosts the:
|
||||
@@ -69,6 +225,20 @@ Battery Power: -0.659 W
|
||||
Battery Charge: 100.000 %
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The Munin plugin location is at /munin (http://localhost:8080/munin).
|
||||
@@ -259,6 +429,285 @@ process_start_time_seconds 1.54506833485e+09
|
||||
process_virtual_memory_bytes 1.15101696e+08
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
The intertergui application makes use of a serial tty device to monitor the Multiplus.
|
||||
@@ -320,4 +769,4 @@ The last four lines are optional, but is useful when debugging and logging conne
|
||||
|
||||
This repos includes a [Grafana](https://grafana.com/) dashboard in the [grafana folder](./grafana/prometheus-dashboard.json) that you can import. This is useful if you are using prometheus to log your data and want to display it in a nice way.
|
||||
|
||||

|
||||

|
||||
|
||||
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,90 +31,287 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/diebietse/invertergui/mk2core"
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"github.com/diebietse/invertergui/plugins/cli"
|
||||
"github.com/diebietse/invertergui/plugins/munin"
|
||||
"github.com/diebietse/invertergui/plugins/prometheus"
|
||||
"github.com/diebietse/invertergui/plugins/webui"
|
||||
"github.com/diebietse/invertergui/plugins/webui/static"
|
||||
"git.coadcorp.com/nathan/invertergui/mk2core"
|
||||
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||
"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/sirupsen/logrus"
|
||||
"github.com/tarm/serial"
|
||||
)
|
||||
|
||||
func main() {
|
||||
source := flag.String("source", "serial", "Set the source of data for the inverter gui. \"serial\", \"tcp\" or \"mock\"")
|
||||
addr := flag.String("addr", ":8080", "TCP address to listen on.")
|
||||
ip := flag.String("ip", "localhost:8139", "IP to connect when using tcp connection.")
|
||||
dev := flag.String("dev", "/dev/ttyUSB0", "TTY device to use.")
|
||||
cliEnable := flag.Bool("cli", false, "Enable CLI output")
|
||||
flag.Parse()
|
||||
var log = logrus.WithField("ctx", "inverter-gui")
|
||||
|
||||
mk2 := getMk2Device(*source, *ip, *dev)
|
||||
func main() {
|
||||
conf, err := parseConfig()
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Could not parse configuration")
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("Starting invertergui")
|
||||
logLevel, err := logrus.ParseLevel(conf.Loglevel)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not parse log level: %v", err)
|
||||
}
|
||||
logrus.SetLevel(logLevel)
|
||||
log.WithFields(logrus.Fields{
|
||||
"loglevel": conf.Loglevel,
|
||||
"address": conf.Address,
|
||||
"read_only": conf.ReadOnly,
|
||||
"data_source": conf.Data.Source,
|
||||
"data_host": conf.Data.Host,
|
||||
"data_device": conf.Data.Device,
|
||||
"cli_enabled": conf.Cli.Enabled,
|
||||
"mqtt_enabled": conf.MQTT.Enabled,
|
||||
"mqtt_broker": conf.MQTT.Broker,
|
||||
"mqtt_topic": conf.MQTT.Topic,
|
||||
"mqtt_command_topic": conf.MQTT.CommandTopic,
|
||||
"mqtt_status_topic": conf.MQTT.StatusTopic,
|
||||
"mqtt_device_id": conf.MQTT.DeviceID,
|
||||
"mqtt_history_size": conf.MQTT.HistorySize,
|
||||
"mqtt_ha_enabled": conf.MQTT.HA.Enabled,
|
||||
"mqtt_venus_enabled": conf.MQTT.Venus.Enabled,
|
||||
"mqtt_venus_portal": conf.MQTT.Venus.PortalID,
|
||||
"mqtt_venus_service": conf.MQTT.Venus.Service,
|
||||
"mqtt_venus_prefix": conf.MQTT.Venus.TopicPrefix,
|
||||
"mqtt_venus_guide": conf.MQTT.Venus.GuideCompat,
|
||||
"control_profile": conf.Control.Profile,
|
||||
"control_modes": conf.Control.AllowedPanelModes,
|
||||
}).Info("Configuration loaded")
|
||||
|
||||
mk2, err := getMk2Device(conf.Data.Source, conf.Data.Host, conf.Data.Device)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not open data source: %v", err)
|
||||
}
|
||||
defer mk2.Close()
|
||||
log.Info("MK2 device connection established")
|
||||
|
||||
core := mk2core.NewCore(mk2)
|
||||
|
||||
if *cliEnable {
|
||||
if conf.Cli.Enabled {
|
||||
log.Info("CLI plugin enabled")
|
||||
cli.NewCli(core.NewSubscription())
|
||||
}
|
||||
|
||||
// Webgui
|
||||
gui := webui.NewWebGui(core.NewSubscription())
|
||||
var writer mk2driver.SettingsWriter
|
||||
if w, ok := mk2.(mk2driver.SettingsWriter); ok {
|
||||
writer = w
|
||||
log.Info("MK2 data source supports settings writes")
|
||||
} else {
|
||||
log.Warn("MK2 data source does not support settings writes")
|
||||
}
|
||||
if conf.ReadOnly {
|
||||
if writer != nil {
|
||||
log.Warn("READ_ONLY enabled; disabling all write operations")
|
||||
} else {
|
||||
log.Info("READ_ONLY enabled")
|
||||
}
|
||||
writer = nil
|
||||
} else if writer != nil {
|
||||
allowedPanelStates, allowedPanelModeNames, parseErr := parseAllowedPanelModes(conf.Control.AllowedPanelModes)
|
||||
if parseErr != nil {
|
||||
log.WithError(parseErr).Fatal("Invalid control.allowed_panel_modes configuration")
|
||||
}
|
||||
|
||||
policyProfile := mk2driver.WriterProfile(strings.ToLower(strings.TrimSpace(conf.Control.Profile)))
|
||||
if policyProfile == "" {
|
||||
policyProfile = mk2driver.WriterProfileNormal
|
||||
}
|
||||
if policyProfile != mk2driver.WriterProfileNormal &&
|
||||
policyProfile != mk2driver.WriterProfileMaintenance &&
|
||||
policyProfile != mk2driver.WriterProfileReadOnly {
|
||||
log.WithField("profile", conf.Control.Profile).Warn("Unknown control profile; defaulting to normal")
|
||||
policyProfile = mk2driver.WriterProfileNormal
|
||||
}
|
||||
|
||||
var maxCurrentLimit *float64
|
||||
if conf.Control.MaxCurrentLimit > 0 {
|
||||
limit := conf.Control.MaxCurrentLimit
|
||||
maxCurrentLimit = &limit
|
||||
}
|
||||
|
||||
writer = mk2driver.NewManagedWriter(writer, mk2driver.WriterPolicy{
|
||||
Profile: policyProfile,
|
||||
MaxCurrentLimitA: maxCurrentLimit,
|
||||
ModeChangeMinInterval: conf.Control.ModeChangeMinInterval,
|
||||
LockoutWindow: conf.Control.LockoutWindow,
|
||||
AllowedPanelStates: allowedPanelStates,
|
||||
})
|
||||
|
||||
allowedModes := "all"
|
||||
if len(allowedPanelModeNames) > 0 {
|
||||
allowedModes = strings.Join(allowedPanelModeNames, ",")
|
||||
}
|
||||
log.WithFields(logrus.Fields{
|
||||
"profile": policyProfile,
|
||||
"max_current_limit": conf.Control.MaxCurrentLimit,
|
||||
"mode_change_min_interval": conf.Control.ModeChangeMinInterval,
|
||||
"lockout_window": conf.Control.LockoutWindow,
|
||||
"allowed_panel_modes": allowedModes,
|
||||
}).Info("Write policy/arbitration layer enabled")
|
||||
}
|
||||
gui := webui.NewWebGui(core.NewSubscription(), writer)
|
||||
http.Handle("/", static.New())
|
||||
http.Handle("/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")
|
||||
|
||||
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||
// MQTT
|
||||
if conf.MQTT.Enabled {
|
||||
mqttConf := mqttclient.Config{
|
||||
Broker: conf.MQTT.Broker,
|
||||
Topic: conf.MQTT.Topic,
|
||||
CommandTopic: conf.MQTT.CommandTopic,
|
||||
StatusTopic: conf.MQTT.StatusTopic,
|
||||
ClientID: conf.MQTT.ClientID,
|
||||
DeviceID: conf.MQTT.DeviceID,
|
||||
HistorySize: conf.MQTT.HistorySize,
|
||||
InstanceID: conf.MQTT.InstanceID,
|
||||
Phase: conf.MQTT.Phase,
|
||||
PhaseGroup: conf.MQTT.PhaseGroup,
|
||||
HomeAssistant: mqttclient.HomeAssistantConfig{
|
||||
Enabled: conf.MQTT.HA.Enabled,
|
||||
DiscoveryPrefix: conf.MQTT.HA.DiscoveryPrefix,
|
||||
NodeID: conf.MQTT.HA.NodeID,
|
||||
DeviceName: conf.MQTT.HA.DeviceName,
|
||||
},
|
||||
Venus: mqttclient.VenusConfig{
|
||||
Enabled: conf.MQTT.Venus.Enabled,
|
||||
PortalID: conf.MQTT.Venus.PortalID,
|
||||
Service: conf.MQTT.Venus.Service,
|
||||
SubscribeWrites: conf.MQTT.Venus.SubscribeWrites,
|
||||
TopicPrefix: conf.MQTT.Venus.TopicPrefix,
|
||||
GuideCompat: conf.MQTT.Venus.GuideCompat,
|
||||
},
|
||||
Username: conf.MQTT.Username,
|
||||
Password: conf.MQTT.Password,
|
||||
}
|
||||
if writer == nil {
|
||||
log.Warn("MK2 data source does not support write commands; MQTT command topic will be ignored")
|
||||
}
|
||||
if err := mqttclient.New(core.NewSubscription(), writer, mqttConf); err != nil {
|
||||
log.Fatalf("Could not setup MQTT client: %v", err)
|
||||
}
|
||||
log.Info("MQTT client initialized")
|
||||
}
|
||||
log.Infof("Invertergui web server starting on: %v", conf.Address)
|
||||
|
||||
if err := http.ListenAndServe(conf.Address, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func getMk2Device(source, ip, dev string) mk2driver.Mk2 {
|
||||
func getMk2Device(source, ip, dev string) (mk2driver.Mk2, error) {
|
||||
var p io.ReadWriteCloser
|
||||
var err error
|
||||
var tcpAddr *net.TCPAddr
|
||||
|
||||
switch source {
|
||||
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 {
|
||||
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 {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
p, err = net.DialTCP("tcp", nil, tcpAddr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
case "mock":
|
||||
return mk2driver.NewMk2Mock()
|
||||
log.Info("Using mock MK2 data source")
|
||||
return mk2driver.NewMk2Mock(), nil
|
||||
default:
|
||||
log.Printf("Invalid source selection: %v\nUse \"serial\", \"tcp\" or \"mock\"", source)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("Invalid source selection: %v\nUse \"serial\", \"tcp\" or \"mock\"", source)
|
||||
}
|
||||
|
||||
mk2, err := mk2driver.NewMk2Connection(p)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
log.WithField("source", source).Info("MK2 connection ready")
|
||||
|
||||
return mk2, nil
|
||||
}
|
||||
|
||||
func parseAllowedPanelModes(raw string) (map[mk2driver.PanelSwitchState]struct{}, []string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
return mk2
|
||||
out := make(map[mk2driver.PanelSwitchState]struct{})
|
||||
names := make([]string, 0, 4)
|
||||
|
||||
for _, token := range strings.Split(raw, ",") {
|
||||
mode := strings.ToLower(strings.TrimSpace(token))
|
||||
if mode == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var state mk2driver.PanelSwitchState
|
||||
var canonical string
|
||||
|
||||
switch mode {
|
||||
case "on":
|
||||
state = mk2driver.PanelSwitchOn
|
||||
canonical = "on"
|
||||
case "off":
|
||||
state = mk2driver.PanelSwitchOff
|
||||
canonical = "off"
|
||||
case "charger_only", "charger-only":
|
||||
state = mk2driver.PanelSwitchChargerOnly
|
||||
canonical = "charger_only"
|
||||
case "inverter_only", "inverter-only":
|
||||
state = mk2driver.PanelSwitchInverterOnly
|
||||
canonical = "inverter_only"
|
||||
default:
|
||||
return nil, nil, fmt.Errorf(
|
||||
"unsupported panel mode %q in control.allowed_panel_modes; supported values: on, off, charger_only, inverter_only",
|
||||
token,
|
||||
)
|
||||
}
|
||||
|
||||
if _, exists := out[state]; !exists {
|
||||
out[state] = struct{}{}
|
||||
names = append(names, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, nil, fmt.Errorf("control.allowed_panel_modes is set but no valid modes were provided")
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
return out, names, nil
|
||||
}
|
||||
|
||||
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})
|
||||
34
go.mod
34
go.mod
@@ -1,10 +1,32 @@
|
||||
module github.com/diebietse/invertergui
|
||||
module git.coadcorp.com/nathan/invertergui
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.4.0
|
||||
github.com/prometheus/client_golang v0.9.2
|
||||
github.com/rakyll/statik v0.1.5
|
||||
github.com/stretchr/testify v1.3.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
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
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
84
go.sum
84
go.sum
@@ -1,32 +1,60 @@
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
|
||||
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740=
|
||||
github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8=
|
||||
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE=
|
||||
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/rakyll/statik v0.1.5 h1:Ly2UjURzxnsSYS0zI50fZ+srA+Fu7EbpV5hglvJvJG0=
|
||||
github.com/rakyll/statik v0.1.5/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313 h1:pczuHS43Cp2ktBEEmLwScxgjWsBSzdaQiKzUyf3DTTc=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
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=
|
||||
|
||||
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
|
||||
@@ -1,9 +1,12 @@
|
||||
package mk2core
|
||||
|
||||
import (
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var log = logrus.WithField("ctx", "inverter-gui-core")
|
||||
|
||||
type Core struct {
|
||||
mk2driver.Mk2
|
||||
plugins map[*subscription]bool
|
||||
@@ -16,6 +19,7 @@ func NewCore(m mk2driver.Mk2) *Core {
|
||||
register: make(chan *subscription, 255),
|
||||
plugins: map[*subscription]bool{},
|
||||
}
|
||||
log.Info("Core initialized")
|
||||
go core.run()
|
||||
return core
|
||||
}
|
||||
@@ -25,6 +29,7 @@ func (c *Core) NewSubscription() mk2driver.Mk2 {
|
||||
send: make(chan *mk2driver.Mk2Info),
|
||||
}
|
||||
c.register <- sub
|
||||
log.Debug("New plugin subscription registered")
|
||||
return sub
|
||||
}
|
||||
|
||||
@@ -33,11 +38,13 @@ func (c *Core) run() {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
1600
mk2driver/mk2.go
1600
mk2driver/mk2.go
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -76,3 +76,277 @@ 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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ type mock struct {
|
||||
c chan *Mk2Info
|
||||
}
|
||||
|
||||
var _ ProtocolControl = (*mock)(nil)
|
||||
|
||||
func NewMk2Mock() Mk2 {
|
||||
tmp := &mock{
|
||||
c: make(chan *Mk2Info, 1),
|
||||
@@ -37,6 +39,77 @@ func (m *mock) Close() {
|
||||
|
||||
}
|
||||
|
||||
func (m *mock) WriteRAMVar(_ uint16, _ int16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) WriteSetting(_ uint16, _ int16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) SetPanelState(_ PanelSwitchState, _ *float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) SetStandby(_ bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) GetDeviceState() (DeviceState, error) {
|
||||
return DeviceStateOn, nil
|
||||
}
|
||||
|
||||
func (m *mock) SetDeviceState(_ DeviceState) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) ReadRAMVarByID(_ uint16) (int16, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mock) ReadSettingByID(_ uint16) (int16, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mock) SelectRAMVar(_ uint16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) SelectSetting(_ uint16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) ReadSelected() (int16, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mock) ReadRAMVarInfo(id uint16) (RAMVarInfo, error) {
|
||||
return RAMVarInfo{
|
||||
ID: id,
|
||||
Supported: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mock) WriteSettingByID(_ uint16, _ int16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) WriteRAMVarByID(_ uint16, _ int16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) WriteSelectedData(_ int16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) WriteSettingBySelection(_ uint16, _ int16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) WriteRAMVarBySelection(_ uint16, _ int16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mock) genMockValues() {
|
||||
mult := 1.0
|
||||
ledState := LedOff
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var log = logrus.WithField("ctx", "inverter-gui-cli")
|
||||
|
||||
type Cli struct {
|
||||
mk2driver.Mk2
|
||||
}
|
||||
@@ -27,21 +27,21 @@ func (c *Cli) run() {
|
||||
}
|
||||
|
||||
func printInfo(info *mk2driver.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:"
|
||||
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 {
|
||||
out += fmt.Sprintf(" %s %s", mk2driver.LedNames[k], mk2driver.StateNames[v])
|
||||
log.Infof(" %s %s", mk2driver.LedNames[k], mk2driver.StateNames[v])
|
||||
}
|
||||
|
||||
out += "\nErrors:"
|
||||
for _, v := range info.Errors {
|
||||
out += " " + v.Error()
|
||||
if len(info.Errors) != 0 {
|
||||
log.Info("Errors:")
|
||||
for _, err := range info.Errors {
|
||||
log.Error(err)
|
||||
}
|
||||
}
|
||||
out += "\n"
|
||||
log.Printf("System Info: \n%v", out)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -36,23 +36,26 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var log = logrus.WithField("ctx", "inverter-gui-munin")
|
||||
|
||||
type Munin struct {
|
||||
mk2driver.Mk2
|
||||
muninResponse chan *muninData
|
||||
muninResponse chan muninData
|
||||
}
|
||||
|
||||
type muninData struct {
|
||||
status *mk2driver.Mk2Info
|
||||
status mk2driver.Mk2Info
|
||||
timesUpdated int
|
||||
}
|
||||
|
||||
func NewMunin(mk2 mk2driver.Mk2) *Munin {
|
||||
m := &Munin{
|
||||
Mk2: mk2,
|
||||
muninResponse: make(chan *muninData),
|
||||
muninResponse: make(chan muninData),
|
||||
}
|
||||
|
||||
go m.run()
|
||||
@@ -60,17 +63,18 @@ func NewMunin(mk2 mk2driver.Mk2) *Munin {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Munin) ServeMuninHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
calcMuninAverages(&muninDat)
|
||||
|
||||
status := muninDat.status
|
||||
tmpInput := buildTemplateInput(status)
|
||||
tmpInput := buildTemplateInput(&status)
|
||||
outputBuf := &bytes.Buffer{}
|
||||
fmt.Fprintf(outputBuf, "multigraph in_batvolt\n")
|
||||
fmt.Fprintf(outputBuf, "volt.value %s\n", tmpInput.BatVoltage)
|
||||
@@ -95,79 +99,75 @@ func (m *Munin) ServeMuninHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
_, err := rw.Write(outputBuf.Bytes())
|
||||
if err != nil {
|
||||
fmt.Printf("%v\n", err)
|
||||
log.Errorf("Could not write data response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Munin) ServeMuninConfigHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
func (m *Munin) ServeMuninConfigHTTP(rw http.ResponseWriter, _ *http.Request) {
|
||||
output := muninConfig
|
||||
_, err := rw.Write([]byte(output))
|
||||
if err != nil {
|
||||
fmt.Printf("%v\n", err)
|
||||
log.Errorf("Could not write config response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Munin) run() {
|
||||
muninValues := &muninData{
|
||||
status: &mk2driver.Mk2Info{},
|
||||
status: mk2driver.Mk2Info{},
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case e := <-m.C():
|
||||
if e.Valid {
|
||||
calcMuninValues(muninValues, e)
|
||||
|
||||
}
|
||||
case m.muninResponse <- muninValues:
|
||||
case m.muninResponse <- *muninValues:
|
||||
zeroMuninValues(muninValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Munin only samples once every 5 minutes so averages have to be calculated for some values.
|
||||
func calcMuninValues(muninDat *muninData, newStatus *mk2driver.Mk2Info) {
|
||||
muninDat.timesUpdated++
|
||||
muninVal := muninDat.status
|
||||
muninVal.OutCurrent += newStatus.OutCurrent
|
||||
muninVal.InCurrent += newStatus.InCurrent
|
||||
muninVal.BatCurrent += newStatus.BatCurrent
|
||||
// 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
|
||||
|
||||
muninVal.OutVoltage += newStatus.OutVoltage
|
||||
muninVal.InVoltage += newStatus.InVoltage
|
||||
muninVal.BatVoltage += newStatus.BatVoltage
|
||||
m.status.OutVoltage += newStatus.OutVoltage
|
||||
m.status.InVoltage += newStatus.InVoltage
|
||||
m.status.BatVoltage += newStatus.BatVoltage
|
||||
|
||||
muninVal.InFrequency = newStatus.InFrequency
|
||||
muninVal.OutFrequency = newStatus.OutFrequency
|
||||
m.status.InFrequency = newStatus.InFrequency
|
||||
m.status.OutFrequency = newStatus.OutFrequency
|
||||
|
||||
muninVal.ChargeState = newStatus.ChargeState
|
||||
m.status.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)
|
||||
func calcMuninAverages(m *muninData) {
|
||||
m.status.OutCurrent /= float64(m.timesUpdated)
|
||||
m.status.InCurrent /= float64(m.timesUpdated)
|
||||
m.status.BatCurrent /= float64(m.timesUpdated)
|
||||
|
||||
muninVal.OutVoltage /= float64(muninDat.timesUpdated)
|
||||
muninVal.InVoltage /= float64(muninDat.timesUpdated)
|
||||
muninVal.BatVoltage /= float64(muninDat.timesUpdated)
|
||||
m.status.OutVoltage /= float64(m.timesUpdated)
|
||||
m.status.InVoltage /= float64(m.timesUpdated)
|
||||
m.status.BatVoltage /= float64(m.timesUpdated)
|
||||
}
|
||||
|
||||
func zeroMuninValues(muninDat *muninData) {
|
||||
muninDat.timesUpdated = 0
|
||||
muninVal := muninDat.status
|
||||
muninVal.OutCurrent = 0
|
||||
muninVal.InCurrent = 0
|
||||
muninVal.BatCurrent = 0
|
||||
func zeroMuninValues(m *muninData) {
|
||||
m.timesUpdated = 0
|
||||
m.status.OutCurrent = 0
|
||||
m.status.InCurrent = 0
|
||||
m.status.BatCurrent = 0
|
||||
|
||||
muninVal.OutVoltage = 0
|
||||
muninVal.InVoltage = 0
|
||||
muninVal.BatVoltage = 0
|
||||
m.status.OutVoltage = 0
|
||||
m.status.InVoltage = 0
|
||||
m.status.BatVoltage = 0
|
||||
|
||||
muninVal.InFrequency = 0
|
||||
muninVal.OutFrequency = 0
|
||||
m.status.InFrequency = 0
|
||||
m.status.OutFrequency = 0
|
||||
|
||||
muninVal.ChargeState = 0
|
||||
m.status.ChargeState = 0
|
||||
}
|
||||
|
||||
type templateInput struct {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"github.com/rakyll/statik/fs"
|
||||
|
||||
"log"
|
||||
"embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// New exports the static part of the webgui that is served via statik
|
||||
//go:embed css js index.html favicon.ico
|
||||
var content embed.FS
|
||||
|
||||
// New exports the static part of the webgui that is served via embed
|
||||
func New() http.Handler {
|
||||
statikFs, err := fs.New()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return http.FileServer(statikFs)
|
||||
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">
|
||||
{{ error.error_message }}
|
||||
</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="col">
|
||||
<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
12
plugins/webui/static/root/css/bootstrap.min.css
vendored
12
plugins/webui/static/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
@@ -31,16 +31,20 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"github.com/diebietse/invertergui/websocket"
|
||||
"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"
|
||||
@@ -49,25 +53,54 @@ const (
|
||||
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) *WebGui {
|
||||
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"`
|
||||
|
||||
@@ -92,12 +125,170 @@ type templateInput struct {
|
||||
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 {
|
||||
@@ -156,21 +347,32 @@ func buildTemplateInput(status *mk2driver.Mk2Info) *templateInput {
|
||||
}
|
||||
|
||||
func (w *WebGui) Stop() {
|
||||
log.Info("Stopping Web UI polling")
|
||||
close(w.stopChan)
|
||||
w.wg.Wait()
|
||||
log.Info("Web UI polling stopped")
|
||||
}
|
||||
|
||||
// dataPoll waits for data from the w.poller channel. It will send its currently stored status
|
||||
// to respChan if anything reads from it.
|
||||
func (w *WebGui) dataPoll() {
|
||||
for {
|
||||
select {
|
||||
case s := <-w.C():
|
||||
if s.Valid {
|
||||
err := w.hub.Broadcast(buildTemplateInput(s))
|
||||
if err != nil {
|
||||
log.Printf("Could not send update to clients: %v", err)
|
||||
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()
|
||||
@@ -178,3 +380,100 @@ func (w *WebGui) dataPoll() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebGui) getRemotePanelState() remotePanelState {
|
||||
w.stateMu.RLock()
|
||||
defer w.stateMu.RUnlock()
|
||||
return copyRemotePanelState(w.remote)
|
||||
}
|
||||
|
||||
func (w *WebGui) updateRemotePanelState(update func(state *remotePanelState)) {
|
||||
w.stateMu.Lock()
|
||||
update(&w.remote)
|
||||
w.remote.LastUpdated = time.Now().UTC().Format(time.RFC3339)
|
||||
log.WithFields(logrus.Fields{
|
||||
"mode": w.remote.Mode,
|
||||
"has_limit": w.remote.CurrentLimit != nil,
|
||||
"has_standby": w.remote.Standby != nil,
|
||||
"last_command": w.remote.LastCommand,
|
||||
"last_error": w.remote.LastError,
|
||||
}).Debug("Updated remote panel state cache")
|
||||
snapshot := w.snapshotLocked()
|
||||
w.stateMu.Unlock()
|
||||
|
||||
if snapshot != nil {
|
||||
if err := w.hub.Broadcast(snapshot); err != nil {
|
||||
log.Errorf("Could not send control update to clients: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebGui) snapshotLocked() *templateInput {
|
||||
if w.latest == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshot := cloneTemplateInput(w.latest)
|
||||
snapshot.RemotePanel = copyRemotePanelState(w.remote)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func cloneTemplateInput(in *templateInput) *templateInput {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
|
||||
if in.Error != nil {
|
||||
out.Error = append([]error(nil), in.Error...)
|
||||
}
|
||||
if in.LedMap != nil {
|
||||
out.LedMap = make(map[string]string, len(in.LedMap))
|
||||
for k, v := range in.LedMap {
|
||||
out.LedMap[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
out.RemotePanel = copyRemotePanelState(in.RemotePanel)
|
||||
return &out
|
||||
}
|
||||
|
||||
func copyRemotePanelState(in remotePanelState) remotePanelState {
|
||||
in.CurrentLimit = copyFloat64Ptr(in.CurrentLimit)
|
||||
in.Standby = copyBoolPtr(in.Standby)
|
||||
return in
|
||||
}
|
||||
|
||||
func copyFloat64Ptr(in *float64) *float64 {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
value := *in
|
||||
return &value
|
||||
}
|
||||
|
||||
func copyBoolPtr(in *bool) *bool {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
value := *in
|
||||
return &value
|
||||
}
|
||||
|
||||
func decodeJSONBody(r *http.Request, destination any) error {
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(rw http.ResponseWriter, statusCode int, payload any) {
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(statusCode)
|
||||
if err := json.NewEncoder(rw).Encode(payload); err != nil {
|
||||
log.Errorf("Could not encode webui API response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
||||
)
|
||||
|
||||
type templateTest struct {
|
||||
@@ -91,3 +91,53 @@ func TestTemplateInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePanelMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want mk2driver.PanelSwitchState
|
||||
wantRaw string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "on",
|
||||
input: "on",
|
||||
want: mk2driver.PanelSwitchOn,
|
||||
wantRaw: "on",
|
||||
},
|
||||
{
|
||||
name: "charger_only",
|
||||
input: "charger_only",
|
||||
want: mk2driver.PanelSwitchChargerOnly,
|
||||
wantRaw: "charger_only",
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
input: "banana",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, gotRaw, err := parsePanelMode(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("got switch %d, want %d", got, tt.want)
|
||||
}
|
||||
if gotRaw != tt.wantRaw {
|
||||
t.Fatalf("got mode %q, want %q", gotRaw, tt.wantRaw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user