Compare commits

...

25 Commits

Author SHA1 Message Date
Jarno Rankinen 4045de3f15 Update README.md 2024-07-03 14:28:54 +03:00
Jarno Rankinen d5e87bd073 Update README.md 2024-07-03 14:21:31 +03:00
Jarno Rankinen 26c16e7c8e Update README.md 2024-07-03 14:21:01 +03:00
Jarno Rankinen 972398d8ea v0.2.0, update HA-related instructions in Readme 2024-04-07 12:44:51 +03:00
Jarno Rankinen 8c825580fa Placeholder IP_ADDRESS for daemon ip address in HA rest configuration 2024-04-07 12:24:19 +03:00
Jarno Rankinen 6431315462 gh-32 Update HA automation, helper and rest endpoint definitions
- Added automations to alter the behaviour of the Pingvin unit
2024-04-07 12:02:13 +03:00
Jarno Rankinen 11debcecc5 gh-32 Update HA dashboard definitions
- Added toggles for Summer Night Cooling and toggle
  for disabling HA changing fan speeds
- Removed supply air humidity, probably needs an optional
  extra sensor to work
2024-04-07 11:14:01 +03:00
Jarno Rankinen 2c00babf4d gh-32 Supply/Exhaust fan speeds to status endpoint 2024-04-07 11:00:17 +03:00
Jarno Rankinen cf22dcf6f9 docs: Mention more tested devices and the dialout group 2024-03-19 22:38:41 +02:00
Jarno Rankinen 86afcef6ec Update build pipeline for Go 1.22 2024-03-19 22:23:57 +02:00
Jarno Rankinen 25887e1111 v0.1.3 Module updates
- Go update to v1.22
- github.com/golang/protobuf v1.5.2 -> google.golang.org/protobuf v1.33.0
- github.com/gorilla/handlers v1.5.1 -> v1.5.2
- github.com/prometheus/client_golang v1.14.0 -> v1.19.0
- github.com/cespare/xxhash/v2 v2.1.2 -> v2.2.0
- github.com/felixge/httpsnoop v1.0.1 -> v1.0.4
- github.com/matttproud/golang_protobuf_extensions v1.0.1 -> v1.0.4
- github.com/prometheus/client_model v0.3.0 -> v0.6.0
- github.com/prometheus/common v0.37.0 -> v0.50.0
- github.com/prometheus/procfs v0.8.0 -> v0.13.0
- golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a -> v0.18.0
- golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
  -> v0.0.0-20231012003039-104605ab7028
2024-03-19 22:16:22 +02:00
Jarno Rankinen ef6627dae8 ci: Add 'fetch-depth: 0' to build checkout action, probably fixes changelog for releases 2024-03-08 00:29:39 +02:00
Jarno Rankinen d1f734dd32 v0.1.2
- Fix routing, remove the awkward symlinking system for index.html
- Automatic binary build with GH Actions
2024-03-08 00:20:02 +02:00
Jarno Rankinen 8278bc9445 Automatic build action, take 1 2024-03-07 23:03:33 +02:00
Jarno Rankinen 28a555fa2b Don't exit on max retries in updateRegisters, except on initial run 2024-03-07 22:54:24 +02:00
Jarno Rankinen ea8ca1a6df Don't exit on max retries in updateRegisters 2024-03-07 22:38:18 +02:00
Jarno Rankinen d9fbbfac1c Fix typos 2024-03-07 21:20:40 +02:00
Jarno Rankinen 5458c3ba86 v0.1.0 2024-01-21 11:39:36 +02:00
Jarno Rankinen 027d678a09 v0.0.29 Improved error handling
- Retry mechanic for coil read errors
- Utilize pointers, attempt to have more persistent state
- Improved error handling and slightly more verbose logging
2024-01-21 11:34:45 +02:00
Jarno Rankinen 32fa6d4321 Update README.md 2024-01-14 17:06:46 +02:00
Jarno Rankinen 0aeb54dbd8 Update README.md 2024-01-14 17:04:35 +02:00
Jarno Rankinen 1a4b22df02 Updated readme for v0.0.28 2023-10-09 21:39:12 +03:00
Jarno Rankinen 19e047b142 v0.0.28 Added --read-only flag & read_only: true|false config option. Closes gh-33 2023-10-09 21:33:53 +03:00
Jarno Rankinen efbfe72bba Disable Add to project action 2023-10-09 21:12:45 +03:00
Jarno Rankinen af4405550c v0.0.27 Added --disable-auth flag and disable_auth: true|false config options. Disables HTTP authentication. build script added to repo. Closes gh-31 2023-10-09 21:08:26 +03:00
18 changed files with 991 additions and 433 deletions

View File

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

30
.github/workflows/build.yaml vendored Normal file
View File

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

32
.goreleaser.yaml Normal file
View File

@ -0,0 +1,32 @@
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:"

View File

@ -23,10 +23,6 @@ has been used.
### Building
- clone or download the repo
- `static/html/index.html` is symlinked to `coils` and `registers`
for development purposes, the symlinks need to be dereferenced before
building the binary on filesystems that support symlinks
- Replace symlinks with copies of the files or use e.g. `tar -h`
- Build for the correct architecture, e.g. for Linux 32-bit ARM (Rpi Zero W 1):
```
cd /path/to/repo
@ -37,30 +33,34 @@ has been used.
- CLI flags:
```
-cert string
Path to SSL public key to use for HTTPS (default "~/.config/enervent-ctrl/certificate.pem")
Path to SSL public key to use for HTTPS (default "~/.config/enervent-ctrl/certificate.pem")
-debug
Enable debug logging
Enable debug logging
-disable-auth
Disable HTTP basic authentication (default true)
-enable-metrics
Enable the built-in Prometheus exporter (default true)
Enable the built-in Prometheus exporter (default true)
-httplog
Enable HTTP access logging
Enable HTTP access logging
-interval int
Set the interval of background updates (default 4)
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")
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
Path to log file. Default is empty string, log to stdout
-password string
Password for HTTP Basic Authentication (default "enervent")
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.
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")
Path to serial console for RS-485 connection. Defaults to /dev/ttyS0 (default "/dev/ttyS0")
-username string
Username for HTTP Basic Authentication (default "pingvin")
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 precedenence over the config file.
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
@ -110,10 +110,13 @@ WantedBy=default.target
### 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 only on a RPi 4B and Zero W 1 with the Zihatec RS-485 HAT
- You may need terminating resistors in your adapter, see documentation of your adapter.
- 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.
- No need to run the daemon as root, and it is not recommended
- **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
@ -140,6 +143,14 @@ should be enough length in the wires to move the lid with the switch connected o
- 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)
@ -148,3 +159,6 @@ 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>

15
build.sh Executable file
View File

@ -0,0 +1,15 @@
#!/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,27 +1,25 @@
module github.com/0ranki/enervent-ctrl
go 1.18
go 1.22
require (
github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433
github.com/0ranki/https-go v0.0.0-20230314073101-4eca22af948c
github.com/goburrow/modbus v0.1.0
github.com/gorilla/handlers v1.5.1
github.com/prometheus/client_golang v1.14.0
github.com/gorilla/handlers v1.5.2
github.com/prometheus/client_golang v1.19.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.1.2 // indirect
github.com/felixge/httpsnoop 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/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
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
)

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-20230314064508-ba9a558db433 h1:QT2IRJnhIdCSr26LJktnZnBpHdiLfTrUFzLSdP3h9Wo=
github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433/go.mod h1:r4Jb05+PuiVKHDYwSsSBuSz4LpOlC2DgOY4N58+K8Hk=
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=
@ -48,6 +48,8 @@ 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=
@ -62,6 +64,8 @@ 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=
@ -107,6 +111,8 @@ 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=
@ -119,6 +125,8 @@ 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=
@ -134,6 +142,8 @@ 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=
@ -152,11 +162,14 @@ 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=
@ -175,18 +188,24 @@ 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=
@ -194,6 +213,8 @@ 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=
@ -334,6 +355,8 @@ 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=
@ -389,9 +412,10 @@ 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-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
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=
@ -467,13 +491,14 @@ 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.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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=

185
handlers.go Normal file
View File

@ -0,0 +1,185 @@
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,103 +1,323 @@
automation:
## Max heating
- alias: Penguin Max Heating input
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
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
## 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
## Max cooling
- alias: Penguin max cooling sensor
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 cooling input
## 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: []
condition: [ ]
action:
- if:
- condition: state
@ -111,15 +331,15 @@ automation:
above: input_number.penguin_temperature_setting_helper
then:
- service: rest_command.penguin_max_cooling_on
data: {}
else: []
data: { }
else: [ ]
- if:
- condition: state
entity_id: input_boolean.penguin_max_cooling
state: "off"
then:
- service: rest_command.penguin_max_cooling_off
data: {}
data: { }
- if:
- condition: numeric_state
entity_id: sensor.penguin_room_temperature_1
@ -129,88 +349,220 @@ automation:
state: "on"
then:
- service: input_boolean.turn_off
data: {}
data: { }
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 circulation fan mode sensor
- alias: Penguin max cooling sensor v0.2.0
description: ""
trigger:
- platform: state
entity_id:
- binary_sensor.penguin_circulation_adaptive
condition: []
- binary_sensor.penguin_max_cooling
condition: [ ]
action:
- if:
- condition: state
entity_id: binary_sensor.penguin_circulation_adaptive
entity_id: binary_sensor.penguin_max_cooling
state: "on"
then:
- service: input_boolean.turn_on
data: {}
data: { }
target:
entity_id: input_boolean.penguin_circulation_fan_adaptive
entity_id: input_boolean.penguin_max_cooling
else:
- service: input_boolean.turn_off
data: {}
data: { }
target:
entity_id: input_boolean.penguin_circulation_fan_adaptive
entity_id: input_boolean.penguin_max_cooling
mode: single
## Target temperature setting automations
- alias: Penguin temperature down
- 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: []
condition: [ ]
action:
- service: rest_command.penguin_temperature_down
data: {}
- service: input_number.decrement
data: {}
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: {}
data: { }
target:
entity_id: sensor.penguin_temperature_setting
mode: single
- alias: Penguin Temperature setting updater
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: []
condition: [ ]
action:
- service: input_number.set_value
data:
@ -218,22 +570,3 @@ automation:
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,5 +1,6 @@
views:
- title: Penguin
- title: Pingvin
icon: mdi:penguin
cards:
- type: vertical-stack
cards:
@ -24,6 +25,11 @@ views:
- 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
@ -122,8 +128,6 @@ views:
name: Outside air 24h avg
- entity: sensor.penguin_supply_air_hrc
name: Intake after HRC
- entity: sensor.penguin_supply_air_humidity
name: Intake humidity
- entity: sensor.penguin_supply_air
name: Supply air
- entity: sensor.penguin_return_water
@ -141,5 +145,9 @@ views:
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,5 +1,6 @@
views:
- title: Penguin
- title: Pingvin
icon: mdi:penguin
cards:
- type: vertical-stack
cards:
@ -24,6 +25,11 @@ views:
- 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
@ -122,8 +128,6 @@ 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
@ -141,5 +145,9 @@ 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,7 +9,7 @@ input_boolean:
name: Penguin Overpressure
icon: mdi:fireplace
penguin_boost:
name: Penguin Overpressure
name: Penguin Boost
icon: mdi:fan-plus
penguin_max_cooling:
name: Penguin Max Cooling
@ -17,6 +17,12 @@ input_boolean:
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
@ -30,3 +36,4 @@ input_number:
min: 20
max: 30
unit_of_measurement: "°C"
step: 0.5

View File

@ -28,6 +28,14 @@ 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: "%"
@ -36,10 +44,6 @@ 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"
@ -99,6 +103,9 @@ rest:
- 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:
@ -125,13 +132,13 @@ rest_command:
username: pingvin
password: enervent
penguin_boost_on:
url: https://192.168.0.210:8888/api/v1/coils/10/1
url: https://IP_ADDRESS:8888/api/v1/coils/10/1
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_boost_off:
url: https://192.168.0.210:8888/api/v1/coils/10/0
url: https://IP_ADDRESS:8888/api/v1/coils/10/0
method: POST
verify_ssl: false
username: pingvin
@ -158,37 +165,37 @@ rest_command:
username: pingvin
password: enervent
penguin_max_cooling_on:
url: https://192.168.0.210:8888/api/v1/coils/7/1
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://192.168.0.210:8888/api/v1/coils/7/0
url: https://IP_ADDRESS:8888/api/v1/coils/7/0
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_temperature_up:
url: https://192.168.0.210:8888/api/v1/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://192.168.0.210:8888/api/v1/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://192.168.0.210:8888/api/v1/coils/54/1
url: https://IP_ADDRESS:8888/api/v1/coils/54/1
method: POST
verify_ssl: false
username: pingvin
password: enervent
penguin_heater_disabled:
url: https://192.168.0.210:8888/api/v1/coils/54/0
url: https://IP_ADDRESS:8888/api/v1/coils/54/0
method: POST
verify_ssl: false
username: pingvin

188
main.go
View File

@ -2,17 +2,12 @@ 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"
@ -30,7 +25,7 @@ import (
var static embed.FS
var (
version = "0.0.26"
version = "0.2.0"
device pingvin.Pingvin
config Conf
usernamehash [32]byte
@ -42,6 +37,7 @@ 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"`
@ -49,149 +45,7 @@ type Conf struct {
LogFile string `yaml:"log_file"`
LogAccess bool `yaml:"log_access"`
Debug bool `yaml:"debug"`
}
// HTTP Basic Authentication middleware for http.HandlerFunc
func authHandlerFunc(next http.HandlerFunc) http.HandlerFunc {
// Based on https://www.alexedwards.net/blog/basic-authentication-in-go
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok {
userHash := sha256.Sum256([]byte(user))
passHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1)
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
if len(user) == 0 {
user = "-"
}
log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// HTTP Basic Authentication middleware for http.Handler
func authHandler(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok {
userHash := sha256.Sum256([]byte(user))
passHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1)
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
if len(user) == 0 {
user = "-"
}
log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// \/api/v1/coils endpoint
func coils(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/coils/"), "/")
if len(pathparams[0]) == 0 {
json.NewEncoder(w).Encode(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
}
ReadOnly bool `yaml:"read_only"`
}
// Start the HTTP server
@ -210,6 +64,8 @@ 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)
@ -259,12 +115,12 @@ func parseConfigFile() {
}
}
conffile := confpath + "/configuration.yaml"
yamldata, err := ioutil.ReadFile(conffile)
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 = ioutil.ReadFile(conffile); err != nil {
if yamldata, err = os.ReadFile(conffile); err != nil {
log.Fatal("Error parsing configuration:", err)
}
}
@ -277,17 +133,19 @@ func parseConfigFile() {
// Write the default configuration to $HOME/.config/enervent-ctrl/configuration.yaml
func initDefaultConfig(confpath string) {
config = Conf{
"/dev/ttyS0",
8888,
confpath + "/certificate.pem",
confpath + "/privatekey.pem",
"device",
"enervent",
4,
false,
"",
false,
false,
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)
@ -299,7 +157,7 @@ func initDefaultConfig(confpath string) {
}
}
// Read configuration. CLI flags take presedence over configuration file
// Read configuration. CLI flags take precedence over configuration file
func configure() {
log.Println("Reading configuration")
parseConfigFile()
@ -309,11 +167,13 @@ 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
@ -321,11 +181,13 @@ 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,14 @@ type pingvinCoil struct {
// unit modbus data
type Pingvin struct {
Coils []pingvinCoil
Registers []pingvinRegister
Status pingvinStatus
buslock *sync.Mutex
handler *modbus.RTUClientHandler
modbusclient modbus.Client
Debug PingvinLogger
Coils []*pingvinCoil
Registers []*pingvinRegister
Status *pingvinStatus
buslock *sync.Mutex
handler *modbus.RTUClientHandler
modbusclient modbus.Client
firstReadDone bool
Debug PingvinLogger
}
// single register data
@ -67,13 +68,15 @@ 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 {
@ -101,7 +104,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)
@ -111,7 +114,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,
@ -120,10 +123,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)
@ -141,7 +144,7 @@ func newRegister(address, symbol, typ, multiplier, description string) pingvinRe
promdesc := strings.ToLower(symbol)
zpadaddr := fmt.Sprintf("%03d", addr)
promdesc = strings.Replace(promdesc, "_", "_"+zpadaddr+"_", 1)
return pingvinRegister{
return &pingvinRegister{
addr,
symbol,
0,
@ -158,7 +161,7 @@ func newRegister(address, symbol, typ, multiplier, description string) pingvinRe
),
}
}
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
@ -210,11 +213,23 @@ func (p *Pingvin) Quit() {
// Update all coil values
func (p *Pingvin) updateCoils() {
p.buslock.Lock()
results, err := p.modbusclient.ReadCoils(0, uint16(len(p.Coils)))
p.buslock.Unlock()
if err != nil {
log.Fatal("updateCoils: client.ReadCoils: ", err)
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.
@ -242,8 +257,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 0, err
//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)
@ -270,6 +285,7 @@ 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")
@ -288,8 +304,8 @@ func (p *Pingvin) updateRegisters() {
if regs-k < 125 {
r = regs - k
}
results := []byte{}
for retries := 0; retries < 5; retries++ {
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))
@ -297,11 +313,18 @@ func (p *Pingvin) updateRegisters() {
if len(results) > 0 {
break
} else if retries == 4 {
log.Fatal("updateRegisters: client.ReadHoldingRegisters: ", err)
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.Println("WARNING: updateRegisters: client.ReadHoldingRegisters: ", err)
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
@ -350,22 +373,30 @@ func (p *Pingvin) Update() {
}
// Read single coil
func (p *Pingvin) ReadCoil(n uint16) ([]byte, error) {
p.buslock.Lock()
results, err := p.modbusclient.ReadCoils(n, 1)
p.buslock.Unlock()
if err != nil {
log.Fatal("ReadCoil: client.ReadCoils: ", err)
return nil, err
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 results, nil
return
}
// 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 {
@ -377,14 +408,15 @@ 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) {
log.Println("WriteCoil: wrote coil", n, "to value", val)
} else {
if (val && results[0] != 255) && (!val && results[0] != 0) {
log.Println("ERROR: WriteCoil: failed to write coil")
return false
}
p.ReadCoil(n)
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
}
@ -440,7 +472,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, handler *modbus.RTUClientHandler) error {
func (p *Pingvin) checkMutexCoils(addr uint16) error { //, handler *modbus.RTUClientHandler) error {
for _, mutexcoil := range mutexcoils {
if mutexcoil == addr {
for _, n := range mutexcoils {
@ -462,6 +494,7 @@ func (p *Pingvin) checkMutexCoils(addr uint16, handler *modbus.RTUClientHandler)
// 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
@ -472,6 +505,8 @@ 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

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

View File

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