From 9dfff55ae688fc80dcd81b60a4503ba61ff8ec0e Mon Sep 17 00:00:00 2001 From: Pekka Vainio Date: Tue, 2 Nov 2021 11:38:24 +0200 Subject: [PATCH] Initial commit --- README.md | 48 ++++++++++ go.mod | 19 ++++ go.sum | 29 ++++++ main.go | 279 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..0386762 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Sensirion SCD30 CO2 sensor MQTT gateway for Home Assistant + +## Overview + +This gateway can be used to publish measurements SCD30 to mqtt. + +It supports Home Assistant MQTT Discovery but can also be used without Home Assistant. + +Only requirement is MQTT Broker to connect to. + +## Example usecase + +Attach SCD30 to Raspberry Pi Zero W I2C bus and run this gateway to publish CO2, temperature and humidity to Home Assistant. + +## Configuration + +Application is configure with environment variables + +| variable | required | default | description | +|-----------------|:--------:|---------|-------------| +| SCD30_MQTT_URL | x | | mqtt url, for example tcp://10.1.2.3:8883 | +| SCD30_MQTT_USER | | | mqtt username | +| SCD30_MQTT_PASSWORD | | | mqtt password | +| SCD30_MQTT_CLIENT_ID | | scd30 | mqtt client id | +| SCD30_DEBUG | | false | enable debug output, true/false | +| SCD30_ID | | scd30 | home assistant discovery id | +| SCD30_TEMP_OFFSET | | 150 | temperature compensation offset | +| SCD30_NAME | | SCD30 | home assistant device name | +| SCD30_INTEVAL | | 50 | measurement interval in seconds | + +## Usage + +For example with following script +```sh +#!/bin/sh + +# Change to your real mqtt url +export MQTT_URL=tcp://localhost:8883 + +./scd30-mqtt +``` + +## MQTT Topics used + +- homeassistant/status subscribe to HA status changes +- scd30/_id_/co2 publish co2 +- scd30/_id_/temperature publish temperature +- scd30/_id_/humidity publish humidity diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2c884f6 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/pvainio/scd30-mqtt + +go 1.17 + +require github.com/pvainio/scd30 v0.0.1 + +require ( + github.com/gorilla/websocket v1.4.2 // indirect + golang.org/x/net v0.0.0-20211029224645-99673261e6eb // indirect +) + +require ( + github.com/eclipse/paho.mqtt.golang v1.3.5 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/sigurn/crc8 v0.0.0-20160107002456-e55481d6f45c // indirect + github.com/sigurn/utils v0.0.0-20190728110027-e1fefb11a144 // indirect + periph.io/x/conn/v3 v3.6.9 + periph.io/x/host/v3 v3.7.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c6ae639 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/eclipse/paho.mqtt.golang v1.3.5 h1:sWtmgNxYM9P2sP+xEItMozsR3w0cqZFlqnNN1bdl41Y= +github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/pvainio/scd30 v0.0.0 h1:ISENJRndmH+EeqhYWd9WHXF8ZW558tjV8/YF4qwAn5k= +github.com/pvainio/scd30 v0.0.0/go.mod h1:wf93naSx0qvVeZUPMepOpcIOw4svGg+3+6SEOdhICIk= +github.com/pvainio/scd30 v0.0.1 h1:YMmgDn4dwoZe6K6l3ZBUcZBeqJuQXFT5cSERANboDAo= +github.com/pvainio/scd30 v0.0.1/go.mod h1:wf93naSx0qvVeZUPMepOpcIOw4svGg+3+6SEOdhICIk= +github.com/sigurn/crc8 v0.0.0-20160107002456-e55481d6f45c h1:hk0Jigjfq59yDMgd6bzi22Das5tyxU0CtOkh7a9io84= +github.com/sigurn/crc8 v0.0.0-20160107002456-e55481d6f45c/go.mod h1:cyrWuItcOVIGX6fBZ/G00z4ykprWM7hH58fSavNkjRg= +github.com/sigurn/utils v0.0.0-20190728110027-e1fefb11a144 h1:ccb8W1+mYuZvlpn/mJUMAbsFHTMCpcJBS78AsBQxNcY= +github.com/sigurn/utils v0.0.0-20190728110027-e1fefb11a144/go.mod h1:VRI4lXkrUH5Cygl6mbG1BRUfMMoT2o8BkrtBDUAm+GU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U= +golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb h1:pirldcYWx7rx7kE5r+9WsOXPXK0+WH5+uZ7uPmJ44uM= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +periph.io/x/conn/v3 v3.6.9 h1:cSAvXC6IRRYC9pTW/Fzhp0a7zq+aeAxV8+/JZ+oxwZI= +periph.io/x/conn/v3 v3.6.9/go.mod h1:UqWNaPMosWmNCwtufoTSTTYhB2wXWsMRAJyo1PlxO4Q= +periph.io/x/d2xx v0.0.4/go.mod h1:38Euaaj+s6l0faIRHh32a+PrjXvxFTFkPBEQI0TKg34= +periph.io/x/host/v3 v3.7.1 h1:SAe/7IWSOoFsqh2/74+SxbqehzOPny+jAPs25fd/NUI= +periph.io/x/host/v3 v3.7.1/go.mod h1:kqMB+cJHtIPQCCMqDoiIMwr0pu1p+qQObkrPha3mX6E= diff --git a/main.go b/main.go new file mode 100644 index 0000000..610a7e2 --- /dev/null +++ b/main.go @@ -0,0 +1,279 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "math" + "os" + "time" + + "github.com/pvainio/scd30" + + "periph.io/x/conn/v3/i2c/i2creg" + "periph.io/x/host/v3" + + "github.com/kelseyhightower/envconfig" + + mqttClient "github.com/eclipse/paho.mqtt.golang" +) + +type Config struct { + MqttUrl string `envconfig:"mqtt_url" required:"true"` + MqttUser string `envconfig:"mqtt_user"` + MqttPwd string `envconfig:"mqtt_password"` + MqttClientId string `envconfig:"mqtt_client_id" default:"scd30"` + Interval uint16 `envconfig:"interval" default:"50"` + TempOffset uint16 `envconfig:"temp_offset" default:"150"` + Id string `envconfig:"id" default:"scd30"` + Name string `envconfig:"name" default:"SCD30"` + Debug bool `envconfig:"debug" default:"false"` +} + +type measurement struct { + id string + time time.Time + value float32 + format string + min float32 + max float32 +} + +var ( + config Config + + logInfo *log.Logger + logDebug *log.Logger + + co2 *measurement + humidity *measurement + temperature *measurement + + mqtt mqttClient.Client + + homeassistantStatus = make(chan string, 10) +) + +func main() { + + mqtt = connectMqtt() + + dev, err := openSCD30() + if err != nil { + log.Fatalf("error %v", err) + } + + if err := dev.StartMeasurements(config.Interval); err != nil { + log.Fatalf("error %v", err) + } + + if err := dev.SetAutomaticSelfCalibration(1); err != nil { + log.Fatalf("error %v", err) + } + + announceMeToMqttDiscovery(mqtt) + + for { + select { + case <-time.After(time.Duration(config.Interval) * time.Second): + checkMeasurement(dev) + case status := <-homeassistantStatus: + if status == "online" { + // HA became online, send discovery so it knows about entities + go announceMeToMqttDiscovery(mqtt) + } else if status != "offline" { + logInfo.Printf("unknown HA status message %s", status) + } + + } + } +} + +func openSCD30() (*scd30.SCD30, error) { + if _, err := host.Init(); err != nil { + return nil, err + } + + bus, err := i2creg.Open("") + if err != nil { + return nil, err + } + + dev, err := scd30.Open(bus) + if err != nil { + return nil, err + } + + var to uint16 = config.TempOffset + if o, err := dev.GetTemperatureOffset(); err != nil { + return nil, err + } else { + logInfo.Printf("Got temp offset %d", o) + if o != to { + logInfo.Printf("Setting offset to %d", to) + if err := dev.SetTemperatureOffset(to); err != nil { + return nil, err + } + } + } + return dev, nil +} + +func checkMeasurement(dev *scd30.SCD30) { + if has, err := dev.HasMeasurement(); err != nil { + log.Fatalf("error %v", err) + } else if !has { + return + } + + m, err := dev.GetMeasurement() + if err != nil { + log.Fatalf("error %v", err) + } + + logInfo.Printf("Got measure %f ppm %f%% %fC", m.CO2, m.Humidity, m.Temperature) + + publishIfNeeded(m.CO2, co2, 50) + publishIfNeeded(m.Temperature, temperature, 0.3) + publishIfNeeded(m.Humidity, humidity, 2) +} + +func publishIfNeeded(current float32, old *measurement, diff float64) { + if time.Since(old.time) < 600*time.Second && math.Abs(float64(current)-float64(old.value)) < diff { + return + } + + if current < old.min || current > old.max { + logInfo.Printf("value for %s is out of range %f", old.id, current) + return + } + + old.time = time.Now() + old.value = current + + publish(mqtt, stateTopic(old.id), fmt.Sprintf(old.format, current)) +} + +func subscribe(mqtt mqttClient.Client) { + logInfo.Print("subscribed to topics") + mqtt.Subscribe("homeassistant/status", 0, haStatusHandler) +} + +func haStatusHandler(mqtt mqttClient.Client, msg mqttClient.Message) { + body := string(msg.Payload()) + logInfo.Printf("received HA status %s", body) + homeassistantStatus <- body +} + +func announceMeToMqttDiscovery(mqtt mqttClient.Client) { + publishDiscovery(mqtt, "co2", "co2", "ppm", "carbon_dioxide") + publishDiscovery(mqtt, "temperature", "temperature", "°C", "temperature") + publishDiscovery(mqtt, "humidity", "humidity", "%", "humidity") +} + +func publishDiscovery(mqtt mqttClient.Client, id string, name string, unit string, class string) { + uid := config.Id + "_" + id + pname := config.Name + " " + name + discoveryTopic := fmt.Sprintf("homeassistant/sensor/%s/config", uid) + msg := discoveryMsg(id, uid, pname, unit, class) + publish(mqtt, discoveryTopic, msg) +} + +func publish(mqtt mqttClient.Client, topic string, msg interface{}) { + + logDebug.Printf("publish to %s: %s", topic, msg) + + t := mqtt.Publish(topic, 0, false, msg) + go func() { + _ = t.Wait() + if t.Error() != nil { + logInfo.Printf("publishing msg failed %v", t.Error()) + } + }() +} + +func discoveryMsg(id string, uid string, name string, unit string, class string) []byte { + msg := make(map[string]interface{}) + msg["unique_id"] = uid + msg["name"] = name + + dev := make(map[string]string) + msg["device"] = dev + dev["identifiers"] = config.Id + dev["manufacturer"] = "Sensirion" + dev["name"] = "Sensirion SCD30" + dev["model"] = "SCD30" + + msg["state_topic"] = stateTopic(id) + + msg["expire_after"] = 1800 + + msg["unit_of_measurement"] = unit + msg["state_class"] = "measurement" + msg["device_class"] = class + + jsonm, err := json.Marshal(msg) + if err != nil { + logInfo.Printf("cannot marshal json %v", err) + } + return jsonm +} + +func stateTopic(id string) string { + return "scd30/" + config.Id + "/" + id +} + +func init() { + + co2 = &measurement{id: "co2", format: "%.0f", min: 100, max: 10000} + humidity = &measurement{id: "humidity", format: "%.0f", min: 1, max: 100} + temperature = &measurement{id: "temperature", format: "%.1f", min: -50, max: 150} + + err := envconfig.Process("scd30", &config) + if err != nil { + log.Fatal(err.Error()) + } + + logInfo = log.New(os.Stdout, "INFO ", log.Ldate|log.Ltime|log.Lmsgprefix) + + if config.Debug { + logDebug = log.New(os.Stdout, "DEBUG ", log.Ldate|log.Ltime|log.Lmsgprefix) + } else { + logDebug = log.New(ioutil.Discard, "DEBUG ", 0) + } +} + +func connectHandler(client mqttClient.Client) { + options := client.OptionsReader() + logInfo.Printf("MQTT connected to %s", options.Servers()) + subscribe(client) +} + +func connectMqtt() mqttClient.Client { + + opts := mqttClient.NewClientOptions(). + AddBroker(config.MqttUrl). + SetClientID(config.MqttClientId). + SetOrderMatters(false). + SetKeepAlive(150 * time.Second). + SetAutoReconnect(true). + SetOnConnectHandler(connectHandler) + + if len(config.MqttUser) > 0 { + opts = opts.SetUsername(config.MqttUser) + } + + if len(config.MqttPwd) > 0 { + opts = opts.SetPassword(config.MqttPwd) + } + + logInfo.Printf("connecting to mqtt %s client id %s user %s", opts.Servers, opts.ClientID, opts.Username) + + c := mqttClient.NewClient(opts) + if token := c.Connect(); token.Wait() && token.Error() != nil { + panic(token.Error()) + } + + return c +}