Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
pvainio committed Nov 2, 2021
1 parent bca529f commit 9dfff55
Show file tree
Hide file tree
Showing 4 changed files with 375 additions and 0 deletions.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
29 changes: 29 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
279 changes: 279 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 9dfff55

Please sign in to comment.