Files
archived-teslamateapi/src/webserver.go
Lukáš Stankovič 1c6906c077 feat: add battery health endpoint for cars (#355)
Co-authored-by: Tobias Lindberg <tobias.ehlert@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-22 16:20:10 +02:00

495 lines
15 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
_ "github.com/lib/pq"
)
var (
// application readyz endpoint value for k8s
isReady *atomic.Value
// setting TeslaMateApi parameters
apiVersion = "unspecified"
dbTimestampFormat = "2006-01-02T15:04:05Z" // format used in postgres for dates
// defining db var
db *sql.DB
// app-settings
appUsersTimezone *time.Location
)
// main function
func main() {
// setup of readyness endpoint code
isReady = &atomic.Value{}
isReady.Store(false)
// setting log parameters
log.SetFlags(log.Ldate | log.Lmicroseconds)
// setting application to ReleaseMode if DEBUG_MODE is false
if !getEnvAsBool("DEBUG_MODE", false) {
// setting GIN_MODE to ReleaseMode
gin.SetMode(gin.ReleaseMode)
log.Printf("[info] TeslaMateApi running in release mode.")
} else {
// setting GIN_MODE to DebugMode
gin.SetMode(gin.DebugMode)
log.Printf("[info] TeslaMateApi running in debug mode.")
}
// getting app-settings from environment
appUsersTimezone, _ = time.LoadLocation(getEnv("TZ", "Europe/Berlin"))
if gin.IsDebugging() {
log.Println("[debug] TeslaMateApi appUsersTimezone:", appUsersTimezone)
}
// init of API with connection to database
initDBconnection()
defer db.Close()
// run initAuthToken to validate environment vars
initAuthToken()
// initialize allowList stored for /command section
initCommandAllowList()
// Connect to the MQTT broker
statusCache, err := startMQTT()
if getEnvAsBool("DISABLE_MQTT", false) {
log.Printf("[info] TeslaMateApi MQTT connection not established.")
} else {
if err != nil {
log.Fatalf("[error] TeslaMateApi MQTT connection failed: %s", err)
}
}
if getEnvAsBool("API_TOKEN_DISABLE", false) {
log.Println("[warning] validateAuthToken - header authorization bearer token disabled. Authorization: Bearer token will not be required for commands.")
}
if teslaApiHost := getEnv("TESLA_API_HOST", ""); teslaApiHost != "" {
log.Printf("[info] TESLA_API_HOST is set: %s", teslaApiHost)
}
// kicking off Gin in value r
r := gin.Default()
// gin middleware to enable GZIP support
r.Use(gzip.Gzip(gzip.DefaultCompression))
// set 404 not found page
r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"code": "PAGE_NOT_FOUND", "message": "Page not found"})
})
// disable proxy feature of gin
_ = r.SetTrustedProxies(nil)
// root endpoint telling API is running
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "TeslaMateApi container runnnig..", "path": r.BasePath()})
})
// TeslaMateApi /api endpoints
api := r.Group("/api")
{
// TeslaMateApi /api root
api.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "TeslaMateApi container runnnig..", "path": api.BasePath()})
})
// TeslaMateApi /api/v1 endpoints
v1 := api.Group("/v1")
{
// TeslaMateApi /api/v1 root
v1.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "TeslaMateApi v1 runnnig..", "path": v1.BasePath()})
})
// v1 /api/v1/cars endpoints
v1.GET("/cars", TeslaMateAPICarsV1)
v1.GET("/cars/:CarID", TeslaMateAPICarsV1)
// v1 /api/v1/cars/:CarID/battery-health endpoints
v1.GET("/cars/:CarID/battery-health", TeslaMateAPICarsBatteryHealthV1)
// v1 /api/v1/cars/:CarID/charges endpoints
v1.GET("/cars/:CarID/charges", TeslaMateAPICarsChargesV1)
v1.GET("/cars/:CarID/charges/:ChargeID", TeslaMateAPICarsChargesDetailsV1)
// v1 /api/v1/cars/:CarID/command endpoints
v1.GET("/cars/:CarID/command", TeslaMateAPICarsCommandV1)
v1.GET("/cars/:CarID/commands", TeslaMateAPICarsCommandV1)
v1.POST("/cars/:CarID/command/:Command", TeslaMateAPICarsCommandV1)
// v1 /api/v1/cars/:CarID/drives endpoints
v1.GET("/cars/:CarID/drives", TeslaMateAPICarsDrivesV1)
v1.GET("/cars/:CarID/drives/:DriveID", TeslaMateAPICarsDrivesDetailsV1)
// v1 /api/v1/cars/:CarID/logging endpoints
v1.GET("/cars/:CarID/logging", TeslaMateAPICarsLoggingV1)
v1.PUT("/cars/:CarID/logging/:Command", TeslaMateAPICarsLoggingV1)
// v1 /api/v1/cars/:CarID/status endpoints
v1.GET("/cars/:CarID/status", statusCache.TeslaMateAPICarsStatusV1)
// v1 /api/v1/cars/:CarID/updates endpoints
v1.GET("/cars/:CarID/updates", TeslaMateAPICarsUpdatesV1)
// v1 /api/v1/cars/:CarID/wake_up endpoints
v1.POST("/cars/:CarID/wake_up", TeslaMateAPICarsCommandV1)
// v1 /api/v1/globalsettings endpoints
v1.GET("/globalsettings", TeslaMateAPIGlobalsettingsV1)
}
// /api/ping endpoint
api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) })
// health endpoints for kubernetes
api.GET("/healthz", healthz)
api.GET("/readyz", readyz)
}
// TeslaMateApi endpoints (before versioning)
BasePathV1 := api.BasePath() + "/v1"
r.GET("/cars", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
r.GET("/cars/:CarID", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
r.GET("/cars/:CarID/charges", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
r.GET("/cars/:CarID/charges/:ChargeID", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
r.GET("/cars/:CarID/drives", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
r.GET("/cars/:CarID/drives/:DriveID", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
r.GET("/cars/:CarID/status", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
r.GET("/cars/:CarID/updates", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
r.GET("/globalsettings", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, BasePathV1+c.Request.RequestURI) })
// build the http server
server := &http.Server{
Addr: ":8080", // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
Handler: r,
}
// setting readyz endpoint to true (if not using MQTT)
if getEnvAsBool("DISABLE_MQTT", false) {
isReady.Store(true)
}
// graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
// we run a go routine that will receive the shutdown input
go func() {
<-quit
log.Println("[info] TeslaMateAPI received shutdown input")
if err := server.Close(); err != nil {
log.Fatal("[error] TeslaMateAPI server close error:", err)
}
}()
// run the server
if err := server.ListenAndServe(); err != nil {
if err == http.ErrServerClosed {
log.Println("[info] TeslaMateAPI server gracefully shut down")
} else {
log.Fatal("[error] TeslaMateAPI server closed unexpectedly")
}
}
}
// initDBconnection func
func initDBconnection() {
var err error
// read environment variables with defaults for connection string
dbhost := getEnv("DATABASE_HOST", "database")
dbport := getEnvAsInt("DATABASE_PORT", 5432)
dbuser := getEnv("DATABASE_USER", "teslamate")
dbpass := getEnv("DATABASE_PASS", "secret")
dbname := getEnv("DATABASE_NAME", "teslamate")
dbtimeout := (getEnvAsInt("DATABASE_TIMEOUT", 60000) / 1000)
dbsslmode := getEnv("DATABASE_SSL", "disable")
dbsslrootcert := getEnv("DATABASE_SSL_CA_CERT_FILE", "")
// convert boolean-like SSL mode for backwards compatibility
switch dbsslmode {
case "true", "noverify":
dbsslmode = "require"
case "false":
dbsslmode = "disable"
}
// construct connection string
psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=%d", dbhost, dbport, dbuser, dbpass, dbname, dbsslmode, dbtimeout)
// add SSL certificate configuration if provided
if dbsslrootcert != "" {
psqlInfo += " sslrootcert=" + dbsslrootcert
}
// open database connection
db, err = sql.Open("postgres", psqlInfo)
if err != nil {
log.Fatalf("[error] initDBconnection - database connection error: %v", err)
}
// test database connection
if err = db.Ping(); err != nil {
log.Fatalf("[error] initDBconnection - database ping error: %v", err)
}
// showing database successfully connected
if gin.IsDebugging() {
log.Println("[debug] initDBconnection - database connection established successfully.")
}
}
func TeslaMateAPIHandleErrorResponse(c *gin.Context, s1 string, s2 string, s3 string) {
log.Println("[error] " + s1 + " - (" + c.Request.RequestURI + "). " + s2 + "; " + s3)
c.JSON(http.StatusOK, gin.H{"error": s2})
}
func TeslaMateAPIHandleOtherResponse(c *gin.Context, httpCode int, s string, j interface{}) {
// return successful response
log.Println("[info] " + s + " - (" + c.Request.RequestURI + ") executed successfully.")
c.JSON(httpCode, j)
}
func TeslaMateAPIHandleSuccessResponse(c *gin.Context, s string, j interface{}) {
// print to log about request
if gin.IsDebugging() {
log.Println("[debug] " + s + " - (" + c.Request.RequestURI + ") returned data:")
js, _ := json.Marshal(j)
log.Printf("[debug] %s\n", js)
}
// return successful response
log.Println("[info] " + s + " - (" + c.Request.RequestURI + ") executed successfully.")
c.JSON(http.StatusOK, j)
}
func getTimeInTimeZone(datestring string) string {
// parsing datestring into dbTimestampFormat
t, _ := time.Parse(dbTimestampFormat, datestring)
// formatting in users location in RFC3339 format
ReturnDate := t.In(appUsersTimezone).Format(time.RFC3339)
// logging time conversion to log
if gin.IsDebugging() {
log.Println("[debug] getTimeInTimeZone - UTC", t.Format(time.RFC3339), "time converted to", appUsersTimezone, "is", ReturnDate)
}
return ReturnDate
}
func parseDateParam(datestring string) (string, error) {
if datestring == "" {
return "", nil
}
// RFC3339 formats first — includes Z or timezone offset
if t, err := time.Parse(time.RFC3339, datestring); err == nil {
return t.UTC().Format(dbTimestampFormat), nil
}
// DateTime format (2006-01-02 15:04:05) without timezone info, interpret in user's timezone
normalizedDateString := strings.ReplaceAll(datestring, "T", " ")
if t, err := time.ParseInLocation(time.DateTime, normalizedDateString, appUsersTimezone); err == nil {
return t.UTC().Format(dbTimestampFormat), nil
}
sanitizedInput := strings.NewReplacer("\n", "\\n", "\r", "\\r", "\t", "\\t").Replace(datestring)
return "", fmt.Errorf("invalid date format: %s, please use RFC3339 format", sanitizedInput)
}
/*
func isNil(i interface{}) bool {
if i == nil {
return true
}
switch reflect.TypeOf(i).Kind() {
case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice:
return reflect.ValueOf(i).IsNil()
}
return false
}
// isEnvExist func - check if environment var is set
func isEnvExist(key string) (ok bool) {
_, ok = os.LookupEnv(key)
return
}
*/
// getEnv func - read an environment or return a default value
func getEnv(key string, defaultVal string) string {
if value, exists := os.LookupEnv(key); exists && value != "" {
return value
}
return defaultVal
}
// getEnvAsBool func - read an environment variable into a bool or return default value
func getEnvAsBool(name string, defaultVal bool) bool {
valStr := getEnv(name, "")
if val, err := strconv.ParseBool(valStr); err == nil {
return val
}
return defaultVal
}
/*
// getEnvAsFloat func - read an environment variable into a float64 or return default value
func getEnvAsFloat(name string, defaultVal float64) float64 {
valStr := getEnv(name, "")
if val, err := strconv.ParseFloat(valStr, 64); err == nil {
return val
}
return defaultVal
}
*/
// getEnvAsInt func - read an environment variable into integer or return a default value
func getEnvAsInt(name string, defaultVal int) int {
valueStr := getEnv(name, "")
if value, err := strconv.Atoi(valueStr); err == nil {
return value
}
return defaultVal
}
// convertStringToBool func
func convertStringToBool(data string) bool {
value, err := strconv.ParseBool(data)
if err == nil {
return value
}
log.Printf("[warning] convertStringToBool error when converting value correctly.. returning false. Error: %s", err)
return false
}
// convertStringToFloat func
func convertStringToFloat(data string) float64 {
value, err := strconv.ParseFloat(data, 64)
if err == nil {
return value
}
log.Printf("[warning] convertStringToFloat error when converting value correctly.. returning 0.0. Error: %s", err)
return 0.0
}
// convertStringToInteger func
func convertStringToInteger(data string) int {
value, err := strconv.Atoi(data)
if err == nil {
return value
}
log.Printf("[warning] convertStringToInteger error when converting value correctly.. returning 0. Error: %s", err)
return 0
}
// kilometersToMiles func
func kilometersToMiles(km float64) float64 {
return (km * 0.62137119223733)
}
// kilometersToMilesNilSupport func
func kilometersToMilesNilSupport(km NullFloat64) NullFloat64 {
km.Float64 = (km.Float64 * 0.62137119223733)
return (km)
}
// milesToKilometers func
func milesToKilometers(mi float64) float64 {
return (mi * 1.609344)
}
/*
// milesToKilometersNilSupport func
func milesToKilometersNilSupport(mi NullFloat64) NullFloat64 {
mi.Float64 = (mi.Float64 * 1.609344)
return (mi)
}
*/
// kilometersToMilesInteger func
func kilometersToMilesInteger(km int) int {
return int(float64(km) * 0.62137119223733)
}
// barToPsi func
func barToPsi(bar float64) float64 {
return (bar * 14.503773800722)
}
/*
// psiToBar func
func psiToBar(psi float64) float64 {
return (psi * 0.068947572932)
}
*/
// celsiusToFahrenheit func
func celsiusToFahrenheit(c float64) float64 {
return (c*9/5 + 32)
}
// celsiusToFahrenheitNilSupport func
func celsiusToFahrenheitNilSupport(c NullFloat64) NullFloat64 {
c.Float64 = (c.Float64*9/5 + 32)
return (c)
}
/*
// fahrenheitToCelsius func
func fahrenheitToCelsius(f float64) float64 {
return ((f - 32) * 5 / 9)
}
// fahrenheitToCelsiusNilSupport func
func fahrenheitToCelsiusNilSupport(f NullFloat64) NullFloat64 {
f.Float64 = ((f.Float64 - 32) * 5 / 9)
return (f)
}
*/
// checkArrayContainsString func - check if string is inside stringarray
func checkArrayContainsString(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
// healthz is a liveness probe.
func healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": http.StatusText(http.StatusOK)})
}
// readyz is a readiness probe.
func readyz(c *gin.Context) {
if isReady == nil || !isReady.Load().(bool) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": http.StatusText(http.StatusServiceUnavailable)})
return
}
TeslaMateAPIHandleSuccessResponse(c, "webserver", gin.H{"status": http.StatusText(http.StatusOK)})
}