""" Control de cámaras Blackmagic (Pocket Cinema Camera 4K/6K) por Bluetooth LE. Implementa el "Blackmagic Camera Control Protocol" sobre el servicio BLE de la cámara. Toda la comunicación corre en un hilo propio con su bucle asyncio; la interfaz gráfica habla con él mediante BleWorker.submit() y recibe eventos por una cola (queue.Queue). """ import asyncio import json import math import platform import queue import re import struct import subprocess import threading import time from bleak import BleakClient, BleakScanner def _run_powershell(command: str) -> str: """Ejecuta un comando de PowerShell sin ventana y devuelve su salida en UTF-8.""" startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW full = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; " + command res = subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", full], capture_output=True, timeout=20, startupinfo=startupinfo, ) return res.stdout.decode("utf-8", errors="replace") # --------------------------------------------------------------------------- # UUIDs del servicio Blackmagic Camera Service # --------------------------------------------------------------------------- SERVICE_UUID = "291d567a-6d75-11e6-8b77-86f30ca893d3" CH_OUT = "5dd3465f-1aee-4299-8493-d2eca2f8e1bb" # Outgoing Camera Control (write) CH_IN = "b864e140-76a0-416a-bf30-5876504537d9" # Incoming Camera Control (indicate) CH_TIMECODE = "6d8f2110-86f1-41bf-9afb-451d87e976c8" # Timecode (notify) CH_STATUS = "7fe8691d-95dc-4fc5-8abd-ca74339b51b9" # Camera Status (read/write/notify) CH_DEVNAME = "ffac0c52-c9fb-41a0-b063-cc76282eb89c" # Device Name (write) CH_PROTO_PREFIX = "8f1fd018-b508-456f-8f82-" # Protocol Version (read); el sufijo cambia # Tipos de dato del protocolo T_VOID, T_INT8, T_INT16, T_INT32, T_INT64, T_STRING, T_FIXED16 = 0, 1, 2, 3, 4, 5, 128 # Operaciones OP_ASSIGN, OP_OFFSET, OP_REPORT = 0, 1, 2 # Nombres legibles de (categoría, parámetro) para el registro PARAM_NAMES = { (0, 0): "Foco", (0, 1): "Autofoco", (0, 2): "Apertura (AV)", (0, 3): "Apertura normalizada", (0, 4): "Apertura (ordinal)", (0, 5): "Auto iris", (0, 6): "Estabilización óptica", (0, 7): "Zoom (mm)", (0, 8): "Zoom normalizado", (0, 9): "Zoom continuo", (1, 0): "Modo de vídeo", (1, 1): "Ganancia (antigua)", (1, 2): "Balance de blancos", (1, 3): "Auto balance de blancos", (1, 4): "Restaurar auto WB", (1, 5): "Exposición (µs)", (1, 6): "Exposición (ordinal)", (1, 7): "Rango dinámico", (1, 8): "Nitidez", (1, 9): "Formato de grabación", (1, 10): "Modo de autoexposición", (1, 11): "Ángulo de obturador", (1, 12): "Velocidad de obturador", (1, 13): "Ganancia (dB)", (1, 14): "ISO", (1, 15): "LUT de monitor", (1, 16): "Filtro ND", (2, 0): "Nivel de micrófono", (2, 1): "Nivel de auriculares", (2, 3): "Nivel de altavoz", (2, 4): "Tipo de entrada de audio", (2, 5): "Niveles de entrada", (2, 6): "Alimentación phantom", (3, 0): "Overlays activos", (3, 3): "Overlays", (4, 0): "Brillo de pantalla", (4, 1): "Ayudas de exposición/enfoque", (4, 2): "Nivel de zebra", (4, 3): "Nivel de peaking", (4, 4): "Barras de color (s)", (4, 5): "Focus assist", (5, 0): "Brillo tally", (6, 0): "Fuente de referencia", (7, 0): "Reloj", (7, 1): "Idioma", (8, 0): "Color: lift", (8, 1): "Color: gamma", (8, 2): "Color: gain", (8, 3): "Color: offset", (8, 4): "Color: contraste", (8, 5): "Color: luma mix", (8, 6): "Color: tono/saturación", (8, 7): "Color: reset", (9, 0): "Alimentación (mV, %, ?)", (9, 4): "Timecode", (10, 0): "Códec", (10, 1): "Transporte", (12, 0): "Metadatos: reel", (12, 1): "Metadatos: etiquetas de escena", (12, 2): "Metadatos: escena", (12, 3): "Metadatos: toma", (12, 4): "Metadatos: toma buena", (12, 5): "Metadatos: id de cámara", (12, 6): "Metadatos: operador", (12, 7): "Metadatos: director", (12, 8): "Metadatos: proyecto", (12, 9): "Objetivo: tipo", (12, 10): "Objetivo: iris", (12, 11): "Objetivo: focal", (12, 12): "Objetivo: distancia", (12, 13): "Objetivo: filtro", } # --------------------------------------------------------------------------- # Codificación / decodificación # --------------------------------------------------------------------------- def enc_fixed16(value: float) -> bytes: """fixed16 = 5.11 con signo (valor * 2048), little-endian.""" v = int(round(value * 2048)) v = max(-32768, min(32767, v)) return struct.pack(" bytes: """Paquete del protocolo: cabecera de 4 bytes + comando 0 + datos, relleno a 4 bytes.""" length = 4 + len(data) pkt = bytes([255, length, 0, 0, category, parameter, dtype, op]) + data pkt += b"\x00" * ((-len(pkt)) % 4) return pkt def decode_data(dtype: int, data: bytes): """Convierte los bytes de datos a una lista de valores Python según el tipo.""" try: if dtype == T_VOID: return list(data) if dtype == T_INT8: return list(struct.unpack(f"<{len(data)}b", data)) if dtype == T_INT16: n = len(data) // 2 return list(struct.unpack(f"<{n}h", data[: n * 2])) if dtype == T_INT32: n = len(data) // 4 return list(struct.unpack(f"<{n}i", data[: n * 4])) if dtype == T_INT64: n = len(data) // 8 return list(struct.unpack(f"<{n}q", data[: n * 8])) if dtype == T_STRING: return [data.decode("utf-8", errors="replace").rstrip("\x00")] if dtype == T_FIXED16: n = len(data) // 2 return [v / 2048.0 for v in struct.unpack(f"<{n}h", data[: n * 2])] except struct.error: pass return list(data) def parse_packets(buf: bytes): """Trocea una notificación en paquetes (cat, par, tipo, op, datos). Puede venir más de uno.""" out = [] i = 0 n = len(buf) while i + 8 <= n: length = buf[i + 1] if length < 4: break cat, par, dtype, op = buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7] data = bytes(buf[i + 8 : i + 4 + length]) out.append((cat, par, dtype, op, data)) i += 4 + length i += (-i) % 4 return out def decode_timecode(data: bytes) -> str: """Timecode BCD: bytes [FF, SS, MM, HH]; el bit alto de HH marca drop-frame.""" if len(data) < 4: return "--:--:--:--" f, s, m, h = data[0], data[1], data[2], data[3] sep = ";" if h & 0x80 else ":" return f"{h & 0x7F:02x}:{m:02x}:{s:02x}{sep}{f:02x}" # --------------------------------------------------------------------------- # Hilo con bucle asyncio # --------------------------------------------------------------------------- class BleWorker: """Ejecuta un bucle asyncio en un hilo aparte para que Tk no se bloquee.""" def __init__(self): self.loop = asyncio.new_event_loop() self._thread = threading.Thread(target=self._run, name="ble-loop", daemon=True) self._thread.start() def _run(self): asyncio.set_event_loop(self.loop) self.loop.run_forever() def submit(self, coro): """Lanza una corrutina en el bucle y devuelve un concurrent.futures.Future.""" return asyncio.run_coroutine_threadsafe(coro, self.loop) def stop(self): self.loop.call_soon_threadsafe(self.loop.stop) # --------------------------------------------------------------------------- # Cámara # --------------------------------------------------------------------------- class Camera: """Una cámara Blackmagic por BLE. Los métodos async deben correr en el bucle del worker.""" def __init__(self, events: queue.Queue): self.events = events self.client = None self.device = None self.devices = {} # address -> BLEDevice (de la última búsqueda) self.state = {} # (cat, par) -> (dtype, data bytes, valores) self.protocol_version = "" self.power_on = None self._send_lock = asyncio.Lock() self._proto_uuid = None self._payload_count = 0 self._payload_event = None # ----- eventos hacia la interfaz ------------------------------------- def emit(self, kind: str, **payload): payload["kind"] = kind payload["t"] = time.time() self.events.put(payload) def log(self, text: str): self.emit("log", text=text) # ----- búsqueda y conexión ------------------------------------------ async def scan(self, timeout: float = 5.0, emit_event: bool = True): self.log(f"Buscando cámaras ({timeout:.0f} s)...") found = await BleakScanner.discover(timeout=timeout, return_adv=True) cams = [] for address, (dev, adv) in found.items(): uuids = [u.lower() for u in (adv.service_uuids or [])] name = dev.name or adv.local_name or "" if SERVICE_UUID in uuids or name.startswith("A:"): self.devices[address] = dev cams.append({"address": address, "name": name or "(sin nombre)", "rssi": adv.rssi}) cams.sort(key=lambda c: -c["rssi"]) if emit_event: self.emit("devices", devices=cams) self.log(f"Anunciándose ahora: {len(cams)} cámara(s).") return cams async def paired_cameras(self): """Cámaras Blackmagic ya emparejadas en Windows (aunque no se estén anunciando). Se consulta el árbol PnP de Windows con PowerShell: cada dispositivo BLE emparejado aparece como BTHLE\\DEV_ con su nombre. """ cams = [] try: cmd = ( "Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | " "Where-Object { $_.InstanceId -like 'BTHLE\\DEV_*' } | " "Select-Object FriendlyName, InstanceId, Status | ConvertTo-Json -Compress" ) out = await asyncio.to_thread(_run_powershell, cmd) if out.strip(): items = json.loads(out) if isinstance(items, dict): items = [items] for it in items: name = (it.get("FriendlyName") or "").strip() inst = it.get("InstanceId") or "" if not (name.startswith("A:") or "blackmagic" in name.lower()): continue m = re.search(r"DEV_([0-9A-Fa-f]{12})", inst) if not m: continue mac = m.group(1).upper() addr = ":".join(mac[i : i + 2] for i in range(0, 12, 2)) cams.append({"address": addr, "name": name, "rssi": None, "paired": True}) except Exception as e: # noqa: BLE001 self.log(f"No se pudo consultar los dispositivos emparejados: {e}") return cams async def find_cameras(self, timeout: float = 5.0): """Une las emparejadas en Windows con las que se anuncian ahora mismo.""" paired = await self.paired_cameras() seen = await self.scan(timeout, emit_event=False) by_addr = {c["address"].upper(): dict(c, paired=False) for c in seen} for c in paired: key = c["address"].upper() if key in by_addr: by_addr[key]["paired"] = True else: by_addr[key] = c cams = sorted(by_addr.values(), key=lambda c: (-(c["rssi"] or -999))) self.emit("devices", devices=cams) return cams async def connect(self, address: str, name: str | None = None, fresh_retry: bool = False): if self.client and self.client.is_connected: await self.disconnect() self.emit("connecting", address=address) dev = self.devices.get(address) if dev is None: # En Windows basta la dirección: no hace falta que la cámara se esté anunciando # (una cámara ya conectada a nivel de sistema deja de anunciarse). from bleak.backends.device import BLEDevice dev = BLEDevice(address, name, None) elif name and not dev.name: dev = type(dev)(dev.address, name, dev.details) self.device = dev self.state = {} got_payload = False for attempt in (1, 2): self.client = BleakClient(dev, disconnected_callback=self._on_disconnected, timeout=30.0) self.log(f"Conectando con {dev.name or address}..." if attempt == 1 else "Conectando de nuevo con enlace nuevo...") try: await self.client.connect() got_payload = await self._setup_after_connect(address, dev) except Exception: c, self.client = self.client, None try: await c.disconnect() except Exception: # noqa: BLE001 pass raise if got_payload or not fresh_retry or attempt == 2: break # Sin volcado inicial: el enlace Bluetooth venía reutilizado. Lo cerramos y esperamos a # que Windows lo suelte para que la próxima conexión sea un enlace nuevo. self.log("La cámara no ha enviado su configuración (enlace Bluetooth reutilizado). " "Reinicio el enlace para forzarla...") self.emit("connecting", address=address, text="Reiniciando el enlace Bluetooth para leer la configuración...") c, self.client = self.client, None try: await c.disconnect() except Exception: # noqa: BLE001 pass if not await self._wait_link_down(address, 20.0): self.log("Windows mantiene el enlace abierto: suele ser la ventana de Configuración > " "Bluetooth. Ciérrala y vuelve a conectar para que lleguen todos los valores.") self.emit("link_held", address=address) self.emit( "connected", address=address, name=dev.name or address, protocol=self.protocol_version, mtu=self.client.mtu_size, payload=got_payload, ) if got_payload: self.log(f"Conectada. Protocolo {self.protocol_version or '?'}. Configuración recibida.") else: self.log(f"Conectada. Protocolo {self.protocol_version or '?'}. Sin configuración inicial: los " "valores se irán rellenando según los toques.") async def _wait_link_down(self, address: str, timeout: float) -> bool: """Espera a que Windows suelte el enlace BLE con la cámara (connection_status = 0).""" try: from winrt.windows.devices.bluetooth import BluetoothLEDevice except Exception: # noqa: BLE001 await asyncio.sleep(3.0) return True addr = int(address.replace(":", ""), 16) t0 = time.time() while time.time() - t0 < timeout: d = await BluetoothLEDevice.from_bluetooth_address_async(addr) status = d.connection_status if d is not None else 0 if d is not None: d.close() if status == 0: await asyncio.sleep(0.5) return True await asyncio.sleep(1.0) return False async def _setup_after_connect(self, address: str, dev) -> bool: # Localizar la característica de versión de protocolo (el sufijo del UUID varía) self._proto_uuid = None found_service = False for svc in self.client.services: if svc.uuid.lower() != SERVICE_UUID: continue found_service = True for ch in svc.characteristics: if ch.uuid.lower().startswith(CH_PROTO_PREFIX): self._proto_uuid = ch.uuid if not found_service: # Windows oculta el servicio cuando no puede cifrar el enlace: la cámara ya no # reconoce el emparejamiento guardado (o nunca se emparejó). self.emit("needs_pairing", address=address) raise RuntimeError( "Acceso denegado al servicio de control de la cámara. Causas, por orden: (1) otra " "aplicación de este PC ya está conectada a la cámara (otra copia de Labmagic, un " "script de prueba o la ventana de Configuración > Bluetooth): ciérrala y vuelve a " "conectar; (2) la cámara ya no acepta el emparejamiento guardado: pulsa «Emparejar " "(PIN)» y escribe el PIN que muestre la cámara." ) if self._proto_uuid: try: raw = await self.client.read_gatt_char(self._proto_uuid) self.protocol_version = raw.decode("utf-8", errors="replace").strip("\x00 ") except Exception as e: # noqa: BLE001 self.protocol_version = "?" self.log(f"No se pudo leer la versión de protocolo: {e}") # Nombre con el que aparecemos en el menú Bluetooth de la cámara try: name = f"{platform.node()[:18]} (PC)" await self.client.write_gatt_char(CH_DEVNAME, name.encode("utf-8"), response=True) except Exception as e: # noqa: BLE001 self.log(f"No se pudo enviar el nombre del equipo: {e}") # Estado (encendida/apagada) try: st = await self.client.read_gatt_char(CH_STATUS) self._handle_status(bytes(st)) except Exception as e: # noqa: BLE001 msg = str(e).lower() if "auth" in msg or "access" in msg or "0x05" in msg or "0x0f" in msg: raise RuntimeError( "La cámara exige emparejamiento. Empareja desde Configuración de Windows > " "Bluetooth con el PIN que muestra la cámara y vuelve a conectar." ) from e raise self._payload_count = 0 self._payload_event = asyncio.Event() await self.client.start_notify(CH_STATUS, lambda _c, d: self._handle_status(bytes(d))) await self.client.start_notify(CH_TIMECODE, lambda _c, d: self._handle_timecode(bytes(d))) await self.client.start_notify(CH_IN, lambda _c, d: self._handle_incoming(bytes(d))) # Pedir el volcado completo de configuración. La cámara lo envía sola solo cuando el enlace # Bluetooth es nuevo; escribir "Camera Ready" (0x20) con "Initial Payload Received" (0x10) a # cero (y el bit de encendido) hace que lo reenvíe aunque Windows reutilice el enlace. try: await self.client.write_gatt_char(CH_STATUS, bytes([0x21]), response=True) except Exception as e: # noqa: BLE001 self.log(f"No se pudo pedir la configuración a la cámara: {e}") # El volcado llega en varios bloques a lo largo de unos segundos; esperamos a tener una # cantidad razonable de parámetros (o 6 s) antes de dar la conexión por completa. t0 = time.time() while time.time() - t0 < 6.0 and self._payload_count < 8: await asyncio.sleep(0.2) return self._payload_count >= 8 async def disconnect(self): c = self.client self.client = None if c is not None: try: await c.disconnect() except Exception as e: # noqa: BLE001 self.log(f"Error al desconectar: {e}") self.emit("disconnected", reason="manual") def _on_disconnected(self, client): # Solo cuenta si es el cliente actual (los avisos de clientes antiguos llegan con retraso) if self.client is not None and client is self.client: self.client = None self.log("La cámara se ha desconectado.") self.emit("disconnected", reason="remote") @property def connected(self) -> bool: return bool(self.client and self.client.is_connected) # ----- emparejamiento con PIN (sin pasar por Configuración de Windows) ---------- async def pair(self, address: str, ask_pin, name: str | None = None): """Quita el emparejamiento antiguo de Windows y empareja de nuevo con el PIN de la cámara. ask_pin: función sin argumentos que se llama desde un hilo auxiliar y debe devolver el PIN escrito por el usuario (o None para cancelar). Puede bloquear mientras espera. Al terminar con éxito, conecta. """ from winrt.windows.devices.bluetooth import BluetoothLEDevice from winrt.windows.devices.enumeration import ( DeviceInformation, DevicePairingKinds, DevicePairingProtectionLevel, ) if self.connected: await self.disconnect() self.emit("pairing", stage="start", address=address) dev = await BluetoothLEDevice.from_bluetooth_address_async(int(address.replace(":", ""), 16)) if dev is None: raise RuntimeError("No se encuentra la cámara. ¿Está encendida y con el Bluetooth activado?") info = await DeviceInformation.create_from_id_async(dev.device_information.id) pairing = info.pairing if pairing.is_paired: self.log("Quitando el emparejamiento anterior de Windows...") res = await pairing.unpair_async() self.log(f"Emparejamiento anterior eliminado (estado {int(res.status)}).") await asyncio.sleep(1.5) info = await DeviceInformation.create_from_id_async(dev.device_information.id) pairing = info.pairing if not pairing.can_pair: raise RuntimeError("Windows indica que este dispositivo no se puede emparejar ahora mismo.") custom = pairing.custom log = self.log def handler(_sender, args): kind = int(args.pairing_kind) if kind == int(DevicePairingKinds.PROVIDE_PIN): deferral = args.get_deferral() def work(): try: pin = ask_pin() if pin: args.accept_with_pin(str(pin).strip()) else: log("Emparejamiento cancelado por el usuario.") except Exception as e: # noqa: BLE001 log(f"Error al aceptar el PIN: {e}") finally: deferral.complete() threading.Thread(target=work, name="pin-prompt", daemon=True).start() elif kind == int(DevicePairingKinds.DISPLAY_PIN): log(f"La cámara pide este PIN: {args.pin}") args.accept() else: args.accept() token = custom.add_pairing_requested(handler) try: try: kinds = DevicePairingKinds( int(DevicePairingKinds.PROVIDE_PIN) | int(DevicePairingKinds.CONFIRM_ONLY) | int(DevicePairingKinds.DISPLAY_PIN) ) except Exception: # noqa: BLE001 kinds = DevicePairingKinds.PROVIDE_PIN self.log("Solicitando emparejamiento: mira la pantalla de la cámara, mostrará un PIN.") self.emit("pairing", stage="requested", address=address) result = await custom.pair_with_protection_level_async( kinds, DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION ) finally: custom.remove_pairing_requested(token) status = int(result.status) names = { 0: "emparejada", 1: "la cámara no está lista para emparejar", 2: "no emparejada", 3: "ya estaba emparejada", 4: "la cámara rechazó la conexión", 5: "demasiadas conexiones", 6: "fallo de hardware", 7: "tiempo de espera agotado (no llegó el PIN a tiempo)", 8: "autenticación no permitida", 9: "fallo de autenticación (PIN incorrecto)", 10: "sin perfiles compatibles", 11: "no se alcanzó el nivel de protección", 12: "acceso denegado", 13: "datos de ceremonia no válidos", 14: "emparejamiento cancelado", 15: "ya hay una operación en curso", 16: "falta el manejador de PIN", 17: "rechazado por el manejador", 18: "el dispositivo ya tiene asociación", 19: "fallo", } if status not in (0, 3): self.emit("pairing", stage="failed", address=address, status=status) raise RuntimeError(f"No se pudo emparejar: {names.get(status, status)} (código {status}).") self.log("Cámara emparejada con Windows. Conectando...") self.emit("pairing", stage="done", address=address) await asyncio.sleep(1.0) await self.connect(address, name) # ----- notificaciones ----------------------------------------------- def _handle_status(self, data: bytes): if not data: return self.power_on = bool(data[0] & 0x01) self.emit("status", raw=data.hex(), power_on=self.power_on) def _handle_timecode(self, data: bytes): pk = parse_packets(data) if pk: tc = decode_timecode(pk[-1][4]) elif len(data) >= 4: tc = decode_timecode(data[-4:]) else: return self.emit("timecode", timecode=tc) def _handle_incoming(self, data: bytes): for cat, par, dtype, op, raw in parse_packets(data): if cat == 255 and par == 255: # Fin de bloque del volcado inicial if self._payload_event is not None: self._payload_event.set() continue values = decode_data(dtype, raw) self.state[(cat, par)] = (dtype, raw, values) if cat != 9: self._payload_count += 1 self.emit("param", cat=cat, par=par, dtype=dtype, op=op, raw=raw, values=values) # ----- envío ---------------------------------------------------------- async def send(self, category: int, parameter: int, dtype: int, op: int, data: bytes = b""): if not self.connected: raise RuntimeError("No hay cámara conectada.") pkt = build_packet(category, parameter, dtype, op, data) async with self._send_lock: await self.client.write_gatt_char(CH_OUT, pkt, response=True) self.emit("sent", cat=category, par=parameter, dtype=dtype, op=op, raw=data) # --- Objetivo -------------------------------------------------------- async def autofocus(self): await self.send(0, 1, T_VOID, OP_ASSIGN) async def set_focus(self, value: float): await self.send(0, 0, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value)))) async def offset_focus(self, delta: float): await self.send(0, 0, T_FIXED16, OP_OFFSET, enc_fixed16(delta)) async def auto_iris(self): await self.send(0, 5, T_VOID, OP_ASSIGN) async def set_aperture_norm(self, value: float): await self.send(0, 3, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value)))) async def set_aperture_fstop(self, fnumber: float): """Apertura en f/: el protocolo usa el Aperture Value, AV = 2·log2(f).""" av = 2.0 * math.log2(max(0.7, float(fnumber))) await self.send(0, 2, T_FIXED16, OP_ASSIGN, enc_fixed16(av)) async def offset_aperture_norm(self, delta: float): await self.send(0, 3, T_FIXED16, OP_OFFSET, enc_fixed16(delta)) async def set_zoom_norm(self, value: float): await self.send(0, 8, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value)))) async def set_ois(self, enabled: bool): await self.send(0, 6, T_VOID, OP_ASSIGN, bytes([1 if enabled else 0])) # --- Vídeo / exposición --------------------------------------------- async def set_iso(self, iso: int): await self.send(1, 14, T_INT32, OP_ASSIGN, struct.pack("= 5: data = bytearray(cur[1]) else: data = bytearray([0, 0, 0, 0, 0]) data[0] = 2 if recording else 0 # 0 = preview, 1 = play, 2 = record data[1] = 0 # velocidad: 0 = pausa / grabación normal await self.send(10, 1, T_INT8, OP_ASSIGN, bytes(data)) # --- Monitor --------------------------------------------------------- # Pantallas destino (segundo uint16 de 3.0 Overlay enables y 4.1 Exposure and focus tools) OUT_LCD, OUT_HDMI, OUT_EVF, OUT_SDI_MAIN, OUT_SDI_FRONT = 1, 2, 4, 8, 16 async def set_tools(self, zebra: bool, focus_assist: bool, false_colour: bool, targets: int = 3): """4.1: [bits zebra 0x1, focus assist 0x2, falso color 0x4] + [pantallas destino].""" bits = (1 if zebra else 0) | (2 if focus_assist else 0) | (4 if false_colour else 0) await self.send(4, 1, T_INT16, OP_ASSIGN, struct.pack("= 5 else bytearray([0, 0, 0, 0, 0]) data[0] = mode & 0xFF data[1] = struct.pack("