Kazeia-central/kazeia_central/core/adb/client.py

221 lines
9.0 KiB
Python

"""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 `<serial> <state> [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/<path>.
⚠️ 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