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