add support for barometric pressure

This commit is contained in:
2026-01-29 14:04:18 +11:00
parent 7a0081b2ed
commit 5d07c5d54b
9 changed files with 401 additions and 51 deletions

View File

@@ -3,6 +3,7 @@ package config
import (
"errors"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
@@ -12,12 +13,13 @@ type Config struct {
LogLevel string `yaml:"log_level"`
MQTT struct {
Broker string `yaml:"broker"`
ClientID string `yaml:"client_id"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Topic string `yaml:"topic"`
QoS byte `yaml:"qos"`
Broker string `yaml:"broker"`
ClientID string `yaml:"client_id"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Topic string `yaml:"topic"`
QoS byte `yaml:"qos"`
Topics []MQTTTopic `yaml:"topics"`
} `yaml:"mqtt"`
DB struct {
@@ -52,6 +54,13 @@ type Config struct {
} `yaml:"wunderground"`
}
type MQTTTopic struct {
Name string `yaml:"name"`
Topic string `yaml:"topic"`
Type string `yaml:"type"`
QoS *byte `yaml:"qos"`
}
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
@@ -64,8 +73,31 @@ func Load(path string) (*Config, error) {
}
// Minimal validation
if c.MQTT.Broker == "" || c.MQTT.Topic == "" {
return nil, errors.New("mqtt broker and topic are required")
if c.MQTT.Broker == "" {
return nil, errors.New("mqtt broker is required")
}
if len(c.MQTT.Topics) == 0 && c.MQTT.Topic != "" {
qos := c.MQTT.QoS
c.MQTT.Topics = []MQTTTopic{{
Name: "ws90",
Topic: c.MQTT.Topic,
Type: "ws90",
QoS: &qos,
}}
}
if len(c.MQTT.Topics) == 0 {
return nil, errors.New("mqtt topic(s) are required")
}
for i := range c.MQTT.Topics {
t := c.MQTT.Topics[i]
if t.Topic == "" {
return nil, errors.New("mqtt topics must include topic")
}
if t.Type == "" {
t.Type = "ws90"
}
t.Type = strings.ToLower(t.Type)
c.MQTT.Topics[i] = t
}
if c.DB.ConnString == "" {
return nil, errors.New("db conn_string is required")

View File

@@ -122,3 +122,27 @@ func (d *DB) UpsertOpenMeteoHourly(ctx context.Context, p InsertOpenMeteoHourlyP
return err
}
type InsertBarometerParams struct {
TS time.Time
Site string
Source string
PressureHPA float64
Payload map[string]any
}
func (d *DB) InsertBarometer(ctx context.Context, p InsertBarometerParams) error {
b, _ := json.Marshal(p.Payload)
payloadJSON := json.RawMessage(b)
_, err := d.Pool.Exec(ctx, `
INSERT INTO observations_baro (
ts, site, source, pressure_hpa, payload_json
) VALUES (
$1,$2,$3,$4,$5
)
`, p.TS, p.Site, p.Source, p.PressureHPA, payloadJSON)
return err
}

View File

@@ -0,0 +1,105 @@
package mqttingest
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
type BarometerPayload struct {
PressureHPA float64
}
func ParseBarometer(b []byte) (*BarometerPayload, map[string]any, error) {
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
return nil, nil, err
}
pressure, ok := pressureHPAFromPayload(raw)
if !ok {
return nil, raw, fmt.Errorf("barometer payload missing pressure field")
}
return &BarometerPayload{
PressureHPA: pressure,
}, raw, nil
}
func pressureHPAFromPayload(raw map[string]any) (float64, bool) {
if v, ok := findFloat(raw,
"pressure_hpa",
"pressure_mb",
"pressure_mbar",
"barometer_hpa",
"baro_hpa",
"pressure",
); ok {
return v, true
}
if v, ok := findFloat(raw, "pressure_pa"); ok {
return v / 100.0, true
}
if v, ok := findFloat(raw, "pressure_kpa"); ok {
return v * 10.0, true
}
if v, ok := findFloat(raw, "pressure_inhg", "barometer_inhg"); ok {
return v * 33.8638866667, true
}
return 0, false
}
func findFloat(raw map[string]any, keys ...string) (float64, bool) {
for _, key := range keys {
v, ok := raw[key]
if !ok {
continue
}
if f, ok := asFloat(v); ok {
return f, true
}
}
return 0, false
}
func asFloat(v any) (float64, bool) {
switch t := v.(type) {
case float64:
return t, true
case float32:
return float64(t), true
case int:
return float64(t), true
case int8:
return float64(t), true
case int16:
return float64(t), true
case int32:
return float64(t), true
case int64:
return float64(t), true
case uint:
return float64(t), true
case uint8:
return float64(t), true
case uint16:
return float64(t), true
case uint32:
return float64(t), true
case uint64:
return float64(t), true
case json.Number:
f, err := t.Float64()
return f, err == nil
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
default:
return 0, false
}
}

View File

@@ -15,6 +15,12 @@ type MQTTConfig struct {
Password string
Topic string
QoS byte
Topics []Subscription
}
type Subscription struct {
Topic string
QoS byte
}
type Handler func(ctx context.Context, topic string, payload []byte) error
@@ -38,7 +44,22 @@ func RunSubscriber(ctx context.Context, cfg MQTTConfig, h Handler) error {
}
// Subscribe
if tok := client.Subscribe(cfg.Topic, cfg.QoS, func(_ mqtt.Client, msg mqtt.Message) {
subs := map[string]byte{}
if len(cfg.Topics) > 0 {
for _, sub := range cfg.Topics {
if sub.Topic == "" {
continue
}
subs[sub.Topic] = sub.QoS
}
} else if cfg.Topic != "" {
subs[cfg.Topic] = cfg.QoS
}
if len(subs) == 0 {
return fmt.Errorf("mqtt subscribe: no topics configured")
}
if tok := client.SubscribeMultiple(subs, func(_ mqtt.Client, msg mqtt.Message) {
// Keep callback short; do work with context
_ = h(ctx, msg.Topic(), msg.Payload())
}); tok.Wait() && tok.Error() != nil {

View File

@@ -0,0 +1,36 @@
package mqttingest
import "strings"
// TopicMatches reports whether a topic filter (with + or # wildcards) matches a topic name.
// It follows the MQTT v3.1.1 wildcard rules and supports shared subscriptions ($share).
func TopicMatches(filter, topic string) bool {
return matchTopic(routeSplit(filter), strings.Split(topic, "/"))
}
func matchTopic(route []string, topic []string) bool {
if len(route) == 0 {
return len(topic) == 0
}
if len(topic) == 0 {
return route[0] == "#"
}
if route[0] == "#" {
return true
}
if route[0] == "+" || route[0] == topic[0] {
return matchTopic(route[1:], topic[1:])
}
return false
}
// routeSplit removes $share/group/ when matching shared subscription filters.
func routeSplit(route string) []string {
if strings.HasPrefix(route, "$share/") {
parts := strings.Split(route, "/")
if len(parts) > 2 {
return parts[2:]
}
}
return strings.Split(route, "/")
}