186 lines
7.3 KiB
Python
186 lines
7.3 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
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
AUTHORITY = "com.kazeia.provider"
|
|
PROVIDER_URI = f"content://{AUTHORITY}"
|
|
|
|
ContentRow = dict[str, Any]
|
|
|
|
|
|
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()[1:]: # saute l'en-tête "List of devices attached"
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
parts = line.split()
|
|
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) -> 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).
|
|
"""
|
|
cmd = f"content query --uri {PROVIDER_URI}/{path}"
|
|
if where is not None:
|
|
cmd += f' --where "{where}"'
|
|
return parse_content_query(self.shell(cmd, serial=serial))
|
|
|
|
# ---- 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
|