272 lines
10 KiB
Python
272 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
mi_ble_reader
|
|
==============
|
|
|
|
Async BLE reader for Xiaomi Mi temperature / humidity sensors.
|
|
|
|
This module keeps the original helper names (`sensor_data_query`, `test`, etc.) so
|
|
you can keep your existing code unchanged. In addition it exposes a minimal
|
|
public API that other apps can import:
|
|
|
|
* `read_data` - async coroutine, returns SensorReading or None
|
|
* `read_multiple` - async coroutine, reads a list of MACs concurrently
|
|
* `read_data_sync` - sync wrapper around `read_data` (creates its own loop)
|
|
|
|
All heavy lifting is done in the async functions; the sync wrapper is only
|
|
provided for convenience.
|
|
"""
|
|
|
|
import asyncio
|
|
import warnings, logging
|
|
warnings.filterwarnings("ignore")
|
|
logging.getLogger('apscheduler').setLevel(logging.ERROR)
|
|
from dataclasses import dataclass
|
|
from typing import List, Optional
|
|
from config import *
|
|
from bleak import BleakClient, BleakScanner
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Configuration
|
|
# --------------------------------------------------------------------------- #
|
|
MI_SERVICE_UUID = "ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6"
|
|
TEMP_CHAR_UUID = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"
|
|
|
|
SCAN_TIMEOUT = 5.0 # seconds to scan for each MAC
|
|
CONNECT_TIMEOUT = 10.0
|
|
READ_TIMEOUT = 2.0
|
|
SERVICE_DISCOVERY_TIMEOUT = 5.0
|
|
_lock = asyncio.Lock()
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Logging
|
|
# --------------------------------------------------------------------------- #
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Data model
|
|
# --------------------------------------------------------------------------- #
|
|
@dataclass
|
|
class SensorReading:
|
|
"""Single Mi sensor reading."""
|
|
mac: str
|
|
temperature: float
|
|
humidity: int
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers - kept identical to your original code
|
|
# --------------------------------------------------------------------------- #
|
|
async def _discover_device(mac: str) -> Optional[str]:
|
|
"""Scan once for the given MAC and return its address if found."""
|
|
devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT)
|
|
for d in devices:
|
|
if d.address.lower() == mac.lower():
|
|
log.info("Found device %s (%s)", d.name or d.address, d.address)
|
|
return d.address
|
|
log.warning("Device %s not found in scan", mac)
|
|
return None
|
|
|
|
|
|
def _parse_sensor_data(raw: bytes) -> (float, int):
|
|
"""Parse the 3-byte Mi packet into temperature & humidity."""
|
|
sign = raw[1] & (1 << 7)
|
|
temp = ((raw[1] & 0x7F) << 8 | raw[0])
|
|
if sign:
|
|
temp -= 32767
|
|
temp = temp / 100.0
|
|
hum = raw[2]
|
|
return temp, hum
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Optional device discovery helper
|
|
# --------------------------------------------------------------------------- #
|
|
async def _scan_once() -> List[str]:
|
|
"""
|
|
Scan once for nearby BLE devices and return the MAC addresses of those
|
|
that satisfy *filter_fn*.
|
|
|
|
Parameters
|
|
----------
|
|
filter_fn : Callable[[address, name], bool] | None
|
|
If supplied, a predicate that receives the device address and name
|
|
and returns ``True`` when the device should be kept.
|
|
When ``None`` all discovered addresses are returned.
|
|
|
|
Returns
|
|
-------
|
|
List[str]
|
|
A list of MAC addresses (upper-case as returned by Bleak).
|
|
"""
|
|
log.debug("Starting one-shot BLE scan (timeout = %.1fs)", SCAN_TIMEOUT)
|
|
devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT)
|
|
|
|
return [dev.address for dev in devices if _is_match(dev.address, dev.name)]
|
|
|
|
# this function filters MACs
|
|
def _is_match(mac: str, name: str) -> bool:
|
|
"""Return True if the MAC starts with the configured prefix and is correct model."""
|
|
if name == "LYWSD03MMC":
|
|
return mac.upper().startswith(MAC_PREFIX.upper())
|
|
|
|
# this helper function scans for MACs
|
|
def scan_macs():
|
|
print("Scanning for MACs:")
|
|
new_macs = asyncio.run(_scan_once())
|
|
|
|
print(f"Found {len(new_macs)} new MACs.")
|
|
return new_macs
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Core async read functions (unchanged from the refactored version)
|
|
# --------------------------------------------------------------------------- #
|
|
async def read_data(mac: str) -> Optional[SensorReading]:
|
|
address = await _discover_device(mac)
|
|
if not address:
|
|
return None
|
|
|
|
try:
|
|
async with BleakClient(address, timeout=CONNECT_TIMEOUT) as client:
|
|
# <--- NEW BLOCK ----------------------------------------------
|
|
try:
|
|
await asyncio.wait_for(client.get_services(),
|
|
timeout=SERVICE_DISCOVERY_TIMEOUT)
|
|
except asyncio.TimeoutError:
|
|
log.warning("Service discovery timed out for %s", mac)
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"Discovery failed - {e}")
|
|
# ---------------------------------------------------------------
|
|
|
|
# If we get here, services are available.
|
|
if MI_SERVICE_UUID.lower() not in (
|
|
s.uuid.lower() for s in client.services
|
|
):
|
|
log.error("Mi service not found on %s", mac)
|
|
return None
|
|
|
|
raw = await asyncio.wait_for(
|
|
client.read_gatt_char(TEMP_CHAR_UUID),
|
|
timeout=READ_TIMEOUT,
|
|
)
|
|
temp, hum = _parse_sensor_data(raw)
|
|
log.info("Read from %s: %.1f°C, %d%%", mac, temp, hum)
|
|
return SensorReading(mac=mac, temperature=temp, humidity=hum)
|
|
|
|
except asyncio.TimeoutError:
|
|
# This is the *connect* timeout (BleakClient)
|
|
log.error("Connection to %s timed out after %.1fs", mac, CONNECT_TIMEOUT)
|
|
except Exception as exc:
|
|
log.exception("Error reading from %s: %s", mac, exc)
|
|
return None
|
|
|
|
|
|
async def read_multiple(mac_list: List[str]) -> List[SensorReading]:
|
|
"""
|
|
Read all MACs concurrently. Skips any that fail.
|
|
"""
|
|
tasks = [asyncio.create_task(read_data(mac)) for mac in mac_list]
|
|
results = await asyncio.gather(*tasks, return_exceptions=False)
|
|
readings = [r for r in results if r is not None]
|
|
failed = [mac for mac, r in zip(mac_list, results) if r is None]
|
|
if failed:
|
|
log.warning("Failed to read: %s", ", ".join(failed))
|
|
return readings
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Sync wrapper (kept for legacy code that cannot run an event loop)
|
|
# --------------------------------------------------------------------------- #
|
|
def read_data_sync(mac: str) -> Optional[SensorReading]:
|
|
"""
|
|
Convenience wrapper that runs ``read_data`` in a fresh event-loop.
|
|
Use this from non-async code.
|
|
"""
|
|
return asyncio.run(read_data(mac))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Original helper functions - unchanged
|
|
# --------------------------------------------------------------------------- #
|
|
def sensor_data_query(mac_list: List[str]) -> List[dict]:
|
|
"""Legacy function; returns a list of dicts like the original code."""
|
|
result = []
|
|
for mac in mac_list:
|
|
try:
|
|
this_result = asyncio.run(read_data(mac))
|
|
except Exception as e:
|
|
print(f"Error reading BLE data - {e}")
|
|
this_result = None
|
|
if this_result:
|
|
result.append(
|
|
{"name": mac, "temp": this_result.temperature, "humidity": this_result.humidity}
|
|
)
|
|
print(f"Data query complete - returning result")
|
|
return result
|
|
|
|
|
|
def test(test_count = 0) -> None:
|
|
"""Legacy test harness - identical to your original script."""
|
|
# first test scanner
|
|
test_loop_count = 0
|
|
found_macs = scan_macs()
|
|
print("Found these MACs on a scan:")
|
|
print(found_macs)
|
|
test_result = False
|
|
sensors = []
|
|
mac_list = [
|
|
"A4:C1:38:96:53:30",
|
|
"A4:C1:38:11:F4:2A",
|
|
"A4:C1:38:24:15:B0",
|
|
"A4:C1:38:D7:8D:26",
|
|
]
|
|
|
|
print("Starting BLE test...")
|
|
print(" Scanning MACs:")
|
|
for mac in mac_list:
|
|
print(f" {mac}")
|
|
test_list = mac_list
|
|
while not test_result:
|
|
print(f" Test loop {test_loop_count} start. Initializing run...")
|
|
test_run_results = sensor_data_query(test_list)
|
|
test_loop_count = test_loop_count + 1
|
|
for test_run_result in test_run_results:
|
|
sensors.append(test_run_result)
|
|
passed_list = [device["name"] for device in sensors]
|
|
print(f" Passed list - {passed_list}")
|
|
if len(mac_list) == len(passed_list):
|
|
print(f" Test run {test_count} completed, yee-haw")
|
|
for sensor in sensors:
|
|
print(f" {sensor['name']} - Temp: {round(sensor['temp'],1)} | Humidity: {sensor['humidity']}")
|
|
test_result = True
|
|
else:
|
|
print(f" Test run incomplete/failed, re-running, loop {test_loop_count}. Updating test list")
|
|
for sensor in sensors:
|
|
print(f" {sensor['name']} - Temp: {round(sensor['temp'],1)} | Humidity: {sensor['humidity']}")
|
|
mac_list_new = []
|
|
for mac in mac_list:
|
|
if mac not in passed_list:
|
|
mac_list_new.append(mac)
|
|
test_list = mac_list_new
|
|
print(f" Re-do list - {test_list}")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# If run as a script, execute the legacy test
|
|
# --------------------------------------------------------------------------- #
|
|
if __name__ == "__main__":
|
|
test_count = 0
|
|
print("Welp, this will keep going forever. Have fun :)")
|
|
while True:
|
|
print(f"Starting test run {test_count}")
|
|
test_count = test_count + 1
|
|
try:
|
|
test(test_count)
|
|
except Exception as e:
|
|
print(f"Test failed brah - {e}") |