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
), 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?