455 lines
21 KiB
Python
455 lines
21 KiB
Python
"""
|
|||
|
|
Tkinter setup wizard for the V2X2MAP ITS-G5 Receiver bridge.
|
||
|
|
|
||
|
|
Steps:
|
||
|
|
1. Device — pick the serial port
|
||
|
|
2. Flash — write the bundled firmware
|
||
|
|
3. Done — the device derives its node-id from its own MAC automatically
|
||
|
|
|
||
|
|
Returns dict {port} to the caller.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import io
|
||
|
|
import os
|
||
|
|
import queue
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
import threading
|
||
|
|
import tkinter as tk
|
||
|
|
from tkinter import messagebox, scrolledtext, ttk
|
||
|
|
|
||
|
|
import serial
|
||
|
|
import serial.tools.list_ports
|
||
|
|
|
||
|
|
FIRMWARE_FILES: list[tuple[str, str]] = [
|
||
|
|
("0x2000", "bootloader.bin"),
|
||
|
|
("0x8000", "partition-table.bin"),
|
||
|
|
("0x1e000", "ota_data_initial.bin"),
|
||
|
|
("0x20000", "firmware.bin"),
|
||
|
|
]
|
||
|
|
FLASH_BAUD = "921600"
|
||
|
|
CHIP = "esp32c5"
|
||
|
|
|
||
|
|
# ── platform styling ────────────────────────────────────────────────────────
|
||
|
|
IS_WINDOWS = sys.platform.startswith("win")
|
||
|
|
IS_MAC = sys.platform == "darwin"
|
||
|
|
PORT_WORD = "COM port" if IS_WINDOWS else "Serial port"
|
||
|
|
UI_FONT = "Segoe UI" if IS_WINDOWS else ("Helvetica Neue" if IS_MAC else "DejaVu Sans")
|
||
|
|
MONO_FONT = "Consolas" if IS_WINDOWS else ("Menlo" if IS_MAC else "DejaVu Sans Mono")
|
||
|
|
|
||
|
|
# USB vendor IDs that indicate an ESP32 devboard (native USB-JTAG or common UART bridge).
|
||
|
|
_ESP_VIDS = {0x303A} # Espressif USB-JTAG/serial
|
||
|
|
_UART_VIDS = {0x10C4, 0x1A86, 0x0403} # CP210x, CH34x, FTDI
|
||
|
|
# Ports that are never a devboard (macOS internals).
|
||
|
|
_PORT_BLACKLIST = ("bluetooth-incoming", "debug-console", "wlan-debug")
|
||
|
|
|
||
|
|
def _resource_path(*parts: str) -> str:
|
||
|
|
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
return os.path.join(base, *parts)
|
||
|
|
|
||
|
|
|
||
|
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07")
|
||
|
|
|
||
|
|
|
||
|
|
class _UiWriter(io.TextIOBase):
|
||
|
|
"""File-like sink that forwards text to a queue drained by the Tk MAIN thread.
|
||
|
|
|
||
|
|
Two hard-won rules live here (E2E test on macOS/Tk 9 found both):
|
||
|
|
- NEVER touch tkinter from a worker thread — even `root.after` silently drops
|
||
|
|
events on some builds. Workers only enqueue; `SetupWizard._pump` renders.
|
||
|
|
- esptool ≥5 probes its stdout like a real file (isatty & friends) and emits
|
||
|
|
ANSI cursor codes; TextIOBase supplies the full API and we strip the codes.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, q: "queue.Queue", widget: tk.Text):
|
||
|
|
super().__init__()
|
||
|
|
self._q = q; self._widget = widget
|
||
|
|
|
||
|
|
def writable(self) -> bool:
|
||
|
|
return True
|
||
|
|
|
||
|
|
def isatty(self) -> bool:
|
||
|
|
return False
|
||
|
|
|
||
|
|
@property
|
||
|
|
def encoding(self) -> str: # some libs read .encoding directly
|
||
|
|
return "utf-8"
|
||
|
|
|
||
|
|
def write(self, s: str) -> int:
|
||
|
|
clean = _ANSI_RE.sub("", s)
|
||
|
|
if clean:
|
||
|
|
self._q.put(("log", self._widget, clean))
|
||
|
|
return len(s)
|
||
|
|
|
||
|
|
def flush(self) -> None:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
class _MarkerArt:
|
||
|
|
"""Draws the app's map-marker artwork (top-down car, bus, 3-light signal) onto a
|
||
|
|
tk.Canvas — the same visual language as the V2X2MAP app, as pure vector calls so
|
||
|
|
the bundle needs no image assets and stays crisp at any scale."""
|
||
|
|
|
||
|
|
VEHICLE_BLUE = "#2f7fd0"
|
||
|
|
BODY_DARK = "#242a30"
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _rounded(c: tk.Canvas, x0, y0, x1, y1, r, **kw):
|
||
|
|
"""Rounded rectangle as a smoothed polygon."""
|
||
|
|
pts = [x0+r,y0, x1-r,y0, x1,y0, x1,y0+r, x1,y1-r, x1,y1, x1-r,y1,
|
||
|
|
x0+r,y1, x0,y1, x0,y1-r, x0,y0+r, x0,y0]
|
||
|
|
return c.create_polygon(pts, smooth=True, **kw)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def car(cls, c: tk.Canvas, cx, cy, s=1.0):
|
||
|
|
w, h = 22*s, 38*s
|
||
|
|
x0, y0 = cx-w/2, cy-h/2
|
||
|
|
# mirrors
|
||
|
|
for mx in (x0-2.5*s, x0+w-0.5*s):
|
||
|
|
cls._rounded(c, mx, y0+h*0.30, mx+3*s, y0+h*0.30+6*s, 1.2*s,
|
||
|
|
fill=cls.VEHICLE_BLUE, outline="white", width=1)
|
||
|
|
cls._rounded(c, x0, y0, x0+w, y0+h, w*0.32, fill=cls.VEHICLE_BLUE,
|
||
|
|
outline="white", width=1.5*s)
|
||
|
|
cls._rounded(c, x0+3*s, y0+h*0.17, x0+w-3*s, y0+h*0.32, 2*s,
|
||
|
|
fill="#1b4d80", outline="")
|
||
|
|
cls._rounded(c, x0+3*s, y0+h*0.66, x0+w-3*s, y0+h*0.79, 2*s,
|
||
|
|
fill="#245e9b", outline="")
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def bus(cls, c: tk.Canvas, cx, cy, s=1.0):
|
||
|
|
w, h = 23*s, 48*s
|
||
|
|
x0, y0 = cx-w/2, cy-h/2
|
||
|
|
cls._rounded(c, x0, y0, x0+w, y0+h, 6*s, fill=cls.VEHICLE_BLUE,
|
||
|
|
outline="white", width=1.5*s)
|
||
|
|
cls._rounded(c, x0+3*s, y0+h*0.10, x0+w-3*s, y0+h*0.22, 1.6*s,
|
||
|
|
fill="#1b4d80", outline="")
|
||
|
|
for f in (0.34, 0.50, 0.66):
|
||
|
|
cls._rounded(c, x0+4*s, y0+h*f, x0+w-4*s, y0+h*f+2.6*s, 1.2*s,
|
||
|
|
fill="#7db4e8", outline="")
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def signal(cls, c: tk.Canvas, cx, cy, s=1.0, active="green"):
|
||
|
|
r = 17*s
|
||
|
|
c.create_oval(cx-r, cy-r, cx+r, cy+r, fill="#2b3138", outline="white", width=1.5*s)
|
||
|
|
cls._rounded(c, cx-6.5*s, cy-13*s, cx+6.5*s, cy+13*s, 5*s,
|
||
|
|
fill="#161a1e", outline="#8a949e", width=1)
|
||
|
|
lamps = {"red": "#ff5555", "amber": "#ffc837", "green": "#5bdd6e"}
|
||
|
|
for i, (name, col) in enumerate(lamps.items()):
|
||
|
|
ly = cy + (i-1)*8*s
|
||
|
|
fill = col if name == active else "#3c444d"
|
||
|
|
c.create_oval(cx-3.6*s, ly-3.6*s, cx+3.6*s, ly+3.6*s, fill=fill, outline="")
|
||
|
|
|
||
|
|
|
||
|
|
class SetupWizard:
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.result: dict | None = None
|
||
|
|
|
||
|
|
self.root = tk.Tk()
|
||
|
|
self.root.title("V2X2MAP — Setup")
|
||
|
|
self.root.geometry("640x560")
|
||
|
|
self.root.minsize(620, 500)
|
||
|
|
self._center_window()
|
||
|
|
|
||
|
|
self.port_var = tk.StringVar()
|
||
|
|
self.skip_flash = tk.BooleanVar(value=False)
|
||
|
|
self._port_label_to_device: dict[str, str] = {}
|
||
|
|
|
||
|
|
|
||
|
|
self._busy = False
|
||
|
|
# Single UI queue: worker threads enqueue log text and callables; only the
|
||
|
|
# main thread (via _pump) touches tkinter.
|
||
|
|
self._uiq: queue.Queue = queue.Queue()
|
||
|
|
self._build()
|
||
|
|
self._pump()
|
||
|
|
|
||
|
|
def _pump(self) -> None:
|
||
|
|
try:
|
||
|
|
while True:
|
||
|
|
item = self._uiq.get_nowait()
|
||
|
|
if item[0] == "log":
|
||
|
|
_, widget, chunk = item
|
||
|
|
widget.insert("end", chunk)
|
||
|
|
widget.see("end")
|
||
|
|
else:
|
||
|
|
_, fn, args = item
|
||
|
|
fn(*args)
|
||
|
|
except queue.Empty:
|
||
|
|
pass
|
||
|
|
self.root.after(80, self._pump)
|
||
|
|
|
||
|
|
def _post(self, fn, *args) -> None:
|
||
|
|
"""Thread-safe: schedule `fn(*args)` on the main thread."""
|
||
|
|
self._uiq.put(("call", fn, args))
|
||
|
|
|
||
|
|
# ── UI ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def _center_window(self) -> None:
|
||
|
|
self.root.update_idletasks()
|
||
|
|
w, h = 640, 560
|
||
|
|
x = (self.root.winfo_screenwidth() - w) // 2
|
||
|
|
y = max(40, (self.root.winfo_screenheight() - h) // 3)
|
||
|
|
self.root.geometry(f"{w}x{h}+{x}+{y}")
|
||
|
|
|
||
|
|
def _build_header(self) -> None:
|
||
|
|
"""Brand banner: dark map-night ground, app title, and the app's own map
|
||
|
|
markers (car, bus, live traffic signal) drawn as vector art."""
|
||
|
|
c = tk.Canvas(self.root, height=76, highlightthickness=0, bg="#1d2530")
|
||
|
|
c.pack(fill="x")
|
||
|
|
c.create_text(20, 28, anchor="w", text="V2X2MAP",
|
||
|
|
font=(UI_FONT, 20, "bold"), fill="white")
|
||
|
|
c.create_text(20, 52, anchor="w", text="ITS-G5 Receiver · Setup & Firmware-Flasher",
|
||
|
|
font=(UI_FONT, 11), fill="#93a1af")
|
||
|
|
|
||
|
|
def place_markers(_evt=None):
|
||
|
|
c.delete("art")
|
||
|
|
before = set(c.find_all())
|
||
|
|
w = c.winfo_width() or 640
|
||
|
|
# subtle "road" line behind the vehicles
|
||
|
|
c.create_line(w-215, 66, w-15, 10, fill="#39424c", width=10, capstyle="round")
|
||
|
|
c.create_line(w-215, 66, w-15, 10, fill="#4a5560", width=1, dash=(4, 6))
|
||
|
|
_MarkerArt.car(c, w-180, 46, 0.82)
|
||
|
|
_MarkerArt.bus(c, w-120, 38, 0.82)
|
||
|
|
_MarkerArt.signal(c, w-52, 34, 0.95, active="green")
|
||
|
|
for item in set(c.find_all()) - before:
|
||
|
|
c.addtag_withtag("art", item)
|
||
|
|
c.bind("<Configure>", place_markers)
|
||
|
|
place_markers()
|
||
|
|
|
||
|
|
def _build(self) -> None:
|
||
|
|
self._build_header()
|
||
|
|
nb = ttk.Notebook(self.root)
|
||
|
|
nb.pack(fill="both", expand=True, padx=10, pady=10)
|
||
|
|
self.nb = nb
|
||
|
|
self._build_step1(nb)
|
||
|
|
self._build_step2(nb)
|
||
|
|
self._build_done(nb)
|
||
|
|
|
||
|
|
# ── Step 1: Device ───────────────────────────────────────────────────────
|
||
|
|
def _build_step1(self, nb):
|
||
|
|
f = ttk.Frame(nb, padding=20); nb.add(f, text="1. Device")
|
||
|
|
ttk.Label(f, text="V2X2MAP Setup", font=(UI_FONT, 15, "bold")).pack(anchor="w")
|
||
|
|
ttk.Label(f, text=f"Connect the ESP32-C5 via USB and select the {PORT_WORD}.\n"
|
||
|
|
"Detected ESP32 boards are preselected automatically.",
|
||
|
|
wraplength=540, justify="left").pack(anchor="w", pady=(8, 16))
|
||
|
|
bar = ttk.Frame(f); bar.pack(side="bottom", fill="x", pady=(20, 0))
|
||
|
|
ttk.Button(bar, text="Cancel", command=self._cancel).pack(side="right")
|
||
|
|
ttk.Button(bar, text="Next >", command=self._goto_step2).pack(side="right", padx=(0, 8))
|
||
|
|
row = ttk.Frame(f); row.pack(fill="x")
|
||
|
|
ttk.Label(row, text=f"{PORT_WORD}:").pack(side="left")
|
||
|
|
self.port_combo = ttk.Combobox(row, textvariable=self.port_var, state="readonly")
|
||
|
|
self.port_combo.pack(side="left", padx=(8, 8), fill="x", expand=True)
|
||
|
|
ttk.Button(row, text="Refresh", command=self._refresh_ports).pack(side="left")
|
||
|
|
ttk.Checkbutton(f, text="Skip firmware flash (already flashed)",
|
||
|
|
variable=self.skip_flash).pack(anchor="w", pady=(20, 0))
|
||
|
|
self._refresh_ports()
|
||
|
|
|
||
|
|
# ── Step 2: Flash ────────────────────────────────────────────────────────
|
||
|
|
def _build_step2(self, nb):
|
||
|
|
f = ttk.Frame(nb, padding=20); nb.add(f, text="2. Flash")
|
||
|
|
# Button bar packed FIRST: pack() starves last-packed widgets when space runs
|
||
|
|
# out, and the bar must always win over the expanding log.
|
||
|
|
bar = ttk.Frame(f); bar.pack(side="bottom", fill="x", pady=(12, 0))
|
||
|
|
self.flash_btn = ttk.Button(bar, text="Start flash", command=self._start_flash)
|
||
|
|
self.flash_btn.pack(side="right", padx=(0, 8))
|
||
|
|
self.flash_next_btn = ttk.Button(bar, text="Next >",
|
||
|
|
command=lambda: self.nb.select(2), state="disabled")
|
||
|
|
self.flash_next_btn.pack(side="right", padx=(0, 8))
|
||
|
|
ttk.Button(bar, text="< Back", command=lambda: self.nb.select(0)).pack(side="right")
|
||
|
|
ttk.Label(f, text="Flash firmware", font=(UI_FONT, 13, "bold")).pack(anchor="w")
|
||
|
|
ttk.Label(f, text="Writes bootloader, partition table and application. Takes 30-60 s.",
|
||
|
|
wraplength=540, justify="left").pack(anchor="w", pady=(4, 12))
|
||
|
|
self.flash_status = ttk.Label(f, text="Ready.")
|
||
|
|
self.flash_status.pack(anchor="w")
|
||
|
|
self.flash_progress = ttk.Progressbar(f, mode="determinate")
|
||
|
|
self.flash_progress.pack(fill="x", pady=(0, 8))
|
||
|
|
|
||
|
|
# Boot-mode help: quietly present from the start, highlighted when a flash
|
||
|
|
# attempt fails to connect (the one situation where it's actually needed).
|
||
|
|
self.bootmode_box = tk.Frame(f, bd=1, relief="solid",
|
||
|
|
highlightthickness=0, bg="#f4f6f8")
|
||
|
|
self.bootmode_label = tk.Label(
|
||
|
|
self.bootmode_box, justify="left", anchor="w", bg="#f4f6f8", fg="#3a4552",
|
||
|
|
font=(UI_FONT, 11),
|
||
|
|
text=("If the device is not detected: put the ESP32-C5 into boot mode —\n"
|
||
|
|
" 1. hold the BOOT button\n"
|
||
|
|
" 2. tap RESET once (or re-plug USB) while still holding BOOT\n"
|
||
|
|
" 3. release BOOT, then press “Start flash” again"))
|
||
|
|
self.bootmode_label.pack(fill="x", padx=10, pady=8)
|
||
|
|
self.bootmode_box.pack(fill="x", pady=(0, 8))
|
||
|
|
self.flash_log = scrolledtext.ScrolledText(f, height=12, font=(MONO_FONT, 10), wrap="none")
|
||
|
|
self.flash_log.pack(fill="both", expand=True)
|
||
|
|
|
||
|
|
# ── Step 3: Done ─────────────────────────────────────────────────────────
|
||
|
|
def _build_done(self, nb):
|
||
|
|
f = ttk.Frame(nb, padding=20); nb.add(f, text="3. Done")
|
||
|
|
bar = ttk.Frame(f); bar.pack(side="bottom", fill="x", pady=(12, 0))
|
||
|
|
ttk.Button(bar, text="Finish", command=self._finish).pack(side="right", padx=(0, 8))
|
||
|
|
ttk.Button(bar, text="< Back", command=lambda: self.nb.select(1)).pack(side="right")
|
||
|
|
|
||
|
|
box = tk.Canvas(f, height=88, highlightthickness=0, bg="#eef6ee")
|
||
|
|
box.pack(fill="x", pady=(4, 12))
|
||
|
|
box.create_oval(18, 20, 66, 68, fill="#2e7d32", outline="")
|
||
|
|
box.create_line(30, 45, 40, 55, 56, 32, fill="white", width=5,
|
||
|
|
capstyle="round", joinstyle="round")
|
||
|
|
box.create_text(84, 34, anchor="w", text="Receiver ready!",
|
||
|
|
font=(UI_FONT, 15, "bold"), fill="#20301f")
|
||
|
|
box.create_text(84, 58, anchor="w",
|
||
|
|
text="Firmware flashed — the device restarts by itself.",
|
||
|
|
font=(UI_FONT, 11), fill="#4a5548")
|
||
|
|
|
||
|
|
ttk.Label(f, justify="left", wraplength=560, text=(
|
||
|
|
"No further configuration is needed:\n\n"
|
||
|
|
" • The receiver derives its node-id automatically from its own\n"
|
||
|
|
" hardware MAC address (V2X2MAP…).\n"
|
||
|
|
" • Open the V2X2MAP app on your phone — the receiver appears\n"
|
||
|
|
" via Bluetooth within a few seconds.")).pack(anchor="w")
|
||
|
|
|
||
|
|
# ── serial port ──────────────────────────────────────────────────────────
|
||
|
|
@staticmethod
|
||
|
|
def _port_score(p) -> int:
|
||
|
|
"""Higher = more likely the ESP32 devboard. Blacklisted internals sink."""
|
||
|
|
dev = p.device.lower()
|
||
|
|
if any(b in dev for b in _PORT_BLACKLIST):
|
||
|
|
return -1
|
||
|
|
vid = getattr(p, "vid", None)
|
||
|
|
if vid in _ESP_VIDS:
|
||
|
|
return 100 # Espressif USB-JTAG: that's the board
|
||
|
|
if vid in _UART_VIDS:
|
||
|
|
return 60 # common devboard UART bridge
|
||
|
|
if "usbmodem" in dev or "usbserial" in dev or "ttyusb" in dev or "ttyacm" in dev:
|
||
|
|
return 30 # generic USB serial
|
||
|
|
return 0
|
||
|
|
|
||
|
|
def _refresh_ports(self):
|
||
|
|
ports = sorted(serial.tools.list_ports.comports(),
|
||
|
|
key=self._port_score, reverse=True)
|
||
|
|
labels: list[str] = []
|
||
|
|
self._port_label_to_device.clear()
|
||
|
|
for p in ports:
|
||
|
|
if self._port_score(p) < 0:
|
||
|
|
continue # hide macOS-internal pseudo ports
|
||
|
|
desc = (p.description or "").strip()
|
||
|
|
label = p.device if desc in ("", "n/a") else f"{p.device} - {desc}"
|
||
|
|
if getattr(p, "vid", None) in _ESP_VIDS:
|
||
|
|
label += " ← ESP32"
|
||
|
|
labels.append(label)
|
||
|
|
self._port_label_to_device[label] = p.device
|
||
|
|
self.port_combo["values"] = labels
|
||
|
|
if labels:
|
||
|
|
if not self.port_var.get() or self.port_var.get() not in self._port_label_to_device:
|
||
|
|
self.port_var.set(labels[0]) # best-scored port preselected
|
||
|
|
else:
|
||
|
|
self.port_var.set("")
|
||
|
|
|
||
|
|
def _selected_port(self) -> str:
|
||
|
|
return self._port_label_to_device.get(self.port_var.get(), "")
|
||
|
|
|
||
|
|
# ── Navigation ───────────────────────────────────────────────────────────
|
||
|
|
def _goto_step2(self):
|
||
|
|
if not self._selected_port():
|
||
|
|
messagebox.showerror("Error", f"Please select a {PORT_WORD} first.")
|
||
|
|
return
|
||
|
|
self.nb.select(2 if self.skip_flash.get() else 1)
|
||
|
|
|
||
|
|
def _cancel(self):
|
||
|
|
if self._busy: return
|
||
|
|
self.result = None
|
||
|
|
self.root.destroy()
|
||
|
|
|
||
|
|
# ── Flash ─────────────────────────────────────────────────────────────────
|
||
|
|
def _start_flash(self):
|
||
|
|
if self._busy: return
|
||
|
|
port = self._selected_port()
|
||
|
|
if not port:
|
||
|
|
messagebox.showerror("Error", f"No {PORT_WORD} selected."); return
|
||
|
|
for _, name in FIRMWARE_FILES:
|
||
|
|
path = _resource_path("firmware", name)
|
||
|
|
if not os.path.isfile(path):
|
||
|
|
messagebox.showerror("Error", f"Firmware file missing: {name}"); return
|
||
|
|
self._busy = True
|
||
|
|
self.flash_btn.configure(state="disabled")
|
||
|
|
self.flash_next_btn.configure(state="disabled")
|
||
|
|
self.flash_status.configure(text=f"Flashing {port} ...")
|
||
|
|
self.flash_log.delete("1.0", "end")
|
||
|
|
self.flash_progress.configure(mode="indeterminate")
|
||
|
|
self.flash_progress.start(60)
|
||
|
|
threading.Thread(target=self._flash_worker, args=(port,), daemon=True).start()
|
||
|
|
|
||
|
|
def _flash_worker(self, port):
|
||
|
|
# esptool v4 spells options with underscores, v5 with dashes — pick per version.
|
||
|
|
try:
|
||
|
|
import esptool
|
||
|
|
v5 = int(str(getattr(esptool, "__version__", "5")).split(".")[0]) >= 5
|
||
|
|
except Exception:
|
||
|
|
v5 = True
|
||
|
|
wf, fm, fs, ff = (("write-flash", "--flash-mode", "--flash-size", "--flash-freq") if v5
|
||
|
|
else ("write_flash", "--flash_mode", "--flash_size", "--flash_freq"))
|
||
|
|
args = ["--chip", CHIP, "--port", port, "--baud", FLASH_BAUD,
|
||
|
|
"--before", "default-reset", "--after", "hard-reset",
|
||
|
|
wf, fm, "dio", fs, "4MB", ff, "80m"]
|
||
|
|
for off, name in FIRMWARE_FILES:
|
||
|
|
args += [off, _resource_path("firmware", name)]
|
||
|
|
ok, msg = self._run_esptool(args, self.flash_log)
|
||
|
|
self._post(self._on_flash_done, ok, msg)
|
||
|
|
|
||
|
|
def _on_flash_done(self, ok, msg):
|
||
|
|
self._busy = False
|
||
|
|
self.flash_progress.stop()
|
||
|
|
self.flash_progress.configure(mode="determinate", value=100 if ok else 0)
|
||
|
|
self.flash_btn.configure(state="normal")
|
||
|
|
if ok:
|
||
|
|
self.flash_status.configure(text="Flash successful.")
|
||
|
|
self.flash_next_btn.configure(state="normal")
|
||
|
|
self.nb.select(2)
|
||
|
|
else:
|
||
|
|
self.flash_status.configure(text=f"Flash failed: {msg}")
|
||
|
|
# Connection trouble is the boot-mode case — make the help impossible to miss.
|
||
|
|
log_tail = self.flash_log.get("1.0", "end").lower()
|
||
|
|
if any(k in (msg or "").lower() + log_tail
|
||
|
|
for k in ("failed to connect", "no serial data", "timed out", "could not open")):
|
||
|
|
self.bootmode_box.configure(bg="#fff3cd")
|
||
|
|
self.bootmode_label.configure(bg="#fff3cd", fg="#7a5c00",
|
||
|
|
font=(UI_FONT, 11, "bold"))
|
||
|
|
|
||
|
|
# ── Finish ────────────────────────────────────────────────────────────────
|
||
|
|
def _finish(self):
|
||
|
|
self.result = {"port": self._selected_port()}
|
||
|
|
self.root.destroy()
|
||
|
|
|
||
|
|
# ── esptool runner ─────────────────────────────────────────────────────────
|
||
|
|
def _run_esptool(self, args, log_widget, tee=None):
|
||
|
|
writer = _UiWriter(self._uiq, log_widget)
|
||
|
|
sink = tee(writer) if tee else writer
|
||
|
|
old_out, old_err = sys.stdout, sys.stderr
|
||
|
|
sys.stdout = sys.stderr = sink
|
||
|
|
try:
|
||
|
|
try:
|
||
|
|
import esptool
|
||
|
|
except ImportError as e:
|
||
|
|
return False, f"esptool not in bundle: {e}"
|
||
|
|
try:
|
||
|
|
esptool.main(args)
|
||
|
|
except SystemExit as exc:
|
||
|
|
code = exc.code
|
||
|
|
if code in (0, None): return True, ""
|
||
|
|
return False, f"esptool exit {code}"
|
||
|
|
except Exception as exc:
|
||
|
|
return False, str(exc)
|
||
|
|
return True, ""
|
||
|
|
finally:
|
||
|
|
try: sink.flush()
|
||
|
|
except Exception: pass
|
||
|
|
sys.stdout, sys.stderr = old_out, old_err
|
||
|
|
|
||
|
|
# ── run ────────────────────────────────────────────────────────────────────
|
||
|
|
def run(self) -> dict | None:
|
||
|
|
self.root.protocol("WM_DELETE_WINDOW", self._cancel)
|
||
|
|
self.root.mainloop()
|
||
|
|
return self.result
|
||
|
|
|
||
|
|
|
||
|
|
def run_wizard() -> dict | None:
|
||
|
|
return SetupWizard().run()
|