Compare commits

..

No commits in common. "master" and "ttypath" have entirely different histories.

19 changed files with 431 additions and 1240 deletions

View File

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

View File

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

3
.gitignore vendored
View File

@ -1,4 +1,3 @@
.vscode/
.idea/
build.sh
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.

155
README.md
View File

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

28
go.mod
View File

@ -1,25 +1,27 @@
module github.com/0ranki/enervent-ctrl
go 1.22
go 1.18
require (
github.com/0ranki/https-go v0.0.0-20230314073101-4eca22af948c
github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433
github.com/goburrow/modbus v0.1.0
github.com/gorilla/handlers v1.5.2
github.com/prometheus/client_golang v1.19.0
github.com/gorilla/handlers v1.5.1
github.com/prometheus/client_golang v1.14.0
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
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/cespare/xxhash/v2 v2.1.2 // indirect
github.com/felixge/httpsnoop v1.0.1 // 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
github.com/golang/protobuf v1.5.2 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
google.golang.org/protobuf v1.28.1 // indirect
)

37
go.sum
View File

@ -31,8 +31,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
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/0ranki/https-go v0.0.0-20230314064508-ba9a558db433 h1:QT2IRJnhIdCSr26LJktnZnBpHdiLfTrUFzLSdP3h9Wo=
github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433/go.mod h1:r4Jb05+PuiVKHDYwSsSBuSz4LpOlC2DgOY4N58+K8Hk=
github.com/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=
@ -48,8 +48,6 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
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=
@ -64,8 +62,6 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
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=
@ -111,8 +107,6 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
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=
@ -125,8 +119,6 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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=
@ -142,8 +134,6 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
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=
@ -162,14 +152,11 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxv
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=
@ -188,24 +175,18 @@ github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqr
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=
@ -213,8 +194,6 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
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=
@ -355,8 +334,6 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
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=
@ -412,10 +389,9 @@ golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc
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=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
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=
@ -491,14 +467,13 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
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=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

188
main.go
View File

@ -2,12 +2,17 @@ package main
import (
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/json"
"flag"
"io/fs"
"io/ioutil"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/0ranki/enervent-ctrl/pingvin"
@ -25,7 +30,7 @@ import (
var static embed.FS
var (
version = "0.2.0"
version = "0.0.25"
device pingvin.Pingvin
config Conf
usernamehash [32]byte
@ -37,7 +42,6 @@ type Conf struct {
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"`
@ -45,7 +49,149 @@ type Conf struct {
LogFile string `yaml:"log_file"`
LogAccess bool `yaml:"log_access"`
Debug bool `yaml:"debug"`
ReadOnly bool `yaml:"read_only"`
}
// HTTP Basic Authentication middleware for http.HandlerFunc
func authHandlerFunc(next http.HandlerFunc) http.HandlerFunc {
// Based on https://www.alexedwards.net/blog/basic-authentication-in-go
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok {
userHash := sha256.Sum256([]byte(user))
passHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1)
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
if len(user) == 0 {
user = "-"
}
log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// HTTP Basic Authentication middleware for http.Handler
func authHandler(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok {
userHash := sha256.Sum256([]byte(user))
passHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1)
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
if len(user) == 0 {
user = "-"
}
log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// \/api/v1/coils endpoint
func coils(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/coils/"), "/")
if len(pathparams[0]) == 0 {
json.NewEncoder(w).Encode(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
}
device.ReadCoil(uint16(intaddr))
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
}
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
}
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
}
device.ReadRegister(uint16(intaddr))
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
}
_, 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 {
device.Temperature(pathparams[0])
json.NewEncoder(w).Encode(device.Registers[135])
} else {
return
}
}
// Start the HTTP server
@ -64,8 +210,6 @@ func serve(cert, key *string) {
}
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)
@ -115,12 +259,12 @@ func parseConfigFile() {
}
}
conffile := confpath + "/configuration.yaml"
yamldata, err := os.ReadFile(conffile)
yamldata, err := ioutil.ReadFile(conffile)
if err != nil {
log.Println("Configuration file", conffile, "not found")
log.Println("Generating", conffile, "with default values")
initDefaultConfig(confpath)
if yamldata, err = os.ReadFile(conffile); err != nil {
if yamldata, err = ioutil.ReadFile(conffile); err != nil {
log.Fatal("Error parsing configuration:", err)
}
}
@ -133,19 +277,17 @@ func parseConfigFile() {
// 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,
"/dev/ttyS0",
8888,
confpath + "/certificate.pem",
confpath + "/privatekey.pem",
"device",
"enervent",
4,
false,
"",
false,
false,
}
conffile := confpath + "/configuration.yaml"
confbytes, err := yaml.Marshal(&config)
@ -157,7 +299,7 @@ func initDefaultConfig(confpath string) {
}
}
// Read configuration. CLI flags take precedence over configuration file
// Read configuration. CLI flags take presedence over configuration file
func configure() {
log.Println("Reading configuration")
parseConfigFile()
@ -167,13 +309,11 @@ func configure() {
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
@ -181,13 +321,11 @@ func configure() {
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 {

View File

@ -26,13 +26,12 @@ type pingvinCoil struct {
// unit modbus data
type Pingvin struct {
Coils []*pingvinCoil
Registers []*pingvinRegister
Status *pingvinStatus
Coils []pingvinCoil
Registers []pingvinRegister
Status pingvinStatus
buslock *sync.Mutex
handler *modbus.RTUClientHandler
modbusclient modbus.Client
firstReadDone bool
Debug PingvinLogger
}
@ -68,15 +67,13 @@ type pingvinStatus struct {
HrcPct int `json:"hrc_pct"` // Heat recovery turn speed
TempSetting float32 `json:"temp_setting"` // Requested room temperature
FanPct int `json:"fan_pct"` // Circulation fan setting
FanPctIn int `json:"fan_pct_in"` // Intake fan setting
FanPctEx int `json:"fan_pct_ex"` // Exhaust fan setting
Measurements pingvinMeasurements `json:"measurements"` // Measurements
HrcEffIn int `json:"hrc_efficiency_in"` // Calculated HRC efficiency, intake
HrcEffEx int `json:"hrc_efficiency_ex"` // Calculated HRC efficiency, extract
OpMode string `json:"op_mode"` // Current operating mode, text representation
Uptime string `json:"uptime"` // Unit uptime
SystemTime string `json:"system_time"` // Time and date in unit
Coils []*pingvinCoil `json:"coils"`
Coils []pingvinCoil `json:"coils"`
}
type PingvinLogger struct {
@ -104,7 +101,7 @@ func (logger *PingvinLogger) Println(msg ...any) {
}
}
func newCoil(address string, symbol string, description string) *pingvinCoil {
func newCoil(address string, symbol string, description string) pingvinCoil {
addr, err := strconv.Atoi(address)
if err != nil {
log.Fatal("newCoil: Atoi: ", err)
@ -114,7 +111,7 @@ func newCoil(address string, symbol string, description string) *pingvinCoil {
promdesc := strings.ToLower(symbol)
zpadaddr := fmt.Sprintf("%02d", addr)
promdesc = strings.Replace(promdesc, "_", "_"+zpadaddr+"_", 1)
return &pingvinCoil{addr, symbol, false, description, reserved,
return pingvinCoil{addr, symbol, false, description, reserved,
prometheus.NewDesc(
prometheus.BuildFQName("", "pingvin", promdesc),
description,
@ -123,10 +120,10 @@ func newCoil(address string, symbol string, description string) *pingvinCoil {
),
}
}
return &pingvinCoil{addr, symbol, false, description, reserved, nil}
return pingvinCoil{addr, symbol, false, description, reserved, nil}
}
func newRegister(address, symbol, typ, multiplier, description string) *pingvinRegister {
func newRegister(address, symbol, typ, multiplier, description string) pingvinRegister {
addr, err := strconv.Atoi(address)
if err != nil {
log.Fatal("newRegister: Atoi(address): ", err)
@ -144,7 +141,7 @@ func newRegister(address, symbol, typ, multiplier, description string) *pingvinR
promdesc := strings.ToLower(symbol)
zpadaddr := fmt.Sprintf("%03d", addr)
promdesc = strings.Replace(promdesc, "_", "_"+zpadaddr+"_", 1)
return &pingvinRegister{
return pingvinRegister{
addr,
symbol,
0,
@ -161,7 +158,7 @@ func newRegister(address, symbol, typ, multiplier, description string) *pingvinR
),
}
}
return &pingvinRegister{addr, symbol, 0, "0000000000000000", typ, description, reserved, multipl, nil}
return pingvinRegister{addr, symbol, 0, "0000000000000000", typ, description, reserved, multipl, nil}
}
// read a CSV file containing data for coils or registers
@ -213,23 +210,11 @@ func (p *Pingvin) Quit() {
// Update all coil values
func (p *Pingvin) updateCoils() {
var results []byte
var err error
for retries := 1; retries <= 5; retries++ {
p.Debug.Println("Reading coils, attempt", retries)
p.buslock.Lock()
results, err = p.modbusclient.ReadCoils(0, uint16(len(p.Coils)))
results, err := p.modbusclient.ReadCoils(0, uint16(len(p.Coils)))
p.buslock.Unlock()
if len(results) > 0 {
break
} else if retries == 4 {
log.Println("ERROR: updateCoils: client.Readcoils: ", err)
return
}
if err != nil {
log.Printf("WARNING updateCoils: client.ReadCoils attempt %d: %s\n", retries, err)
}
time.Sleep(100 * time.Millisecond)
log.Fatal("updateCoils: client.ReadCoils: ", err)
}
// modbus.ReadCoils returns a byte array, with the first byte's bits representing coil values 0-7,
// second byte coils 8-15 etc.
@ -257,8 +242,8 @@ func (p *Pingvin) ReadRegister(addr uint16) (int, error) {
results, err := p.modbusclient.ReadHoldingRegisters(addr, 1)
p.buslock.Unlock()
if err != nil {
//log.Println("ERROR: ReadRegister:", err)
return p.Registers[addr].Value, err
log.Println("ERROR: ReadRegister:", err)
return 0, err
}
if p.Registers[addr].Type == "uint16" {
p.Registers[addr].Value = int(uint16(results[0]) << 8)
@ -285,7 +270,6 @@ func (p *Pingvin) WriteRegister(addr uint16, value uint16) (uint16, error) {
return 0, err
}
if val == int(value) {
log.Printf("Wrote register %d to value %d (%s: %s)", addr, p.Registers[addr].Value, p.Registers[addr].Symbol, p.Registers[addr].Description)
return value, nil
}
return 0, fmt.Errorf("Failed to write register")
@ -304,8 +288,8 @@ func (p *Pingvin) updateRegisters() {
if regs-k < 125 {
r = regs - k
}
var results []byte
for retries := 1; retries <= 5; retries++ {
results := []byte{}
for retries := 0; retries < 5; retries++ {
p.Debug.Println("Reading registers, attempt", retries, "k:", k)
p.buslock.Lock()
results, err = p.modbusclient.ReadHoldingRegisters(uint16(k), uint16(r))
@ -313,18 +297,11 @@ func (p *Pingvin) updateRegisters() {
if len(results) > 0 {
break
} else if retries == 4 {
log.Printf("ERROR: updateRegisters: max retries reached, giving up. client.ReadHoldingRegisters: %v", err)
log.Printf("ERROR: error occurred when reading registers %d - %d", k, k+r-1)
if !p.firstReadDone {
panic("FATAL: Error on initial read")
}
return
log.Fatal("updateRegisters: client.ReadHoldingRegisters: ", err)
} else if err != nil {
log.Printf("WARNING: updateRegisters: client.ReadHoldingRegisters attempt %d: %s", retries, err)
log.Println("WARNING: updateRegisters: client.ReadHoldingRegisters: ", err)
}
time.Sleep(200 * time.Millisecond)
}
p.firstReadDone = true
// The values represent 16 bit integers, but modbus works with bytes
// Each even byte of the returned []byte is the 8 MSBs of a new 16-bit
// value, so for each even byte in the reponse slice we bitshift the byte
@ -373,30 +350,22 @@ func (p *Pingvin) Update() {
}
// Read single coil
func (p *Pingvin) ReadCoil(n uint16) (err error) {
var results []byte
for retries := 1; retries <= 5; retries++ {
func (p *Pingvin) ReadCoil(n uint16) ([]byte, error) {
p.buslock.Lock()
results, err = p.modbusclient.ReadCoils(n, 1)
results, err := p.modbusclient.ReadCoils(n, 1)
p.buslock.Unlock()
if len(results) > 0 && err == nil {
break
} else if retries == 4 {
//log.Println("ERROR ReadCoil: client.ReadCoils: ", err)
return
} else if err != nil {
log.Printf("WARNING: ReadCoil: client.ReadCoils attempt %d: %s", retries, err)
}
time.Sleep(100 * time.Millisecond)
if err != nil {
log.Fatal("ReadCoil: client.ReadCoils: ", err)
return nil, err
}
p.Coils[n].Value = results[0] == 1
return
return results, nil
}
// Force a single coil
func (p *Pingvin) WriteCoil(n uint16, val bool) bool {
if val {
_ = p.checkMutexCoils(n) //, p.handler)
p.checkMutexCoils(n, p.handler)
}
var value uint16 = 0
if val {
@ -408,15 +377,14 @@ func (p *Pingvin) WriteCoil(n uint16, val bool) bool {
if err != nil {
log.Println("ERROR: WriteCoil: ", err)
}
if (val && results[0] != 255) && (!val && results[0] != 0) {
if (val && results[0] == 255) || (!val && results[0] == 0) {
log.Println("WriteCoil: wrote coil", n, "to value", val)
} else {
log.Println("ERROR: WriteCoil: failed to write coil")
return false
}
err = p.ReadCoil(n)
if err != nil {
log.Printf("ERROR WriteCoil: p.ReadCoil: %s", err)
}
log.Printf("Wrote coil %d to value %v (%s: %s)", n, p.Coils[n].Value, p.Coils[n].Symbol, p.Coils[n].Description)
p.ReadCoil(n)
return true
}
@ -472,7 +440,7 @@ func (p *Pingvin) WriteCoils(startaddr uint16, quantity uint16, vals []bool) err
// Some of the coils are mutually exclusive, and can only be 1 one at a time.
// Check if coil is one of them and force all of them to 0 if so
func (p *Pingvin) checkMutexCoils(addr uint16) error { //, handler *modbus.RTUClientHandler) error {
func (p *Pingvin) checkMutexCoils(addr uint16, handler *modbus.RTUClientHandler) error {
for _, mutexcoil := range mutexcoils {
if mutexcoil == addr {
for _, n := range mutexcoils {
@ -494,7 +462,6 @@ func (p *Pingvin) checkMutexCoils(addr uint16) error { //, handler *modbus.RTUCl
// 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
@ -505,8 +472,6 @@ func (p *Pingvin) populateStatus() {
}
p.Status.TempSetting = float32(p.Registers[135].Value) / float32(p.Registers[135].Multiplier)
p.Status.FanPct = p.Registers[774].Value / p.Registers[774].Multiplier
p.Status.FanPctIn = p.Registers[3].Value / p.Registers[3].Multiplier
p.Status.FanPctEx = p.Registers[4].Value / p.Registers[4].Multiplier
p.Status.Measurements.Roomtemp1 = float32(p.Registers[1].Value) / float32(p.Registers[1].Multiplier)
p.Status.Measurements.SupplyHeated = float32(p.Registers[8].Value) / float32(p.Registers[8].Multiplier)
p.Status.Measurements.SupplyHrc = float32(p.Registers[7].Value) / float32(p.Registers[7].Multiplier)

View File

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

View File

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