nathanfaber / meaterble

Meater BLE reverse engineering
71 stars 15 forks source link

Meater+ support #9

Open koffienl opened 3 years ago

koffienl commented 3 years ago

Is there any chance to support the meater+ instead of only the probe?

koffienl commented 3 years ago

Played around with your existing code. when connecting to a meater+ you need a different address:

tempBytes = self.readCharacteristic(36)         
batteryBytes = self.readCharacteristic(40)

I wasn't able to find the firmware and ID in the meater+ so I stripped that part out.

caleb-mabry commented 9 months ago

When I ended up following this for my Meater+ I got temperatures above 500 degrees farenheight

caleb-mabry commented 9 months ago

Going to kind of talk about what I'm finding here as I begin working on this. I don't have a regular Meater to work with, only the Meater+. Working on this, I had to enable:

By default GATT is not enable. Add the below lines to /etc/bluetooth/main.conf

EnableLE = true           // Enable Low Energy support. Default is false.
AttributeServer = true    // Enable the GATT attribute server. Default is false.

But I noticed that Meater and Meater+ show up when I am using sudo hcitool lescan. I'm not familiar with this space but it seems odd that I would see both in the list.

nathanfaber commented 9 months ago

Going to kind of talk about what I'm finding here as I begin working on this. I don't have a regular Meater to work with, only the Meater+. Working on this, I had to enable:

By default GATT is not enable. Add the below lines to /etc/bluetooth/main.conf

EnableLE = true           // Enable Low Energy support. Default is false.
AttributeServer = true    // Enable the GATT attribute server. Default is false.

But I noticed that Meater and Meater+ show up when I am using sudo hcitool lescan. I'm not familiar with this space but it seems odd that I would see both in the list.

IIRC The meater+ is simply a BLE extension unit in the dock. The probe itself is just a normal Meater. Try taking the battery out of the Meater+ and talking directly to the Meater and see what you get for readings. I actually haven't used this in a while so I apologize if my information is a bit stale. My Meater+ is also not local to me right now so I cannot easily test.

caleb-mabry commented 9 months ago

Wow, that worked immediately! :confetti_ball:
The readings are fine so long as I remove the battery from the Meater+. It seems like, after taking the probe off of the block, the Meater and Meater+ appear in the scan, but shortly after Meater is removed and only Meater+ remains.

I was exploring using gatttool and noticed that char-read-hnd 24 changes values as I heat up the probe using my hand.

Characteristic value/descriptor: a2 01 21 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: a2 01 21 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: a2 01 22 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: 9f 01 21 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: 9f 01 21 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: 9f 01 21 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: 9f 01 21 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: 9f 01 21 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: 9f 01 21 00 27 00 20 00 
[B8:1F:5E:B1:D1:0D][LE]> char-read-hnd 24
Characteristic value/descriptor: 9f 01 21 00 27 00 20 00 

I was assuming that they use some of the bytes/bits in here to change the temperature. But I also noticed some weird math you've done regarding taking the bytes and converting them into actual temperatures.

   def convertAmbient(array): 
      tip = MeaterProbe.bytesToInt(array[0], array[1])
      ra  = MeaterProbe.bytesToInt(array[2], array[3])
      oa  = MeaterProbe.bytesToInt(array[4], array[5])
      return int(tip+(max(0,((((ra-min(48,oa))*16)*589))/1487)))

And was wondering where those magic numbers come from 48, 16, 589, etc. I'm assuming there's something to do with converting the temperature unit from the actual hardware into the actual number but that's a bit out of my depth currently.

If the Meater+ is a BLE extension, I would love to see a way for me to not have to remove the battery to check the temperature reading.

Would love to get your thoughts on things I might be able to look into to help me determine these values

nathanfaber commented 9 months ago

It is removed because the Meater+ connects to the Meater and the Meater only supports a single BLE connection. When the relay is active via the Meater+ and it is connected to the Meater, you will only see the Meater+. As for the other magic numbers, I'm not currently sure and unable to look at this moment.

caleb-mabry commented 9 months ago

No worries. I appreciate what you've done for this project! I'll play around with this in my free time. But out of the box this has worked extremely well!

narodnik commented 4 months ago

I have the meater+ and by hacking around have managed to get readings working:

import asyncio, time
from bleak import BleakClient

addr = "B8:1F:5E:95:64:8B"
# 35 and 60 are interesting
temp_char = "7edda774-045e-4bbf-909b-45d1991a2876"

#00002a24-0000-1000-8000-00805f9b34fb (Handle: 14): Model Number String
#00002a25-0000-1000-8000-00805f9b34fb (Handle: 16): Serial Number String
#00002a29-0000-1000-8000-00805f9b34fb (Handle: 24): Manufacturer Name String
#00002a23-0000-1000-8000-00805f9b34fb (Handle: 12): System ID
#00002a26-0000-1000-8000-00805f9b34fb (Handle: 18): Firmware Revision String
#00002a50-0000-1000-8000-00805f9b34fb (Handle: 28): PnP ID
#00002a28-0000-1000-8000-00805f9b34fb (Handle: 22): Software Revision String
#00002a27-0000-1000-8000-00805f9b34fb (Handle: 20): Hardware Revision String
#00002a2a-0000-1000-8000-00805f9b34fb (Handle: 26): IEEE 11073-20601 Regulatory Cert. Data List
#7edda774-045e-4bbf-909b-45d1991a2876 (Handle: 35): Unknown
#22db81c4-d125-4e8f-99a4-3609e4c9a017 (Handle: 52): Unknown
#2adb4877-68d8-4884-bd3c-d83853bf27b8 (Handle: 39): Unknown
#1cbff55e-9a06-4721-a178-1e2d84246dd1 (Handle: 49): Unknown
#b3e02c20-85be-4d1e-8da8-30cd88aaf0d4 (Handle: 46): Unknown
#caf28e64-3b17-4cb4-bb0a-2eaa33c47af7 (Handle: 43): Unknown
#370aabe7-4837-4bee-aadc-cd1836dbce53 (Handle: 60): Unknown
#575d3bf1-2757-45ad-94d9-875c2f6120d3 (Handle: 31): Unknown
#e03c6ccc-2aa7-40a4-8a66-c98b599b737a (Handle: 56): Unknown
#bb9b2404-fcfb-4b73-8acd-b1b08da3749d (Handle: 64): Unknown
#00002a05-0000-1000-8000-00805f9b34fb (Handle: 68): Service Changed

def bytesToInt(byte0, byte1):
    return byte1*256+byte0

def convertAmbient(array): 
    tip = bytesToInt(array[0], array[1])
    ra  = bytesToInt(array[2], array[3])
    oa  = bytesToInt(array[4], array[5])
    return tip + max(
        0,
        (ra - min(48, oa)) * 16 * 589 / 1487
    )

def toCelsius(value):
    return (float(value)+8.0)/16.0

def tip_temp(array):
    tip = bytesToInt(array[0], array[1])
    return toCelsius(tip)

async def main():
    async with BleakClient(addr) as client:
        #v = {}
        #for id, char in client.services.characteristics.items():
        #    data = await client.read_gatt_char(char)
        #    v[id] = data

        #print("go")
        #time.sleep(20)
        #for id, char in client.services.characteristics.items():
        #    data = await client.read_gatt_char(char)
        #    old = v[id]
        #    print(char.description, id, char.service_uuid)
        #    print(old)
        #    print(data)
        #    print()

        while True:
            # Tip
            data = await client.read_gatt_char(35)
            #print(len(data))
            # Ambient
            #data = await client.read_gatt_char(60)
            #print(data)
            print("ambient:", toCelsius(convertAmbient(data)))
            print("    tip:", tip_temp(data))
            print()
            time.sleep(1)

        #for id, service in client.services.services.items():
        #    print(id, service, service.description)

asyncio.run(main())

I don't really know bluetooth well, and I noticed characteristic 60 seems to change.

I just wonder where that formula came from, and what those undocumented constants all mean.

narodnik commented 3 months ago

After using it for a while, I'm not sure that formula is correct. The values it outputs seem to be too low imo.

narodnik commented 3 months ago

I downloaded the APK, unzipped com.apptionlabs.meater_app.apk, then used jadx to decompile classes.dex. Then in the decompiled output, go to sources/com/apptionlabs/meater_app/ and that's the entire app. There's a number of files there related to Meater+ which you can find with fd -i meaterplus.

I've attached the source code in case others want to look: meater_app.zip

It seems the main calculation is in data/Temperature.java. There is an enum for v1 or v2. When the "temperature resolution" is 16, it selects the v1 code path, otherwise if it's 32, then it selects v2. These calculations will decode the data differently.

model/MEATERDeviceType.java has EnumSwitchMapping which will tell you whether your device is v1 or v2. Or you can just guess (try both code paths, see which works for you).

For example my meater+ gives 8 bytes, here's one such hex: 790134002d002b00. v2 expects 10 bytes, so it must be v1 for me. The function uses getSignedInternalTemp() which in turn calls f8.b.a() which is available in the original APK. This function simply does x & 255.

Based off this, I made this Python code:

def get_signed_internal_temp(b10, b11):
    # Get a short int from b10 b11 in little endian
    a10 = (b10 & 255) * 256 + (b11 & 255);
    if a10 >= 2048:
        return a10 | (-4096)
    return a10

data = bytes.fromhex("790134002d002b00")
internal = get_signed_internal_temp(data[1], data[0])
ambient = get_signed_internal_temp(data[3], data[2])
initial_ambient_offset = get_signed_internal_temp(data[5], data[4])
lowest_ambient_offset = get_signed_internal_temp(data[5], data[4])
#print(len(data))

def ambient_from_temperature_reading(i10, i11, i12):
    return i10 + int(max(0.0, ((i11 - min(48, i12)) * 9424) / 1487.0))

def to_celsius(i10):
    if i10 > 0:
        return (i10 + 8) / 32
    elif i10 < 0:
        return (i10 - 8) / 32
    return 0

#print(to_celsius(internal))

# ConvertV1Temperatures
ambient = 2 * max(0, ambient_from_temperature_reading(internal, ambient, initial_ambient_offset))
internal *= 2

print(to_celsius(internal))
print(to_celsius(ambient))

Which shows:

23.8125
26.5625

I'm not sure if this is correct, especially since the ambient and internal (I guess this means tip?) should be the same.

narodnik commented 3 months ago

My code so far:

import asyncio, time
from bleak import BleakClient

addr = "B8:1F:5E:95:64:8B"
# 35 and 60 are interesting
temp_char = "7edda774-045e-4bbf-909b-45d1991a2876"

#00002a24-0000-1000-8000-00805f9b34fb (Handle: 14): Model Number String
#00002a25-0000-1000-8000-00805f9b34fb (Handle: 16): Serial Number String
#00002a29-0000-1000-8000-00805f9b34fb (Handle: 24): Manufacturer Name String
#00002a23-0000-1000-8000-00805f9b34fb (Handle: 12): System ID
#00002a26-0000-1000-8000-00805f9b34fb (Handle: 18): Firmware Revision String
#00002a50-0000-1000-8000-00805f9b34fb (Handle: 28): PnP ID
#00002a28-0000-1000-8000-00805f9b34fb (Handle: 22): Software Revision String
#00002a27-0000-1000-8000-00805f9b34fb (Handle: 20): Hardware Revision String
#00002a2a-0000-1000-8000-00805f9b34fb (Handle: 26): IEEE 11073-20601 Regulatory Cert. Data List
#7edda774-045e-4bbf-909b-45d1991a2876 (Handle: 35): Unknown
#22db81c4-d125-4e8f-99a4-3609e4c9a017 (Handle: 52): Unknown
#2adb4877-68d8-4884-bd3c-d83853bf27b8 (Handle: 39): Unknown
#1cbff55e-9a06-4721-a178-1e2d84246dd1 (Handle: 49): Unknown
#b3e02c20-85be-4d1e-8da8-30cd88aaf0d4 (Handle: 46): Unknown
#caf28e64-3b17-4cb4-bb0a-2eaa33c47af7 (Handle: 43): Unknown
#370aabe7-4837-4bee-aadc-cd1836dbce53 (Handle: 60): Unknown
#575d3bf1-2757-45ad-94d9-875c2f6120d3 (Handle: 31): Unknown
#e03c6ccc-2aa7-40a4-8a66-c98b599b737a (Handle: 56): Unknown
#bb9b2404-fcfb-4b73-8acd-b1b08da3749d (Handle: 64): Unknown
#00002a05-0000-1000-8000-00805f9b34fb (Handle: 68): Service Changed

def get_signed_internal_temp(b10, b11):
    # Get a short int from b10 b11 in little endian
    a10 = (b10 & 255) * 256 + (b11 & 255);
    if a10 >= 2048:
        return a10 | (-4096)
    return a10

def ambient_from_temperature_reading(i10, i11, i12):
    return i10 + int(max(0.0, ((i11 - min(48, i12)) * 9424) / 1487.0))

def to_celsius(i10):
    if i10 > 0:
        return (i10 + 8) / 32
    elif i10 < 0:
        return (i10 - 8) / 32
    return 0

async def main():
    print("Starting...")
    async with BleakClient(addr) as client:
        #v = {}
        for id, char in client.services.characteristics.items():
            print(id, char, char.uuid)
        #    print(char.description, id, char.service_uuid)
        #    print(old)
        #    print(data)
        #    print()

        while True:
            data = await client.read_gatt_char("7edda774-045e-4bbf-909b-45d1991a2876")
            if not data:
                continue

            internal = get_signed_internal_temp(data[1], data[0])
            ambient = get_signed_internal_temp(data[3], data[2])
            initial_ambient_offset = get_signed_internal_temp(data[5], data[4])
            lowest_ambient_offset = get_signed_internal_temp(data[5], data[4])
            ambient = 2 * max(0, ambient_from_temperature_reading(internal, ambient, initial_ambient_offset))
            internal *= 2

            print(to_celsius(internal))
            print(to_celsius(ambient))
            print()

            time.sleep(1)

asyncio.run(main())
narodnik commented 3 months ago

I made a simple android kivy app: https://github.com/narodnik/meater_plus_kivy_app

You will find an APK in the download section of that repo.

I verified it works now, so I reckon the calc I posted is good. Not sure what changed since it looks similar to the one before. Maybe I made a mistake, but this one is a straight reverse eng of the actual calc so it should work.