feat(factory): add local flashing toolkit and secure OTA defaults

This commit is contained in:
admin
2026-03-02 01:57:39 +08:00
parent 809fcf6548
commit 3d3c3ebac2
27 changed files with 5260 additions and 4 deletions
@@ -0,0 +1 @@
"""Factory flashing GUI modules."""
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
from factory_private_config import get_optional, get_required, load_private_config
from .models import DeviceCheckConfig, FlashPrivateConfig
_ALLOWED_FLASH_MODE = {"qio", "qout", "dio", "dout", "keep"}
_ALLOWED_FLASH_FREQ = {
"keep",
"80m",
"60m",
"48m",
"40m",
"30m",
"26m",
"24m",
"20m",
"16m",
"15m",
"12m",
}
_ALLOWED_FLASH_SIZE = {
"keep",
"detect",
"256kb",
"512kb",
"1mb",
"2mb",
"2mb-c1",
"4mb",
"4mb-c1",
"8mb",
"16mb",
"32mb",
"64mb",
"128mb",
}
_ALLOWED_FLASH_BEFORE = {"default-reset", "usb-reset", "no-reset", "no-reset-no-sync"}
_ALLOWED_FLASH_AFTER = {
"default-reset",
"hard-reset",
"soft-reset",
"watchdog-reset",
"no-reset",
"no-reset-stub",
"noreset",
"none",
"no",
}
def _coerce_str(value: object, label: str) -> str:
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str) and value.strip():
return value.strip()
raise ValueError(f"私有配置无效: {label}")
def _optional_str(value: object) -> Optional[str]:
if isinstance(value, str):
text = value.strip()
return text if text else None
return None
def _optional_bool_or_none(value: object) -> Optional[bool]:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(int(value))
if isinstance(value, str):
text = value.strip().lower()
if text in {"1", "true", "yes", "y", "on"}:
return True
if text in {"0", "false", "no", "n", "off"}:
return False
return None
def _coerce_positive_float(value: Optional[float], fallback: float) -> float:
if value is None:
return fallback
try:
parsed = float(value)
except (TypeError, ValueError):
return fallback
return max(1.0, parsed)
def _coerce_positive_int(value: Optional[int], fallback: int) -> int:
if value is None:
return fallback
try:
parsed = int(value)
except (TypeError, ValueError):
return fallback
return max(1, parsed)
def _ui_text(ui_cfg: dict, key: str, fallback: str) -> str:
value = ui_cfg.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return fallback
def _coerce_offset_int(value: str, label: str) -> int:
try:
return int(value, 0)
except ValueError as exc:
raise ValueError(f"私有配置无效: {label}") from exc
def _validate_allowed(
value: str,
label: str,
allowed: set[str],
*,
normalize_dash: bool = False,
) -> str:
normalized = value.strip().lower()
if normalize_dash:
normalized = normalized.replace("_", "-")
if normalized not in allowed:
allowed_text = ", ".join(sorted(allowed))
raise ValueError(f"私有配置无效: {label}={value}(允许: {allowed_text})")
return normalized
def _validate_app_layout_binding(
layout: list[tuple[str, str]],
app_bin_name: str,
app_partition_offset: str,
) -> None:
app_offsets = [offset for offset, name in layout if name == app_bin_name]
if not app_offsets:
raise ValueError("私有配置无效: flash.app_bin_name 必须出现在 flash.layout")
app_layout_offsets = {_coerce_offset_int(offset, "flash.layout.offset") for offset in app_offsets}
if len(app_layout_offsets) != 1:
raise ValueError("私有配置无效: flash.layout 中 app_bin_name 对应多个偏移地址")
app_offset = _coerce_offset_int(app_partition_offset, "flash.app_partition_offset")
if app_offset not in app_layout_offsets:
layout_offset = app_offsets[0]
raise ValueError(
"私有配置无效: flash.app_partition_offset 与 flash.layout 中 app_bin_name 的 offset 不一致"
f"(app_partition_offset={app_partition_offset}, layout_offset={layout_offset})"
)
def _load_flash_private_config(
base_dir: Path, raw: Optional[dict] = None
) -> FlashPrivateConfig:
if raw is None:
raw = load_private_config(base_dir)
flash_cfg = get_required(raw, ("flash",), (dict,))
chip = _coerce_str(get_required(flash_cfg, ("chip",), (str, int)), "flash.chip")
baud = _coerce_str(get_required(flash_cfg, ("baud",), (str, int)), "flash.baud")
bin_dir_raw = _coerce_str(get_required(flash_cfg, ("bin_dir",), (str,)), "flash.bin_dir")
bin_dir = Path(bin_dir_raw)
bin_enc_raw = get_optional(flash_cfg, ("bin_encryption",), None)
if bin_enc_raw is not None:
raise ValueError("私有配置无效: flash.bin_encryption 已移除。")
layout_raw = get_required(flash_cfg, ("layout",), (list,))
layout: list[tuple[str, str]] = []
for entry in layout_raw:
offset: Optional[str] = None
name: Optional[str] = None
if isinstance(entry, (list, tuple)) and len(entry) == 2:
offset = _coerce_str(entry[0], "flash.layout.offset")
name = _coerce_str(entry[1], "flash.layout.name")
elif isinstance(entry, dict):
offset = _coerce_str(entry.get("offset"), "flash.layout.offset")
name = _coerce_str(entry.get("name"), "flash.layout.name")
else:
raise ValueError("私有配置无效: flash.layout")
if name.endswith(".enc"):
raise ValueError("私有配置无效: flash.layout 不应包含 .enc 后缀。")
layout.append((offset, name))
if not layout:
raise ValueError("私有配置无效: flash.layout 不能为空")
app_bin_name = _coerce_str(
get_required(flash_cfg, ("app_bin_name",), (str,)), "flash.app_bin_name"
)
if app_bin_name.endswith(".enc"):
raise ValueError("私有配置无效: flash.app_bin_name 不应包含 .enc 后缀。")
app_partition_offset = _coerce_str(
get_required(flash_cfg, ("app_partition_offset",), (str, int)),
"flash.app_partition_offset",
)
_validate_app_layout_binding(layout, app_bin_name, app_partition_offset)
flash_args_raw = get_required(flash_cfg, ("flash_args",), (dict,))
use_stub_value = _optional_bool_or_none(get_optional(flash_args_raw, ("use_stub",), None))
flash_args = {
"flash_mode": _validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("flash_mode",), (str, int)),
"flash.flash_args.flash_mode",
),
"flash.flash_args.flash_mode",
_ALLOWED_FLASH_MODE,
),
"flash_freq": _validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("flash_freq",), (str, int)),
"flash.flash_args.flash_freq",
),
"flash.flash_args.flash_freq",
_ALLOWED_FLASH_FREQ,
),
"flash_size": _validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("flash_size",), (str, int)),
"flash.flash_args.flash_size",
),
"flash.flash_args.flash_size",
_ALLOWED_FLASH_SIZE,
),
"before": _validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("before",), (str, int)),
"flash.flash_args.before",
),
"flash.flash_args.before",
_ALLOWED_FLASH_BEFORE,
normalize_dash=True,
),
"after": _validate_allowed(
_coerce_str(
get_required(flash_args_raw, ("after",), (str, int)),
"flash.flash_args.after",
),
"flash.flash_args.after",
_ALLOWED_FLASH_AFTER,
normalize_dash=True,
),
"use_stub": True if use_stub_value is None else use_stub_value,
}
check_cfg = get_required(raw, ("device_check",), (dict,))
crypt_cnt_fields_raw = get_required(check_cfg, ("crypt_cnt_fields",), (list,))
crypt_cnt_fields = tuple(_coerce_str(item, "device_check.crypt_cnt_fields") for item in crypt_cnt_fields_raw)
crypt_cnt_expect = _optional_str(get_optional(check_cfg, ("crypt_cnt_expect",), None))
if crypt_cnt_expect:
expect_norm = crypt_cnt_expect.strip().lower()
if expect_norm not in {"odd", "even", "zero", "any"}:
raise ValueError("私有配置无效: device_check.crypt_cnt_expect")
crypt_cnt_expect = expect_norm
secure_boot_expected = _optional_bool_or_none(
get_optional(check_cfg, ("secure_boot_expected",), None)
)
secure_boot_field = _coerce_str(
get_required(check_cfg, ("secure_boot_field",), (str,)), "device_check.secure_boot_field"
)
flash_key_purpose_match = _optional_str(
get_optional(check_cfg, ("flash_key_purpose_match",), None)
)
secure_boot_key_purpose_match = _optional_str(
get_optional(check_cfg, ("secure_boot_key_purpose_match",), None)
)
key_read_disable_field = _coerce_str(
get_required(check_cfg, ("key_read_disable_field",), (str,)),
"device_check.key_read_disable_field",
)
ui_cfg = get_optional(raw, ("ui",), {})
if not isinstance(ui_cfg, dict):
ui_cfg = {}
return FlashPrivateConfig(
chip=chip,
baud=baud,
bin_dir=bin_dir,
layout=layout,
app_bin_name=app_bin_name,
app_partition_offset=app_partition_offset,
flash_args=flash_args,
device_check=DeviceCheckConfig(
crypt_cnt_fields=crypt_cnt_fields,
secure_boot_field=secure_boot_field,
secure_boot_expected=secure_boot_expected,
crypt_cnt_expect=crypt_cnt_expect,
flash_key_purpose_match=flash_key_purpose_match,
secure_boot_key_purpose_match=secure_boot_key_purpose_match,
key_read_disable_field=key_read_disable_field,
),
ui=ui_cfg,
)
@@ -0,0 +1,8 @@
from __future__ import annotations
import os
DEFAULT_BAUD = int(os.getenv("FACTORY_UART_BAUD", "115200"))
DEFAULT_READY_TIMEOUT_S = float(os.getenv("FACTORY_READY_TIMEOUT_S", "180"))
DEFAULT_FLASH_MAX_ATTEMPTS = int(os.getenv("FACTORY_FLASH_MAX_ATTEMPTS", "2"))
DEFAULT_FLASH_SECURE_MAX_ATTEMPTS = int(os.getenv("FACTORY_FLASH_SECURE_MAX_ATTEMPTS", "3"))
@@ -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()
@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
@dataclass(frozen=True)
class DeviceCheckConfig:
crypt_cnt_fields: Tuple[str, ...]
secure_boot_field: str
secure_boot_expected: Optional[bool]
crypt_cnt_expect: Optional[str]
flash_key_purpose_match: Optional[str]
secure_boot_key_purpose_match: Optional[str]
key_read_disable_field: str
@dataclass(frozen=True)
class FlashPrivateConfig:
chip: str
baud: str
bin_dir: Path
layout: list[Tuple[str, str]]
app_bin_name: str
app_partition_offset: str
flash_args: dict
device_check: DeviceCheckConfig
ui: dict
@dataclass(frozen=True)
class FlashSource:
offset: int
path: Path
name: str
@dataclass
class FlashEntryReport:
name: str
offset: int
size: int
sha256: str
readback_sha256: Optional[str]
verified: Optional[bool]
@dataclass
class FlashReport:
entries: list[FlashEntryReport]
readback_ok: bool
readback_errors: list[str]
@@ -0,0 +1,74 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Optional
def is_frozen() -> bool:
return getattr(sys, "frozen", False)
def _find_app_bundle_root(exe_path: Path) -> Optional[Path]:
for parent in exe_path.parents:
if parent.suffix.lower() == ".app":
return parent
return None
def _pick_runtime_root(exe_path: Path) -> Path:
candidates: list[Path] = []
app_root = _find_app_bundle_root(exe_path)
if app_root is not None:
candidates.append(app_root.parent)
candidates.append(exe_path.parent)
for candidate in candidates:
if (candidate / "conf" / "factory_private.json.enc").exists():
return candidate
if (candidate / "conf").exists() or (candidate / "bin").exists() or (candidate / "keys").exists():
return candidate
return exe_path.parent
def venv_python(base_dir: Path) -> Path:
scripts = "Scripts" if os.name == "nt" else "bin"
exe = "python.exe" if os.name == "nt" else "python3"
return base_dir / ".venv" / scripts / exe
def resolve_python(base_dir: Path) -> Path:
venv_py = venv_python(base_dir)
if venv_py.exists():
return venv_py
raise FileNotFoundError(
"未找到当前目录的虚拟环境:\n"
f"{venv_py}\n"
"请先创建并安装依赖:\n"
f" python3 -m venv {base_dir / '.venv'}\n"
f" {venv_py} -m pip install -r {base_dir / 'requirements.txt'}"
)
def get_base_dir() -> Path:
if is_frozen():
return _pick_runtime_root(Path(sys.executable).resolve())
return Path(__file__).resolve().parent.parent
def resolve_bin_dir(base_dir: Path, configured: Path) -> Path:
if configured.is_absolute():
return configured
return base_dir / configured
def select_bin_dir(base_dir: Path, configured: Path) -> Path:
return resolve_bin_dir(base_dir, configured)
def _display_path(base_dir: Path, path: Path) -> str:
try:
rel = path.relative_to(base_dir)
return str(rel)
except ValueError:
return path.name
@@ -0,0 +1,95 @@
from __future__ import annotations
import os
import re
SENSITIVE_KEYS = (
"pub_key",
"signature",
"key_b64",
"flash_key_b64",
"sig_b64",
"pub_key_jkt",
"new_pub_key",
"new_pub_key_jkt",
"device_mac",
"operator",
"host",
"port",
"firmware_version",
)
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
_MAC_RE = re.compile(r"(?i)([0-9a-f]{2}:){5}[0-9a-f]{2}")
_UUID_RE = re.compile(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)
_URL_RE = re.compile(r"https?://[^\s]+")
_DEVICE_ID_JSON_RE = re.compile(r'("device_id"\s*:\s*")([^"]+)(")', re.IGNORECASE)
_DEVICE_ID_KV_RE = re.compile(r"(\bdevice_id\b\s*[:=]\s*)([^\s,;]+)", re.IGNORECASE)
_JSON_KEY_RE = re.compile(
r'([\'"](?:(?:' + "|".join(SENSITIVE_KEYS) + r'))[\'"]\s*:\s*[\'"])([^\'"]+)([\'"])',
re.IGNORECASE,
)
_KV_RE = re.compile(r"(?i)\b(" + "|".join(SENSITIVE_KEYS) + r")=([^\s,;]+)")
_HEX_BYTES_RE = re.compile(r"\b(?:[0-9a-fA-F]{2}\s+){7,}[0-9a-fA-F]{2}\b")
_BIN_PATH_RE = re.compile(r"(/[^\\s]+?\\.(?:bin(?:\\.enc)?|enc))")
_BIN_NAME_RE = re.compile(r"\b[\w.-]+\\.(?:bin(?:\\.enc)?|enc)\b")
def _sanitize_filename(value: str) -> str:
if not value:
return "unknown"
return re.sub(r"[^A-Za-z0-9._-]+", "_", value)
def _preserve_device_ids(text: str) -> tuple[str, dict[str, str]]:
tokens: dict[str, str] = {}
def _tokenize(value: str) -> str:
token = f"__DEVICE_ID_{len(tokens)}__"
tokens[token] = value
return token
def _replace_json(match: re.Match[str]) -> str:
return f"{match.group(1)}{_tokenize(match.group(2))}{match.group(3)}"
def _replace_kv(match: re.Match[str]) -> str:
return f"{match.group(1)}{_tokenize(match.group(2))}"
text = _DEVICE_ID_JSON_RE.sub(_replace_json, text)
text = _DEVICE_ID_KV_RE.sub(_replace_kv, text)
return text, tokens
def _redact_sensitive(text: str) -> str:
if not text:
return text
text, device_tokens = _preserve_device_ids(text)
text = _ANSI_RE.sub("", text)
text = _MAC_RE.sub("<mac>", text)
text = _UUID_RE.sub("<id>", text)
text = _URL_RE.sub("<url>", text)
text = _JSON_KEY_RE.sub(
lambda m: f"{m.group(1)}<redacted:{len(m.group(2))}>{m.group(3)}", text
)
text = _KV_RE.sub(
lambda m: f"{m.group(1)}=<redacted:{len(m.group(2))}>", text
)
text = _HEX_BYTES_RE.sub("<redacted:hex>", text)
text = _BIN_PATH_RE.sub(lambda m: os.path.basename(m.group(1)), text)
text = _BIN_NAME_RE.sub("<bin>", text)
lowered = text.lower()
if "security features enabled" in lowered:
return "检测到限制,保持默认配置。"
if "compress and encrypt options are mutually exclusive" in lowered:
return "写入选项冲突,已使用默认方式。"
if "will flash input bytes uncompressed" in lowered:
return "将按默认方式写入。"
if "cannot verify written data" in lowered:
return "写入完成,校验已跳过。"
if "secure boot detected" in lowered or "secure download mode is enabled" in lowered:
return "检测到写入限制,请按工艺选择模式。"
for token, value in device_tokens.items():
text = text.replace(token, value)
return text
@@ -0,0 +1,54 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
from tkinter import messagebox
from .path_utils import get_base_dir
from .tooling import run_command
def ensure_pyserial(python_cmd: Optional[Path], show_dialog: bool) -> bool:
try:
import serial # noqa: F401
return True
except Exception:
if not show_dialog:
return False
if python_cmd is None:
messagebox.showerror("缺少依赖", "内置环境缺少 pyserial,请重新打包。")
return False
if not messagebox.askyesno("缺少依赖", "未检测到 pyserial,是否自动安装?"):
return False
cmd = [str(python_cmd), "-m", "pip", "install", "pyserial"]
code, output = run_command(cmd, cwd=get_base_dir(), stream=False)
if code != 0:
messagebox.showerror(
"安装失败",
"pyserial 安装失败,请手动执行:\n"
f"{python_cmd} -m pip install pyserial\n\n{output}",
)
return False
messagebox.showinfo("安装完成", "pyserial 已安装,请重试检测。")
return True
def list_serial_ports(python_cmd: Optional[Path], show_dialog: bool) -> list[str]:
if not ensure_pyserial(python_cmd, show_dialog):
return []
from serial.tools import list_ports
return [p.device for p in list_ports.comports()]
def pick_preferred_port(ports: list[str]) -> str:
if not ports:
return ""
hints = ("wchusbserial", "usbserial", "ttyusb", "ttyacm", "com")
for hint in hints:
for port in ports:
if hint in port.lower():
return port
return ports[0]
@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso_time(value: str) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _coerce_epoch_seconds(value: object) -> Optional[int]:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
stripped = value.strip()
if stripped.isdigit():
return int(stripped)
return None
@@ -0,0 +1,166 @@
from __future__ import annotations
import contextlib
import io
import os
import runpy
import subprocess
import sys
from pathlib import Path
from typing import Callable, Iterable, Optional, Tuple
@contextlib.contextmanager
def pushd(path: Path):
current = Path.cwd()
os.chdir(path)
try:
yield
finally:
os.chdir(current)
class StreamCapture:
def __init__(self, on_output: Optional[Callable[[str], None]], collect: io.StringIO) -> None:
self.on_output = on_output
self.collect = collect
self.buffer = ""
def write(self, text: str) -> int:
if text is None:
return 0
text = str(text)
self.collect.write(text)
if self.on_output:
text = text.replace("\r", "\n")
self.buffer += text
while "\n" in self.buffer:
line, self.buffer = self.buffer.split("\n", 1)
if line:
self.on_output(line)
return len(text)
def flush(self) -> None:
if self.on_output and self.buffer:
self.on_output(self.buffer)
self.buffer = ""
@property
def encoding(self) -> str:
return "utf-8"
@property
def errors(self) -> str:
return "replace"
def isatty(self) -> bool:
return False
def writable(self) -> bool:
return True
def fileno(self) -> int:
raise OSError("StreamCapture has no file descriptor")
def run_tool_module(
tool: str,
args: Iterable[str],
cwd: Path,
stream: bool,
on_output: Optional[Callable[[str], None]],
) -> Tuple[int, str]:
argv_backup = sys.argv[:]
prev_rich_disable = os.environ.get("RICH_DISABLE")
prev_rich_click_disable = os.environ.get("RICH_CLICK_DISABLE")
output = io.StringIO()
capture = StreamCapture(on_output if stream else None, output)
try:
sys.argv = [tool, *list(args)]
os.environ["RICH_DISABLE"] = "1"
os.environ["RICH_CLICK_DISABLE"] = "1"
with pushd(cwd), contextlib.redirect_stdout(capture), contextlib.redirect_stderr(capture):
try:
module_name = f"{tool}.__main__"
try:
runpy.run_module(module_name, run_name="__main__")
except ModuleNotFoundError as exc:
if exc.name in (module_name, tool):
runpy.run_module(tool, run_name="__main__")
else:
raise
code = 0
except SystemExit as exc:
code = int(exc.code) if isinstance(exc.code, int) else 0
except Exception:
import traceback
traceback.print_exc()
code = 1
finally:
capture.flush()
sys.argv = argv_backup
if prev_rich_disable is None:
os.environ.pop("RICH_DISABLE", None)
else:
os.environ["RICH_DISABLE"] = prev_rich_disable
if prev_rich_click_disable is None:
os.environ.pop("RICH_CLICK_DISABLE", None)
else:
os.environ["RICH_CLICK_DISABLE"] = prev_rich_click_disable
return code, output.getvalue()
def run_esptool_api(
func: Callable[[], None],
stream: bool,
on_output: Optional[Callable[[str], None]],
) -> Tuple[int, str]:
output = io.StringIO()
capture = StreamCapture(on_output if stream else None, output)
try:
with contextlib.redirect_stdout(capture), contextlib.redirect_stderr(capture):
func()
code = 0
except SystemExit as exc:
code = int(exc.code) if isinstance(exc.code, int) else 1
except Exception:
import traceback
traceback.print_exc()
code = 1
finally:
capture.flush()
return code, output.getvalue()
def run_command(
cmd: Iterable[str],
cwd: Path,
stream: bool = False,
on_output: Optional[Callable[[str], None]] = None,
) -> Tuple[int, str]:
if stream:
proc = subprocess.Popen(
list(cmd),
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
output_parts = []
assert proc.stdout is not None
for line in proc.stdout:
output_parts.append(line)
if on_output:
on_output(line.rstrip("\n"))
proc.wait()
return proc.returncode, "".join(output_parts)
result = subprocess.run(list(cmd), cwd=cwd, capture_output=True, text=True)
combined = (result.stdout or "") + (result.stderr or "")
if on_output and combined:
for line in combined.splitlines():
on_output(line)
return result.returncode, combined