Fix: scan-then-connect in one flow to avoid device going stale

This commit is contained in:
2026-07-01 20:09:09 +10:00
parent 8622f4cde9
commit 35707b88d4
+87 -33
View File
@@ -4,9 +4,13 @@ ble_scanner.py - Discover and dump BLE services/characteristics from nearby devi
Run this first to find your KAIYU LED controller and identify its BLE services. Run this first to find your KAIYU LED controller and identify its BLE services.
Usage: Usage:
python scanner/ble_scanner.py python scanner/ble_scanner.py # scan only
python scanner/ble_scanner.py --filter KAIYU python scanner/ble_scanner.py --connect 7C:77:6C:6B:8E:C8 # scan then connect immediately
python scanner/ble_scanner.py --connect AA:BB:CC:DD:EE:FF python scanner/ble_scanner.py --probe 7C:77:6C:6B:8E:C8 # scan then probe with test commands
python scanner/ble_scanner.py --filter KAIYU # scan with name filter
IMPORTANT: --connect and --probe now re-scan first so the device is still
advertising when we try to connect. Do not scan separately then connect.
""" """
import asyncio import asyncio
@@ -15,6 +19,28 @@ import json
from bleak import BleakScanner, BleakClient from bleak import BleakScanner, BleakClient
async def scan_and_get_device(address: str, timeout: float = 10.0):
"""
Scan for a specific address and return the BLEDevice object while it's
still in the scanner's cache (i.e. still advertising).
Returns None if not found.
"""
print(f"Scanning for {address} ({timeout}s)...")
target = None
def detection_callback(device, adv):
nonlocal target
if device.address.upper() == address.upper():
target = device
print(f"Found: {device.address} name={device.name or '(unknown)'} rssi={adv.rssi}")
scanner = BleakScanner(detection_callback=detection_callback)
await scanner.start()
await asyncio.sleep(timeout)
await scanner.stop()
return target
async def scan_devices(filter_name: str = None, timeout: float = 10.0): async def scan_devices(filter_name: str = None, timeout: float = 10.0):
"""Scan for nearby BLE devices and print them.""" """Scan for nearby BLE devices and print them."""
print(f"Scanning for BLE devices ({timeout}s)...\n") print(f"Scanning for BLE devices ({timeout}s)...\n")
@@ -45,12 +71,19 @@ async def scan_devices(filter_name: str = None, timeout: float = 10.0):
return found return found
async def inspect_device(address: str): async def inspect_device(address: str, timeout: float = 10.0):
"""Connect to a device and dump all its services and characteristics.""" """Scan for device, then immediately connect and dump all services/characteristics."""
print(f"Connecting to {address}...")
async with BleakClient(address) as client: device = await scan_and_get_device(address, timeout=timeout)
print(f"Connected: {client.is_connected}\n") if device is None:
print(f"\nDevice {address} not found during scan.")
print("Make sure it is powered on and not connected to another device (e.g. your phone).")
return
print(f"\nConnecting to {device.address}...")
async with BleakClient(device) as client:
print(f"Connected!\n")
print("=" * 60) print("=" * 60)
print("SERVICES & CHARACTERISTICS") print("SERVICES & CHARACTERISTICS")
print("=" * 60) print("=" * 60)
@@ -98,13 +131,12 @@ async def inspect_device(address: str):
for descriptor in char.descriptors: for descriptor in char.descriptors:
print(f" DESC: {descriptor.uuid} handle={descriptor.handle}") print(f" DESC: {descriptor.uuid} handle={descriptor.handle}")
# Save dump to JSON for reference # Save dump to JSON
dump_file = f"scanner/device_{address.replace(':', '-')}.json" dump_file = f"scanner/device_{address.replace(':', '-')}.json"
with open(dump_file, "w") as f: with open(dump_file, "w") as f:
json.dump(dump, f, indent=2) json.dump(dump, f, indent=2)
print(f"\nDump saved to {dump_file}") print(f"\nDump saved to {dump_file}")
# Identify likely write characteristic
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("LIKELY COMMAND CHARACTERISTICS (writable):") print("LIKELY COMMAND CHARACTERISTICS (writable):")
print("=" * 60) print("=" * 60)
@@ -115,27 +147,38 @@ async def inspect_device(address: str):
print(f" -> {char_uuid} [{', '.join(props)}]") print(f" -> {char_uuid} [{', '.join(props)}]")
async def try_known_protocols(address: str): async def try_known_protocols(address: str, timeout: float = 10.0):
"""Try sending known protocol commands to see what sticks.""" """Scan for device then probe it with known protocol commands."""
device = await scan_and_get_device(address, timeout=timeout)
if device is None:
print(f"\nDevice {address} not found during scan.")
print("Make sure it is powered on and not connected to your phone.")
return
KNOWN_WRITE_UUIDS = [ KNOWN_WRITE_UUIDS = [
"0000ffe1-0000-1000-8000-00805f9b34fb", # LEDBLE / MagicLight "0000ffe1-0000-1000-8000-00805f9b34fb", # LEDBLE / MagicLight
"0000fff3-0000-1000-8000-00805f9b34fb", # ELK-BLEDOM "0000fff3-0000-1000-8000-00805f9b34fb", # ELK-BLEDOM
"0000ffd9-0000-1000-8000-00805f9b34fb", # Some controllers "0000ffd9-0000-1000-8000-00805f9b34fb", # Some controllers
] ]
# Try the 0x7e command format (common in cheap controllers)
test_commands = { test_commands = {
"Turn ON": bytes.fromhex("7e000400000000ff ef".replace(" ", "")), "Turn ON (7e)": bytes.fromhex("7e000400000000ffef"),
"Turn OFF": bytes.fromhex("7e000400000000 00 ef".replace(" ", "")), "Turn OFF (7e)": bytes.fromhex("7e000400000000 00ef".replace(" ", "")),
"Red": bytes.fromhex("7e0005 03 ff 00 00 00 ef".replace(" ", "")), "Red (7e)": bytes.fromhex("7e000503ff000000ef"),
"Green": bytes.fromhex("7e0005 03 00 ff 00 00 ef".replace(" ", "")), "Green (7e)": bytes.fromhex("7e00050300ff0000ef"),
"Blue": bytes.fromhex("7e0005 03 00 00 ff 00 ef".replace(" ", "")), "Blue (7e)": bytes.fromhex("7e0005030000ff00ef"),
"White": bytes.fromhex("7e0005 03 ff ff ff 00 ef".replace(" ", "")), "White (7e)": bytes.fromhex("7e000503ffffffff ef".replace(" ", "")),
"Warm Hellfire": bytes.fromhex("7e0005 03 ff 40 00 00 ef".replace(" ", "")), "Turn ON (56/cc)": bytes.fromhex("cc2333"),
"Turn OFF (56/cc)": bytes.fromhex("cc2433"),
"Red (56)": bytes.fromhex("56ff000000f0aa"),
"Green (56)": bytes.fromhex("5600ff000000f0aa".replace(" ", "")),
"Blue (56)": bytes.fromhex("560000ff00f0aa"),
} }
async with BleakClient(address) as client: print(f"\nConnecting to {device.address}...")
print(f"Connected to {address}") async with BleakClient(device) as client:
print(f"Connected!\n")
writable = None writable = None
for svc in client.services: for svc in client.services:
@@ -146,45 +189,56 @@ async def try_known_protocols(address: str):
break break
if not writable: if not writable:
# Try any writable characteristic print("No known UUID found. Available writable characteristics:")
for svc in client.services: for svc in client.services:
for char in svc.characteristics: for char in svc.characteristics:
if "write" in char.properties or "write-without-response" in char.properties: if "write" in char.properties or "write-without-response" in char.properties:
print(f" {char.uuid} [{', '.join(char.properties)}]")
if not writable:
writable = char.uuid writable = char.uuid
print(f"Using writable characteristic: {char.uuid}") print(f" ^ Using this one")
break
if not writable: if not writable:
print("No writable characteristic found!") print("No writable characteristic found at all!")
return return
print(f"\nSending test commands to: {writable}")
print("Watch the lights and press Enter for each one.\n")
for name, cmd in test_commands.items(): for name, cmd in test_commands.items():
input(f"\nPress Enter to send '{name}' ({cmd.hex()})...") input(f" Press Enter to send '{name}' ({cmd.hex()})...")
try: try:
await client.write_gatt_char(writable, cmd, response=False) await client.write_gatt_char(writable, cmd, response=False)
print(f" Sent OK") print(f" Sent OK")
except Exception as e: except Exception as e:
print(f" Error: {e}") # Try with response=True as fallback
try:
await client.write_gatt_char(writable, cmd, response=True)
print(f" Sent OK (with response)")
except Exception as e2:
print(f" Error: {e2}")
def main(): def main():
parser = argparse.ArgumentParser(description="KAIYU BLE Scanner & Inspector") parser = argparse.ArgumentParser(description="KAIYU BLE Scanner & Inspector")
parser.add_argument("--filter", "-f", help="Filter devices by name") parser.add_argument("--filter", "-f", help="Filter devices by name")
parser.add_argument("--connect", "-c", help="Connect to device by MAC address and dump services") parser.add_argument("--connect", "-c", help="Scan then connect to address and dump services")
parser.add_argument("--probe", "-p", help="Probe device with known commands") parser.add_argument("--probe", "-p", help="Scan then probe address with known commands")
parser.add_argument("--timeout", "-t", type=float, default=10.0, help="Scan timeout (default 10s)") parser.add_argument("--timeout", "-t", type=float, default=10.0, help="Scan timeout in seconds (default 10)")
args = parser.parse_args() args = parser.parse_args()
if args.connect: if args.connect:
asyncio.run(inspect_device(args.connect)) asyncio.run(inspect_device(args.connect, timeout=args.timeout))
elif args.probe: elif args.probe:
asyncio.run(try_known_protocols(args.probe)) asyncio.run(try_known_protocols(args.probe, timeout=args.timeout))
else: else:
async def scan_and_pick(): async def scan_and_pick():
found = await scan_devices(filter_name=args.filter, timeout=args.timeout) found = await scan_devices(filter_name=args.filter, timeout=args.timeout)
if found: if found:
print("Tip: run with --connect <ADDRESS> to inspect a device") print("Tip: run with --connect <ADDRESS> to inspect a device")
print(" run with --probe <ADDRESS> to test known protocols") print(" run with --probe <ADDRESS> to test known protocols")
print("\nIMPORTANT: run --connect immediately after the device appears")
print(" in a scan, or it may stop advertising.")
asyncio.run(scan_and_pick()) asyncio.run(scan_and_pick())