Add initial MQTT scrubber service scaffold
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const envPrefix = "MQTT_SCRUBBER_"
|
||||
|
||||
type Config struct {
|
||||
MQTT MQTTConfig `json:"mqtt"`
|
||||
Influx InfluxConfig `json:"influx"`
|
||||
App AppConfig `json:"app"`
|
||||
}
|
||||
|
||||
type MQTTConfig struct {
|
||||
Broker string `json:"broker"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ClientID string `json:"client_id"`
|
||||
Topics []string `json:"topics"`
|
||||
QoS byte `json:"qos"`
|
||||
}
|
||||
|
||||
type InfluxConfig struct {
|
||||
URL string `json:"url"`
|
||||
Database string `json:"database"`
|
||||
Token string `json:"token"`
|
||||
Precision string `json:"precision"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
BatchSize int `json:"batch_size"`
|
||||
BufferSize int `json:"buffer_size"`
|
||||
FlushInterval DurationValue `json:"flush_interval"`
|
||||
FlushTimeout DurationValue `json:"flush_timeout"`
|
||||
LogLevel string `json:"log_level"`
|
||||
HealthAddress string `json:"health_address"`
|
||||
}
|
||||
|
||||
type DurationValue struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
func (value *DurationValue) UnmarshalJSON(data []byte) error {
|
||||
var raw string
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("duration must be a string: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", raw, err)
|
||||
}
|
||||
|
||||
value.Duration = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := defaultConfig()
|
||||
|
||||
if path != "" {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(contents, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := applyEnvOverrides(&cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (cfg Config) Validate() error {
|
||||
if cfg.MQTT.Broker == "" {
|
||||
return errors.New("mqtt broker is required")
|
||||
}
|
||||
if cfg.MQTT.ClientID == "" {
|
||||
return errors.New("mqtt client_id is required")
|
||||
}
|
||||
if len(cfg.MQTT.Topics) == 0 {
|
||||
return errors.New("at least one mqtt topic is required")
|
||||
}
|
||||
if cfg.Influx.URL == "" {
|
||||
return errors.New("influx url is required")
|
||||
}
|
||||
if cfg.Influx.Database == "" {
|
||||
return errors.New("influx database is required")
|
||||
}
|
||||
if cfg.Influx.Precision == "" {
|
||||
return errors.New("influx precision is required")
|
||||
}
|
||||
if cfg.App.BatchSize <= 0 {
|
||||
return errors.New("app batch_size must be greater than zero")
|
||||
}
|
||||
if cfg.App.BufferSize <= 0 {
|
||||
return errors.New("app buffer_size must be greater than zero")
|
||||
}
|
||||
if cfg.App.FlushInterval.Duration <= 0 {
|
||||
return errors.New("app flush_interval must be greater than zero")
|
||||
}
|
||||
if cfg.App.FlushTimeout.Duration <= 0 {
|
||||
return errors.New("app flush_timeout must be greater than zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
MQTT: MQTTConfig{
|
||||
Broker: "tcp://127.0.0.1:1883",
|
||||
ClientID: "mqqt-scrubber",
|
||||
Topics: []string{
|
||||
"tele/+/SENSOR",
|
||||
"tele/+/STATE",
|
||||
},
|
||||
QoS: 0,
|
||||
},
|
||||
Influx: InfluxConfig{
|
||||
URL: "http://127.0.0.1:8181",
|
||||
Database: "home",
|
||||
Precision: "ns",
|
||||
},
|
||||
App: AppConfig{
|
||||
BatchSize: 200,
|
||||
BufferSize: 1000,
|
||||
FlushInterval: DurationValue{Duration: 10 * time.Second},
|
||||
FlushTimeout: DurationValue{Duration: 10 * time.Second},
|
||||
LogLevel: "info",
|
||||
HealthAddress: ":8080",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) error {
|
||||
setString(&cfg.MQTT.Broker, envPrefix+"MQTT_BROKER")
|
||||
setString(&cfg.MQTT.Username, envPrefix+"MQTT_USERNAME")
|
||||
setString(&cfg.MQTT.Password, envPrefix+"MQTT_PASSWORD")
|
||||
setString(&cfg.MQTT.ClientID, envPrefix+"MQTT_CLIENT_ID")
|
||||
setString(&cfg.Influx.URL, envPrefix+"INFLUX_URL")
|
||||
setString(&cfg.Influx.Database, envPrefix+"INFLUX_DATABASE")
|
||||
setString(&cfg.Influx.Token, envPrefix+"INFLUX_TOKEN")
|
||||
setString(&cfg.Influx.Precision, envPrefix+"INFLUX_PRECISION")
|
||||
setString(&cfg.App.LogLevel, envPrefix+"APP_LOG_LEVEL")
|
||||
setString(&cfg.App.HealthAddress, envPrefix+"APP_HEALTH_ADDRESS")
|
||||
|
||||
if raw, ok := os.LookupEnv(envPrefix + "MQTT_TOPICS"); ok {
|
||||
cfg.MQTT.Topics = splitAndTrim(raw)
|
||||
}
|
||||
|
||||
if raw, ok := os.LookupEnv(envPrefix + "MQTT_QOS"); ok {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %sMQTT_QOS: %w", envPrefix, err)
|
||||
}
|
||||
cfg.MQTT.QoS = byte(parsed)
|
||||
}
|
||||
|
||||
if raw, ok := os.LookupEnv(envPrefix + "APP_BATCH_SIZE"); ok {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %sAPP_BATCH_SIZE: %w", envPrefix, err)
|
||||
}
|
||||
cfg.App.BatchSize = parsed
|
||||
}
|
||||
|
||||
if raw, ok := os.LookupEnv(envPrefix + "APP_BUFFER_SIZE"); ok {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %sAPP_BUFFER_SIZE: %w", envPrefix, err)
|
||||
}
|
||||
cfg.App.BufferSize = parsed
|
||||
}
|
||||
|
||||
if raw, ok := os.LookupEnv(envPrefix + "APP_FLUSH_INTERVAL"); ok {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %sAPP_FLUSH_INTERVAL: %w", envPrefix, err)
|
||||
}
|
||||
cfg.App.FlushInterval = DurationValue{Duration: parsed}
|
||||
}
|
||||
|
||||
if raw, ok := os.LookupEnv(envPrefix + "APP_FLUSH_TIMEOUT"); ok {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %sAPP_FLUSH_TIMEOUT: %w", envPrefix, err)
|
||||
}
|
||||
cfg.App.FlushTimeout = DurationValue{Duration: parsed}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setString(target *string, key string) {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
|
||||
func splitAndTrim(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
address string
|
||||
server *http.Server
|
||||
metrics func() any
|
||||
}
|
||||
|
||||
func NewServer(address string, metrics func() any) *Server {
|
||||
if address == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
})
|
||||
mux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if metrics == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "metrics_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
_, _ = w.Write([]byte(formatMetrics(metrics())))
|
||||
})
|
||||
|
||||
return &Server{
|
||||
address: address,
|
||||
metrics: metrics,
|
||||
server: &http.Server{
|
||||
Addr: address,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) Start() {
|
||||
if server == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("health server started", "address", server.address)
|
||||
if err := server.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("health server stopped with error", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
return server.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func formatMetrics(snapshot any) string {
|
||||
encoded, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return "mqqt_scrubber_metrics_encode_error 1\n"
|
||||
}
|
||||
|
||||
var fields map[string]uint64
|
||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||
return "mqqt_scrubber_metrics_decode_error 1\n"
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for key, value := range fields {
|
||||
builder.WriteString(fmt.Sprintf("mqqt_scrubber_%s %d\n", key, value))
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package influx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mqqt-scrubber/internal/config"
|
||||
"mqqt-scrubber/internal/model"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
database string
|
||||
token string
|
||||
precision string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg config.InfluxConfig) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.URL, "/"),
|
||||
database: cfg.Database,
|
||||
token: cfg.Token,
|
||||
precision: cfg.Precision,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) Write(ctx context.Context, records []model.Record) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
line, err := toLineProtocol(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
writeURL, err := url.Parse(client.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse influx url: %w", err)
|
||||
}
|
||||
|
||||
writeURL.Path = strings.TrimRight(writeURL.Path, "/") + "/api/v3/write_lp"
|
||||
query := writeURL.Query()
|
||||
query.Set("db", client.database)
|
||||
query.Set("precision", client.precision)
|
||||
writeURL.RawQuery = query.Encode()
|
||||
|
||||
body := strings.Join(lines, "\n")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, writeURL.String(), bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create write request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if client.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+client.token)
|
||||
}
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute write request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("influx write failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func toLineProtocol(record model.Record) (string, error) {
|
||||
if record.Measurement == "" {
|
||||
return "", fmt.Errorf("record measurement is required")
|
||||
}
|
||||
if len(record.Fields) == 0 {
|
||||
return "", fmt.Errorf("record fields are required")
|
||||
}
|
||||
|
||||
tagKeys := sortedKeys(record.Tags)
|
||||
fieldKeys := sortedKeysAny(record.Fields)
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString(escapeMeasurement(record.Measurement))
|
||||
|
||||
for _, key := range tagKeys {
|
||||
builder.WriteByte(',')
|
||||
builder.WriteString(escapeTagOrKey(key))
|
||||
builder.WriteByte('=')
|
||||
builder.WriteString(escapeTagOrKey(record.Tags[key]))
|
||||
}
|
||||
|
||||
builder.WriteByte(' ')
|
||||
for index, key := range fieldKeys {
|
||||
if index > 0 {
|
||||
builder.WriteByte(',')
|
||||
}
|
||||
|
||||
builder.WriteString(escapeTagOrKey(key))
|
||||
builder.WriteByte('=')
|
||||
|
||||
formatted, err := formatFieldValue(record.Fields[key])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("format field %q: %w", key, err)
|
||||
}
|
||||
builder.WriteString(formatted)
|
||||
}
|
||||
|
||||
builder.WriteByte(' ')
|
||||
builder.WriteString(strconv.FormatInt(record.Timestamp.UnixNano(), 10))
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]string) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func sortedKeysAny(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func formatFieldValue(value any) (string, error) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
escaped := strings.ReplaceAll(typed, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
return `"` + escaped + `"`, nil
|
||||
case bool:
|
||||
return strconv.FormatBool(typed), nil
|
||||
case int:
|
||||
return strconv.Itoa(typed) + "i", nil
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10) + "i", nil
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64), nil
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(typed), 'f', -1, 32), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported field type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func escapeMeasurement(value string) string {
|
||||
return strings.NewReplacer(
|
||||
",", `\,`,
|
||||
" ", `\ `,
|
||||
).Replace(value)
|
||||
}
|
||||
|
||||
func escapeTagOrKey(value string) string {
|
||||
return strings.NewReplacer(
|
||||
",", `\,`,
|
||||
"=", `\=`,
|
||||
" ", `\ `,
|
||||
).Replace(value)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package influx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mqqt-scrubber/internal/model"
|
||||
"mqqt-scrubber/internal/parser"
|
||||
)
|
||||
|
||||
func TestToLineProtocolFromParsedLWT(t *testing.T) {
|
||||
receivedAt := time.Date(2026, time.March, 12, 15, 21, 39, 0, time.UTC)
|
||||
records, err := parser.ParseTasmota(model.RawMessage{
|
||||
Topic: "tele/tasmota_896001/LWT",
|
||||
Payload: []byte("Online"),
|
||||
ReceivedAt: receivedAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTasmota returned error: %v", err)
|
||||
}
|
||||
|
||||
line, err := toLineProtocol(records[0])
|
||||
if err != nil {
|
||||
t.Fatalf("toLineProtocol returned error: %v", err)
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf(
|
||||
"tasmota_lwt,device=tasmota_896001,message_type=lwt,source=tasmota online=true,state=\"Online\" %d",
|
||||
receivedAt.UnixNano(),
|
||||
)
|
||||
if line != expected {
|
||||
t.Fatalf("unexpected line protocol:\n got: %s\nwant: %s", line, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToLineProtocolFromParsedSensor(t *testing.T) {
|
||||
receivedAt := time.Date(2026, time.March, 12, 16, 23, 13, 0, time.UTC)
|
||||
records, err := parser.ParseTasmota(model.RawMessage{
|
||||
Topic: "tele/tasmota_C88994/SENSOR",
|
||||
Payload: []byte(`{"Time":"2026-03-12T16:23:13","ENERGY":{"TotalStartTime":"2026-02-04T19:13:40","Total":41.385,"Yesterday":1.124,"Today":0.799,"Period":0,"Power":1,"ApparentPower":4,"ReactivePower":4,"Factor":0.22,"Voltage":231,"Current":0.016}}`),
|
||||
ReceivedAt: receivedAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTasmota returned error: %v", err)
|
||||
}
|
||||
|
||||
line, err := toLineProtocol(records[0])
|
||||
if err != nil {
|
||||
t.Fatalf("toLineProtocol returned error: %v", err)
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf(
|
||||
"tasmota_sensor,device=tasmota_c88994,message_type=sensor,source=tasmota energy_apparent_power=4,energy_current=0.016,energy_factor=0.22,energy_period=0,energy_power=1,energy_reactive_power=4,energy_today=0.799,energy_total=41.385,energy_total_start_time=\"2026-02-04T19:13:40\",energy_voltage=231,energy_yesterday=1.124 %d",
|
||||
receivedAt.UnixNano(),
|
||||
)
|
||||
if line != expected {
|
||||
t.Fatalf("unexpected line protocol:\n got: %s\nwant: %s", line, expected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type RawMessage struct {
|
||||
Topic string
|
||||
Payload []byte
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
Measurement string
|
||||
Tags map[string]string
|
||||
Fields map[string]any
|
||||
Timestamp time.Time
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
|
||||
"mqqt-scrubber/internal/config"
|
||||
"mqqt-scrubber/internal/model"
|
||||
)
|
||||
|
||||
type Subscriber struct {
|
||||
config config.MQTTConfig
|
||||
handle func(model.RawMessage)
|
||||
client paho.Client
|
||||
started bool
|
||||
}
|
||||
|
||||
func NewSubscriber(cfg config.MQTTConfig, handle func(model.RawMessage)) *Subscriber {
|
||||
return &Subscriber{
|
||||
config: cfg,
|
||||
handle: handle,
|
||||
}
|
||||
}
|
||||
|
||||
func (subscriber *Subscriber) Start() error {
|
||||
options := paho.NewClientOptions()
|
||||
options.AddBroker(subscriber.config.Broker)
|
||||
options.SetClientID(subscriber.config.ClientID)
|
||||
options.SetAutoReconnect(true)
|
||||
options.SetConnectRetry(true)
|
||||
options.SetConnectRetryInterval(5 * time.Second)
|
||||
options.SetKeepAlive(30 * time.Second)
|
||||
options.SetPingTimeout(10 * time.Second)
|
||||
options.SetOrderMatters(false)
|
||||
|
||||
if subscriber.config.Username != "" {
|
||||
options.SetUsername(subscriber.config.Username)
|
||||
}
|
||||
if subscriber.config.Password != "" {
|
||||
options.SetPassword(subscriber.config.Password)
|
||||
}
|
||||
|
||||
callback := func(_ paho.Client, message paho.Message) {
|
||||
subscriber.handle(model.RawMessage{
|
||||
Topic: message.Topic(),
|
||||
Payload: append([]byte(nil), message.Payload()...),
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
options.SetOnConnectHandler(func(client paho.Client) {
|
||||
filters := make(map[string]byte, len(subscriber.config.Topics))
|
||||
for _, topic := range subscriber.config.Topics {
|
||||
filters[topic] = subscriber.config.QoS
|
||||
}
|
||||
|
||||
token := client.SubscribeMultiple(filters, callback)
|
||||
token.Wait()
|
||||
if err := token.Error(); err != nil {
|
||||
slog.Error("failed to subscribe after connect", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("subscribed to mqtt topics", "topics", subscriber.config.Topics)
|
||||
})
|
||||
|
||||
options.SetConnectionLostHandler(func(_ paho.Client, err error) {
|
||||
slog.Warn("mqtt connection lost", "error", err)
|
||||
})
|
||||
|
||||
options.SetReconnectingHandler(func(_ paho.Client, _ *paho.ClientOptions) {
|
||||
slog.Info("mqtt reconnecting")
|
||||
})
|
||||
|
||||
subscriber.client = paho.NewClient(options)
|
||||
token := subscriber.client.Connect()
|
||||
token.Wait()
|
||||
if err := token.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subscriber.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (subscriber *Subscriber) Stop() {
|
||||
if !subscriber.started || subscriber.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
subscriber.client.Disconnect(250)
|
||||
subscriber.started = false
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mqqt-scrubber/internal/model"
|
||||
)
|
||||
|
||||
var invalidNameCharacters = regexp.MustCompile(`[^a-z0-9_]+`)
|
||||
var acronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`)
|
||||
var camelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||
|
||||
var tasmotaTimeLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
}
|
||||
|
||||
func ParseTasmota(message model.RawMessage) ([]model.Record, error) {
|
||||
parts := strings.Split(message.Topic, "/")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("unsupported topic shape: %s", message.Topic)
|
||||
}
|
||||
if parts[0] != "tele" {
|
||||
return nil, fmt.Errorf("unsupported topic root: %s", message.Topic)
|
||||
}
|
||||
|
||||
measurement := "tasmota_" + sanitizeName(parts[2])
|
||||
tags := map[string]string{
|
||||
"device": sanitizeDeviceName(parts[1]),
|
||||
"message_type": sanitizeName(parts[2]),
|
||||
"source": "tasmota",
|
||||
}
|
||||
|
||||
if strings.EqualFold(parts[2], "LWT") {
|
||||
return []model.Record{parseLWT(message, measurement, tags)}, nil
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(message.Payload, &payload); err != nil {
|
||||
return nil, fmt.Errorf("invalid json payload: %w", err)
|
||||
}
|
||||
|
||||
fields := flattenPayload(payload, nil)
|
||||
delete(fields, "time")
|
||||
if len(fields) == 0 {
|
||||
return nil, fmt.Errorf("no usable fields in payload for topic %s", message.Topic)
|
||||
}
|
||||
|
||||
timestamp := parsePayloadTimestamp(payload, message.ReceivedAt)
|
||||
|
||||
record := model.Record{
|
||||
Measurement: measurement,
|
||||
Tags: tags,
|
||||
Fields: fields,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
|
||||
return []model.Record{record}, nil
|
||||
}
|
||||
|
||||
func parseLWT(message model.RawMessage, measurement string, tags map[string]string) model.Record {
|
||||
state := strings.TrimSpace(string(message.Payload))
|
||||
return model.Record{
|
||||
Measurement: measurement,
|
||||
Tags: tags,
|
||||
Fields: map[string]any{
|
||||
"state": state,
|
||||
"online": strings.EqualFold(state, "Online"),
|
||||
},
|
||||
Timestamp: message.ReceivedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func parsePayloadTimestamp(payload map[string]any, fallback time.Time) time.Time {
|
||||
rawTime, ok := payload["Time"].(string)
|
||||
if !ok || strings.TrimSpace(rawTime) == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
for _, layout := range tasmotaTimeLayouts {
|
||||
var (
|
||||
parsed time.Time
|
||||
err error
|
||||
)
|
||||
|
||||
if layout == "2006-01-02T15:04:05" {
|
||||
parsed, err = time.ParseInLocation(layout, rawTime, time.UTC)
|
||||
} else {
|
||||
parsed, err = time.Parse(layout, rawTime)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func flattenPayload(payload map[string]any, prefix []string) map[string]any {
|
||||
result := make(map[string]any)
|
||||
|
||||
keys := make([]string, 0, len(payload))
|
||||
for key := range payload {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
value := payload[key]
|
||||
nameParts := append(prefix, sanitizeName(key))
|
||||
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
nested := flattenPayload(typed, nameParts)
|
||||
for nestedKey, nestedValue := range nested {
|
||||
result[nestedKey] = nestedValue
|
||||
}
|
||||
case float64, bool, string:
|
||||
result[strings.Join(nameParts, "_")] = typed
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeName(value string) string {
|
||||
normalized := strings.TrimSpace(value)
|
||||
normalized = strings.ReplaceAll(normalized, "-", "_")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "_")
|
||||
normalized = acronymBoundary.ReplaceAllString(normalized, `${1}_${2}`)
|
||||
normalized = camelBoundary.ReplaceAllString(normalized, `${1}_${2}`)
|
||||
normalized = strings.ToLower(normalized)
|
||||
normalized = invalidNameCharacters.ReplaceAllString(normalized, "_")
|
||||
normalized = strings.Trim(normalized, "_")
|
||||
if normalized == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func sanitizeDeviceName(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
normalized = strings.ReplaceAll(normalized, "-", "_")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "_")
|
||||
normalized = invalidNameCharacters.ReplaceAllString(normalized, "_")
|
||||
normalized = strings.Trim(normalized, "_")
|
||||
if normalized == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mqqt-scrubber/internal/model"
|
||||
)
|
||||
|
||||
type fixtureCase struct {
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
Payload string `json:"payload"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
ExpectedMeasurement string `json:"expected_measurement"`
|
||||
ExpectedTimestamp string `json:"expected_timestamp"`
|
||||
ExpectedTags map[string]string `json:"expected_tags"`
|
||||
ExpectedFields map[string]any `json:"expected_fields"`
|
||||
}
|
||||
|
||||
func TestParseTasmotaFixtures(t *testing.T) {
|
||||
contents, err := os.ReadFile("testdata/tasmota_samples.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture file: %v", err)
|
||||
}
|
||||
|
||||
var fixtures []fixtureCase
|
||||
if err := json.Unmarshal(contents, &fixtures); err != nil {
|
||||
t.Fatalf("parse fixture file: %v", err)
|
||||
}
|
||||
|
||||
for _, fixture := range fixtures {
|
||||
t.Run(fixture.Name, func(t *testing.T) {
|
||||
receivedAt, err := time.Parse(time.RFC3339, fixture.ReceivedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse receivedAt: %v", err)
|
||||
}
|
||||
|
||||
expectedTimestamp, err := time.Parse(time.RFC3339, fixture.ExpectedTimestamp)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expected timestamp: %v", err)
|
||||
}
|
||||
|
||||
records, err := ParseTasmota(model.RawMessage{
|
||||
Topic: fixture.Topic,
|
||||
Payload: []byte(fixture.Payload),
|
||||
ReceivedAt: receivedAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTasmota returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(records))
|
||||
}
|
||||
|
||||
record := records[0]
|
||||
if record.Measurement != fixture.ExpectedMeasurement {
|
||||
t.Fatalf("unexpected measurement: got %s want %s", record.Measurement, fixture.ExpectedMeasurement)
|
||||
}
|
||||
|
||||
if !record.Timestamp.Equal(expectedTimestamp) {
|
||||
t.Fatalf("unexpected timestamp: got %s want %s", record.Timestamp.Format(time.RFC3339), expectedTimestamp.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
for key, value := range fixture.ExpectedTags {
|
||||
if record.Tags[key] != value {
|
||||
t.Fatalf("unexpected tag %s: got %q want %q", key, record.Tags[key], value)
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range fixture.ExpectedFields {
|
||||
fieldValue, ok := record.Fields[key]
|
||||
if !ok {
|
||||
t.Fatalf("expected field %s to be present", key)
|
||||
}
|
||||
|
||||
if !fieldEquals(fieldValue, value) {
|
||||
t.Fatalf("unexpected field %s: got %#v want %#v", key, fieldValue, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fieldEquals(got any, want any) bool {
|
||||
switch typedWant := want.(type) {
|
||||
case float64:
|
||||
typedGot, ok := got.(float64)
|
||||
return ok && typedGot == typedWant
|
||||
case string:
|
||||
typedGot, ok := got.(string)
|
||||
return ok && typedGot == typedWant
|
||||
case bool:
|
||||
typedGot, ok := got.(bool)
|
||||
return ok && typedGot == typedWant
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
[
|
||||
{
|
||||
"name": "lwt-online",
|
||||
"topic": "tele/tasmota_896001/LWT",
|
||||
"payload": "Online",
|
||||
"received_at": "2026-03-12T15:21:39Z",
|
||||
"expected_measurement": "tasmota_lwt",
|
||||
"expected_timestamp": "2026-03-12T15:21:39Z",
|
||||
"expected_tags": {
|
||||
"device": "tasmota_896001",
|
||||
"message_type": "lwt",
|
||||
"source": "tasmota"
|
||||
},
|
||||
"expected_fields": {
|
||||
"state": "Online",
|
||||
"online": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lwt-offline-hyphenated-device",
|
||||
"topic": "tele/tasmota-prusa-mini/LWT",
|
||||
"payload": "Offline",
|
||||
"received_at": "2026-03-12T15:21:39Z",
|
||||
"expected_measurement": "tasmota_lwt",
|
||||
"expected_timestamp": "2026-03-12T15:21:39Z",
|
||||
"expected_tags": {
|
||||
"device": "tasmota_prusa_mini",
|
||||
"message_type": "lwt",
|
||||
"source": "tasmota"
|
||||
},
|
||||
"expected_fields": {
|
||||
"state": "Offline",
|
||||
"online": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "state-single-relay",
|
||||
"topic": "tele/tasmota_67850B/STATE",
|
||||
"payload": "{\"Time\":\"2025-10-28T11:56:55\",\"Uptime\":\"0T15:35:12\",\"UptimeSec\":56112,\"Heap\":27,\"SleepMode\":\"Dynamic\",\"Sleep\":50,\"LoadAvg\":19,\"MqttCount\":1,\"POWER\":\"ON\",\"Wifi\":{\"AP\":1,\"SSId\":\"Home_MiNi_smart\",\"BSSId\":\"02:E2:C6:A9:5F:E9\",\"Channel\":6,\"RSSI\":78,\"Signal\":-61,\"LinkCount\":1,\"Downtime\":\"0T00:00:05\"}}",
|
||||
"received_at": "2025-10-28T11:56:54Z",
|
||||
"expected_measurement": "tasmota_state",
|
||||
"expected_timestamp": "2025-10-28T11:56:55Z",
|
||||
"expected_tags": {
|
||||
"device": "tasmota_67850b",
|
||||
"message_type": "state",
|
||||
"source": "tasmota"
|
||||
},
|
||||
"expected_fields": {
|
||||
"power": "ON",
|
||||
"uptime_sec": 56112,
|
||||
"wifi_rssi": 78,
|
||||
"wifi_signal": -61
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "state-multi-relay",
|
||||
"topic": "tele/tasmota_725D2D/STATE",
|
||||
"payload": "{\"Time\":\"2026-03-12T16:23:14\",\"Uptime\":\"4T07:10:12\",\"UptimeSec\":371412,\"Heap\":24,\"SleepMode\":\"Dynamic\",\"Sleep\":50,\"LoadAvg\":19,\"MqttCount\":1,\"POWER1\":\"OFF\",\"POWER2\":\"OFF\",\"POWER3\":\"OFF\",\"POWER4\":\"OFF\",\"Wifi\":{\"AP\":1,\"SSId\":\"Home_MiNi_smart\",\"BSSId\":\"02:E2:C6:A9:5F:E9\",\"Channel\":6,\"Mode\":\"11n\",\"RSSI\":58,\"Signal\":-71,\"LinkCount\":1,\"Downtime\":\"0T00:00:05\"}}",
|
||||
"received_at": "2026-03-12T16:23:14Z",
|
||||
"expected_measurement": "tasmota_state",
|
||||
"expected_timestamp": "2026-03-12T16:23:14Z",
|
||||
"expected_tags": {
|
||||
"device": "tasmota_725d2d",
|
||||
"message_type": "state",
|
||||
"source": "tasmota"
|
||||
},
|
||||
"expected_fields": {
|
||||
"power1": "OFF",
|
||||
"power4": "OFF",
|
||||
"wifi_mode": "11n",
|
||||
"wifi_rssi": 58
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "state-with-berry",
|
||||
"topic": "tele/tasmota_C8BD20/STATE",
|
||||
"payload": "{\"Time\":\"2026-03-12T16:23:11\",\"Uptime\":\"4T07:10:08\",\"UptimeSec\":371408,\"Heap\":142,\"SleepMode\":\"Dynamic\",\"Sleep\":50,\"LoadAvg\":19,\"MqttCount\":2,\"Berry\":{\"HeapUsed\":4,\"Objects\":46},\"POWER\":\"ON\",\"Wifi\":{\"AP\":1,\"SSId\":\"Home_MiNi_smart\",\"BSSId\":\"32:5A:4C:53:3F:56\",\"Channel\":1,\"Mode\":\"HE20\",\"RSSI\":96,\"Signal\":-52,\"LinkCount\":1,\"Downtime\":\"0T00:00:03\"}}",
|
||||
"received_at": "2026-03-12T16:23:11Z",
|
||||
"expected_measurement": "tasmota_state",
|
||||
"expected_timestamp": "2026-03-12T16:23:11Z",
|
||||
"expected_tags": {
|
||||
"device": "tasmota_c8bd20",
|
||||
"message_type": "state",
|
||||
"source": "tasmota"
|
||||
},
|
||||
"expected_fields": {
|
||||
"berry_heap_used": 4,
|
||||
"berry_objects": 46,
|
||||
"power": "ON",
|
||||
"wifi_mode": "HE20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sensor-energy-only",
|
||||
"topic": "tele/tasmota_C88994/SENSOR",
|
||||
"payload": "{\"Time\":\"2026-03-12T16:23:13\",\"ENERGY\":{\"TotalStartTime\":\"2026-02-04T19:13:40\",\"Total\":41.385,\"Yesterday\":1.124,\"Today\":0.799,\"Period\":0,\"Power\":1,\"ApparentPower\":4,\"ReactivePower\":4,\"Factor\":0.22,\"Voltage\":231,\"Current\":0.016}}",
|
||||
"received_at": "2026-03-12T16:23:13Z",
|
||||
"expected_measurement": "tasmota_sensor",
|
||||
"expected_timestamp": "2026-03-12T16:23:13Z",
|
||||
"expected_tags": {
|
||||
"device": "tasmota_c88994",
|
||||
"message_type": "sensor",
|
||||
"source": "tasmota"
|
||||
},
|
||||
"expected_fields": {
|
||||
"energy_power": 1,
|
||||
"energy_voltage": 231,
|
||||
"energy_total": 41.385,
|
||||
"energy_current": 0.016
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sensor-analog-temperature",
|
||||
"topic": "tele/tasmota_896001/SENSOR",
|
||||
"payload": "{\"Time\":\"2026-03-12T16:25:38\",\"ANALOG\":{\"Temperature\":33.8},\"ENERGY\":{\"TotalStartTime\":\"2022-12-30T00:18:41\",\"Total\":1.413,\"Yesterday\":0.000,\"Today\":0.000,\"Period\":0,\"Power\":0,\"ApparentPower\":0,\"ReactivePower\":0,\"Factor\":0.00,\"Voltage\":0,\"Current\":0.000},\"TempUnit\":\"C\"}",
|
||||
"received_at": "2026-03-12T16:25:38Z",
|
||||
"expected_measurement": "tasmota_sensor",
|
||||
"expected_timestamp": "2026-03-12T16:25:38Z",
|
||||
"expected_tags": {
|
||||
"device": "tasmota_896001",
|
||||
"message_type": "sensor",
|
||||
"source": "tasmota"
|
||||
},
|
||||
"expected_fields": {
|
||||
"analog_temperature": 33.8,
|
||||
"temp_unit": "C",
|
||||
"energy_total": 1.413
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sensor-analog-a0",
|
||||
"topic": "tele/tasmota_725D2D/SENSOR",
|
||||
"payload": "{\"Time\":\"2026-03-12T16:23:14\",\"ANALOG\":{\"A0\":1024},\"ENERGY\":{\"TotalStartTime\":\"2025-05-23T14:48:03\",\"Total\":15.782,\"Yesterday\":0.000,\"Today\":0.000,\"Period\":0,\"Power\":0,\"ApparentPower\":0,\"ReactivePower\":0,\"Factor\":0.00,\"Voltage\":0,\"Current\":0.000}}",
|
||||
"received_at": "2026-03-12T16:23:14Z",
|
||||
"expected_measurement": "tasmota_sensor",
|
||||
"expected_timestamp": "2026-03-12T16:23:14Z",
|
||||
"expected_tags": {
|
||||
"device": "tasmota_725d2d",
|
||||
"message_type": "sensor",
|
||||
"source": "tasmota"
|
||||
},
|
||||
"expected_fields": {
|
||||
"analog_a0": 1024,
|
||||
"energy_total": 15.782,
|
||||
"energy_power": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"mqqt-scrubber/internal/config"
|
||||
"mqqt-scrubber/internal/model"
|
||||
"mqqt-scrubber/internal/parser"
|
||||
)
|
||||
|
||||
type writer interface {
|
||||
Write(ctx context.Context, records []model.Record) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
config config.Config
|
||||
influxClient writer
|
||||
input chan model.RawMessage
|
||||
received atomic.Uint64
|
||||
parsed atomic.Uint64
|
||||
written atomic.Uint64
|
||||
dropped atomic.Uint64
|
||||
failed atomic.Uint64
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Received uint64 `json:"received"`
|
||||
Parsed uint64 `json:"parsed"`
|
||||
Written uint64 `json:"written"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
Failed uint64 `json:"failed"`
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, influxClient writer) *Service {
|
||||
return &Service{
|
||||
config: cfg,
|
||||
influxClient: influxClient,
|
||||
input: make(chan model.RawMessage, cfg.App.BufferSize),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) Enqueue(message model.RawMessage) {
|
||||
service.received.Add(1)
|
||||
|
||||
select {
|
||||
case service.input <- message:
|
||||
default:
|
||||
service.dropped.Add(1)
|
||||
slog.Warn("dropping message because buffer is full", "topic", message.Topic)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(service.config.App.FlushInterval.Duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
batch := make([]model.Record, 0, service.config.App.BatchSize)
|
||||
var input <-chan model.RawMessage = service.input
|
||||
|
||||
for {
|
||||
if len(batch) >= service.config.App.BatchSize {
|
||||
input = nil
|
||||
} else {
|
||||
input = service.input
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
flushCtx, cancel := context.WithTimeout(context.Background(), service.config.App.FlushTimeout.Duration)
|
||||
err := service.flush(flushCtx, batch)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.logCounters()
|
||||
return nil
|
||||
case message := <-input:
|
||||
records, err := parser.ParseTasmota(message)
|
||||
if err != nil {
|
||||
service.failed.Add(1)
|
||||
slog.Warn("failed to parse message", "topic", message.Topic, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
service.parsed.Add(uint64(len(records)))
|
||||
batch = append(batch, records...)
|
||||
|
||||
if len(batch) >= service.config.App.BatchSize {
|
||||
flushCtx, cancel := context.WithTimeout(ctx, service.config.App.FlushTimeout.Duration)
|
||||
err := service.flush(flushCtx, batch)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Error("failed to flush full batch to influx; keeping batch in memory", "count", len(batch), "error", err)
|
||||
continue
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
case <-ticker.C:
|
||||
if len(batch) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
flushCtx, cancel := context.WithTimeout(ctx, service.config.App.FlushTimeout.Duration)
|
||||
err := service.flush(flushCtx, batch)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Error("failed to flush batch to influx; will retry on next interval", "count", len(batch), "error", err)
|
||||
continue
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) flush(ctx context.Context, batch []model.Record) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := service.influxClient.Write(ctx, batch); err != nil {
|
||||
service.failed.Add(uint64(len(batch)))
|
||||
return err
|
||||
}
|
||||
|
||||
service.written.Add(uint64(len(batch)))
|
||||
slog.Info("flushed records to influx", "count", len(batch))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) logCounters() {
|
||||
slog.Info("service counters",
|
||||
"received", service.received.Load(),
|
||||
"parsed", service.parsed.Load(),
|
||||
"written", service.written.Load(),
|
||||
"dropped", service.dropped.Load(),
|
||||
"failed", service.failed.Load(),
|
||||
)
|
||||
}
|
||||
|
||||
func (service *Service) Snapshot() Snapshot {
|
||||
return Snapshot{
|
||||
Received: service.received.Load(),
|
||||
Parsed: service.parsed.Load(),
|
||||
Written: service.written.Load(),
|
||||
Dropped: service.dropped.Load(),
|
||||
Failed: service.failed.Load(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mqqt-scrubber/internal/config"
|
||||
"mqqt-scrubber/internal/model"
|
||||
)
|
||||
|
||||
type fakeWriter struct {
|
||||
mu sync.Mutex
|
||||
batches [][]model.Record
|
||||
flushed chan struct{}
|
||||
}
|
||||
|
||||
func newFakeWriter() *fakeWriter {
|
||||
return &fakeWriter{flushed: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (writer *fakeWriter) Write(_ context.Context, records []model.Record) error {
|
||||
writer.mu.Lock()
|
||||
copyBatch := append([]model.Record(nil), records...)
|
||||
writer.batches = append(writer.batches, copyBatch)
|
||||
writer.mu.Unlock()
|
||||
|
||||
select {
|
||||
case writer.flushed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (writer *fakeWriter) firstBatch() []model.Record {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
if len(writer.batches) == 0 {
|
||||
return nil
|
||||
}
|
||||
return writer.batches[0]
|
||||
}
|
||||
|
||||
func TestServiceFlushesParsedRecords(t *testing.T) {
|
||||
fake := newFakeWriter()
|
||||
service := NewService(config.Config{
|
||||
App: config.AppConfig{
|
||||
BatchSize: 2,
|
||||
BufferSize: 8,
|
||||
FlushInterval: config.DurationValue{Duration: time.Hour},
|
||||
FlushTimeout: config.DurationValue{Duration: time.Second},
|
||||
},
|
||||
}, fake)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- service.Run(ctx)
|
||||
}()
|
||||
|
||||
service.Enqueue(model.RawMessage{
|
||||
Topic: "tele/tasmota_896001/LWT",
|
||||
Payload: []byte("Online"),
|
||||
ReceivedAt: time.Date(2026, time.March, 12, 15, 21, 39, 0, time.UTC),
|
||||
})
|
||||
service.Enqueue(model.RawMessage{
|
||||
Topic: "tele/tasmota_C88994/SENSOR",
|
||||
Payload: []byte(`{"Time":"2026-03-12T16:23:13","ENERGY":{"TotalStartTime":"2026-02-04T19:13:40","Total":41.385,"Yesterday":1.124,"Today":0.799,"Period":0,"Power":1,"ApparentPower":4,"ReactivePower":4,"Factor":0.22,"Voltage":231,"Current":0.016}}`),
|
||||
ReceivedAt: time.Date(2026, time.March, 12, 16, 23, 13, 0, time.UTC),
|
||||
})
|
||||
|
||||
select {
|
||||
case <-fake.flushed:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for pipeline flush")
|
||||
}
|
||||
|
||||
batch := fake.firstBatch()
|
||||
if len(batch) != 2 {
|
||||
t.Fatalf("expected 2 records in flushed batch, got %d", len(batch))
|
||||
}
|
||||
|
||||
if batch[0].Measurement != "tasmota_lwt" {
|
||||
t.Fatalf("unexpected first measurement: %s", batch[0].Measurement)
|
||||
}
|
||||
if batch[0].Fields["online"] != true {
|
||||
t.Fatalf("unexpected lwt online field: %#v", batch[0].Fields["online"])
|
||||
}
|
||||
|
||||
if batch[1].Measurement != "tasmota_sensor" {
|
||||
t.Fatalf("unexpected second measurement: %s", batch[1].Measurement)
|
||||
}
|
||||
if batch[1].Fields["energy_total"] != float64(41.385) {
|
||||
t.Fatalf("unexpected sensor energy_total field: %#v", batch[1].Fields["energy_total"])
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("service returned error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for service shutdown")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user