Meawow CO2 Detector (model: MHO-H411)

Hi everyone,

Looking to connect a CO2 detector (Meawow MHO-H411) with home assistant. It is a Bluetooth device.

2024-09-20 16_33_20-93.jpg (700×700) and 8 more pages - Personal - Microsoft​ Edge

I guess the way to do it is to be able to retrieve the Bluetooth encryption key, but none of the usual approaches worked.

Any suggestions? Did anyone managed to connect such device to HA?

Thank you!

Did you connect it? I try now, but i don’t know how

Hi! Still not able to connect…

Anyone had any luck since?

My 2 cents here:

Well, I guess I was not the only one I purchased this thing on Aliexpress a couple of years ago; and now wanted to add it to HA nowadays… of course without luck! :sweat_smile:

I don’t think this thing is quite similar to the typical Xiaomi Mija ones (or maybe pretty similar somehow, but starting with the fact is not recognizable at Mi Home is not a good start… ), and it seems no HA integration for … Meawow? (Sounds like a …cat? :scream_cat: anyways… ) sounds very unlikely to exists for now (for some reason some meawow stuff works in MiHome and have PVVX support mmphmm)… so I guess we are on uncharted waters here.

Just warn you people: I’m super noob here, just took this for learning some BT stuff… hope the info here will be useful somehow…

Took a little look with nRF connect and found several interesting characteristics that allows me to read temperature, humidity and CO2 levels (only when CO2 reading is enabled).

Service
ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6
    Characteristic
    ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6

That gives us a 7 byte data, little endian (surprise! No encryption!).

00 Temperature  - uint16 (2 bytes), multipied by 100. 
02 Humidity - uint8 (1 byte)
03 Unknown - uint16 (2 bytes), (battery perhaps? ... or hour?)
05 CO2 - uint16 (2 bytes), [0xFF 0xFF] if CO2 reading disabled

Some example data:

c2 06 3c 71 0f c7 01
'temp': 1730, 'humidity': 60, 'unknown': 3953,  'co2': 455

Made a proof of concept with python + bleak (kudos to ChatGPT) and it actually do the readings, so nice to hear.

import asyncio
from bleak import BleakClient

ADDRESS = "A4:C1:38:B7:12:BA"  # That's my MHO-H411 MAC
CHAR_UUID = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"

def decode_payload(b: bytes):
    if len(b) < 7:
        return None

    temp_raw = int.from_bytes(b[0:2], "little", signed=True)
    temp_c = temp_raw / 100.0

    humidity = b[2]

    unknown = int.from_bytes(b[3:5], "little", signed=False) 

    co2_ppm = int.from_bytes(b[5:7], "little", signed=False)

    return {
        "temperature_c": temp_c,
        "humidity_pct": humidity,
        "co2_ppm": co2_ppm,
        "unknown": unknown,
        "raw_hex": b.hex(" "),
    }

def handle_notify(_, data: bytearray):
    print("Raw data:", data.hex(" "))
    out = decode_payload(data)
    print(out)

async def main():
    async with BleakClient(ADDRESS) as client:
        print("Connected:", client.is_connected)

        await client.start_notify(CHAR_UUID, handle_notify)
        await asyncio.sleep(60)  # keep listening for 1 minute

asyncio.run(main())

Of course, it misses some stuff… Like put this thing on CO2 reading mode (otherwise you need to put the button each time you want CO2 readings). Tried to seek some other characteristics for that but got out of luck. Found some other unrelated stuff btw.

Service
ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6
    Characteristic
    ebe0ccb7-7a0a-4b0c-8a1a-6ff2997da3a6 - set this value to set the hour in seconds. Put something out of range and hour will be cleared (?)
    ebe0ccbe-7a0a-4b0c-8a1a-6ff2997da3a6 - 0x0 set Celsius, 0x1 set Farenheit.

Service
00010203-0405-0607-0809-0a0b0c0d1912
    Characteristic
    00010203-0405-0607-0809-0a0b0c0d2b12 - If I put any value (for example 0x0), the device seems to reset/crash? Pros: That implies turning ON the CO2 reading. Cons: It disconnects bluetooth... plus I think this is something related with OTA, sooooo ... It might be a bad idea playing with fire here... 

If you are quite desesperate with that you can already do something that publish that data into a MQTT broker and use it at HA.

At this point I’m seriously guessing it is better (but more boring) looking for something more popular and/or already proven for CO2 reading (considering those things could be veeeeery inaccurate). Probably this will not be veeeeery different that those MIja ones, and maybe it could even be able to run something similar like PVVX + BTHome and that stuff. But again, just noob trying to playing with fire :sweat_smile:

Well, hope this will help someone in the adventrures of making things a 2% more useful!

1 Like

Me again!
Had some sparse time while I wash my clothes (should I automate notifications? xD), then reached to this little thing again.

This time, equiped with the Meaow Smart android app + Android embedded Bluetooth snoop feature (in developer options) + Wireshark, found the following:

  • Well, seems not even the Meawow app could set the monitoring mode via bluetooth, so I guess that’s a task you need to do yourself.
    • By the user manual https://fcc.report/FCC-ID/2awmomhoh411/6308201.pdf that could be enable by double-pressing the button, and it lasts up to 4 hours (unless you have it connected to USB … or be like me and send corrupt data to make it crash (see my previous comment) and automatically turn that on at reboot whaha XD).
  • By the MAC address I saw that this thing actually uses a Telink chipset (Telink flasher anyone?). Not xiaomi one, so no chance for using that one (but it could lead into interesting stuff If I got some free time which is quite difficult).
  • Found some extra missing chunks by capturing activity made by Meawow app:
Characteristic:
ebe0ccb1-7a0a-4b0c-8a1a-6ff2997da3a6
Setting CO2 indicator light (a tiny light at upper of the device)

0x6 - Turn off indicator light
0x7 - Turn on indicator light

Chracteristic:
ebe0ccb7-7a0a-4b0c-8a1a-6ff2997da3a6
Nothing new, the hour, but now it's decoded! 

[t0 t1 t2 t3] [tz] [reserved] [fmt]
  uint32      int8   0x00     0x00/0xAA
tx: datetime (UTC) in unix timestamp format (it uses the date? really?)
tz: timezone: timezone, signed. 
00: reserved?
fmt: 0x00: 24 hour format, 0xAA for 12 hour format
(You can read and/or set this as you like).
  • App seems there is a callibration mode, where you can apparently set reference CO2 readings, but I didn’t reached that (yet… do I need thtat?).
  • And also, an interface for upgrading the firmware, but threre was anything to update (and will refuse myself to update it, don’t want to sacrifice that unencryped data).

Well, seems this thing is almost usable for making … something with it. Again, super noob in this area and not sure how to make a proper … “driver” let’s say? But as a pending list:

  • Decode that battery reading thing
  • (Optional) see how to callibrate
  • How to make a standard bluetooth library to read this? Having the MAC and assuming this will be a MHO-H411 is not enough…
  • Something to put it at HA?

Well, and if you are reading this and you are interested in cheap CO2 reading monitors, I just heard that not so long ago Sonoff announced something similar (not yet released): 2025 New Products , in the hope this thing will have better HA integration. And if does not, wel… I will have fun here haha.

Greetings!
PD: Talking about sonoff… I wonder if they will release a 64bit version of iHost :frowning: Relly liked running HA into iHost (but that thing is 32-bit and it was already deprecated boooo… )

1 Like

Do you guys think I’m dead?
Well, you guessed right! (Cuz had tons of work to do), but I did not forgot about this little devil device of doom.

After playing there and there got the following new:

  • Got the missing characteristic to read battery level, yay !
  • Found that the histogram shown at Meawow app’s data is not collected by the app. It seems the device itself has an internal memory to collect historical ppm/temp/humidity data (not sure why ppm as readings are disabled from time to time but anyways…). Tried to read that but I wasn’t able to get how that info is read… Anyways, I guess that’s not important for now.
  • CO2 callibration is quite funny at Meawow app, you need to put your sensor at exterior (to get a ambiental reference reading xD) then introduce the default value (400ppm) or a custom value. Just like histogram, I was not able to figure how that works at BLE level.
  • As I still can’t find a realiable way to trigger CO2 reading, I gave up and figured a “safe” way to force a reset, by writing a 0x00 in OTA characteristic. Quite risky, but seems writing anything on that triggers a reset of the device, effectively triggering CO2 reading (plus resetting light setting and reading mode for some reason).
  • Just off of the track, but seems the LED display have unused icons for Matter and Bell ringing (alarms? Wifi? I heard Telink have some interesting functionality specially at those Xiaomi sensors… perhaps this thing could be more interesting than I tought!?)
  • Inside Meawow APK at /assets/flutter_assets/Asset/File there is a really suspicious file called starfish.bin which I belive is a firmware image for this device (seems starfish is the codename of this device?) binwalk reports it as mcrypt 2.2 encrypted data, algorithm: 3DES, mode: CBC, keymode: 8bitbut I don’t really think so, as in the end I saw strings in clear text such as following (only a sample):
ccd9_write=
---ccd9_init =
ccd9_read
[R-%3d] ccb1 write: flash[0x%x]== data[0x%x]
*******************[E-%3d] [%s]
[R-%3d] ccb1 read : data[0x%x]
[R-%3d] factory_app_ccb4_write
*****************************[R-%3d] ccb4 write: co2 error
[R-%3d] meawow_app_ccb4_write
[R-%3d] CO2_error
[R-%3d] CO2_ok
[R-%3d] CO2_state_error
[R-%3d] CO2_reboot
[R-%3d] ccb4_read_state : [0x%x]
[R-%3d] ccb7 write = %2x.%2x.%2x.%2x.%2x.%2x.%2x timestamp= %4x
[R-%3d] ccb7 read = %2x.%2x.%2x.%2x.%2x.%2x.%2x %x
[R-%3d] ccb9 read tail_index = %d ,record_count = %d
[R-%3d] ccba write 0x%2x.%2x.%2x.%2x == 0x%4x
[R-%3d] ccba write notify_start_index 0x%4x = %d
[R-%3d] ccba read notify_start_index 0x%2x.%2x.%2x.%2x == 0x%4x
[R] %3d ccbb read =
----------------------notify_finish_complete=%4d
push Notify error, ret[0x%x]
ns_push=%4d
---[R-%3d] ccbc :
data_notify_cfg = %d
timer_lock = %d
ucLv_ret = %d
[R-%3d] ccbe write: F
[R-%3d] ccbe write: C
[R-%3d] ccbe read
[R-%3d] ccc1 read done %3d %2d %4d
[R] ccd4_write_done
*******************[E-%3d] [%s] ccd4_write_error 0x%2x.%2x.%2x.%2x
'*vI2C_HAL_i2c_write_series_byte
*******************[E-%3d] [%s] i2c_init error
*******************[E-%3d] [%s] i2c_write_byte error

Perhaps there are interesting stuff at UART level? Well, that’s out of my knowledge (Man, I need to take electronic-101 lessons). I will not publish the file here (get it yourself :stuck_out_tongue: ), as I don’t want to end in a gulag for reversing engineering stuff xD.

But hey, I’m getting detour from the original topic!

Given that, I made a python library (needs some polish and documentation, but by installing Bleak you can use it). Got extra features such as get/set hour, temperature C/F, light setting, battery level (as I told you) and firmware information.

import asyncio
from bleak import BleakClient, BleakScanner
import logging
import datetime

logger = logging.getLogger(__name__)

class MHOH411:

    DEVICE_NAME = "MHO-H411"

    INDICATOR_LIGHT_OFF = 6
    INDICATOR_LIGHT_ON = 7

    TIME_FORMAT_12 = 0xAA
    TIME_FORMAT_24 = 0x00

    TEMP_CELSIUS = 0x00
    TEMP_FAHRENHEIT = 0x01

    UUID_MODEL_NUMBER = "00002a24-0000-1000-8000-00805f9b34fb"
    UUID_INDICATOR_LIGHT = "ebe0ccb1-7a0a-4b0c-8a1a-6ff2997da3a6"
    UUID_HOUR_SETTING = "ebe0ccb7-7a0a-4b0c-8a1a-6ff2997da3a6"
    UUID_TEMP_READ = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"
    UUID_TEMP_SETTING = "ebe0ccbe-7a0a-4b0c-8a1a-6ff2997da3a6"
    UUID_BATTERY_READ = "ebe0ccc4-7a0a-4b0c-8a1a-6ff2997da3a6"
    UUID_FIRMWARE_VER = "00002a26-0000-1000-8000-00805f9b34fb"
    UUID_HARDWARE_VER = "00002a27-0000-1000-8000-00805f9b34fb"
    UUID_SERIALNO_VER = "00002a25-0000-1000-8000-00805f9b34fb"

    UUID_EXPERIMENTAL_REBOOT = "00010203-0405-0607-0809-0a0b0c0d2b12"

    # Default duration of device discovery
    DEFAULT_DISCOVER_DURATION = 20.0

    # =============================================

    @staticmethod
    async def _getModelNumber(address):
        try:
            async with BleakClient(address) as client:
                data = await client.read_gatt_char(MHOH411.UUID_MODEL_NUMBER)
                return data.decode("utf-8").strip()
        except Exception:
            return ""

    @staticmethod
    async def findMHOH411(timeout=DEFAULT_DISCOVER_DURATION):
        out = []
        # Search for devices
        devices = await BleakScanner.discover(timeout)
        # In my experience, MHO-H411 does not advertise its services properly,
        # soooo let's filter all devices named MHO-H411 then check Device Info ->
        # Model number string (if exists) to confirm if it's an MHO-H412
        for item in devices:
            if item.name == MHOH411.DEVICE_NAME:
                model = await MHOH411._getModelNumber(item.address)
                if MHOH411.DEVICE_NAME in model.upper():
                    out.append(item.address)
        return out

    # =============================================

    def __init__(self, mac):
        self.mac = mac

    # =============================================
    async def connect(self):
        self.client = BleakClient(self.mac)
        await self.client.connect()

    async def disconnect(self):
        await self.client.disconnect()
        return

    async def __aenter__(self):
        await self.connect()
        return self

    async def __aexit__(self, exception_type, exception_value, exception_traceback):
        # Exception handling here
        await self.disconnect()
        return

    async def _write_single_byte(self, uuid, value):
        if not (0 <= value <= 0xFF):
            raise ValueError("Single byte must be 0..255")
        data = bytes([value])
        await self.client.write_gatt_char(uuid, data)

    async def _write_int_le(self, uuid, value, length=None, signed=False):
        if type(value) == str:
            data = value.encode("utf-8")
        elif type(value) == int:
            if length is None:
                raise ValueError("Must specify length")
            data = int.to_bytes(value, length, byteorder="little", signed=signed)
        else:
            raise ValueError(f"Unsupported data type = {type(value)}")
        await self.client.write_gatt_char(uuid, data)

    # =============================================

    async def getIndicatorLightSetting(self):
        b = await self.client.read_gatt_char(MHOH411.UUID_INDICATOR_LIGHT)
        status_raw = int.from_bytes(b, "little", signed=True)
        return True if status_raw == MHOH411.INDICATOR_LIGHT_ON else False

    async def setIndicatorLightSetting(self, toggle):
        status_raw = MHOH411.INDICATOR_LIGHT_ON if toggle else MHOH411.INDICATOR_LIGHT_OFF
        await self._write_single_byte(MHOH411.UUID_INDICATOR_LIGHT, status_raw)

    # =============================================

    async def getTimeSettings(self):
        b = await self.client.read_gatt_char(MHOH411.UUID_HOUR_SETTING)
        unix_timestamp = int.from_bytes(b[0:4], "little", signed=True)
        tz = int.from_bytes(b[4:5], "little", signed=True)
        timeformat = b[6]

        dt = datetime.datetime.fromtimestamp(unix_timestamp)
        if timeformat == MHOH411.TIME_FORMAT_24:
            timeformat = 24
        elif timeformat == MHOH411.TIME_FORMAT_12:
            timeformat = 12

        return {
            "dt": dt,
            "tz": tz,
            "timeformat": timeformat,
            "raw_hex": b.hex(" ")
        }

    async def setTimeSettings(self, dt, tz, set1224):

        unix_timestamp = int(dt.timestamp())
        if set1224 == 12:
            timeformat = MHOH411.TIME_FORMAT_12
        elif set1224 == 24:
            timeformat = MHOH411.TIME_FORMAT_24
        else:
            raise ValueError(f"Unsupported time format: {set1224}")

        # Building our byte array
        out = bytearray()
        out += unix_timestamp.to_bytes(4, 'little')
        out += tz.to_bytes(1, 'little', signed=True)
        out.append(0x00)
        out.append(timeformat)

        # Writing it
        await self.client.write_gatt_char(MHOH411.UUID_HOUR_SETTING, out)

    # =============================================

    async def getReadings(self):
        b = await self.client.read_gatt_char(MHOH411.UUID_TEMP_READ)
        temp_raw = int.from_bytes(b[0:2], "little", signed=True)
        temp_c = temp_raw / 100.0
        humidity = b[2]
        voltage_mv = int.from_bytes(b[3:5], "little", signed=False)  # maybe battery mV?
        co2_ppm = int.from_bytes(b[5:7], "little", signed=False)

        # When CO2 reading is disabled, value will be maximum of 16 bits
        if co2_ppm == 65535:
            co2_ppm = None

        return {
            "temperature_c": temp_c,
            "humidity_pct": humidity,
            "co2_ppm": co2_ppm,
            "voltage_mv": voltage_mv,
            "raw_hex": b.hex(" "),
        }

    async def getCelsiusFahrenheitSetting(self):
        b = await self.client.read_gatt_char(MHOH411.UUID_TEMP_SETTING)
        status_raw = int.from_bytes(b, "little", signed=True)
        return "f" if status_raw == MHOH411.TEMP_FAHRENHEIT else "c"

    async def setCelsiusFahrenheitSetting(self, value):
        if value == "f":
            status_raw = MHOH411.TEMP_FAHRENHEIT
        elif value == "c":
            status_raw = MHOH411.TEMP_CELSIUS
        else:
            raise ValueError(f"Unsupported temperature format: {value}")
        await self._write_single_byte(MHOH411.UUID_TEMP_SETTING, status_raw)

    # =============================================
    async def experimentalRebot(self):
        # Be careful with this one, my guess this is a crash
        # Also resets light setting?
        await self._write_single_byte(MHOH411.UUID_EXPERIMENTAL_REBOOT, 0xFE)

    # =============================================
    async def getBatteryLevel(self):
        b = await self.client.read_gatt_char(MHOH411.UUID_BATTERY_READ)
        if len(b) < 1:
            return None
        batt_raw = int.from_bytes(b, "little", signed=True)
        return batt_raw

    # =============================================
    def setCallibrationParameters(self):
        raise ValueError(f"I'm not getting enough paid for this :P")

    async def getDeviceInfo(self):
        b = await self.client.read_gatt_char(MHOH411.UUID_FIRMWARE_VER)
        if len(b) < 1:
            return None
        firmware = b.decode("utf-8").strip("\x00")
        b = await self.client.read_gatt_char(MHOH411.UUID_HARDWARE_VER)
        if len(b) < 1:
            return None
        hardware = b.decode("utf-8").strip("\x00")
        b = await self.client.read_gatt_char(MHOH411.UUID_SERIALNO_VER)
        if len(b) < 1:
            return None
        serial = b.decode("utf-8").strip("\x00")
        return {
            "serial": serial,
            "hardware": hardware,
            "firmware": firmware,
            "mac": self.mac
        }


async def main():
    # print(await MHOH411.findMHOH411()) # ['A4:C1:38:B7:12:BA']
    async with MHOH411('A4:C1:38:B7:12:BA') as m:
        '''print(f"Readings: {await m.getReadings()}")
        print(f"Battery: {await m.getBatteryLevel()}%")
        print(f"Light status: {await m.getIndicatorLightSetting()}")
        #await m.setIndicatorLightSetting(False)
        #print(f"Light status: {await m.getIndicatorLightSetting()}")

        print(f"Time settings: {await m.getTimeSettings()}")
        #out = await m.setTimeSettings(datetime.datetime(2025, 6, 9, 12, 34, 00), -3, 24)
        #await m.setTimeSettings(datetime.datetime.now(), -3, 24)
        #print(f"Time settings: {await m.getTimeSettings()}")

        print(f"Temp settings: {await m.getCelsiusFahrenheitSetting()}")
        #await m.setCelsiusFahrenheitSetting("c")
        #print(f"Temp settings: {await m.getCelsiusFahrenheitSetting()}")

        print(f"Device Info: {await m.getDeviceInfo()}")
        await m.experimentalRebot()'''


if __name__ == "__main__":
    asyncio.run(main())

My next task then it will be use this library and transmiting the data via MQTT or something potable for HA.

Okay, time to sleep, it’s 1:30 AM here. Until next month! (Or two… who knows xD)… aaaagh, why I spend time on this?

1 Like

Not dead, just struggling with lower back pain, typical of 35+ yo haha.
I did not had much luck making something working with HomeAssistant to recognize this thing, but for now, a promise is promise sooo…

Good things needs to be shared, so here is the library to read this thing, available to everyone :slight_smile: : GitHub - elsemieni/elsemieni-mho-h411: Python library to read MHO-H411 CO2 sensors.

1 Like

Thanks for the great work! I have vibecoded a tiny HACS integration based on your library: GitHub - dfn96304/mhoh411_hacs

It seems to work although I have zero HA integration development experience so I’ll have to see why the restart integration option causes the integration to crash with a yellow warning - to restart it, just disable and reenable, which works.

2 Likes