Compare commits

..

1 Commits

Author SHA1 Message Date
Jarno Rankinen 4b3f7c6f45 Configuration file ~/.config/enervent-ctrl/configuration.yaml
If the file does not exist, will be generated with default values.
CLI flags override values from configuration file.
2023-03-14 12:36:03 +02:00
41 changed files with 1068 additions and 2062 deletions

View File

@ -1,9 +1,9 @@
name: Add issues to project
#on:
# issues:
# types:
# - opened
on:
issues:
types:
- opened
jobs:
add-to-project:

View File

@ -1,30 +0,0 @@
name: Build release binaries
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.22.1'
- name: Build release binaries
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GO_RELEASER_TOKEN }}

3
.gitignore vendored
View File

@ -1,4 +1 @@
.vscode/
.idea/
BUILD/*
TMP/*

View File

@ -1,32 +0,0 @@
version: 1
before:
hooks:
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
- arm
archives:
- format: binary
name_template: >-
{{ .ProjectName }}-
{{- .Os }}-
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 Jarno Rankinen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

171
README.md
View File

@ -4,161 +4,22 @@ External control of an Enervent Pingvin
Kotilämpö residential heating/ventilation
unit via RS485 bus using the Modbus protocol.
Provides a REST API for integration into Home Assistant,
with measurements and basic control over Pingvin functions.
Template YAML configurations for Home Assistant are included
in the `homeassistant` folder, intended to be simple to copy-paste
into Home Assistant's `configuration.yaml` with minimal necessary
modifications. These include sensor configurations, helpers and automations for button functions
and a ready made basic dashboard. No custom components are necessary.
![image](https://user-images.githubusercontent.com/50285623/228834067-503f9820-292c-4614-9316-6cec683e89ef.png)
The daemon is designed to run on a Linux host
that has some sort of RS-485 connector attached.
For development a RPi Zero W 1 with a
connected [Zihatec RS 485 HAT](https://www.hwhardsoft.de/english/projects/rs485-shield/?mobile=1)
has been used.
### Building
- clone or download the repo
- Build for the correct architecture, e.g. for Linux 32-bit ARM (Rpi Zero W 1):
```
cd /path/to/repo
env GOOS=linux GOARCH=arm go build -o BUILD/enervent-ctrl-linux-arm32
```
### Configuration:
- CLI flags:
```
-cert string
Path to SSL public key to use for HTTPS (default "~/.config/enervent-ctrl/certificate.pem")
-debug
Enable debug logging
-disable-auth
Disable HTTP basic authentication (default true)
-enable-metrics
Enable the built-in Prometheus exporter (default true)
-httplog
Enable HTTP access logging
-interval int
Set the interval of background updates (default 4)
-key string
Path to SSL private key to use for HTTPS (default "~/.config/enervent-ctrl/privatekey.pem")
-logfile string
Path to log file. Default is empty string, log to stdout
-password string
Password for HTTP Basic Authentication (default "enervent")
-read-only
Read only mode, no writes to device are allowed
-regenerate-certs ~/.config/enervent-ctrl/server.crt
Generate a new SSL certificate. A new one is generated on startup as ~/.config/enervent-ctrl/server.crt if it doesn't exist.
-serial string
Path to serial console for RS-485 connection. Defaults to /dev/ttyS0 (default "/dev/ttyS0")
-username string
Username for HTTP Basic Authentication (default "pingvin")
```
On first run, the daemon generates `~/.config/enervent-ctrl/configuration.yaml` with default values.
Configuration options are the same as with CLI flags. CLI flags take precedence over the config file.
- `serial_address:` Path to RS-485 serial device
- `port:` TCP port for the REST API to listen on
- `ssl_certificate:` Path to SSL certificate for HTTPS
- `ssl_privatekey:` Path to SSL private key for HTTPS
- `username:` Username for REST API HTTP Basic Auth
- `password:` Password for REST API HTTP Basic Auth
- `interval:` Interval of background updates from Modbus
- `enable_metrics:` Enable the built-in Prometheus exporter
- `log_file:` Path to log file, default logging is to STDOUT
- `log_access:` Enable HTTP Access logging to logfile/STDOUT
- `debug:` Enable debug logging
### Running
- Upload the built executable along with `coils.csv` and `registers.csv` to the target host. The files should
be placed in the same folder.
- Run the binary as a regular user. Adding the user to the correct group for serial access may be necessary
- To run persistently, you can use `screen`, `tmux`, or generate a user systemd service unit file.
- Example systemd service file (named e.g. enervent-ctrl.service):
```
[Unit]
Description=Enervent-ctrl
After=network-online.target
[Service]
Type=simple
Restart=on-failure
RestartSec=30
ExecStart=/path/to/enervent-ctrl-executable
[Install]
WantedBy=default.target
```
- Replace paths in the file and place it under `~/.config/systemd/user`. Create the folder if it doesn't exist.
- `systemctl --user daemon-reload`
- `systemctl --user enable --now enervent-ctrl.service`
- To let user services continue running after logging out:
- `sudo loginctl enable-linger $USER`
***
# Disclaimer:
**I am not responsible of possible damage to your device if you choose to follow these instructions**
**The manufacturer may void your warranty if you choose to follow these instructions**
***
### Connecting to the Pingvin unit
#### RPi/computer running the daemon
- Connect an RS-485 adapter to the computer you intend to run the daemon on
- Tested on:
- RPi 4B and Zero W 1, generic x86_64 linux machines (Alma Linux 8 & 9, Fedora)
- Zihatec RS-485 HAT with the Pis
- generic USB-RS485 adapter (checksum errors considerably more often, but nothing critical)
- Ensure the user you intend to run the daemon as has read/write privileges to the serial device.
- **Not recommended and no need to run as root**
- Usually adding the user running the executable to the `dialout` group gives permissions to serial devices
#### Pingvin
- Shut down the main power of the unit
- Disconnect the device from mains, discharge any static electricity before proceeding
- A new motherboard seems to cost close to 1000€ + labour
- Open the cover in which the power switch is attached to. No need to disconnect the switch, there
should be enough length in the wires to move the lid with the switch connected out of the way
![IMG_20230114_133625](https://user-images.githubusercontent.com/50285623/229897490-33d917be-9dea-4b74-bfed-c7b25f9f45f6.jpg)
- Locate the green RS-485 connector on the motherboard, should be on the right edge
- Schematics available from Enervent at [https://doc.enervent.com/op/op.ViewOnline.php?documentid=940&version=1](https://doc.enervent.com/op/op.ViewOnline.php?documentid=940&version=1), page 38 (finnish)
![IMG_20230114_133824](https://user-images.githubusercontent.com/50285623/229898136-ce7dc020-6c33-4605-86ff-5285000cbbd2.jpg)
- There should be available outlet holes to pass the wires through on the top of the electronics compartment.
- The connector has a detachable plug part. Grab the top of the connector (the part with the screws) with plyers and carefully pull it out. This will make attaching the wire much easier
- Attach wires by tightening the screws in the connector
- Connect **A connector to A connector and B to B**. (they are not Tx/Rx like in many other serials)
- **NOTE:** After reading quite a few forum posts, many RS-485 adapters seem to have printed the A and B the wrong way, I wouldn't be surprised if this was the case with Pingvin too.
![IMG_20230114_133936](https://user-images.githubusercontent.com/50285623/229900176-5bac0027-80c6-4702-ab74-0ff2b9739507.jpg)
- Plug the plug back to the Pingvin motherboard and close the cover and screws
![IMG_20230114_135258](https://user-images.githubusercontent.com/50285623/229899975-45126a64-7344-4ca0-bfba-c4e524ebe2f8.jpg)
- Reconnect mains and switch both devices on
- Mixing A and B should be safe and won't break anything, but the daemon won't work. If that's the case, disconnect power again and switch the wires on the RPi end.
### Home Assistant
- There are so many variations for HASS configs, that definite instructions are hard to do.
- All the YAMLs are intended to be copy-pasted to `configuration.yaml` (or files included to configuration.yaml)
- Contents of `homeassistant/automations.yaml` to automations.yaml in your HA `config/` folder
- Contents of `homeassistant/homeassistant-rest.yaml` and `homeassistant/helpers.yaml` to configuration.yaml in your HA `config/` folder
- Replace IP_ADDRESS with the correct IP address, for example with sed: `sed -i 's/IP_ADDRESS/192.168.4.5/g' configuration.yaml`
- If you set a different port for enervent-ctrl, use `sed -i 's/IP_ADDRESS:8888/192.168.4.5:9999/g' configuration.yaml`
- Dashboard:
- create an empty dashboard
- opening the YAML editor in the HA Lovelace UI
- copy the contents from `homeassistant/dashboard-en/fi.yaml` to the editor as-is.
- Change the IP address, port, username and password according to your configuration
- Restart Home Assistant (A full reload doesn't seem to be enough for all REST integration features to update)
Work is part of my Bachelor's Thesis at Oulu University
Work part of my Bachelor's Thesis at Oulu University
of Applied Sciences.
Pingvin and Kotilämpö are registered trademarks of Enervent Zehnder Oy.
The Python version under `enervent-ctrl-python`
is an initial proof-of-concept,
mainly to test that the hardware side of things
works as expected. The main daemon is written
in Go and the source is under `enervent-ctrl-go`
<sup><sub>Github is used to build the binaries and container images with Github Actions, and host pre-built releases.
Mirrored from https://git.oranki.net/jarno/enervent-ctrl</sub></sup>
The daemon is designed to run on a Linux host
that has some sort of RS485 connector attached.
For development a Raspberry Pi 4B was initially
used for convenience, but after the Go
implementation started, a RPi Zero W 1 with a
connected [Zihatec RS 485 HAT](https://www.hwhardsoft.de/english/projects/rs485-shield/?mobile=1)
has been used to make sure the daemon stays as
lightweight as possible.
Pingvin and Kotilämpö are registered trademarks of Enervent Zehnder Oy.

View File

@ -1,15 +0,0 @@
#!/bin/bash -x
pwd
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
echo -e "Usage: $0 [ARCH|-h|--help]"
echo -e "\tARCH: amd64 (default), arm, arm64"
exit
fi
ARCH=${1:-"amd64"}
VERSION=$(grep -e 'version.*=' main.go | awk '{print $3}' | tr -d '"')
CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build -o "BUILD/enervent-ctrl-${VERSION}.linux-$ARCH" .

2
enervent-ctrl-go/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
build.sh
BUILD/*

View File

@ -36,7 +36,7 @@
35;-;-
36;COIL_TEMP_DECREASE;Temperature decrease function desc
37;COIL_OVERTIME;Programmatic equivalent of OVERTIME digital input.
38;COIL_EMERG_STOP;Emergency stop switch type desc
38;-;Emergency stop switch type desc
39;-;-
40;COIL_ECO_MODE;Eco mode desc
41;COIL_ALARM_A;Alarm of class A active desc
1 0 COIL_STOP Stop the machine
36 35 - -
37 36 COIL_TEMP_DECREASE Temperature decrease function desc
38 37 COIL_OVERTIME Programmatic equivalent of OVERTIME digital input.
39 38 COIL_EMERG_STOP - Emergency stop switch type desc
40 39 - -
41 40 COIL_ECO_MODE Eco mode desc
42 41 COIL_ALARM_A Alarm of class A active desc

17
enervent-ctrl-go/go.mod Normal file
View File

@ -0,0 +1,17 @@
module github.com/0ranki/enervent-ctrl/enervent-ctrl-go
go 1.18
require (
github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433
github.com/goburrow/modbus v0.1.0
github.com/gorilla/handlers v1.5.1
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
require (
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/goburrow/serial v0.1.0 // indirect
)

16
enervent-ctrl-go/go.sum Normal file
View File

@ -0,0 +1,16 @@
github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433 h1:QT2IRJnhIdCSr26LJktnZnBpHdiLfTrUFzLSdP3h9Wo=
github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433/go.mod h1:r4Jb05+PuiVKHDYwSsSBuSz4LpOlC2DgOY4N58+K8Hk=
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/goburrow/modbus v0.1.0 h1:DejRZY73nEM6+bt5JSP6IsFolJ9dVcqxsYbpLbeW/ro=
github.com/goburrow/modbus v0.1.0/go.mod h1:Kx552D5rLIS8E7TyUwQ/UdHEqvX5T8tyiGBTlzMcZBg=
github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA=
github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

337
enervent-ctrl-go/main.go Normal file
View File

@ -0,0 +1,337 @@
package main
import (
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/json"
"flag"
"io/fs"
"io/ioutil"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/0ranki/enervent-ctrl/enervent-ctrl-go/pingvinKL"
"github.com/0ranki/https-go"
"github.com/gorilla/handlers"
"gopkg.in/yaml.v3"
)
// Remember to dereference the symbolic links under ./static/html
// prior to building the binary e.g. by using tar
//go:embed static/html/*
var static embed.FS
var (
version = "0.0.22"
pingvin pingvinKL.PingvinKL
config Conf
usernamehash [32]byte
passwordhash [32]byte
)
type Conf struct {
Port int `yaml:"port"`
SslCertificate string `yaml:"ssl_certificate"`
SslPrivatekey string `yaml:"ssl_privatekey"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Interval int `yaml:"interval"`
LogAccess bool `yaml:"log_access"`
Debug bool `yaml:"debug"`
}
// HTTP Basic Authentication middleware for http.HandlerFunc
func authHandlerFunc(next http.HandlerFunc) http.HandlerFunc {
// Based on https://www.alexedwards.net/blog/basic-authentication-in-go
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok {
userHash := sha256.Sum256([]byte(user))
passHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1)
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
if len(user) == 0 {
user = "-"
}
log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// HTTP Basic Authentication middleware for http.Handler
func authHandler(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok {
userHash := sha256.Sum256([]byte(user))
passHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1)
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
if len(user) == 0 {
user = "-"
}
log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// \/api/v1/coils endpoint
func coils(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/coils/"), "/")
if len(pathparams[0]) == 0 {
json.NewEncoder(w).Encode(pingvin.Coils)
} else if len(pathparams[0]) > 0 && r.Method == "GET" && len(pathparams) < 2 { // && r.Method == "POST"
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
pingvin.ReadCoil(uint16(intaddr))
json.NewEncoder(w).Encode(pingvin.Coils[intaddr])
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 2 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
boolval, err := strconv.ParseBool(pathparams[1])
if err != nil {
log.Println("ERROR: Could not parse coil value", pathparams[1])
log.Println(err)
return
}
pingvin.WriteCoil(uint16(intaddr), boolval)
json.NewEncoder(w).Encode(pingvin.Coils[intaddr])
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 1 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
pingvin.WriteCoil(uint16(intaddr), !pingvin.Coils[intaddr].Value)
json.NewEncoder(w).Encode(pingvin.Coils[intaddr])
}
}
// \/api/v1/registers endpoint
func registers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/registers/"), "/")
if len(pathparams[0]) == 0 {
json.NewEncoder(w).Encode(pingvin.Registers)
} else if len(pathparams[0]) > 0 && r.Method == "GET" && len(pathparams) < 2 { // && r.Method == "POST"
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse register address", pathparams[0])
log.Println(err)
return
}
pingvin.ReadRegister(uint16(intaddr))
json.NewEncoder(w).Encode(pingvin.Registers[intaddr])
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 2 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse register address", pathparams[0])
log.Println(err)
return
}
intval, err := strconv.Atoi(pathparams[1])
if err != nil {
log.Println("ERROR: Could not parse register value", pathparams[1])
log.Println(err)
return
}
_, err = pingvin.WriteRegister(uint16(intaddr), uint16(intval))
if err != nil {
log.Println(err)
}
json.NewEncoder(w).Encode(pingvin.Registers[intaddr])
}
}
// \/status endpoint
func status(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(pingvin.Status)
}
// \/api/v1/temperature endpoint
func temperature(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/temperature/"), "/")
if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 1 {
pingvin.Temperature(pathparams[0])
json.NewEncoder(w).Encode(pingvin.Registers[135])
} else {
return
}
}
// Start the HTTP server
func serve(cert, key *string) {
log.Println("Starting pingvinAPI...")
http.HandleFunc("/api/v1/coils/", authHandlerFunc(coils))
http.HandleFunc("/api/v1/status", authHandlerFunc(status))
http.HandleFunc("/api/v1/registers/", authHandlerFunc(registers))
http.HandleFunc("/api/v1/temperature/", authHandlerFunc(temperature))
html, err := fs.Sub(static, "static/html")
if err != nil {
log.Fatal(err)
}
htmlroot := http.FileServer(http.FS(html))
http.HandleFunc("/", authHandler(htmlroot))
logdst, err := os.OpenFile(os.DevNull, os.O_WRONLY, os.ModeAppend)
if err != nil {
log.Fatal(err)
}
if config.LogAccess {
logdst = os.Stdout
}
handler := handlers.LoggingHandler(logdst, http.DefaultServeMux)
err = http.ListenAndServeTLS(":8888", *cert, *key, handler)
if err != nil {
log.Fatal(err)
}
}
// Generate self-signed SSL keypair
func generateCertificate(cert, key string) {
opts := https.GenerateOptions{Host: "enervent-ctrl.local", RSABits: 4096, ValidFor: 10 * 365 * 24 * time.Hour}
log.Println("Generating new self-signed SSL keypair")
log.Println("This may take a while...")
pub, priv, err := https.GenerateKeys(opts)
if err != nil {
log.Fatal("Error generating SSL certificate: ", err)
}
pingvin.Debug.Println("Certificate:\n", string(pub))
pingvin.Debug.Println("Key:\n", string(priv))
if err := os.WriteFile(key, priv, 0600); err != nil {
log.Fatal("Error writing private key ", key, ": ", err)
}
log.Println("Wrote new SSL private key ", cert)
if err := os.WriteFile(cert, pub, 0644); err != nil {
log.Fatal("Error writing certificate ", cert, ": ", err)
}
log.Println("Wrote new SSL public key ", cert)
}
// Read & parse the configuration file
func parseConfigFile() {
homedir, err := os.UserHomeDir()
if err != nil {
log.Fatal("Could not determine user home directory")
}
confpath := homedir + "/.config/enervent-ctrl"
if _, err := os.Stat(confpath); err != nil {
log.Println("Generating configuration directory", confpath)
if err := os.MkdirAll(confpath, 0700); err != nil {
log.Fatal("Failed to generate configuration directory:", err)
}
}
conffile := confpath + "/configuration.yaml"
yamldata, err := ioutil.ReadFile(conffile)
if err != nil {
log.Println("Configuration file", conffile, "not found")
log.Println("Generating", conffile, "with default values")
initDefaultConfig(confpath)
if yamldata, err = ioutil.ReadFile(conffile); err != nil {
log.Fatal("Error parsing configuration:", err)
}
}
err = yaml.Unmarshal(yamldata, &config)
if err != nil {
log.Fatal("Failed to parse YAML:", err)
}
}
// Write the default configuration to $HOME/.config/enervent-ctrl/configuration.yaml
func initDefaultConfig(confpath string) {
config = Conf{
8888,
confpath + "/certificate.pem",
confpath + "/privatekey.pem",
"pingvin",
"enervent",
4,
false,
false,
}
conffile := confpath + "/configuration.yaml"
confbytes, err := yaml.Marshal(&config)
if err != nil {
log.Println("Error writing default configuration:", err)
}
if err := os.WriteFile(conffile, confbytes, 0600); err != nil {
log.Fatal("Failed to write default configuration:", err)
}
}
// Read configuration. CLI flags take presedence over configuration file
func configure() {
log.Println("Reading configuration")
parseConfigFile()
debugflag := flag.Bool("debug", config.Debug, "Enable debug logging")
intervalflag := flag.Int("interval", config.Interval, "Set the interval of background updates")
logaccflag := flag.Bool("httplog", config.LogAccess, "Enable HTTP access logging")
generatecert := flag.Bool("regenerate-certs", false, "Generate a new SSL certificate. A new one is generated on startup as `~/.config/enervent-ctrl/server.crt` if it doesn't exist.")
certflag := flag.String("cert", config.SslCertificate, "Path to SSL public key to use for HTTPS")
keyflag := flag.String("key", config.SslPrivatekey, "Path to SSL private key to use for HTTPS")
usernflag := flag.String("username", config.Username, "Username for HTTP Basic Authentication")
passwflag := flag.String("password", config.Password, "Password for HTTP Basic Authentication")
// TODO: log file flag
flag.Parse()
config.Debug = *debugflag
config.Interval = *intervalflag
config.LogAccess = *logaccflag
config.SslCertificate = *certflag
config.SslPrivatekey = *keyflag
config.Username = *usernflag
config.Password = *passwflag
usernamehash = sha256.Sum256([]byte(config.Username))
passwordhash = sha256.Sum256([]byte(config.Password))
// Check that certificate file exists, generate if needed
if _, err := os.Stat(config.SslCertificate); err != nil || *generatecert {
generateCertificate(config.SslCertificate, config.SslPrivatekey)
}
// Enable debug if configured
if config.Debug {
log.Println("Debug logging enabled")
}
// Enable HTTP access logging if configured
if config.LogAccess {
log.Println("HTTP Access logging enabled")
}
log.Println("Update interval set to", config.Interval, "seconds")
}
func main() {
log.Println("enervent-ctrl version", version)
configure()
pingvin = pingvinKL.New(config.Debug)
pingvin.Update()
go pingvin.Monitor(config.Interval)
serve(&config.SslCertificate, &config.SslPrivatekey)
pingvin.Quit()
}

View File

@ -1,4 +1,4 @@
package pingvin
package pingvinKL
import (
"bufio"
@ -11,7 +11,6 @@ import (
"time"
"github.com/goburrow/modbus"
"github.com/prometheus/client_golang/prometheus"
)
// single coil data
@ -21,18 +20,17 @@ type pingvinCoil struct {
Value bool `json:"value"`
Description string `json:"description"`
Reserved bool `json:"reserved"`
PromDesc *prometheus.Desc `json:"-"`
}
// unit modbus data
type Pingvin struct {
Coils []*pingvinCoil
Registers []*pingvinRegister
Status *pingvinStatus
type PingvinKL struct {
Coils []pingvinCoil
Registers []pingvinRegister
Status pingvinStatus
buslock *sync.Mutex
statuslock *sync.Mutex
handler *modbus.RTUClientHandler
modbusclient modbus.Client
firstReadDone bool
Debug PingvinLogger
}
@ -46,7 +44,6 @@ type pingvinRegister struct {
Description string `json:"description"`
Reserved bool `json:"reserved"`
Multiplier int `json:"multiplier"`
PromDesc *prometheus.Desc `json:"-"`
}
type pingvinMeasurements struct {
@ -68,15 +65,13 @@ type pingvinStatus struct {
HrcPct int `json:"hrc_pct"` // Heat recovery turn speed
TempSetting float32 `json:"temp_setting"` // Requested room temperature
FanPct int `json:"fan_pct"` // Circulation fan setting
FanPctIn int `json:"fan_pct_in"` // Intake fan setting
FanPctEx int `json:"fan_pct_ex"` // Exhaust fan setting
Measurements pingvinMeasurements `json:"measurements"` // Measurements
HrcEffIn int `json:"hrc_efficiency_in"` // Calculated HRC efficiency, intake
HrcEffEx int `json:"hrc_efficiency_ex"` // Calculated HRC efficiency, extract
OpMode string `json:"op_mode"` // Current operating mode, text representation
Uptime string `json:"uptime"` // Unit uptime
SystemTime string `json:"system_time"` // Time and date in unit
Coils []*pingvinCoil `json:"coils"`
Coils []pingvinCoil `json:"coils"`
}
type PingvinLogger struct {
@ -104,29 +99,17 @@ func (logger *PingvinLogger) Println(msg ...any) {
}
}
func newCoil(address string, symbol string, description string) *pingvinCoil {
func newCoil(address string, symbol string, description string) pingvinCoil {
addr, err := strconv.Atoi(address)
if err != nil {
log.Fatal("newCoil: Atoi: ", err)
}
reserved := symbol == "-" && description == "-"
if !reserved {
promdesc := strings.ToLower(symbol)
zpadaddr := fmt.Sprintf("%02d", addr)
promdesc = strings.Replace(promdesc, "_", "_"+zpadaddr+"_", 1)
return &pingvinCoil{addr, symbol, false, description, reserved,
prometheus.NewDesc(
prometheus.BuildFQName("", "pingvin", promdesc),
description,
nil,
nil,
),
}
}
return &pingvinCoil{addr, symbol, false, description, reserved, nil}
coil := pingvinCoil{addr, symbol, false, description, reserved}
return coil
}
func newRegister(address, symbol, typ, multiplier, description string) *pingvinRegister {
func newRegister(address, symbol, typ, multiplier, description string) pingvinRegister {
addr, err := strconv.Atoi(address)
if err != nil {
log.Fatal("newRegister: Atoi(address): ", err)
@ -139,29 +122,8 @@ func newRegister(address, symbol, typ, multiplier, description string) *pingvinR
}
}
reserved := symbol == "Reserved" && description == "Reserved"
if !reserved {
promdesc := strings.ToLower(symbol)
zpadaddr := fmt.Sprintf("%03d", addr)
promdesc = strings.Replace(promdesc, "_", "_"+zpadaddr+"_", 1)
return &pingvinRegister{
addr,
symbol,
0,
"0000000000000000",
typ,
description,
reserved,
multipl,
prometheus.NewDesc(
prometheus.BuildFQName("", "pingvin", promdesc),
description,
nil,
nil,
),
}
}
return &pingvinRegister{addr, symbol, 0, "0000000000000000", typ, description, reserved, multipl, nil}
register := pingvinRegister{addr, symbol, 0, "0000000000000000", typ, description, reserved, multipl}
return register
}
// read a CSV file containing data for coils or registers
@ -186,10 +148,9 @@ func readCsvLines(file string) [][]string {
// Create modbus.Handler, store it in p.handler,
// connect the handler and create p.modbusclient (modbus.Client)
func (p *Pingvin) createModbusClient(serial string) {
// TODO: read configuration from file, mostly hardcoded for now
log.Println("Connecting to serial console on", serial)
p.handler = modbus.NewRTUClientHandler(serial)
func (p *PingvinKL) createModbusClient() {
// TODO: read configuration from file, hardcoded for now
p.handler = modbus.NewRTUClientHandler("/dev/ttyS0")
p.handler.BaudRate = 19200
p.handler.DataBits = 8
p.handler.Parity = "N"
@ -204,7 +165,7 @@ func (p *Pingvin) createModbusClient(serial string) {
p.modbusclient = modbus.NewClient(p.handler)
}
func (p *Pingvin) Quit() {
func (p *PingvinKL) Quit() {
err := p.handler.Close()
if err != nil {
log.Println("ERROR: Quit:", err)
@ -212,24 +173,12 @@ func (p *Pingvin) Quit() {
}
// Update all coil values
func (p *Pingvin) updateCoils() {
var results []byte
var err error
for retries := 1; retries <= 5; retries++ {
p.Debug.Println("Reading coils, attempt", retries)
func (p *PingvinKL) updateCoils() {
p.buslock.Lock()
results, err = p.modbusclient.ReadCoils(0, uint16(len(p.Coils)))
results, err := p.modbusclient.ReadCoils(0, uint16(len(p.Coils)))
p.buslock.Unlock()
if len(results) > 0 {
break
} else if retries == 4 {
log.Println("ERROR: updateCoils: client.Readcoils: ", err)
return
}
if err != nil {
log.Printf("WARNING updateCoils: client.ReadCoils attempt %d: %s\n", retries, err)
}
time.Sleep(100 * time.Millisecond)
log.Fatal("updateCoils: client.ReadCoils: ", err)
}
// modbus.ReadCoils returns a byte array, with the first byte's bits representing coil values 0-7,
// second byte coils 8-15 etc.
@ -252,13 +201,13 @@ func (p *Pingvin) updateCoils() {
// Read a single holding register, stores value in p.Registers
// Returns integer value of register
func (p *Pingvin) ReadRegister(addr uint16) (int, error) {
func (p *PingvinKL) ReadRegister(addr uint16) (int, error) {
p.buslock.Lock()
results, err := p.modbusclient.ReadHoldingRegisters(addr, 1)
p.buslock.Unlock()
if err != nil {
//log.Println("ERROR: ReadRegister:", err)
return p.Registers[addr].Value, err
log.Println("ERROR: ReadRegister:", err)
return 0, err
}
if p.Registers[addr].Type == "uint16" {
p.Registers[addr].Value = int(uint16(results[0]) << 8)
@ -271,7 +220,7 @@ func (p *Pingvin) ReadRegister(addr uint16) (int, error) {
}
// Update a single holding register
func (p *Pingvin) WriteRegister(addr uint16, value uint16) (uint16, error) {
func (p *PingvinKL) WriteRegister(addr uint16, value uint16) (uint16, error) {
p.buslock.Lock()
_, err := p.modbusclient.WriteSingleRegister(addr, value)
p.buslock.Unlock()
@ -285,14 +234,13 @@ func (p *Pingvin) WriteRegister(addr uint16, value uint16) (uint16, error) {
return 0, err
}
if val == int(value) {
log.Printf("Wrote register %d to value %d (%s: %s)", addr, p.Registers[addr].Value, p.Registers[addr].Symbol, p.Registers[addr].Description)
return value, nil
}
return 0, fmt.Errorf("Failed to write register")
}
// Update all holding register values
func (p *Pingvin) updateRegisters() {
func (p *PingvinKL) updateRegisters() {
var err error
regs := len(p.Registers)
k := 0
@ -304,8 +252,8 @@ func (p *Pingvin) updateRegisters() {
if regs-k < 125 {
r = regs - k
}
var results []byte
for retries := 1; retries <= 5; retries++ {
results := []byte{}
for retries := 0; retries < 5; retries++ {
p.Debug.Println("Reading registers, attempt", retries, "k:", k)
p.buslock.Lock()
results, err = p.modbusclient.ReadHoldingRegisters(uint16(k), uint16(r))
@ -313,18 +261,11 @@ func (p *Pingvin) updateRegisters() {
if len(results) > 0 {
break
} else if retries == 4 {
log.Printf("ERROR: updateRegisters: max retries reached, giving up. client.ReadHoldingRegisters: %v", err)
log.Printf("ERROR: error occurred when reading registers %d - %d", k, k+r-1)
if !p.firstReadDone {
panic("FATAL: Error on initial read")
}
return
log.Fatal("updateRegisters: client.ReadHoldingRegisters: ", err)
} else if err != nil {
log.Printf("WARNING: updateRegisters: client.ReadHoldingRegisters attempt %d: %s", retries, err)
log.Println("WARNING: updateRegisters: client.ReadHoldingRegisters: ", err)
}
time.Sleep(200 * time.Millisecond)
}
p.firstReadDone = true
// The values represent 16 bit integers, but modbus works with bytes
// Each even byte of the returned []byte is the 8 MSBs of a new 16-bit
// value, so for each even byte in the reponse slice we bitshift the byte
@ -366,37 +307,29 @@ func (p *Pingvin) updateRegisters() {
// Wrapper function for updating coils, registers and populating
// p.Status for Home Assistant
func (p *Pingvin) Update() {
func (p *PingvinKL) Update() {
p.updateCoils()
p.updateRegisters()
p.populateStatus()
}
// Read single coil
func (p *Pingvin) ReadCoil(n uint16) (err error) {
var results []byte
for retries := 1; retries <= 5; retries++ {
func (p PingvinKL) ReadCoil(n uint16) ([]byte, error) {
p.buslock.Lock()
results, err = p.modbusclient.ReadCoils(n, 1)
results, err := p.modbusclient.ReadCoils(n, 1)
p.buslock.Unlock()
if len(results) > 0 && err == nil {
break
} else if retries == 4 {
//log.Println("ERROR ReadCoil: client.ReadCoils: ", err)
return
} else if err != nil {
log.Printf("WARNING: ReadCoil: client.ReadCoils attempt %d: %s", retries, err)
}
time.Sleep(100 * time.Millisecond)
if err != nil {
log.Fatal("ReadCoil: client.ReadCoils: ", err)
return nil, err
}
p.Coils[n].Value = results[0] == 1
return
return results, nil
}
// Force a single coil
func (p *Pingvin) WriteCoil(n uint16, val bool) bool {
func (p *PingvinKL) WriteCoil(n uint16, val bool) bool {
if val {
_ = p.checkMutexCoils(n) //, p.handler)
p.checkMutexCoils(n, p.handler)
}
var value uint16 = 0
if val {
@ -408,20 +341,19 @@ func (p *Pingvin) WriteCoil(n uint16, val bool) bool {
if err != nil {
log.Println("ERROR: WriteCoil: ", err)
}
if (val && results[0] != 255) && (!val && results[0] != 0) {
if (val && results[0] == 255) || (!val && results[0] == 0) {
log.Println("WriteCoil: wrote coil", n, "to value", val)
} else {
log.Println("ERROR: WriteCoil: failed to write coil")
return false
}
err = p.ReadCoil(n)
if err != nil {
log.Printf("ERROR WriteCoil: p.ReadCoil: %s", err)
}
log.Printf("Wrote coil %d to value %v (%s: %s)", n, p.Coils[n].Value, p.Coils[n].Symbol, p.Coils[n].Description)
p.ReadCoil(n)
return true
}
// Force multiple coils
func (p *Pingvin) WriteCoils(startaddr uint16, quantity uint16, vals []bool) error {
func (p *PingvinKL) WriteCoils(startaddr uint16, quantity uint16, vals []bool) error {
p.updateCoils()
coilslice := p.Coils[startaddr:(startaddr + quantity)]
if len(coilslice) != len(vals) {
@ -472,7 +404,7 @@ func (p *Pingvin) WriteCoils(startaddr uint16, quantity uint16, vals []bool) err
// Some of the coils are mutually exclusive, and can only be 1 one at a time.
// Check if coil is one of them and force all of them to 0 if so
func (p *Pingvin) checkMutexCoils(addr uint16) error { //, handler *modbus.RTUClientHandler) error {
func (p *PingvinKL) checkMutexCoils(addr uint16, handler *modbus.RTUClientHandler) error {
for _, mutexcoil := range mutexcoils {
if mutexcoil == addr {
for _, n := range mutexcoils {
@ -493,8 +425,7 @@ func (p *Pingvin) checkMutexCoils(addr uint16) error { //, handler *modbus.RTUCl
}
// populate p.Status struct for Home Assistant
func (p *Pingvin) populateStatus() {
p.Status = &pingvinStatus{}
func (p *PingvinKL) populateStatus() {
hpct := p.Registers[49].Value / p.Registers[49].Multiplier
if hpct > 100 {
p.Status.HeaterPct = hpct - 100
@ -505,8 +436,6 @@ func (p *Pingvin) populateStatus() {
}
p.Status.TempSetting = float32(p.Registers[135].Value) / float32(p.Registers[135].Multiplier)
p.Status.FanPct = p.Registers[774].Value / p.Registers[774].Multiplier
p.Status.FanPctIn = p.Registers[3].Value / p.Registers[3].Multiplier
p.Status.FanPctEx = p.Registers[4].Value / p.Registers[4].Multiplier
p.Status.Measurements.Roomtemp1 = float32(p.Registers[1].Value) / float32(p.Registers[1].Multiplier)
p.Status.Measurements.SupplyHeated = float32(p.Registers[8].Value) / float32(p.Registers[8].Multiplier)
p.Status.Measurements.SupplyHrc = float32(p.Registers[7].Value) / float32(p.Registers[7].Multiplier)
@ -562,7 +491,7 @@ func parseStatus(value int) string {
// a decimal degree value (20.0 - 23.0), or full degrees (20-30)
// Temperature must be between 20 and 30 deg Celsius, otherwise
// returns an error
func (p *Pingvin) Temperature(action string) error {
func (p *PingvinKL) Temperature(action string) error {
temperature := 0
if action == "up" {
temperature = p.Registers[135].Value + 1*p.Registers[135].Multiplier
@ -600,7 +529,7 @@ func (p *Pingvin) Temperature(action string) error {
return nil
}
func (p *Pingvin) Monitor(interval int) {
func (p *PingvinKL) Monitor(interval int) {
for {
time.Sleep(time.Duration(interval) * time.Second)
p.Debug.Println("Updating values")
@ -608,52 +537,12 @@ func (p *Pingvin) Monitor(interval int) {
}
}
// Implements prometheus.Describe()
func (p *Pingvin) Describe(ch chan<- *prometheus.Desc) {
for _, hreg := range p.Registers {
if !hreg.Reserved {
ch <- hreg.PromDesc
}
}
for _, coil := range p.Coils {
if !coil.Reserved {
ch <- coil.PromDesc
}
}
}
// Implements prometheus.Collect()
func (p *Pingvin) Collect(ch chan<- prometheus.Metric) {
for _, hreg := range p.Registers {
if !hreg.Reserved {
ch <- prometheus.MustNewConstMetric(
hreg.PromDesc,
prometheus.GaugeValue,
float64(hreg.Value)/float64(hreg.Multiplier),
)
}
}
for _, coil := range p.Coils {
val := 0
if coil.Value {
val = 1
}
if !coil.Reserved {
ch <- prometheus.MustNewConstMetric(
coil.PromDesc,
prometheus.GaugeValue,
float64(val),
)
}
}
}
// create a Pingvin struct, read coils and registers from CSVs
func New(serial string, debug bool) *Pingvin {
pingvin := Pingvin{}
// create a PingvinKL struct, read coils and registers from CSVs
func New(debug bool) PingvinKL {
pingvin := PingvinKL{}
pingvin.Debug.dbg = debug
pingvin.buslock = &sync.Mutex{}
pingvin.createModbusClient(serial)
pingvin.createModbusClient()
log.Println("Parsing coil data...")
coilData := readCsvLines("coils.csv")
for i := 0; i < len(coilData); i++ {
@ -667,5 +556,5 @@ func New(serial string, debug bool) *Pingvin {
newRegister(registerData[i][0], registerData[i][1], registerData[i][2], registerData[i][3], registerData[i][6]))
}
log.Println("Parsed", len(pingvin.Registers), "registers")
return &pingvin
return pingvin
}

View File

@ -1,4 +1,4 @@
package pingvin
package pingvinKL
import (
"fmt"

View File

@ -0,0 +1 @@
../index.html

View File

@ -0,0 +1 @@
../index.html

6
enervent-ctrl-python/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
*/__pycache__/
bin/
include/
lib/
lib64
share/

View File

@ -0,0 +1 @@
../index.html

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/static/tabledata.css">
<script src="/static/tabledata.js"></script>
<meta charset="UTF-8">
<title>Enervent Pingvin Kotilämpö</title>
</head>
<body onload="getData()">
<table id="data">
<caption>Coil values at <span id="time"></span></caption>
<thead><th>Address</th><th>Value</th><th>Symbol</th><th>Description</th></thead>
<tbody id="coildata"></tbody>
</table>
</body>
</html>

View File

@ -0,0 +1 @@
../index.html

View File

@ -0,0 +1,17 @@
.addr {
text-align: center;
}
.val {
text-align: center;
}
#data {
padding: 2pt;
border-collapse: collapse;
}
thead {
border-bottom: 1px solid;
text-align: left;
}
td {
padding: 2pt;
}

View File

@ -0,0 +1,52 @@
function zeroPad(number) {
return ("0" + number).slice(-2)
}
function getData() {
now = new Date()
Y = now.getFullYear()
m = now.getMonth()
d = now.getDate()
H = zeroPad(now.getHours())
M = zeroPad(now.getMinutes())
S = zeroPad(now.getSeconds())
document.getElementById('time').innerHTML = `${Y}-${m}-${d} ${H}:${M}:${S}`
error = false
// The same index.html is used for both coil and register data,
// change api url based on which we're looking at
if (document.location.pathname == "/coils/") {
url = "/api/v1/coils"
}
else if (document.location.pathname == "/registers/") {
url = "/api/v1/registers"
}
else {
document.getElementById("data").innerHTML = 'Page not found'
error = true
}
if (!error) {
// Fetch data from API
fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(`Error fetching data: ${response.status}`)
}
return response.json()
})
.then((data) => {
// Populate table
document.getElementById('coildata').innerHTML = "";
for (n=0; n<data.length; n++) {
tablerow = `<tr><td class="addr" id="addr_${data[n].address}">${data[n].address}</td>\
<td class ="val" id="value_${data[n].address}">${Number(data[n].value)}</td>\
<td class="symbol" id="symbol_${data[n].address}">${data[n].symbol}</td>\
<td class="desc" id="description_${data[n].address}">${data[n].description}</td></tr>`
document.getElementById('coildata').innerHTML += tablerow
}
});
}
// Using setTimeout instead of setInterval to avoid possible connection issues
// There's no need to update exactly every 5 seconds, the skew is fine
setTimeout(getData, 1*1000);
}

View File

@ -0,0 +1,59 @@
upstream enervent-ctrl {
server localhost:8888;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
# SSL configuration
#
# listen 443 ssl default_server;
# listen [::]:443 ssl default_server;
#
# Note: You should disable gzip for SSL traffic.
# See: https://bugs.debian.org/773332
#
# Read up on ssl_ciphers to ensure a secure configuration.
# See: https://bugs.debian.org/765782
#
# Self signed certs generated by the ssl-cert package
# Don't use them in a production server!
#
# include snippets/snakeoil.conf;
root /home/jarno/enervent-ctrl/enervent-ctrl-python/html;
index index.html;
server_name _;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
if ($http_user_agent ~* "^curl") {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://enervent-ctrl;
}
try_files $uri $uri/ =404;
}
#location ~ /static|/coils|/registers {
# root /home/jarno/enervent-ctrl/enervent-ctrl-python/html;
#}
location ~ /api {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://enervent-ctrl;
}
}

View File

@ -0,0 +1,3 @@
home = /usr/bin
include-system-site-packages = false
version = 3.9.2

View File

@ -0,0 +1,11 @@
click==8.1.3
Flask==2.2.2
importlib-metadata==6.0.0
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.1
minimalmodbus==2.0.1
pyserial==3.5
waitress==2.1.2
Werkzeug==2.2.3
zipp==3.11.0

View File

@ -0,0 +1,188 @@
import minimalmodbus
import logging
from flask import jsonify
from threading import Lock
from time import sleep
class PingvinCoil():
"""Single coil data structure"""
def __init__(self, symbol="-", description="-"):
self.symbol = symbol
self.value = False
self.description = description
self.reserved = symbol == "-" and description == "-"
def serialize(self):
return {
"value": self.value,
"symbol": self.symbol,
"description": self.description,
"reserved": self.reserved
}
def get(self):
return jsonify(self.serialize())
def flip(self):
self.value = not self.value
class PingvinCoils():
"""Class for handling Modbus coils"""
## coil descriptions and symbols courtesy of Ensto Enervent
## https://doc.enervent.com/out/out.ViewDocument.php?documentid=59
coils = [
PingvinCoil("COIL_STOP", "Stop"),
PingvinCoil("COIL_AWAY", "Away mode"),
PingvinCoil("COIL_AWAY_L", "Away Long mode"),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil("COIL_MAX_H", "Max Heating"),
PingvinCoil("COIL_MAX_C", "Max Cooling"),
PingvinCoil("COIL_CO_BOOST_EN", "CO2 boost"),
PingvinCoil("COIL_RH_BOOST_EN", "Relative humidity boost"),
PingvinCoil("COIL_M_BOOST", "Manual boost 100%"),
PingvinCoil("COIL_TEMP_BOOST_EN", "Temperature boost"),
PingvinCoil("COIL_SNC", "Summer night cooling"),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil("COIL_AWAY_H", "Heating enabled/disabled in AWAY mode"),
PingvinCoil("COIL_AWAY_C", "Cooling enabled/disabled in AWAY mode"),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil("COIL_LTO_ON", "Heat recycler state (running=1, stopped = 0)"),
PingvinCoil(),
PingvinCoil("COIL_HEAT_ON", "After heater element state (On = 1, Off = 0)"),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil("COIL_TEMP_DECREASE", "Temperature decrease function"),
PingvinCoil("COIL_OVERTIME", "Programmatic equivalent of OVERTIME digital input"),
PingvinCoil(),
PingvinCoil(),
PingvinCoil("COIL_ECO_MODE", "Eco mode"),
PingvinCoil("COIL_ALARM_A", "Alarm of class A active"),
PingvinCoil("COIL_ALARM_B", "Alarm of class B active"),
PingvinCoil("COIL_CLK_PROG", "Clock program is currently active"),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil("COIL_SILENT_MODE", "Silent mode"),
PingvinCoil("COIL_STOP_SLP_COOLING", "Electrical heater cool-off function enabled when the machine has stopped"),
PingvinCoil("COIL_SERVICE_EN", "Service reminder"),
PingvinCoil(),
PingvinCoil(),
PingvinCoil("COIL_COOLING_EN", "Active cooling function enabled"),
PingvinCoil("COIL_LTO_EN", "N/A"),
PingvinCoil("COIL_HEATING_EN", "Active heating function enabled"),
PingvinCoil("COIL_LTO_DEFROST_EN", "HRC defrosting function enabled during winter season"),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil(),
PingvinCoil()
]
def __init__(self, device, semaphore, debug=False):
self.pingvin = device
self.semaphore = semaphore
def __getitem__(self, item):
return self.coils[item]
def update(self, debug=False):
"""Fetch all coils values from device"""
self.pingvin.serial.timeout = 0.2
self.pingvin.debug = debug
if debug: logging.info(f"{len(self.coils)} coils registered")
self.semaphore.acquire()
curvalues = self.pingvin.read_bits(0,len(self.coils),1)
self.semaphore.release()
for i, coil in enumerate(self.coils):
self.coils[i].value = bool(curvalues[i])
if debug: logging.info("Coil values read succesfully\n")
def fetchValue(self, address, debug=False):
"""Update single coil value from device and return it"""
self.pingvin.debug = debug
if debug: logging.debug("Updating coil value from device to cache")
self.semaphore.acquire()
self.coils[address].value = bool(self.pingvin.read_bit(address, 1))
self.semaphore.release()
return self.value(address, debug)
def value(self, address, debug=False):
"""Get single local coil value"""
if debug: logging.debug("Reading coil value from cache")
return self.coils[address].value
def print(self, debug=False):
"""Human-readable print of all coil values"""
coilvals = ""
for i, coil in enumerate(self.coils):
coilvals = coilvals + f"Coil {i : <{4}}{coil.value : <{2}} {coil.symbol : <{25}}{coil.description}\n"
return coilvals
def serialize(self, include_reserved=False):
"""Returns coil values as parseable Python object"""
coilvals = []
for i, coil in enumerate(self.coils):
if not coil.reserved or include_reserved:
coil = coil.serialize()
coil['address'] = i
coilvals.append(coil)
return coilvals
def get(self, include_reserved=False, live=False, debug=False):
"""Return all coil values in JSON format"""
if live: self.update(debug)
return jsonify(self.serialize(include_reserved))
def write(self, address):
self.semaphore.acquire()
self.pingvin.write_bit(address, int(not self.coils[address].value))
if self.pingvin.read_bit(address, 1) != self.coils[address].value:
self.coils[address].flip()
self.semaphore.release()
return True
self.semaphore.release()
return False
class PingvinKL():
"""Class for communicating with an Enervent Pinvin Kotilämpö ventilation/heating unit"""
def __init__(self, serialdevice='/dev/ttyS0', modbusaddr=1, debug=False):
self.semaphore = Lock()
self.pingvin = minimalmodbus.Instrument(serialdevice, modbusaddr)
self.coils = PingvinCoils(self.pingvin, self.semaphore, debug)
self.run = False
def monitor(self, interval=15, debug=False):
if not self.run: # Prevent starting two monitor threads
self.run = True
logging.info("Starting data monitor loop")
while self.run:
if debug: logging.info("Data monitor updating coil data")
self.coils.update(debug)
sleep(interval)

View File

@ -0,0 +1,47 @@
#!/usr/bin/env python
import logging
from PingvinKL import PingvinKL
from flask import Flask, request
import threading
from waitress import serve
VERSION = "0.0.1"
DEBUG = False
## Logging configuration
log = logging.getLogger(__name__)
if DEBUG:
dbglevel = logging.DEBUG
else:
dbglevel = logging.INFO
logging.basicConfig(
level=dbglevel,
format='%(asctime)s %(message)s',
datefmt='%y/%m/%d %H:%M:%S'
)
pingvin = PingvinKL('/dev/ttyS0',1,debug=DEBUG)
app = Flask(__name__)
@app.route('/api/v1/coils')
def get_all():
return pingvin.coils.get(include_reserved=request.args.get('include_reserved'),live=request.args.get('live'),debug=DEBUG)
@app.route('/api/v1/coils/<int:address>', methods=["GET","PUT"])
def coil(address):
if request.method == 'GET':
coil = pingvin.coils[address].get()
return coil
elif request.method == 'PUT':
return {"success": pingvin.coils.write(address)}
@app.route('/')
def dump():
return pingvin.coils.print(debug=DEBUG)
if __name__ == "__main__":
log.info(f"Starting enervent-logger {VERSION}")
datathread = threading.Thread(target=pingvin.monitor, kwargs={"interval": 2, "debug": DEBUG})
datathread.start()
# app.run(host='0.0.0.0', port=8888)
serve(app, listen='*:8888', trusted_proxy='127.0.0.1', trusted_proxy_headers="x-forwarded-for x-forwarded-host x-forwarded-proto x-forwarded-port")

25
go.mod
View File

@ -1,25 +0,0 @@
module github.com/0ranki/enervent-ctrl
go 1.22
require (
github.com/0ranki/https-go v0.0.0-20230314073101-4eca22af948c
github.com/goburrow/modbus v0.1.0
github.com/gorilla/handlers v1.5.2
github.com/prometheus/client_golang v1.19.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/goburrow/serial v0.1.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/prometheus/client_model v0.6.0 // indirect
github.com/prometheus/common v0.50.0 // indirect
github.com/prometheus/procfs v0.13.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/protobuf v1.33.0 // indirect
)

520
go.sum
View File

@ -1,520 +0,0 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/0ranki/https-go v0.0.0-20230314073101-4eca22af948c h1:Tmui5U+C7KF4gYHnpXxe2sfROcrGksSmFheTVJAHdLo=
github.com/0ranki/https-go v0.0.0-20230314073101-4eca22af948c/go.mod h1:r4Jb05+PuiVKHDYwSsSBuSz4LpOlC2DgOY4N58+K8Hk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/goburrow/modbus v0.1.0 h1:DejRZY73nEM6+bt5JSP6IsFolJ9dVcqxsYbpLbeW/ro=
github.com/goburrow/modbus v0.1.0/go.mod h1:Kx552D5rLIS8E7TyUwQ/UdHEqvX5T8tyiGBTlzMcZBg=
github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA=
github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ=
github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View File

@ -1,185 +0,0 @@
package main
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
)
// HTTP Basic Authentication middleware for http.HandlerFunc
// This is used for the API
func authHandlerFunc(next http.HandlerFunc) http.HandlerFunc {
// Based on https://www.alexedwards.net/blog/basic-authentication-in-go
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if config.DisableAuth {
next.ServeHTTP(w, r)
return
}
user, pass, ok := r.BasicAuth()
if ok {
userHash := sha256.Sum256([]byte(user))
passHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1)
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
if len(user) == 0 {
user = "-"
}
log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// HTTP Basic Authentication middleware for http.Handler
// Used for the HTML monitor views
func authHandler(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if config.DisableAuth {
next.ServeHTTP(w, r)
return
}
user, pass, ok := r.BasicAuth()
if ok {
userHash := sha256.Sum256([]byte(user))
passHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1)
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
if len(user) == 0 {
user = "-"
}
log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// /api/v1/coils endpoint
func coils(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/coils/"), "/")
if len(pathparams[0]) == 0 {
_ = json.NewEncoder(w).Encode(device.Coils)
} else if len(pathparams[0]) > 0 && r.Method == "GET" && len(pathparams) < 2 { // && r.Method == "POST"
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
err = device.ReadCoil(uint16(intaddr))
if err != nil {
log.Println("ERROR ReadCoil: client.ReadCoils: ", err)
}
_ = json.NewEncoder(w).Encode(device.Coils[intaddr])
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 2 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
boolval, err := strconv.ParseBool(pathparams[1])
if err != nil {
log.Println("ERROR: Could not parse coil value", pathparams[1])
log.Println(err)
return
}
if config.ReadOnly {
log.Println("WARNING: Read only mode, refusing to write to device")
} else {
device.WriteCoil(uint16(intaddr), boolval)
}
_ = json.NewEncoder(w).Encode(device.Coils[intaddr])
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 1 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
if config.ReadOnly {
log.Println("WARNING: Read only mode, refusing to write to device")
} else {
device.WriteCoil(uint16(intaddr), !device.Coils[intaddr].Value)
}
_ = json.NewEncoder(w).Encode(device.Coils[intaddr])
}
}
// /api/v1/registers endpoint
func registers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/registers/"), "/")
if len(pathparams[0]) == 0 {
_ = json.NewEncoder(w).Encode(device.Registers)
} else if len(pathparams[0]) > 0 && r.Method == "GET" && len(pathparams) < 2 { // && r.Method == "POST"
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse register address", pathparams[0])
log.Println(err)
return
}
_, err = device.ReadRegister(uint16(intaddr))
if err != nil {
log.Println("ERROR: ReadRegister:", err)
}
_ = json.NewEncoder(w).Encode(device.Registers[intaddr])
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 2 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse register address", pathparams[0])
log.Println(err)
return
}
intval, err := strconv.Atoi(pathparams[1])
if err != nil {
log.Println("ERROR: Could not parse register value", pathparams[1])
log.Println(err)
return
}
if config.ReadOnly {
log.Println("WARNING: Read only mode, refusing to write to device")
} else {
_, err = device.WriteRegister(uint16(intaddr), uint16(intval))
if err != nil {
log.Println(err)
}
}
_ = json.NewEncoder(w).Encode(device.Registers[intaddr])
}
}
// /status endpoint
func status(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(device.Status)
}
// /api/v1/temperature endpoint
func temperature(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/temperature/"), "/")
if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 1 {
err := device.Temperature(pathparams[0])
if err != nil {
log.Println("ERROR: ", err)
}
_ = json.NewEncoder(w).Encode(device.Registers[135])
} else {
return
}
}

View File

@ -1,382 +1,6 @@
## FAN / HEATER CONTROL AUTOMATIONS
## THESE WILL AFFECT THE BEHAVIOUR OF THE UNIT
- alias: Penguin Auto Heater Disable v0.2.0
description: |-
Temperature is 0.5 over setpoint and outside temperature over 10
-> heater off
trigger:
- platform: state
entity_id:
- sensor.penguin_temperature_delta
for:
hours: 0
minutes: 0
seconds: 0
- platform: numeric_state
entity_id: sensor.penguin_temperature_delta
above: 0.5
condition:
- condition: numeric_state
entity_id: sensor.penguin_intake_air
above: 10
value_template: " {{ states['sensor.penguin_intake_air'].state }}"
- condition: state
entity_id: sun.sun
state: above_horizon
action:
- delay:
hours: 0
minutes: 15
seconds: 0
milliseconds: 0
- condition: numeric_state
entity_id: sensor.penguin_intake_air
above: 10
value_template: "{{ states['sensor.penguin_intake_air'].state }}"
- service: rest_command.penguin_heater_disable
data: { }
- service: input_boolean.turn_off
data: { }
target:
entity_id: input_boolean.penguin_after_heater
- delay:
hours: 0
minutes: 0
seconds: 5
milliseconds: 0
- service: rest_command.penguin_circulation_adaptive
data: { }
mode: single
- alias: Penguin Auto Heater Enable v0.2.0
description: |-
Temperature below setpoint
-> heater on
trigger:
- platform: numeric_state
entity_id: sensor.penguin_temperature_delta
value_template: "{{ states['sensor.penguin_temperature_delta'].state }}"
below: 0
- platform: sun
event: sunset
offset: 0
enabled: true
condition: [ ]
action:
- service: rest_command.penguin_heater_enable
data: { }
- service: input_boolean.turn_on
data: { }
target:
entity_id: input_boolean.penguin_after_heater
mode: single
- alias: Penguin Auto Heating increase v0.2.0
description: |-
Temperature 0.2 below setpoint
-> circulation fan to manual
-> max heating on
trigger:
- platform: numeric_state
entity_id: sensor.penguin_temperature_delta
below: -0.2
value_template: "{{ states['sensor.penguin_temperature_delta'].state }}"
condition: [ ]
action:
- delay:
hours: 0
minutes: 15
seconds: 0
milliseconds: 0
enabled: true
- condition: numeric_state
entity_id: sensor.penguin_temperature_delta
value_template: "{{ states['sensor.penguin_temperature_delta'].state }}"
below: -0.2
- if:
- condition: state
entity_id: input_boolean.penguin_fan_control
state: "on"
then:
- service: rest_command.penguin_max_heating_on
data: { }
- service: rest_command.penguin_circulation_manual
data: { }
enabled: false
- delay:
hours: 0
minutes: 0
seconds: 5
milliseconds: 0
- service: rest_command.penguin_max_heating_on
data: { }
mode: single
- alias: Penguin Auto Heating decrease v0.2.0
description: |-
Temperature 0.2 over setpoint
-> adaptive circulation on (fan should go to minimum allowed)
This works sometimes, sometimes doesn't, you may want to disable this
trigger:
- platform: numeric_state
entity_id: sensor.penguin_temperature_delta
value_template: "{{ states['sensor.penguin_temperature_delta'].state }}"
above: 0.2
condition:
- condition: state
entity_id: input_boolean.penguin_fan_control
state: "on"
action:
- delay:
hours: 0
minutes: 15
seconds: 0
milliseconds: 0
enabled: true
- condition: numeric_state
entity_id: sensor.penguin_temperature_delta
above: 0.2
value_template: "{{ states['sensor.penguin_temperature_delta'].state }}"
- if:
- condition: state
entity_id: input_boolean.penguin_fan_control
state: "on"
then:
- service: rest_command.penguin_circulation_adaptive
data: { }
mode: single
- alias: Penguin Auto Max Heating v0.2.0
description: |-
Temperature 0.3 below setpoint
-> Max heat and max circulation (adaptive circulation on, with max heating means max fan setting)
trigger:
- platform: numeric_state
entity_id: sensor.penguin_temperature_delta
value_template: "{{ states['sensor.penguin_temperature_delta'].state }}"
below: -0.3
condition: [ ]
action:
- delay:
hours: 0
minutes: 15
seconds: 0
milliseconds: 0
enabled: true
- condition: numeric_state
entity_id: sensor.penguin_temperature_delta
below: -0.3
value_template: "{{ states['sensor.penguin_temperature_delta'].state }}"
enabled: true
- service: rest_command.penguin_max_heating_off
data: { }
- delay:
hours: 0
minutes: 0
seconds: 5
milliseconds: 0
- if:
- condition: state
entity_id: input_boolean.penguin_fan_control
state: "on"
then:
- service: rest_command.penguin_circulation_adaptive
data: { }
- delay:
hours: 0
minutes: 0
seconds: 5
milliseconds: 0
- service: rest_command.penguin_max_heating_on
data: { }
mode: single
## DASHBOARD TOGGLE/SETTING RELATED AUTOMATIONS
## REQUIRED FOR e.g. SETTING THE TEMPERATURE SETPOINT VIA HA AND OTHER ACTIONS
- alias: Penguin After Heater Input v0.2.0
description: "Actions to take when input_boolean.penguin_after_heater is toggled"
trigger:
- platform: state
entity_id:
- input_boolean.penguin_after_heater
condition: [ ]
action:
- if:
- condition: state
entity_id: input_boolean.penguin_after_heater
state: "on"
- condition: state
entity_id: binary_sensor.penguin_after_heater_enabled
state: "off"
then:
- service: rest_command.penguin_heater_enable
data: { }
- if:
- condition: state
entity_id: input_boolean.penguin_after_heater
state: "off"
- condition: state
entity_id: binary_sensor.penguin_after_heater_enabled
state: "on"
then:
- service: rest_command.penguin_heater_disable
data: { }
mode: single
- alias: Penguin boost input v0.2.0
description: "Actions when toggling input_boolean.penguin_boost"
trigger:
- platform: state
entity_id:
- input_boolean.penguin_boost
condition: [ ]
action:
- if:
- condition: state
entity_id: input_boolean.penguin_boost
state: "on"
then:
- service: rest_command.penguin_boost_on
data: { }
else:
- service: rest_command.penguin_boost_off
data: { }
- delay:
hours: 0
minutes: 0
seconds: 5
milliseconds: 0
- if:
- condition: state
entity_id: binary_sensor.penguin_boost
state: "on"
then:
- service: input_boolean.turn_on
data: { }
target:
entity_id: input_boolean.penguin_boost
else:
- service: input_boolean.turn_off
data: { }
target:
entity_id: input_boolean.penguin_boost
mode: single
- alias: Penguin circulation fan mode sensor v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- binary_sensor.penguin_circulation_adaptive
condition: [ ]
action:
- if:
- condition: state
entity_id: binary_sensor.penguin_circulation_adaptive
state: "on"
then:
- service: input_boolean.turn_on
data: { }
target:
entity_id: input_boolean.penguin_circulation_fan_adaptive
else:
- service: input_boolean.turn_off
data: { }
target:
entity_id: input_boolean.penguin_circulation_fan_adaptive
- delay:
hours: 0
minutes: 0
seconds: 2
milliseconds: 0
- service: homeassistant.update_entity
data: { }
target:
entity_id: sensor.penguin_circulation_fan_pct
- service: homeassistant.update_entity
data: { }
target:
entity_id: sensor.penguin_operating_mode
mode: single
- alias: Penguin circulation fan mode v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- input_boolean.penguin_circulation_fan_adaptive
condition: [ ]
action:
- if:
- condition: state
entity_id: input_boolean.penguin_circulation_fan_adaptive
state: "on"
then:
- service: rest_command.penguin_circulation_adaptive
data: { }
else:
- service: rest_command.penguin_circulation_manual
data: { }
mode: single
- alias: Penguin max cooling input v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- input_boolean.penguin_max_cooling
condition: [ ]
action:
- if:
- condition: state
entity_id: input_boolean.penguin_max_cooling
state: "on"
- condition: state
entity_id: binary_sensor.penguin_max_cooling
state: "off"
- condition: numeric_state
entity_id: sensor.penguin_room_temperature_1
above: input_number.penguin_temperature_setting_helper
then:
- service: rest_command.penguin_max_cooling_on
data: { }
else: [ ]
- if:
- condition: state
entity_id: input_boolean.penguin_max_cooling
state: "off"
then:
- service: rest_command.penguin_max_cooling_off
data: { }
- if:
- condition: numeric_state
entity_id: sensor.penguin_room_temperature_1
below: input_number.penguin_temperature_setting_helper
- condition: state
entity_id: input_boolean.penguin_max_cooling
state: "on"
then:
- service: input_boolean.turn_off
data: { }
target:
entity_id: input_boolean.penguin_max_cooling
mode: single
- alias: Penguin max cooling sensor v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- binary_sensor.penguin_max_cooling
condition: [ ]
action:
- if:
- condition: state
entity_id: binary_sensor.penguin_max_cooling
state: "on"
then:
- service: input_boolean.turn_on
data: { }
target:
entity_id: input_boolean.penguin_max_cooling
else:
- service: input_boolean.turn_off
data: { }
target:
entity_id: input_boolean.penguin_max_cooling
mode: single
- alias: Penguin Max Heating input v0.2.0
automation:
## Max heating
- alias: Penguin Max Heating input
description: ""
trigger:
- platform: state
@ -418,7 +42,7 @@
target:
entity_id: input_boolean.penguin_max_heating
mode: single
- alias: Penguin Max Heating sensor v0.2.0
- alias: Penguin Max Heating sensor
description: ""
trigger:
- platform: state
@ -441,64 +65,127 @@
target:
entity_id: input_boolean.penguin_max_heating
mode: single
- alias: Penguin overpressure input v0.2.0
## Max cooling
- alias: Penguin max cooling sensor
description: ""
trigger:
- platform: state
entity_id:
- input_boolean.penguin_overpressure
- binary_sensor.penguin_max_cooling
condition: []
action:
- if:
- condition: state
entity_id: input_boolean.penguin_overpressure
state: "on"
then:
- service: rest_command.penguin_overpressure_on
data: { }
else:
- service: rest_command.penguin_overpressure_off
data: { }
- delay:
hours: 0
minutes: 0
seconds: 5
milliseconds: 0
- if:
- condition: state
entity_id: binary_sensor.penguin_overpressure
entity_id: binary_sensor.penguin_max_cooling
state: "on"
then:
- service: input_boolean.turn_on
data: {}
target:
entity_id: input_boolean.penguin_overpressure
entity_id: input_boolean.penguin_max_cooling
else:
- service: input_boolean.turn_off
data: {}
target:
entity_id: input_boolean.penguin_overpressure
entity_id: input_boolean.penguin_max_cooling
mode: single
- alias: Penguin SNC input v0.2.0
- alias: Penguin max cooling input
description: ""
trigger:
- platform: state
entity_id:
- input_boolean.penguin_snc
- input_boolean.penguin_max_cooling
condition: []
action:
- if:
- condition: state
entity_id: input_boolean.penguin_snc
entity_id: input_boolean.penguin_max_cooling
state: "on"
- condition: state
entity_id: binary_sensor.penguin_max_cooling
state: "off"
- condition: numeric_state
entity_id: sensor.penguin_room_temperature_1
above: input_number.penguin_temperature_setting_helper
then:
- service: rest_command.penguin_max_cooling_on
data: {}
else: []
- if:
- condition: state
entity_id: input_boolean.penguin_max_cooling
state: "off"
then:
- service: rest_command.penguin_max_cooling_off
data: {}
- if:
- condition: numeric_state
entity_id: sensor.penguin_room_temperature_1
below: input_number.penguin_temperature_setting_helper
- condition: state
entity_id: input_boolean.penguin_max_cooling
state: "on"
then:
- service: rest_command.penguin_snc_enable
- service: input_boolean.turn_off
data: {}
else:
- service: rest_command.penguin_snc_disable
target:
entity_id: input_boolean.penguin_max_cooling
mode: single
## Circulation fan mode
- alias: Penguin circulation fan mode
description: ""
trigger:
- platform: state
entity_id:
- input_boolean.penguin_circulation_fan_adaptive
condition: []
action:
- if:
- condition: state
entity_id: input_boolean.penguin_circulation_fan_adaptive
state: "on"
then:
- service: rest_command.penguin_circulation_adaptive
data: {}
- if:
- condition: state
entity_id: input_boolean.penguin_circulation_fan_adaptive
state: "off"
then:
- service: rest_command.penguin_circulation_manual
data: {}
mode: single
- alias: Penguin temperature down v0.2.0
- alias: Penguin circulation fan mode sensor
description: ""
trigger:
- platform: state
entity_id:
- binary_sensor.penguin_circulation_adaptive
condition: []
action:
- if:
- condition: state
entity_id: binary_sensor.penguin_circulation_adaptive
state: "on"
then:
- service: input_boolean.turn_on
data: {}
target:
entity_id: input_boolean.penguin_circulation_fan_adaptive
else:
- service: input_boolean.turn_off
data: {}
target:
entity_id: input_boolean.penguin_circulation_fan_adaptive
mode: single
## Target temperature setting automations
- alias: Penguin temperature down
description: ""
trigger:
- platform: state
@ -506,57 +193,18 @@
- input_button.penguin_temperature_down
condition: []
action:
- service: rest_command.penguin_temperature_down
data: {}
- service: input_number.decrement
data: {}
target:
entity_id: input_number.penguin_temperature_setting_helper
- delay:
hours: 0
minutes: 0
seconds: 3
milliseconds: 0
- service: rest_command.penguin_temperature_set
data: { }
- delay:
hours: 0
minutes: 0
seconds: 1
milliseconds: 0
- service: homeassistant.update_entity
data: {}
target:
entity_id: sensor.penguin_temperature_setting
mode: restart
- alias: Penguin temperature up v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- input_button.penguin_temperature_up
condition: [ ]
action:
- service: input_number.increment
data: { }
target:
entity_id: input_number.penguin_temperature_setting_helper
- delay:
hours: 0
minutes: 0
seconds: 3
milliseconds: 0
- service: rest_command.penguin_temperature_set
data: { }
- delay:
hours: 0
minutes: 0
seconds: 1
milliseconds: 0
- service: homeassistant.update_entity
data: { }
target:
entity_id: sensor.penguin_temperature_setting
mode: restart
- alias: Penguin Temperature setting updater v0.2.0
mode: single
- alias: Penguin Temperature setting updater
description: ""
trigger:
- platform: state
@ -570,3 +218,22 @@
target:
entity_id: input_number.penguin_temperature_setting_helper
mode: single
- alias: Penguin temperature up
description: ""
trigger:
- platform: state
entity_id:
- input_button.penguin_temperature_up
condition: []
action:
- service: rest_command.penguin_temperature_up
data: {}
- service: input_number.increment
data: {}
target:
entity_id: input_number.penguin_temperature_setting_helper
- service: homeassistant.update_entity
data: {}
target:
entity_id: sensor.penguin_temperature_setting
mode: single

View File

@ -1,6 +1,5 @@
views:
- title: Pingvin
icon: mdi:penguin
- title: Penguin
cards:
- type: vertical-stack
cards:
@ -20,22 +19,11 @@ views:
green: 0
yellow: 0
red: 100
- type: entities
entities:
- entity: input_boolean.penguin_after_heater
name: Heating allowed
secondary_info: last-changed
- entity: input_boolean.penguin_snc
icon: mdi:snowflake-thermometer
name: Summer Night Cooling
- entity: input_boolean.penguin_fan_control
name: Circulation Fan Control
state_color: true
- square: false
columns: 4
columns: 3
type: grid
cards:
- show_name: false
- show_name: true
show_icon: true
type: button
tap_action:
@ -43,12 +31,7 @@ views:
entity: input_boolean.penguin_circulation_fan_adaptive
name: Adaptive circulation
show_state: false
- type: conditional
conditions:
- entity: binary_sensor.penguin_max_heating_enabled
state: 'on'
card:
show_name: false
- show_name: true
show_icon: true
type: button
tap_action:
@ -56,98 +39,41 @@ views:
entity: input_boolean.penguin_max_heating
name: Max heating
show_state: false
- type: conditional
conditions:
- entity: binary_sensor.penguin_max_heating_enabled
state: 'off'
card:
show_name: false
show_icon: true
type: button
tap_action:
action: toggle
entity: input_boolean.penguin_max_cooling
name: Max cooling
show_state: false
- show_name: false
show_icon: true
type: button
tap_action:
action: toggle
icon: mdi:fireplace
entity: input_boolean.penguin_overpressure
- show_name: false
show_icon: true
type: button
tap_action:
action: toggle
icon: mdi:fan-plus
entity: input_boolean.penguin_boost
- square: false
columns: 3
type: grid
cards:
- show_name: false
show_icon: true
type: button
tap_action:
action: toggle
entity: input_button.penguin_temperature_down
icon: mdi:minus
- type: gauge
entity: input_number.penguin_temperature_setting_helper
name: ' '
needle: true
min: 16
max: 30
- show_name: false
show_icon: true
type: button
tap_action:
action: toggle
entity: input_button.penguin_temperature_up
icon: mdi:plus
- type: entities
entities:
- entity: sensor.penguin_operating_mode
name: Tila
secondary_info: last-changed
name: Mode
- entity: sensor.penguin_room_temperature_1
name: Room temperature
- entity: sensor.penguin_temperature_setting
name: Temperature setpoint
- entity: sensor.penguin_circulation_fan_pct
name: Circulation fan pct
secondary_info: last-changed
name: Criculation fan
state_color: false
- type: entities
entities:
- entity: sensor.penguin_intake_air
name: Outside air at unit
name: Outside air at machine
- entity: sensor.penguin_intake_air_24h
name: Outside air 24h avg
- entity: sensor.penguin_supply_air_hrc
name: Intake after HRC
name: Supply air after HRC
- entity: sensor.penguin_supply_air_humidity
name: Supply air humidity
- entity: sensor.penguin_supply_air
name: Supply air
- entity: sensor.penguin_return_water
name: Return water temperature
name: Return water
- entity: sensor.penguin_extract_air
name: Extract before HRC
name: Extract air before HRC
- entity: sensor.penguin_waste_air
name: Waste air
- entity: sensor.penguin_extract_air_humidity
name: Extract air humidity
- entity: sensor.penguin_extract_air_humidity_48h
name: Extract air humidity 48h
name: Extract air humidity 48h avg
- entity: sensor.penguin_hrc_efficiency_intake
icon: ''
name: HRC Efficiency intake
name: HRC efficiency intake
- entity: sensor.penguin_hrc_efficiency_extract
name: HRC Efficiency extract
- entity: sensor.penguin_intake_fan_pct
name: Intake fan speed
- entity: sensor.penguin_exhaust_fan_pct
name: Exhaust fan speed
name: HRC efficiency extract
title: Measurements
title: Heating & Ventilation

View File

@ -1,6 +1,5 @@
views:
- title: Pingvin
icon: mdi:penguin
- title: Penguin
cards:
- type: vertical-stack
cards:
@ -20,17 +19,6 @@ views:
green: 0
yellow: 0
red: 100
- type: entities
entities:
- entity: input_boolean.penguin_after_heater
name: Lämmitys sallittu
secondary_info: last-changed
- entity: input_boolean.penguin_snc
icon: mdi:snowflake-thermometer
name: Kesäyöjäähdytys
- entity: input_boolean.penguin_fan_control
name: Kiertoilman hallinta
state_color: true
- square: false
columns: 4
type: grid
@ -111,14 +99,12 @@ views:
entities:
- entity: sensor.penguin_operating_mode
name: Tila
secondary_info: last-changed
- entity: sensor.penguin_room_temperature_1
name: Huonelämpötila
- entity: sensor.penguin_temperature_setting
name: Asetettu lämpötila
- entity: sensor.penguin_circulation_fan_pct
name: Kiertoilma
secondary_info: last-changed
state_color: false
- type: entities
entities:
@ -128,6 +114,8 @@ views:
name: Ulkoilma 24h keskiarvo
- entity: sensor.penguin_supply_air_hrc
name: Tuloilma LTO jälkeen
- entity: sensor.penguin_supply_air_humidity
name: Tuloilma kosteus
- entity: sensor.penguin_supply_air
name: Tuloilma
- entity: sensor.penguin_return_water
@ -145,9 +133,4 @@ views:
name: LTO hyötysuhde tuloilma
- entity: sensor.penguin_hrc_efficiency_extract
name: LTO hyötysuhde poistoilma
- entity: sensor.penguin_intake_fan_pct
name: Puhallin tuloilma
- entity: sensor.penguin_exhaust_fan_pct
name: Puhallin poistoilma
title: Mittaukset
title: Lämmitys & IV

View File

@ -9,20 +9,11 @@ input_boolean:
name: Penguin Overpressure
icon: mdi:fireplace
penguin_boost:
name: Penguin Boost
name: Penguin Overpressure
icon: mdi:fan-plus
penguin_max_cooling:
name: Penguin Max Cooling
icon: mdi:snowflake
penguin_after_heater:
name: Penguin After Heater
icon: mdi:heating-coil
penguin_snc:
name: Penguin Summer Night Cooling
icon: mdi:snowflake-thermometer
penguin_fan_control:
name: Penguin Fan Control
icon: mdi:fan-alert
input_button:
penguin_temperature_up:
name: Penguin temperature up
@ -36,4 +27,3 @@ input_number:
min: 20
max: 30
unit_of_measurement: "°C"
step: 0.5

View File

@ -28,14 +28,6 @@ rest:
value_template: "{{ value_json['fan_pct'] }}"
unit_of_measurement: "%"
icon: mdi:fan
- name: "Penguin intake fan pct"
value_template: "{{ value_json['fan_pct_in'] }}"
unit_of_measurement: "%"
icon: mdi:fan
- name: "Penguin exhaust fan pct"
value_template: "{{ value_json['fan_pct_ex'] }}"
unit_of_measurement: "%"
icon: mdi:fan
- name: "Penguin HRC efficiency intake"
value_template: "{{ value_json['hrc_efficiency_in'] }}"
unit_of_measurement: "%"
@ -44,6 +36,10 @@ rest:
value_template: "{{ value_json['hrc_efficiency_ex'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Penguin days until service"
value_template: "{{ value_json['days_until_service'] }}"
unit_of_measurement: "pv"
icon: mdi:calendar
- name: "Penguin supply air"
value_template: "{{ value_json['measurements']['supply_heated'] }}"
unit_of_measurement: "°C"
@ -100,18 +96,8 @@ rest:
- name: "Penguin overpressure"
value_template: "{{ value_json['coils'][3]['value'] }}"
icon: mdi:fireplace
- name: "Penguin after heater enabled"
value_template: "{{ value_json['coils'][54]['value'] }}"
icon: mdi:heating-coil
- name: "Penguin summer night cooling enabled"
value_template: "{{ value_json['coils'][12]['value'] }}"
icon: mdi:heating-coil
template:
- sensor:
- name: "Penguin temperature delta"
state: "{{ (states('sensor.penguin_room_temperature_1')|float(default=0) - states('sensor.penguin_temperature_setting')|float(default=0)) | round(1, default=0) }}"
unit_of_measurement: "°C"
- binary_sensor:
- name: "Penguin max heating enabled"
state: "{{ states('input_number.penguin_temperature_setting_helper') > states('sensor.penguin_room_temperature_1') }}"
@ -132,13 +118,13 @@ rest_command:
username: pingvin
password: enervent
penguin_boost_on:
url: https://IP_ADDRESS:8888/api/v1/coils/10/1
url: https://192.168.0.210:8888/api/v1/coils/10/1
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_boost_off:
url: https://IP_ADDRESS:8888/api/v1/coils/10/0
url: https://192.168.0.210:8888/api/v1/coils/10/0
method: POST
verify_ssl: false
username: pingvin
@ -165,37 +151,25 @@ rest_command:
username: pingvin
password: enervent
penguin_max_cooling_on:
url: https://IP_ADDRESS:8888/api/v1/coils/7/1
url: https://192.168.0.210:8888/api/v1/coils/7/1
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_max_cooling_off:
url: https://IP_ADDRESS:8888/api/v1/coils/7/0
url: https://192.168.0.210:8888/api/v1/coils/7/0
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_temperature_up:
url: https://IP_ADDRESS:8888/api/v1/temperature/up
url: https://192.168.0.210:8888/api/v1/temperature/up
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_temperature_down:
url: https://IP_ADDRESS:8888/api/v1/temperature/down
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_heater_enabled:
url: https://IP_ADDRESS:8888/api/v1/coils/54/1
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_heater_disabled:
url: https://IP_ADDRESS:8888/api/v1/coils/54/0
url: https://192.168.0.210:8888/api/v1/temperature/down
method: POST
verify_ssl: false
username: pingvin

228
main.go
View File

@ -1,228 +0,0 @@
package main
import (
"crypto/sha256"
"embed"
"flag"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/0ranki/enervent-ctrl/pingvin"
"github.com/0ranki/https-go"
"github.com/gorilla/handlers"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"gopkg.in/yaml.v3"
)
// Remember to dereference the symbolic links under ./static/html
// prior to building the binary e.g. by using tar
//go:embed static/html/*
var static embed.FS
var (
version = "0.2.0"
device pingvin.Pingvin
config Conf
usernamehash [32]byte
passwordhash [32]byte
)
type Conf struct {
SerialAddress string `yaml:"serial_address"`
Port int `yaml:"port"`
SslCertificate string `yaml:"ssl_certificate"`
SslPrivatekey string `yaml:"ssl_privatekey"`
DisableAuth bool `yaml:"disable_auth"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Interval int `yaml:"interval"`
EnableMetrics bool `yaml:"enable_metrics"`
LogFile string `yaml:"log_file"`
LogAccess bool `yaml:"log_access"`
Debug bool `yaml:"debug"`
ReadOnly bool `yaml:"read_only"`
}
// Start the HTTP server
func serve(cert, key *string) {
log.Println("Starting service")
http.HandleFunc("/api/v1/coils/", authHandlerFunc(coils))
http.HandleFunc("/api/v1/status", authHandlerFunc(status))
http.HandleFunc("/api/v1/registers/", authHandlerFunc(registers))
http.HandleFunc("/api/v1/temperature/", authHandlerFunc(temperature))
if config.EnableMetrics {
http.Handle("/metrics", promhttp.Handler())
}
html, err := fs.Sub(static, "static/html")
if err != nil {
log.Fatal(err)
}
htmlroot := http.FileServer(http.FS(html))
http.HandleFunc("/", authHandler(htmlroot))
http.HandleFunc("/coils/", authHandler(http.StripPrefix("/coils/", htmlroot)))
http.HandleFunc("/registers/", authHandler(http.StripPrefix("/registers/", htmlroot)))
logdst, err := os.OpenFile(os.DevNull, os.O_WRONLY, os.ModeAppend)
if err != nil {
log.Fatal(err)
}
if config.LogAccess {
logdst = os.Stdout
}
handler := handlers.LoggingHandler(logdst, http.DefaultServeMux)
err = http.ListenAndServeTLS(":8888", *cert, *key, handler)
if err != nil {
log.Fatal(err)
}
}
// Generate self-signed SSL keypair
func generateCertificate(cert, key string) {
opts := https.GenerateOptions{Host: "enervent-ctrl.local", RSABits: 4096, ValidFor: 10 * 365 * 24 * time.Hour}
log.Println("Generating new self-signed SSL keypair")
log.Println("This may take a while...")
pub, priv, err := https.GenerateKeys(opts)
if err != nil {
log.Fatal("Error generating SSL certificate: ", err)
}
device.Debug.Println("Certificate:\n", string(pub))
device.Debug.Println("Key:\n", string(priv))
if err := os.WriteFile(key, priv, 0600); err != nil {
log.Fatal("Error writing private key ", key, ": ", err)
}
log.Println("Wrote new SSL private key ", cert)
if err := os.WriteFile(cert, pub, 0644); err != nil {
log.Fatal("Error writing certificate ", cert, ": ", err)
}
log.Println("Wrote new SSL public key ", cert)
}
// Read & parse the configuration file
func parseConfigFile() {
homedir, err := os.UserHomeDir()
if err != nil {
log.Fatal("Could not determine user home directory")
}
confpath := homedir + "/.config/enervent-ctrl"
if _, err := os.Stat(confpath); err != nil {
log.Println("Generating configuration directory", confpath)
if err := os.MkdirAll(confpath, 0700); err != nil {
log.Fatal("Failed to generate configuration directory:", err)
}
}
conffile := confpath + "/configuration.yaml"
yamldata, err := os.ReadFile(conffile)
if err != nil {
log.Println("Configuration file", conffile, "not found")
log.Println("Generating", conffile, "with default values")
initDefaultConfig(confpath)
if yamldata, err = os.ReadFile(conffile); err != nil {
log.Fatal("Error parsing configuration:", err)
}
}
err = yaml.Unmarshal(yamldata, &config)
if err != nil {
log.Fatal("Failed to parse YAML:", err)
}
}
// Write the default configuration to $HOME/.config/enervent-ctrl/configuration.yaml
func initDefaultConfig(confpath string) {
config = Conf{
SerialAddress: "/dev/ttyS0",
Port: 8888,
SslCertificate: confpath + "/certificate.pem",
SslPrivatekey: confpath + "/privatekey.pem",
DisableAuth: false,
Username: "pingvin",
Password: "enervent",
Interval: 4,
EnableMetrics: false,
LogAccess: false,
LogFile: "",
Debug: false,
ReadOnly: false,
}
conffile := confpath + "/configuration.yaml"
confbytes, err := yaml.Marshal(&config)
if err != nil {
log.Println("Error writing default configuration:", err)
}
if err := os.WriteFile(conffile, confbytes, 0600); err != nil {
log.Fatal("Failed to write default configuration:", err)
}
}
// Read configuration. CLI flags take precedence over configuration file
func configure() {
log.Println("Reading configuration")
parseConfigFile()
debugflag := flag.Bool("debug", config.Debug, "Enable debug logging")
intervalflag := flag.Int("interval", config.Interval, "Set the interval of background updates")
logaccflag := flag.Bool("httplog", config.LogAccess, "Enable HTTP access logging")
generatecert := flag.Bool("regenerate-certs", false, "Generate a new SSL certificate. A new one is generated on startup as `~/.config/enervent-ctrl/server.crt` if it doesn't exist.")
certflag := flag.String("cert", config.SslCertificate, "Path to SSL public key to use for HTTPS")
keyflag := flag.String("key", config.SslPrivatekey, "Path to SSL private key to use for HTTPS")
noauthflag := flag.Bool("disable-auth", config.DisableAuth, "Disable HTTP basic authentication")
usernflag := flag.String("username", config.Username, "Username for HTTP Basic Authentication")
passwflag := flag.String("password", config.Password, "Password for HTTP Basic Authentication")
promflag := flag.Bool("enable-metrics", config.EnableMetrics, "Enable the built-in Prometheus exporter")
logflag := flag.String("logfile", config.LogFile, "Path to log file. Default is empty string, log to stdout")
serialflag := flag.String("serial", config.SerialAddress, "Path to serial console for RS-485 connection. Defaults to /dev/ttyS0")
readOnly := flag.Bool("read-only", config.ReadOnly, "Read only mode, no writes to device are allowed")
// TODO: log file flag
flag.Parse()
config.Debug = *debugflag
config.Interval = *intervalflag
config.LogAccess = *logaccflag
config.SslCertificate = *certflag
config.SslPrivatekey = *keyflag
config.DisableAuth = *noauthflag
config.Username = *usernflag
config.Password = *passwflag
config.EnableMetrics = *promflag
config.LogFile = *logflag
config.SerialAddress = *serialflag
config.ReadOnly = *readOnly
usernamehash = sha256.Sum256([]byte(config.Username))
passwordhash = sha256.Sum256([]byte(config.Password))
if len(config.LogFile) != 0 {
logfile, err := os.OpenFile(config.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0640)
if err != nil {
log.Fatal("Failed to open log file", config.LogFile)
}
log.SetOutput(logfile)
log.Println("Opened logfile")
}
// Check that certificate file exists, generate if needed
if _, err := os.Stat(config.SslCertificate); err != nil || *generatecert {
generateCertificate(config.SslCertificate, config.SslPrivatekey)
}
// Enable debug if configured
if config.Debug {
log.Println("Debug logging enabled")
}
// Enable HTTP access logging if configured
if config.LogAccess {
log.Println("HTTP Access logging enabled")
}
log.Println("Update interval set to", config.Interval, "seconds")
if config.EnableMetrics {
log.Println("Prometheus exporter enabled (/metrics)")
prometheus.MustRegister(&device)
}
}
func main() {
log.Println("enervent-ctrl version", version)
configure()
device = *pingvin.New(config.SerialAddress, config.Debug)
device.Update()
go device.Monitor(config.Interval)
serve(&config.SslCertificate, &config.SslPrivatekey)
device.Quit()
}