""" kaiyu_controller.py - Python BLE controller for the KAIYU LED hub. Wraps bleak for async BLE communication with colour, brightness, and effect helpers tailored to the Hazbin Hotel LEGO display. Usage (standalone test): python controller/kaiyu_controller.py """ import asyncio import sys from bleak import BleakClient, BleakError from config import DEVICE_ADDRESS, WRITE_CHARACTERISTIC, WRITE_WITH_RESPONSE, COLORS from protocol import ActiveProtocol as P class KaiyuController: def __init__( self, address: str = DEVICE_ADDRESS, write_uuid: str = WRITE_CHARACTERISTIC, write_response: bool = WRITE_WITH_RESPONSE, ): self.address = address self.write_uuid = write_uuid self.write_response = write_response self._client: BleakClient | None = None # ── Connection ───────────────────────────────────────────────────────── async def connect(self) -> bool: print(f"Connecting to {self.address}...") self._client = BleakClient(self.address) try: await self._client.connect() print(f"Connected. Services: {[s.uuid for s in self._client.services]}") # Auto-detect write characteristic if default doesn't exist await self._auto_detect_write_uuid() return True except BleakError as e: print(f"Connection failed: {e}") return False async def disconnect(self): if self._client and self._client.is_connected: await self._client.disconnect() print("Disconnected.") async def __aenter__(self): await self.connect() return self async def __aexit__(self, *_): await self.disconnect() # ── Internal ─────────────────────────────────────────────────────────── async def _auto_detect_write_uuid(self): """If configured UUID isn't found, pick the first writable characteristic.""" found_uuids = [ c.uuid for s in self._client.services for c in s.characteristics ] if self.write_uuid in found_uuids: return # Already good print(f"Write UUID {self.write_uuid} not found. Searching...") for s in self._client.services: for c in s.characteristics: if "write" in c.properties or "write-without-response" in c.properties: self.write_uuid = c.uuid print(f"Using: {c.uuid} [{', '.join(c.properties)}]") return print("WARNING: No writable characteristic found!") async def _send(self, data: bytes): if not self._client or not self._client.is_connected: raise RuntimeError("Not connected") await self._client.write_gatt_char( self.write_uuid, data, response=self.write_response ) # ── Controls ─────────────────────────────────────────────────────────── async def on(self): await self._send(P.turn_on()) async def off(self): await self._send(P.turn_off()) async def set_color(self, r: int, g: int, b: int): await self._send(P.set_color(r, g, b)) async def set_color_name(self, name: str): if name not in COLORS: raise ValueError(f"Unknown colour '{name}'. Available: {list(COLORS.keys())}") await self.set_color(*COLORS[name]) async def set_brightness(self, level: int): """0–100""" await self._send(P.set_brightness(level)) async def set_effect(self, effect_id: int, speed: int = 3): await self._send(P.set_effect(effect_id, speed)) async def fade_to( self, r2: int, g2: int, b2: int, r1: int = 0, g1: int = 0, b1: int = 0, steps: int = 20, duration: float = 1.0, ): """Fade from (r1,g1,b1) to (r2,g2,b2) over `duration` seconds.""" delay = duration / steps for i in range(steps + 1): t = i / steps r = int(r1 + (r2 - r1) * t) g = int(g1 + (g2 - g1) * t) b = int(b1 + (b2 - b1) * t) await self.set_color(r, g, b) await asyncio.sleep(delay) async def flash( self, r: int, g: int, b: int, times: int = 3, on_time: float = 0.2, off_time: float = 0.1, ): """Flash a colour on/off.""" for _ in range(times): await self.set_color(r, g, b) await asyncio.sleep(on_time) await self.set_color(0, 0, 0) await asyncio.sleep(off_time) async def pulse( self, r: int, g: int, b: int, cycles: int = 2, period: float = 1.0, steps: int = 20, ): """Pulse (breathe) a colour.""" half = period / 2 for _ in range(cycles): await self.fade_to(r, g, b, 0, 0, 0, steps=steps, duration=half) await self.fade_to(0, 0, 0, r, g, b, steps=steps, duration=half) # ── Standalone test ──────────────────────────────────────────────────────────── async def _test(): async with KaiyuController() as ctrl: print("\n--- Testing basic colours ---") for name in ["hellfire_red", "angel_gold", "heaven_blue", "hope_pink", "warm_white", "off"]: print(f" -> {name}") await ctrl.set_color_name(name) await asyncio.sleep(1.5) print("\n--- Fade: off → hope pink ---") await ctrl.fade_to(*COLORS["hope_pink"], duration=2.0) await asyncio.sleep(1.0) print("\n--- Flash: angel gold ---") await ctrl.flash(*COLORS["angel_gold"], times=5) print("\n--- Pulse: heaven blue ---") await ctrl.pulse(*COLORS["heaven_blue"], cycles=2, period=1.5) await ctrl.off() print("\nTest complete.") if __name__ == "__main__": asyncio.run(_test())