-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
375 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |