Merge branch 'mqtt' Add presence helper

This commit is contained in:
Mike Bloy 2024-09-29 13:08:42 -05:00
commit cf8065b543
10 changed files with 204 additions and 55 deletions

2
.gitignore vendored
View File

@ -23,3 +23,5 @@ go.work
/cmd/git_version.go /cmd/git_version.go
/dist /dist
.env .env
screen_off
screen_on

View File

@ -1,15 +1,18 @@
package cmd package cmd
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"math/rand"
"os" "os"
"strings" "os/signal"
"time" "syscall"
"git.bloy.org/mike/hasshelper/kiosk"
"git.bloy.org/mike/hasshelper/web" "git.bloy.org/mike/hasshelper/web"
_ "github.com/joho/godotenv/autoload"
"github.com/lmittmann/tint"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -22,30 +25,38 @@ var rootCmd = &cobra.Command{
Run: rootCmdRun, Run: rootCmdRun,
} }
func logLevelVar(str string) slog.Level {
levelUpper := strings.ToUpper(str)
switch levelUpper {
case "DEBUG":
return slog.LevelDebug
case "INFO":
return slog.LevelInfo
case "WARN":
return slog.LevelWarn
case "ERROR":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func rootCmdRun(cmd *cobra.Command, args []string) { func rootCmdRun(cmd *cobra.Command, args []string) {
logLevel := logLevelVar(viper.GetString("loglevel")) var logLevel slog.Level
logger := slog.New(slog.NewTextHandler(os.Stdout, logLevel.UnmarshalText([]byte(viper.GetString("loglevel")))
&slog.HandlerOptions{Level: logLevel})) logger := slog.New(
tint.NewHandler(os.Stdout, &tint.Options{
Level: logLevel,
TimeFormat: "2006-01-02T15:04:05.999",
NoColor: viper.GetString("deployment") == "prod",
}))
logger.Info("HASSHelper startup", "version", viper.GetString("version")) logger.Info("HASSHelper startup", "version", viper.GetString("version"))
exitchan := make(chan bool) exitchan := make(chan bool)
web.Run(logger, exitchan) signalchan := make(chan os.Signal, 1)
<-exitchan // run the main command until one of the goroutines is done done := make(chan bool, 2)
signal.Notify(signalchan, syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
select {
case <-signalchan:
logger.Warn("received interrupt. Exiting")
cancel()
done <- true
case <-exitchan:
logger.Error("unexpected exit of component")
cancel()
done <- true
}
}()
go web.Run(logger, exitchan, ctx)
go kiosk.Run(logger, exitchan, ctx)
logger.Debug("Waiting for exit")
<-done
} }
// Execute will kick off cobra's processing of the root command // Execute will kick off cobra's processing of the root command
@ -57,26 +68,27 @@ func Execute() {
} }
func init() { func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "",
"config file (default is $HOME/.config/hasshelper/config.toml)")
cobra.OnInitialize(initConfig)
const dirName = "hasshelper"
var userConfigDir, err = os.UserConfigDir() var userConfigDir, err = os.UserConfigDir()
const dirName = "hasshelper"
var defDir = fmt.Sprintf("%s%c%s", "/etc", os.PathSeparator, dirName)
if err == nil { if err == nil {
viper.AddConfigPath( defDir = fmt.Sprintf("%s%c%s", userConfigDir, os.PathSeparator, dirName)
fmt.Sprintf("%s%c%s", userConfigDir, os.PathSeparator, dirName)) viper.AddConfigPath(defDir)
} else { } else {
log.Println("could not locate user config dir:", err) log.Println("could not locate user config dir:", err)
} }
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "",
fmt.Sprintf("config file (default is %s%c%s)", defDir, os.PathSeparator, "hasshelper.toml"))
cobra.OnInitialize(initConfig)
viper.AddConfigPath(fmt.Sprintf("/etc%c%s", os.PathSeparator, dirName)) viper.AddConfigPath(fmt.Sprintf("/etc%c%s", os.PathSeparator, dirName))
viper.SetConfigName("config.toml")
viper.SetEnvPrefix("hasshelper")
} }
func initConfig() { func initConfig() {
viper.Set("version", gitVersion) viper.Set("version", gitVersion)
viper.SetDefault("deployment", "prod") viper.SetDefault("deployment", "prod")
viper.SetDefault("loglevel", "info") viper.SetDefault("loglevel", "info")
viper.SetDefault("kiosk_cmd_shell", "bash")
viper.SetEnvPrefix("HASS")
viper.AutomaticEnv() viper.AutomaticEnv()
if cfgFile != "" { if cfgFile != "" {
viper.SetConfigFile(cfgFile) viper.SetConfigFile(cfgFile)
@ -93,12 +105,18 @@ func initConfig() {
"image_dir", "image_dir",
"version", "version",
"webserver_port", "webserver_port",
"mqtt_broker_url",
"mqtt_broker_user",
"mqtt_broker_password",
"mqtt_presence_topic",
"kiosk_cmd_shell",
"kiosk_cmd_screen_on",
"kiosk_cmd_screen_off",
} }
for _, key := range expected_config { for _, key := range expected_config {
if !viper.IsSet(key) { if !viper.IsSet(key) {
fmt.Fprintf(os.Stderr, "Missing configuration value: %s\n", key) log.Fatalf("Missing configuration value: %s\n", key)
os.Exit(1) os.Exit(1)
} }
} }
rand.Seed(time.Now().UnixNano())
} }

View File

@ -14,6 +14,12 @@ func TestInitConfig(t *testing.T) {
viper.Set("deployment", "testing") viper.Set("deployment", "testing")
viper.Set("image_dir", "/tmp") viper.Set("image_dir", "/tmp")
viper.Set("webserver_port", "8080") viper.Set("webserver_port", "8080")
viper.Set("mqtt_broker_url", "mqtt://example.com:1883")
viper.Set("mqtt_broker_user", "testuser")
viper.Set("mqtt_broker_password", "testpass")
viper.Set("mqtt_presence_topic", "home/foo/test")
viper.Set("kiosk_cmd_screen_on", "echo screen on")
viper.Set("kiosk_cmd_screen_off", "echo screen off")
initConfig() initConfig()
assert.Equal(t, gitVersion, viper.GetString("version"), "config version mismatch") assert.Equal(t, gitVersion, viper.GetString("version"), "config version mismatch")
@ -25,6 +31,12 @@ func TestInitConfigMissingImageDir(t *testing.T) {
viper.Set("deployment", "testing") viper.Set("deployment", "testing")
viper.Set("version", "vTest") viper.Set("version", "vTest")
viper.Set("webserver_port", "8080") viper.Set("webserver_port", "8080")
viper.Set("mqtt_broker_url", "mqtt://example.com:1883")
viper.Set("mqtt_broker_user", "testuser")
viper.Set("mqtt_broker_password", "testpass")
viper.Set("mqtt_presence_topic", "home/foo/test")
viper.Set("kiosk_cmd_screen_on", "echo screen on")
viper.Set("kiosk_cmd_screen_off", "echo screen off")
initConfig() initConfig()
return return

2
go.mod
View File

@ -8,6 +8,8 @@ require (
github.com/cortesi/devd v0.0.0-20200427000907-c1a3bfba27d8 github.com/cortesi/devd v0.0.0-20200427000907-c1a3bfba27d8
github.com/cortesi/modd v0.8.1 github.com/cortesi/modd v0.8.1
github.com/eclipse/paho.mqtt.golang v1.5.0 github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/joho/godotenv v1.5.1
github.com/lmittmann/tint v1.0.5
github.com/mdomke/git-semver/v6 v6.9.0 github.com/mdomke/git-semver/v6 v6.9.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0

4
go.sum
View File

@ -106,6 +106,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
@ -120,6 +122,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=

59
kiosk/kiosk.go Normal file
View File

@ -0,0 +1,59 @@
package kiosk
import (
"context"
"log/slog"
"os/exec"
"github.com/spf13/viper"
)
type handler func(area string, present bool)
type configObj struct {
logger *slog.Logger
broker string
username string
password string
presenceTopic string
shell string
screen_on_cmd string
screen_off_cmd string
}
var config = configObj{}
func Run(rootLogger *slog.Logger, exitch chan bool, ctx context.Context) {
config.logger = rootLogger.With("component", "kiosk")
config.broker = viper.GetString("mqtt_broker_url")
config.username = viper.GetString("mqtt_broker_user")
config.password = viper.GetString("mqtt_broker_password")
config.presenceTopic = viper.GetString("mqtt_presence_topic")
config.shell = viper.GetString("kiosk_cmd_shell")
config.screen_on_cmd = viper.GetString("kiosk_cmd_screen_on")
config.screen_off_cmd = viper.GetString("kiosk_cmd_screen_off")
config.logger.Info("starting MQTT broker client")
brokerConsume(ctx)
exitch <- true
}
func handlePresenceMessage(msg [2]string) {
logger := config.logger
logger.Debug("received presence message", "msg", msg)
cmdStr := ""
switch msg[1] {
case "on":
cmdStr = config.screen_on_cmd
case "off":
cmdStr = config.screen_off_cmd
default:
logger.Warn("recieved unexpected presence message", "msg", msg)
return
}
logger.Info("Presence change event", "presence", msg[1], "command", cmdStr)
cmd := exec.Command(config.shell, "-c", cmdStr)
err := cmd.Run()
if err != nil {
logger.Error("error while executing command", "msg", msg, "command", cmdStr, "err", err)
}
}

53
kiosk/mqtt.go Normal file
View File

@ -0,0 +1,53 @@
package kiosk
import (
"context"
"fmt"
"os"
MQTT "github.com/eclipse/paho.mqtt.golang"
)
func brokerConsume(ctx context.Context) {
logger := config.logger
opts := MQTT.NewClientOptions()
opts.AddBroker(config.broker)
hostname, err := os.Hostname()
if err != nil {
panic(err)
}
messages := make(chan [2]string)
opts.SetClientID(fmt.Sprintf("hasskiosk-%s-%d", hostname, os.Getpid()))
opts.SetUsername(config.username)
opts.SetPassword(config.password)
opts.SetDefaultPublishHandler(func(client MQTT.Client, msg MQTT.Message) {
messages <- [2]string{msg.Topic(), string(msg.Payload())}
})
done := make(chan bool)
logger.Debug("creating client")
client := MQTT.NewClient(opts)
logger.Debug("starting client")
if token := client.Connect(); token.Wait() && token.Error() != nil {
logger.Error("MQTT client startup problem", "err", token.Error())
return
}
defer client.Disconnect(250)
if token := client.Subscribe(config.presenceTopic, 1, nil); token.Wait() && token.Error() != nil {
logger.Error("MQTT subscribe error", "err", token.Error())
return
}
go func() {
for true {
select {
case <-ctx.Done():
logger.Info("Context shutdown. Exiting MQTT loop")
done <- true
return
case msg := <-messages:
go handlePresenceMessage(msg)
}
}
}()
<-done
}

View File

@ -9,6 +9,5 @@
**/*.go .env { **/*.go .env {
prep: go mod tidy prep: go mod tidy
prep: go test @dirmods prep: go test @dirmods
prep: go build daemon +sigterm: go run ./main.go
daemon +sigterm: ./hasshelper -c ../hasshelper.toml
} }

View File

@ -1,7 +0,0 @@
package mqtt
import mqtt "github.com/eclipse/paho.mqtt.golang"
func init() {
mqtt.NewClient()
}

View File

@ -3,10 +3,12 @@ package web
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"time" "time"
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.org/x/net/context"
) )
type configObj struct { type configObj struct {
@ -19,7 +21,7 @@ var config = configObj{}
// Run starts up the webserver part of hasshelper. It writes to exitch if // Run starts up the webserver part of hasshelper. It writes to exitch if
// the webserver ends unexpectedly. Config values will be read from viper. // the webserver ends unexpectedly. Config values will be read from viper.
func Run(rootLogger *slog.Logger, exitch chan bool) { func Run(rootLogger *slog.Logger, exitch chan bool, ctx context.Context) {
rootLogger = rootLogger.With("component", "web") rootLogger = rootLogger.With("component", "web")
config.logger = rootLogger config.logger = rootLogger
config.port = viper.GetInt("webserver_port") config.port = viper.GetInt("webserver_port")
@ -30,15 +32,21 @@ func Run(rootLogger *slog.Logger, exitch chan bool) {
var logger = config.logger var logger = config.logger
logger.Info("Webserver startup", "port", config.port, "imageDir", config.imageDir) logger.Info("Webserver startup", "port", config.port, "imageDir", config.imageDir)
go func() {
addr := fmt.Sprintf(":%d", config.port) server := http.Server{
Addr: fmt.Sprintf(":%d", config.port),
if err := http.ListenAndServe(addr, middleware(http.DefaultServeMux)); err != nil { Handler: middleware(http.DefaultServeMux),
BaseContext: func(net.Listener) context.Context {
return ctx
},
}
if err := server.ListenAndServe(); err != nil {
logger.Error("Webserver fatal error", "err", err) logger.Error("Webserver fatal error", "err", err)
} else { } else {
logger.Info("Webserver shutting down") logger.Info("Webserver shutting down")
} }
exitch <- true exitch <- true
}()
} }
func middleware(next http.Handler) http.Handler { func middleware(next http.Handler) http.Handler {
@ -46,8 +54,7 @@ func middleware(next http.Handler) http.Handler {
logger := config.logger. logger := config.logger.
WithGroup("request"). WithGroup("request").
With("method", r.Method, With("method", r.Method,
"url", r.URL, "url", r.URL)
"remote", r.RemoteAddr)
logger.Info("Starting web request") logger.Info("Starting web request")
start := time.Now() start := time.Now()
next.ServeHTTP(w, r) next.ServeHTTP(w, r)