feat(factory): add local flashing toolkit and secure OTA defaults
This commit is contained in:
452
tools/esptool-factory/factory_gui/flash_utils.py
Normal file
452
tools/esptool-factory/factory_gui/flash_utils.py
Normal file
@@ -0,0 +1,452 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user