feat(factory): add local flashing toolkit and secure OTA defaults
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Factory flashing GUI modules."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user