"""Wrapper adb pour Kazeia-central. Couche de transport bas niveau : découverte des tablettes, `content query/call`, push/pull. Tout est ciblé par `serial` (multi-tablette). PORTÉE (étapes 0-1) : lecture via `content query` sur les endpoints du provider `com.kazeia.provider` dont les valeurs sont numériques ou des chaînes courtes. Le texte riche (prompts, texte des tours, écriture de config) passera par les méthodes `call()` base64-JSON décrites dans docs/PROVIDER_RPC_SPEC.md, ABSENTES côté app patiente à ce jour → non implémentées ici tant qu'elles n'existent pas. """ from __future__ import annotations import base64 import json import re import subprocess import time from dataclasses import dataclass, field from typing import Any AUTHORITY = "com.kazeia.provider" PROVIDER_URI = f"content://{AUTHORITY}" ContentRow = dict[str, Any] # États possibles d'une tablette dans `adb devices -l` (2e colonne). Sert à # distinguer une vraie ligne device des lignes de bruit (`* daemon ... *`, # l'en-tête "List of devices attached", lignes vides). _DEVICE_STATES = frozenset({ "device", "offline", "unauthorized", "authorizing", "bootloader", "recovery", "sideload", "connecting", "host", "no", # "no permissions" }) # Délai avant le retry de réveil quand le provider est endormi/throttlé (§3.5). _WAKE_RETRY_DELAY = 0.4 class AdbError(RuntimeError): """Échec d'une commande adb (code de retour non nul ou adb introuvable).""" @dataclass(frozen=True) class AdbDevice: serial: str state: str # "device", "unauthorized", "offline", ... props: dict[str, str] = field(default_factory=dict) @property def model(self) -> str: return self.props.get("model", "") @property def ready(self) -> bool: return self.state == "device" class Adb: """Façade sur le binaire `adb`. Instancier une fois, réutiliser.""" def __init__(self, adb_path: str = "adb", timeout: float = 30.0) -> None: self.adb_path = adb_path self.timeout = timeout # ---- bas niveau -------------------------------------------------------- def _run(self, args: list[str], *, serial: str | None = None, timeout: float | None = None, binary: bool = False) -> subprocess.CompletedProcess: cmd = [self.adb_path] if serial: cmd += ["-s", serial] cmd += args try: cp = subprocess.run( cmd, capture_output=True, timeout=timeout or self.timeout, check=False, ) except FileNotFoundError as e: raise AdbError(f"binaire adb introuvable: {self.adb_path}") from e except subprocess.TimeoutExpired as e: raise AdbError(f"timeout adb: {' '.join(args)}") from e if cp.returncode != 0: stderr = cp.stderr.decode("utf-8", "replace").strip() raise AdbError(f"adb {' '.join(args)} -> rc={cp.returncode}: {stderr}") return cp def shell(self, command: str, *, serial: str | None = None, timeout: float | None = None) -> str: """Exécute une commande shell. `command` est passé en un seul argument (adb le ré-assemble) — éviter les métacaractères non maîtrisés.""" cp = self._run(["shell", command], serial=serial, timeout=timeout) return cp.stdout.decode("utf-8", "replace") # ---- découverte -------------------------------------------------------- def devices(self) -> list[AdbDevice]: out = self._run(["devices", "-l"]).stdout.decode("utf-8", "replace") devices: list[AdbDevice] = [] for line in out.splitlines(): # Ne pas se fier à la position de l'en-tête : adb émet des lignes # `* daemon not running... *` AVANT "List of devices attached". # Une vraie ligne device a ` [props...]`. line = line.strip() parts = line.split() if len(parts) < 2 or parts[1] not in _DEVICE_STATES: continue serial, state = parts[0], parts[1] props = {} for tok in parts[2:]: if ":" in tok: k, v = tok.split(":", 1) props[k] = v devices.append(AdbDevice(serial=serial, state=state, props=props)) return devices def ready_devices(self) -> list[AdbDevice]: return [d for d in self.devices() if d.ready] # ---- provider : lecture (query) --------------------------------------- def content_query(self, path: str, *, serial: str | None = None, where: str | None = None, expect_rows: bool = False, _retry: bool = True) -> list[ContentRow]: """`content query` sur content://com.kazeia.provider/. ⚠️ Le format de sortie d'`adb content query` (`Row: N k=v, k=v`) est FRAGILE : il casse si une valeur contient `, ` ou un saut de ligne. N'utiliser que pour les endpoints à valeurs simples. Pour le texte riche, attendre les méthodes call() base64-JSON (PROVIDER_RPC_SPEC.md). Réveil/retry (§3.5) : le provider peut être endormi/throttlé et alors la commande échoue ou rend 0 ligne. La 1ʳᵉ requête sert de réveil ; on re-tente UNE fois. `expect_rows=True` pour les endpoints mono-ligne (/state, /rag_status, /updates, /dist_config) où 0 ligne = endormi, pas un résultat légitime. Les endpoints-liste laissent `expect_rows=False` (0 ligne est une réponse valide → pas de retry inutile). """ cmd = f"content query --uri {PROVIDER_URI}/{path}" if where is not None: cmd += f' --where "{where}"' try: rows = parse_content_query(self.shell(cmd, serial=serial)) except AdbError: if not _retry: raise time.sleep(_WAKE_RETRY_DELAY) return self.content_query(path, serial=serial, where=where, expect_rows=expect_rows, _retry=False) if expect_rows and not rows and _retry: time.sleep(_WAKE_RETRY_DELAY) return self.content_query(path, serial=serial, where=where, expect_rows=expect_rows, _retry=False) return rows # ---- provider : RPC (call) — pour les méthodes base64-JSON futures ----- def content_call(self, method: str, *, arg_json: Any | None = None, extras: dict[str, str] | None = None, serial: str | None = None) -> dict[str, Any]: """`content call` générique. Encode `arg_json` en base64(JSON) (convention PROVIDER_RPC_SPEC §1). Nécessite les méthodes côté app patiente (à venir). Retourne le Bundle parsé en dict.""" cmd = f"content call --uri {PROVIDER_URI} --method {method}" if arg_json is not None: payload = base64.b64encode(json.dumps(arg_json).encode("utf-8")).decode("ascii") cmd += f" --arg {payload}" for k, v in (extras or {}).items(): cmd += f" --extra {k}:s:{v}" return parse_call_bundle(self.shell(cmd, serial=serial)) # ---- fichiers ---------------------------------------------------------- def pull(self, remote: str, local: str, *, serial: str | None = None) -> None: self._run(["pull", remote, local], serial=serial, timeout=600) def push(self, local: str, remote: str, *, serial: str | None = None) -> None: self._run(["push", local, remote], serial=serial, timeout=600) # ---- parsing ------------------------------------------------------------- _ROW_RE = re.compile(r"^Row:\s*\d+\s+(.*)$") def parse_content_query(output: str) -> list[ContentRow]: """Parse la sortie texte d'`adb content query`. Format : une ligne `Row: 0 col=val, col=val, ...` par ligne de résultat, ou `No result found.`. Les valeurs `NULL` deviennent None. Limitation documentée : un `, ` ou un saut de ligne dans une valeur casse le découpage. """ rows: list[ContentRow] = [] for line in output.splitlines(): line = line.strip() m = _ROW_RE.match(line) if not m: continue row: ContentRow = {} for pair in m.group(1).split(", "): if "=" not in pair: continue k, v = pair.split("=", 1) row[k.strip()] = None if v == "NULL" else v rows.append(row) return rows def parse_call_bundle(output: str) -> dict[str, Any]: """Parse le `Result: Bundle[mParcelledData...]` d'`adb content call`. L'outil rend une représentation peu structurée du Bundle ; on extrait les paires `clé=valeur`. Suffisant pour les retours simples (ok, error, path, count, result_b64). À durcir quand les méthodes call() seront livrées. """ result: dict[str, Any] = {} m = re.search(r"Bundle\[\{(.*)\}\]", output, re.DOTALL) if not m: return result for pair in m.group(1).split(", "): if "=" in pair: k, v = pair.split("=", 1) result[k.strip()] = v.strip() return result