This commit is contained in:
Daniil Gentili 2023-05-01 18:33:15 +02:00
parent 08be78f907
commit 9bd3f35cc7
Signed by: danog
GPG key ID: 8C1BE3B34B230CA7
8 changed files with 403 additions and 150 deletions

259
.golangci.yml Normal file
View file

@ -0,0 +1,259 @@
# options for analysis running
run:
# timeout for analysis, e.g. 30s, 5m, default is 1m
timeout: 5m
# output configuration options
output:
# sorts results by: filepath, line and column
sort-results: true
# all available settings of specific linters
linters-settings:
cyclop:
# the maximal code complexity to report
max-complexity: 30
# the maximal average package complexity. If it's higher than 0.0 (float) the check is enabled (default 0.0)
package-average: 0.0
# should ignore tests (default false)
skip-tests: true
dogsled:
# checks assignments with too many blank identifiers; default is 2
max-blank-identifiers: 2
dupl:
# tokens count to trigger issue, 150 by default
threshold: 100
errcheck:
# report about not checking of errors in type assertions: `a := b.(MyStruct)`;
# default is false: such cases aren't reported by default.
check-type-assertions: true
# report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`;
# default is false: such cases aren't reported by default.
check-blank: true
errorlint:
# Check whether fmt.Errorf uses the %w verb for formatting errors. See the readme for caveats
errorf: true
# Check for plain type assertions and type switches
asserts: true
# Check for plain error comparisons
comparison: true
exhaustive:
# indicates that switch statements are to be considered exhaustive if a
# 'default' case is present, even if all enum members aren't listed in the
# switch
default-signifies-exhaustive: true
exhaustivestruct:
# Struct Patterns is list of expressions to match struct packages and names
# The struct packages have the form example.com/package.ExampleStruct
# The matching patterns can use matching syntax from https://pkg.go.dev/path#Match
# If this list is empty, all structs are tested.
struct-patterns:
gocognit:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
min-complexity: 30
nestif:
# minimal complexity of if statements to report, 5 by default
min-complexity: 4
goconst:
# minimal length of string constant, 3 by default
min-len: 3
# minimal occurrences count to trigger, 3 by default
min-occurrences: 3
gocritic:
# Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks.
# Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags".
enabled-tags:
- diagnostic
- style
- performance
disabled-checks:
- paramTypeCombine
- commentedOutCode
- ifElseChain
# Settings passed to gocritic.
# The settings key is the name of a supported gocritic checker.
# The list of supported checkers can be find in https://go-critic.github.io/overview.
settings:
unnamedResult:
# whether to check exported functions
checkExported: true
gocyclo:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
min-complexity: 30
godot:
# comments to be checked: `declarations`, `toplevel`, or `all`
scope: declarations
# check that each sentence starts with a capital letter
capital: true
gofmt:
# simplify code: gofmt with `-s` option, true by default
simplify: true
gofumpt:
# Choose whether or not to use the extra rules that are disabled
# by default
extra-rules: false
golint:
# minimal confidence for issues, default is 0.8
min-confidence: 0.8
gosimple:
# Select the Go version to target. The default is '1.13'.
go: "1.20"
# https://staticcheck.io/docs/options#checks
checks: ["all"]
govet:
# report about shadowed variables
check-shadowing: true
enable-all: true
maligned:
# print struct with more effective memory layout or not, false by default
suggest-new: true
misspell:
# Correct spellings using locale preferences for US or UK.
# Default is to use a neutral variety of English.
# Setting locale to US will correct the British spelling of 'colour' to 'color'.
locale: US
prealloc:
# XXX: we don't recommend using this linter before doing performance profiling.
# For most programs usage of prealloc will be a premature optimization.
# Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them.
# True by default.
simple: true
range-loops: true
for-loops: false
nolintlint:
# Enable to ensure that nolint directives are all used. Default is true.
allow-unused: true
# Disable to ensure that nolint directives don't have a leading space. Default is true.
allow-leading-space: true
# Exclude following linters from requiring an explanation. Default is [].
allow-no-explanation: []
# Enable to require an explanation of nonzero length after each nolint directive. Default is false.
require-explanation: true
# Enable to require nolint directives to mention the specific linter being suppressed. Default is false.
require-specific: true
staticcheck:
# Select the Go version to target. The default is '1.13'.
go: "1.20"
# https://staticcheck.io/docs/options#checks
checks: ["all"]
stylecheck:
# Select the Go version to target. The default is '1.13'.
go: "1.20"
# https://staticcheck.io/docs/options#checks
checks: ["all"]
unparam:
# Inspect exported functions, default is false. Set to true if no external program/library imports your code.
# XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
# if it's called for subdir of a project it can't find external interfaces. All text editor integrations
# with golangci-lint call it on a directory with the changed file.
check-exported: false
unused:
# Select the Go version to target. The default is '1.13'.
go: "1.20"
whitespace:
multi-if: false # Enforces newlines (or comments) after every multi-line if statement
multi-func: false # Enforces newlines (or comments) after every multi-line function signature
wrapcheck:
# An array of strings that specify substrings of signatures to ignore.
# If this set, it will override the default set of ignored signatures.
# See https://github.com/tomarrell/wrapcheck#configuration for more information.
ignoreSigs:
- .Errorf(
- errors.New(
- errors.Unwrap(
- .Wrap(
- .Wrapf(
- .WithMessage(
linters:
disable:
# Don't check for newlines before return
- nlreturn
# Don't check line length
- lll
- funlen
- exhaustivestruct
# Don't check nested ifs
- nestif
# Don't check var length
- varnamelen
# Don't check excessive blank identifiers
- dogsled
# Don't check json struct field tags
- tagliatelle
# Absolutely useless whitespace linting
- wsl
# Cognitive complexity ;)
- gocognit
# Deprecated
- golint
- interfacer
- scopelint
# Don't care about this (for now)
- exhaustruct
- wrapcheck
- nonamedreturns
- gomnd
enable-all: true
fast: false
issues:
# Fix found issues (if it's supported by the linter)
fix: true
severity:
# Default value is empty string.
# Set the default severity for issues. If severity rules are defined and the issues
# do not match or no severity is provided to the rule this will be the default
# severity applied. Severities should match the supported severity names of the
# selected out format.
# - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity
# - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity
# - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
default-severity: error
# The default value is false.
# If set to true severity-rules regular expressions become case sensitive.
case-sensitive: false

View file

@ -1,14 +1,23 @@
FROM golang:1.19 FROM golangci/golangci-lint:latest-alpine
FROM golang:alpine
RUN mkdir -p /app COPY --from=0 /usr/bin/golangci-lint /usr/bin/golangci-lint
RUN apk add --no-cache gcc libc-dev
WORKDIR /app WORKDIR /app
ADD . /app COPY src /app/
RUN rm -rf go.mod go.sum config.json cache.json; \ RUN golangci-lint run
touch config.json; \ RUN go test ./src/...
go mod init ecodash; \
go mod tidy RUN CGO_ENABLED=1 go build -o app src/main.go
RUN go build -o app .
FROM alpine:latest
WORKDIR /app
COPY --from=1 /app/app .
COPY ./templates /app/templates
RUN touch config.json
CMD ["./app"] CMD ["./app"]

4
go.mod
View file

@ -1,6 +1,6 @@
module ecodash module git.massivebox.net/ecodash/ecodash
go 1.17 go 1.20
require ( require (
github.com/gofiber/fiber/v2 v2.37.1 github.com/gofiber/fiber/v2 v2.37.1

View file

@ -1,8 +1,10 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"log" "log"
"math" "math"
@ -13,8 +15,8 @@ import (
) )
type HistoryResult []struct { type HistoryResult []struct {
State string `json:"state"`
LastUpdated time.Time `json:"last_updated"` LastUpdated time.Time `json:"last_updated"`
State string `json:"state"`
} }
func dayStart(t time.Time) time.Time { func dayStart(t time.Time) time.Time {
@ -22,13 +24,20 @@ func dayStart(t time.Time) time.Time {
return t.Add(-(time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second)) return t.Add(-(time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second))
} }
func (config Config) queryHistory(entityID string, startTime, endTime time.Time) (HistoryResult, error) { var errNon200 = errors.New("got a non-200 status code. Check the correctness of sensors IDs")
req, err := http.NewRequest("GET", config.HomeAssistant.BaseURL+ func (config *Config) queryHistory(entityID string, startTime, endTime time.Time) (HistoryResult, error) {
"/api/history/period/"+url.QueryEscape(startTime.Format(time.RFC3339))+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
"?filter_entity_id="+entityID+ req, err := http.NewRequestWithContext(
"&end_time="+url.QueryEscape(endTime.Format(time.RFC3339)), /*+ ctx,
"&minimal_response",*/nil) http.MethodGet,
config.HomeAssistant.BaseURL+
"/api/history/period/"+url.QueryEscape(startTime.Format(time.RFC3339))+
"?filter_entity_id="+entityID+
"&end_time="+url.QueryEscape(endTime.Format(time.RFC3339)),
nil,
)
cancel()
if err != nil { if err != nil {
return HistoryResult{}, err return HistoryResult{}, err
} }
@ -42,8 +51,8 @@ func (config Config) queryHistory(entityID string, startTime, endTime time.Time)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != http.StatusOK {
return HistoryResult{}, errors.New("got a non-200 status code. Check the correctness of sensors IDs -" + resp.Status) return HistoryResult{}, fmt.Errorf("%w - %s", errNon200, resp.Status)
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@ -62,30 +71,26 @@ func (config Config) queryHistory(entityID string, startTime, endTime time.Time)
} }
return result[0], nil return result[0], nil
} }
// t can be any time during the desired day. // t can be any time during the desired day.
func (config Config) getDayHistory(entityID string, t time.Time) (HistoryResult, error) { func (config *Config) getDayHistory(entityID string, t time.Time) (HistoryResult, error) {
hours, minutes, seconds := t.Clock() hours, minutes, seconds := t.Clock()
endTime := t.Add(time.Duration(23-hours)*time.Hour + time.Duration(59-minutes)*time.Minute + time.Duration(59-seconds)*time.Second) endTime := t.Add(time.Duration(23-hours)*time.Hour + time.Duration(59-minutes)*time.Minute + time.Duration(59-seconds)*time.Second)
return config.queryHistory(entityID, dayStart(t), endTime) return config.queryHistory(entityID, dayStart(t), endTime)
} }
type DayData struct { type DayData struct {
DayNumber int
DayTime time.Time DayTime time.Time
DayNumber int
Measurements int Measurements int
Value float32 Value float32
High float32 High float32
Low float32 Low float32
} }
func (config Config) historyAverageAndConvertToGreen(entityID string, t time.Time) (DayData, error) { func (config *Config) historyAverageAndConvertToGreen(entityID string, t time.Time) (DayData, error) {
history, err := config.getDayHistory(entityID, t) history, err := config.getDayHistory(entityID, t)
if err != nil { if err != nil {
return DayData{}, err return DayData{}, err
@ -94,7 +99,6 @@ func (config Config) historyAverageAndConvertToGreen(entityID string, t time.Tim
var day DayData var day DayData
for _, historyChange := range history { for _, historyChange := range history {
val, err := strconv.ParseFloat(historyChange.State, 32) val, err := strconv.ParseFloat(historyChange.State, 32)
if err != nil { if err != nil {
continue continue
@ -102,16 +106,13 @@ func (config Config) historyAverageAndConvertToGreen(entityID string, t time.Tim
day.Value += float32(val) day.Value += float32(val)
day.Measurements++ day.Measurements++
} }
day.Value = 100 - (day.Value / float32(day.Measurements)) day.Value = 100 - (day.Value / float32(day.Measurements))
return day, nil return day, nil
} }
func (config Config) historyBulkAverageAndConvertToGreen(entityID string, startTime, endTime time.Time) ([]DayData, error) { func (config *Config) historyBulkAverageAndConvertToGreen(entityID string, startTime, endTime time.Time) ([]DayData, error) {
history, err := config.queryHistory(entityID, startTime, endTime) history, err := config.queryHistory(entityID, startTime, endTime)
if err != nil { if err != nil {
return nil, err return nil, err
@ -154,11 +155,9 @@ func (config Config) historyBulkAverageAndConvertToGreen(entityID string, startT
days = fillMissing(days, startTime, endTime) days = fillMissing(days, startTime, endTime)
return days, nil return days, nil
} }
func (config Config) historyDelta(entityID string, t time.Time) (DayData, error) { func (config *Config) historyDelta(entityID string, t time.Time) (DayData, error) {
history, err := config.getDayHistory(entityID, t) history, err := config.getDayHistory(entityID, t)
if err != nil { if err != nil {
return DayData{}, err return DayData{}, err
@ -167,7 +166,6 @@ func (config Config) historyDelta(entityID string, t time.Time) (DayData, error)
var day DayData var day DayData
for _, historyChange := range history { for _, historyChange := range history {
val, err := strconv.ParseFloat(historyChange.State, 32) val, err := strconv.ParseFloat(historyChange.State, 32)
if err != nil { if err != nil {
continue continue
@ -180,16 +178,13 @@ func (config Config) historyDelta(entityID string, t time.Time) (DayData, error)
if value < day.Low || day.Low == 0 { if value < day.Low || day.Low == 0 {
day.Low = value day.Low = value
} }
} }
day.Value = day.High - day.Low day.Value = day.High - day.Low
return day, nil return day, nil
} }
func (config Config) historyBulkDelta(entityID string, startTime, endTime time.Time) ([]DayData, error) { func (config *Config) historyBulkDelta(entityID string, startTime, endTime time.Time) ([]DayData, error) {
history, err := config.queryHistory(entityID, startTime, endTime) history, err := config.queryHistory(entityID, startTime, endTime)
if err != nil { if err != nil {
return nil, err return nil, err
@ -198,33 +193,34 @@ func (config Config) historyBulkDelta(entityID string, startTime, endTime time.T
var days []DayData var days []DayData
for _, historyChange := range history { for _, historyChange := range history {
if historyChange.State != "off" { if historyChange.State == "off" {
val, err := strconv.ParseFloat(historyChange.State, 32) continue
if err != nil { }
continue val, err := strconv.ParseFloat(historyChange.State, 32)
} if err != nil {
value := float32(val) continue
var found bool }
dayNo := dayStart(historyChange.LastUpdated.Local()).Day() value := float32(val)
for key, day := range days { var found bool
if dayNo == day.DayNumber { dayNo := dayStart(historyChange.LastUpdated.Local()).Day()
found = true for key, day := range days {
if value > day.High { if dayNo == day.DayNumber {
day.High = value found = true
} if value > day.High {
if value < day.Low || day.Low == 0 { day.High = value
day.Low = value
}
days[key] = day
} }
if value < day.Low || day.Low == 0 {
day.Low = value
}
days[key] = day
} }
if !found { }
days = append(days, DayData{ if !found {
DayNumber: dayNo, days = append(days, DayData{
DayTime: dayStart(historyChange.LastUpdated.Local()), DayNumber: dayNo,
Value: value, DayTime: dayStart(historyChange.LastUpdated.Local()),
}) Value: value,
} })
} }
} }
@ -236,11 +232,9 @@ func (config Config) historyBulkDelta(entityID string, startTime, endTime time.T
days = fillMissing(days, startTime, endTime) days = fillMissing(days, startTime, endTime)
return days, nil return days, nil
} }
func fillMissing(days []DayData, startTime, endTime time.Time) []DayData { func fillMissing(days []DayData, startTime, endTime time.Time) []DayData {
var ( var (
previousDay time.Time previousDay time.Time
defaultDay time.Time defaultDay time.Time
@ -252,9 +246,7 @@ func fillMissing(days []DayData, startTime, endTime time.Time) []DayData {
expectedDaysDiff := int(math.Trunc(endTime.Sub(startTime).Hours()/24) + 1) expectedDaysDiff := int(math.Trunc(endTime.Sub(startTime).Hours()/24) + 1)
for key, day := range days { for key, day := range days {
if key != 0 { if key != 0 {
if day.DayTime.Day() != previousDay.Add(24*time.Hour).Day() { if day.DayTime.Day() != previousDay.Add(24*time.Hour).Day() {
daysDiff := math.Trunc(day.DayTime.Sub(previousDay).Hours() / 24) daysDiff := math.Trunc(day.DayTime.Sub(previousDay).Hours() / 24)
for i := 1; float64(i) < daysDiff; i++ { for i := 1; float64(i) < daysDiff; i++ {
@ -266,13 +258,11 @@ func fillMissing(days []DayData, startTime, endTime time.Time) []DayData {
}) })
} }
} }
} }
ret = append(ret, day) ret = append(ret, day)
previousValue = day.Value previousValue = day.Value
previousDay = day.DayTime previousDay = day.DayTime
} }
// note that here previousDay is the last logged day // note that here previousDay is the last logged day
@ -313,5 +303,4 @@ func fillMissing(days []DayData, startTime, endTime time.Time) []DayData {
} }
return ret return ret
} }

View file

@ -6,12 +6,14 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/gofiber/fiber/v2"
_ "github.com/mattn/go-sqlite3"
"os" "os"
"reflect"
"regexp" "regexp"
"strings" "strings"
"time" "time"
"github.com/gofiber/fiber/v2"
_ "github.com/mattn/go-sqlite3"
) )
type Config struct { type Config struct {
@ -23,9 +25,9 @@ type Config struct {
} }
type HomeAssistant struct { type HomeAssistant struct {
InstallationDate time.Time `json:"installation_date"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
ApiKey string `json:"api_key"` ApiKey string `json:"api_key"`
InstallationDate time.Time `json:"installation_date"`
} }
type Sensors struct { type Sensors struct {
PolledSmartEnergySummation string `json:"polled_smart_energy_summation"` PolledSmartEnergySummation string `json:"polled_smart_energy_summation"`
@ -42,31 +44,28 @@ type Dashboard struct {
HeaderLinks []Link `json:"header_links"` HeaderLinks []Link `json:"header_links"`
} }
func formatURL(url string) (string, error) { var errBadHAFormat = errors.New("HomeAssistant base URL is badly formatted")
func formatURL(url string) (string, error) {
// the URL we want is: protocol://hostname[:port] without a final / // the URL we want is: protocol://hostname[:port] without a final /
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
url = "http://" + url url = "http://" + url
} }
if strings.HasSuffix(url, "/") { url = strings.TrimSuffix(url, "/")
url = url[0 : len(url)-1]
}
test := regexp.MustCompile(`(?m)https?:\/\/[^/]*`).ReplaceAllString(url, "") test := regexp.MustCompile(`(?m)https?:\/\/[^/]*`).ReplaceAllString(url, "")
if test != "" { if test != "" {
return "", errors.New("HomeAssistant base URL is badly formatted") return "", errBadHAFormat
} }
return url, nil return url, nil
} }
func loadConfig() (config Config, err error, isFirstRun bool) { func loadConfig() (config *Config, isFirstRun bool, err error) {
db, err := sql.Open("sqlite3", "./database.db") db, err := sql.Open("sqlite3", "./database.db")
if err != nil { if err != nil {
return Config{}, err, false return &Config{}, false, err
} }
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS "cache" ( _, err = db.Exec(`CREATE TABLE IF NOT EXISTS "cache" (
@ -76,10 +75,10 @@ func loadConfig() (config Config, err error, isFirstRun bool) {
PRIMARY KEY("time") PRIMARY KEY("time")
);`) );`)
if err != nil { if err != nil {
return Config{}, err, false return &Config{}, false, err
} }
var defaultConfig = Config{} defaultConfig := &Config{}
defaultConfig.Dashboard.Theme = "default" defaultConfig.Dashboard.Theme = "default"
defaultConfig.Dashboard.Name = "EcoDash" defaultConfig.Dashboard.Name = "EcoDash"
defaultConfig.Dashboard.HeaderLinks = append(defaultConfig.Dashboard.HeaderLinks, Link{ defaultConfig.Dashboard.HeaderLinks = append(defaultConfig.Dashboard.HeaderLinks, Link{
@ -97,35 +96,41 @@ func loadConfig() (config Config, err error, isFirstRun bool) {
if err != nil { if err != nil {
// if the data file doesn't exist, we consider it a first run // if the data file doesn't exist, we consider it a first run
if os.IsNotExist(err) { if os.IsNotExist(err) {
return defaultConfig, nil, true return defaultConfig, true, nil
} }
return Config{}, err, false return &Config{}, false, err
} }
// if the data file is empty, we consider it as a first run // if the data file is empty, we consider it as a first run
if string(data) == "" { if len(data) == 0 {
return defaultConfig, nil, true return defaultConfig, true, nil
} }
var conf Config conf := &Config{}
err = json.Unmarshal(data, &conf) err = json.Unmarshal(data, &conf)
if err != nil { if err != nil {
return Config{}, err, false return &Config{}, false, err
} }
conf.db = db conf.db = db
return conf, nil, false return conf, false, nil
} }
// just a little utility function to SHA256 strings (for hashing passwords) // just a little utility function to SHA256 strings (for hashing passwords).
func hash(toHash string) string { func hash(toHash string) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(toHash))) return fmt.Sprintf("%x", sha256.Sum256([]byte(toHash)))
} }
func (config Config) isAuthorized(c *fiber.Ctx) bool { func (config *Config) isAuthorized(c *fiber.Ctx) bool {
if config.Administrator.PasswordHash == "" { if config.Administrator.PasswordHash == "" {
return true return true
} }
return c.Cookies("admin_username") == config.Administrator.Username && c.Cookies("admin_password_hash") == config.Administrator.PasswordHash return c.Cookies("admin_username") == config.Administrator.Username && c.Cookies("admin_password_hash") == config.Administrator.PasswordHash
} }
func (config *Config) equals(new *Config) bool {
return reflect.DeepEqual(new.HomeAssistant, config.HomeAssistant) &&
reflect.DeepEqual(new.Sensors, config.Sensors) &&
reflect.DeepEqual(new.Administrator, config.Administrator) &&
reflect.DeepEqual(new.Dashboard, config.Dashboard)
}

View file

@ -2,7 +2,7 @@ package main
import ( import (
"errors" "errors"
"fmt" "log"
"time" "time"
) )
@ -13,8 +13,7 @@ type HistoryEntry struct {
} }
type History []HistoryEntry type History []HistoryEntry
func (config Config) updateHistory() { func (config *Config) updateHistory() {
greenEnergyPercentage, err := config.historyAverageAndConvertToGreen(config.Sensors.FossilPercentage, time.Now()) greenEnergyPercentage, err := config.historyAverageAndConvertToGreen(config.Sensors.FossilPercentage, time.Now())
if err != nil { if err != nil {
return return
@ -24,31 +23,34 @@ func (config Config) updateHistory() {
return return
} }
config.db.Exec("INSERT OR REPLACE INTO cache(time,green_energy_percentage,energy_consumption) VALUES (?,?,?);", dayStart(time.Now()).Unix(), greenEnergyPercentage.Value, historyPolledSmartEnergySummation.Value) _, err = config.db.Exec("INSERT OR REPLACE INTO cache(time,green_energy_percentage,energy_consumption) VALUES (?,?,?);", dayStart(time.Now()).Unix(), greenEnergyPercentage.Value, historyPolledSmartEnergySummation.Value)
if err != nil {
log.Println("Error inserting into cache", err.Error())
}
cached, err := config.readHistory() cached, err := config.readHistory()
if err != nil { if err != nil {
return return
} }
if len(cached) != 8 && time.Now().Sub(config.HomeAssistant.InstallationDate) > 8*time.Hour*24 { if len(cached) != 8 && time.Since(config.HomeAssistant.InstallationDate) > 8*time.Hour*24 {
err := config.refreshCacheFromPast(time.Now().Add(-8 * time.Hour * 24)) err := config.refreshCacheFromPast(time.Now().Add(-8 * time.Hour * 24))
if err != nil { if err != nil {
fmt.Println("Error refreshing cache", err.Error()) log.Println("Error refreshing cache", err.Error())
return return
} }
} }
} }
func (config Config) refreshCacheFromInstall() error { func (config *Config) refreshCacheFromInstall() error {
return config.refreshCacheFromPast(config.HomeAssistant.InstallationDate) return config.refreshCacheFromPast(config.HomeAssistant.InstallationDate)
} }
func (config Config) refreshCacheFromPast(pastTime time.Time) error { var errNoInstallDate = errors.New("installation date not set")
func (config *Config) refreshCacheFromPast(pastTime time.Time) error {
// in order to avoid querying and storing each day's data from 0001-01-01 in future versions // in order to avoid querying and storing each day's data from 0001-01-01 in future versions
if config.HomeAssistant.InstallationDate.IsZero() { if config.HomeAssistant.InstallationDate.IsZero() {
return errors.New("installation date not set") return errNoInstallDate
} }
greenEnergyPercentage, err := config.historyBulkAverageAndConvertToGreen(config.Sensors.FossilPercentage, pastTime, time.Now()) greenEnergyPercentage, err := config.historyBulkAverageAndConvertToGreen(config.Sensors.FossilPercentage, pastTime, time.Now())
@ -61,7 +63,6 @@ func (config Config) refreshCacheFromPast(pastTime time.Time) error {
} }
for key, day := range greenEnergyPercentage { for key, day := range greenEnergyPercentage {
var action2 string var action2 string
if greenEnergyPercentage[key].Value != 0 && historyPolledSmartEnergySummation[key].Value != 0 { if greenEnergyPercentage[key].Value != 0 && historyPolledSmartEnergySummation[key].Value != 0 {
action2 = "REPLACE" action2 = "REPLACE"
@ -77,15 +78,12 @@ func (config Config) refreshCacheFromPast(pastTime time.Time) error {
if err != nil { if err != nil {
return err return err
} }
} }
return nil return nil
} }
func (config Config) readHistory() (History, error) { func (config *Config) readHistory() (History, error) {
start := dayStart(time.Now()).AddDate(0, 0, -8) start := dayStart(time.Now()).AddDate(0, 0, -8)
rows, err := config.db.Query("SELECT time, green_energy_percentage, energy_consumption FROM cache WHERE time > ?", start.Unix()) rows, err := config.db.Query("SELECT time, green_energy_percentage, energy_consumption FROM cache WHERE time > ?", start.Unix())
@ -102,9 +100,11 @@ func (config Config) readHistory() (History, error) {
polledSmartEnergyConsumption float32 polledSmartEnergyConsumption float32
) )
err = rows.Scan(&date, &greenEnergyPercentage, &polledSmartEnergyConsumption) err = rows.Scan(&date, &greenEnergyPercentage, &polledSmartEnergyConsumption)
if err != nil {
return nil, err
}
ret = append(ret, HistoryEntry{date, greenEnergyPercentage, polledSmartEnergyConsumption}) ret = append(ret, HistoryEntry{date, greenEnergyPercentage, polledSmartEnergyConsumption})
} }
return ret, nil return ret, nil
} }

View file

@ -4,13 +4,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/gofiber/fiber/v2"
"html/template" "html/template"
"math" "math"
"os" "os"
"reflect"
"strconv" "strconv"
"time" "time"
"github.com/gofiber/fiber/v2"
) )
type Link struct { type Link struct {
@ -27,7 +27,7 @@ type Warning struct {
IsSuccess bool IsSuccess bool
} }
func (config Config) getTemplateDefaults() fiber.Map { func (config *Config) getTemplateDefaults() fiber.Map {
return fiber.Map{ return fiber.Map{
"DashboardName": config.Dashboard.Name, "DashboardName": config.Dashboard.Name,
"HeaderLinks": config.Dashboard.HeaderLinks, "HeaderLinks": config.Dashboard.HeaderLinks,
@ -35,16 +35,14 @@ func (config Config) getTemplateDefaults() fiber.Map {
} }
} }
func (config Config) templateDefaultsMap() fiber.Map { func (config *Config) templateDefaultsMap() fiber.Map {
return fiber.Map{ return fiber.Map{
"Default": config.getTemplateDefaults(), "Default": config.getTemplateDefaults(),
} }
} }
func (config Config) adminEndpoint(c *fiber.Ctx) error { func (config *Config) adminEndpoint(c *fiber.Ctx) error {
if c.Method() == "POST" { if c.Method() == "POST" {
if config.isAuthorized(c) { // here the user is submitting the form to change configuration if config.isAuthorized(c) { // here the user is submitting the form to change configuration
err := config.saveAdminForm(c) err := config.saveAdminForm(c)
if err != nil { if err != nil {
@ -68,18 +66,15 @@ func (config Config) adminEndpoint(c *fiber.Ctx) error {
return config.renderAdminPanel(c) return config.renderAdminPanel(c)
} }
return c.Render("login", fiber.Map{"Defaults": config.getTemplateDefaults(), "Failed": true}, "base") return c.Render("login", fiber.Map{"Defaults": config.getTemplateDefaults(), "Failed": true}, "base")
} }
if config.isAuthorized(c) { if config.isAuthorized(c) {
return config.renderAdminPanel(c) return config.renderAdminPanel(c)
} }
return c.Render("login", config.templateDefaultsMap(), "base") return c.Render("login", config.templateDefaultsMap(), "base")
} }
func (config Config) renderAdminPanel(c *fiber.Ctx, warning ...Warning) error { func (config *Config) renderAdminPanel(c *fiber.Ctx, warning ...Warning) error {
dirs, err := os.ReadDir("./templates") dirs, err := os.ReadDir("./templates")
if err != nil { if err != nil {
return err return err
@ -100,15 +95,18 @@ func (config Config) renderAdminPanel(c *fiber.Ctx, warning ...Warning) error {
"Themes": dirs, "Themes": dirs,
"Config": config, "Config": config,
}, "base") }, "base")
} }
func (config Config) saveAdminForm(c *fiber.Ctx) error { var (
errNoChanges = errors.New("no changes from previous config")
errMissingField = errors.New("required field is missing")
)
func (config *Config) saveAdminForm(c *fiber.Ctx) error {
requiredFields := []string{"base_url", "api_key", "polled_smart_energy_summation", "fossil_percentage", "username", "theme", "name", "installation_date"} requiredFields := []string{"base_url", "api_key", "polled_smart_energy_summation", "fossil_percentage", "username", "theme", "name", "installation_date"}
for _, requiredField := range requiredFields { for _, requiredField := range requiredFields {
if c.FormValue(requiredField) == "" { if c.FormValue(requiredField) == "" {
return errors.New("Required field is missing: " + requiredField) return fmt.Errorf("%w: %s", errMissingField, requiredField)
} }
} }
@ -117,7 +115,7 @@ func (config Config) saveAdminForm(c *fiber.Ctx) error {
return err return err
} }
form := Config{ form := &Config{
HomeAssistant: HomeAssistant{ /*BaseURL to be filled later*/ ApiKey: c.FormValue("api_key"), InstallationDate: dayStart(parsedTime)}, HomeAssistant: HomeAssistant{ /*BaseURL to be filled later*/ ApiKey: c.FormValue("api_key"), InstallationDate: dayStart(parsedTime)},
Sensors: Sensors{PolledSmartEnergySummation: c.FormValue("polled_smart_energy_summation"), FossilPercentage: c.FormValue("fossil_percentage")}, Sensors: Sensors{PolledSmartEnergySummation: c.FormValue("polled_smart_energy_summation"), FossilPercentage: c.FormValue("fossil_percentage")},
Administrator: Administrator{Username: c.FormValue("username") /*PasswordHash to be filled later*/}, Administrator: Administrator{Username: c.FormValue("username") /*PasswordHash to be filled later*/},
@ -136,8 +134,8 @@ func (config Config) saveAdminForm(c *fiber.Ctx) error {
} }
form.HomeAssistant.BaseURL = fmtURL form.HomeAssistant.BaseURL = fmtURL
if reflect.DeepEqual(form, config) { if form.equals(config) {
return errors.New("No changes from previous config.") return errNoChanges
} }
form.db = config.db form.db = config.db
@ -151,8 +149,7 @@ func (config Config) saveAdminForm(c *fiber.Ctx) error {
return err return err
} }
return os.WriteFile("config.json", js, 0666) return os.WriteFile("config.json", js, 0o666)
} }
func averageExcludingCurrentDay(data []float32) float32 { func averageExcludingCurrentDay(data []float32) float32 {
@ -164,12 +161,11 @@ func averageExcludingCurrentDay(data []float32) float32 {
for _, num := range data { for _, num := range data {
sum += num sum += num
} }
var avg = sum / float32(len(data)) avg := sum / float32(len(data))
return float32(math.Floor(float64(avg)*100)) / 100 return float32(math.Floor(float64(avg)*100)) / 100
} }
func (config Config) renderIndex(c *fiber.Ctx) error { func (config *Config) renderIndex(c *fiber.Ctx) error {
if config.HomeAssistant.InstallationDate.IsZero() { if config.HomeAssistant.InstallationDate.IsZero() {
return c.Render("config-error", fiber.Map{ return c.Render("config-error", fiber.Map{
"Defaults": config.getTemplateDefaults(), "Defaults": config.getTemplateDefaults(),
@ -182,12 +178,10 @@ func (config Config) renderIndex(c *fiber.Ctx) error {
return err return err
} }
var ( labels := make([]string, 0, len(data))
labels []string greenEnergyConsumptionAbsolute := make([]float32, 0, len(data))
greenEnergyConsumptionAbsolute []float32 greenEnergyPercents := make([]float32, 0, len(data))
greenEnergyPercents []float32 energyConsumptions := make([]float32, 0, len(data))
energyConsumptions []float32
)
for _, datum := range data { for _, datum := range data {
labels = append(labels, time.Unix(datum.Date, 0).Format("02/01")) labels = append(labels, time.Unix(datum.Date, 0).Format("02/01"))
@ -206,21 +200,18 @@ func (config Config) renderIndex(c *fiber.Ctx) error {
"GreenEnergyPercent": averageExcludingCurrentDay(greenEnergyPercents), "GreenEnergyPercent": averageExcludingCurrentDay(greenEnergyPercents),
"PerDayUsage": perDayUsage, "PerDayUsage": perDayUsage,
}, "base") }, "base")
} }
func templateDivide(num1, num2 float32) template.HTML { func templateDivide(num1, num2 float32) template.HTML {
division := float64(num1 / num2) division := float64(num1 / num2)
powerOfTen := int(math.Floor(math.Log10(division))) powerOfTen := int(math.Floor(math.Log10(division)))
if powerOfTen >= -2 && powerOfTen <= 2 { if powerOfTen >= -2 && powerOfTen <= 2 {
return template.HTML(fmt.Sprintf("%s", strconv.FormatFloat(math.Round(division*100)/100, 'f', -1, 64))) return template.HTML(strconv.FormatFloat(math.Round(division*100)/100, 'f', -1, 64))
} }
preComma := division / math.Pow10(powerOfTen) preComma := division / math.Pow10(powerOfTen)
return template.HTML(fmt.Sprintf("%s * 10<sup>%d</sup>", strconv.FormatFloat(math.Round(preComma*100)/100, 'f', -1, 64), powerOfTen)) return template.HTML(fmt.Sprintf("%s * 10<sup>%d</sup>", strconv.FormatFloat(math.Round(preComma*100)/100, 'f', -1, 64), powerOfTen))
} }
func templateHTMLDateFormat(date time.Time) template.HTML { func templateHTMLDateFormat(date time.Time) template.HTML {

View file

@ -1,17 +1,18 @@
package main package main
import ( import (
"log"
"net/http"
"os"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/template/html" "github.com/gofiber/template/html"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
"log"
"os"
"time"
) )
func main() { func main() {
config, isFirstRun, err := loadConfig()
config, err, isFirstRun := loadConfig()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -59,7 +60,7 @@ func main() {
}() }()
return c.Render("restart", config.templateDefaultsMap(), "base") return c.Render("restart", config.templateDefaultsMap(), "base")
} }
return c.Redirect("./", 307) return c.Redirect("./", http.StatusTemporaryRedirect)
}) })
port := os.Getenv("PORT") port := os.Getenv("PORT")
@ -67,5 +68,4 @@ func main() {
port = "80" port = "80"
} }
log.Fatal(app.Listen(":" + port)) log.Fatal(app.Listen(":" + port))
} }