github.com/martinohmann/rfoutlet@v1.2.1-0.20220707195255-8a66aa411105/cmd/serve.go (about)

     1  package cmd
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net/http"
     7  	"os"
     8  	"os/signal"
     9  	"syscall"
    10  	"time"
    11  
    12  	"github.com/gin-contrib/cors"
    13  	"github.com/gin-gonic/gin"
    14  	"github.com/gobuffalo/packr"
    15  	"github.com/imdario/mergo"
    16  	"github.com/martinohmann/rfoutlet/internal/command"
    17  	"github.com/martinohmann/rfoutlet/internal/config"
    18  	"github.com/martinohmann/rfoutlet/internal/controller"
    19  	"github.com/martinohmann/rfoutlet/internal/outlet"
    20  	"github.com/martinohmann/rfoutlet/internal/statedrift"
    21  	"github.com/martinohmann/rfoutlet/internal/timeswitch"
    22  	"github.com/martinohmann/rfoutlet/internal/websocket"
    23  	"github.com/martinohmann/rfoutlet/pkg/gpio"
    24  	log "github.com/sirupsen/logrus"
    25  	"github.com/spf13/cobra"
    26  )
    27  
    28  const webDir = "../web/build"
    29  
    30  func NewServeCommand() *cobra.Command {
    31  	options := &ServeOptions{
    32  		ConfigFilename: "/etc/rfoutlet/config.yml",
    33  	}
    34  
    35  	cmd := &cobra.Command{
    36  		Use:   "serve",
    37  		Short: "Serve the frontend for controlling outlets",
    38  		Long:  "The serve command starts a server which serves the frontend and connects clients through websockets for controlling outlets via web interface.",
    39  		RunE: func(cmd *cobra.Command, _ []string) error {
    40  			return options.Run(cmd)
    41  		},
    42  	}
    43  
    44  	options.AddFlags(cmd)
    45  
    46  	return cmd
    47  }
    48  
    49  type ServeOptions struct {
    50  	config.Config
    51  	ConfigFilename string
    52  }
    53  
    54  func (o *ServeOptions) AddFlags(cmd *cobra.Command) {
    55  	cmd.Flags().StringVar(&o.ConfigFilename, "config", o.ConfigFilename, "path to the outlet config file")
    56  	cmd.Flags().StringVar(&o.StateFile, "state-file", o.StateFile, "path to the file where outlet state and schedule should be stored")
    57  	cmd.Flags().StringVar(&o.ListenAddress, "listen-address", o.ListenAddress, "address to serve the web app on")
    58  	cmd.Flags().BoolVar(&o.DetectStateDrift, "detect-state-drift", o.DetectStateDrift, "detect state drift (e.g. if an outlet was switched via the phyical remote instead of rfoutlet)")
    59  	cmd.Flags().UintVar(&o.GPIO.TransmitPin, "transmit-pin", o.GPIO.TransmitPin, "gpio pin to transmit rf codes on")
    60  	cmd.Flags().UintVar(&o.GPIO.ReceivePin, "receive-pin", o.GPIO.ReceivePin, "gpio pin to receive rf codes on (this is used by the state drift detector)")
    61  	cmd.Flags().IntVar(&o.GPIO.TransmissionCount, "transmission-count", o.GPIO.TransmissionCount, "number of times a code should be transmitted in a row. The higher the value, the more likely it is that an outlet actually received the code")
    62  }
    63  
    64  func (o *ServeOptions) Run(cmd *cobra.Command) error {
    65  	cfg, err := config.LoadWithDefaults(o.ConfigFilename)
    66  	if err != nil {
    67  		return fmt.Errorf("failed to load config: %v", err)
    68  	}
    69  
    70  	err = mergo.Merge(cfg, o.Config, mergo.WithOverride)
    71  	if err != nil {
    72  		return fmt.Errorf("failed to merge config values: %v", err)
    73  	}
    74  
    75  	log.Debugf("merged config values: %#v", cfg)
    76  
    77  	registry := outlet.NewRegistry()
    78  
    79  	err = registry.RegisterGroups(cfg.BuildOutletGroups()...)
    80  	if err != nil {
    81  		return fmt.Errorf("failed to register outlet groups: %v", err)
    82  	}
    83  
    84  	device, err := openGPIODevice(cmd)
    85  	if err != nil {
    86  		return err
    87  	}
    88  	defer device.Close()
    89  
    90  	transmitter, err := gpio.NewTransmitter(device.Chip, int(cfg.GPIO.TransmitPin), gpio.TransmissionCount(cfg.GPIO.TransmissionCount))
    91  	if err != nil {
    92  		return fmt.Errorf("failed to create gpio transmitter: %v", err)
    93  	}
    94  	defer transmitter.Close()
    95  
    96  	if cfg.StateFile != "" {
    97  		log := log.WithField("stateFile", cfg.StateFile)
    98  
    99  		stateFile := outlet.NewStateFile(cfg.StateFile)
   100  
   101  		log.Debug("loading outlet states")
   102  
   103  		err := stateFile.ReadBack(registry.GetOutlets())
   104  		if err != nil && !os.IsNotExist(err) {
   105  			return fmt.Errorf("failed to load outlet states: %v", err)
   106  		}
   107  
   108  		defer func() {
   109  			log.Info("saving outlet states")
   110  
   111  			err := stateFile.WriteOut(registry.GetOutlets())
   112  			if err != nil {
   113  				log.Errorf("failed to save state: %v", err)
   114  			}
   115  		}()
   116  	}
   117  
   118  	ctx, cancel := context.WithCancel(context.Background())
   119  	defer cancel()
   120  
   121  	stopCh := ctx.Done()
   122  	commandQueue := make(chan command.Command)
   123  
   124  	if cfg.DetectStateDrift {
   125  		receiver, err := gpio.NewReceiver(device.Chip, int(cfg.GPIO.ReceivePin))
   126  		if err != nil {
   127  			return fmt.Errorf("failed to create gpio receiver: %v", err)
   128  		}
   129  		defer receiver.Close()
   130  
   131  		detector := statedrift.NewDetector(registry, receiver, commandQueue)
   132  
   133  		go detector.Run(stopCh)
   134  	}
   135  
   136  	hub := websocket.NewHub()
   137  
   138  	controller := controller.Controller{
   139  		Registry:     registry,
   140  		Switcher:     outlet.NewSwitch(transmitter),
   141  		Broadcaster:  hub,
   142  		CommandQueue: commandQueue,
   143  	}
   144  
   145  	timeSwitch := timeswitch.New(registry, commandQueue)
   146  
   147  	go handleSignals(cancel)
   148  	go controller.Run(stopCh)
   149  	go timeSwitch.Run(stopCh)
   150  	go hub.Run(stopCh)
   151  
   152  	router := setupRouter(hub, commandQueue)
   153  
   154  	return listenAndServe(stopCh, router, cfg.ListenAddress)
   155  }
   156  
   157  func setupRouter(hub *websocket.Hub, commandQueue chan<- command.Command) http.Handler {
   158  	r := gin.New()
   159  	r.Use(gin.Recovery(), gin.Logger(), cors.Default())
   160  	r.GET("/", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/app") })
   161  	r.GET("/ws", websocket.Handler(hub, commandQueue))
   162  	r.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
   163  	r.StaticFS("/app", packr.NewBox(webDir))
   164  
   165  	return r
   166  }
   167  
   168  func listenAndServe(stopCh <-chan struct{}, handler http.Handler, addr string) error {
   169  	srv := &http.Server{
   170  		Addr:    addr,
   171  		Handler: handler,
   172  	}
   173  
   174  	go func() {
   175  		log.Infof("listening on %s", addr)
   176  
   177  		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
   178  			log.Fatalf("Listen: %s\n", err)
   179  		}
   180  	}()
   181  
   182  	<-stopCh
   183  
   184  	log.Info("shutting down server...")
   185  
   186  	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   187  	defer cancel()
   188  
   189  	return srv.Shutdown(ctx)
   190  }
   191  
   192  func handleSignals(cancel func()) {
   193  	quit := make(chan os.Signal, 1)
   194  	signal.Notify(quit, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT)
   195  	sig := <-quit
   196  	log.WithField("signal", sig).Info("received signal, shutting down...")
   197  	cancel()
   198  }