Files
ble_api/api/ble.py
T

511 lines
19 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 subprocess
import time
import warnings, logging, sys
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 = 10.0 # seconds to scan for each MAC
CONNECT_TIMEOUT = 20.0
READ_TIMEOUT = 5.0
SERVICE_DISCOVERY_TIMEOUT = 10.0
DISCOVER_TIMEOUT = SCAN_TIMEOUT + 5.0 # Timeout for discovering device
current_usb_id = None
# --------------------------------------------------------------------------- #
# Logging
# --------------------------------------------------------------------------- #
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
# Stream (stdout) handler - always present
_stdout_handler = logging.StreamHandler(sys.stdout)
_stdout_handler.setFormatter(
logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
)
_logger.addHandler(_stdout_handler)
# finally create the log
log = _logger
# --------------------------------------------------------------------------- #
# 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."""
try:
devices = await asyncio.wait_for(
BleakScanner.discover(timeout=SCAN_TIMEOUT),
timeout=SCAN_TIMEOUT + 2.0, # give a small buffer over 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)
except asyncio.TimeoutError:
log.warning("Discover call timed out after %.1fs for %s", SCAN_TIMEOUT + 2.0, mac)
except Exception as e:
log.error(f"Dicvoere call failed - {e}")
reset_adapter()
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.info("Starting one-shot BLE scan (timeout = %.1fs)", SCAN_TIMEOUT)
try:
devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT)
except Exception as e:
log.error(f"Error reading BLE data - {e}")
this_result = None
if "org.freedesktop.DBus.Error.AccessDenied" in e:
return []
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():
log.info("Scanning for MACs:")
new_macs = asyncio.run(_scan_once())
log.info(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]:
try:
address = await asyncio.wait_for(
_discover_device(mac),
timeout=DISCOVER_TIMEOUT,
)
except asyncio.TimeoutError:
log.warning("Device discovery timed out for %s after %.1fs", mac, DISCOVER_TIMEOUT)
return None
if not address:
return None
#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:
log.error(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))
# --------------------------------------------------------------------------- #
# Other helper functions
# --------------------------------------------------------------------------- #
def reset_adapter():
log.info("Restting BT Adapter...")
if len(current_usb_id) > 0:
log.warning(f"Doing big reset - USB ID {current_usb_id}")
log.info(subprocess.run(["usbreset", current_usb_id], capture_output=True, text=True, check=True).stdout.strip())
time.sleep(2)
else:
log.warning("Hmm, this shouldn't happen, the current_usb_id is blank 0_o")
#big_reset = False
#else:
# log.info(subprocess.run(["sudo", "rfkill", "block", "bluetooth"], capture_output=True, text=True, check=True).stdout.strip())
# time.sleep(2)
# log.info(subprocess.run(["sudo", "rfkill", "unblock", "bluetooth"], capture_output=True, text=True, check=True).stdout.strip())
# time.sleep(5)
log.info("Reset complete. ")
def sensor_data_query(mac_list: List[str]) -> List[dict]:
result = []
for mac in mac_list:
try:
this_result = asyncio.run(read_data(mac))
except Exception as e:
log.error(f"Error reading BLE data - {e}")
this_result = None
if "org.freedesktop.DBus.Error.AccessDenied" in e:
reset_adapter()
return result
if this_result:
result.append(
{"name": mac, "temp": this_result.temperature, "humidity": this_result.humidity}
)
log.info(f"Data query complete - returning result")
return result
####################################################
# Test Function
# This runs a single full test of the driver
####################################################
def test(test_count = 0, bt_usb_id = None) -> None:
# first test scanner
if bt_usb_id == None:
log.error(f"Error - no USB ID Passed, this is needed for the test")
return -1
log.info(f"Ensuring BLE active...")
test_loop_count = 0
found_macs = scan_macs()
log.info("Found these MACs on a scan:")
log.info(found_macs)
test_result = False
sensors = []
mac_list = []
for mac in comment_lookup:
mac_list.append(mac['mac'])
#[
# "A4:C1:38:96:53:30",
# "A4:C1:38:11:F4:2A",
# "A4:C1:38:24:15:B0",
# "A4:C1:38:D7:8D:26",
#]
log.info("Starting BLE test...")
log.info(f" Scanning MACs: {mac_list}")
test_list = mac_list
passed_list = []
# History buffer - keep the last N snapshots (N=5)
mac_history = []
prev_list_snapshot = None
while not test_result:
# Check if the failed list has been the same for 5 loops
if prev_list_snapshot is not None and passed_list == prev_list_snapshot:
# List unchanged - add to mac_history
mac_history.append(passed_list)
else:
# List changed - reset mac_history
mac_history = []
mac_history.append(passed_list)
if len(mac_history) == reset_retries and all(lst == mac_history[0] for lst in mac_history):
log.warning(f"Last {reset_retries} tests have been identical, resetting adapter")
reset_adapter()
mac_history = []
prev_list_snapshot = passed_list
log.info(f" Test run: {test_count} - 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]
log.info(f" Passed list - {passed_list}")
if len(mac_list) == len(passed_list):
log.info(f" Test run {test_count} completed, yee-haw")
for sensor in sensors:
temp_f = (sensor['temp'] * 1.80) + 32.0
log.info(f" {sensor['name']} - Temp: {round(temp_f,1)} | Humidity: {sensor['humidity']}")
result = {
"found_macs": found_macs,
"sensor_results": sensors
}
test_result = True
return result
else:
log.warning(f" Test run incomplete/failed, attempt {len(mac_history)} of {reset_retries}, re-running, loop {test_loop_count}. Updating test list.")
for sensor in sensors:
temp_f = (sensor['temp'] * 1.80) + 32.0
log.info(f" {sensor['name']} - Temp: {round(temp_f,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
log.warning(f" Re-do list - {test_list}")
# helper function to guess the USB ID. It isn't hard
def usb_id_guess():
log.warning("USB ID Not found, I'll try and guess...")
result = ''
guess_command = "lsusb | grep -i blue | awk '{print $6'}"
try:
# Run the command in a shell because of the pipe
output = subprocess.run(
guess_command,
shell=True,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = output
log.info(f"USB Best guess - {result}")
except subprocess.CalledProcessError as exc:
log.error(f"USB-ID guess failed: {exc}")
return result
# --------------------------------------------------------------------------- #
# If run as a script, execute the legacy test
# Since I intend to run this service for months at a time,
# I need to do some robust long term tests on the driver
# This entire main subroutine is dedicated to testing the driver
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
####################################################
# initialize test
####################################################
# make a log file in temp
import json
from datetime import datetime
logfile_path = f"/tmp/ble-test-{datetime.now().strftime('%y%m%d-%H%M%S')}.log"
_file_handler = logging.FileHandler(logfile_path, mode="a")
_file_handler.setLevel(logging.INFO) # same level (or tweak)
_file_handler.setFormatter(_stdout_handler.formatter) # reuse formatter
_logger.addHandler(_file_handler)
log.info(f"Logfile initialized at {logfile_path}")
# Make sure the test count value is passed
if len(sys.argv) < 2:
print("Usage: python3 test.py <count>")
sys.exit(1)
try:
count = int(sys.argv[1])
except ValueError:
print("The count must be an integer.")
sys.exit(1)
# make sure bluetooth is unblocked in case something weird happened
log.info("Unblocking Bluetooth")
log.info(subprocess.run(["sudo", "rfkill", "unblock", "bluetooth"], capture_output=True, text=True, check=True).stdout.strip())
time.sleep(2)
# We need the usb id for all the resetting this has to do
present_usb_ids_command = "lsusb | awk '{print $6}'"
# Capture each USB ID as a separate line
present_usb_id_output = subprocess.run(
present_usb_ids_command,
shell=True,
capture_output=True,
text=True,
check=True,
).stdout.splitlines()
current_usb_id = ''
# first let's see if it's already in the list
for usb_id in present_usb_id_output:
if usb_id in bt_usb_ids:
current_usb_id = usb_id
# if it's not in the list, guess
if len(current_usb_id) == 0:
current_usb_id = usb_id_guess()
log.info(f"BLE USB ID Detected: {current_usb_id} - Resetting")
reset_adapter()
####################################################
# Beginning test
####################################################
# run the test
test_count = 0
test_results = []
log.info(f"Starting BLE Test for {count} iterations")
while test_count < count:
test_count = test_count + 1
this_test_result = []
log.info(f"Starting test run {test_count} of {count}")
test_label = f"Test {test_count} result"
# it's tricky, the test run is called here inside the json
this_test_result = {
"test_label": test_label,
"test_result": test(test_count = test_count, bt_usb_id = current_usb_id)
}
test_results.append(this_test_result)
# Show test results
log.info("Test run completed! Summary:")
for test_result in test_results:
# Which scan is this
log.info(f"{test_result['test_label']}:")
# Show summary of MACs found in this scan
mac_parts: List[str] = []
for mac in sorted(test_result['test_result']['found_macs']):
short_mac = ":".join(mac.split(":")[-2:])
mac_parts.append(short_mac)
found_macs_summary = " | ".join(mac_parts)
log.info(f"Found {len(test_result['test_result']['found_macs'])} MACs - {found_macs_summary}")
parts: List[str] = []
# Show summary of Sensor data in this scan
for reading in sorted(test_result['test_result']['sensor_results'], key=lambda r: r['name']):
mac = reading['name']
temp = reading['temp']
# Grab the last two octets of the MAC address
octets = mac.split(":")[-2:]
short_mac = ":".join(octets)
parts.append(f"{short_mac} - {temp:.1f}")
result_summary = " | ".join(parts)
log.info(f" {result_summary}")
# This is the structure of test_results
# [
# {
# "test_label": test_label,
# "test_result": {
# "found_macs": found_macs,
# "sensor_results": [
# {
# "name": mac,
# "temp": this_result.temperature,
# "humidity": this_result.humidity
# },
# {
# "name": mac,
# "temp": this_result.temperature,
# "humidity": this_result.humidity
# }
# ]
# }
# },
# {
# "test_label": test_label,
# "test_result": {
# "found_macs": found_macs,
# "sensor_results": [
# {
# "name": mac,
# "temp": this_result.temperature,
# "humidity": this_result.humidity
# },
# {
# "name": mac,
# "temp": this_result.temperature,
# "humidity": this_result.humidity
# }
# ]
# }
# }
# ]