diff --git a/.gitignore b/.gitignore index 9f4fd54..346b10f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ go.work /cmd/git_version.go /dist .env +screen_off +screen_on diff --git a/cmd/root.go b/cmd/root.go index 3fc47a1..a779e31 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,15 +1,18 @@ package cmd import ( + "context" "fmt" "log" "log/slog" - "math/rand" "os" - "strings" - "time" + "os/signal" + "syscall" + "git.bloy.org/mike/hasshelper/kiosk" "git.bloy.org/mike/hasshelper/web" + _ "github.com/joho/godotenv/autoload" + "github.com/lmittmann/tint" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -22,30 +25,38 @@ var rootCmd = &cobra.Command{ 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) { - logLevel := logLevelVar(viper.GetString("loglevel")) - logger := slog.New(slog.NewTextHandler(os.Stdout, - &slog.HandlerOptions{Level: logLevel})) + var logLevel slog.Level + logLevel.UnmarshalText([]byte(viper.GetString("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")) exitchan := make(chan bool) - web.Run(logger, exitchan) - <-exitchan // run the main command until one of the goroutines is done + signalchan := make(chan os.Signal, 1) + 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 @@ -57,26 +68,27 @@ func Execute() { } 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() + const dirName = "hasshelper" + var defDir = fmt.Sprintf("%s%c%s", "/etc", os.PathSeparator, dirName) if err == nil { - viper.AddConfigPath( - fmt.Sprintf("%s%c%s", userConfigDir, os.PathSeparator, dirName)) + defDir = fmt.Sprintf("%s%c%s", userConfigDir, os.PathSeparator, dirName) + viper.AddConfigPath(defDir) } else { 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.SetConfigName("config.toml") - viper.SetEnvPrefix("hasshelper") } func initConfig() { viper.Set("version", gitVersion) viper.SetDefault("deployment", "prod") viper.SetDefault("loglevel", "info") + viper.SetDefault("kiosk_cmd_shell", "bash") + viper.SetEnvPrefix("HASS") viper.AutomaticEnv() if cfgFile != "" { viper.SetConfigFile(cfgFile) @@ -93,12 +105,18 @@ func initConfig() { "image_dir", "version", "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 { 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) } } - rand.Seed(time.Now().UnixNano()) } diff --git a/cmd/root_test.go b/cmd/root_test.go index 0f512a0..434e216 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -14,6 +14,12 @@ func TestInitConfig(t *testing.T) { viper.Set("deployment", "testing") viper.Set("image_dir", "/tmp") 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() 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("version", "vTest") 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() return diff --git a/go.mod b/go.mod index c26d792..0228c4b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( github.com/cortesi/devd v0.0.0-20200427000907-c1a3bfba27d8 github.com/cortesi/modd v0.8.1 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/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 diff --git a/go.sum b/go.sum index 1d98eae..7db1df9 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 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.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= diff --git a/kiosk/kiosk.go b/kiosk/kiosk.go new file mode 100644 index 0000000..05fa57b --- /dev/null +++ b/kiosk/kiosk.go @@ -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) + } +} diff --git a/kiosk/mqtt.go b/kiosk/mqtt.go new file mode 100644 index 0000000..cdc97ce --- /dev/null +++ b/kiosk/mqtt.go @@ -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 +} diff --git a/modd.conf b/modd.conf index 1258eba..47cfce8 100644 --- a/modd.conf +++ b/modd.conf @@ -9,6 +9,5 @@ **/*.go .env { prep: go mod tidy prep: go test @dirmods - prep: go build - daemon +sigterm: ./hasshelper -c ../hasshelper.toml + daemon +sigterm: go run ./main.go } diff --git a/mqtt/mqtt.go b/mqtt/mqtt.go deleted file mode 100644 index 4d563b5..0000000 --- a/mqtt/mqtt.go +++ /dev/null @@ -1,7 +0,0 @@ -package mqtt - -import mqtt "github.com/eclipse/paho.mqtt.golang" - -func init() { - mqtt.NewClient() -} diff --git a/web/server.go b/web/server.go index 5197e14..bb8151a 100644 --- a/web/server.go +++ b/web/server.go @@ -3,10 +3,12 @@ package web import ( "fmt" "log/slog" + "net" "net/http" "time" "github.com/spf13/viper" + "golang.org/x/net/context" ) type configObj struct { @@ -19,7 +21,7 @@ var config = configObj{} // Run starts up the webserver part of hasshelper. It writes to exitch if // 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") config.logger = rootLogger config.port = viper.GetInt("webserver_port") @@ -30,15 +32,21 @@ func Run(rootLogger *slog.Logger, exitch chan bool) { var logger = config.logger logger.Info("Webserver startup", "port", config.port, "imageDir", config.imageDir) - - addr := fmt.Sprintf(":%d", config.port) - - if err := http.ListenAndServe(addr, middleware(http.DefaultServeMux)); err != nil { - logger.Error("Webserver fatal error", "err", err) - } else { - logger.Info("Webserver shutting down") - } - exitch <- true + go func() { + server := http.Server{ + Addr: fmt.Sprintf(":%d", config.port), + 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) + } else { + logger.Info("Webserver shutting down") + } + exitch <- true + }() } func middleware(next http.Handler) http.Handler { @@ -46,8 +54,7 @@ func middleware(next http.Handler) http.Handler { logger := config.logger. WithGroup("request"). With("method", r.Method, - "url", r.URL, - "remote", r.RemoteAddr) + "url", r.URL) logger.Info("Starting web request") start := time.Now() next.ServeHTTP(w, r)