// 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) } }