commit 5b812225bbd2c931fd6a8ddc36f45af14fa63e05 Author: tfa Date: Thu Dec 8 14:59:39 2022 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2b44de --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +lywsd03mmc-mqtt-exporter +go.sum diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a41f6d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (C) 2020 Leah Neukirchen + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..42361a9 --- /dev/null +++ b/README.md @@ -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 diff --git a/config.go b/config.go new file mode 100644 index 0000000..1ed78dd --- /dev/null +++ b/config.go @@ -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 +} + diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..27a7079 --- /dev/null +++ b/config.ini @@ -0,0 +1,3 @@ +[MQTT] +host=localhost +port=1883 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ab37ab3 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/lywsd03mmc-exporter.go b/lywsd03mmc-exporter.go new file mode 100644 index 0000000..2cac1df --- /dev/null +++ b/lywsd03mmc-exporter.go @@ -0,0 +1,214 @@ +// lywsd03mmc-exporter - a Prometheus exporter for the LYWSD03MMC BLE thermometer + +// Copyright (C) 2020 Leah Neukirchen +// 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) + } +} diff --git a/mqtt.go b/mqtt.go new file mode 100644 index 0000000..d002c43 --- /dev/null +++ b/mqtt.go @@ -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) +} +