initial aubox skeleton: web-UI, kirin DLOAD, firmware library

- FastAPI Web-UI auf 127.0.0.1:8080 mit Geräte-Live-Erkennung,
  sandboxed File-Browser, Firmware-Library (SQLite + Auto-Identifikation)
- Huawei update.app Parser: extrahiert Hardware-ID, Section-Layout,
  BOOT/SYSTEM-Vorhandensein direkt aus den Headern
- Kirin Download-Mode: hisi-idt-Protokoll-Implementation gegen pyusb
- USB-Erkennung für Huawei (DLOAD/Fastboot-D), Google, MediaTek, Qualcomm EDL
- Huawei-P10-Lite-Workflow (eRecovery + Testpoint-DLOAD-Pfade)
- Docker-Compose mit USB-Passthrough (Major 189) für Re-Enumeration
- udev-Regeln + Setup-Script für Debian/Ubuntu/Pi-OS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-26 12:09:39 +02:00
parent d0386b3c53
commit fb3534553b
35 changed files with 1883 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "0.0.1"
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
+119
View File
@@ -0,0 +1,119 @@
"""CLI-Einstiegspunkt: `python -m aubox <command>`."""
from __future__ import annotations
import argparse
import sys
import time
from . import p10lite, usb
from .kirin import KirinDload
def cmd_detect(_args: argparse.Namespace) -> int:
devs = usb.scan()
if not devs:
print("Kein bekanntes Hersteller-Gerät angeschlossen.")
print("Probiere `lsusb` für die rohe Liste.")
return 1
for d in devs:
print(d)
if d.mode.notes:
print(f" {d.mode.notes}")
return 0
def cmd_p10lite_frp(args: argparse.Namespace) -> int:
if args.method == "erecovery":
print(p10lite.erecovery_instructions())
return 0
if args.method == "dload-erase":
try:
plan = p10lite.dload_erase_plan()
except FileNotFoundError as e:
print(str(e), file=sys.stderr)
return 2
print("Plan:")
for stage in plan["stages"]:
print(f" - {stage['label']}: {stage['file']} -> 0x{stage['addr']:08x}")
print()
print("Bitte jetzt Testpoint setzen und USB anschließen.")
print("Warte auf Kirin DLOAD (12d1:1100)...")
for _ in range(60):
if usb.find_first("kirin-dload") is not None:
break
time.sleep(0.5)
else:
print("Timeout: kein Kirin DLOAD erschienen.", file=sys.stderr)
return 3
print("Gerät erkannt. Sende Loader-Stages...")
with KirinDload() as k:
for stage in plan["stages"]:
print(f" -> {stage['label']}")
k.send_loader(stage["file"], stage["addr"])
# Zwischen den Stages re-enumeriert das Gerät evtl.
time.sleep(2)
print("Loader-Stages gesendet. Erwarteter Folgemodus:",
plan["post"])
print("Nächster Schritt (manuell, bis Workflow ausgereift):")
print(" fastboot devices")
print(f" fastboot erase {plan['frp_partition']}")
return 0
print(f"Methode {args.method!r} noch nicht implementiert.", file=sys.stderr)
return 2
def cmd_web(args: argparse.Namespace) -> int:
import uvicorn
uvicorn.run(
"aubox.web.app:app",
host=args.host,
port=args.port,
reload=args.reload,
log_level="info",
)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="aubox")
sub = p.add_subparsers(dest="cmd", required=True)
sp_detect = sub.add_parser("detect", help="Angeschlossene Geräte erkennen")
sp_detect.set_defaults(func=cmd_detect)
sp_web = sub.add_parser("web", help="Lokale Web-UI starten")
sp_web.add_argument("--host", default="127.0.0.1",
help="Bind-Adresse (Default: 127.0.0.1, im Container 0.0.0.0)")
sp_web.add_argument("--port", type=int, default=8080)
sp_web.add_argument("--reload", action="store_true",
help="Auto-Reload bei Code-Änderungen (Entwicklung)")
sp_web.set_defaults(func=cmd_web)
sp_p10 = sub.add_parser("p10lite", help="Workflows für Huawei P10 Lite")
p10_sub = sp_p10.add_subparsers(dest="p10_cmd", required=True)
sp_frp = p10_sub.add_parser("frp-remove", help="Google-Account-Sperre entfernen")
sp_frp.add_argument(
"--method",
choices=["erecovery", "dload-erase", "dload-flash"],
default="erecovery",
help="Welcher Pfad? Default: erecovery (sicherster, kein Loader nötig)",
)
sp_frp.set_defaults(func=cmd_p10lite_frp)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
+70
View File
@@ -0,0 +1,70 @@
"""Sandbox-fähiger Datei-Browser.
Egal welcher Pfad reinkommt — er wird gegen ein konfiguriertes Root-Verzeichnis
geprüft. Path-Traversal (`../..`) ist damit ausgeschlossen.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
class PathEscapeError(PermissionError):
"""Pfad würde aus dem erlaubten Root ausbrechen."""
@dataclass
class Entry:
name: str
rel_path: str # relativ zum Root, mit '/' Separator
is_dir: bool
size: int # Bytes (0 für Dirs)
def safe_resolve(root: Path, rel: str) -> Path:
"""Pfad relativ zum Root auflösen und verifizieren, dass er drin bleibt."""
root = root.resolve()
rel_clean = rel.lstrip("/").strip()
target = (root / rel_clean).resolve()
try:
target.relative_to(root)
except ValueError as e:
raise PathEscapeError(f"Pfad {rel!r} verlässt Root {root}") from e
return target
def list_dir(root: Path, rel: str = "") -> tuple[Path, list[Entry]]:
"""Inhalt eines Verzeichnisses listen, Dirs zuerst, dann Files alphabetisch."""
target = safe_resolve(root, rel)
if not target.exists():
raise FileNotFoundError(target)
if not target.is_dir():
raise NotADirectoryError(target)
entries: list[Entry] = []
for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
if child.name.startswith("."):
continue
rel_child = str(child.relative_to(root.resolve())).replace("\\", "/")
try:
size = child.stat().st_size if child.is_file() else 0
except OSError:
size = 0
entries.append(Entry(
name=child.name,
rel_path=rel_child,
is_dir=child.is_dir(),
size=size,
))
return target, entries
def breadcrumbs(rel: str) -> list[tuple[str, str]]:
"""Liste von (Label, Pfad) für die Anzeige als Breadcrumb-Navigation."""
parts = [p for p in rel.split("/") if p]
out = [("/", "")]
cur = ""
for p in parts:
cur = f"{cur}/{p}" if cur else p
out.append((p, cur))
return out
+144
View File
@@ -0,0 +1,144 @@
"""Kirin Download-Mode (HiSilicon) — hisi-idt-Protokoll.
Wenn ein Kirin-Gerät per Testpoint zur Erde gezogen und gebootet wird,
landet es im "DLOAD"-Modus mit USB-VID:PID 12d1:1100. In diesem Modus
hat es selbst noch keinen Speicher initialisiert — es wartet darauf,
dass der Host einen *xloader* (kleiner sekundärer Bootloader) per USB
schickt. Erst danach kommt der nächste Stage-Loader (`usb_loader.bin`),
und dann fährt das Gerät als erweiterter Fastboot hoch.
Das Protokoll dafür heißt im Huawei-Kosmos schlicht "hisi-idt". Es ist
nicht öffentlich dokumentiert, aber seit Jahren bekannt aus dem
hisi-idt.py-Skript, das u.a. im Kirin-Tools-Umfeld kursiert. Hier eine
saubere Implementation gegen pyusb.
WICHTIG:
Die Loader-Dateien (`hisi-sec_usb_xloader.bin`, `usb_loader.bin`)
sind Huawei-signierte Binaries. Du musst sie selbst beschaffen
und in `loaders/kirin/<soc>/` ablegen. Siehe loaders/README.md.
Diese Implementation orientiert sich am öffentlichen Protokoll-Wissen.
Auf realer P10-Lite-Hardware vor produktivem Einsatz validieren.
"""
from __future__ import annotations
import struct
import time
from pathlib import Path
import usb.core
import usb.util
KIRIN_DLOAD_VID = 0x12D1
KIRIN_DLOAD_PID = 0x1100
# Protokoll-Konstanten (hisi-idt)
HEAD_TAG = 0xFE
TAIL_TAG = 0xFE
TYPE_DATA = 0xDA
TYPE_HEAD = 0xA5
CHUNK = 0x800 # 2 KiB Payload pro Frame
TIMEOUT_MS = 5000
def _crc16_xmodem(data: bytes) -> int:
"""CRC-16/XMODEM (Polynom 0x1021, init 0)."""
crc = 0
for b in data:
crc ^= b << 8
for _ in range(8):
if crc & 0x8000:
crc = (crc << 1) ^ 0x1021
else:
crc <<= 1
crc &= 0xFFFF
return crc
def _frame(seq: int, payload: bytes, frame_type: int) -> bytes:
"""Ein hisi-idt-Frame bauen.
Layout:
HEAD (1) | TYPE (1) | SEQ (2 BE) | PAYLOAD | CRC16 (2 BE) | TAIL (1)
"""
body = struct.pack(">BBH", HEAD_TAG, frame_type, seq) + payload
crc = _crc16_xmodem(body[1:]) # CRC ohne HEAD
return body + struct.pack(">HB", crc, TAIL_TAG)
class KirinDload:
"""Verbindung zum Kirin im Download-Mode."""
def __init__(self) -> None:
self.dev: usb.core.Device | None = None
self.ep_out = None
self.ep_in = None
def open(self) -> None:
dev = usb.core.find(idVendor=KIRIN_DLOAD_VID, idProduct=KIRIN_DLOAD_PID)
if dev is None:
raise RuntimeError(
f"Kein Kirin-DLOAD-Gerät gefunden "
f"({KIRIN_DLOAD_VID:04x}:{KIRIN_DLOAD_PID:04x}). "
f"Testpoint sitzen? USB-Kabel datentauglich?"
)
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
dev.set_configuration()
cfg = dev.get_active_configuration()
intf = cfg[(0, 0)]
self.ep_out = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
== usb.util.ENDPOINT_OUT,
)
self.ep_in = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
== usb.util.ENDPOINT_IN,
)
if self.ep_out is None:
raise RuntimeError("Kein OUT-Endpunkt gefunden — falsche USB-Konfiguration?")
self.dev = dev
def close(self) -> None:
if self.dev is not None:
usb.util.dispose_resources(self.dev)
self.dev = None
def __enter__(self) -> "KirinDload":
self.open()
return self
def __exit__(self, *exc) -> None:
self.close()
def send_loader(self, path: Path, load_addr: int) -> None:
"""Loader-Binary in einer Folge von Frames an das Gerät senden.
Args:
path: Pfad zur xloader/usb_loader-Datei.
load_addr: Zieladresse im SRAM. Pro SoC unterschiedlich.
Für Kirin 658 (P10 Lite, xloader) typischerweise 0x07012000.
In der Doku des SoCs verifizieren.
"""
data = path.read_bytes()
self._send_header(load_addr, len(data))
seq = 1
for offset in range(0, len(data), CHUNK):
chunk = data[offset : offset + CHUNK]
self._write(_frame(seq, chunk, TYPE_DATA))
seq = (seq + 1) & 0xFFFF
# Kleines Delay — Kirin akzeptiert zu schnelle Bursts nicht zuverlässig
time.sleep(0.001)
# Sequenz abschließen: einige Implementationen senden ein finales Frame
# mit Länge 0. Bei Bedarf hier ergänzen, je nach SoC-Verhalten.
def _send_header(self, addr: int, size: int) -> None:
"""Header-Frame: sagt dem Gerät, wohin und wieviel."""
payload = struct.pack(">II", addr, size)
self._write(_frame(0, payload, TYPE_HEAD))
def _write(self, buf: bytes) -> None:
assert self.dev is not None and self.ep_out is not None
self.ep_out.write(buf, timeout=TIMEOUT_MS)
+9
View File
@@ -0,0 +1,9 @@
"""Firmware-Library: SQLite-Index + Format-Identifikation.
Öffentliche API:
db.connect(path) -> sqlite3.Connection
db.upsert_firmware(conn, record)
db.list_firmware(conn, ...)
identify.identify(path) -> FirmwareInfo | None
"""
from . import db, identify # noqa: F401
+94
View File
@@ -0,0 +1,94 @@
"""SQLite-Index der Firmware-Library.
Die DB-Datei wandert standardmäßig nach `<firmware_root>/firmware.db`,
damit sie zur Library gehört und beim Mounten automatisch da ist.
"""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
SCHEMA = """
CREATE TABLE IF NOT EXISTS firmware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rel_path TEXT NOT NULL UNIQUE, -- relativ zum firmware-Root
size INTEGER NOT NULL,
mtime REAL NOT NULL,
sha256 TEXT, -- erst nach erstem Hash gefüllt
format TEXT, -- 'huawei-update.app', 'mtk-scatter', 'unknown', ...
vendor TEXT,
model TEXT, -- z.B. 'WAS-LX1'
soc TEXT, -- z.B. 'kirin-658'
version TEXT, -- z.B. '8.0.0.367'
region TEXT, -- z.B. 'C432'
extra_json TEXT, -- alles weitere als JSON
added_at REAL DEFAULT (strftime('%s','now')),
last_seen_at REAL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_fw_model ON firmware(model);
CREATE INDEX IF NOT EXISTS idx_fw_format ON firmware(format);
"""
def connect(db_path: Path) -> sqlite3.Connection:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
return conn
@contextmanager
def open_db(db_path: Path) -> Iterator[sqlite3.Connection]:
"""Connection-Lifecycle für FastAPI-Handler: öffnet, gibt zurück, schließt."""
conn = connect(db_path)
try:
yield conn
finally:
conn.close()
@contextmanager
def transaction(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def upsert(conn: sqlite3.Connection, rec: dict) -> int:
"""Einfügen oder bei Konflikt auf rel_path aktualisieren."""
cols = ["rel_path", "size", "mtime", "sha256", "format",
"vendor", "model", "soc", "version", "region", "extra_json"]
placeholders = ",".join(f":{c}" for c in cols)
update = ",".join(f"{c}=excluded.{c}" for c in cols if c != "rel_path")
sql = (f"INSERT INTO firmware ({','.join(cols)}) VALUES ({placeholders}) "
f"ON CONFLICT(rel_path) DO UPDATE SET {update}, "
f"last_seen_at=strftime('%s','now')")
cur = conn.execute(sql, {c: rec.get(c) for c in cols})
return cur.lastrowid or 0
def list_all(conn: sqlite3.Connection) -> list[sqlite3.Row]:
return list(conn.execute(
"SELECT * FROM firmware ORDER BY vendor, model, version"
))
def get_by_id(conn: sqlite3.Connection, fw_id: int) -> sqlite3.Row | None:
cur = conn.execute("SELECT * FROM firmware WHERE id = ?", (fw_id,))
return cur.fetchone()
def delete_missing(conn: sqlite3.Connection, present_rel_paths: set[str]) -> int:
"""Einträge entfernen, die beim letzten Scan nicht mehr da waren."""
cur = conn.execute("SELECT id, rel_path FROM firmware")
to_delete = [row["id"] for row in cur if row["rel_path"] not in present_rel_paths]
if to_delete:
conn.executemany("DELETE FROM firmware WHERE id = ?",
[(i,) for i in to_delete])
return len(to_delete)
+148
View File
@@ -0,0 +1,148 @@
"""Parser für Huawei `update.app`-Container.
Format-Beschreibung (öffentlich aus splituapp / Huawei Update Extractor):
Datei beginnt mit 0x5C Bytes Padding/Header, danach folgen Section-Header.
Jeder Section-Header ist 98 Bytes:
uint32 magic = 0xA55AAA55
uint32 header_length = 98
uint32 unknown1
char[8] hardware_id z.B. "WAS-LX1"
uint32 file_sequence
uint32 file_size
char[16] file_date z.B. "2018.04.16"
char[16] file_time z.B. "10:23:45"
char[16] file_type z.B. "BOOT", "SYSTEM", "USERDATA", "CRC"
char[16] blank1
uint16 header_checksum
uint16 block_size
uint16 blank2
Danach `file_size` Bytes Section-Daten, gefolgt von einer CRC-Tabelle
(eine uint16 pro `block_size` Bytes Daten), dann 4-Byte-Alignment auf
die nächste Section-Header-Position.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from pathlib import Path
UPDATE_MAGIC = 0xA55AAA55
HEADER_FMT = "<III8sII16s16s16s16sHHH"
HEADER_SIZE = 98
@dataclass
class Section:
sequence: int
size: int
date: str
time: str
type: str
@dataclass
class UpdateAppInfo:
hardware_id: str
section_count: int
sections: list[Section] = field(default_factory=list)
has_boot: bool = False
has_system: bool = False
earliest_date: str | None = None
def _cstr(b: bytes) -> str:
return b.split(b"\x00", 1)[0].decode("ascii", errors="replace").strip()
def parse(path: Path, max_sections: int = 500) -> UpdateAppInfo | None:
"""Header der ersten N Sections einlesen, ohne das ganze Image zu lesen."""
if not path.is_file():
return None
if path.stat().st_size < 0x100:
return None
with path.open("rb") as f:
# Magic suchen — sitzt meist bei 0x5C, manche Builds variieren
head = f.read(0x200)
idx = head.find(struct.pack("<I", UPDATE_MAGIC))
if idx < 0:
return None
f.seek(idx)
sections: list[Section] = []
hardware_id = ""
earliest: str | None = None
has_boot = has_system = False
for _ in range(max_sections):
buf = f.read(HEADER_SIZE)
if len(buf) < HEADER_SIZE:
break
(magic, _hlen, _u, hw, seq, size,
date, time_, ftype, _b, _crc, blksz, _b2) = struct.unpack(HEADER_FMT, buf)
if magic != UPDATE_MAGIC:
break
hw_str = _cstr(hw)
type_str = _cstr(ftype)
date_str = _cstr(date)
time_str = _cstr(time_)
if not hardware_id and hw_str:
hardware_id = hw_str
if earliest is None or (date_str and date_str < earliest):
if date_str:
earliest = date_str
if type_str.upper() in ("BOOT", "FASTBOOT", "XLOADER", "BOOTLOADER"):
has_boot = True
if type_str.upper() in ("SYSTEM", "SYSTEM_IMAGE"):
has_system = True
sections.append(Section(
sequence=seq, size=size, date=date_str,
time=time_str, type=type_str,
))
# Daten + CRC + Padding überspringen
if blksz <= 0:
break
crc_size = ((size + blksz - 1) // blksz) * 2
cur_after_data = f.tell() + size + crc_size
pad = (-cur_after_data) % 4
f.seek(size + crc_size + pad, 1)
if not sections:
return None
return UpdateAppInfo(
hardware_id=hardware_id,
section_count=len(sections),
sections=sections,
has_boot=has_boot,
has_system=has_system,
earliest_date=earliest,
)
# ---------------------------------------------------------------------------
# Modell -> SoC-Mapping. Wird mit der Zeit erweitert; was hier nicht steht,
# kommt als 'unknown' raus und der User kann es manuell pflegen.
HUAWEI_MODEL_SOC = {
"WAS-LX1": "kirin-658",
"WAS-LX2": "kirin-658",
"WAS-LX3": "kirin-658",
"PRA-LX1": "kirin-655", # P8 Lite 2017
"VTR-L09": "kirin-960", # P10
"VTR-L29": "kirin-960",
"VKY-L09": "kirin-960", # P10 Plus
"EML-L09": "kirin-970", # P20
"EML-L29": "kirin-970",
"CLT-L29": "kirin-970", # P20 Pro
"ANE-LX1": "kirin-710", # P20 Lite
}
def soc_for(hardware_id: str) -> str | None:
return HUAWEI_MODEL_SOC.get(hardware_id.upper())
+92
View File
@@ -0,0 +1,92 @@
"""Format-Erkennung. Dispatcher: schaut auf Magic + Dateinamen, leitet an
den passenden Parser weiter. Liefert ein einheitliches Dict mit den Feldern,
die in die DB gehen.
Aktuell unterstützt:
- Huawei update.app (vollständig)
Geplant (Stubs als Hinweis):
- MediaTek scatter (txt + bin)
- Samsung Odin (.tar.md5 mit AP/BL/CP/CSC)
- Qualcomm rawprogram*.xml
"""
from __future__ import annotations
import json
import struct
from pathlib import Path
from . import huawei
HUAWEI_MAGIC = struct.pack("<I", huawei.UPDATE_MAGIC)
def _peek(path: Path, n: int = 0x200) -> bytes:
try:
with path.open("rb") as f:
return f.read(n)
except OSError:
return b""
def identify(path: Path, root: Path) -> dict:
"""Rückgabe: dict für db.upsert. Enthält mindestens rel_path, size, mtime,
format. Erkennungs-Misserfolg -> format='unknown'."""
rel = str(path.resolve().relative_to(root.resolve())).replace("\\", "/")
stat = path.stat()
base: dict = {
"rel_path": rel,
"size": stat.st_size,
"mtime": stat.st_mtime,
"sha256": None,
"format": "unknown",
"vendor": None,
"model": None,
"soc": None,
"version": None,
"region": None,
"extra_json": None,
}
head = _peek(path)
# Huawei update.app — Magic taucht innerhalb der ersten 0x100 Bytes auf
if HUAWEI_MAGIC in head[:0x200]:
info = huawei.parse(path)
if info is not None:
base.update(
format="huawei-update.app",
vendor="Huawei",
model=info.hardware_id or None,
soc=huawei.soc_for(info.hardware_id) if info.hardware_id else None,
version=info.earliest_date, # bessere Quelle wenn verfügbar
region=_region_from_filename(path.name),
extra_json=json.dumps({
"section_count": info.section_count,
"has_boot": info.has_boot,
"has_system": info.has_system,
"first_sections": [
s.type for s in info.sections[:10]
],
}),
)
return base
# Fallback: Filename-Heuristik (MTK scatter etc. — TODO)
name = path.name.lower()
if name.startswith("mt") and name.endswith(".txt"):
base["format"] = "mtk-scatter-candidate"
elif name.endswith(".tar.md5"):
base["format"] = "samsung-odin-candidate"
base["vendor"] = "Samsung"
return base
def _region_from_filename(name: str) -> str | None:
"""Huawei-Region oft im Filename: '...C432B198...' -> 'C432'."""
upper = name.upper()
for marker in ("C432", "C636", "C185", "C233", "C605", "C10", "C461"):
if marker in upper:
return marker
return None
+93
View File
@@ -0,0 +1,93 @@
"""Workflows für Huawei P10 Lite (WAS-LX1, Kirin 658, EMUI 8.x).
Drei FRP-Removal-Pfade in absteigender Erfolgswahrscheinlichkeit:
1. erecovery — eRecovery + update.app von SD-Karte. Kein Loader nötig.
Kein Bootloader-Unlock nötig. Funktioniert in der Regel.
2. dload-erase — Testpoint -> Kirin DLOAD -> xloader -> frp-Partition leeren.
Braucht passende Loader-Files (siehe loaders/README.md).
3. dload-flash — Testpoint -> Kirin DLOAD -> kompletter Reflash via update.app.
Maximaler Eingriff, höchstes Risiko, bricht Garantie endgültig.
Was hier *nicht* funktioniert:
- ADB/Fastboot-FRP-Tricks: das Gerät ist im Setup-Wizard, ADB ist aus.
- TalkBack/YouTube-Tricks: von Google/Huawei seit Jahren gepatcht.
- Bootloader-Unlock-Code von Huawei: seit Juli 2018 abgeschaltet.
"""
from __future__ import annotations
from pathlib import Path
LOADER_ROOT = Path(__file__).resolve().parent.parent / "loaders" / "kirin" / "kirin960_lite"
# Kirin 658 wird intern oft mit dem Kirin-960-Loader-Set gefahren (Lite-Variante).
# Für 100%-Sicherheit: Loader vom exakten WAS-LX1-Firmware-Dump verwenden.
XLOADER_NAME = "hisi-sec_usb_xloader.bin"
USB_LOADER_NAME = "usb_loader.bin"
FASTBOOT_BIN_NAME = "fastboot.bin"
# SRAM-Adressen für die Loader-Stages (öffentlich aus Kirin-Reverse-Engineering)
# Im Zweifel mit dem Image abgleichen — manche WAS-Builds nutzen 0x07012000.
XLOADER_ADDR = 0x07012000
USB_LOADER_ADDR = 0x07012000
def erecovery_instructions() -> str:
"""Schritt-für-Schritt für den eRecovery-Pfad. Pure Anleitung, kein Code."""
return """
eRecovery-Methode (ohne Loader, ohne Bootloader-Unlock)
========================================================
Du brauchst:
- Eine FAT32-formatierte SD-Karte, max. 32 GB
- Die *exakte* update.app deiner Region (z.B. WAS-LX1 EMEA C432)
Bezug: huaweifirm.com, hovatek.com, stockromhuawei.com
- USB-Ladegerät (kein Datenkabel nötig)
Schritte:
1. Auf der SD-Karte einen Ordner `dload` (klein!) anlegen.
2. update.app dort hineinkopieren. Pfad: SD:/dload/update.app
3. SD-Karte in das ausgeschaltete P10 Lite stecken.
4. Beim Einschalten gleichzeitig halten: Power + Volume-Up + Volume-Down.
Halten bis das Huawei-Logo erscheint, dann nur noch Power weiter, andere lösen.
5. eRecovery erkennt die SD-Karte und bietet "Software-Update" / "SD-Karten-Update".
Bestätigen.
6. Nach Abschluss bootet das Gerät neu, FRP-Partition ist neu geschrieben,
Setup-Assistent startet ohne alten Google-Account.
Wenn eRecovery die Datei nicht erkennt:
- Region passt nicht (häufigster Fehler)
- update.app ist Multi-Part (.app + .lst). Beide nach /dload kopieren.
- SD-Karte nicht FAT32 oder > 32 GB
"""
def dload_erase_plan(loader_dir: Path = LOADER_ROOT) -> dict:
"""Plant den Testpoint -> DLOAD -> frp-erase-Pfad.
Liefert ein Dict mit Pfaden und Adressen, das vom CLI ausgeführt wird.
Wirft FileNotFoundError, wenn die Loader-Files fehlen — dann landet der
User bei loaders/README.md und weiß, was zu tun ist.
"""
xloader = loader_dir / XLOADER_NAME
usb_loader = loader_dir / USB_LOADER_NAME
missing = [str(p) for p in (xloader, usb_loader) if not p.is_file()]
if missing:
raise FileNotFoundError(
"Folgende Loader-Files fehlen:\n - "
+ "\n - ".join(missing)
+ "\nSiehe loaders/README.md für Beschaffung."
)
return {
"stages": [
{"file": xloader, "addr": XLOADER_ADDR, "label": "xloader"},
{"file": usb_loader, "addr": USB_LOADER_ADDR, "label": "usb_loader"},
],
"post": "huawei-fastboot-d", # erwarteter Modus nach dem Loaden
"frp_partition": "frp",
# Größe der FRP-Partition auf P10 Lite: meist 1 MiB. Beim ersten echten
# Lauf gegen die ptable des Geräts gegenchecken (gpt-Dump via fastboot).
"frp_size_bytes": 1024 * 1024,
}
+82
View File
@@ -0,0 +1,82 @@
"""USB-Geräte-Erkennung über VID:PID.
Erkennt die wichtigsten Hersteller-Modi und ordnet sie einem semantischen
Status zu, damit Workflows wissen, in welchem Modus das Gerät gerade ist.
"""
from __future__ import annotations
from dataclasses import dataclass
import usb.core
import usb.util
@dataclass(frozen=True)
class DeviceMode:
label: str
vendor: str
notes: str = ""
# (vid, pid) -> Modus
KNOWN: dict[tuple[int, int], DeviceMode] = {
# Huawei / Kirin
(0x12D1, 0x1100): DeviceMode("kirin-dload", "Huawei",
"Kirin Download-Mode, wartet auf xloader via hisi-idt"),
(0x12D1, 0x1052): DeviceMode("huawei-fastboot-d", "Huawei",
"Erweiterter Fastboot-Mode (nach xloader)"),
(0x12D1, 0x3609): DeviceMode("kirin-dload-alt", "Huawei",
"Kirin DLOAD alternative PID"),
(0x12D1, 0x107E): DeviceMode("huawei-hisuite", "Huawei",
"HiSuite/MTP-Modus, kein Recovery-Zugriff"),
# Google / Android Standard
(0x18D1, 0x4EE0): DeviceMode("fastboot", "Google", "Standard-Fastboot"),
(0x18D1, 0x4EE7): DeviceMode("adb", "Google", "ADB"),
# MediaTek
(0x0E8D, 0x0003): DeviceMode("mtk-preloader", "MediaTek",
"Preloader (DA-Mode möglich)"),
(0x0E8D, 0x2000): DeviceMode("mtk-brom", "MediaTek",
"BROM — Ziel für mtkclient/kamakiri"),
# Qualcomm EDL
(0x05C6, 0x9008): DeviceMode("qc-edl", "Qualcomm",
"Emergency Download (Firehose-fähig)"),
}
@dataclass
class FoundDevice:
vid: int
pid: int
mode: DeviceMode
bus: int
address: int
def __str__(self) -> str:
return (f"[{self.bus:03d}:{self.address:03d}] "
f"{self.vid:04x}:{self.pid:04x} {self.mode.vendor} "
f"{self.mode.label}")
def scan() -> list[FoundDevice]:
"""Alle bekannten Hersteller-Modi auflisten, die gerade angeschlossen sind."""
found: list[FoundDevice] = []
for dev in usb.core.find(find_all=True):
key = (dev.idVendor, dev.idProduct)
mode = KNOWN.get(key)
if mode is None:
continue
found.append(FoundDevice(
vid=dev.idVendor,
pid=dev.idProduct,
mode=mode,
bus=dev.bus,
address=dev.address,
))
return found
def find_first(label: str) -> FoundDevice | None:
for d in scan():
if d.mode.label == label:
return d
return None
View File
+163
View File
@@ -0,0 +1,163 @@
"""FastAPI-App. Lokale Web-UI für aubox.
Start:
uvicorn aubox.web.app:app --host 127.0.0.1 --port 8080
oder über CLI:
python -m aubox web
"""
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .. import filebrowse, p10lite, usb
from ..library import db as fwdb
from ..library import identify as fwid
WEB_ROOT = Path(__file__).resolve().parent
FIRMWARE_ROOT = Path(os.environ.get("AUBOX_FIRMWARE_ROOT", "./firmware")).resolve()
LOADER_ROOT = Path(os.environ.get("AUBOX_LOADER_ROOT", "./loaders")).resolve()
DB_PATH = FIRMWARE_ROOT / "firmware.db"
app = FastAPI(title="aubox")
app.mount("/static", StaticFiles(directory=WEB_ROOT / "static"), name="static")
templates = Jinja2Templates(directory=WEB_ROOT / "templates")
def _human_size(n: int) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024:
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
n /= 1024
return f"{n:.1f} PB"
templates.env.filters["humansize"] = _human_size
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
devices = usb.scan()
with fwdb.open_db(DB_PATH) as conn:
fw_count = conn.execute("SELECT COUNT(*) FROM firmware").fetchone()[0]
return templates.TemplateResponse("index.html", {
"request": request,
"devices": devices,
"fw_count": fw_count,
"firmware_root": FIRMWARE_ROOT,
"loader_root": LOADER_ROOT,
})
# ---------- Devices --------------------------------------------------------
@app.get("/devices", response_class=HTMLResponse)
async def devices_page(request: Request):
return templates.TemplateResponse("devices.html", {"request": request})
@app.get("/api/devices/html", response_class=HTMLResponse)
async def devices_partial(request: Request):
return templates.TemplateResponse("_devices.html", {
"request": request,
"devices": usb.scan(),
})
# ---------- Firmware Library ----------------------------------------------
@app.get("/firmware", response_class=HTMLResponse)
async def firmware_page(request: Request):
with fwdb.open_db(DB_PATH) as conn:
rows = fwdb.list_all(conn)
return templates.TemplateResponse("firmware.html", {
"request": request,
"firmware": rows,
"firmware_root": FIRMWARE_ROOT,
})
@app.post("/firmware/scan")
async def firmware_scan():
"""Walkt FIRMWARE_ROOT, identifiziert jede Datei, schreibt in DB."""
if not FIRMWARE_ROOT.is_dir():
raise HTTPException(404, f"Firmware-Root {FIRMWARE_ROOT} nicht gefunden")
seen: set[str] = set()
added = updated = 0
with fwdb.open_db(DB_PATH) as conn:
with fwdb.transaction(conn):
for path in FIRMWARE_ROOT.rglob("*"):
if not path.is_file():
continue
if path.name == "firmware.db" or path.name.startswith("."):
continue
rec = fwid.identify(path, FIRMWARE_ROOT)
seen.add(rec["rel_path"])
existed = conn.execute(
"SELECT 1 FROM firmware WHERE rel_path = ?",
(rec["rel_path"],),
).fetchone()
fwdb.upsert(conn, rec)
if existed:
updated += 1
else:
added += 1
removed = fwdb.delete_missing(conn, seen)
return JSONResponse({
"scanned": len(seen),
"added": added,
"updated": updated,
"removed": removed,
})
@app.get("/firmware/{fw_id}", response_class=HTMLResponse)
async def firmware_detail(request: Request, fw_id: int):
with fwdb.open_db(DB_PATH) as conn:
row = fwdb.get_by_id(conn, fw_id)
if row is None:
raise HTTPException(404)
return templates.TemplateResponse("firmware_detail.html", {
"request": request,
"fw": row,
"firmware_root": FIRMWARE_ROOT,
})
# ---------- File Browser (sandboxed auf FIRMWARE_ROOT) --------------------
@app.get("/browse", response_class=HTMLResponse)
async def browse(request: Request, path: str = ""):
try:
target, entries = filebrowse.list_dir(FIRMWARE_ROOT, path)
except (filebrowse.PathEscapeError, FileNotFoundError, NotADirectoryError) as e:
raise HTTPException(400, str(e))
return templates.TemplateResponse("browse.html", {
"request": request,
"rel": path,
"entries": entries,
"crumbs": filebrowse.breadcrumbs(path),
"firmware_root": FIRMWARE_ROOT,
})
# ---------- Workflows ------------------------------------------------------
@app.get("/workflows/p10lite", response_class=HTMLResponse)
async def workflow_p10lite(request: Request):
return templates.TemplateResponse("p10lite.html", {
"request": request,
"instructions": p10lite.erecovery_instructions(),
})
@app.get("/health")
async def health():
return {"ok": True}
+50
View File
@@ -0,0 +1,50 @@
// Live-Refresh für Container mit data-refresh="<url>" und data-interval="<ms>"
function startAutoRefresh() {
document.querySelectorAll("[data-refresh]").forEach((el) => {
const url = el.dataset.refresh;
const interval = parseInt(el.dataset.interval || "2000", 10);
const tick = async () => {
try {
const r = await fetch(url, { headers: { "Accept": "text/html" } });
if (r.ok) el.innerHTML = await r.text();
} catch (e) { /* netzkurz weg, nicht weiter schlimm */ }
};
tick();
setInterval(tick, interval);
});
}
// Forms mit data-action posten und Antwort in data-target rendern
function wireScanForms() {
document.querySelectorAll("form[data-action]").forEach((form) => {
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const btn = form.querySelector("button[type=submit]");
const target = document.querySelector(form.dataset.target);
btn.disabled = true;
if (target) target.textContent = "läuft…";
try {
const r = await fetch(form.dataset.action, { method: "POST" });
const j = await r.json().catch(() => null);
if (target) {
if (j) {
target.textContent = `gescannt: ${j.scanned} · neu: ${j.added} · aktualisiert: ${j.updated} · entfernt: ${j.removed}`;
} else {
target.textContent = r.ok ? "fertig" : "Fehler";
}
}
// Seite neu laden, damit die Tabelle die neuen Einträge zeigt
if (r.ok) setTimeout(() => location.reload(), 800);
} catch (e) {
if (target) target.textContent = "Fehler: " + e;
} finally {
btn.disabled = false;
}
});
});
}
document.addEventListener("DOMContentLoaded", () => {
startAutoRefresh();
wireScanForms();
});
+136
View File
@@ -0,0 +1,136 @@
:root {
--bg: #1c1f24;
--bg-card: #262a31;
--fg: #e6e6e6;
--muted: #8a8f98;
--accent: #6cb4ff;
--accent-hover: #8fc8ff;
--border: #353a44;
--ok: #7adf7a;
--warn: #ffb86c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
}
header {
background: var(--bg-card);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 2rem;
}
header h1 { margin: 0; font-size: 1.4rem; }
header h1 a { color: var(--fg); text-decoration: none; }
nav { display: flex; gap: 1.5rem; }
nav a { color: var(--muted); text-decoration: none; font-weight: 500; }
nav a:hover { color: var(--accent); }
main { max-width: 1200px; margin: 0 auto; padding: 2rem; }
footer {
text-align: center; padding: 2rem; color: var(--muted);
border-top: 1px solid var(--border); margin-top: 4rem;
}
a { color: var(--accent); }
a:hover { color: var(--accent-hover); }
code, pre {
font-family: "JetBrains Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
}
pre {
background: var(--bg-card);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
border: 1px solid var(--border);
}
code { background: var(--bg-card); padding: 0.1em 0.4em; border-radius: 3px; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin: 2rem 0;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.card h2 { margin-top: 0; font-size: 1rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.card .big { font-size: 2.5rem; font-weight: 700; margin: 0.5rem 0; }
.info { margin-top: 3rem; }
.info ul { list-style: none; padding: 0; }
.info li { margin: 0.5rem 0; }
table.grid {
width: 100%;
border-collapse: collapse;
background: var(--bg-card);
border-radius: 8px;
overflow: hidden;
}
table.grid th, table.grid td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
table.grid th { background: rgba(255,255,255,0.04); font-weight: 600; }
table.grid tbody tr:last-child td { border-bottom: none; }
table.grid tbody tr:hover { background: rgba(255,255,255,0.03); }
table.kv { border-collapse: collapse; }
table.kv th { text-align: left; padding: 0.5rem 1rem 0.5rem 0; color: var(--muted); font-weight: 500; }
table.kv td { padding: 0.5rem 0; }
button {
background: var(--accent);
color: #0d1117;
border: 0;
padding: 0.6rem 1.2rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.95rem;
}
button:hover { background: var(--accent-hover); }
button:disabled { opacity: 0.5; cursor: wait; }
.empty {
background: var(--bg-card);
border: 1px dashed var(--border);
padding: 1.5rem;
border-radius: 8px;
color: var(--muted);
text-align: center;
}
.muted { color: var(--muted); }
.crumbs {
background: var(--bg-card);
padding: 0.6rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
border: 1px solid var(--border);
font-family: "JetBrains Mono", Menlo, monospace;
}
.crumbs a { color: var(--fg); text-decoration: none; }
.crumbs a:hover { color: var(--accent); }
#scan-result { margin-left: 1rem; color: var(--muted); }
+20
View File
@@ -0,0 +1,20 @@
{% if not devices %}
<p class="empty">Kein bekanntes Hersteller-Gerät am USB.</p>
{% else %}
<table class="grid">
<thead>
<tr><th>Bus:Addr</th><th>VID:PID</th><th>Hersteller</th><th>Modus</th><th>Hinweis</th></tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td>{{ "%03d"|format(d.bus) }}:{{ "%03d"|format(d.address) }}</td>
<td><code>{{ "%04x"|format(d.vid) }}:{{ "%04x"|format(d.pid) }}</code></td>
<td>{{ d.mode.vendor }}</td>
<td><strong>{{ d.mode.label }}</strong></td>
<td>{{ d.mode.notes }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>{% block title %}aubox{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1><a href="/">aubox</a></h1>
<nav>
<a href="/">Übersicht</a>
<a href="/devices">Geräte</a>
<a href="/firmware">Firmware</a>
<a href="/browse">Dateien</a>
<a href="/workflows/p10lite">P10 Lite</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<small>aubox · lokale Web-UI</small>
</footer>
<script src="/static/app.js"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Dateien · aubox{% endblock %}
{% block content %}
<h2>Datei-Browser</h2>
<p class="muted">Sandboxed auf <code>{{ firmware_root }}</code> — Path-Traversal blockiert.</p>
<nav class="crumbs">
{% for label, p in crumbs %}
<a href="/browse?path={{ p }}">{{ label }}</a>
{% if not loop.last %} / {% endif %}
{% endfor %}
</nav>
{% if not entries %}
<p class="empty">Verzeichnis ist leer.</p>
{% else %}
<table class="grid">
<thead><tr><th>Name</th><th>Typ</th><th>Größe</th></tr></thead>
<tbody>
{% for e in entries %}
<tr>
<td>
{% if e.is_dir %}
<a href="/browse?path={{ e.rel_path }}">{{ e.name }}/</a>
{% else %}
{{ e.name }}
{% endif %}
</td>
<td>{{ "DIR" if e.is_dir else "FILE" }}</td>
<td>{{ e.size|humansize if not e.is_dir else "—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Geräte · aubox{% endblock %}
{% block content %}
<h2>Angeschlossene Geräte</h2>
<p>Aktualisiert sich alle 2 Sekunden.</p>
<div id="devices" data-refresh="/api/devices/html" data-interval="2000">
Lade…
</div>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Firmware · aubox{% endblock %}
{% block content %}
<h2>Firmware-Library</h2>
<p>Quelle: <code>{{ firmware_root }}</code></p>
<form id="scan-form" data-action="/firmware/scan" data-target="#scan-result">
<button type="submit">Library scannen</button>
<span id="scan-result"></span>
</form>
{% if not firmware %}
<p class="empty">Noch keine Einträge. Lege Firmware-Dateien unter
<code>{{ firmware_root }}</code> ab und klicke "Library scannen".</p>
{% else %}
<table class="grid">
<thead>
<tr>
<th>Vendor</th><th>Modell</th><th>SoC</th><th>Region</th>
<th>Format</th><th>Größe</th><th>Pfad</th>
</tr>
</thead>
<tbody>
{% for fw in firmware %}
<tr>
<td>{{ fw.vendor or "—" }}</td>
<td><a href="/firmware/{{ fw.id }}">{{ fw.model or "—" }}</a></td>
<td>{{ fw.soc or "—" }}</td>
<td>{{ fw.region or "—" }}</td>
<td>{{ fw.format }}</td>
<td>{{ fw.size|humansize }}</td>
<td><code>{{ fw.rel_path }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}{{ fw.model or fw.rel_path }} · aubox{% endblock %}
{% block content %}
<h2>{{ fw.model or "Unbekannt" }} <small>{{ fw.format }}</small></h2>
<table class="kv">
<tr><th>Pfad</th><td><code>{{ fw.rel_path }}</code></td></tr>
<tr><th>Vendor</th><td>{{ fw.vendor or "—" }}</td></tr>
<tr><th>Modell</th><td>{{ fw.model or "—" }}</td></tr>
<tr><th>SoC</th><td>{{ fw.soc or "—" }}</td></tr>
<tr><th>Region</th><td>{{ fw.region or "—" }}</td></tr>
<tr><th>Version</th><td>{{ fw.version or "—" }}</td></tr>
<tr><th>Größe</th><td>{{ fw.size|humansize }}</td></tr>
<tr><th>SHA-256</th><td><code>{{ fw.sha256 or "noch nicht berechnet" }}</code></td></tr>
</table>
{% if fw.extra_json %}
<h3>Format-spezifische Daten</h3>
<pre>{{ fw.extra_json }}</pre>
{% endif %}
<p><a href="/firmware">← zurück</a></p>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}aubox · Übersicht{% endblock %}
{% block content %}
<section class="cards">
<article class="card">
<h2>Geräte</h2>
<p class="big">{{ devices|length }}</p>
<p>{{ "angeschlossen" if devices else "keins erkannt" }}</p>
<p><a href="/devices">öffnen →</a></p>
</article>
<article class="card">
<h2>Firmware-Library</h2>
<p class="big">{{ fw_count }}</p>
<p>Einträge in der Datenbank</p>
<p><a href="/firmware">öffnen →</a></p>
</article>
<article class="card">
<h2>Dateien</h2>
<p>Sandbox-Browser für die Firmware-Library</p>
<p><a href="/browse">öffnen →</a></p>
</article>
<article class="card">
<h2>Huawei P10 Lite</h2>
<p>FRP-Removal · WAS-LX1 · Kirin 658</p>
<p><a href="/workflows/p10lite">öffnen →</a></p>
</article>
</section>
<section class="info">
<h3>Aktive Pfade im Container</h3>
<ul>
<li><strong>Firmware:</strong> <code>{{ firmware_root }}</code></li>
<li><strong>Loader:</strong> <code>{{ loader_root }}</code></li>
</ul>
</section>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Huawei P10 Lite · aubox{% endblock %}
{% block content %}
<h2>Huawei P10 Lite — FRP-Removal</h2>
<p class="muted">WAS-LX1 · Kirin 658 · EMUI 8.x</p>
<h3>Methode 1: eRecovery + SD-Karte (empfohlen)</h3>
<pre>{{ instructions }}</pre>
<h3>Methode 2: Testpoint + Kirin DLOAD</h3>
<p>Über CLI vorbereitet, Web-UI-Button folgt:</p>
<pre>python -m aubox p10lite frp-remove --method dload-erase</pre>
<p>Voraussetzung: Loader-Files in <code>/loaders/kirin/kirin960_lite/</code>
(siehe Loader-README im Repo).</p>
{% endblock %}