first commit

This commit is contained in:
tfa
2022-12-08 14:59:39 +01:00
commit 5b812225bb
8 changed files with 460 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
lywsd03mmc-mqtt-exporter
go.sum

20
LICENSE Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
[MQTT]
host=localhost
port=1883

11
go.mod Normal file
View 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
View 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
View 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)
}