Refactor
This commit is contained in:
parent
08be78f907
commit
9bd3f35cc7
8 changed files with 403 additions and 150 deletions
306
src/api.go
Normal file
306
src/api.go
Normal file
|
@ -0,0 +1,306 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HistoryResult []struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func dayStart(t time.Time) time.Time {
|
||||
hours, minutes, seconds := t.Clock()
|
||||
return t.Add(-(time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second))
|
||||
}
|
||||
|
||||
var errNon200 = errors.New("got a non-200 status code. Check the correctness of sensors IDs")
|
||||
|
||||
func (config *Config) queryHistory(entityID string, startTime, endTime time.Time) (HistoryResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
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 {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", "Bearer "+config.HomeAssistant.ApiKey)
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return HistoryResult{}, fmt.Errorf("%w - %s", errNon200, resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
|
||||
var result []HistoryResult
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
return HistoryResult{}, nil
|
||||
}
|
||||
|
||||
return result[0], nil
|
||||
}
|
||||
|
||||
// t can be any time during the desired day.
|
||||
func (config *Config) getDayHistory(entityID string, t time.Time) (HistoryResult, error) {
|
||||
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)
|
||||
|
||||
return config.queryHistory(entityID, dayStart(t), endTime)
|
||||
}
|
||||
|
||||
type DayData struct {
|
||||
DayTime time.Time
|
||||
DayNumber int
|
||||
Measurements int
|
||||
Value float32
|
||||
High float32
|
||||
Low float32
|
||||
}
|
||||
|
||||
func (config *Config) historyAverageAndConvertToGreen(entityID string, t time.Time) (DayData, error) {
|
||||
history, err := config.getDayHistory(entityID, t)
|
||||
if err != nil {
|
||||
return DayData{}, err
|
||||
}
|
||||
|
||||
var day DayData
|
||||
|
||||
for _, historyChange := range history {
|
||||
val, err := strconv.ParseFloat(historyChange.State, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
day.Value += float32(val)
|
||||
day.Measurements++
|
||||
}
|
||||
|
||||
day.Value = 100 - (day.Value / float32(day.Measurements))
|
||||
return day, nil
|
||||
}
|
||||
|
||||
func (config *Config) historyBulkAverageAndConvertToGreen(entityID string, startTime, endTime time.Time) ([]DayData, error) {
|
||||
history, err := config.queryHistory(entityID, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var days []DayData
|
||||
|
||||
for _, historyChange := range history {
|
||||
val, err := strconv.ParseFloat(historyChange.State, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value := float32(val)
|
||||
var found bool
|
||||
dayNo := dayStart(historyChange.LastUpdated.Local()).Day()
|
||||
for key, day := range days {
|
||||
if dayNo == day.DayNumber {
|
||||
found = true
|
||||
day.Value += value
|
||||
day.Measurements++
|
||||
days[key] = day
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
days = append(days, DayData{
|
||||
DayNumber: dayNo,
|
||||
DayTime: dayStart(historyChange.LastUpdated.Local()),
|
||||
Measurements: 1,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for key, day := range days {
|
||||
// by using 100 - value we get the percentage of green energy instead of the percentage of fossil-generated energy
|
||||
day.Value = 100 - (day.Value / float32(day.Measurements))
|
||||
days[key] = day
|
||||
}
|
||||
|
||||
days = fillMissing(days, startTime, endTime)
|
||||
|
||||
return days, nil
|
||||
}
|
||||
|
||||
func (config *Config) historyDelta(entityID string, t time.Time) (DayData, error) {
|
||||
history, err := config.getDayHistory(entityID, t)
|
||||
if err != nil {
|
||||
return DayData{}, err
|
||||
}
|
||||
|
||||
var day DayData
|
||||
|
||||
for _, historyChange := range history {
|
||||
val, err := strconv.ParseFloat(historyChange.State, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value := float32(val)
|
||||
|
||||
if value > day.High {
|
||||
day.High = value
|
||||
}
|
||||
if value < day.Low || day.Low == 0 {
|
||||
day.Low = value
|
||||
}
|
||||
}
|
||||
|
||||
day.Value = day.High - day.Low
|
||||
return day, nil
|
||||
}
|
||||
|
||||
func (config *Config) historyBulkDelta(entityID string, startTime, endTime time.Time) ([]DayData, error) {
|
||||
history, err := config.queryHistory(entityID, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var days []DayData
|
||||
|
||||
for _, historyChange := range history {
|
||||
if historyChange.State == "off" {
|
||||
continue
|
||||
}
|
||||
val, err := strconv.ParseFloat(historyChange.State, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value := float32(val)
|
||||
var found bool
|
||||
dayNo := dayStart(historyChange.LastUpdated.Local()).Day()
|
||||
for key, day := range days {
|
||||
if dayNo == day.DayNumber {
|
||||
found = true
|
||||
if value > day.High {
|
||||
day.High = value
|
||||
}
|
||||
if value < day.Low || day.Low == 0 {
|
||||
day.Low = value
|
||||
}
|
||||
days[key] = day
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
days = append(days, DayData{
|
||||
DayNumber: dayNo,
|
||||
DayTime: dayStart(historyChange.LastUpdated.Local()),
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for key, day := range days {
|
||||
day.Value = day.High - day.Low
|
||||
days[key] = day
|
||||
}
|
||||
|
||||
days = fillMissing(days, startTime, endTime)
|
||||
|
||||
return days, nil
|
||||
}
|
||||
|
||||
func fillMissing(days []DayData, startTime, endTime time.Time) []DayData {
|
||||
var (
|
||||
previousDay time.Time
|
||||
defaultDay time.Time
|
||||
previousValue float32
|
||||
ret []DayData
|
||||
currentTime = time.Now()
|
||||
)
|
||||
|
||||
expectedDaysDiff := int(math.Trunc(endTime.Sub(startTime).Hours()/24) + 1)
|
||||
|
||||
for key, day := range days {
|
||||
if key != 0 {
|
||||
if day.DayTime.Day() != previousDay.Add(24*time.Hour).Day() {
|
||||
daysDiff := math.Trunc(day.DayTime.Sub(previousDay).Hours() / 24)
|
||||
for i := 1; float64(i) < daysDiff; i++ {
|
||||
fakeTime := previousDay.Add(time.Duration(24*i) * time.Hour)
|
||||
ret = append(ret, DayData{
|
||||
DayNumber: fakeTime.Day(),
|
||||
DayTime: dayStart(fakeTime),
|
||||
Value: previousValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ret = append(ret, day)
|
||||
previousValue = day.Value
|
||||
previousDay = day.DayTime
|
||||
}
|
||||
|
||||
// note that here previousDay is the last logged day
|
||||
if previousDay == defaultDay {
|
||||
return []DayData{}
|
||||
}
|
||||
|
||||
if previousDay.Day() != currentTime.Day() {
|
||||
daysDiff := math.Trunc(currentTime.Sub(previousDay).Hours() / 24)
|
||||
for i := 1; float64(i) < daysDiff; i++ {
|
||||
fakeTime := previousDay.Add(time.Duration(24*i) * time.Hour)
|
||||
ret = append(ret, DayData{
|
||||
DayNumber: fakeTime.Day(),
|
||||
DayTime: dayStart(fakeTime),
|
||||
Value: previousValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(ret) < expectedDaysDiff {
|
||||
shouldAdd := expectedDaysDiff - len(ret)
|
||||
startDay := currentTime.Add(-time.Duration(24*len(ret)) * time.Hour)
|
||||
for i := 0; i < shouldAdd; i++ {
|
||||
fakeTime := startDay.Add(-time.Duration(24*i) * time.Hour)
|
||||
ret = append([]DayData{
|
||||
{
|
||||
DayNumber: fakeTime.Day(),
|
||||
DayTime: dayStart(fakeTime),
|
||||
Value: 0,
|
||||
},
|
||||
}, ret...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ret) != expectedDaysDiff {
|
||||
// oh shit
|
||||
log.Panicln("You've found a logic bug! Open a bug report ASAP.")
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
136
src/config.go
Normal file
136
src/config.go
Normal file
|
@ -0,0 +1,136 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
db *sql.DB
|
||||
HomeAssistant HomeAssistant `json:"home_assistant"`
|
||||
Sensors Sensors `json:"sensors"`
|
||||
Administrator Administrator `json:"administrator"`
|
||||
Dashboard Dashboard `json:"dashboard"`
|
||||
}
|
||||
|
||||
type HomeAssistant struct {
|
||||
InstallationDate time.Time `json:"installation_date"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ApiKey string `json:"api_key"`
|
||||
}
|
||||
type Sensors struct {
|
||||
PolledSmartEnergySummation string `json:"polled_smart_energy_summation"`
|
||||
FossilPercentage string `json:"fossil_percentage"`
|
||||
}
|
||||
type Administrator struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
}
|
||||
type Dashboard struct {
|
||||
Name string `json:"name"`
|
||||
Theme string `json:"theme"`
|
||||
FooterLinks []Link `json:"footer_links"`
|
||||
HeaderLinks []Link `json:"header_links"`
|
||||
}
|
||||
|
||||
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 /
|
||||
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
url = "http://" + url
|
||||
}
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
|
||||
test := regexp.MustCompile(`(?m)https?:\/\/[^/]*`).ReplaceAllString(url, "")
|
||||
if test != "" {
|
||||
return "", errBadHAFormat
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func loadConfig() (config *Config, isFirstRun bool, err error) {
|
||||
db, err := sql.Open("sqlite3", "./database.db")
|
||||
if err != nil {
|
||||
return &Config{}, false, err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS "cache" (
|
||||
"time" NUMERIC NOT NULL,
|
||||
"green_energy_percentage" REAL NOT NULL,
|
||||
"energy_consumption" REAL NOT NULL,
|
||||
PRIMARY KEY("time")
|
||||
);`)
|
||||
if err != nil {
|
||||
return &Config{}, false, err
|
||||
}
|
||||
|
||||
defaultConfig := &Config{}
|
||||
defaultConfig.Dashboard.Theme = "default"
|
||||
defaultConfig.Dashboard.Name = "EcoDash"
|
||||
defaultConfig.Dashboard.HeaderLinks = append(defaultConfig.Dashboard.HeaderLinks, Link{
|
||||
Label: "Admin",
|
||||
Destination: "/admin",
|
||||
}, Link{
|
||||
Label: "EcoDash",
|
||||
Destination: "https://ecodash.xyz",
|
||||
NewTab: true,
|
||||
Primary: true,
|
||||
})
|
||||
defaultConfig.db = db
|
||||
|
||||
data, err := os.ReadFile("config.json")
|
||||
if err != nil {
|
||||
// if the data file doesn't exist, we consider it a first run
|
||||
if os.IsNotExist(err) {
|
||||
return defaultConfig, true, nil
|
||||
}
|
||||
return &Config{}, false, err
|
||||
}
|
||||
|
||||
// if the data file is empty, we consider it as a first run
|
||||
if len(data) == 0 {
|
||||
return defaultConfig, true, nil
|
||||
}
|
||||
|
||||
conf := &Config{}
|
||||
err = json.Unmarshal(data, &conf)
|
||||
if err != nil {
|
||||
return &Config{}, false, err
|
||||
}
|
||||
conf.db = db
|
||||
|
||||
return conf, false, nil
|
||||
}
|
||||
|
||||
// just a little utility function to SHA256 strings (for hashing passwords).
|
||||
func hash(toHash string) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(toHash)))
|
||||
}
|
||||
|
||||
func (config *Config) isAuthorized(c *fiber.Ctx) bool {
|
||||
if config.Administrator.PasswordHash == "" {
|
||||
return true
|
||||
}
|
||||
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)
|
||||
}
|
110
src/database.go
Normal file
110
src/database.go
Normal file
|
@ -0,0 +1,110 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HistoryEntry struct {
|
||||
Date int64
|
||||
GreenEnergyPercentage float32
|
||||
PolledSmartEnergySummation float32
|
||||
}
|
||||
type History []HistoryEntry
|
||||
|
||||
func (config *Config) updateHistory() {
|
||||
greenEnergyPercentage, err := config.historyAverageAndConvertToGreen(config.Sensors.FossilPercentage, time.Now())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
historyPolledSmartEnergySummation, err := config.historyDelta(config.Sensors.PolledSmartEnergySummation, time.Now())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, 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()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(cached) != 8 && time.Since(config.HomeAssistant.InstallationDate) > 8*time.Hour*24 {
|
||||
err := config.refreshCacheFromPast(time.Now().Add(-8 * time.Hour * 24))
|
||||
if err != nil {
|
||||
log.Println("Error refreshing cache", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (config *Config) refreshCacheFromInstall() error {
|
||||
return config.refreshCacheFromPast(config.HomeAssistant.InstallationDate)
|
||||
}
|
||||
|
||||
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
|
||||
if config.HomeAssistant.InstallationDate.IsZero() {
|
||||
return errNoInstallDate
|
||||
}
|
||||
|
||||
greenEnergyPercentage, err := config.historyBulkAverageAndConvertToGreen(config.Sensors.FossilPercentage, pastTime, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
historyPolledSmartEnergySummation, err := config.historyBulkDelta(config.Sensors.PolledSmartEnergySummation, pastTime, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, day := range greenEnergyPercentage {
|
||||
var action2 string
|
||||
if greenEnergyPercentage[key].Value != 0 && historyPolledSmartEnergySummation[key].Value != 0 {
|
||||
action2 = "REPLACE"
|
||||
} else {
|
||||
action2 = "IGNORE"
|
||||
}
|
||||
|
||||
stmt, err := config.db.Prepare("INSERT OR " + action2 + " INTO cache(time, green_energy_percentage, energy_consumption) values(?,?,?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(day.DayTime.Unix(), greenEnergyPercentage[key].Value, historyPolledSmartEnergySummation[key].Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *Config) readHistory() (History, error) {
|
||||
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())
|
||||
if err != nil {
|
||||
return History{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ret History
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
date int64
|
||||
greenEnergyPercentage float32
|
||||
polledSmartEnergyConsumption float32
|
||||
)
|
||||
err = rows.Scan(&date, &greenEnergyPercentage, &polledSmartEnergyConsumption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret = append(ret, HistoryEntry{date, greenEnergyPercentage, polledSmartEnergyConsumption})
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
222
src/http.go
Normal file
222
src/http.go
Normal file
|
@ -0,0 +1,222 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type Link struct {
|
||||
Label string `json:"label"`
|
||||
Destination string `json:"destination"`
|
||||
NewTab bool `json:"new_tab"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
type Warning struct {
|
||||
Header string
|
||||
Body string
|
||||
BodyHTML template.HTML
|
||||
IsSuccess bool
|
||||
}
|
||||
|
||||
func (config *Config) getTemplateDefaults() fiber.Map {
|
||||
return fiber.Map{
|
||||
"DashboardName": config.Dashboard.Name,
|
||||
"HeaderLinks": config.Dashboard.HeaderLinks,
|
||||
"FooterLinks": config.Dashboard.FooterLinks,
|
||||
}
|
||||
}
|
||||
|
||||
func (config *Config) templateDefaultsMap() fiber.Map {
|
||||
return fiber.Map{
|
||||
"Default": config.getTemplateDefaults(),
|
||||
}
|
||||
}
|
||||
|
||||
func (config *Config) adminEndpoint(c *fiber.Ctx) error {
|
||||
if c.Method() == "POST" {
|
||||
if config.isAuthorized(c) { // here the user is submitting the form to change configuration
|
||||
err := config.saveAdminForm(c)
|
||||
if err != nil {
|
||||
return config.renderAdminPanel(c, Warning{
|
||||
Header: "An error occurred!",
|
||||
Body: err.Error(),
|
||||
})
|
||||
}
|
||||
return config.renderAdminPanel(c, Warning{
|
||||
Header: "Restart needed",
|
||||
Body: "In order to apply changes, please <b>restart EcoDash</b>.<br>" +
|
||||
"If you're running via Docker, click <a href='./restart'>here</a> to restart automatically.",
|
||||
IsSuccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
// here the user is trying to authenticate
|
||||
if c.FormValue("username") == config.Administrator.Username && hash(c.FormValue("password")) == config.Administrator.PasswordHash {
|
||||
c.Cookie(&fiber.Cookie{Name: "admin_username", Value: c.FormValue("username")})
|
||||
c.Cookie(&fiber.Cookie{Name: "admin_password_hash", Value: hash(c.FormValue("password"))})
|
||||
return config.renderAdminPanel(c)
|
||||
}
|
||||
return c.Render("login", fiber.Map{"Defaults": config.getTemplateDefaults(), "Failed": true}, "base")
|
||||
}
|
||||
|
||||
if config.isAuthorized(c) {
|
||||
return config.renderAdminPanel(c)
|
||||
}
|
||||
return c.Render("login", config.templateDefaultsMap(), "base")
|
||||
}
|
||||
|
||||
func (config *Config) renderAdminPanel(c *fiber.Ctx, warning ...Warning) error {
|
||||
dirs, err := os.ReadDir("./templates")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(warning) > 0 {
|
||||
warning[0].BodyHTML = template.HTML(warning[0].Body)
|
||||
return c.Render("admin", fiber.Map{
|
||||
"Defaults": config.getTemplateDefaults(),
|
||||
"Themes": dirs,
|
||||
"Config": config,
|
||||
"Warning": warning[0],
|
||||
}, "base")
|
||||
}
|
||||
|
||||
return c.Render("admin", fiber.Map{
|
||||
"Defaults": config.getTemplateDefaults(),
|
||||
"Themes": dirs,
|
||||
"Config": config,
|
||||
}, "base")
|
||||
}
|
||||
|
||||
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"}
|
||||
for _, requiredField := range requiredFields {
|
||||
if c.FormValue(requiredField) == "" {
|
||||
return fmt.Errorf("%w: %s", errMissingField, requiredField)
|
||||
}
|
||||
}
|
||||
|
||||
parsedTime, err := time.Parse("2006-01-02", c.FormValue("installation_date"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form := &Config{
|
||||
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")},
|
||||
Administrator: Administrator{Username: c.FormValue("username") /*PasswordHash to be filled later*/},
|
||||
Dashboard: Dashboard{Theme: c.FormValue("theme"), Name: c.FormValue("name"), HeaderLinks: config.Dashboard.HeaderLinks, FooterLinks: config.Dashboard.FooterLinks},
|
||||
}
|
||||
|
||||
if c.FormValue("keep_old_password") == "" {
|
||||
form.Administrator.PasswordHash = hash(c.FormValue("password"))
|
||||
} else {
|
||||
form.Administrator.PasswordHash = config.Administrator.PasswordHash
|
||||
}
|
||||
|
||||
fmtURL, err := formatURL(c.FormValue("base_url"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
form.HomeAssistant.BaseURL = fmtURL
|
||||
|
||||
if form.equals(config) {
|
||||
return errNoChanges
|
||||
}
|
||||
|
||||
form.db = config.db
|
||||
err = form.refreshCacheFromInstall()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
js, err := json.Marshal(form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile("config.json", js, 0o666)
|
||||
}
|
||||
|
||||
func averageExcludingCurrentDay(data []float32) float32 {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
data = data[:len(data)-1]
|
||||
var sum float32
|
||||
for _, num := range data {
|
||||
sum += num
|
||||
}
|
||||
avg := sum / float32(len(data))
|
||||
return float32(math.Floor(float64(avg)*100)) / 100
|
||||
}
|
||||
|
||||
func (config *Config) renderIndex(c *fiber.Ctx) error {
|
||||
if config.HomeAssistant.InstallationDate.IsZero() {
|
||||
return c.Render("config-error", fiber.Map{
|
||||
"Defaults": config.getTemplateDefaults(),
|
||||
"Error": "The installation date is not set! This is normal if you've just updated from v0.1 to v0.2.",
|
||||
}, "base")
|
||||
}
|
||||
|
||||
data, err := config.readHistory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
labels := make([]string, 0, len(data))
|
||||
greenEnergyConsumptionAbsolute := make([]float32, 0, len(data))
|
||||
greenEnergyPercents := make([]float32, 0, len(data))
|
||||
energyConsumptions := make([]float32, 0, len(data))
|
||||
|
||||
for _, datum := range data {
|
||||
labels = append(labels, time.Unix(datum.Date, 0).Format("02/01"))
|
||||
greenEnergyPercents = append(greenEnergyPercents, datum.GreenEnergyPercentage)
|
||||
greenEnergyConsumptionAbsolute = append(greenEnergyConsumptionAbsolute, datum.GreenEnergyPercentage/100*datum.PolledSmartEnergySummation)
|
||||
energyConsumptions = append(energyConsumptions, datum.PolledSmartEnergySummation)
|
||||
}
|
||||
|
||||
perDayUsage := averageExcludingCurrentDay(energyConsumptions)
|
||||
|
||||
return c.Render("index", fiber.Map{
|
||||
"Defaults": config.getTemplateDefaults(),
|
||||
"Labels": labels,
|
||||
"GreenEnergyPercents": greenEnergyConsumptionAbsolute,
|
||||
"EnergyConsumptions": energyConsumptions,
|
||||
"GreenEnergyPercent": averageExcludingCurrentDay(greenEnergyPercents),
|
||||
"PerDayUsage": perDayUsage,
|
||||
}, "base")
|
||||
}
|
||||
|
||||
func templateDivide(num1, num2 float32) template.HTML {
|
||||
division := float64(num1 / num2)
|
||||
|
||||
powerOfTen := int(math.Floor(math.Log10(division)))
|
||||
if powerOfTen >= -2 && powerOfTen <= 2 {
|
||||
return template.HTML(strconv.FormatFloat(math.Round(division*100)/100, 'f', -1, 64))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func templateHTMLDateFormat(date time.Time) template.HTML {
|
||||
if date.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return template.HTML(date.Format("2006-01-02"))
|
||||
}
|
71
src/main.go
Normal file
71
src/main.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/template/html"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config, isFirstRun, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !isFirstRun {
|
||||
cr := cron.New()
|
||||
_, err = cr.AddFunc("@hourly", config.updateHistory)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cr.Start()
|
||||
config.updateHistory()
|
||||
}
|
||||
|
||||
engine := html.New("./templates/"+config.Dashboard.Theme, ".html")
|
||||
engine.AddFunc("divide", templateDivide)
|
||||
engine.AddFunc("HTMLDateFormat", templateHTMLDateFormat)
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
Views: engine,
|
||||
})
|
||||
|
||||
app.Static("/assets", "./templates/"+config.Dashboard.Theme+"/assets")
|
||||
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
if isFirstRun {
|
||||
c.Cookie(&fiber.Cookie{Name: "admin_username", Value: ""})
|
||||
c.Cookie(&fiber.Cookie{Name: "admin_password_hash", Value: hash("")})
|
||||
return config.renderAdminPanel(c)
|
||||
}
|
||||
return config.renderIndex(c)
|
||||
})
|
||||
|
||||
app.Get("/accuracy-notice", func(c *fiber.Ctx) error {
|
||||
return c.Render("accuracy-notice", config.templateDefaultsMap(), "base")
|
||||
})
|
||||
|
||||
app.All("/admin", config.adminEndpoint)
|
||||
|
||||
app.Get("/restart", func(c *fiber.Ctx) error {
|
||||
if config.isAuthorized(c) {
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
os.Exit(1)
|
||||
}()
|
||||
return c.Render("restart", config.templateDefaultsMap(), "base")
|
||||
}
|
||||
return c.Redirect("./", http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "80"
|
||||
}
|
||||
log.Fatal(app.Listen(":" + port))
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue