Files
AI_Printer/tools/esptool-factory/factory_gui/flash_utils.py

453 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Iterable, Optional, Tuple
from .models import DeviceCheckConfig
_MAC_RE = re.compile(r"(?i)([0-9a-f]{2}:){5}[0-9a-f]{2}")
_MAC_OUTPUT_RE = re.compile(r"(?i)\bMAC:\s*([0-9a-f]{2}(?::[0-9a-f]{2}){5})")
_ESP_IMAGE_MAGIC = 0xE9
def _sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _wipe_bytearray(buf: Optional[bytearray]) -> None:
if not buf:
return
for idx in range(len(buf)):
buf[idx] = 0
def collect_error_hints(output: str) -> list[str]:
lowered = output.lower()
hints: list[str] = []
if "secure boot detected" in lowered or "writing to flash regions < 0x8000 is disabled" in lowered:
hints.append("检测到写入限制:请按工艺选择模式。")
hints.append("请按工艺选择模式后重试。")
if "secure download mode is enabled" in lowered:
hints.append("检测到下载限制:请按工艺选择模式。")
if "could not open port" in lowered or "permissionerror" in lowered or "access is denied" in lowered:
hints.append("串口被占用或权限不足,关闭串口工具后重试。")
if "no serial data received" in lowered or "failed to connect to esp32" in lowered:
hints.append("确认串口号正确,未被其他软件占用。")
hints.append("按住 BOOT点一下 RESET 进入下载模式。")
if "download mode successfully detected" in lowered and "no sync reply" in lowered:
hints.append("下载模式已进入但无法同步,通常是 TX/RX 线序或供电问题。")
if "serial tx path seems to be down" in lowered:
hints.append("检查 USB 转串口的 TX/RX 线序、焊接和接触。")
if "invalid head of packet" in lowered or "possible serial noise" in lowered:
hints.append("串口噪声/干扰,尝试更换 USB 线/接口或降低波特率(如 115200")
if "stopiteration" in lowered:
hints.append("串口通信异常中断,重新插拔设备后重试。")
if not hints:
hints.append("请检查串口号、线缆/供电,并按住 BOOT 点一下 RESET 进入下载模式。")
deduped: list[str] = []
seen = set()
for hint in hints:
if hint in seen:
continue
seen.add(hint)
deduped.append(hint)
return deduped
def detect_secure_boot_block(output: str) -> bool:
lowered = output.lower()
return (
"secure boot detected" in lowered
or "writing to flash regions < 0x8000 is disabled" in lowered
)
def detect_boot_chain_header_state(header_bytes: bytes) -> tuple[bool, str]:
if not header_bytes:
return False, "empty"
if all(value == 0xFF for value in header_bytes):
return False, "blank_ff"
if all(value == 0x00 for value in header_bytes):
return False, "blank_00"
first = header_bytes[0]
if first != _ESP_IMAGE_MAGIC:
return False, f"invalid_magic_0x{first:02x}"
return True, "ok"
def _extract_json_blob(output: str) -> Optional[dict]:
start = output.find("{")
end = output.rfind("}")
if start < 0 or end <= start:
return None
try:
return json.loads(output[start : end + 1])
except json.JSONDecodeError:
return None
def _json_field_value(data: Optional[dict], name: str) -> Optional[object]:
if not isinstance(data, dict):
return None
field = data.get(name)
if not isinstance(field, dict):
return None
return field.get("value")
def _value_to_int(value: object) -> Optional[int]:
if isinstance(value, bool):
return int(value)
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
stripped = value.strip()
lowered = stripped.lower()
if lowered in ("true", "yes", "enable", "enabled", "on"):
return 1
if lowered in ("false", "no", "disable", "disabled", "off"):
return 0
if lowered.startswith("0x"):
try:
return int(stripped, 16)
except ValueError:
return None
if lowered.startswith("0b"):
try:
return int(stripped, 2)
except ValueError:
return None
if stripped.isdigit():
return int(stripped)
return None
def get_efuse_flag(data: Optional[dict], *field_names: str) -> Optional[bool]:
if not isinstance(data, dict):
return None
for name in field_names:
if not isinstance(name, str) or not name:
continue
value = _json_field_value(data, name)
value_int = _value_to_int(value)
if value_int is None:
continue
return bool(value_int)
return None
def _parse_offset(value: object) -> int:
parsed = _value_to_int(value)
if parsed is None:
raise ValueError(f"无效偏移: {value}")
return parsed
def recommend_mode_from_json(
data: dict, crypt_cnt_fields: Iterable[str]
) -> Tuple[Optional[str], Optional[str]]:
targets = tuple(crypt_cnt_fields)
if not targets:
return None, "CRYPT_CNT_NOT_CONFIGURED"
for name in targets:
value = _json_field_value(data, name)
if value is None:
continue
value_int = _value_to_int(value)
if value_int is not None:
ones = bin(value_int).count("1")
if ones == 0:
return "1", f"{name}_ZERO"
if ones % 2 == 1:
return "2", f"{name}_ODD"
return None, f"{name}_UNEXPECTED"
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("enable", "enabled"):
return "2", f"{name}_ENABLE"
if lowered in ("disable", "disabled"):
return "1", f"{name}_DISABLE"
return None, "CRYPT_CNT_NOT_FOUND"
def _crypt_cnt_parity(data: dict, crypt_cnt_fields: Iterable[str]) -> Optional[int]:
for name in crypt_cnt_fields:
value = _json_field_value(data, name)
value_int = _value_to_int(value)
if value_int is None:
continue
ones = bin(value_int).count("1")
return ones % 2
return None
def _purpose_matches(value: object, match_text: Optional[str]) -> Optional[bool]:
if match_text is None:
return None
if isinstance(value, str):
return match_text.upper() in value.upper()
if isinstance(value, (int, float)):
if int(value) == 0:
return False
return None
def _match_secure_boot_purpose(value: object) -> bool:
if not isinstance(value, str):
return False
return value.upper().startswith("SECURE_BOOT_DIGEST")
def _key_index_from_purpose_name(name: Optional[str]) -> Optional[int]:
if not name:
return None
if not name.startswith("KEY_PURPOSE_"):
return None
suffix = name[len("KEY_PURPOSE_") :]
if not suffix.isdigit():
return None
return int(suffix)
def _rd_dis_for_key(rd_dis_value: Optional[int], key_name: Optional[str]) -> Optional[bool]:
if rd_dis_value is None:
return None
key_index = _key_index_from_purpose_name(key_name)
if key_index is None:
return None
return bool(rd_dis_value & (1 << key_index))
def _iter_key_purpose_fields(data: dict) -> list[tuple[str, object]]:
rows: list[tuple[str, object]] = []
if not isinstance(data, dict):
return rows
for key_name, raw_field in data.items():
if not isinstance(key_name, str):
continue
if not key_name.startswith("KEY_PURPOSE_"):
continue
value = None
if isinstance(raw_field, dict):
value = raw_field.get("value")
rows.append((key_name, value))
def _sort_key(row: tuple[str, object]) -> tuple[int, str]:
key_name = row[0]
idx = _key_index_from_purpose_name(key_name)
if idx is None:
return (1_000_000, key_name)
return (idx, key_name)
rows.sort(key=_sort_key)
return rows
def _purpose_value_matches(
value: object,
expected_match: Optional[str],
allow_secure_boot_prefix: bool = False,
) -> bool:
matched = _purpose_matches(value, expected_match)
if matched is True:
return True
if not allow_secure_boot_prefix or not expected_match:
return False
if "SECURE_BOOT_DIGEST" not in expected_match.upper():
return False
return _match_secure_boot_purpose(value)
def _collect_expected_purpose_matches(cfg: DeviceCheckConfig) -> tuple[list[str], list[str]]:
flash_expected: list[str] = []
secure_expected: list[str] = []
flash_match = cfg.flash_key_purpose_match
if flash_match:
flash_match = flash_match.strip()
if flash_match:
flash_expected.append(flash_match)
secure_boot_match = cfg.secure_boot_key_purpose_match
if secure_boot_match:
secure_boot_match = secure_boot_match.strip()
if secure_boot_match:
secure_expected.append(secure_boot_match)
return flash_expected, secure_expected
def _match_expected_keys(
key_values: list[tuple[str, object]],
expected_matches: list[str],
allow_secure_boot_prefix: bool = False,
) -> tuple[list[dict], list[str]]:
matches: list[dict] = []
missing: list[str] = []
used_keys: set[str] = set()
for expected in expected_matches:
found: Optional[dict] = None
for key_name, value in key_values:
if key_name in used_keys:
continue
if _purpose_value_matches(
value,
expected,
allow_secure_boot_prefix=allow_secure_boot_prefix,
):
found = {"key": key_name, "value": value}
break
if found is None:
missing.append(expected)
continue
used_keys.add(found["key"])
matches.append(found)
return matches, missing
def evaluate_post_flash_status(
data: dict, cfg: DeviceCheckConfig
) -> Tuple[bool, dict, list[str]]:
details: dict = {}
errors: list[str] = []
crypt_cnt_mode, crypt_cnt_reason = recommend_mode_from_json(data, cfg.crypt_cnt_fields)
details["crypt_cnt_mode"] = crypt_cnt_mode
details["crypt_cnt_reason"] = crypt_cnt_reason
parity = _crypt_cnt_parity(data, cfg.crypt_cnt_fields)
if parity is not None:
details["crypt_cnt_parity"] = "odd" if parity == 1 else "even"
if cfg.crypt_cnt_expect and cfg.crypt_cnt_expect != "any":
expect = cfg.crypt_cnt_expect
if expect == "odd" and crypt_cnt_mode != "2":
errors.append("crypt_cnt_not_odd")
elif expect == "zero" and crypt_cnt_mode != "1":
errors.append("crypt_cnt_not_zero")
elif expect == "even" and crypt_cnt_mode == "2":
errors.append("crypt_cnt_unexpected_odd")
secure_boot_value = _value_to_int(_json_field_value(data, cfg.secure_boot_field))
secure_boot_enabled: Optional[bool] = None
if secure_boot_value is not None:
secure_boot_enabled = bool(secure_boot_value)
details["secure_boot_enabled"] = secure_boot_enabled
if cfg.secure_boot_expected is not None and secure_boot_enabled is not None:
if secure_boot_enabled != cfg.secure_boot_expected:
errors.append("secure_boot_unexpected")
elif cfg.secure_boot_expected is not None and secure_boot_enabled is None:
errors.append("secure_boot_unknown")
rd_dis_value = _value_to_int(_json_field_value(data, cfg.key_read_disable_field))
details["rd_dis_value"] = rd_dis_value
key_details: dict = {}
key_values = _iter_key_purpose_fields(data)
flash_expected, secure_expected = _collect_expected_purpose_matches(cfg)
flash_matches, flash_missing = _match_expected_keys(
key_values, flash_expected, allow_secure_boot_prefix=False
)
secure_matches, secure_missing = _match_expected_keys(
key_values, secure_expected, allow_secure_boot_prefix=True
)
flash_rd_details: list[dict] = []
secure_rd_details: list[dict] = []
flash_rd_missing = False
flash_rd_unknown = False
secure_rd_blocked = False
secure_rd_unknown = False
for matched in flash_matches:
key_name = matched["key"]
rd_ok = _rd_dis_for_key(rd_dis_value, key_name)
flash_rd_details.append({"key": key_name, "rd_protected": rd_ok})
if rd_ok is False:
flash_rd_missing = True
elif rd_ok is None:
flash_rd_unknown = True
for matched in secure_matches:
key_name = matched["key"]
rd_ok = _rd_dis_for_key(rd_dis_value, key_name)
secure_rd_details.append({"key": key_name, "rd_protected": rd_ok})
if rd_ok is True:
secure_rd_blocked = True
elif rd_ok is None:
secure_rd_unknown = True
if flash_expected and flash_missing:
errors.append("flash_key_missing")
if secure_expected and secure_missing:
errors.append("secure_boot_key_missing")
if flash_matches and flash_rd_missing:
errors.append("flash_key_rd_dis_missing")
elif flash_matches and flash_rd_unknown:
errors.append("flash_key_rd_dis_unknown")
if secure_matches and secure_rd_blocked:
errors.append("secure_boot_key_rd_dis_set")
elif secure_matches and secure_rd_unknown:
errors.append("secure_boot_key_rd_dis_unknown")
key_details["flash_enc_keys"] = flash_matches
key_details["secure_boot_keys"] = secure_matches
key_details["flash_enc_rd_protected"] = flash_rd_details
key_details["secure_boot_rd_protected"] = secure_rd_details
details["keys"] = key_details
return len(errors) == 0, details, errors
def build_flash_entries(
bin_dir: Path,
layout: Iterable[Tuple[str, str]],
app_only: bool,
app_partition_offset: str,
app_bin_name: str,
) -> list[Tuple[object, Path]]:
if app_only:
return [(app_partition_offset, bin_dir / app_bin_name)]
return [(offset, bin_dir / name) for offset, name in layout]
def recommend_mode(
summary_text: str, crypt_cnt_fields: Iterable[str]
) -> Tuple[Optional[str], Optional[str]]:
targets = tuple(crypt_cnt_fields)
if not targets:
return None, "CRYPT_CNT_NOT_CONFIGURED"
for line in summary_text.splitlines():
if "CRYPT_CNT_DISABLE" in line:
continue
if not any(target in line for target in targets):
continue
match = re.search(r"\(0b([01]+)\)", line)
if match:
ones = match.group(1).count("1")
else:
match = re.search(r"=\s*([0-9]+)", line)
if not match:
return None, "CRYPT_CNT_PARSE_FAIL"
value = int(match.group(1), 10)
ones = bin(value).count("1")
if ones == 0:
return "1", "CRYPT_CNT_ZERO"
if ones % 2 == 1:
return "2", "CRYPT_CNT_ODD"
return None, "CRYPT_CNT_UNEXPECTED"
return None, "CRYPT_CNT_NOT_FOUND"
def extract_mac_from_output(output: str) -> Optional[str]:
match = _MAC_OUTPUT_RE.search(output) or _MAC_RE.search(output)
if not match:
return None
mac = match.group(1) if match.re is _MAC_OUTPUT_RE else match.group(0)
return mac.lower()