Compare commits

..

1 Commits

46 changed files with 1214 additions and 3009 deletions

View File

@ -1,9 +1,9 @@
name: Add issues to project name: Add issues to project
#on: on:
# issues: issues:
# types: types:
# - opened - opened
jobs: jobs:
add-to-project: 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/ .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.

167
README.md
View File

@ -4,161 +4,20 @@ External control of an Enervent Pingvin
Kotilämpö residential heating/ventilation Kotilämpö residential heating/ventilation
unit via RS485 bus using the Modbus protocol. unit via RS485 bus using the Modbus protocol.
Provides a REST API for integration into Home Assistant, Work is my Bachelor's Thesis at Oulu University
with measurements and basic control over Pingvin functions. of Applied Sciences, software development.
Template YAML configurations for Home Assistant are included The Python version under `enervent-ctrl-python`
in the `homeassistant` folder, intended to be simple to copy-paste is an initial proof-of-concept,
into Home Assistant's `configuration.yaml` with minimal necessary mainly to test that the hardware side of things
modifications. These include sensor configurations, helpers and automations for button functions works as expected. The main daemon is written
and a ready made basic dashboard. No custom components are necessary. in Go and the source is under `enervent-ctrl-go`
![image](https://user-images.githubusercontent.com/50285623/228834067-503f9820-292c-4614-9316-6cec683e89ef.png)
The daemon is designed to run on a Linux host The daemon is designed to run on a Linux host
that has some sort of RS-485 connector attached. that has some sort of RS485 connector attached.
For development a RPi Zero W 1 with a 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) connected [Zihatec RS 485 HAT](https://www.hwhardsoft.de/english/projects/rs485-shield/?mobile=1)
has been used. has been used to make sure the daemon stays as
lightweight as possible.
### 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
of Applied Sciences.
Pingvin and Kotilämpö are registered trademarks of Enervent Zehnder Oy.
<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>

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;-;- 35;-;-
36;COIL_TEMP_DECREASE;Temperature decrease function desc 36;COIL_TEMP_DECREASE;Temperature decrease function desc
37;COIL_OVERTIME;Programmatic equivalent of OVERTIME digital input. 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;-;- 39;-;-
40;COIL_ECO_MODE;Eco mode desc 40;COIL_ECO_MODE;Eco mode desc
41;COIL_ALARM_A;Alarm of class A active 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

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

@ -0,0 +1,7 @@
module github.com/0ranki/enervent-ctrl/enervent-ctrl-go
go 1.18
require github.com/goburrow/modbus v0.1.0
require github.com/goburrow/serial v0.1.0 // indirect

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

@ -0,0 +1,4 @@
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=

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

@ -0,0 +1,98 @@
package main
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"strconv"
"strings"
"github.com/0ranki/enervent-ctrl/enervent-ctrl-go/pingvinKL"
)
// 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.6"
pingvin pingvinKL.PingvinKL
DEBUG = false
)
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])
}
}
func registers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if DEBUG {
log.Println("Received request for /registers")
}
json.NewEncoder(w).Encode(pingvin.Registers)
if DEBUG {
log.Println("Handled request for /registers")
}
}
func status(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(pingvin.Status)
}
func listen() {
log.Println("Starting pingvinAPI...")
http.HandleFunc("/api/v1/coils/", coils)
http.HandleFunc("/api/v1/registers/", registers)
http.HandleFunc("/api/v1/status", status)
html, err := fs.Sub(static, "static/html")
if err != nil {
log.Fatal(err)
}
htmlroot := http.FileServer(http.FS(html))
http.Handle("/", htmlroot)
err = http.ListenAndServe(":8888", nil)
if err != nil {
log.Fatal(err)
}
}
func main() {
log.Println("enervent-ctrl version", version)
pingvin = pingvinKL.New(DEBUG)
pingvin.Update()
go pingvin.Monitor(2)
listen()
}

View File

@ -0,0 +1,500 @@
package pingvinKL
import (
"bufio"
"fmt"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/goburrow/modbus"
)
// single coil data
type pingvinCoil struct {
Address int `json:"address"`
Symbol string `json:"symbol"`
Value bool `json:"value"`
Description string `json:"description"`
Reserved bool `json:"reserved"`
}
// unit modbus data
type PingvinKL struct {
Coils []pingvinCoil
Registers []pingvinRegister
Status pingvinStatus
buslock *sync.Mutex
statuslock *sync.Mutex
debug bool
}
// single register data
type pingvinRegister struct {
Address int `json:"address"`
Symbol string `json:"symbol"`
Value int `json:"value"`
Bitfield string `json:"bitfield"`
Type string `json:"type"`
Description string `json:"description"`
Reserved bool `json:"reserved"`
Multiplier int `json:"multiplier"`
}
type pingvinMeasurements struct {
Roomtemp1 float32 `json:"room_temp1"` // Room temperature at panel 1
SupplyHeated float32 `json:"supply_heated"` // Temperature of supply air after heating
SupplyHrc float32 `json:"supply_hrc"` // Temperature of supply air after heat recovery
SupplyIntake float32 `json:"supply_intake"` // Temperature of outside air at device
SupplyIntake24h float32 `json:"supply_intake_24h"` // 24h avg of outside air humidity
SupplyHum float32 `json:"supply_hum"` // Supply air humidity
Watertemp float32 `json:"watertemp"` // Heater element return water temperature
ExtractIntake float32 `json:"extract_intake"` // Temperature of extract air
ExtractHrc float32 `json:"extract_hrc"` // Temperature of extract air after heat recovery
ExtractHum float32 `json:"extract_hum"` // Relative humidity of extract air
ExtractHum48h float32 `json:"extract_hum_48h"` // 48h avg extract air humidity
}
type pingvinStatus struct {
HeaterPct int `json:"heater_pct"` // After heater valve position
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
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
DaysUntilService int `json:"days_until_service"` // Days until next filter service
Uptime string `json:"uptime"` // Unit uptime
SystemTime string `json:"system_time"` // Time and date in unit
}
var (
// Mutually exclusive coils
// Thanks to https://github.com/Jalle19/eda-modbus-bridge
// 1 = Away mode
// 2 = Away long mode
// 3 = Overpressure
// 6 = Max heating
// 7 = Max cooling
// 10 = Manual boost
// 40 = Eco mode
// Only one of these should be enabled at a time
mutexcoils = []uint16{1, 2, 3, 6, 7, 10, 40}
)
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 == "-"
coil := pingvinCoil{addr, symbol, false, description, reserved}
return coil
}
func newRegister(address, symbol, typ, multiplier, description string) pingvinRegister {
addr, err := strconv.Atoi(address)
if err != nil {
log.Fatal("newRegister: Atoi(address): ", err)
}
multipl := 1
if len(multiplier) > 0 {
multipl, err = strconv.Atoi(multiplier)
if err != nil {
log.Fatal("newRegister: Atoi(multiplier): ", err)
}
}
reserved := symbol == "Reserved" && description == "Reserved"
register := pingvinRegister{addr, symbol, 0, "0000000000000000", typ, description, reserved, multipl}
return register
}
// read a CSV file containing data for coils or registers
func readCsvLines(file string) [][]string {
delim := ";"
data := [][]string{}
csv, err := os.Open(file)
if err != nil {
log.Fatal(err)
}
defer csv.Close()
scanner := bufio.NewScanner(csv)
for scanner.Scan() {
elements := strings.Split(scanner.Text(), delim)
data = append(data, elements)
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
return data
}
// Configure the modbus parameters
func (p PingvinKL) getHandler() *modbus.RTUClientHandler {
// TODO: read configuration from file, hardcoded for now
handler := modbus.NewRTUClientHandler("/dev/ttyS0")
handler.BaudRate = 19200
handler.DataBits = 8
handler.Parity = "N"
handler.StopBits = 1
handler.SlaveId = 1
handler.Timeout = 1500 * time.Millisecond
return handler
}
func (p *PingvinKL) updateCoils() {
handler := p.getHandler()
p.buslock.Lock()
err := handler.Connect()
if err != nil {
log.Fatal("updateCoils: handler.Connect: ", err)
}
defer handler.Close()
client := modbus.NewClient(handler)
results, err := client.ReadCoils(0, uint16(len(p.Coils)))
if err != nil {
log.Fatal("updateCoils: client.ReadCoils: ", err)
}
p.buslock.Unlock()
// modbus.ReadCoils returns a byte array, with the first byte's bits representing coil values 0-7,
// second byte coils 8-15 etc.
// Within each byte, LSB represents the lowest n coil while MSB is the highest
// e.g. reading the first 8 coils might return a byte array of length 1, with the following:
// [4], which is 00000100, meaning all other coils are 0 except coil #2 (3rd coil)
//
k := 0 // pingvinCoil index
for i := 0; i < len(results); i++ { // loop through the byte array
for j := 0; j < 8; j++ {
// Here we loop through each bit in the byte, shifting right
// and checking if the LSB after the shift is 1 with a bitwise AND
// A coil value of 1 means on/true/yes, so == 1 returns the bool value
// for each coil
p.Coils[k].Value = (results[i] >> j & 0x1) == 1
k++
}
}
}
func (p *PingvinKL) getUnreservedRegisterSections() [][]uint16 {
// This function returns a slice of uint16 slices, each representing
// a consecutive section of unreserved registers as [start_address, length]
// for use as parameters to modbus.Client.ReadHoldingRegisters
// But, as there are so many of these sections (17), it's actually faster
// to fetch all registers in batches of 125 (total 7 modbus calls)
// so this is scrapped. Left on its own branch to shame.
nonreserved := [][]uint16{}
batch_index := 0
for k, register := range p.Registers {
if k == 0 {
continue // Register 0 is reserved
} else if register.Reserved && !p.Registers[k-1].Reserved {
log.Println("Found end of batch.", nonreserved[batch_index])
batch_index++
} else if !register.Reserved && p.Registers[k-1].Reserved {
nonreserved = append(nonreserved, []uint16{uint16(k), uint16(1)})
// if (p.debug) {
log.Println("Starting batch of unreserved registers at index", k)
// }
} else if !register.Reserved && !p.Registers[k-1].Reserved {
log.Println(nonreserved[batch_index][1])
nonreserved[batch_index][1] = nonreserved[batch_index][1] + 1
// if (p.debug) {
log.Println("Continuing batch, current nonreserved batches", nonreserved)
// }
}
}
return nonreserved
}
func (p *PingvinKL) updateRegisters() {
handler := p.getHandler()
p.buslock.Lock()
err := handler.Connect()
if err != nil {
log.Fatal("updateRegisters: handler.Connect: ", err)
}
defer handler.Close()
client := modbus.NewClient(handler)
regs := len(p.Registers)
k := 0
// modbus.ReadHoldingRegisters can read 125 regs at a time, so first we loop
// until all the values are fethed, increasing the value of k for each register
// When there are less than 125 registers to go, it's the last pass
for k < regs {
r := 125
if regs-k < 125 {
r = regs - k
}
results := []byte{}
for retries := 0; retries < 5; retries++ {
results, err = client.ReadHoldingRegisters(uint16(k), uint16(r))
if len(results) > 0 {
break
} else if retries == 4 {
log.Fatal("updateRegisters: client.ReadHoldingRegisters: ", err)
} else if err != nil {
log.Println("WARNING: updateRegisters: client.ReadHoldingRegisters: ", err)
}
}
// 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
// left by 8, then add the odd byte as is to the shifted 16-bit value
msb := true
value := int16(0)
uvalue := uint16(0)
for i := 0; i < len(results); i++ {
if msb {
value = int16(results[i]) << 8
uvalue = uint16(results[i]) << 8
} else {
value += int16(results[i])
uvalue += uint16(results[i])
if p.Registers[k].Type == "int16" {
p.Registers[k].Value = int(value)
}
if p.Registers[k].Type == "uint16" || p.Registers[k].Type == "enumeration" {
p.Registers[k].Value = int(uvalue)
}
if p.Registers[k].Type == "bitfield" {
p.Registers[k].Value = int(value)
// p.Registers[k].Bitfield = fmt.Sprintf("%16b", uvalue)
p.Registers[k].Bitfield = ""
for i := 16; i >= 0; i-- {
x := 0
if p.Registers[k].Value>>i&0x1 == 1 {
x = 1
}
p.Registers[k].Bitfield = fmt.Sprintf("%s%s", p.Registers[k].Bitfield, strconv.Itoa(x))
}
}
k++
}
msb = !msb
}
}
p.buslock.Unlock()
}
func (p *PingvinKL) Update() {
p.updateCoils()
p.updateRegisters()
p.populateStatus()
}
func (p PingvinKL) ReadCoil(n uint16) []byte {
handler := p.getHandler()
p.buslock.Lock()
err := handler.Connect()
if err != nil {
log.Fatal("ReadCoil: handler.Connect: ", err)
}
defer handler.Close()
client := modbus.NewClient(handler)
results, err := client.ReadCoils(n, 1)
p.buslock.Unlock()
if err != nil {
log.Fatal("ReadCoil: client.ReadCoils: ", err)
}
p.Coils[n].Value = results[0] == 1
return results
}
func (p *PingvinKL) WriteCoil(n uint16, val bool) bool {
handler := p.getHandler()
p.buslock.Lock()
err := handler.Connect()
if err != nil {
log.Println("WARNING: WriteCoil: failed to connect handler")
return false
}
defer handler.Close()
var value uint16 = 0
if val {
value = 0xff00
}
client := modbus.NewClient(handler)
results, err := client.WriteSingleCoil(n, value)
p.buslock.Unlock()
if err != nil {
log.Println("ERROR: WriteCoil: ", err)
}
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
}
p.ReadCoil(n)
return true
}
func (p *PingvinKL) WriteCoils(startaddr uint16, quantity uint16, vals []bool) error {
handler := p.getHandler()
p.buslock.Lock()
err := handler.Connect()
if err != nil {
log.Println("WARNING: WriteCoils: failed to connect handler:", err)
return err
}
defer handler.Close()
p.updateCoils()
coilslice := p.Coils[startaddr:(startaddr + quantity)]
if len(coilslice) != len(vals) {
return fmt.Errorf("ERROR: WriteCoils: vals ([]bool) is not the correct length")
}
// Convert slice of booleans to byte slice
// representing individual bits
// modbus.NewClient.WriteMultipleCoils wants the individual
// bits in each byte "inverted", e.g. if you want to set 16 coils
// with values 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, the
// byte array needs to be [0x01,0x80] or [0b00000001, 0b10000000]
bits := make([]byte, (len(coilslice)+7)/8)
for i, coil := range coilslice {
if coil.Value || vals[i] {
// i/8 integer division, returns 0 for 0-7 etc.
// i%8 loops through 0-7
// If coil.Value or vals[i] is true, set i%8 + 1 least significant bit
// to 1 in bits[i/8]
// e.g. coil[19]: (i/8 = 2, i%8 = 3)
// -> bits[2] = (bits[2] | 0b00000001 << 3)
// -> bits[2] = bits[2] | 0b00001000
// -> 4th least sign. bit is set to 1
bits[i/8] |= 0x01 << uint(i%8)
}
if !vals[i] {
// bits contains the current values. If vals[i] is false,
// the bit should be set to 0
// ^(1 << 3) = ^0b00001000 = 0b11110111
// 0b10101010 &| ^(1 << 3)
// 0b10101010
// AND 0b11110111
// -> 0b10100010
bits[i/8] &= ^(1 << uint(i%8))
}
if p.debug {
log.Println("index:", i/8, "value:", bits[i/8], "shift:", i%8)
}
}
log.Println(bits)
client := modbus.NewClient(handler)
results, err := client.WriteMultipleCoils(startaddr, quantity, bits)
p.buslock.Unlock()
if err != nil {
log.Println("ERROR: WriteCoils: ", err)
return err
}
log.Println(results)
return nil
}
// // TODO
// func (p *PingvinKL) checkMutexCoils(addr uint16) {
// for i := 0; i < len(mutexcoils); i++ {
// if mutexcoils[i] == addr {
// log.Println("Resetting mode coils")
// // TODO
// return
// }
// }
// }
func (p *PingvinKL) populateStatus() {
hpct := p.Registers[49].Value / p.Registers[49].Multiplier
if hpct > 100 {
p.Status.HeaterPct = hpct - 100
p.Status.HrcPct = 100
} else {
p.Status.HeaterPct = 0
p.Status.HrcPct = hpct
}
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.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)
p.Status.Measurements.SupplyIntake = float32(p.Registers[6].Value) / float32(p.Registers[6].Multiplier)
p.Status.Measurements.SupplyIntake24h = float32(p.Registers[134].Value) / float32(p.Registers[134].Multiplier)
p.Status.Measurements.SupplyHum = float32(p.Registers[36].Value) / float32(p.Registers[46].Multiplier)
p.Status.Measurements.Watertemp = float32(p.Registers[12].Value) / float32(p.Registers[12].Multiplier)
p.Status.Measurements.ExtractIntake = float32(p.Registers[10].Value) / float32(p.Registers[10].Multiplier)
p.Status.Measurements.ExtractHrc = float32(p.Registers[9].Value) / float32(p.Registers[9].Multiplier)
p.Status.Measurements.ExtractHum = float32(p.Registers[28].Value) / float32(p.Registers[28].Multiplier)
p.Status.Measurements.ExtractHum48h = float32(p.Registers[50].Value) / float32(p.Registers[50].Multiplier)
p.Status.HrcEffIn = p.Registers[29].Value / p.Registers[29].Multiplier
p.Status.HrcEffEx = p.Registers[30].Value / p.Registers[30].Multiplier
p.Status.OpMode = parseStatus(p.Registers[44].Value)
// TODO: Alarms, n of alarms
p.Status.DaysUntilService = p.Registers[538].Value / p.Registers[538].Multiplier
// TODO: Uptime & date in separate functions
}
func parseStatus(value int) string {
val := int16(value)
pingvinStatuses := []string{
"Max cooling",
"Max heating",
"Stopped by alarm",
"Stopped by user",
"Away",
"reserved",
"Adaptive",
"CO2 boost",
"RH boost",
"Manual boost",
"Overpressure",
"Cooker hood mode",
"Central vac mode",
"Electric heater cooloff",
"Summer night cooling",
"HRC defrost",
}
for i := 0; i < 15; i++ {
if val>>i&0x1 == 1 {
return pingvinStatuses[i]
}
}
return "Normal"
}
func (p *PingvinKL) Monitor(interval int) {
for {
time.Sleep(time.Duration(interval) * time.Second)
if p.debug {
log.Println("DEBUG: Updating values")
}
p.Update()
if p.debug {
log.Println("DEBUG: coils:", p.Coils)
log.Println("DEBUG: registers:", p.Registers)
}
}
}
// create a PingvinKL struct, read coils and registers from CSVs
func New(debug bool) PingvinKL {
pingvin := PingvinKL{}
pingvin.debug = debug
pingvin.buslock = &sync.Mutex{}
log.Println("Parsing coil data...")
coilData := readCsvLines("coils.csv")
for i := 0; i < len(coilData); i++ {
pingvin.Coils = append(pingvin.Coils, newCoil(coilData[i][0], coilData[i][1], coilData[i][2]))
}
log.Println("Parsed", len(pingvin.Coils), "coils")
log.Println("Parsing register data...")
registerData := readCsvLines("registers.csv")
for i := 0; i < len(registerData); i++ {
pingvin.Registers = append(pingvin.Registers,
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
}

View File

@ -1,4 +1,4 @@
package pingvin package pingvinKL
import ( import (
"fmt" "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,572 +0,0 @@
## 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
description: ""
trigger:
- platform: state
entity_id:
- input_boolean.penguin_max_heating
condition: [ ]
action:
- if:
- condition: state
entity_id: input_boolean.penguin_max_heating
state: "on"
- condition: state
entity_id: binary_sensor.penguin_max_heating
state: "off"
- condition: numeric_state
entity_id: sensor.penguin_room_temperature_1
below: input_number.penguin_temperature_setting_helper
then:
- service: rest_command.penguin_max_heating_on
data: { }
else: [ ]
- if:
- condition: state
entity_id: input_boolean.penguin_max_heating
state: "off"
then:
- service: rest_command.penguin_max_heating_off
data: { }
- if:
- condition: numeric_state
entity_id: sensor.penguin_room_temperature_1
above: input_number.penguin_temperature_setting_helper
- condition: state
entity_id: input_boolean.penguin_max_heating
state: "on"
then:
- service: input_boolean.turn_off
data: { }
target:
entity_id: input_boolean.penguin_max_heating
mode: single
- alias: Penguin Max Heating sensor v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- binary_sensor.penguin_max_heating
condition: [ ]
action:
- if:
- condition: state
entity_id: binary_sensor.penguin_max_heating
state: "on"
then:
- service: input_boolean.turn_on
data: { }
target:
entity_id: input_boolean.penguin_max_heating
else:
- service: input_boolean.turn_off
data: { }
target:
entity_id: input_boolean.penguin_max_heating
mode: single
- alias: Penguin overpressure input v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- input_boolean.penguin_overpressure
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
state: "on"
then:
- service: input_boolean.turn_on
data: { }
target:
entity_id: input_boolean.penguin_overpressure
else:
- service: input_boolean.turn_off
data: { }
target:
entity_id: input_boolean.penguin_overpressure
mode: single
- alias: Penguin SNC input v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- input_boolean.penguin_snc
condition: [ ]
action:
- if:
- condition: state
entity_id: input_boolean.penguin_snc
state: "on"
then:
- service: rest_command.penguin_snc_enable
data: { }
else:
- service: rest_command.penguin_snc_disable
data: { }
mode: single
- alias: Penguin temperature down v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- input_button.penguin_temperature_down
condition: [ ]
action:
- 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
description: ""
trigger:
- platform: state
entity_id:
- sensor.penguin_temperature_setting
condition: [ ]
action:
- service: input_number.set_value
data:
value: "{{ states('sensor.penguin_temperature_setting') }}"
target:
entity_id: input_number.penguin_temperature_setting_helper
mode: single

View File

@ -1,153 +0,0 @@
views:
- title: Pingvin
icon: mdi:penguin
cards:
- type: vertical-stack
cards:
- type: horizontal-stack
cards:
- type: gauge
entity: sensor.penguin_heat_recovery_pct
name: HRC
severity:
green: 100
yellow: 0
red: 0
- type: gauge
entity: sensor.penguin_after_heater_pct
name: Heating
severity:
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
type: grid
cards:
- show_name: false
show_icon: true
type: button
tap_action:
action: toggle
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_icon: true
type: button
tap_action:
action: toggle
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
- 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
state_color: false
- type: entities
entities:
- entity: sensor.penguin_intake_air
name: Outside air at unit
- entity: sensor.penguin_intake_air_24h
name: Outside air 24h avg
- entity: sensor.penguin_supply_air_hrc
name: Intake after HRC
- entity: sensor.penguin_supply_air
name: Supply air
- entity: sensor.penguin_return_water
name: Return water temperature
- entity: sensor.penguin_extract_air
name: Extract 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
- entity: sensor.penguin_hrc_efficiency_intake
icon: ''
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
title: Measurements
title: Heating & Ventilation

View File

@ -1,153 +0,0 @@
views:
- title: Pingvin
icon: mdi:penguin
cards:
- type: vertical-stack
cards:
- type: horizontal-stack
cards:
- type: gauge
entity: sensor.penguin_heat_recovery_pct
name: LTO
severity:
green: 100
yellow: 0
red: 0
- type: gauge
entity: sensor.penguin_after_heater_pct
name: Lämmitys
severity:
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
cards:
- show_name: false
show_icon: true
type: button
tap_action:
action: toggle
entity: input_boolean.penguin_circulation_fan_adaptive
name: Mukautuva kiertoilma
show_state: false
- type: conditional
conditions:
- entity: binary_sensor.penguin_max_heating_enabled
state: 'on'
card:
show_name: false
show_icon: true
type: button
tap_action:
action: toggle
entity: input_boolean.penguin_max_heating
name: Max lämmitys
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 jäähdytys
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
- 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:
- entity: sensor.penguin_intake_air
name: Ulkoilma koneen luona
- entity: sensor.penguin_intake_air_24h
name: Ulkoilma 24h keskiarvo
- entity: sensor.penguin_supply_air_hrc
name: Tuloilma LTO jälkeen
- entity: sensor.penguin_supply_air
name: Tuloilma
- entity: sensor.penguin_return_water
name: Paluuvesi
- entity: sensor.penguin_extract_air
name: Poistoilma ennen LTO
- entity: sensor.penguin_waste_air
name: Jäteilma
- entity: sensor.penguin_extract_air_humidity
name: Poistoilma kosteus
- entity: sensor.penguin_extract_air_humidity_48h
name: Poistoilma kosteus 48h
- entity: sensor.penguin_hrc_efficiency_intake
icon: ''
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

@ -0,0 +1,27 @@
views:
- title: Home
cards:
- type: entities
entities:
- entity: sensor.enervent_pingvin_mode
- entity: sensor.enervent_pingvin_room_temperature_1
- entity: sensor.enervent_pingvin_heating
- entity: sensor.enervent_pingvin_heat_recovery
- entity: sensor.enervent_pingvin_temperature_setpoint
- entity: sensor.enervent_pingvin_circulation_fan
- entity: sensor.enervent_pingvin_next_maintenance
title: Enervent Pingvin
- type: entities
entities:
- entity: sensor.enervent_pingvin_outside_air
- entity: sensor.enervent_pingvin_outside_air_24h
- entity: sensor.enervent_pingvin_supply_air_hrc
- entity: sensor.enervent_pingvin_outside_air_humidity
- entity: sensor.enervent_pingvin_supply_air
- entity: sensor.enervent_pingvin_return_water
- entity: sensor.enervent_pingvin_extract_air
- entity: sensor.enervent_pingvin_waste_air
- entity: sensor.enervent_pingvin_extract_air_humidity
- entity: sensor.enervent_pingvin_hrc_efficiency_intake
- entity: sensor.enervent_pingvin_hrc_efficiency_exhaust
title: Enervent Pingvin measurements

View File

@ -0,0 +1,63 @@
rest:
- resource: http://IP_ADDRESS:8888/api/v1/status
scan_interval: 15
sensor:
- name: "Enervent Pingvin mode"
value_template: "{{ value_json['op_mode'] }}"
icon: mdi:information
- name: "Enervent Pingvin room temperature 1"
value_template: "{{ value_json['measurements']['room_temp1'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin heating"
value_template: "{{ value_json['heater_pct'] }}"
unit_of_measurement: "%"
icon: mdi:heating-coil
- name: "Enervent Pingvin heat recovery"
value_template: "{{ value_json['hrc_pct'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Enervent Pingvin temperature setpoint"
value_template: "{{ value_json['temp_setting'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer-check
- name: "Enervent Pingvin circulation fan"
value_template: "{{ value_json['fan_pct'] }}"
unit_of_measurement: "%"
icon: mdi:fan
- name: "Enervent Pingvin HRC efficiency intake"
value_template: "{{ value_json['hrc_efficiency_in'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Enervent Pingvin HRC efficiency exhaust"
value_template: "{{ value_json['hrc_efficiency_ex'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Enervent Pingvin next maintenance"
value_template: "{{ value_json['days_until_service'] }}"
unit_of_measurement: "pv"
icon: mdi:calendar
- name: "Enervent Pingvin supply air"
value_template: "{{ value_json['measurements']['supply_heated'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin supply air HRC"
value_template: "{{ value_json['measurements']['supply_hrc'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin outside air"
value_template: "{{ value_json['measurements']['supply_intake'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin outside air 24h"
value_template: "{{ value_json['measurements']['supply_intake_24h'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin outside air humidity"
value_template: "{{ value_json['measurements']['supply_hum'] }}"
unit_of_measurement: "%"
icon: mdi:water-percent
- name: "Enervent Pingvin return water"
value_template: "{{ value_json['measurements']['watertemp'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin extract air"
value_template: "{{ value_json['measurements']['extract_intake'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin waste air"
value_template: "{{ value_json['measurements']['extract_hrc'] }}"
unit_of_measurement: "°C"

View File

@ -0,0 +1,28 @@
views:
- title: Home
cards:
- type: entities
entities:
- entity: sensor.enervent_pingvin_tila
- entity: sensor.enervent_pingvin_huonelampotila_1
- entity: sensor.enervent_pingvin_lammitys
- entity: sensor.enervent_pingvin_lammontalteenotto
- entity: sensor.enervent_pingvin_lampotila_asetus
- entity: sensor.enervent_pingvin_kiertoilmapuhallin
- entity: sensor.enervent_pingvin_seuraava_huolto
title: Enervent Pingvin
- type: entities
entities:
- entity: sensor.enervent_pingvin_tuloilma_ulko
- entity: sensor.enervent_pingvin_tuloilma_ulko_24h
- entity: sensor.enervent_pingvin_tuloilma_lto
- entity: sensor.enervent_pingvin_tuloilma_kosteus
- entity: sensor.enervent_pingvin_tuloilma
- entity: sensor.enervent_pingvin_paluuvesi
- entity: sensor.enervent_pingvin_poistoilma
- entity: sensor.enervent_pingvin_jateilma
- entity: sensor.enervent_pingvin_poistoilma_kosteus_48h
- entity: sensor.enervent_pingvin_lto_hyotysuhde_tuloilma
icon: ''
- entity: sensor.enervent_pingvin_lto_hyotysuhde_poistoilma
title: Enervent Pingvin mittaukset

View File

@ -0,0 +1,63 @@
rest:
- resource: http://IP_ADDRESS:8888/api/v1/status
scan_interval: 15
sensor:
- name: "Enervent Pingvin tila"
value_template: "{{ value_json['op_mode'] }}"
icon: mdi:information
- name: "Enervent Pingvin huonelämpötila 1"
value_template: "{{ value_json['measurements']['room_temp1'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin lämmitys"
value_template: "{{ value_json['heater_pct'] }}"
unit_of_measurement: "%"
icon: mdi:heating-coil
- name: "Enervent Pingvin lämmöntalteenotto"
value_template: "{{ value_json['hrc_pct'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Enervent Pingvin lämpötila-asetus"
value_template: "{{ value_json['temp_setting'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer-check
- name: "Enervent Pingvin kiertoilmapuhallin"
value_template: "{{ value_json['fan_pct'] }}"
unit_of_measurement: "%"
icon: mdi:fan
- name: "Enervent Pingvin LTO hyötysuhde tuloilma"
value_template: "{{ value_json['hrc_efficiency_in'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Enervent Pingvin LTO hyötysuhde poistoilma"
value_template: "{{ value_json['hrc_efficiency_ex'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Enervent Pingvin seuraava huolto"
value_template: "{{ value_json['days_until_service'] }}"
unit_of_measurement: "pv"
icon: mdi:calendar
- name: "Enervent Pingvin tuloilma"
value_template: "{{ value_json['measurements']['supply_heated'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin tuloilma LTO"
value_template: "{{ value_json['measurements']['supply_hrc'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin tuloilma ulko"
value_template: "{{ value_json['measurements']['supply_intake'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin tuloilma ulko 24h"
value_template: "{{ value_json['measurements']['supply_intake_24h'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin tuloilma kosteus"
value_template: "{{ value_json['measurements']['supply_hum'] }}"
unit_of_measurement: "%"
icon: mdi:water-percent
- name: "Enervent Pingvin paluuvesi"
value_template: "{{ value_json['measurements']['watertemp'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin poistoilma"
value_template: "{{ value_json['measurements']['extract_intake'] }}"
unit_of_measurement: "°C"
- name: "Enervent Pingvin jäteilma"
value_template: "{{ value_json['measurements']['extract_hrc'] }}"
unit_of_measurement: "°C"

View File

@ -1,39 +0,0 @@
input_boolean:
penguin_max_heating:
name: Penguin Max Heating
icon: mdi:heat-wave
penguin_circulation_fan_adaptive:
name: Penguin Adaptive Circulation Fan
icon: mdi:fan-auto
penguin_overpressure:
name: Penguin Overpressure
icon: mdi:fireplace
penguin_boost:
name: Penguin Boost
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
icon: mdi:thermometer-plus
penguin_temperature_down:
name: Penguin temperature down
icon: mdi:thermometer-minus
input_number:
penguin_temperature_setting_helper:
name: Penguin temperature setting helper
min: 20
max: 30
unit_of_measurement: "°C"
step: 0.5

View File

@ -1,202 +0,0 @@
rest:
- resource: https://IP_ADDRESS:8888/api/v1/status
scan_interval: 5
verify_ssl: false
username: pingvin
password: enervent
sensor:
- name: "Penguin operating mode"
value_template: "{{ value_json['op_mode'] }}"
icon: mdi:information
- name: "Penguin room temperature 1"
value_template: "{{ value_json['measurements']['room_temp1'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer
- name: "Penguin after heater pct"
value_template: "{{ value_json['heater_pct'] }}"
unit_of_measurement: "%"
icon: mdi:heating-coil
- name: "Penguin heat recovery pct"
value_template: "{{ value_json['hrc_pct'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Penguin temperature setting"
value_template: "{{ value_json['temp_setting'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer-check
- name: "Penguin circulation fan pct"
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: "%"
icon: mdi:recycle
- name: "Penguin HRC efficiency extract"
value_template: "{{ value_json['hrc_efficiency_ex'] }}"
unit_of_measurement: "%"
icon: mdi:recycle
- name: "Penguin supply air"
value_template: "{{ value_json['measurements']['supply_heated'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer
- name: "Penguin supply air hrc"
value_template: "{{ value_json['measurements']['supply_hrc'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer
- name: "Penguin intake air"
value_template: "{{ value_json['measurements']['supply_intake'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer
- name: "Penguin intake air 24h"
value_template: "{{ value_json['measurements']['supply_intake_24h'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer
- name: "Penguin supply air humidity"
value_template: "{{ value_json['measurements']['supply_hum'] }}"
unit_of_measurement: "%"
icon: mdi:water-percent
- name: "Penguin return water"
value_template: "{{ value_json['measurements']['watertemp'] }}"
unit_of_measurement: "°C"
icon: mdi:water-thermometer-outline
- name: "Penguin extract air"
value_template: "{{ value_json['measurements']['extract_intake'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer
- name: "Penguin waste air"
value_template: "{{ value_json['measurements']['extract_hrc'] }}"
unit_of_measurement: "°C"
icon: mdi:thermometer
- name: "Penguin extract air humidity"
value_template: "{{ value_json['measurements']['extract_hum'] }}"
unit_of_measurement: "%"
icon: mdi:water-percent
- name: "Penguin extract air humidity 48h"
value_template: "{{ value_json['measurements']['extract_hum_48h'] }}"
unit_of_measurement: "%"
icon: mdi:water-percent
binary_sensor:
- name: "Penguin circulation adaptive"
value_template: "{{ value_json['coils'][11]['value'] }}"
icon: mdi:fan-auto
- name: "Penguin max heating"
value_template: "{{ value_json['coils'][6]['value'] }}"
icon: mdi:heat-wave
- name: "Penguin max cooling"
value_template: "{{ value_json['coils'][7]['value'] }}"
icon: mdi:snowflake
- name: "Penguin boost"
value_template: "{{ value_json['coils'][10]['value'] }}"
icon: mdi:fan-plus
- 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') }}"
rest_command:
penguin_circulation_adaptive:
url: https://IP_ADDRESS:8888/api/v1/coils/11/1
method: POST
icon: mdi:fan-auto
verify_ssl: false
username: pingvin
password: enervent
penguin_circulation_manual:
url: https://IP_ADDRESS:8888/api/v1/coils/11/0
method: POST
icon: mdi:fan
verify_ssl: false
username: pingvin
password: enervent
penguin_boost_on:
url: https://IP_ADDRESS: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
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_overpressure_toggle:
url: https://IP_ADDRESS:8888/api/v1/coils/3
method: POST
icon: mdi:arrow-expand-all
verify_ssl: false
username: pingvin
password: enervent
penguin_max_heating_on:
url: https://IP_ADDRESS:8888/api/v1/coils/6/1
method: POST
icon: mdi:heat-wave
verify_ssl: false
username: pingvin
password: enervent
penguin_max_heating_off:
url: https://IP_ADDRESS:8888/api/v1/coils/6/0
method: POST
icon: mdi:scent-off
verify_ssl: false
username: pingvin
password: enervent
penguin_max_cooling_on:
url: https://IP_ADDRESS: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
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_temperature_up:
url: https://IP_ADDRESS: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
method: POST
verify_ssl: false
username: pingvin
password: enervent

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()
}

View File

@ -1,671 +0,0 @@
package pingvin
import (
"bufio"
"fmt"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/goburrow/modbus"
"github.com/prometheus/client_golang/prometheus"
)
// single coil data
type pingvinCoil struct {
Address int `json:"address"`
Symbol string `json:"symbol"`
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
buslock *sync.Mutex
handler *modbus.RTUClientHandler
modbusclient modbus.Client
firstReadDone bool
Debug PingvinLogger
}
// single register data
type pingvinRegister struct {
Address int `json:"address"`
Symbol string `json:"symbol"`
Value int `json:"value"`
Bitfield string `json:"bitfield"`
Type string `json:"type"`
Description string `json:"description"`
Reserved bool `json:"reserved"`
Multiplier int `json:"multiplier"`
PromDesc *prometheus.Desc `json:"-"`
}
type pingvinMeasurements struct {
Roomtemp1 float32 `json:"room_temp1"` // Room temperature at panel 1
SupplyHeated float32 `json:"supply_heated"` // Temperature of supply air after heating
SupplyHrc float32 `json:"supply_hrc"` // Temperature of supply air after heat recovery
SupplyIntake float32 `json:"supply_intake"` // Temperature of outside air at device
SupplyIntake24h float32 `json:"supply_intake_24h"` // 24h avg of outside air humidity
SupplyHum float32 `json:"supply_hum"` // Supply air humidity
Watertemp float32 `json:"watertemp"` // Heater element return water temperature
ExtractIntake float32 `json:"extract_intake"` // Temperature of extract air
ExtractHrc float32 `json:"extract_hrc"` // Temperature of extract air after heat recovery
ExtractHum float32 `json:"extract_hum"` // Relative humidity of extract air
ExtractHum48h float32 `json:"extract_hum_48h"` // 48h avg extract air humidity
}
type pingvinStatus struct {
HeaterPct int `json:"heater_pct"` // After heater valve position
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"`
}
type PingvinLogger struct {
dbg bool
}
var (
// Mutually exclusive coils
// Thanks to https://github.com/Jalle19/eda-modbus-bridge
// 1 = Away mode
// 2 = Away long mode
// 3 = Overpressure
// 6 = Max heating
// 7 = Max cooling
// 10 = Manual boost
// 40 = Eco mode
// Only one of these should be enabled at a time
mutexcoils = []uint16{1, 2, 3, 6, 7, 10, 40}
)
func (logger *PingvinLogger) Println(msg ...any) {
if logger.dbg {
log.Println(msg...)
}
}
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}
}
func newRegister(address, symbol, typ, multiplier, description string) *pingvinRegister {
addr, err := strconv.Atoi(address)
if err != nil {
log.Fatal("newRegister: Atoi(address): ", err)
}
multipl := 1
if len(multiplier) > 0 {
multipl, err = strconv.Atoi(multiplier)
if err != nil {
log.Fatal("newRegister: Atoi(multiplier): ", err)
}
}
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}
}
// read a CSV file containing data for coils or registers
func readCsvLines(file string) [][]string {
delim := ";"
data := [][]string{}
csv, err := os.Open(file)
if err != nil {
log.Fatal(err)
}
defer csv.Close()
scanner := bufio.NewScanner(csv)
for scanner.Scan() {
elements := strings.Split(scanner.Text(), delim)
data = append(data, elements)
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
return data
}
// 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)
p.handler.BaudRate = 19200
p.handler.DataBits = 8
p.handler.Parity = "N"
p.handler.StopBits = 1
p.handler.SlaveId = 1
p.handler.Timeout = 1500 * time.Millisecond
err := p.handler.Connect()
if err != nil {
log.Fatal("createModbusClient: p.handler.Connect: ", err)
}
p.Debug.Println("Handler connected")
p.modbusclient = modbus.NewClient(p.handler)
}
func (p *Pingvin) Quit() {
err := p.handler.Close()
if err != nil {
log.Println("ERROR: Quit:", err)
}
}
// 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)
p.buslock.Lock()
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)
}
// modbus.ReadCoils returns a byte array, with the first byte's bits representing coil values 0-7,
// second byte coils 8-15 etc.
// Within each byte, LSB represents the lowest n coil while MSB is the highest
// e.g. reading the first 8 coils might return a byte array of length 1, with the following:
// [4], which is 00000100, meaning all other coils are 0 except coil #2 (3rd coil)
//
k := 0 // pingvinCoil index
for i := 0; i < len(results); i++ { // loop through the byte array
for j := 0; j < 8; j++ {
// Here we loop through each bit in the byte, shifting right
// and checking if the LSB after the shift is 1 with a bitwise AND
// A coil value of 1 means on/true/yes, so == 1 returns the bool value
// for each coil
p.Coils[k].Value = (results[i] >> j & 0x1) == 1
k++
}
}
}
// Read a single holding register, stores value in p.Registers
// Returns integer value of register
func (p *Pingvin) 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
}
if p.Registers[addr].Type == "uint16" {
p.Registers[addr].Value = int(uint16(results[0]) << 8)
p.Registers[addr].Value += int(uint16(results[1]))
} else if p.Registers[addr].Type == "int16" {
p.Registers[addr].Value = int(int16(results[0]) << 8)
p.Registers[addr].Value += int(int16(results[1]))
}
return p.Registers[addr].Value, nil
}
// Update a single holding register
func (p *Pingvin) WriteRegister(addr uint16, value uint16) (uint16, error) {
p.buslock.Lock()
_, err := p.modbusclient.WriteSingleRegister(addr, value)
p.buslock.Unlock()
if err != nil {
log.Println("ERROR: WriteRegister:", err)
return 0, err
}
val, err := p.ReadRegister(addr)
if err != nil {
log.Println("ERROR: WriteRegister:", err)
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() {
var err error
regs := len(p.Registers)
k := 0
// modbus.ReadHoldingRegisters can read 125 regs at a time, so first we loop
// until all the values are fethed, increasing the value of k for each register
// When there are less than 125 registers to go, it's the last pass
for k < regs {
r := 125
if regs-k < 125 {
r = regs - k
}
var results []byte
for retries := 1; retries <= 5; retries++ {
p.Debug.Println("Reading registers, attempt", retries, "k:", k)
p.buslock.Lock()
results, err = p.modbusclient.ReadHoldingRegisters(uint16(k), uint16(r))
p.buslock.Unlock()
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
} else if err != nil {
log.Printf("WARNING: updateRegisters: client.ReadHoldingRegisters attempt %d: %s", retries, 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
// left by 8, then add the odd byte as is to the shifted 16-bit value
msb := true
value := int16(0)
uvalue := uint16(0)
for i := 0; i < len(results); i++ {
if msb {
value = int16(results[i]) << 8
uvalue = uint16(results[i]) << 8
} else {
value += int16(results[i])
uvalue += uint16(results[i])
if p.Registers[k].Type == "int16" {
p.Registers[k].Value = int(value)
}
if p.Registers[k].Type == "uint16" || p.Registers[k].Type == "enumeration" {
p.Registers[k].Value = int(uvalue)
}
if p.Registers[k].Type == "bitfield" {
p.Registers[k].Value = int(value)
// p.Registers[k].Bitfield = fmt.Sprintf("%16b", uvalue)
p.Registers[k].Bitfield = ""
for i := 16; i >= 0; i-- {
x := 0
if p.Registers[k].Value>>i&0x1 == 1 {
x = 1
}
p.Registers[k].Bitfield = fmt.Sprintf("%s%s", p.Registers[k].Bitfield, strconv.Itoa(x))
}
}
k++
}
msb = !msb
}
}
}
// Wrapper function for updating coils, registers and populating
// p.Status for Home Assistant
func (p *Pingvin) 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++ {
p.buslock.Lock()
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)
}
p.Coils[n].Value = results[0] == 1
return
}
// Force a single coil
func (p *Pingvin) WriteCoil(n uint16, val bool) bool {
if val {
_ = p.checkMutexCoils(n) //, p.handler)
}
var value uint16 = 0
if val {
value = 0xff00
}
p.buslock.Lock()
results, err := p.modbusclient.WriteSingleCoil(n, value)
p.buslock.Unlock()
if err != nil {
log.Println("ERROR: WriteCoil: ", err)
}
if (val && results[0] != 255) && (!val && results[0] != 0) {
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)
return true
}
// Force multiple coils
func (p *Pingvin) WriteCoils(startaddr uint16, quantity uint16, vals []bool) error {
p.updateCoils()
coilslice := p.Coils[startaddr:(startaddr + quantity)]
if len(coilslice) != len(vals) {
return fmt.Errorf("ERROR: WriteCoils: vals ([]bool) is not the correct length")
}
// Convert slice of booleans to byte slice
// representing individual bits
// modbus.NewClient.WriteMultipleCoils wants the individual
// bits in each byte "inverted", e.g. if you want to set 16 coils
// with values 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, the
// byte array needs to be [0x01,0x80] or [0b00000001, 0b10000000]
bits := make([]byte, (len(coilslice)+7)/8)
for i, coil := range coilslice {
if coil.Value || vals[i] {
// i/8 integer division, returns 0 for 0-7 etc.
// i%8 loops through 0-7
// If coil.Value or vals[i] is true, set i%8 + 1 least significant bit
// to 1 in bits[i/8]
// e.g. coil[19]: (i/8 = 2, i%8 = 3)
// -> bits[2] = (bits[2] | 0b00000001 << 3)
// -> bits[2] = bits[2] | 0b00001000
// -> 4th least sign. bit is set to 1
bits[i/8] |= 0x01 << uint(i%8)
}
if !vals[i] {
// bits contains the current values. If vals[i] is false,
// the bit should be set to 0
// ^(1 << 3) = ^0b00001000 = 0b11110111
// 0b10101010 &| ^(1 << 3)
// 0b10101010
// AND 0b11110111
// -> 0b10100010
bits[i/8] &= ^(1 << uint(i%8))
}
p.Debug.Println("index:", i/8, "value:", bits[i/8], "shift:", i%8)
}
p.Debug.Println(bits)
p.buslock.Lock()
results, err := p.modbusclient.WriteMultipleCoils(startaddr, quantity, bits)
p.buslock.Unlock()
if err != nil {
log.Println("ERROR: WriteCoils: ", err)
return err
}
log.Println(results)
return nil
}
// 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 {
for _, mutexcoil := range mutexcoils {
if mutexcoil == addr {
for _, n := range mutexcoils {
if p.Coils[n].Value {
p.buslock.Lock()
_, err := p.modbusclient.WriteSingleCoil(n, 0)
p.buslock.Unlock()
if err != nil {
log.Println("ERROR: checkMutexCoils:", err)
return err
}
}
}
return nil
}
}
return nil
}
// populate p.Status struct for Home Assistant
func (p *Pingvin) populateStatus() {
p.Status = &pingvinStatus{}
hpct := p.Registers[49].Value / p.Registers[49].Multiplier
if hpct > 100 {
p.Status.HeaterPct = hpct - 100
p.Status.HrcPct = 100
} else {
p.Status.HeaterPct = 0
p.Status.HrcPct = hpct
}
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)
p.Status.Measurements.SupplyIntake = float32(p.Registers[6].Value) / float32(p.Registers[6].Multiplier)
p.Status.Measurements.SupplyIntake24h = float32(p.Registers[134].Value) / float32(p.Registers[134].Multiplier)
p.Status.Measurements.SupplyHum = float32(p.Registers[36].Value) / float32(p.Registers[36].Multiplier)
p.Status.Measurements.Watertemp = float32(p.Registers[12].Value) / float32(p.Registers[12].Multiplier)
p.Status.Measurements.ExtractIntake = float32(p.Registers[10].Value) / float32(p.Registers[10].Multiplier)
p.Status.Measurements.ExtractHrc = float32(p.Registers[9].Value) / float32(p.Registers[9].Multiplier)
p.Status.Measurements.ExtractHum = float32(p.Registers[13].Value) / float32(p.Registers[13].Multiplier)
p.Status.Measurements.ExtractHum48h = float32(p.Registers[35].Value) / float32(p.Registers[35].Multiplier)
p.Status.HrcEffIn = p.Registers[29].Value / p.Registers[29].Multiplier
p.Status.HrcEffEx = p.Registers[30].Value / p.Registers[30].Multiplier
p.Status.OpMode = parseStatus(p.Registers[44].Value)
// TODO: Alarms, n of alarms
// TODO: Uptime & date in separate functions
p.Status.Coils = p.Coils
}
// Parse readable status from integer (bitfield) value
func parseStatus(value int) string {
val := int16(value)
pingvinStatuses := []string{
"Max cooling",
"Max heating",
"Stopped by alarm",
"Stopped by user",
"Away",
"reserved",
"Adaptive",
"CO2 boost",
"RH boost",
"Manual boost",
"Overpressure",
"Cooker hood mode",
"Central vac mode",
"Electric heater cooloff",
"Summer night cooling",
"HRC defrost",
}
for i := 0; i < 15; i++ {
if val>>i&0x1 == 1 {
return pingvinStatuses[i]
}
}
return "Normal"
}
// Change temperature setpoint (register 135)
// action can be up, down or a value.
// If value, the value can be the raw register value (200-300),
// 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 {
temperature := 0
if action == "up" {
temperature = p.Registers[135].Value + 1*p.Registers[135].Multiplier
p.Debug.Println("Raising temperature to", temperature)
} else if action == "down" {
temperature = p.Registers[135].Value - 1*p.Registers[135].Multiplier
p.Debug.Println("Lowering temperature to", temperature)
} else {
t, err := strconv.Atoi(action)
if err != nil {
p.Debug.Println(err)
tfloat, err := strconv.ParseFloat(action, 32)
if err != nil {
p.Debug.Println(err)
return err
}
t = int(tfloat * float64(p.Registers[135].Multiplier))
}
if t <= 30 && t >= 20 {
temperature = 10 * t
} else {
temperature = t
}
p.Debug.Println("Setting temperature to", temperature)
}
if temperature > 300 || temperature < 200 {
return fmt.Errorf("Temperature setpoint must be between 200 and 300")
}
p.Debug.Println("Writing register 135 to", temperature)
res, err := p.WriteRegister(135, uint16(temperature))
if err != nil {
return err
}
p.Debug.Println("Temperature changed to", res)
return nil
}
func (p *Pingvin) Monitor(interval int) {
for {
time.Sleep(time.Duration(interval) * time.Second)
p.Debug.Println("Updating values")
p.Update()
}
}
// 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{}
pingvin.Debug.dbg = debug
pingvin.buslock = &sync.Mutex{}
pingvin.createModbusClient(serial)
log.Println("Parsing coil data...")
coilData := readCsvLines("coils.csv")
for i := 0; i < len(coilData); i++ {
pingvin.Coils = append(pingvin.Coils, newCoil(coilData[i][0], coilData[i][1], coilData[i][2]))
}
log.Println("Parsed", len(pingvin.Coils), "coils")
log.Println("Parsing register data...")
registerData := readCsvLines("registers.csv")
for i := 0; i < len(registerData); i++ {
pingvin.Registers = append(pingvin.Registers,
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
}