first commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
lywsd03mmc-mqtt-exporter
|
||||
go.sum
|
||||
20
LICENSE
Normal file
20
LICENSE
Normal file
@@ -0,0 +1,20 @@
|
||||
Copyright (C) 2020 Leah Neukirchen <leah@vuxu.org>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# lywsd03mmc-exporter — a MQTT exporter for the LYWSD03MMC BLE thermometer
|
||||
|
||||
lywsd03mmc-exporter is a small tool to scan and keep track of
|
||||
Bluetooth beacons the LYWSD03MMC BLE thermometer sends periodically.
|
||||
|
||||
Data are publised to an MQTT broker
|
||||
|
||||
## Modes of operation
|
||||
|
||||
Due to talking to lower levels of the Bluetooth stack,
|
||||
lywsd03mmc-exporter needs to be run as `root` or with CAP_NET_ADMIN.
|
||||
|
||||
### Stock firmware
|
||||
|
||||
To use lywsd03mmc-exporter with the
|
||||
[stock firmware](https://github.com/custom-components/sensor.mitemp_bt/files/4022697/d4135e135443ba86e403ecb2af2bf0af_upd_miaomiaoce.sensor_ht.t2.zip),
|
||||
you need to *activate* your device and extract the *Mi Bindkey*. You
|
||||
can either use the Xiaomi Home software for that (requires an account
|
||||
and a HTTPS MITM attack on your phone), or more easily, use the
|
||||
[TelinkFlasher](https://atc1441.github.io/TelinkFlasher.html) provided
|
||||
by [@atc1441](https://github.com/atc1441).
|
||||
|
||||
You will need to create a keyfile in a format like this,
|
||||
and use `-k file`:
|
||||
|
||||
```
|
||||
# format: MAC KEY, hex digits only
|
||||
A4C138FFFFFF 00112233445566778899aabbccddeeff
|
||||
```
|
||||
|
||||
This mode sends measurements every 10 minutes.
|
||||
|
||||
Note: Supposedly, the battery ratio is always 100% unless the battery
|
||||
is really empty.
|
||||
|
||||
### Custom firmware
|
||||
|
||||
@atc1441 wrote a [custom firmware](https://github.com/atc1441/ATC_MiThermometer)
|
||||
for the LYWSD03MMC. It sends data unencrypted in beacons.
|
||||
Negative temperatures are supported.
|
||||
|
||||
You can flash it easily with above TelinkFlasher.
|
||||
|
||||
This mode sends measurements every 10 seconds.
|
||||
|
||||
### Polling mode
|
||||
|
||||
This requires an active connection to the device.
|
||||
Pass the MAC addresses of the devices as arguments to lywsd03mmc-exporter.
|
||||
This is currently limited to one device (bug in go-ble?).
|
||||
|
||||
## Config file
|
||||
|
||||
```
|
||||
[MQTT]
|
||||
host=localhost
|
||||
port=1883
|
||||
#user=user
|
||||
#pass=pass
|
||||
```
|
||||
|
||||
## Copying
|
||||
|
||||
lywsd03mmc-exporter is licensed under the MIT license.
|
||||
|
||||
## Thanks
|
||||
|
||||
This software would not be possible without the help of code and
|
||||
documentation in:
|
||||
|
||||
* https://github.com/atc1441/ATC_MiThermometer
|
||||
* https://github.com/danielkucera/mi-standardauth
|
||||
* https://github.com/ahpohl/xiaomi_lywsd03mmc
|
||||
* https://github.com/custom-components/sensor.mitemp_bt
|
||||
* https://github.com/JsBergbau/MiTemperature2
|
||||
* https://github.com/lcsfelix/reading-xiaomi-temp
|
||||
* https://tasmota.github.io/docs/Bluetooth/
|
||||
* https://github.com/DazWilkin/gomijia2
|
||||
* https://github.com/leahneukirchen/lywsd03mmc-exporter
|
||||
50
config.go
Normal file
50
config.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/currantlabs/ble/linux"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// Config represents a configuration
|
||||
type Config struct {
|
||||
MQTT MQTT
|
||||
Host *linux.Device
|
||||
}
|
||||
|
||||
// NewConfig returns a new Config
|
||||
func NewConfig(file string) (*Config, error) {
|
||||
log.Printf("[Config] Loading Configuration (%s)", file)
|
||||
cfg, err := ini.Load(file)
|
||||
if err != nil {
|
||||
return &Config{}, err
|
||||
}
|
||||
|
||||
sec, err := cfg.GetSection("MQTT")
|
||||
if err != nil {
|
||||
return &Config{}, err
|
||||
}
|
||||
if !sec.HasKey("host") {
|
||||
return &Config{}, fmt.Errorf("Configuration requires MQTT host")
|
||||
}
|
||||
|
||||
if !sec.HasKey("port") {
|
||||
log.Print("MQTT port not defined; defaulting to 1883")
|
||||
sec.NewKey("port", "1883")
|
||||
}
|
||||
|
||||
mqtt := MQTT{
|
||||
Host: sec.Key("host").String(),
|
||||
Port: sec.Key("port").String(),
|
||||
User: sec.Key("user").String(),
|
||||
Pass: sec.Key("pass").String(),
|
||||
}
|
||||
|
||||
|
||||
return &Config{
|
||||
MQTT: mqtt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
3
config.ini
Normal file
3
config.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[MQTT]
|
||||
host=localhost
|
||||
port=1883
|
||||
11
go.mod
Normal file
11
go.mod
Normal file
@@ -0,0 +1,11 @@
|
||||
module lywsd03mmc-mqtt-exporter
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/currantlabs/ble v0.0.0-20171229162446-c1d21c164cf8
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.2
|
||||
github.com/go-ble/ble v0.0.0-20200407180624-067514cd6e24
|
||||
github.com/pschlump/AesCCM v0.0.0-20160925022350-c5df73b5834e
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
)
|
||||
214
lywsd03mmc-exporter.go
Normal file
214
lywsd03mmc-exporter.go
Normal file
@@ -0,0 +1,214 @@
|
||||
// lywsd03mmc-exporter - a Prometheus exporter for the LYWSD03MMC BLE thermometer
|
||||
|
||||
// Copyright (C) 2020 Leah Neukirchen <leah@vuxu.org>
|
||||
// Licensed under the terms of the MIT license, see LICENSE.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-ble/ble"
|
||||
"github.com/go-ble/ble/examples/lib/dev"
|
||||
)
|
||||
|
||||
|
||||
// Reading represents a Temperature|Humidity readings
|
||||
type Reading struct {
|
||||
Temperature float64
|
||||
Humidity float64
|
||||
}
|
||||
|
||||
const Sensor = "LYWSD03MMC"
|
||||
const TelinkVendorPrefix = "a4:c1:38"
|
||||
|
||||
var EnvironmentalSensingUUID = ble.UUID16(0x181a)
|
||||
var XiaomiIncUUID = ble.UUID16(0xfe95)
|
||||
|
||||
const ExpiryAtc = 2.5 * 10 * time.Second
|
||||
const ExpiryStock = 2.5 * 10 * time.Minute
|
||||
const ExpiryConn = 2.5 * 10 * time.Second
|
||||
|
||||
var expirers = make(map[string]*time.Timer)
|
||||
var expirersLock sync.Mutex
|
||||
|
||||
|
||||
|
||||
var (
|
||||
configFile = flag.String("config_file", "config.ini", "Config file location")
|
||||
)
|
||||
|
||||
var config, ERR = NewConfig(*configFile)
|
||||
|
||||
func bump(mac string, expiry time.Duration) {
|
||||
expirersLock.Lock()
|
||||
if t, ok := expirers[mac]; ok {
|
||||
t.Reset(expiry)
|
||||
} else {
|
||||
expirers[mac] = time.AfterFunc(expiry, func() {
|
||||
fmt.Printf("expiring %s\n", mac)
|
||||
expirersLock.Lock()
|
||||
delete(expirers, mac)
|
||||
expirersLock.Unlock()
|
||||
})
|
||||
}
|
||||
expirersLock.Unlock()
|
||||
}
|
||||
|
||||
func macWithColons(mac string) string {
|
||||
return strings.ToUpper(fmt.Sprintf("%s:%s:%s:%s:%s:%s",
|
||||
mac[0:2],
|
||||
mac[2:4],
|
||||
mac[4:6],
|
||||
mac[6:8],
|
||||
mac[8:10],
|
||||
mac[10:12]))
|
||||
}
|
||||
|
||||
func macWithoutColons(mac string) string {
|
||||
return strings.ReplaceAll(strings.ToUpper(mac), ":", "")
|
||||
}
|
||||
|
||||
func decodeSign(i uint16) int {
|
||||
if i < 32768 {
|
||||
return int(i)
|
||||
} else {
|
||||
return int(i) - 65536
|
||||
}
|
||||
}
|
||||
|
||||
func registerData(data []byte, frameMac string, rssi int) {
|
||||
if len(data) != 13 {
|
||||
return
|
||||
}
|
||||
|
||||
mac := fmt.Sprintf("%X", data[0:6])
|
||||
|
||||
if mac != frameMac {
|
||||
return
|
||||
}
|
||||
|
||||
temp := float64(decodeSign(binary.BigEndian.Uint16(data[6:8]))) / 10.0
|
||||
hum := float64(data[8])
|
||||
batp := float64(data[9])
|
||||
batv := float64(binary.BigEndian.Uint16(data[10:12])) / 1000.0
|
||||
//frame := float64(data[12])
|
||||
|
||||
bump(mac, ExpiryAtc)
|
||||
|
||||
logTemperature(mac, temp)
|
||||
logHumidity(mac, hum)
|
||||
logBatteryPercent(mac, batp)
|
||||
logVoltage(mac, batv)
|
||||
logRssi(mac, rssi)
|
||||
var r = Reading{temp, hum}
|
||||
config.MQTT.Publish(mac,&r)
|
||||
}
|
||||
|
||||
func advHandler(a ble.Advertisement) {
|
||||
mac := strings.ReplaceAll(strings.ToUpper(a.Addr().String()), ":", "")
|
||||
|
||||
for _, sd := range a.ServiceData() {
|
||||
if sd.UUID.Equal(EnvironmentalSensingUUID) {
|
||||
registerData(sd.Data, mac, a.RSSI())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func logTemperature(mac string, temp float64) {
|
||||
log.Printf("%s thermometer_temperature_celsius %.1f\n", mac, temp)
|
||||
}
|
||||
|
||||
func logHumidity(mac string, hum float64) {
|
||||
log.Printf("%s thermometer_humidity_ratio %.0f\n", mac, hum)
|
||||
}
|
||||
|
||||
func logVoltage(mac string, batv float64) {
|
||||
log.Printf("%s thermometer_battery_volts %.3f\n", mac, batv)
|
||||
}
|
||||
|
||||
func logBatteryPercent(mac string, batp float64) {
|
||||
log.Printf("%s thermometer_battery_ratio %.0f\n", mac, batp)
|
||||
}
|
||||
|
||||
func logRssi(mac string, rssi int) {
|
||||
log.Printf("%s thermometer_rssi %d\n", mac, rssi)
|
||||
}
|
||||
|
||||
func decodeAtcTemp(mac string) func(req []byte) {
|
||||
return func(req []byte) {
|
||||
temp := float64(decodeSign(binary.LittleEndian.Uint16(req[0:2]))) / 10.0
|
||||
bump(mac, ExpiryConn)
|
||||
logTemperature(mac, temp)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAtcHumidity(mac string) func(req []byte) {
|
||||
return func(req []byte) {
|
||||
hum := float64(binary.LittleEndian.Uint16(req[0:2])) / 100.0
|
||||
bump(mac, ExpiryConn)
|
||||
logHumidity(mac, hum)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAtcBattery(mac string) func(req []byte) {
|
||||
return func(req []byte) {
|
||||
batp := float64(req[0])
|
||||
bump(mac, ExpiryConn)
|
||||
logBatteryPercent(mac, batp)
|
||||
}
|
||||
}
|
||||
|
||||
// ToString converts a Reading to a string
|
||||
func (r *Reading) String() string {
|
||||
return fmt.Sprintf("Temperature: %.04f; Humidity: %.04f", r.Temperature, r.Humidity)
|
||||
}
|
||||
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
//config, err := NewConfig(*configFile)
|
||||
//if err != nil {
|
||||
// log.Fatal("Unable to parse configuration")
|
||||
//}
|
||||
|
||||
log.Printf("MQTT broker: %s", config.MQTT.Server())
|
||||
if err := config.MQTT.Connect("xiaomi"); err != nil {
|
||||
log.Print("[main] Unable to connect to MQTT broker")
|
||||
}
|
||||
|
||||
|
||||
deviceID := flag.Int("i", 0, "use device hci`N`")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Usage: %s [FLAGS...] \n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
device, err := dev.NewDevice("default", ble.OptDeviceID(*deviceID))
|
||||
if err != nil {
|
||||
log.Fatal("oops: ", err)
|
||||
}
|
||||
|
||||
ble.SetDefaultDevice(device)
|
||||
|
||||
ctx := ble.WithSigHandler(context.Background(), nil)
|
||||
|
||||
telinkVendorFilter := func(a ble.Advertisement) bool {
|
||||
return strings.HasPrefix(a.Addr().String(), TelinkVendorPrefix)
|
||||
}
|
||||
err = ble.Scan(ctx, true, advHandler, telinkVendorFilter)
|
||||
if err != nil {
|
||||
log.Fatal("oops: %s", err)
|
||||
}
|
||||
}
|
||||
81
mqtt.go
Normal file
81
mqtt.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// MQTT represents an MQTT client configuration
|
||||
type MQTT struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Pass string
|
||||
Client mqtt.Client
|
||||
id string
|
||||
}
|
||||
|
||||
// Server returns a new MQTT connection string
|
||||
func (m *MQTT) Server() string {
|
||||
return fmt.Sprintf("tcp://%s:%s", m.Host, m.Port)
|
||||
}
|
||||
|
||||
// Connect connects to an MQTT broker
|
||||
func (m *MQTT) Connect(id string) error {
|
||||
log.Printf("[MQTT:Connect] Connecting: %s", id)
|
||||
|
||||
m.id = id
|
||||
|
||||
opts := mqtt.NewClientOptions().AddBroker(m.Server())
|
||||
opts.SetClientID(id)
|
||||
opts.SetKeepAlive(30 * time.Second)
|
||||
opts.SetPingTimeout(10 * time.Second)
|
||||
if m.User != "" {
|
||||
opts.SetUsername(m.User)
|
||||
}
|
||||
if m.Pass != "" {
|
||||
opts.SetPassword(m.Pass)
|
||||
}
|
||||
|
||||
log.Print("[MQTT] Creating")
|
||||
m.Client = mqtt.NewClient(opts)
|
||||
|
||||
log.Print("[MQTT] Connecting")
|
||||
if token := m.Client.Connect(); token.Wait() && token.Error() != nil {
|
||||
return (token.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect disconnects from an MQTT broker
|
||||
func (m *MQTT) Disconnect() {
|
||||
log.Print("[MQTT] Disconnecting")
|
||||
m.Client.Disconnect(250)
|
||||
}
|
||||
|
||||
// Publish publishes a message to an MQTT topic
|
||||
func (m *MQTT) Publish(name string, r *Reading) {
|
||||
log.Printf("[MQTT:Publish] %s (%s), %s", name, r.String(), m.id)
|
||||
format := "sensors/%s/%s/%s"
|
||||
|
||||
// Publish Temperature
|
||||
{
|
||||
topic := fmt.Sprintf(format, m.id, name, "temperature")
|
||||
value := fmt.Sprintf("%04f", r.Temperature)
|
||||
m.publish(topic, value)
|
||||
}
|
||||
// Publish Humidity
|
||||
{
|
||||
topic := fmt.Sprintf(format, m.id, name, "humidity")
|
||||
value := fmt.Sprintf("%04f", r.Humidity)
|
||||
m.publish(topic, value)
|
||||
}
|
||||
}
|
||||
func (m *MQTT) publish(topic string, payload interface{}) {
|
||||
log.Printf("[MQTT:publish] %s (%v)", topic, payload)
|
||||
m.Client.Publish(topic, 0, false, payload)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user