From cb0254c6d06aaef2e8b4576e6ae6098ac9ec6a73 Mon Sep 17 00:00:00 2001 From: jon Date: Sun, 16 Jun 2024 21:54:41 +0200 Subject: [PATCH] Replaced log with slog, added debug levels and an option to have semi-structured logs in json Added graceful shutdown for MQTT and HTTP, just letting the main process die might lead to http not releasing the port Changed the connection to MQTT a bit and added waitgroups for the go routines The for loop in server.go was blocking so the function never listened for signals to shut down Added a new debug endpoint which prints out a semi-decent configuration for lovelace-xiaomi-vacuum-map-card --- pkg/config/config.go | 43 ++++++- pkg/mqtt/consumer.go | 52 ++++++-- pkg/mqtt/mqtt.go | 16 ++- pkg/mqtt/producer.go | 128 +++++++++++++------ pkg/renderer/drawer.go | 17 ++- pkg/renderer/renderer.go | 5 +- pkg/renderer/result.go | 3 +- pkg/renderer/xiaomi_map_card_config.go | 165 +++++++++++++++++++++++++ pkg/server/http.go | 48 ++++++- pkg/server/server.go | 104 ++++++++++------ 10 files changed, 479 insertions(+), 102 deletions(-) create mode 100644 pkg/renderer/xiaomi_map_card_config.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 2630ae1..f71a7e6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "errors" + "log/slog" "os" "time" @@ -12,6 +13,7 @@ type MQTTConfig struct { Connection *ConnectionConfig `yaml:"connection"` Topics *TopicsConfig `yaml:"topics"` ImageAsBase64 bool `yaml:"image_as_base64"` + SendTimeout time.Duration `yaml:"send_timeout"` } type HTTPConfig struct { @@ -59,9 +61,11 @@ type MapConfig struct { } type Config struct { - Mqtt *MQTTConfig `yaml:"mqtt"` - HTTP *HTTPConfig `yaml:"http"` - Map *MapConfig `yaml:"map"` + Mqtt *MQTTConfig `yaml:"mqtt"` + HTTP *HTTPConfig `yaml:"http"` + Map *MapConfig `yaml:"map"` + LogLevel string `yaml:"logLevel"` + LogType string `yaml:"logType"` } func NewConfig(configFile string) (*Config, error) { @@ -131,6 +135,11 @@ func validate(c *Config) (*Config, error) { return nil, errors.New("missing mqtt.topics section") } + // Set default timeout for messages being published if it's not set + if c.Mqtt.SendTimeout == 0 { + c.Mqtt.SendTimeout = 10 * time.Second + } + // Check MQTT topics section if c.Mqtt.Topics.ValetudoIdentifier == "" { return nil, errors.New("missing mqtt.topics.valetudo_identifier value") @@ -150,6 +159,34 @@ func validate(c *Config) (*Config, error) { return nil, errors.New("invalid map.png_compression value") } + // Check logging + lvl := new(slog.LevelVar) + switch c.LogLevel { + case "debug": + lvl.Set(slog.LevelDebug) + case "info": + lvl.Set(slog.LevelInfo) + case "warn": + lvl.Set(slog.LevelWarn) + case "error": + lvl.Set(slog.LevelError) + default: + lvl.Set(slog.LevelInfo) + } + if c.LogType == "json" { + logger := slog.New( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: lvl, + })) + slog.SetDefault(logger) + + } else { + logger := slog.New( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: lvl, + })) + slog.SetDefault(logger) + } // Everything else should fail when used (e.g. wrong IP/port will cause // fatal error when starting http server) diff --git a/pkg/mqtt/consumer.go b/pkg/mqtt/consumer.go index 0e143bb..932f758 100644 --- a/pkg/mqtt/consumer.go +++ b/pkg/mqtt/consumer.go @@ -1,7 +1,9 @@ package mqtt import ( - "log" + "context" + "log/slog" + "sync" "time" mqttgo "github.com/eclipse/paho.mqtt.golang" @@ -9,14 +11,15 @@ import ( "github.com/erkexzcx/valetudopng/pkg/mqtt/decoder" ) -func startConsumer(c *config.MQTTConfig, mapJSONChan chan []byte) { +func startConsumer(ctx context.Context, wg *sync.WaitGroup, panic chan bool, c *config.MQTTConfig, mapJSONChan chan []byte) { + defer wg.Done() opts := mqttgo.NewClientOptions() if c.Connection.TLSEnabled { opts.AddBroker("ssl://" + c.Connection.Host + ":" + c.Connection.Port) tlsConfig, err := newTLSConfig(c.Connection.TLSCaPath, c.Connection.TLSInsecure, c.Connection.TLSMinVersion) if err != nil { - log.Fatalln(err) + panic <- true } opts.SetTLSConfig(tlsConfig) } else { @@ -36,34 +39,59 @@ func startConsumer(c *config.MQTTConfig, mapJSONChan chan []byte) { // On connection opts.OnConnect = func(client mqttgo.Client) { - log.Println("[MQTT consumer] Connected") - token := client.Subscribe(c.Topics.ValetudoPrefix+"/"+c.Topics.ValetudoIdentifier+"/MapData/map-data", 1, nil) - token.Wait() - log.Println("[MQTT consumer] Subscribed to map data topic") + slog.Info("[MQTT consumer] Connected") } // On disconnection opts.OnConnectionLost = func(client mqttgo.Client, err error) { - log.Printf("[MQTT consumer] Connection lost: %v", err) + slog.Error("[MQTT consumer] Connection lost", slog.String("error", err.Error())) } // Initial connection client := mqttgo.NewClient(opts) - for { + + const maxTries = 24 + success := false + for i := 1; i <= maxTries; i++ { // try to connect for 2 minutes, then give up if token := client.Connect(); token.Wait() && token.Error() != nil { - log.Printf("[MQTT consumer] Failed to connect: %v. Retrying in 5 seconds...\n", token.Error()) + slog.Error("[MQTT consumer] Failed to connect to MQTT, trying again in 5s", slog.Int("tries left", maxTries-i), slog.String("type", "consumer"), slog.String("error", token.Error().Error())) time.Sleep(5 * time.Second) } else { + success = true break } } + if !success { + slog.Error("[MQTT consumer] failed to connect to MQTT") + panic <- true + return + } + + topic := c.Topics.ValetudoPrefix + "/" + c.Topics.ValetudoIdentifier + "/MapData/map-data" + slog.Info("[MQTT consumer] Subscribing to topic", slog.String("topic", topic)) + token := client.Subscribe(topic, 1, nil) + token.Wait() + slog.Info("[MQTT consumer] Subscribed to map data topic") + +DONE: + for { + select { + case <-ctx.Done(): + break DONE + case <-panic: + break DONE + } + } + client.Disconnect(0) + slog.Info("[MQTT consumer] shutdown") } -func consumerMapDataReceiveHandler(client mqttgo.Client, msg mqttgo.Message, mapJSONChan chan []byte) { +func consumerMapDataReceiveHandler(_ mqttgo.Client, msg mqttgo.Message, mapJSONChan chan []byte) { payload, err := decoder.Decode(msg.Payload()) if err != nil { - log.Println("[MQTT consumer] Failed to process raw data:", err) + slog.Error("[MQTT consumer] Failed to parse MQTT message", slog.String("error", err.Error())) return } + slog.Debug("[MQTT consumer] Received message") mapJSONChan <- payload } diff --git a/pkg/mqtt/mqtt.go b/pkg/mqtt/mqtt.go index 4939502..9405bda 100644 --- a/pkg/mqtt/mqtt.go +++ b/pkg/mqtt/mqtt.go @@ -1,10 +1,20 @@ package mqtt import ( + "context" + "log/slog" + "sync" + "github.com/erkexzcx/valetudopng/pkg/config" ) -func Start(c *config.MQTTConfig, mapJSONChan, renderedMapChan, calibrationDataChan chan []byte) { - go startConsumer(c, mapJSONChan) - go startProducer(c, renderedMapChan, calibrationDataChan) +func Start(ctx context.Context, pwg *sync.WaitGroup, panic chan bool, c *config.MQTTConfig, mapJSONChan, renderedMapChan, calibrationDataChan chan []byte) { + defer pwg.Done() + + var wg sync.WaitGroup + wg.Add(2) + go startConsumer(ctx, &wg, panic, c, mapJSONChan) + go startProducer(ctx, &wg, panic, c, renderedMapChan, calibrationDataChan) + wg.Wait() + slog.Info("MQTT shutting down") } diff --git a/pkg/mqtt/producer.go b/pkg/mqtt/producer.go index 208ef1f..ec7b429 100644 --- a/pkg/mqtt/producer.go +++ b/pkg/mqtt/producer.go @@ -1,7 +1,9 @@ package mqtt import ( - "log" + "context" + "log/slog" + "sync" "time" "github.com/bitly/go-simplejson" @@ -21,14 +23,16 @@ type Map struct { Topic string `json:"topic"` } -func startProducer(c *config.MQTTConfig, renderedMapChan, calibrationDataChan chan []byte) { +func startProducer(ctx context.Context, pwg *sync.WaitGroup, panic chan bool, c *config.MQTTConfig, renderedMapChan, calibrationDataChan chan []byte) { + defer pwg.Done() opts := mqttgo.NewClientOptions() if c.Connection.TLSEnabled { opts.AddBroker("ssl://" + c.Connection.Host + ":" + c.Connection.Port) tlsConfig, err := newTLSConfig(c.Connection.TLSCaPath, c.Connection.TLSInsecure, c.Connection.TLSMinVersion) if err != nil { - log.Fatalln(err) + slog.Error("Failed to setup TLS config", slog.String("error", err.Error())) + panic <- true } opts.SetTLSConfig(tlsConfig) } else { @@ -42,45 +46,83 @@ func startProducer(c *config.MQTTConfig, renderedMapChan, calibrationDataChan ch // On connection opts.OnConnect = func(client mqttgo.Client) { - log.Println("[MQTT producer] Connected") + slog.Info("[MQTT producer] Connected") } // On disconnection opts.OnConnectionLost = func(client mqttgo.Client, err error) { - log.Printf("[MQTT producer] Connection lost: %v", err) + slog.Error("[MQTT producer] Connection lost: %v", slog.String("error", err.Error())) } // Initial connection client := mqttgo.NewClient(opts) - for { + const maxTries = 24 + success := false + for i := 1; i <= maxTries; i++ { // try to connect for 2 minutes, then give up if token := client.Connect(); token.Wait() && token.Error() != nil { - log.Printf("[MQTT producer] Failed to connect: %v. Retrying in 5 seconds...\n", token.Error()) + slog.Error("[MQTT producer] Failed to connect to MQTT, trying again in 5s", slog.Int("tries left", maxTries-i), slog.String("type", "consumer"), slog.String("error", token.Error().Error())) time.Sleep(5 * time.Second) } else { + success = true break } } - + if !success { + slog.Error("Publisher failed to connect to MQTT") + panic <- true + return + } + var wg sync.WaitGroup + wg.Add(2) renderedMapTopic := c.Topics.ValetudoPrefix + "/" + c.Topics.ValetudoIdentifier + "/MapData/map" - go produceAnnounceMapTopic(client, renderedMapTopic, c) - go producerMapUpdatesHandler(client, renderedMapChan, renderedMapTopic) + go produceAnnounceMapTopic(&wg, client, renderedMapTopic, c) + go producerMapUpdatesHandler(ctx, &wg, panic, client, renderedMapChan, renderedMapTopic, c) + wg.Add(2) calibrationTopic := c.Topics.ValetudoPrefix + "/" + c.Topics.ValetudoIdentifier + "/MapData/calibration" - go producerAnnounceCalibrationTopic(client, calibrationTopic, c) - go producerCalibrationDataHandler(client, calibrationDataChan, calibrationTopic) + go producerAnnounceCalibrationTopic(&wg, client, calibrationTopic, c) + go producerCalibrationDataHandler(ctx, &wg, panic, client, calibrationDataChan, calibrationTopic, c) + + done := make(chan bool) + go func() { + wg.Wait() + done <- true + }() +DONE: + for { + select { + case <-done: + break DONE + case <-panic: + break DONE + case <-ctx.Done(): + break DONE + } + } + slog.Info("[MQTT producer] shutdown") } -func producerMapUpdatesHandler(client mqttgo.Client, renderedMapChan chan []byte, topic string) { - for img := range renderedMapChan { - token := client.Publish(topic, 1, true, img) - token.Wait() - if token.Error() != nil { - log.Printf("[MQTT producer] Failed to publish: %v\n", token.Error()) +func producerMapUpdatesHandler(ctx context.Context, wg *sync.WaitGroup, panic chan bool, client mqttgo.Client, renderedMapChan chan []byte, topic string, c *config.MQTTConfig) { + defer wg.Done() + for { + select { + case img := <-renderedMapChan: + token := client.Publish(topic, 1, true, img) + if ok := token.WaitTimeout(c.SendTimeout); !ok || token.Error() != nil { + slog.Error("[MQTT producer] Failed to publish", slog.String("error", token.Error().Error())) + } else { + slog.Debug("[MQTT producer] published a message") + } + case <-ctx.Done(): + return + case <-panic: + return } } } -func produceAnnounceMapTopic(client mqttgo.Client, rmt string, c *config.MQTTConfig) { +func produceAnnounceMapTopic(wg *sync.WaitGroup, client mqttgo.Client, rmt string, c *config.MQTTConfig) { + defer wg.Done() announceTopic := c.Topics.HaAutoconfPrefix + "/camera/" + c.Topics.ValetudoIdentifier + "/" + c.Topics.ValetudoPrefix + "_" + c.Topics.ValetudoIdentifier + "_map/config" js := simplejson.New() @@ -95,28 +137,40 @@ func produceAnnounceMapTopic(client mqttgo.Client, rmt string, c *config.MQTTCon js.Set("device", device) announcementData, err := js.MarshalJSON() - if err != nil { - panic(err) + if err != nil { // this isn't really possible + slog.Error("[MQTT producer] failed to parse annoucement") + return } token := client.Publish(announceTopic, 1, true, announcementData) - token.Wait() - if token.Error() != nil { - log.Printf("[MQTT producer] Failed to publish: %v\n", token.Error()) + if ok := token.WaitTimeout(c.SendTimeout); !ok || token.Error() != nil { + slog.Error("[MQTT producer] Failed to publish", slog.String("error", token.Error().Error())) + } else { + slog.Debug("[MQTT producer] published AnnounceMapTopic") } } -func producerCalibrationDataHandler(client mqttgo.Client, calibrationDataChan chan []byte, topic string) { - for img := range calibrationDataChan { - token := client.Publish(topic, 1, true, img) - token.Wait() - if token.Error() != nil { - log.Printf("[MQTT producer] Failed to publish: %v\n", token.Error()) +func producerCalibrationDataHandler(ctx context.Context, wg *sync.WaitGroup, panic chan bool, client mqttgo.Client, calibrationDataChan chan []byte, topic string, c *config.MQTTConfig) { + defer wg.Done() + for { + select { + case img := <-calibrationDataChan: + token := client.Publish(topic, 1, true, img) + if ok := token.WaitTimeout(c.SendTimeout); !ok || token.Error() != nil { + slog.Error("[MQTT producer] Failed to publish", slog.String("error", token.Error().Error())) + } else { + slog.Debug("[MQTT producer] published a message") + } + case <-ctx.Done(): + return + case <-panic: + return } } } -func producerAnnounceCalibrationTopic(client mqttgo.Client, cdt string, c *config.MQTTConfig) { +func producerAnnounceCalibrationTopic(wg *sync.WaitGroup, client mqttgo.Client, cdt string, c *config.MQTTConfig) { + defer wg.Done() announceTopic := c.Topics.HaAutoconfPrefix + "/sensor/" + c.Topics.ValetudoIdentifier + "/" + c.Topics.ValetudoPrefix + "_" + c.Topics.ValetudoIdentifier + "_calibration/config" js := simplejson.New() @@ -131,13 +185,15 @@ func producerAnnounceCalibrationTopic(client mqttgo.Client, cdt string, c *confi js.Set("device", device) announcementData, err := js.MarshalJSON() - if err != nil { - panic(err) + if err != nil { // this isn't really possible + slog.Error("[MQTT producer] failed to parse CalibrationTopic annoucement") + return } token := client.Publish(announceTopic, 1, true, announcementData) - token.Wait() - if token.Error() != nil { - log.Printf("[MQTT producer] Failed to publish: %v\n", token.Error()) + if ok := token.WaitTimeout(c.SendTimeout); !ok || token.Error() != nil { + slog.Error("[MQTT producer] Failed to publish", slog.String("error", token.Error().Error())) + } else { + slog.Debug("[MQTT producer] published AnnounceCalibrationTopic") } } diff --git a/pkg/renderer/drawer.go b/pkg/renderer/drawer.go index 37c569c..ea56b43 100644 --- a/pkg/renderer/drawer.go +++ b/pkg/renderer/drawer.go @@ -23,6 +23,8 @@ type valetudoImage struct { // JSON data valetudoJSON *ValetudoJSON + xiaomiMapCardConfig *XiaomiMapCardConfig + // Renderer reference, for easy access renderer *Renderer @@ -49,8 +51,9 @@ type valetudoImage struct { func newValetudoImage(valetudoJSON *ValetudoJSON, r *Renderer) *valetudoImage { // Create new object vi := &valetudoImage{ - valetudoJSON: valetudoJSON, - renderer: r, + valetudoJSON: valetudoJSON, + renderer: r, + xiaomiMapCardConfig: newMapConf(r), } // Prepare layers and entities (to speed up iterations) @@ -63,7 +66,12 @@ func newValetudoImage(valetudoJSON *ValetudoJSON, r *Renderer) *valetudoImage { } else { vi.layers[layer.Type] = append(vi.layers[layer.Type], layer) } + if layer.Type == "segment" { + vi.xiaomiMapCardConfig.addSegment(&layer.MetaData, layer.Dimensions) + } } + vi.xiaomiMapCardConfig.setMapModes() + for _, entity := range vi.valetudoJSON.Entities { _, found := vi.entities[entity.Type] if !found { @@ -72,7 +80,6 @@ func newValetudoImage(valetudoJSON *ValetudoJSON, r *Renderer) *valetudoImage { vi.entities[entity.Type] = append(vi.entities[entity.Type], entity) } } - // Load colors for each segment vi.segmentColor = make(map[string]color.RGBA) vi.findFourColors(r.settings.SegmentColors) @@ -261,3 +268,7 @@ func (vi *valetudoImage) getRotationFunc(subtractOne bool) rotationFunc { } return func(x, y int) (int, int) { return x, y } } + +func (vi *valetudoImage) YamlConf() []byte { + return vi.xiaomiMapCardConfig.asYaml() +} diff --git a/pkg/renderer/renderer.go b/pkg/renderer/renderer.go index 59319d7..f8bb042 100644 --- a/pkg/renderer/renderer.go +++ b/pkg/renderer/renderer.go @@ -16,6 +16,7 @@ type Renderer struct { assetRobot map[int]image.Image assetCharger image.Image settings *Settings + conf *config.Config } type Settings struct { @@ -35,7 +36,7 @@ type Settings struct { SegmentColors []color.RGBA } -func New(s *Settings) *Renderer { +func New(s *Settings, c *config.Config) *Renderer { switch s.PNGCompression { case 0: pngEncoder.CompressionLevel = png.BestSpeed @@ -49,6 +50,7 @@ func New(s *Settings) *Renderer { r := &Renderer{ settings: s, + conf: c, } loadAssetRobot(r) loadAssetCharger(r) @@ -82,6 +84,7 @@ func (r *Renderer) Render(data []byte, mc *config.MapConfig) (*Result, error) { Settings: vi.renderer.settings, Calibration: vi.getCalibrationPointsJSON(), PixelSize: vi.valetudoJSON.PixelSize, + CardCfg: vi.YamlConf(), }, nil } diff --git a/pkg/renderer/result.go b/pkg/renderer/result.go index 3d74c45..c24eb67 100644 --- a/pkg/renderer/result.go +++ b/pkg/renderer/result.go @@ -15,7 +15,8 @@ type Result struct { RobotCoords *RbtCoords Settings *Settings Calibration []byte - PixelSize int // taken from JSON, for traslating image coords to robot's coords system coordinates + PixelSize int // taken from JSON, for traslating image coords to robot's coords system coordinates + CardCfg []byte // generated configuration for lovelace card } type ImgSize struct { diff --git a/pkg/renderer/xiaomi_map_card_config.go b/pkg/renderer/xiaomi_map_card_config.go new file mode 100644 index 0000000..4766440 --- /dev/null +++ b/pkg/renderer/xiaomi_map_card_config.go @@ -0,0 +1,165 @@ +package renderer + +import ( + "bytes" + + "gopkg.in/yaml.v2" +) + +// XiaomiMapCardConfig is based on https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card/blob/master/docs/demo_config.yaml +type XiaomiMapCardConfig struct { + Type string `yaml:"type"` + Title string `yaml:"title"` + PresetName string `yaml:"preset_name"` + Entity string `yaml:"entity"` + MapLocked bool `yaml:"map_locked"` + TwoFingerPan bool `yaml:"two_finger_pan"` + MapSource MapSource `yaml:"map_source"` + InternalVariables InternalVariables `yaml:"internal_variables"` + CalibrationSource CalibrationSource `yaml:"calibration_source"` + MapModes []MapMode `yaml:"map_modes"` + AdditionalPresets []AdditionalPreset `yaml:"additional_presets,omitempty"` + segments []segment +} + +type MapSource struct { + Camera string `yaml:"camera"` +} + +type CalibrationSource struct { + Camera *bool `yaml:"camera,omitempty"` + Entity string `yaml:"entity,omitempty"` +} + +type InternalVariables struct { + Topics string `yaml:"topics,omitempty"` +} + +type MapMode struct { + Template string `yaml:"template,omitempty"` + PredefinedSelections []PredefinedSelection `yaml:"predefined_selections,omitempty"` +} + +type PredefinedSelection struct { + Zones [][]int `yaml:"zones,omitempty"` + Label Label `yaml:"label"` + Icon Icon `yaml:"icon"` + ID string `yaml:"id,omitempty"` + Outline [][]int `yaml:"outline,omitempty"` + Position []int `yaml:"position,omitempty"` +} + +type Label struct { + Text string `yaml:"text"` + X int `yaml:"x"` + Y int `yaml:"y"` + OffsetY int `yaml:"offset_y"` +} + +type Icon struct { + Name string `yaml:"name"` + X int `yaml:"x,omitempty"` + Y int `yaml:"y,omitempty"` +} +type AdditionalPreset struct { + Name string `yaml:"name"` + Icon string `yaml:"icon"` + SelectionType string `yaml:"selection_type"` + MaxSelections int `yaml:"max_selections"` + RepeatsType string `yaml:"repeats_type"` + MaxRepeats int `yaml:"max_repeats"` + ServiceCallSchema ServiceCallSchema `yaml:"service_call_schema"` + PredefinedSelections []PredefinedSelection `yaml:"predefined_selections,omitempty"` +} + +type ServiceCallSchema struct { + Service string `yaml:"service"` + ServiceData ServiceData `yaml:"service_data"` + Target Target `yaml:"target"` +} + +type ServiceData struct { + Path string `yaml:"path,omitempty"` + Repeats string `yaml:"repeats,omitempty"` + Predefined string `yaml:"predefined,omitempty"` + Point string `yaml:"point,omitempty"` + PointX string `yaml:"point_x,omitempty"` + PointY string `yaml:"point_y,omitempty"` +} + +type Target struct { + EntityID string `yaml:"entity_id"` +} + +type segment struct { + name string + id string + d Dimensions +} + +func newMapConf(r *Renderer) *XiaomiMapCardConfig { + name := r.conf.Mqtt.Topics.ValetudoIdentifier + return &XiaomiMapCardConfig{ + Type: "custom:xiaomi-vacuum-map-card", + Title: "Xiaomi Vacuum Map Card", + PresetName: "Live map", + Entity: "vacuum.valetudo_" + name, + MapSource: MapSource{"camera." + name + "_map"}, + CalibrationSource: CalibrationSource{Entity: "sensor." + name + "_calibration"}, + InternalVariables: InternalVariables{"valetudo/" + name}, + segments: make([]segment, 0, 3), + MapLocked: true, + } +} + +func (x *XiaomiMapCardConfig) addSegment(m *MetaData, d Dimensions) { + if m == nil || m.SegmentId == "" || m.Name == "" { + return + } + x.segments = append(x.segments, segment{ + name: m.Name, + id: m.SegmentId, + d: d, + }) +} + +func (x *XiaomiMapCardConfig) setMapModes() { + x.MapModes = make([]MapMode, 0, 4) + + if len(x.segments) > 0 { + m := MapMode{ + Template: "vacuum_clean_segment", + PredefinedSelections: make([]PredefinedSelection, 0, len(x.segments)), + } + + for _, s := range x.segments { + m.PredefinedSelections = append(m.PredefinedSelections, PredefinedSelection{ + ID: s.id, + Label: Label{ + Text: s.name, + X: s.d.X.Mid*5 - 20, + Y: s.d.Y.Mid*5 - 50, + }, + Icon: Icon{ + Name: "mdi:broom", + X: s.d.X.Mid * 5, + Y: s.d.Y.Mid * 5, + }, + }) + } + + x.MapModes = append(x.MapModes, m) + } + + x.MapModes = append(x.MapModes, + MapMode{Template: "vacuum_goto"}, + MapMode{Template: "vacuum_clean_zone"}, + MapMode{Template: "vacuum_goto_predefined"}, + ) +} + +func (x *XiaomiMapCardConfig) asYaml() []byte { + reqBodyBytes := new(bytes.Buffer) + yaml.NewEncoder(reqBodyBytes).Encode(x) + return reqBodyBytes.Bytes() +} diff --git a/pkg/server/http.go b/pkg/server/http.go index 9eab41a..05bef4f 100644 --- a/pkg/server/http.go +++ b/pkg/server/http.go @@ -2,20 +2,48 @@ package server import ( "bytes" + "context" "io" + "log/slog" "net/http" "strconv" "strings" + "sync" "text/template" "github.com/erkexzcx/valetudopng" ) -func runWebServer(bind string) { +func runWebServer(ctx context.Context, wg *sync.WaitGroup, panic chan bool, bind string) { + http.HandleFunc("/api/map/image", requestHandlerImage) http.HandleFunc("/api/map/image/debug", requestHandlerDebug) + http.HandleFunc("/api/map/image/debug/lovelace/", requestHandlerDebugConfig) http.HandleFunc("/api/map/image/debug/static/", requestHandlerDebugStatic) - panic(http.ListenAndServe(bind, nil)) + server := http.Server{ + Addr: bind, + Handler: http.DefaultServeMux, + } + + go func() { + DONE: + for { + select { + case <-ctx.Done(): + break DONE + case <-panic: + break DONE + } + } + server.Shutdown(context.Background()) + wg.Done() + }() + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("HTTP server error", slog.String("error", err.Error())) + panic <- true + } + slog.Info("HTTP server shut down") } func isResultNotReady() bool { @@ -117,3 +145,19 @@ func requestHandlerDebugStatic(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, info.Name(), info.ModTime(), reader) } + +func requestHandlerDebugConfig(w http.ResponseWriter, _ *http.Request) { + if isResultNotReady() { + http.Error(w, "image not yet loaded", http.StatusAccepted) + return + } + + // TODO: add lock + w.Header().Set("Content-Length", strconv.Itoa(len(renderedCfg))) + // w.Header().Set("Content-Type", "application/x-yaml") // is this preferred? annoying when the browser downloads instead of shows + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + w.WriteHeader(200) + renderedPNGMux.RLock() + defer renderedPNGMux.RUnlock() + w.Write(renderedCfg) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 9fc2cbc..6a92b6a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -1,10 +1,12 @@ package server import ( + "context" "encoding/base64" "fmt" "image/color" "log" + "log/slog" "os" "os/signal" "strconv" @@ -19,6 +21,7 @@ import ( var ( renderedPNG = make([]byte, 0) + renderedCfg = make([]byte, 0) renderedPNGMux = &sync.RWMutex{} result *renderer.Result ) @@ -45,63 +48,82 @@ func Start(c *config.Config) { HexColor(c.Map.Colors.Segments[2]), HexColor(c.Map.Colors.Segments[3]), }, - }) + }, c) + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + + panic := make(chan bool) if c.HTTP.Enabled { - go runWebServer(c.HTTP.Bind) + wg.Add(1) + go runWebServer(ctx, &wg, panic, c.HTTP.Bind) } mapJSONChan := make(chan []byte) renderedMapChan := make(chan []byte) calibrationDataChan := make(chan []byte) - go mqtt.Start(c.Mqtt, mapJSONChan, renderedMapChan, calibrationDataChan) + wg.Add(1) + go mqtt.Start(ctx, &wg, panic, c.Mqtt, mapJSONChan, renderedMapChan, calibrationDataChan) renderedAt := time.Now().Add(-c.Map.MinRefreshInt) - for payload := range mapJSONChan { - if time.Now().Before(renderedAt) { - log.Println("Skipping image render due to min_refresh_int") - continue - } - renderedAt = time.Now().Add(c.Map.MinRefreshInt) - - tsStart := time.Now() - res, err := r.Render(payload, c.Map) - if err != nil { - log.Fatalln("Error occurred while rendering map:", err) + wg.Add(1) + go func() { + for { + select { + case <-ctx.Done(): + wg.Done() + return + case payload := <-mapJSONChan: + if time.Now().Before(renderedAt) { + slog.Info("Skipping image render due to min_refresh_int") + continue + } + renderedAt = time.Now().Add(c.Map.MinRefreshInt) + + tsStart := time.Now() + res, err := r.Render(payload, c.Map) + if err != nil { + log.Fatalln("Error occurred while rendering map:", err) + } + drawnInMS := time.Since(tsStart).Milliseconds() + img, err := res.RenderPNG() + if err != nil { + slog.Error("Error occurred while rendering PNG image", slog.String("error", err.Error())) + return + } + renderedIn := time.Since(tsStart).Milliseconds() - drawnInMS + + slog.Info("Image rendered", slog.Int64("drawTime", drawnInMS), + slog.Int64("renderedIn", renderedIn), slog.String("bytes", ByteCountSI(int64(len(img))))) + + if !(c.Mqtt.ImageAsBase64 && !c.HTTP.Enabled) { + renderedPNGMux.Lock() + renderedPNG = img + renderedCfg = res.CardCfg + result = res + renderedPNGMux.Unlock() + } + + if c.Mqtt.ImageAsBase64 { + img = []byte(base64.StdEncoding.EncodeToString(img)) + } + + // Send data to MQTT + renderedMapChan <- img + calibrationDataChan <- res.Calibration + } } - drawnInMS := time.Since(tsStart).Milliseconds() - - img, err := res.RenderPNG() - if err != nil { - log.Fatalln("Error occurred while rendering PNG image:", err) - } - renderedIn := time.Since(tsStart).Milliseconds() - drawnInMS - - log.Printf("Image rendered! drawing:%dms, encoding:%dms, size:%s\n", drawnInMS, renderedIn, ByteCountSI(int64(len(img)))) - - if !(c.Mqtt.ImageAsBase64 && !c.HTTP.Enabled) { - renderedPNGMux.Lock() - renderedPNG = img - result = res - renderedPNGMux.Unlock() - } - - if c.Mqtt.ImageAsBase64 { - img = []byte(base64.StdEncoding.EncodeToString(img)) - } - - // Send data to MQTT - renderedMapChan <- img - calibrationDataChan <- res.Calibration - } - + }() // Create a channel to wait for OS interrupt signal interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) // Block main function here until an interrupt is received <-interrupt - fmt.Println("Program interrupted") + cancel() + slog.Warn("Program interrupted") + wg.Wait() + slog.Warn("Program shut down") } func ByteCountSI(b int64) string {