From 10e7d0b0778c9a32d94e088e849a79afa133e0db Mon Sep 17 00:00:00 2001 From: AjinkyaGokhale Date: Wed, 27 May 2026 11:40:14 +0200 Subject: [PATCH] feat: popular firmware catalog with download, cache, and explore view --- pom.xml | 2 +- .../espflasher/model/FirmwareDefinition.java | 44 + .../espflasher/service/FirmwareCatalog.java | 54 + .../service/FirmwareDownloader.java | 403 +++ .../espflasher/ui/FlasherApp.java | 2771 +++++++++-------- src/main/resources/icons/firmware/tasmota.png | Bin 0 -> 5450 bytes src/main/resources/styles.css | 144 +- 7 files changed, 2162 insertions(+), 1256 deletions(-) create mode 100644 src/main/java/com/ajinkyagokhale/espflasher/model/FirmwareDefinition.java create mode 100644 src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareCatalog.java create mode 100644 src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareDownloader.java create mode 100644 src/main/resources/icons/firmware/tasmota.png diff --git a/pom.xml b/pom.xml index 17e7ae2..71f45dc 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.ajinkyagokhale espflasher - 1.0.3 + 1.0.4 jar diff --git a/src/main/java/com/ajinkyagokhale/espflasher/model/FirmwareDefinition.java b/src/main/java/com/ajinkyagokhale/espflasher/model/FirmwareDefinition.java new file mode 100644 index 0000000..414a482 --- /dev/null +++ b/src/main/java/com/ajinkyagokhale/espflasher/model/FirmwareDefinition.java @@ -0,0 +1,44 @@ +package com.ajinkyagokhale.espflasher.model; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class FirmwareDefinition { + + private final String name; + private final String description; + private final String githubRepo; + private final String websiteUrl; + private final Map chipBinMap; + + // constructor + public FirmwareDefinition(String name, String description, + String githubRepo, String websiteUrl, + Map chipBinMap) { + this.name = name; + this.description = description; + this.githubRepo = githubRepo; + this.websiteUrl = websiteUrl; + this.chipBinMap = chipBinMap == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(chipBinMap)); + } + + public String getName() { return name; } + public String getDescription() { return description; } + public String getGithubRepo() { return githubRepo; } + public String getWebsiteUrl() { return websiteUrl; } + @SuppressFBWarnings(value = "EI_EXPOSE_REP", + justification = "chipBinMap is an unmodifiableMap wrapping a defensive copy; safe to expose") + public Map getChipBinMap() { return chipBinMap; } + + public String getBinForChip(String chip) { + return chipBinMap.getOrDefault(chip, chipBinMap.get("default")); + } + + @Override + public String toString() { return name; } +} \ No newline at end of file diff --git a/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareCatalog.java b/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareCatalog.java new file mode 100644 index 0000000..8699c13 --- /dev/null +++ b/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareCatalog.java @@ -0,0 +1,54 @@ +package com.ajinkyagokhale.espflasher.service; + +import com.ajinkyagokhale.espflasher.model.FirmwareDefinition; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class FirmwareCatalog { + + public static List getCatalog() { + return List.of( + new FirmwareDefinition( + "Tasmota", + "Universal smart home firmware with web UI and MQTT", + "arendst/Tasmota", + "https://tasmota.github.io", + tasmotaBins() + ), + new FirmwareDefinition( + "Tasmota SML (ottelo9)", + "Tasmota with SML support for smart meter reading", + "ottelo9/tasmota-sml-images", + "https://github.com/ottelo9/tasmota-sml-images", + tasmotaSmlBins() + ) + ); + } + + private static Map tasmotaBins() { + LinkedHashMap m = new LinkedHashMap<>(); + m.put("esp32c6", "tasmota32c6.bin"); + m.put("esp32", "tasmota32.bin"); + m.put("esp32s2", "tasmota32s2.bin"); + m.put("esp32s3", "tasmota32s3.bin"); + m.put("esp32c3", "tasmota32c3.bin"); + m.put("esp8266", "tasmota.bin"); + m.put("default", "tasmota32.bin"); + return m; + } + + private static Map tasmotaSmlBins() { + LinkedHashMap m = new LinkedHashMap<>(); + m.put("esp32c6", "tasmota32c6_ottelo_tas.zip"); + m.put("esp32", "tasmota32_ottelo_tas.zip"); + m.put("esp32s2", "tasmota32s2_ottelo_tas.zip"); + m.put("esp32s3", "tasmota32s3_ottelo_tas.zip"); + m.put("esp32c3", "tasmota32c3_ottelo_tas.zip"); + m.put("esp8266", "tasmota8266_bundle_ottelo.zip"); + m.put("default", "tasmota32_ottelo_tas.zip"); + return m; + } + +} diff --git a/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareDownloader.java b/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareDownloader.java new file mode 100644 index 0000000..113087d --- /dev/null +++ b/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareDownloader.java @@ -0,0 +1,403 @@ +package com.ajinkyagokhale.espflasher.service; + +import com.ajinkyagokhale.espflasher.model.FirmwareDefinition; + +import java.io.*; +import java.net.URI; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; +import java.util.function.Consumer; + +public class FirmwareDownloader { + + private static final String CACHE_DIR = + System.getProperty("user.home") + "/.esp-flasher/firmware-cache"; + + private static final Path VERSION_CACHE_FILE = + Paths.get(System.getProperty("user.home"), ".esp-flasher", "version-cache.properties"); + + // 12-hour TTL — long enough to avoid the rate limit, short enough to catch new releases + private static final long VERSION_TTL_MS = 12L * 60L * 60L * 1000L; + + // In-memory version cache — populated on first fetch, kept for app lifetime + private final Map versionCache = new ConcurrentHashMap<>(); + + private Properties diskCache; + private boolean diskCacheLoaded; + + // Optional sink for diagnostic messages (e.g. UI log area). Null = silent. + private Consumer logger; + + public void setLogger(Consumer logger) { + this.logger = logger; + } + + private void log(String msg) { + if (logger != null) logger.accept(msg); + } + + // ── Internet check ──────────────────────────────────── + public boolean isOnline() { + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(3)) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://api.github.com")) + .GET() + .build(); + HttpResponse response = client.send( + request, HttpResponse.BodyHandlers.discarding() + ); + return response.statusCode() < 500; + } catch (java.io.IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + return false; + } + } + + // ── Fetch latest version (Atom feed first, REST fallback, disk-cached) ───── + public String fetchLatestVersion(FirmwareDefinition firmware) { + String repo = firmware.getGithubRepo(); + + // 1. In-memory cache (this session) + String cached = versionCache.get(repo); + if (cached != null) return cached; + + // 2. Disk cache (survives restarts, 12h TTL) + String fromDisk = readDiskCache(repo); + if (fromDisk != null) { + versionCache.put(repo, fromDisk); + return fromDisk; + } + + // 3. Atom feeds — not rate-limited, no auth needed + String tag = fetchAtomTag(repo, "releases.atom"); + if (tag == null) tag = fetchAtomTag(repo, "tags.atom"); + + if (tag == null) return "unknown"; + + if (tag.startsWith("v") || tag.startsWith("V")) tag = tag.substring(1); + versionCache.put(repo, tag); + writeDiskCache(repo, tag); + return tag; + } + + private String fetchAtomTag(String repo, String suffix) { + try { + String url = "https://github.com/" + repo + "/" + suffix; + HttpClient client = HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(5)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/atom+xml") + .build(); + + HttpResponse response = client.send( + request, HttpResponse.BodyHandlers.ofString() + ); + + if (response.statusCode() != 200) { + log("[version] " + repo + " — " + suffix + " HTTP " + response.statusCode()); + return null; + } + + String body = response.body(); + + // Prefer the /releases/tag/ URL fragment (present in releases.atom) + String marker = "/releases/tag/"; + int idx = body.indexOf(marker); + if (idx != -1) { + int start = idx + marker.length(); + int end = start; + while (end < body.length()) { + char c = body.charAt(end); + if (c == '"' || c == '<' || c == ' ' || c == '\n' || c == '\r') break; + end++; + } + String tag = body.substring(start, end); + if (!tag.isEmpty()) return tag; + } + + // Fallback: first 's (works for tags.atom and unusual releases.atom) + int entryIdx = body.indexOf("<entry"); + if (entryIdx == -1) { + log("[version] " + repo + " — " + suffix + " had no entries"); + return null; + } + int titleStart = body.indexOf("<title>", entryIdx); + if (titleStart == -1) return null; + titleStart += "<title>".length(); + int titleEnd = body.indexOf("", titleStart); + if (titleEnd == -1) return null; + String tag = body.substring(titleStart, titleEnd).trim(); + return tag.isEmpty() ? null : tag; + + } catch (Exception e) { + log("[version] " + repo + " — " + suffix + " fetch failed: " + + e.getClass().getSimpleName() + ": " + e.getMessage()); + return null; + } + } + + // ── Disk-cache helpers ───────────────────────────────── + private synchronized String readDiskCache(String repo) { + loadDiskCacheIfNeeded(); + String entry = diskCache.getProperty(repo); + if (entry == null) return null; + int sep = entry.indexOf('|'); + if (sep <= 0) return null; + try { + long ts = Long.parseLong(entry.substring(0, sep)); + if (System.currentTimeMillis() - ts > VERSION_TTL_MS) return null; + String version = entry.substring(sep + 1); + return version.isEmpty() ? null : version; + } catch (NumberFormatException e) { + return null; + } + } + + private synchronized void writeDiskCache(String repo, String version) { + loadDiskCacheIfNeeded(); + diskCache.setProperty(repo, System.currentTimeMillis() + "|" + version); + try { + Path parent = VERSION_CACHE_FILE.getParent(); + if (parent != null) Files.createDirectories(parent); + try (OutputStream out = Files.newOutputStream(VERSION_CACHE_FILE)) { + diskCache.store(out, "ESP Flasher firmware version cache"); + } + } catch (IOException e) { + log("[version] could not persist cache: " + e.getMessage()); + } + } + + private void loadDiskCacheIfNeeded() { + if (diskCacheLoaded) return; + diskCache = new Properties(); + if (Files.exists(VERSION_CACHE_FILE)) { + try (InputStream in = Files.newInputStream(VERSION_CACHE_FILE)) { + diskCache.load(in); + } catch (IOException e) { + log("[version] could not read cache: " + e.getMessage()); + } + } + diskCacheLoaded = true; + } + + // ── Fetch download URL for specific bin ────────────── + public String fetchDownloadUrl(FirmwareDefinition firmware, String chip) { + String repo = firmware.getGithubRepo(); + String binName = firmware.getBinForChip(chip); + if (binName == null) return null; + + // GitHub's "/releases/latest/download/" is a public redirect — no API, no rate limits. + // The actual download() call follows the redirect to the asset's S3 URL. + String directUrl = "https://github.com/" + repo + "/releases/latest/download/" + binName; + if (assetExists(directUrl)) return directUrl; + + // Fallback: parse releases.atom to find the latest tag, then build a tagged-asset URL. + String tag = fetchAtomTag(repo, "releases.atom"); + if (tag != null) { + String taggedUrl = "https://github.com/" + repo + "/releases/download/" + tag + "/" + binName; + if (assetExists(taggedUrl)) return taggedUrl; + log("[download] asset '" + binName + "' not found at " + taggedUrl); + } + + log("[download] no published asset named '" + binName + "' for " + repo); + return null; + } + + /** Returns true if a GET (HEAD isn't always honoured by GitHub redirects) lands on a 2xx response. */ + private boolean assetExists(String url) { + try { + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(java.time.Duration.ofSeconds(5)) + .build(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.discarding()); + return resp.statusCode() >= 200 && resp.statusCode() < 300; + } catch (Exception e) { + log("[download] assetExists check failed: " + e.getMessage()); + return false; + } + } + + // ── Download bin file ──────────────────────────────── + public String download(String downloadUrl, String fileName, + DownloadListener listener) throws Exception { + // create cache dir + Files.createDirectories(Paths.get(CACHE_DIR)); + String destPath = CACHE_DIR + "/" + fileName; + + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(downloadUrl)) + .build(); + + HttpResponse response = client.send( + request, HttpResponse.BodyHandlers.ofInputStream() + ); + + if (response.statusCode() >= 400) { + throw new Exception("HTTP " + response.statusCode() + " downloading " + downloadUrl); + } + + long totalBytes = response.headers() + .firstValueAsLong("content-length") + .orElse(-1); + + try (InputStream in = response.body(); + OutputStream out = new FileOutputStream(destPath)) { + + byte[] buffer = new byte[8192]; + long downloaded = 0; + int read; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + downloaded += read; + if (listener != null) { + listener.onProgress(downloaded, totalBytes); + } + } + } + + return destPath; + } + + // ── Listener interface ─────────────────────────────── + public interface DownloadListener { + void onProgress(long downloaded, long total); + } + + public String downloadAndExtract(String downloadUrl, String zipName, + DownloadListener listener) throws Exception { + return downloadAndExtract(downloadUrl, zipName, null, null, listener); + } + + public String downloadAndExtract(String downloadUrl, String zipName, String chip, + String version, DownloadListener listener) throws Exception { + + String dirName = (version != null && !version.isBlank()) + ? zipName.replace(".zip", "") + "-v" + version + : zipName.replace(".zip", ""); + String extractDir = CACHE_DIR + "/" + dirName; + + // ── Cache hit: extracted dir already has the bin for this chip ───── + File existing = new File(extractDir); + if (existing.isDirectory()) { + String hit = pickBinForChip(existing, chip); + if (hit != null) { + log("[download] cache hit: " + hit); + return hit; + } + } + + String zipPath = download(downloadUrl, zipName, listener); + log("[download] saved " + zipPath + " (" + new File(zipPath).length() + " bytes)"); + + long size = new File(zipPath).length(); + if (size < 1024) { + // Likely an HTML error page, not a zip + String preview = new String( + Files.readAllBytes(Paths.get(zipPath)), + StandardCharsets.UTF_8 + ); + throw new Exception("Downloaded file is too small to be a zip (" + + size + " bytes). Content: " + preview.substring(0, Math.min(200, preview.length()))); + } + + Files.createDirectories(Paths.get(extractDir)); + + java.util.List entries = new java.util.ArrayList<>(); + + try (java.util.zip.ZipInputStream zis = new java.util.zip.ZipInputStream( + new FileInputStream(zipPath))) { + + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + entries.add(entry.getName() + " (" + entry.getSize() + " bytes)"); + + String name = entry.getName(); + String lower = name.toLowerCase(); + if (lower.endsWith(".bin")) { + Path out = Paths.get(extractDir, new File(name).getName()); + try (OutputStream os = new FileOutputStream(out.toString())) { + zis.transferTo(os); + } + } + } + } + + String picked = pickBinForChip(new File(extractDir), chip); + if (picked != null) return picked; + + log("[download] zip entries: " + entries); + boolean requireFactory = zipName.toLowerCase().contains("ottelo"); + throw new Exception((requireFactory ? "No factory .bin" : "No .bin") + + " for chip '" + chip + "' inside " + zipName + ". Entries: " + entries); + } + + /** + * Pick the best matching factory bin in an extracted dir for the given chip. + * Preference order: factory bin matching chip → any bin matching chip → any factory bin. + * Returns null if the directory has no bin we'd be confident flashing. + */ + private String pickBinForChip(File dir, String chip) { + File[] files = dir.listFiles(); + if (files == null) return null; + + File factoryForChip = null; + File anyForChip = null; + + for (File f : files) { + String lower = f.getName().toLowerCase(); + if (!lower.endsWith(".bin")) continue; + if (!binMatchesChip(lower, chip)) continue; // never flash a bin for the wrong chip + if (lower.contains("factory") && factoryForChip == null) factoryForChip = f; + else if (anyForChip == null) anyForChip = f; + } + + if (factoryForChip != null) return factoryForChip.getAbsolutePath(); + return anyForChip != null ? anyForChip.getAbsolutePath() : null; + } + + private static boolean binMatchesChip(String binNameLower, String chip) { + if (chip == null || chip.isBlank() || chip.equals("auto")) return false; + if (chip.equals("esp8266")) { + // tasmota.bin / *8266*.bin — exclude any tasmota32* variants + return binNameLower.contains("8266") + || (binNameLower.contains("tasmota") && !binNameLower.contains("tasmota32")); + } + if (chip.equals("esp32")) { + // match "32" but NOT followed by a chip-family letter (c/s/h) + int i = binNameLower.indexOf("32"); + while (i != -1) { + int next = i + 2; + if (next >= binNameLower.length()) return true; + char c = binNameLower.charAt(next); + if (c != 'c' && c != 's' && c != 'h') return true; + i = binNameLower.indexOf("32", next); + } + return false; + } + // esp32c6, esp32c3, esp32s2, esp32s3, esp32h2 → match "32c6", "32c3", etc. + if (chip.startsWith("esp32") && chip.length() > 5) { + return binNameLower.contains("32" + chip.substring(5)); + } + return binNameLower.contains(chip); + } +} \ No newline at end of file diff --git a/src/main/java/com/ajinkyagokhale/espflasher/ui/FlasherApp.java b/src/main/java/com/ajinkyagokhale/espflasher/ui/FlasherApp.java index 544727f..73d09d9 100644 --- a/src/main/java/com/ajinkyagokhale/espflasher/ui/FlasherApp.java +++ b/src/main/java/com/ajinkyagokhale/espflasher/ui/FlasherApp.java @@ -1,1247 +1,1524 @@ -package com.ajinkyagokhale.espflasher.ui; - -import com.ajinkyagokhale.espflasher.listener.FlashListener; -import com.ajinkyagokhale.espflasher.listener.PortListener; -import com.ajinkyagokhale.espflasher.model.AppSettings; -import com.ajinkyagokhale.espflasher.model.FlashConfig; -import com.ajinkyagokhale.espflasher.model.FlashResult; -import com.ajinkyagokhale.espflasher.service.*; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; - -import javafx.application.Application; -import javafx.application.Platform; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.Scene; -import javafx.scene.control.*; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.TextArea; -import javafx.scene.control.TextField; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; -import javafx.stage.FileChooser; -import javafx.stage.Modality; -import javafx.stage.Stage; - -import javafx.scene.media.AudioClip; - -import java.awt.*; -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicBoolean; - -public class FlasherApp extends Application implements FlashListener, PortListener { - // UI Controls - private TextField binPathField; - private ComboBox chipCombo; - private ComboBox portCombo; - private ComboBox baudCombo; - private TextField flashOffsetField; - private ProgressBar progressBar; - private TextArea logArea; - private Button flashButton; - private Button stopButton; - private Button factoryButton; - private Label statusLabel; - - // Services - private EsptoolRunner esptoolRunner; - private PortWatcher portWatcher; - private PrereqChecker prereqChecker; - - //audio clips - private AudioClip successSound; - private AudioClip failSound; - - //flashcounter - private int flashCount = 0; - private Label flashCountLabel; - private Label flashCountNumber; - private boolean isFactoryMode = false; - -//settings - private SettingsManager settingsManager; - private FlashLogger flashLogger; - - // tracks state across flash lifecycle - private String currentFlashPort; - private String currentFlashMac; - - @Override - public void onProgress(int percent) { - Platform.runLater(() -> { - progressBar.setProgress(percent / 100.0); - }); - } - - @Override - public void onLog(String line) { - if (line.contains("MAC:")) { - String[] parts = line.split("MAC:"); - if (parts.length > 1) currentFlashMac = parts[1].trim(); - } - Platform.runLater(() -> logArea.appendText(line + "\n")); - } - - @Override - public void onComplete(boolean success, String message) { - String timestamp = java.time.LocalDateTime.now() - .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - flashLogger.append(new FlashResult(success, message, currentFlashMac, currentFlashPort, timestamp)); - - Platform.runLater(() -> { - statusLabel.setText(message); - flashButton.setDisable(false); - factoryButton.setDisable(false); - stopButton.setDisable(true); - if (success) { - flashCount++; - flashCountNumber.setText(String.valueOf(flashCount)); - flashCountLabel.setStyle("-fx-background-color: rgba(102,187,106,0.25); -fx-border-color: #66bb6a;"); - new javafx.animation.Timeline( - new javafx.animation.KeyFrame(javafx.util.Duration.millis(800), - ev -> flashCountLabel.setStyle("")) - ).play(); - progressBar.setStyle("-fx-accent: green;"); - progressBar.setProgress(1.0); - successSound.play(); - statusLabel.setStyle("-fx-text-fill: green;"); - } else { - failSound.play(); - progressBar.setStyle("-fx-accent: red;"); - progressBar.setProgress(1.0); - statusLabel.setStyle("-fx-text-fill: red;"); - } - - if (isFactoryMode) { - statusLabel.setText("Waiting for next device..."); - statusLabel.setStyle("-fx-text-fill: #66bb6a;"); - flashButton.setDisable(true); - stopButton.setDisable(false); - } - }); - } - - @Override - public void onNewPort(String portName) { - Platform.runLater(() -> { - statusLabel.setText("Device detected: " + portName + " — flashing..."); - startFlash(portName); - }); - } - - private void showPythonMissingDialog() { - flashButton.setDisable(true); - factoryButton.setDisable(true); - - Stage dialog = new Stage(); - dialog.initModality(Modality.APPLICATION_MODAL); - dialog.setTitle("Python Not Found"); - dialog.setResizable(false); - - Label heading = new Label("Python 3 is required to run esptool."); - heading.setStyle("-fx-font-weight: bold; -fx-font-size: 13px;"); - - String os = System.getProperty("os.name", "").toLowerCase(); - String steps; - if (os.contains("win")) { - steps = """ - 1. Go to https://python.org/downloads - 2. Download the latest Python 3 installer for Windows. - 3. Run the installer — check "Add Python to PATH" before clicking Install. - 4. Restart ESP Flasher after installation."""; - } else if (os.contains("mac")) { - steps = """ - Option A — Homebrew (recommended): - brew install python3 - - Option B — Installer: - 1. Go to https://python.org/downloads - 2. Download and run the macOS installer. - 3. Restart ESP Flasher after installation."""; - } else { - steps = """ - Debian / Ubuntu: - sudo apt install python3 python3-pip - - Fedora / RHEL: - sudo dnf install python3 - - Arch: - sudo pacman -S python - - Restart ESP Flasher after installation."""; - } - - TextArea guide = new TextArea(steps); - guide.setEditable(false); - guide.setWrapText(true); - guide.setPrefHeight(140); - guide.setPrefWidth(400); - guide.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;"); - - Button downloadBtn = new Button("Open python.org"); - downloadBtn.setOnAction(e -> getHostServices().showDocument("https://python.org/downloads")); - - Button closeBtn = new Button("Close"); - closeBtn.setOnAction(e -> dialog.close()); - - HBox buttons = new HBox(10, downloadBtn, closeBtn); - buttons.setAlignment(Pos.CENTER_RIGHT); - - VBox root = new VBox(12, heading, guide, buttons); - root.setPadding(new Insets(16)); - - dialog.setScene(new Scene(root)); - dialog.showAndWait(); - - statusLabel.setText("⚠ Python not found — install Python and restart."); - statusLabel.setStyle("-fx-text-fill: red;"); - } - - private void autoInstallEsptool() { - showEsptoolInstallDialog(); - } - - private void startFlash(String overridePort) { - - //guard - if (!prereqChecker.isReady()) { - statusLabel.setText("esptool not found. Please install it first."); - return; - } - //factory mode - String port = overridePort != null ? overridePort : portCombo.getValue(); - - // 1. validate inputs - String binPath = binPathField.getText(); - if (binPath.isEmpty()) { - statusLabel.setText("Please select a firmware file."); - return; - } - - //String port = portCombo.getValue(); - if (port == null || port.isEmpty()) { - statusLabel.setText("Please select a port."); - return; - } - - // 2. build FlashConfig - FlashConfig config = new FlashConfig( - chipCombo.getValue(), - Integer.parseInt(baudCombo.getValue()), - port, - binPath, - flashOffsetField.getText(), - prereqChecker.getEsptoolCmd() - ); - - // 3. update UI - flashButton.setDisable(true); - stopButton.setDisable(false); - progressBar.setStyle(""); - progressBar.setProgress(0); - statusLabel.setText("Flashing..."); - - // 4. start flashing - currentFlashPort = port; - currentFlashMac = null; - esptoolRunner.startFlashing(config, this); - } - - private void startFactoryMode() { - - if (binPathField.getText().isEmpty()) { - Alert alert = new Alert(Alert.AlertType.WARNING); - alert.setTitle("No Firmware Selected"); - alert.setHeaderText(null); - alert.setContentText("Please select a firmware .bin file before starting factory mode."); - alert.showAndWait(); - return; - } - if (!prereqChecker.isReady()) { - statusLabel.setText("esptool not ready."); - return; - } - - - isFactoryMode = true; - - flashButton.setDisable(true); - factoryButton.setText("Stop Factory"); - factoryButton.setOnAction(e -> stopAll()); - stopButton.setDisable(false); - statusLabel.setText("Factory mode — waiting for device..."); - statusLabel.setStyle("-fx-text-fill: #66bb6a;"); - - - - //confirmation box - // confirmation dialog - Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); - confirm.setTitle("Start Factory Mode"); - confirm.setHeaderText("Ready to flash?"); - -// big font bin file name - Label binLabel = new Label(new File(binPathField.getText()).getName()); - binLabel.getStyleClass().add("confirm-bin-name"); - - Label chipLabel = new Label("Chip: " + chipCombo.getValue() + " Baud: " + baudCombo.getValue()); - chipLabel.getStyleClass().add("confirm-details"); - - VBox content = new VBox(8, binLabel, chipLabel); - confirm.getDialogPane().setContent(content); - // add this here ↓ - confirm.getDialogPane().getStylesheets().add( - getClass().getResource("/styles.css").toExternalForm() - ); - confirm.setGraphic(null); // - ButtonType startBtn = new ButtonType("Start Factory", ButtonBar.ButtonData.OK_DONE); - ButtonType cancelBtn = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); - confirm.getButtonTypes().setAll(startBtn, cancelBtn); - - Optional result = confirm.showAndWait(); - if (result.isEmpty() || result.get() != startBtn) return; // cancelled - - - portWatcher.startWatching(this); - } - - private void stopAll() { - portWatcher.stopWatching(); - esptoolRunner.stopFlashing(); - isFactoryMode = false; - flashButton.setDisable(false); - factoryButton.setText("Factory Mode"); - factoryButton.setOnAction(e -> startFactoryMode()); - stopButton.setDisable(true); - statusLabel.setText("Stopped."); - statusLabel.setStyle(""); - } - - -// private void showSettingsDialog(Stage owner) { -// Stage dialog = new Stage(); -// dialog.initOwner(owner); -// dialog.initModality(Modality.APPLICATION_MODAL); -// dialog.setTitle("Settings"); -// -// // ── Detected paths (read-only) ────────────────────────────────── -// Label detectedHeading = new Label("Detected paths"); -// detectedHeading.setStyle("-fx-font-weight: bold;"); -// -// Label detectedPython = detectedPathLabel("Python", prereqChecker.getPythonCmd()); -// Label detectedPip = detectedPathLabel("pip", prereqChecker.getPipCmd()); -// Label detectedEsptool = detectedPathLabel("esptool", prereqChecker.getEsptoolCmd()); -// -// VBox detectedBox = new VBox(4, detectedPython, detectedPip, detectedEsptool); -// detectedBox.setStyle("-fx-background-color: rgba(255,255,255,0.04); " -// + "-fx-border-color: rgba(255,255,255,0.08); " -// + "-fx-border-radius: 6; -fx-background-radius: 6; -fx-padding: 8;"); -// -// // ── Custom overrides ──────────────────────────────────────────── -// Label overrideHeading = new Label("Custom overrides (leave blank to use auto-detect)"); -// overrideHeading.setStyle("-fx-font-weight: bold;"); -// -// TextField pythonField = new TextField(PrereqChecker.getCustomPythonPath()); -// pythonField.setPromptText("e.g. C:\\Python313\\python.exe"); -// pythonField.setPrefWidth(400); -// Button pythonBrowse = new Button("Browse..."); -// pythonBrowse.setOnAction(e -> { -// FileChooser fc = new FileChooser(); -// fc.setTitle("Select Python executable"); -// File f = fc.showOpenDialog(dialog); -// if (f != null) pythonField.setText(f.getAbsolutePath()); -// }); -// -// TextField esptoolField = new TextField(PrereqChecker.getCustomEsptoolPath()); -// esptoolField.setPromptText("e.g. C:\\Tools\\esptool.exe"); -// esptoolField.setPrefWidth(400); -// Button esptoolBrowse = new Button("Browse..."); -// esptoolBrowse.setOnAction(e -> { -// FileChooser fc = new FileChooser(); -// fc.setTitle("Select esptool executable"); -// File f = fc.showOpenDialog(dialog); -// if (f != null) esptoolField.setText(f.getAbsolutePath()); -// }); -// -// Label status = new Label(); -// -// Button save = new Button("Save & Recheck"); -// save.setOnAction(e -> { -// PrereqChecker.setCustomPaths(pythonField.getText(), esptoolField.getText()); -// status.setText("Rechecking..."); -// new Thread(() -> { -// prereqChecker.checkAll(); -// Platform.runLater(() -> { -// detectedPython.setText(detectedText("Python", prereqChecker.getPythonCmd())); -// detectedPip.setText(detectedText("pip", prereqChecker.getPipCmd())); -// detectedEsptool.setText(detectedText("esptool", prereqChecker.getEsptoolCmd())); -// if (prereqChecker.isReady()) { -// status.setText("✓ Ready."); -// status.setStyle("-fx-text-fill: #66bb6a;"); -// statusLabel.setText("Ready."); -// statusLabel.setStyle(""); -// } else { -// status.setText("✗ esptool not found. Check paths."); -// status.setStyle("-fx-text-fill: red;"); -// } -// }); -// }).start(); -// }); -// Button close = new Button("Close"); -// close.setOnAction(e -> dialog.close()); -// -// HBox actions = new HBox(10, save, close); -// actions.setAlignment(Pos.CENTER_RIGHT); -// -// VBox content = new VBox(12, -// detectedHeading, -// detectedBox, -// new Separator(), -// overrideHeading, -// new Label("Python:"), -// new HBox(8, pythonField, pythonBrowse), -// new Label("esptool:"), -// new HBox(8, esptoolField, esptoolBrowse), -// status, -// actions -// ); -// content.setPadding(new Insets(16)); -// -// dialog.setScene(new Scene(content)); -// dialog.show(); -// } -private void showSettingsDialog(Stage owner) { - Stage dialog = new Stage(); - dialog.initOwner(owner); - dialog.initModality(Modality.APPLICATION_MODAL); - dialog.setTitle("Settings"); - dialog.setResizable(false); - - // ── Left menu ────────────────────────────────────── - VBox leftMenu = new VBox(0); - leftMenu.getStyleClass().add("settings-menu"); - leftMenu.setPrefWidth(150); - - Label menuTitle = new Label("Settings"); - menuTitle.getStyleClass().add("settings-menu-title"); - menuTitle.setPadding(new Insets(16)); - - Button generalBtn = new Button("General"); - generalBtn.getStyleClass().add("settings-menu-item"); - generalBtn.setMaxWidth(Double.MAX_VALUE); - - Button pathsBtn = new Button("Paths"); - pathsBtn.getStyleClass().add("settings-menu-item"); - pathsBtn.setMaxWidth(Double.MAX_VALUE); - - Button logFileBtn = new Button("Log File"); - logFileBtn.getStyleClass().add("settings-menu-item"); - logFileBtn.setMaxWidth(Double.MAX_VALUE); - - leftMenu.getChildren().addAll(menuTitle, generalBtn, pathsBtn, logFileBtn); - - // ── Right panels ──────────────────────────────────── - VBox generalPane = buildGeneralPane(); - VBox pathsPane = buildPathsPane(dialog); - VBox logFilePane = buildLogFilePane(dialog); - - pathsPane.setVisible(false); - logFilePane.setVisible(false); - - StackPane rightContent = new StackPane(generalPane, pathsPane, logFilePane); - rightContent.setPadding(new Insets(20)); - rightContent.setPrefWidth(370); - - // ── Menu click handlers ───────────────────────────── - generalBtn.setOnAction(e -> { - generalPane.setVisible(true); - pathsPane.setVisible(false); - logFilePane.setVisible(false); - setActiveMenu(generalBtn, pathsBtn, logFileBtn); - }); - pathsBtn.setOnAction(e -> { - generalPane.setVisible(false); - pathsPane.setVisible(true); - logFilePane.setVisible(false); - setActiveMenu(pathsBtn, generalBtn, logFileBtn); - }); - logFileBtn.setOnAction(e -> { - generalPane.setVisible(false); - pathsPane.setVisible(false); - logFilePane.setVisible(true); - setActiveMenu(logFileBtn, generalBtn, pathsBtn); - }); - - // General active by default - generalBtn.getStyleClass().add("settings-menu-item-active"); - - // ── Bottom bar ────────────────────────────────────── - Button saveBtn = new Button("Save"); - saveBtn.getStyleClass().add("btn-primary"); - saveBtn.setOnAction(e -> { - settingsManager.save(); - dialog.close(); - }); - - Button cancelBtn = new Button("Cancel"); - cancelBtn.getStyleClass().add("btn-secondary"); - cancelBtn.setOnAction(e -> dialog.close()); - - HBox bottomBar = new HBox(10, cancelBtn, saveBtn); - bottomBar.setAlignment(Pos.CENTER_RIGHT); - bottomBar.setPadding(new Insets(10, 20, 16, 20)); - - // ── Main layout ───────────────────────────────────── - HBox mainLayout = new HBox(leftMenu, rightContent); - VBox root = new VBox(mainLayout, bottomBar); - root.getStyleClass().add("settings-root"); - - Scene scene = new Scene(root, 520, 380); - scene.getStylesheets().add( - Objects.requireNonNull(getClass().getResource("/styles.css")).toExternalForm() - ); - dialog.setScene(scene); - dialog.show(); -} - - // helper — sets active style on clicked button - private void setActiveMenu(Button active, Button... others) { - active.getStyleClass().add("settings-menu-item-active"); - for (Button b : others) b.getStyleClass().remove("settings-menu-item-active"); - } - - // ── General pane ──────────────────────────────────────── - private VBox buildGeneralPane() { - VBox pane = new VBox(12); - - Label heading = new Label("General"); - heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); - - Label info = new Label("Last used settings are saved automatically."); - info.setStyle("-fx-text-fill: #888888;"); - - pane.getChildren().addAll(heading, info); - return pane; - } - - // ── Paths pane — moved from old showSettingsDialog ─────── - private VBox buildPathsPane(Stage dialog) { - VBox pane = new VBox(12); - - Label heading = new Label("Detected Paths"); - heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); - - Label detectedPython = detectedPathLabel("Python", prereqChecker.getPythonCmd()); - Label detectedPip = detectedPathLabel("pip", prereqChecker.getPipCmd()); - Label detectedEsptool = detectedPathLabel("esptool", prereqChecker.getEsptoolCmd()); - - VBox detectedBox = new VBox(4, detectedPython, detectedPip, detectedEsptool); - detectedBox.setStyle("-fx-background-color: rgba(255,255,255,0.04); " - + "-fx-border-color: rgba(255,255,255,0.08); " - + "-fx-border-radius: 6; -fx-background-radius: 6; -fx-padding: 8;"); - - Label overrideHeading = new Label("Custom Overrides"); - overrideHeading.setStyle("-fx-font-weight: bold;"); - - TextField pythonField = new TextField(PrereqChecker.getCustomPythonPath()); - pythonField.setPromptText("e.g. /usr/bin/python3"); - HBox.setHgrow(pythonField, Priority.ALWAYS); - Button pythonBrowse = new Button("Browse..."); - pythonBrowse.setOnAction(e -> { - FileChooser fc = new FileChooser(); - File f = fc.showOpenDialog(dialog); - if (f != null) pythonField.setText(f.getAbsolutePath()); - }); - - TextField esptoolField = new TextField(PrereqChecker.getCustomEsptoolPath()); - esptoolField.setPromptText("e.g. /usr/local/bin/esptool.py"); - HBox.setHgrow(esptoolField, Priority.ALWAYS); - Button esptoolBrowse = new Button("Browse..."); - esptoolBrowse.setOnAction(e -> { - FileChooser fc = new FileChooser(); - File f = fc.showOpenDialog(dialog); - if (f != null) esptoolField.setText(f.getAbsolutePath()); - }); - - Label status = new Label(); - Button recheckBtn = new Button("Save & Recheck"); - recheckBtn.setOnAction(e -> { - PrereqChecker.setCustomPaths(pythonField.getText(), esptoolField.getText()); - status.setText("Rechecking..."); - new Thread(() -> { - prereqChecker.checkAll(); - Platform.runLater(() -> { - detectedPython.setText(detectedText("Python", prereqChecker.getPythonCmd())); - detectedPip.setText(detectedText("pip", prereqChecker.getPipCmd())); - detectedEsptool.setText(detectedText("esptool", prereqChecker.getEsptoolCmd())); - status.setText(prereqChecker.isReady() ? "✓ Ready." : "✗ esptool not found."); - status.setStyle(prereqChecker.isReady() ? "-fx-text-fill: green;" : "-fx-text-fill: red;"); - }); - }).start(); - }); - - pane.getChildren().addAll( - heading, detectedBox, - new Separator(), overrideHeading, - new Label("Python:"), new HBox(8, pythonField, pythonBrowse), - new Label("esptool:"), new HBox(8, esptoolField, esptoolBrowse), - status, recheckBtn - ); - return pane; - } - - // ── Log File pane ──────────────────────────────────────── - private VBox buildLogFilePane(Stage dialog) { - VBox pane = new VBox(12); - AppSettings settings = settingsManager.getSettings(); - - Label heading = new Label("Log File"); - heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); - - Label infoBtn = new Label("ℹ"); - infoBtn.setStyle( - "-fx-border-color: #666; -fx-border-radius: 50%; -fx-background-radius: 50%; " + - "-fx-background-color: #2a2a2a; -fx-font-size: 12px; -fx-text-fill: #cccccc; " + - "-fx-min-width: 22px; -fx-min-height: 22px; -fx-max-width: 22px; -fx-max-height: 22px; " + - "-fx-alignment: center; -fx-cursor: hand;"); - infoBtn.setOnMouseClicked(e -> { - Alert info = new Alert(Alert.AlertType.NONE); - info.setTitle("Why use a log file?"); - info.getButtonTypes().add(javafx.scene.control.ButtonType.OK); - - Label title = new Label("MAC Address Provisioning Audit"); - title.setStyle("-fx-font-weight: bold; -fx-font-size: 13px;"); - - Label recordedLabel = new Label("Each flash attempt is recorded with:"); - recordedLabel.setStyle("-fx-font-size: 12px;"); - - VBox bullets = new VBox(4, - bullet("Timestamp"), - bullet("Serial port"), - bullet("Device MAC address"), - bullet("Status (success / failed)") - ); - bullets.setStyle("-fx-padding: 0 0 0 12;"); - - Label useCaseLabel = new Label("Use case — backend provisioning check:"); - useCaseLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 12px; -fx-padding: 8 0 0 0;"); - - Label useCaseBody = new Label( - "Upload flash-log.csv to your backend and cross-reference the MAC " + - "addresses against your provisioned-device registry. This lets you verify " + - "that every flashed device was registered, catch devices that were flashed " + - "but never provisioned, and flag duplicates (same MAC flashed twice)." - ); - useCaseBody.setStyle("-fx-font-size: 12px;"); - useCaseBody.setWrapText(true); - useCaseBody.setMaxWidth(380); - - VBox content = new VBox(8, title, recordedLabel, bullets, useCaseLabel, useCaseBody); - content.setStyle("-fx-padding: 4;"); - - info.getDialogPane().setContent(content); - info.getDialogPane().getStylesheets().addAll(dialog.getScene().getStylesheets()); - info.showAndWait(); - }); - - HBox headingRow = new HBox(8, heading, infoBtn); - headingRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT); - - CheckBox enableToggle = new CheckBox("Enable log file"); - enableToggle.setSelected(settings.isLogFileEnabled()); - enableToggle.setStyle(settings.isLogFileEnabled() ? "-fx-text-fill: #66bb6a;" : ""); - - - Label pathLabel = new Label("Log folder:"); - TextField pathField = new TextField(settings.getLogFilePath()); - pathField.setDisable(!settings.isLogFileEnabled()); - HBox.setHgrow(pathField, Priority.ALWAYS); - - Button browseBtn = new Button("Browse..."); - browseBtn.setDisable(!settings.isLogFileEnabled()); - browseBtn.setOnAction(e -> { - javafx.stage.DirectoryChooser dc = new javafx.stage.DirectoryChooser(); - dc.setTitle("Select Log Folder"); - File dir = dc.showDialog(dialog); - if (dir != null) pathField.setText(dir.getAbsolutePath()); - }); - - Label formatInfo = new Label("Format: timestamp, port, MAC address, status"); - formatInfo.setStyle("-fx-text-fill: #888888; -fx-font-size: 11px;"); - - // enable/disable fields when toggle changes - enableToggle.setOnAction(e -> { - boolean enabled = enableToggle.isSelected(); - pathField.setDisable(!enabled); - browseBtn.setDisable(!enabled); - settings.setLogFileEnabled(enabled); - enableToggle.setStyle(enabled ? "-fx-text-fill: #66bb6a;" : ""); - }); - - // save path on change - pathField.textProperty().addListener((obs, old, val) -> - settings.setLogFilePath(val) - ); - - pane.getChildren().addAll( - headingRow, - enableToggle, - pathLabel, - new HBox(8, pathField, browseBtn), - formatInfo - ); - return pane; - } - private Label bullet(String text) { - Label l = new Label("• " + text); - l.setStyle("-fx-font-size: 12px;"); - return l; - } - - private Label detectedPathLabel(String name, String value) { - Label l = new Label(detectedText(name, value)); - l.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;"); - return l; - } - - private String detectedText(String name, String value) { - if (value == null || value.isBlank()) - return "✗ " + name + ": not found"; - return "✓ " + name + ": " + value; - } - - private void browseBin(Stage stage) { - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle("Select Firmware"); - fileChooser.getExtensionFilters().add( - new FileChooser.ExtensionFilter("Binary files (*.bin)", "*.bin") - ); - File file = fileChooser.showOpenDialog(stage); - if (file != null) { - binPathField.setText(file.getAbsolutePath()); - } - - } - - private void refreshPorts() { - portCombo.getItems().clear(); - List espPorts = PortWatcher.listEsp32Ports(); - - if (espPorts.isEmpty()) { - portCombo.setPromptText("No ESP32 detected..."); - } else { - portCombo.getItems().addAll(espPorts); - portCombo.getSelectionModel().selectFirst(); // auto select first ESP32 - } - } - - private void installEsptool() { - statusLabel.setStyle(""); - statusLabel.setOnMouseClicked(null); - if (prereqChecker.getPythonCmd() == null) { - showPythonMissingDialog(); - return; - } - if (prereqChecker.getPipCmd() == null) { - statusLabel.setText("⚠ pip not found — reinstall Python with pip included."); - statusLabel.setStyle("-fx-text-fill: red;"); - return; - } - showEsptoolInstallDialog(); - } - - private void showEsptoolInstallDialog() { - Stage dialog = new Stage(); - dialog.initModality(Modality.APPLICATION_MODAL); - dialog.setTitle("Installing esptool"); - dialog.setResizable(false); - - Label titleLabel = new Label("Installing esptool via pip..."); - titleLabel.setStyle("-fx-font-weight: bold;"); - - ProgressBar bar = new ProgressBar(); - bar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); - bar.setMaxWidth(Double.MAX_VALUE); - - TextArea output = new TextArea(); - output.setEditable(false); - output.setWrapText(true); - output.setPrefHeight(180); - output.setPrefWidth(440); - - Label statusMsg = new Label("Please wait..."); - - Button closeBtn = new Button("Close"); - closeBtn.setDisable(true); - closeBtn.setOnAction(e -> dialog.close()); - - VBox root = new VBox(10, titleLabel, bar, output, statusMsg, closeBtn); - root.setPadding(new Insets(16)); - root.setAlignment(Pos.CENTER_LEFT); - - dialog.setScene(new Scene(root)); - - Thread thread = new Thread(() -> { - boolean success = prereqChecker.installEsptool(line -> - Platform.runLater(() -> output.appendText(line + "\n")) - ); - - Platform.runLater(() -> { - bar.setProgress(1.0); - if (success) { - logArea.clear(); - statusLabel.setText("Ready."); - statusLabel.setStyle(""); - flashButton.setDisable(false); - factoryButton.setDisable(false); - dialog.close(); - } else { - statusMsg.setText("✗ Install failed. Check output above."); - statusMsg.setStyle("-fx-text-fill: red;"); - statusLabel.setText("⚠ esptool install failed."); - closeBtn.setDisable(false); - } - }); - }); - thread.setDaemon(true); - thread.start(); - - dialog.show(); - } - - private void checkPrerequisites() { - statusLabel.setText("Checking prerequisites..."); - - Thread thread = new Thread(() -> { - prereqChecker.checkAll(); - - Platform.runLater(() -> { - if (prereqChecker.isReady()) { - statusLabel.setText("Ready."); - } else { - statusLabel.setText("esptool not found — click here to install."); - statusLabel.setStyle("-fx-text-fill: #2196f3; -fx-underline: true; -fx-cursor: hand;"); - statusLabel.setOnMouseClicked(e -> installEsptool()); - } - }); - }); - thread.setDaemon(true); - thread.start(); - - Platform.runLater(() -> { - if (prereqChecker.isReady()) { - statusLabel.setText("Ready."); - } else if (prereqChecker.getPythonCmd() == null) { - showPythonMissingDialog(); - } else { - autoInstallEsptool(); - } - }); - } - - private boolean isDarkMode() { - String os = System.getProperty("os.name").toLowerCase(); - try { - if (os.contains("mac")) { - Process p = Runtime.getRuntime().exec( - new String[]{"defaults", "read", "-g", "AppleInterfaceStyle"} - ); - String result = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); - return result.equalsIgnoreCase("dark"); - } else if (os.contains("windows")) { - Process p = Runtime.getRuntime().exec(new String[]{ - "reg", "query", - "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", - "/v", "AppsUseLightTheme" - }); - String result = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - return result.contains("0x0"); - } - } catch (Exception e) { - return false; - } - return false; - } - - private void showAboutDialog() { - Stage dialog = new Stage(); - dialog.setTitle("About"); - dialog.setResizable(false); - - VBox content = new VBox(12); - content.setPadding(new Insets(24)); - content.setAlignment(Pos.CENTER); - content.getStyleClass().add("about-dialog"); - - var iconUrl = getClass().getResource("/icons/icon.png"); - if (iconUrl != null) { - javafx.scene.image.ImageView logo = new javafx.scene.image.ImageView( - new javafx.scene.image.Image(iconUrl.toExternalForm())); - logo.setFitWidth(72); - logo.setFitHeight(72); - logo.setPreserveRatio(true); - content.getChildren().add(logo); - } - - Label title = new Label("ESP Flasher"); - title.getStyleClass().add("about-title"); - - String appVersion = new UpdateService().currentVersion(); - Label version = new Label("v" + appVersion); - version.getStyleClass().add("about-version"); - - Separator sep = new Separator(); - sep.setStyle("-fx-background-color: #3a3a3c;"); - - Label author = new Label("Built by Ajinkya Gokhale"); - author.getStyleClass().add("about-author"); - - Label email = new Label("✉ hi@ajinkyagokhale.com"); - email.getStyleClass().add("about-link"); - email.setOnMouseClicked(e -> - getHostServices().showDocument("mailto:hi@ajinkyagokhale.com")); - - Label github = new Label("⚡ github.com/ajinkyagokhale"); - github.getStyleClass().add("about-link"); - github.setOnMouseClicked(e -> - getHostServices().showDocument("https://github.com/ajinkyagokhale")); - - Label license = new Label("MIT License — 2026"); - license.getStyleClass().add("about-license"); - - Button closeBtn = new Button("Close"); - closeBtn.setOnAction(e -> dialog.close()); - closeBtn.setPrefWidth(100); - - content.getChildren().addAll( - title, version, sep, - author, email, github, - license, closeBtn - ); - - Scene scene = new Scene(content, 280, 360); - dialog.setScene(scene); - dialog.show(); - } - - @Override - public void stop() { - AppSettings settings = settingsManager.getSettings(); - settings.setLastChip(chipCombo.getValue()); - settings.setLastBaudRate(baudCombo.getValue()); - settings.setLastBinPath(binPathField.getText()); - settings.setLastPort(portCombo.getValue()); - settingsManager.save(); - - portWatcher.stopWatching(); - esptoolRunner.stopFlashing(); - } - - - @Override - public void start(Stage primaryStage) throws Exception { - checkForUpdates(); - // set app icon — JavaFX Image only supports PNG/JPEG/BMP, not .ico/.icns - var iconUrl = getClass().getResource("/icons/icon.png"); - if (iconUrl != null) { - primaryStage.getIcons().add(new javafx.scene.image.Image(iconUrl.toExternalForm())); - } - - - esptoolRunner = new EsptoolRunner(); - portWatcher = new PortWatcher(); - prereqChecker = new PrereqChecker(); - settingsManager = new SettingsManager(); - settingsManager.load(); - flashLogger = new FlashLogger(settingsManager.getSettings()); - - var successUrl = getClass().getResource("/sounds/success.wav"); - var failUrl = getClass().getResource("/sounds/failure.wav"); - - if (successUrl != null && failUrl != null) { - successSound = new AudioClip(successUrl.toExternalForm()); - failSound = new AudioClip(failUrl.toExternalForm()); - } else { - System.out.println("Sound files not found at /sounds/success.wav or /sounds/failure.wav"); - } - - - primaryStage.setTitle("ESP Flasher"); - primaryStage.setWidth(750); - primaryStage.setHeight(650); - - VBox root = new VBox(10); // 10px spacing between children - root.setPadding(new Insets(20)); - - Scene scene = new Scene(root); - if (isDarkMode()) { - scene.getStylesheets().add( - getClass().getResource("/styles.css").toExternalForm() - ); - } - primaryStage.setScene(scene); - // File row - binPathField = new TextField(); - binPathField.setPromptText("Select firmware .bin file..."); - binPathField.setEditable(false); - HBox.setHgrow(binPathField, Priority.ALWAYS); - - Button browseButton = new Button("Browse..."); - browseButton.setOnAction(e -> browseBin(primaryStage)); - - HBox fileRow = new HBox(10, new Label("Firmware"), binPathField, browseButton); - fileRow.setAlignment(Pos.CENTER_LEFT); - - //root.getChildren().add(fileRow); - - // Chip row - chipCombo = new ComboBox<>(); - chipCombo.getItems().addAll( - "auto", "esp32c6", "esp32", "esp32s2", - "esp32s3", "esp32c3", "esp32h2", "esp8266" - ); - chipCombo.setValue("auto"); - - HBox chipRow = new HBox(10, new Label("Chip"), chipCombo); - chipRow.setAlignment(Pos.CENTER_LEFT); - - //root.getChildren().add(chipRow); - // Port row - portCombo = new ComboBox<>(); - portCombo.setEditable(true); - portCombo.setPromptText("Select port..."); - refreshPorts(); - Button refreshButton = new Button("Refresh"); - refreshButton.setOnAction(e -> refreshPorts()); - - HBox portRow = new HBox(10, new Label("Port"), portCombo, refreshButton); - portRow.setAlignment(Pos.CENTER_LEFT); - - //root.getChildren().add(portRow); - - // Baud row - baudCombo = new ComboBox<>(); - baudCombo.getItems().addAll( - "460800", "921600", "230400", "115200" - ); - baudCombo.setValue("460800"); - - HBox baudRow = new HBox(10, new Label("Baud Rate"), baudCombo); - baudRow.setAlignment(Pos.CENTER_LEFT); - //root.getChildren().add(baudRow); - -// Offset row - flashOffsetField = new TextField("0x0"); - flashOffsetField.setPrefWidth(120); - - HBox offsetRow = new HBox(10, new Label("Flash Offset"), flashOffsetField); - offsetRow.setAlignment(Pos.CENTER_LEFT); - //root.getChildren().add(offsetRow); - VBox configCard = new VBox(10); - configCard.getStyleClass().add("config-card"); - configCard.getChildren().addAll( - fileRow, chipRow, portRow, baudRow, offsetRow - ); - root.getChildren().add(configCard); - // Buttons row - flashButton = new Button("Flash Once"); - flashButton.setOnAction(e -> startFlash(null)); - - factoryButton = new Button("Factory Mode"); - factoryButton.setOnAction(e -> startFactoryMode()); - - - flashCountNumber = new Label("0"); - flashCountNumber.getStyleClass().add("flash-count-number"); - - Label flashCountSub = new Label("flashed"); - flashCountSub.getStyleClass().add("flash-count-sub"); - - flashCountLabel = new Label(); - flashCountLabel.getStyleClass().add("flash-count-card"); - VBox flashCard = new VBox(0, flashCountNumber, flashCountSub); - flashCard.setAlignment(Pos.CENTER); - flashCountLabel.setGraphic(flashCard); - - stopButton = new Button("Stop"); - stopButton.setOnAction(e -> stopAll()); - stopButton.setDisable(true); - -// Button aboutButton = new Button("About"); -// aboutButton.setOnAction(e -> showAboutDialog()); - - - Button settingsButton = new Button("Settings"); - settingsButton.setOnAction(e -> showSettingsDialog(primaryStage)); - - javafx.scene.layout.Region spacer = new javafx.scene.layout.Region(); - HBox.setHgrow(spacer, Priority.ALWAYS); - HBox buttonRow = new HBox(10, flashButton, factoryButton, settingsButton, spacer, flashCountLabel); - buttonRow.setAlignment(Pos.CENTER_LEFT); - - root.getChildren().add(buttonRow); -// Progress bar - progressBar = new ProgressBar(0); - progressBar.setMaxWidth(Double.MAX_VALUE); - statusLabel = new Label("Ready."); - - root.getChildren().addAll(progressBar, statusLabel); - checkPrerequisites(); - - - //apply last settings - AppSettings settings = settingsManager.getSettings(); - if (!settings.getLastChip().isEmpty()) chipCombo.setValue(settings.getLastChip()); - if (!settings.getLastBaudRate().isEmpty()) baudCombo.setValue(settings.getLastBaudRate()); - if (!settings.getLastBinPath().isEmpty()) binPathField.setText(settings.getLastBinPath()); - - -// Log area - logArea = new TextArea(); - logArea.setEditable(false); - logArea.setWrapText(false); - VBox.setVgrow(logArea, Priority.ALWAYS); - - root.getChildren().add(logArea); - - //footer - Label footer = new Label("Built with ❤ by Ajinkya Gokhale"); - footer.setMaxWidth(Double.MAX_VALUE); - footer.setAlignment(Pos.CENTER); - footer.getStyleClass().add("footer"); - - root.getChildren().add(footer); - footer.setOnMouseClicked(e -> showAboutDialog()); - footer.setStyle("-fx-cursor: hand;"); - primaryStage.show(); - if (isDarkMode()) applyDarkTitleBar(primaryStage); - - } //start-end - - private interface Dwmapi extends com.sun.jna.Library { - Dwmapi INSTANCE = com.sun.jna.Native.load("dwmapi", Dwmapi.class); - void DwmSetWindowAttribute(com.sun.jna.platform.win32.WinDef.HWND hwnd, int attr, - com.sun.jna.ptr.IntByReference value, int size); - } - - @SuppressFBWarnings("DE_MIGHT_IGNORE") - private void applyDarkTitleBar(Stage stage) { - if (!System.getProperty("os.name", "").toLowerCase().contains("win")) return; - try { - com.sun.jna.platform.win32.WinDef.HWND hwnd = - com.sun.jna.platform.win32.User32.INSTANCE.FindWindow(null, stage.getTitle()); - if (hwnd == null) return; - com.sun.jna.ptr.IntByReference dark = new com.sun.jna.ptr.IntByReference(1); - Dwmapi.INSTANCE.DwmSetWindowAttribute(hwnd, 20, dark, 4); // Windows 10 20H1+ - Dwmapi.INSTANCE.DwmSetWindowAttribute(hwnd, 19, dark, 4); // older Win10 builds - } catch (Exception ignored) {} - } - - private void checkForUpdates() { - Thread worker = new Thread(() -> { - UpdateService updates = new UpdateService(); - String current = updates.currentVersion(); - updates.latestRelease() - .filter(release -> updates.isNewer(release.version(), current)) - .ifPresent(release -> - Platform.runLater(() -> promptForcedUpdate(updates, release, current))); - }, "update-check"); - worker.setDaemon(true); - worker.start(); - } - - @SuppressFBWarnings("DM_EXIT") - private void promptForcedUpdate(UpdateService updates, UpdateService.Release release, String current) { - ButtonType updateNow = new ButtonType("Update Now", ButtonBar.ButtonData.OK_DONE); - ButtonType quit = new ButtonType("Quit", ButtonBar.ButtonData.CANCEL_CLOSE); - - Alert alert = new Alert(Alert.AlertType.CONFIRMATION); - alert.setTitle("Update Required"); - alert.setHeaderText("A new version of ESP Flasher is available"); - alert.setContentText("Installed: " + current + "\nLatest: " + release.version() - + "\n\nYou must update to continue."); - alert.getButtonTypes().setAll(updateNow, quit); - alert.initModality(Modality.APPLICATION_MODAL); - - Optional choice = alert.showAndWait(); - while (choice.isEmpty()) { - choice = alert.showAndWait(); - } - - if (choice.get() == updateNow) { - downloadUpdate(updates, release); - } else { - System.exit(0); - } - } - - private void downloadUpdate(UpdateService updates, UpdateService.Release release) { - AtomicBoolean cancelled = new AtomicBoolean(false); - - ProgressBar bar = new ProgressBar(0); - bar.setPrefWidth(380); - Label info = new Label("Starting download..."); - Label hint = new Label("The app will restart to install the update."); - hint.getStyleClass().add("footer"); - VBox box = new VBox(10, info, bar, hint); - box.setPadding(new Insets(16)); - - ButtonType cancelType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); - Alert dialog = new Alert(Alert.AlertType.NONE); - dialog.setTitle("Downloading Update"); - dialog.setHeaderText("Updating to " + release.version()); - dialog.getDialogPane().setContent(box); - dialog.getButtonTypes().setAll(cancelType); - dialog.initModality(Modality.APPLICATION_MODAL); - dialog.setOnHidden(e -> cancelled.set(true)); - - Thread worker = new Thread(() -> { - try { - updates.downloadAndLaunch(release, - (downloaded, total) -> Platform.runLater( - () -> updateProgress(bar, info, downloaded, total)), - cancelled); - Platform.runLater(() -> { - dialog.close(); - promptForcedUpdate(updates, release, updates.currentVersion()); - }); - } catch (Exception e) { - Platform.runLater(() -> { - dialog.close(); - Alert error = new Alert(Alert.AlertType.ERROR); - error.setTitle("Update Failed"); - error.setHeaderText("Could not download the update"); - error.setContentText(e.getMessage()); - error.showAndWait(); - promptForcedUpdate(updates, release, updates.currentVersion()); - }); - } - }, "update-download"); - worker.setDaemon(true); - worker.start(); - - dialog.show(); - } - - private void updateProgress(ProgressBar bar, Label info, long downloaded, long total) { - double mb = downloaded / 1048576.0; - if (total > 0) { - double fraction = (double) downloaded / total; - bar.setProgress(fraction); - info.setText(String.format("%.1f MB / %.1f MB (%d%%)", - mb, total / 1048576.0, (int) (fraction * 100))); - } else { - bar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); - info.setText(String.format("%.1f MB downloaded", mb)); - } - } - -} +package com.ajinkyagokhale.espflasher.ui; + +import com.ajinkyagokhale.espflasher.listener.FlashListener; +import com.ajinkyagokhale.espflasher.listener.PortListener; +import com.ajinkyagokhale.espflasher.model.AppSettings; +import com.ajinkyagokhale.espflasher.model.FirmwareDefinition; +import com.ajinkyagokhale.espflasher.model.FlashConfig; +import com.ajinkyagokhale.espflasher.model.FlashResult; +import com.ajinkyagokhale.espflasher.service.*; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import javafx.application.Application; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.media.AudioClip; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import javafx.scene.Node; +import javafx.scene.control.ScrollPane; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.FlowPane; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +public class FlasherApp extends Application implements FlashListener, PortListener { + + // ── UI controls ───────────────────────────────────────── + private TextField binPathField; + private ComboBox chipCombo; + private ComboBox portCombo; + private ComboBox baudCombo; + private TextField flashOffsetField; + private ProgressBar progressBar; + private TextArea logArea; + private Button flashButton; + private Button stopButton; + private Button factoryButton; + private Label statusLabel; + private Label flashCountLabel; + private Label flashCountNumber; + + // ── Services ──────────────────────────────────────────── + private EsptoolRunner esptoolRunner; + private PortWatcher portWatcher; + private PrereqChecker prereqChecker; + private SettingsManager settingsManager; + private FlashLogger flashLogger; + + // ── Audio ─────────────────────────────────────────────── + private AudioClip successSound; + private AudioClip failSound; + + // ── Flash state ───────────────────────────────────────── + private int flashCount = 0; + private boolean isFactoryMode = false; + private String currentFlashPort; + private String currentFlashMac; + + private RadioButton myBinaryRadio; + private RadioButton popularFirmwareRadio; + private ComboBox firmwareCombo; + private Label firmwareDescLabel; + private Label firmwareVersionLabel; + private VBox popularFirmwareRow; + private FirmwareDownloader firmwareDownloader; + private FirmwareDefinition selectedFirmware; + + // Top-level views (toggled by toolbar / Explore button) + private VBox flasherView; + private VBox settingsView; + private VBox exploreView; + private Button toolbarFlasherBtn; + private Button toolbarSettingsBtn; + + + // ════════════════════════════════════════════════════════ + // Application lifecycle + // ════════════════════════════════════════════════════════ + + @Override + public void start(Stage primaryStage) throws Exception { + checkForUpdates(); + + + initIcon(primaryStage); + initService(); + initSound(); + + primaryStage.setTitle("ESP Flasher"); + primaryStage.setWidth(750); + primaryStage.setHeight(650); + primaryStage.setMinWidth(750); + primaryStage.setMinHeight(650); + + VBox root = new VBox(10); + root.setPadding(new Insets(20)); + + // Flasher view container — all existing build methods feed into this + flasherView = new VBox(10); + VBox.setVgrow(flasherView, Priority.ALWAYS); + + buildConfigCard(primaryStage, flasherView); + buildButtonRow(primaryStage, flasherView); + addProgressBar(flasherView); + checkPrerequisites(); + applyLastSettings(); + buildLogArea(flasherView); + + // Settings view — initially hidden + settingsView = buildSettingsView(primaryStage); + VBox.setVgrow(settingsView, Priority.ALWAYS); + settingsView.setVisible(false); + settingsView.setManaged(false); + + // Explore view — initially hidden + exploreView = buildExploreView(); + VBox.setVgrow(exploreView, Priority.ALWAYS); + exploreView.setVisible(false); + exploreView.setManaged(false); + + HBox toolbar = buildToolbar(); + root.getChildren().addAll(toolbar, flasherView, settingsView, exploreView); + + // Footer + buildFooter(root); + + Scene scene = new Scene(root); + if (isDarkMode()) { + scene.getStylesheets().add(Objects.requireNonNull(getClass().getResource("/styles.css")).toExternalForm()); + } + primaryStage.setScene(scene); + + primaryStage.show(); + if (isDarkMode()) applyDarkTitleBar(primaryStage); + } + + private HBox buildToolbar() { + toolbarFlasherBtn = new Button("Flasher"); + toolbarSettingsBtn = new Button("Settings"); + + toolbarFlasherBtn.getStyleClass().addAll("toolbar-btn", "toolbar-btn-active"); + toolbarSettingsBtn.getStyleClass().add("toolbar-btn"); + + toolbarFlasherBtn.setOnAction(e -> showView(flasherView)); + toolbarSettingsBtn.setOnAction(e -> showView(settingsView)); + + HBox toolbar = new HBox(2, toolbarFlasherBtn, toolbarSettingsBtn); + toolbar.getStyleClass().add("toolbar"); + toolbar.setAlignment(Pos.CENTER); + + HBox toolbarWrapper = new HBox(toolbar); + toolbarWrapper.setAlignment(Pos.CENTER); + toolbarWrapper.setPadding(new Insets(4, 0, 4, 0)); + return toolbarWrapper; + } + + /** Show one of flasher/settings/explore; hide the others. Updates toolbar active state. */ + private void showView(VBox target) { + VBox[] all = { flasherView, settingsView, exploreView }; + for (VBox v : all) { + boolean show = v == target; + v.setVisible(show); + v.setManaged(show); + } + toolbarFlasherBtn.getStyleClass().remove("toolbar-btn-active"); + toolbarSettingsBtn.getStyleClass().remove("toolbar-btn-active"); + if (target == flasherView) { + toolbarFlasherBtn.getStyleClass().add("toolbar-btn-active"); + } else if (target == settingsView) { + toolbarSettingsBtn.getStyleClass().add("toolbar-btn-active"); + } + // explore view leaves both toolbar buttons inactive (deliberate) + } + + private void initIcon(Stage primaryStage) { + // set app icon — JavaFX Image only supports PNG/JPEG/BMP, not .ico/.icns + var iconUrl = getClass().getResource("/icons/icon.png"); + if (iconUrl != null) { + primaryStage.getIcons().add(new javafx.scene.image.Image(iconUrl.toExternalForm())); + } + } + + private void buildFooter(VBox root) { + Label footer = new Label("Built with ❤ by Ajinkya Gokhale"); + footer.setMaxWidth(Double.MAX_VALUE); + footer.setAlignment(Pos.CENTER); + footer.getStyleClass().add("footer"); + footer.setOnMouseClicked(e -> showAboutDialog()); + footer.setStyle("-fx-cursor: hand;"); + root.getChildren().add(footer); + } + + private void applyLastSettings() { + // Apply last-used settings + AppSettings settings = settingsManager.getSettings(); + if (!settings.getLastChip().isEmpty()) chipCombo.setValue(settings.getLastChip()); + if (!settings.getLastBaudRate().isEmpty()) baudCombo.setValue(settings.getLastBaudRate()); + if (!settings.getLastBinPath().isEmpty()) binPathField.setText(settings.getLastBinPath()); + } + + private void initSound() { + var successUrl = getClass().getResource("/sounds/success.wav"); + var failUrl = getClass().getResource("/sounds/failure.wav"); + if (successUrl != null && failUrl != null) { + successSound = new AudioClip(successUrl.toExternalForm()); + failSound = new AudioClip(failUrl.toExternalForm()); + } else { + System.out.println("Sound files not found at /sounds/success.wav or /sounds/failure.wav"); + } + } + + private void initService() { + esptoolRunner = new EsptoolRunner(); + portWatcher = new PortWatcher(); + prereqChecker = new PrereqChecker(); + settingsManager = new SettingsManager(); + settingsManager.load(); + flashLogger = new FlashLogger(settingsManager.getSettings()); + firmwareDownloader = new FirmwareDownloader(); + } + + private void buildLogArea(VBox root) { + // Log area + logArea = new TextArea(); + logArea.setEditable(false); + logArea.setWrapText(false); + VBox.setVgrow(logArea, Priority.ALWAYS); + root.getChildren().add(logArea); + + // Route firmware-downloader diagnostics here (thread-safe via Platform.runLater) + firmwareDownloader.setLogger(msg -> + Platform.runLater(() -> logArea.appendText(msg + "\n")) + ); + } + + private void addProgressBar(VBox root) { + // Progress + status + progressBar = new ProgressBar(0); + progressBar.setMaxWidth(Double.MAX_VALUE); + statusLabel = new Label("Ready."); + root.getChildren().addAll(progressBar, statusLabel); + } + + private void buildButtonRow(Stage primaryStage, VBox root) { + + // Action buttons + flash counter + flashButton = new Button("Flash Once"); + flashButton.setOnAction(e -> startFlash(null)); + + factoryButton = new Button("Factory Mode"); + factoryButton.setOnAction(e -> startFactoryMode()); + + stopButton = new Button("Stop"); + stopButton.setOnAction(e -> stopAll()); + stopButton.setDisable(true); + + flashCountNumber = new Label("0"); + flashCountNumber.getStyleClass().add("flash-count-number"); + Label flashCountSub = new Label("flashed"); + flashCountSub.getStyleClass().add("flash-count-sub"); + flashCountLabel = new Label(); + flashCountLabel.getStyleClass().add("flash-count-card"); + VBox flashCard = new VBox(0, flashCountNumber, flashCountSub); + flashCard.setAlignment(Pos.CENTER); + flashCountLabel.setGraphic(flashCard); + + javafx.scene.layout.Region spacer = new javafx.scene.layout.Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox buttonRow = new HBox(10, flashButton, factoryButton, spacer, flashCountLabel); + buttonRow.setAlignment(Pos.CENTER_LEFT); + root.getChildren().add(buttonRow); + } + private void buildConfigCard(Stage primaryStage, VBox root) { + + // ── Firmware source selector ──────────────────────── + ComboBox firmwareSourceCombo = new ComboBox<>(); + firmwareSourceCombo.getItems().add("Custom Binary"); + FirmwareCatalog.getCatalog().forEach(f -> + firmwareSourceCombo.getItems().add(f.getName()) + ); + firmwareSourceCombo.setValue("Custom Binary"); + + // ── Inline firmware description (next to Source dropdown) ── + firmwareDescLabel = new Label(""); + firmwareDescLabel.getStyleClass().add("config-label"); + firmwareDescLabel.setStyle("-fx-text-fill: #888888; -fx-font-size: 11px;"); + firmwareDescLabel.setWrapText(true); + HBox.setHgrow(firmwareDescLabel, Priority.ALWAYS); + + Button exploreBtn = new Button("Explore Popular Projects →"); + exploreBtn.getStyleClass().add("link-button"); + exploreBtn.setOnAction(e -> showView(exploreView)); + + HBox sourceRow = new HBox(10, new Label("Source"), firmwareSourceCombo, firmwareDescLabel, exploreBtn); + sourceRow.setAlignment(Pos.CENTER_LEFT); + + // ── Custom binary file row ────────────────────────── + binPathField = new TextField(); + binPathField.setPromptText("Select firmware .bin file..."); + binPathField.setEditable(false); + HBox.setHgrow(binPathField, Priority.ALWAYS); + + Button browseButton = new Button("Browse..."); + browseButton.setOnAction(e -> browseBin(primaryStage)); + + HBox fileRow = new HBox(10, new Label("Firmware"), binPathField, browseButton); + fileRow.setAlignment(Pos.CENTER_LEFT); + + // ── Popular firmware version row ──────────────────── + firmwareVersionLabel = new Label(""); + firmwareVersionLabel.setStyle("-fx-text-fill: #66bb6a; -fx-font-size: 11px;"); + + popularFirmwareRow = new VBox(4, firmwareVersionLabel); + popularFirmwareRow.setVisible(false); + popularFirmwareRow.setManaged(false); + + // ── Source toggle behavior ────────────────────────── + firmwareSourceCombo.setOnAction(e -> { + String selected = firmwareSourceCombo.getValue(); + boolean isCustom = selected.equals("Custom Binary"); + + fileRow.setVisible(isCustom); + fileRow.setManaged(isCustom); + popularFirmwareRow.setVisible(!isCustom); + popularFirmwareRow.setManaged(!isCustom); + + if (isCustom) { + firmwareDescLabel.setText(""); + selectedFirmware = null; + updateChipListForFirmware(null); + return; + } + + // find matching FirmwareDefinition + FirmwareDefinition def = FirmwareCatalog.getCatalog().stream() + .filter(f -> f.getName().equals(selected)) + .findFirst() + .orElse(null); + + selectedFirmware = def; + updateChipListForFirmware(def); + + if (def != null) { + firmwareDescLabel.setText(def.getDescription()); + firmwareVersionLabel.setText("Fetching version..."); + new Thread(() -> { + boolean online = firmwareDownloader.isOnline(); + if (!online) { + Platform.runLater(() -> + firmwareVersionLabel.setText("⚠ No internet connection") + ); + return; + } + String version = firmwareDownloader.fetchLatestVersion(def); + Platform.runLater(() -> + firmwareVersionLabel.setText("Latest: v" + version) + ); + }).start(); + } + }); + + // ── Chip row ──────────────────────────────────────── + chipCombo = new ComboBox<>(); + chipCombo.getItems().addAll( + "auto", "esp32c6", "esp32", "esp32s2", + "esp32s3", "esp32c3", "esp32h2", "esp8266" + ); + chipCombo.setValue("auto"); + HBox chipRow = new HBox(10, new Label("Chip"), chipCombo); + chipRow.setAlignment(Pos.CENTER_LEFT); + + // ── Port row ──────────────────────────────────────── + portCombo = new ComboBox<>(); + portCombo.setEditable(true); + portCombo.setPromptText("Select port..."); + refreshPorts(); + Button refreshButton = new Button("Refresh"); + refreshButton.setOnAction(e -> refreshPorts()); + HBox portRow = new HBox(10, new Label("Port"), portCombo, refreshButton); + portRow.setAlignment(Pos.CENTER_LEFT); + + // ── Baud row ──────────────────────────────────────── + baudCombo = new ComboBox<>(); + baudCombo.getItems().addAll("460800", "921600", "230400", "115200"); + baudCombo.setValue("460800"); + HBox baudRow = new HBox(10, new Label("Baud Rate"), baudCombo); + baudRow.setAlignment(Pos.CENTER_LEFT); + + // ── Offset row ────────────────────────────────────── + flashOffsetField = new TextField("0x0"); + flashOffsetField.setPrefWidth(120); + HBox offsetRow = new HBox(10, new Label("Flash Offset"), flashOffsetField); + offsetRow.setAlignment(Pos.CENTER_LEFT); + + // ── Config card ───────────────────────────────────── + VBox configCard = new VBox(10); + configCard.getStyleClass().add("config-card"); + configCard.setMaxWidth(Double.MAX_VALUE); + configCard.setFillWidth(true); + configCard.getChildren().addAll( + sourceRow, + fileRow, + popularFirmwareRow, + chipRow, portRow, baudRow, offsetRow + ); + root.getChildren().add(configCard); + } + + @Override + public void stop() { + AppSettings settings = settingsManager.getSettings(); + settings.setLastChip(chipCombo.getValue()); + settings.setLastBaudRate(baudCombo.getValue()); + settings.setLastBinPath(binPathField.getText()); + settings.setLastPort(portCombo.getValue()); + settingsManager.save(); + + portWatcher.stopWatching(); + esptoolRunner.stopFlashing(); + } + + + // ════════════════════════════════════════════════════════ + // Listener callbacks (FlashListener, PortListener) + // ════════════════════════════════════════════════════════ + + @Override + public void onProgress(int percent) { + Platform.runLater(() -> progressBar.setProgress(percent / 100.0)); + } + + @Override + public void onLog(String line) { + if (line.contains("MAC:")) { + String[] parts = line.split("MAC:"); + if (parts.length > 1) currentFlashMac = parts[1].trim(); + } + Platform.runLater(() -> logArea.appendText(line + "\n")); + } + + @Override + public void onComplete(boolean success, String message) { + String timestamp = java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + flashLogger.append(new FlashResult(success, message, currentFlashMac, currentFlashPort, timestamp)); + + Platform.runLater(() -> { + statusLabel.setText(message); + flashButton.setDisable(false); + factoryButton.setDisable(false); + stopButton.setDisable(true); + if (success) { + flashCount++; + flashCountNumber.setText(String.valueOf(flashCount)); + flashCountLabel.setStyle("-fx-background-color: rgba(102,187,106,0.25); -fx-border-color: #66bb6a;"); + new javafx.animation.Timeline( + new javafx.animation.KeyFrame(javafx.util.Duration.millis(800), + ev -> flashCountLabel.setStyle("")) + ).play(); + progressBar.setStyle("-fx-accent: green;"); + progressBar.setProgress(1.0); + successSound.play(); + statusLabel.setStyle("-fx-text-fill: green;"); + } else { + failSound.play(); + progressBar.setStyle("-fx-accent: red;"); + progressBar.setProgress(1.0); + statusLabel.setStyle("-fx-text-fill: red;"); + } + + if (isFactoryMode) { + statusLabel.setText("Waiting for next device..."); + statusLabel.setStyle("-fx-text-fill: #66bb6a;"); + flashButton.setDisable(true); + stopButton.setDisable(false); + } + }); + } + + @Override + public void onNewPort(String portName) { + Platform.runLater(() -> { + statusLabel.setText("Device detected: " + portName + " — flashing..."); + startFlash(portName); + }); + } + + + // ════════════════════════════════════════════════════════ + // Flash actions + // ════════════════════════════════════════════════════════ + + private void startFlash(String overridePort) { + if (!prereqChecker.isReady()) { + statusLabel.setText("esptool not found. Please install it first."); + return; + } + + String port = overridePort != null ? overridePort : portCombo.getValue(); + if (port == null || port.isEmpty()) { + statusLabel.setText("Please select a port."); + return; + } + + if (selectedFirmware != null) { + downloadAndFlash(selectedFirmware, port); + return; + } + + String binPath = binPathField.getText(); + if (binPath.isEmpty()) { + statusLabel.setText("Please select a firmware file."); + return; + } + + runFlash(port, binPath); + } + + private void runFlash(String port, String binPath) { + FlashConfig config = new FlashConfig( + chipCombo.getValue(), + Integer.parseInt(baudCombo.getValue()), + port, + binPath, + flashOffsetField.getText(), + prereqChecker.getEsptoolCmd() + ); + + flashButton.setDisable(true); + stopButton.setDisable(false); + progressBar.setStyle(""); + progressBar.setProgress(0); + statusLabel.setText("Flashing..."); + + currentFlashPort = port; + currentFlashMac = null; + esptoolRunner.startFlashing(config, this); + } + + private void downloadAndFlash(FirmwareDefinition fw, String port) { + flashButton.setDisable(true); + stopButton.setDisable(false); + progressBar.setStyle(""); + progressBar.setProgress(0); + statusLabel.setText("Downloading " + fw.getName() + "..."); + + String chip = chipCombo.getValue(); + String binName = fw.getBinForChip(chip); + if (binName == null) { + Platform.runLater(() -> { + statusLabel.setText("No binary defined for " + chip); + statusLabel.setStyle("-fx-text-fill: red;"); + flashButton.setDisable(false); + stopButton.setDisable(true); + }); + return; + } + + new Thread(() -> { + try { + String url = firmwareDownloader.fetchDownloadUrl(fw, chip); + if (url == null) { + Platform.runLater(() -> { + statusLabel.setText("Could not find download URL for " + binName); + statusLabel.setStyle("-fx-text-fill: red;"); + flashButton.setDisable(false); + stopButton.setDisable(true); + }); + return; + } + + FirmwareDownloader.DownloadListener listener = (downloaded, total) -> + Platform.runLater(() -> { + if (total > 0) progressBar.setProgress((double) downloaded / total); + }); + + String version = firmwareDownloader.fetchLatestVersion(fw); + String localPath = binName.toLowerCase().endsWith(".zip") + ? firmwareDownloader.downloadAndExtract(url, binName, chip, version, listener) + : firmwareDownloader.download(url, binName, listener); + + Platform.runLater(() -> { + binPathField.setText(localPath); + progressBar.setProgress(0); + runFlash(port, localPath); + }); + } catch (Exception ex) { + Platform.runLater(() -> { + logArea.appendText("[download] " + ex.getClass().getSimpleName() + ": " + ex.getMessage() + "\n"); + statusLabel.setText("Download failed."); + statusLabel.setStyle("-fx-text-fill: red;"); + flashButton.setDisable(false); + stopButton.setDisable(true); + }); + } + }).start(); + } + + private void startFactoryMode() { + if (binPathField.getText().isEmpty()) { + Alert alert = new Alert(Alert.AlertType.WARNING); + alert.setTitle("No Firmware Selected"); + alert.setHeaderText(null); + alert.setContentText("Please select a firmware .bin file before starting factory mode."); + alert.showAndWait(); + return; + } + if (!prereqChecker.isReady()) { + statusLabel.setText("esptool not ready."); + return; + } + + isFactoryMode = true; + flashButton.setDisable(true); + factoryButton.setText("Stop Factory"); + factoryButton.setOnAction(e -> stopAll()); + stopButton.setDisable(false); + statusLabel.setText("Factory mode — waiting for device..."); + statusLabel.setStyle("-fx-text-fill: #66bb6a;"); + + // Confirmation dialog + Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); + confirm.setTitle("Start Factory Mode"); + confirm.setHeaderText("Ready to flash?"); + + Label binLabel = new Label(new File(binPathField.getText()).getName()); + binLabel.getStyleClass().add("confirm-bin-name"); + + Label chipLabel = new Label("Chip: " + chipCombo.getValue() + " Baud: " + baudCombo.getValue()); + chipLabel.getStyleClass().add("confirm-details"); + + VBox content = new VBox(8, binLabel, chipLabel); + confirm.getDialogPane().setContent(content); + confirm.getDialogPane().getStylesheets().add( + getClass().getResource("/styles.css").toExternalForm() + ); + confirm.setGraphic(null); + + ButtonType startBtn = new ButtonType("Start Factory", ButtonBar.ButtonData.OK_DONE); + ButtonType cancelBtn = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + confirm.getButtonTypes().setAll(startBtn, cancelBtn); + + Optional result = confirm.showAndWait(); + if (result.isEmpty() || result.get() != startBtn) return; + + portWatcher.startWatching(this); + } + + private void stopAll() { + portWatcher.stopWatching(); + esptoolRunner.stopFlashing(); + isFactoryMode = false; + flashButton.setDisable(false); + factoryButton.setText("Factory Mode"); + factoryButton.setOnAction(e -> startFactoryMode()); + stopButton.setDisable(true); + statusLabel.setText("Stopped."); + statusLabel.setStyle(""); + } + + + // ════════════════════════════════════════════════════════ + // Settings dialog + // ════════════════════════════════════════════════════════ + + private VBox buildSettingsView(Stage parentStage) { + // Left menu + VBox leftMenu = new VBox(0); + leftMenu.getStyleClass().add("settings-menu"); + leftMenu.setPrefWidth(150); + leftMenu.setMinWidth(150); + + Label menuTitle = new Label("Settings"); + menuTitle.getStyleClass().add("settings-menu-title"); + menuTitle.setPadding(new Insets(16)); + + Button generalBtn = new Button("General"); + generalBtn.getStyleClass().add("settings-menu-item"); + generalBtn.setMaxWidth(Double.MAX_VALUE); + + Button pathsBtn = new Button("Paths"); + pathsBtn.getStyleClass().add("settings-menu-item"); + pathsBtn.setMaxWidth(Double.MAX_VALUE); + + Button logFileBtn = new Button("Log File"); + logFileBtn.getStyleClass().add("settings-menu-item"); + logFileBtn.setMaxWidth(Double.MAX_VALUE); + + leftMenu.getChildren().addAll(menuTitle, generalBtn, pathsBtn, logFileBtn); + + // Right panels + VBox generalPane = buildGeneralPane(); + VBox pathsPane = buildPathsPane(parentStage); + VBox logFilePane = buildLogFilePane(parentStage); + + pathsPane.setVisible(false); + logFilePane.setVisible(false); + + StackPane rightContent = new StackPane(generalPane, pathsPane, logFilePane); + rightContent.setPadding(new Insets(20)); + HBox.setHgrow(rightContent, Priority.ALWAYS); + + generalBtn.setOnAction(e -> { + generalPane.setVisible(true); + pathsPane.setVisible(false); + logFilePane.setVisible(false); + setActiveMenu(generalBtn, pathsBtn, logFileBtn); + }); + pathsBtn.setOnAction(e -> { + generalPane.setVisible(false); + pathsPane.setVisible(true); + logFilePane.setVisible(false); + setActiveMenu(pathsBtn, generalBtn, logFileBtn); + }); + logFileBtn.setOnAction(e -> { + generalPane.setVisible(false); + pathsPane.setVisible(false); + logFilePane.setVisible(true); + setActiveMenu(logFileBtn, generalBtn, pathsBtn); + }); + + generalBtn.getStyleClass().add("settings-menu-item-active"); + + HBox mainLayout = new HBox(leftMenu, rightContent); + VBox.setVgrow(mainLayout, Priority.ALWAYS); + + VBox view = new VBox(mainLayout); + view.getStyleClass().add("settings-root"); + return view; + } + + private void setActiveMenu(Button active, Button... others) { + active.getStyleClass().add("settings-menu-item-active"); + for (Button b : others) b.getStyleClass().remove("settings-menu-item-active"); + } + + private VBox buildGeneralPane() { + VBox pane = new VBox(12); + + Label heading = new Label("General"); + heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); + + Label info = new Label("Last used settings are saved automatically."); + info.setStyle("-fx-text-fill: #888888;"); + + pane.getChildren().addAll(heading, info); + return pane; + } + + private VBox buildPathsPane(Stage dialog) { + VBox pane = new VBox(12); + + Label heading = new Label("Detected Paths"); + heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); + + Label detectedPython = detectedPathLabel("Python", prereqChecker.getPythonCmd()); + Label detectedPip = detectedPathLabel("pip", prereqChecker.getPipCmd()); + Label detectedEsptool = detectedPathLabel("esptool", prereqChecker.getEsptoolCmd()); + + VBox detectedBox = new VBox(4, detectedPython, detectedPip, detectedEsptool); + detectedBox.setStyle("-fx-background-color: rgba(255,255,255,0.04); " + + "-fx-border-color: rgba(255,255,255,0.08); " + + "-fx-border-radius: 6; -fx-background-radius: 6; -fx-padding: 8;"); + + Label overrideHeading = new Label("Custom Overrides"); + overrideHeading.setStyle("-fx-font-weight: bold;"); + + TextField pythonField = new TextField(PrereqChecker.getCustomPythonPath()); + pythonField.setPromptText("e.g. /usr/bin/python3"); + HBox.setHgrow(pythonField, Priority.ALWAYS); + Button pythonBrowse = new Button("Browse..."); + pythonBrowse.setOnAction(e -> { + FileChooser fc = new FileChooser(); + File f = fc.showOpenDialog(dialog); + if (f != null) pythonField.setText(f.getAbsolutePath()); + }); + + TextField esptoolField = new TextField(PrereqChecker.getCustomEsptoolPath()); + esptoolField.setPromptText("e.g. /usr/local/bin/esptool.py"); + HBox.setHgrow(esptoolField, Priority.ALWAYS); + Button esptoolBrowse = new Button("Browse..."); + esptoolBrowse.setOnAction(e -> { + FileChooser fc = new FileChooser(); + File f = fc.showOpenDialog(dialog); + if (f != null) esptoolField.setText(f.getAbsolutePath()); + }); + + Label status = new Label(); + Button recheckBtn = new Button("Save & Recheck"); + recheckBtn.setOnAction(e -> { + PrereqChecker.setCustomPaths(pythonField.getText(), esptoolField.getText()); + status.setText("Rechecking..."); + new Thread(() -> { + prereqChecker.checkAll(); + Platform.runLater(() -> { + detectedPython.setText(detectedText("Python", prereqChecker.getPythonCmd())); + detectedPip.setText(detectedText("pip", prereqChecker.getPipCmd())); + detectedEsptool.setText(detectedText("esptool", prereqChecker.getEsptoolCmd())); + status.setText(prereqChecker.isReady() ? "✓ Ready." : "✗ esptool not found."); + status.setStyle(prereqChecker.isReady() ? "-fx-text-fill: green;" : "-fx-text-fill: red;"); + }); + }).start(); + }); + + pane.getChildren().addAll( + heading, detectedBox, + new Separator(), overrideHeading, + new Label("Python:"), new HBox(8, pythonField, pythonBrowse), + new Label("esptool:"), new HBox(8, esptoolField, esptoolBrowse), + status, recheckBtn + ); + return pane; + } + + private VBox buildLogFilePane(Stage dialog) { + VBox pane = new VBox(12); + AppSettings settings = settingsManager.getSettings(); + + Label heading = new Label("Log File"); + heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); + + Label infoBtn = new Label("ℹ"); + infoBtn.setStyle( + "-fx-border-color: #666; -fx-border-radius: 50%; -fx-background-radius: 50%; " + + "-fx-background-color: #2a2a2a; -fx-font-size: 12px; -fx-text-fill: #cccccc; " + + "-fx-min-width: 22px; -fx-min-height: 22px; -fx-max-width: 22px; -fx-max-height: 22px; " + + "-fx-alignment: center; -fx-cursor: hand;"); + infoBtn.setOnMouseClicked(e -> { + Alert info = new Alert(Alert.AlertType.NONE); + info.setTitle("Why use a log file?"); + info.getButtonTypes().add(ButtonType.OK); + + Label title = new Label("MAC Address Provisioning Audit"); + title.setStyle("-fx-font-weight: bold; -fx-font-size: 13px;"); + + Label recordedLabel = new Label("Each flash attempt is recorded with:"); + recordedLabel.setStyle("-fx-font-size: 12px;"); + + VBox bullets = new VBox(4, + bullet("Timestamp"), + bullet("Serial port"), + bullet("Device MAC address"), + bullet("Status (success / failed)") + ); + bullets.setStyle("-fx-padding: 0 0 0 12;"); + + Label useCaseLabel = new Label("Use case — backend provisioning check:"); + useCaseLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 12px; -fx-padding: 8 0 0 0;"); + + Label useCaseBody = new Label( + "Upload flash-log.csv to your backend and cross-reference the MAC " + + "addresses against your provisioned-device registry. This lets you verify " + + "that every flashed device was registered, catch devices that were flashed " + + "but never provisioned, and flag duplicates (same MAC flashed twice)." + ); + useCaseBody.setStyle("-fx-font-size: 12px;"); + useCaseBody.setWrapText(true); + useCaseBody.setMaxWidth(380); + + VBox content = new VBox(8, title, recordedLabel, bullets, useCaseLabel, useCaseBody); + content.setStyle("-fx-padding: 4;"); + + info.getDialogPane().setContent(content); + info.getDialogPane().getStylesheets().addAll(dialog.getScene().getStylesheets()); + info.showAndWait(); + }); + + HBox headingRow = new HBox(8, heading, infoBtn); + headingRow.setAlignment(Pos.CENTER_LEFT); + + CheckBox enableToggle = new CheckBox("Enable log file"); + enableToggle.setSelected(settings.isLogFileEnabled()); + enableToggle.setStyle(settings.isLogFileEnabled() ? "-fx-text-fill: #66bb6a;" : ""); + + Label pathLabel = new Label("Log folder:"); + TextField pathField = new TextField(settings.getLogFilePath()); + pathField.setDisable(!settings.isLogFileEnabled()); + HBox.setHgrow(pathField, Priority.ALWAYS); + + Button browseBtn = new Button("Browse..."); + browseBtn.setDisable(!settings.isLogFileEnabled()); + browseBtn.setOnAction(e -> { + javafx.stage.DirectoryChooser dc = new javafx.stage.DirectoryChooser(); + dc.setTitle("Select Log Folder"); + File dir = dc.showDialog(dialog); + if (dir != null) { + pathField.setText(dir.getAbsolutePath()); + settingsManager.save(); + } + }); + + Label formatInfo = new Label("Format: timestamp, port, MAC address, status"); + formatInfo.setStyle("-fx-text-fill: #888888; -fx-font-size: 11px;"); + + enableToggle.setOnAction(e -> { + boolean enabled = enableToggle.isSelected(); + pathField.setDisable(!enabled); + browseBtn.setDisable(!enabled); + settings.setLogFileEnabled(enabled); + enableToggle.setStyle(enabled ? "-fx-text-fill: #66bb6a;" : ""); + settingsManager.save(); + }); + + // Persist path on focus loss to avoid saving on every keystroke + pathField.textProperty().addListener((obs, old, val) -> settings.setLogFilePath(val)); + pathField.focusedProperty().addListener((obs, was, isFocused) -> { + if (!isFocused) settingsManager.save(); + }); + + pane.getChildren().addAll( + headingRow, + enableToggle, + pathLabel, + new HBox(8, pathField, browseBtn), + formatInfo + ); + return pane; + } + + + // ════════════════════════════════════════════════════════ + // Explore Popular Projects view + // ════════════════════════════════════════════════════════ + + private VBox buildExploreView() { + Label title = new Label("Explore Popular Projects"); + title.getStyleClass().add("explore-title"); + + Label subtitle = new Label("Tap any project to open its GitHub repository."); + subtitle.setStyle("-fx-text-fill: #888888; -fx-font-size: 12px;"); + + Button backBtn = new Button("← Back to Flasher"); + backBtn.getStyleClass().add("link-button"); + backBtn.setOnAction(e -> showView(flasherView)); + + HBox header = new HBox(12, backBtn); + header.setAlignment(Pos.CENTER_LEFT); + + VBox content = new VBox(18); + content.setPadding(new Insets(4, 4, 16, 4)); + content.getChildren().addAll(header, title, subtitle); + + Map> grouped = groupByCategory(); + for (Map.Entry> entry : grouped.entrySet()) { + Label catLabel = new Label(entry.getKey()); + catLabel.getStyleClass().add("explore-category"); + + FlowPane cards = new FlowPane(12, 12); + for (FirmwareDefinition fw : entry.getValue()) { + cards.getChildren().add(buildFirmwareCard(fw)); + } + content.getChildren().addAll(catLabel, cards); + } + + ScrollPane scroll = new ScrollPane(content); + scroll.setFitToWidth(true); + scroll.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + VBox.setVgrow(scroll, Priority.ALWAYS); + + VBox view = new VBox(scroll); + view.getStyleClass().add("explore-root"); + return view; + } + + private Node buildFirmwareCard(FirmwareDefinition fw) { + VBox card = new VBox(8); + card.getStyleClass().add("firmware-card"); + card.setPrefWidth(210); + card.setMinWidth(210); + card.setMaxWidth(210); + + String repo = fw.getGithubRepo(); + String org = repo != null && repo.contains("/") ? repo.split("/")[0] : null; + + ImageView logo = new ImageView(); + logo.setFitWidth(48); + logo.setFitHeight(48); + logo.setPreserveRatio(true); + + String localIcon = localLogoFor(fw.getName()); + try { + if (localIcon != null) { + var url = getClass().getResource(localIcon); + if (url != null) logo.setImage(new Image(url.toExternalForm())); + } else if (org != null) { + logo.setImage(new Image( + "https://github.com/" + org + ".png?size=96", + true + )); + } + } catch (Exception ignored) { + } + + Label name = new Label(fw.getName()); + name.getStyleClass().add("firmware-card-title"); + name.setWrapText(true); + + Label desc = new Label(fw.getDescription()); + desc.getStyleClass().add("firmware-card-desc"); + desc.setWrapText(true); + + HBox header = new HBox(10, logo, name); + header.setAlignment(Pos.CENTER_LEFT); + + card.getChildren().addAll(header, desc); + + card.setOnMouseClicked(e -> { + String url = (fw.getGithubRepo() != null && !fw.getGithubRepo().isBlank()) + ? "https://github.com/" + fw.getGithubRepo() + : fw.getWebsiteUrl(); + if (url != null && !url.isBlank()) { + getHostServices().showDocument(url); + } + }); + card.setStyle("-fx-cursor: hand;"); + + return card; + } + + private String localLogoFor(String firmwareName) { + if (firmwareName == null) return null; + if (firmwareName.startsWith("Tasmota")) return "/icons/firmware/tasmota.png"; + return null; + } + + private Map> groupByCategory() { + Map categoryOf = new LinkedHashMap<>(); + categoryOf.put("Tasmota", "Smart Home & Automation"); + categoryOf.put("Tasmota SML (ottelo9)", "Smart Home & Automation"); + + Map> grouped = new LinkedHashMap<>(); + grouped.put("Smart Home & Automation", new ArrayList<>()); + grouped.put("Other", new ArrayList<>()); + + for (FirmwareDefinition fw : FirmwareCatalog.getCatalog()) { + String cat = categoryOf.getOrDefault(fw.getName(), "Other"); + grouped.get(cat).add(fw); + } + + grouped.values().removeIf(List::isEmpty); + return grouped; + } + + + // ════════════════════════════════════════════════════════ + // Prerequisite checks & esptool install + // ════════════════════════════════════════════════════════ + + private void checkPrerequisites() { + statusLabel.setText("Checking prerequisites..."); + + Thread thread = new Thread(() -> { + prereqChecker.checkAll(); + Platform.runLater(() -> { + if (prereqChecker.isReady()) { + statusLabel.setText("Ready."); + } else { + statusLabel.setText("esptool not found — click here to install."); + statusLabel.setStyle("-fx-text-fill: #2196f3; -fx-underline: true; -fx-cursor: hand;"); + statusLabel.setOnMouseClicked(e -> installEsptool()); + } + }); + }); + thread.setDaemon(true); + thread.start(); + + Platform.runLater(() -> { + if (prereqChecker.isReady()) { + statusLabel.setText("Ready."); + } else if (prereqChecker.getPythonCmd() == null) { + showPythonMissingDialog(); + } else { + autoInstallEsptool(); + } + }); + } + + private void installEsptool() { + statusLabel.setStyle(""); + statusLabel.setOnMouseClicked(null); + if (prereqChecker.getPythonCmd() == null) { + showPythonMissingDialog(); + return; + } + if (prereqChecker.getPipCmd() == null) { + statusLabel.setText("⚠ pip not found — reinstall Python with pip included."); + statusLabel.setStyle("-fx-text-fill: red;"); + return; + } + showEsptoolInstallDialog(); + } + + private void autoInstallEsptool() { + showEsptoolInstallDialog(); + } + + private void showEsptoolInstallDialog() { + Stage dialog = new Stage(); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.setTitle("Installing esptool"); + dialog.setResizable(false); + + Label titleLabel = new Label("Installing esptool via pip..."); + titleLabel.setStyle("-fx-font-weight: bold;"); + + ProgressBar bar = new ProgressBar(); + bar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); + bar.setMaxWidth(Double.MAX_VALUE); + + TextArea output = new TextArea(); + output.setEditable(false); + output.setWrapText(true); + output.setPrefHeight(180); + output.setPrefWidth(440); + + Label statusMsg = new Label("Please wait..."); + + Button closeBtn = new Button("Close"); + closeBtn.setDisable(true); + closeBtn.setOnAction(e -> dialog.close()); + + VBox root = new VBox(10, titleLabel, bar, output, statusMsg, closeBtn); + root.setPadding(new Insets(16)); + root.setAlignment(Pos.CENTER_LEFT); + + dialog.setScene(new Scene(root)); + + Thread thread = new Thread(() -> { + boolean success = prereqChecker.installEsptool(line -> + Platform.runLater(() -> output.appendText(line + "\n")) + ); + + Platform.runLater(() -> { + bar.setProgress(1.0); + if (success) { + logArea.clear(); + statusLabel.setText("Ready."); + statusLabel.setStyle(""); + flashButton.setDisable(false); + factoryButton.setDisable(false); + dialog.close(); + } else { + statusMsg.setText("✗ Install failed. Check output above."); + statusMsg.setStyle("-fx-text-fill: red;"); + statusLabel.setText("⚠ esptool install failed."); + closeBtn.setDisable(false); + } + }); + }); + thread.setDaemon(true); + thread.start(); + + dialog.show(); + } + + private void showPythonMissingDialog() { + flashButton.setDisable(true); + factoryButton.setDisable(true); + + Stage dialog = new Stage(); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.setTitle("Python Not Found"); + dialog.setResizable(false); + + Label heading = new Label("Python 3 is required to run esptool."); + heading.setStyle("-fx-font-weight: bold; -fx-font-size: 13px;"); + + String os = System.getProperty("os.name", "").toLowerCase(); + String steps; + if (os.contains("win")) { + steps = """ + 1. Go to https://python.org/downloads + 2. Download the latest Python 3 installer for Windows. + 3. Run the installer — check "Add Python to PATH" before clicking Install. + 4. Restart ESP Flasher after installation."""; + } else if (os.contains("mac")) { + steps = """ + Option A — Homebrew (recommended): + brew install python3 + + Option B — Installer: + 1. Go to https://python.org/downloads + 2. Download and run the macOS installer. + 3. Restart ESP Flasher after installation."""; + } else { + steps = """ + Debian / Ubuntu: + sudo apt install python3 python3-pip + + Fedora / RHEL: + sudo dnf install python3 + + Arch: + sudo pacman -S python + + Restart ESP Flasher after installation."""; + } + + TextArea guide = new TextArea(steps); + guide.setEditable(false); + guide.setWrapText(true); + guide.setPrefHeight(140); + guide.setPrefWidth(400); + guide.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;"); + + Button downloadBtn = new Button("Open python.org"); + downloadBtn.setOnAction(e -> getHostServices().showDocument("https://python.org/downloads")); + + Button closeBtn = new Button("Close"); + closeBtn.setOnAction(e -> dialog.close()); + + HBox buttons = new HBox(10, downloadBtn, closeBtn); + buttons.setAlignment(Pos.CENTER_RIGHT); + + VBox root = new VBox(12, heading, guide, buttons); + root.setPadding(new Insets(16)); + + dialog.setScene(new Scene(root)); + dialog.showAndWait(); + + statusLabel.setText("⚠ Python not found — install Python and restart."); + statusLabel.setStyle("-fx-text-fill: red;"); + } + + + // ════════════════════════════════════════════════════════ + // About dialog + // ════════════════════════════════════════════════════════ + + private void showAboutDialog() { + Stage dialog = new Stage(); + dialog.setTitle("About"); + dialog.setResizable(false); + + VBox content = new VBox(12); + content.setPadding(new Insets(24)); + content.setAlignment(Pos.CENTER); + content.getStyleClass().add("about-dialog"); + + var iconUrl = getClass().getResource("/icons/icon.png"); + if (iconUrl != null) { + javafx.scene.image.ImageView logo = new javafx.scene.image.ImageView( + new javafx.scene.image.Image(iconUrl.toExternalForm())); + logo.setFitWidth(72); + logo.setFitHeight(72); + logo.setPreserveRatio(true); + content.getChildren().add(logo); + } + + Label title = new Label("ESP Flasher"); + title.getStyleClass().add("about-title"); + + String appVersion = new UpdateService().currentVersion(); + Label version = new Label("v" + appVersion); + version.getStyleClass().add("about-version"); + + Separator sep = new Separator(); + sep.setStyle("-fx-background-color: #3a3a3c;"); + + Label author = new Label("Built by Ajinkya Gokhale"); + author.getStyleClass().add("about-author"); + + Label email = new Label("✉ hi@ajinkyagokhale.com"); + email.getStyleClass().add("about-link"); + email.setOnMouseClicked(e -> getHostServices().showDocument("mailto:hi@ajinkyagokhale.com")); + + Label github = new Label("⚡ github.com/ajinkyagokhale"); + github.getStyleClass().add("about-link"); + github.setOnMouseClicked(e -> getHostServices().showDocument("https://github.com/ajinkyagokhale")); + + Label license = new Label("MIT License — 2026"); + license.getStyleClass().add("about-license"); + + Button closeBtn = new Button("Close"); + closeBtn.setOnAction(e -> dialog.close()); + closeBtn.setPrefWidth(100); + + content.getChildren().addAll( + title, version, sep, + author, email, github, + license, closeBtn + ); + + Scene scene = new Scene(content, 280, 360); + dialog.setScene(scene); + dialog.show(); + } + + + // ════════════════════════════════════════════════════════ + // Auto-update flow + // ════════════════════════════════════════════════════════ + + private void checkForUpdates() { + Thread worker = new Thread(() -> { + UpdateService updates = new UpdateService(); + String current = updates.currentVersion(); + updates.latestRelease() + .filter(release -> updates.isNewer(release.version(), current)) + .ifPresent(release -> + Platform.runLater(() -> promptForcedUpdate(updates, release, current))); + }, "update-check"); + worker.setDaemon(true); + worker.start(); + } + + @SuppressFBWarnings("DM_EXIT") + private void promptForcedUpdate(UpdateService updates, UpdateService.Release release, String current) { + ButtonType updateNow = new ButtonType("Update Now", ButtonBar.ButtonData.OK_DONE); + ButtonType quit = new ButtonType("Quit", ButtonBar.ButtonData.CANCEL_CLOSE); + + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Update Required"); + alert.setHeaderText("A new version of ESP Flasher is available"); + alert.setContentText("Installed: " + current + "\nLatest: " + release.version() + + "\n\nYou must update to continue."); + alert.getButtonTypes().setAll(updateNow, quit); + alert.initModality(Modality.APPLICATION_MODAL); + + Optional choice = alert.showAndWait(); + while (choice.isEmpty()) { + choice = alert.showAndWait(); + } + + if (choice.get() == updateNow) { + downloadUpdate(updates, release); + } else { + System.exit(0); + } + } + + private void downloadUpdate(UpdateService updates, UpdateService.Release release) { + AtomicBoolean cancelled = new AtomicBoolean(false); + + ProgressBar bar = new ProgressBar(0); + bar.setPrefWidth(380); + Label info = new Label("Starting download..."); + Label hint = new Label("The app will restart to install the update."); + hint.getStyleClass().add("footer"); + VBox box = new VBox(10, info, bar, hint); + box.setPadding(new Insets(16)); + + ButtonType cancelType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + Alert dialog = new Alert(Alert.AlertType.NONE); + dialog.setTitle("Downloading Update"); + dialog.setHeaderText("Updating to " + release.version()); + dialog.getDialogPane().setContent(box); + dialog.getButtonTypes().setAll(cancelType); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.setOnHidden(e -> cancelled.set(true)); + + Thread worker = new Thread(() -> { + try { + updates.downloadAndLaunch(release, + (downloaded, total) -> Platform.runLater( + () -> updateProgress(bar, info, downloaded, total)), + cancelled); + Platform.runLater(() -> { + dialog.close(); + promptForcedUpdate(updates, release, updates.currentVersion()); + }); + } catch (Exception e) { + Platform.runLater(() -> { + dialog.close(); + Alert error = new Alert(Alert.AlertType.ERROR); + error.setTitle("Update Failed"); + error.setHeaderText("Could not download the update"); + error.setContentText(e.getMessage()); + error.showAndWait(); + promptForcedUpdate(updates, release, updates.currentVersion()); + }); + } + }, "update-download"); + worker.setDaemon(true); + worker.start(); + + dialog.show(); + } + + private void updateProgress(ProgressBar bar, Label info, long downloaded, long total) { + double mb = downloaded / 1048576.0; + if (total > 0) { + double fraction = (double) downloaded / total; + bar.setProgress(fraction); + info.setText(String.format("%.1f MB / %.1f MB (%d%%)", + mb, total / 1048576.0, (int) (fraction * 100))); + } else { + bar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); + info.setText(String.format("%.1f MB downloaded", mb)); + } + } + + + // ════════════════════════════════════════════════════════ + // Helpers + // ════════════════════════════════════════════════════════ + + private void browseBin(Stage stage) { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Select Firmware"); + fileChooser.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Binary files (*.bin)", "*.bin") + ); + File file = fileChooser.showOpenDialog(stage); + if (file != null) { + binPathField.setText(file.getAbsolutePath()); + } + } + + private void updateChipListForFirmware(FirmwareDefinition def) { + String previous = chipCombo.getValue(); + chipCombo.getItems().clear(); + + if (def == null) { + chipCombo.getItems().addAll( + "auto", "esp32c6", "esp32", "esp32s2", + "esp32s3", "esp32c3", "esp32h2", "esp8266" + ); + } else { + for (String chip : def.getChipBinMap().keySet()) { + if (!"default".equals(chip)) chipCombo.getItems().add(chip); + } + } + + if (previous != null && chipCombo.getItems().contains(previous)) { + chipCombo.setValue(previous); + } else if (!chipCombo.getItems().isEmpty()) { + chipCombo.getSelectionModel().selectFirst(); + } + } + + private void refreshPorts() { + portCombo.getItems().clear(); + List espPorts = PortWatcher.listEsp32Ports(); + + if (espPorts.isEmpty()) { + portCombo.setPromptText("No ESP32 detected..."); + } else { + portCombo.getItems().addAll(espPorts); + portCombo.getSelectionModel().selectFirst(); + } + } + + private Label bullet(String text) { + Label l = new Label("• " + text); + l.setStyle("-fx-font-size: 12px;"); + return l; + } + + private Label detectedPathLabel(String name, String value) { + Label l = new Label(detectedText(name, value)); + l.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;"); + return l; + } + + private String detectedText(String name, String value) { + if (value == null || value.isBlank()) + return "✗ " + name + ": not found"; + return "✓ " + name + ": " + value; + } + + private boolean isDarkMode() { + String os = System.getProperty("os.name").toLowerCase(); + try { + if (os.contains("mac")) { + Process p = Runtime.getRuntime().exec( + new String[]{"defaults", "read", "-g", "AppleInterfaceStyle"} + ); + String result = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); + return result.equalsIgnoreCase("dark"); + } else if (os.contains("windows")) { + Process p = Runtime.getRuntime().exec(new String[]{ + "reg", "query", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "/v", "AppsUseLightTheme" + }); + String result = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + return result.contains("0x0"); + } + } catch (Exception e) { + return false; + } + return false; + } + + @SuppressFBWarnings("DE_MIGHT_IGNORE") + private void applyDarkTitleBar(Stage stage) { + if (!System.getProperty("os.name", "").toLowerCase().contains("win")) return; + try { + com.sun.jna.platform.win32.WinDef.HWND hwnd = + com.sun.jna.platform.win32.User32.INSTANCE.FindWindow(null, stage.getTitle()); + if (hwnd == null) return; + com.sun.jna.ptr.IntByReference dark = new com.sun.jna.ptr.IntByReference(1); + Dwmapi.INSTANCE.DwmSetWindowAttribute(hwnd, 20, dark, 4); // Windows 10 20H1+ + Dwmapi.INSTANCE.DwmSetWindowAttribute(hwnd, 19, dark, 4); // older Win10 builds + } catch (Exception ignored) { + } + } + + private interface Dwmapi extends com.sun.jna.Library { + Dwmapi INSTANCE = com.sun.jna.Native.load("dwmapi", Dwmapi.class); + + void DwmSetWindowAttribute(com.sun.jna.platform.win32.WinDef.HWND hwnd, int attr, + com.sun.jna.ptr.IntByReference value, int size); + } +} diff --git a/src/main/resources/icons/firmware/tasmota.png b/src/main/resources/icons/firmware/tasmota.png new file mode 100644 index 0000000000000000000000000000000000000000..36dba2e8eb8287ef1c15df7eaf0d381e8a1bc2ff GIT binary patch literal 5450 zcmZ{oc{G&o`^TRlLySGUEZLWs#n`iFPqKtkmcbzV3?XYILiV-6h_WUsTNDz*SW8g| zVQ7

Z1|yd**w-e|&zwbAEq3_j6zG>vg@a>po}Bob%in_I4JW?4s-d0B~AanmGUf z1l&RZwjA1EZs1mTouzqPxR*Ai8ZXx}eC4w>op0b(4t) z2CyGVDUt*LzOPnh#!k`mTNN>-LPKRFLlk_lf1E9aU7IJI*9mswdWLY7|M*W&_xQ@@ z828f!mrnD9xww0~%DO$4=o00Z^*eRl4=ur)M@0DR-(_Mtn$Eh!%C1Np(GkZXHbW1E zH+g;h>Kj`Unx6I;{0;qQ#`&MWQIQp)ro~ELP?S<+MRtp(9S|Td3 zmS8ZB=rHnUA@H9$cn%b@a>xpZ(M(&7OxolOk8sHH++hSST2C^9bnW$nBy3Qsnj!|#PiYuEHq;Cpn#5$xMDEjJ$x0Eq|EcBz$spuIWVRWw#)~o z@GPqx1M!9|QNY7-cL{jY{lkov_fj5Z0wQOL? zmraylIp$#I^3=ghnq@u1U|Edgok|qKvCLX4MGrT=rV!`%&_1~EQF_QPqSmudr$z1+ zI+#FM@KDhJVzRPwgAeP)cQ5>+98FJ7AT}BZ7j>rU53ZCBz#4;{vya~$IaG8RK9MLP zSNN(5qkZe(R~;b)U(HdW*HxFN`=#Dj<*O^nBCbEM!|}9Q+I|tL;OD4T*WV*Bg<*{@VCW*K3Ti0 zL4QDZP`v%>+egU?yCTaJo|>y2ryM*XhEEe>@S8p@UPl-FpD6!*uCp`{5wflk>w1N= z_cK@d$H4`U5h;19_ySDD!EjTcw?9Y3wz_Cvp-Ui4JrS|&C3vM?C@*2VDY<=dLXnHy z!s0&WF&v{mtQr3K_w%S9Lgu(u0S*m?F{Y5dzrI-;4~z9;@4KKmy3$QgT$DN&4=D*v zL~KUOBz$!q;~-cBm~qH%n6B6zFxg?adI%g#v(BS61iO-SLCgM|{g>7ZlkV&;c26I_ zbdk=PastN7|3ffEAm& zXmSKUy3&tkhA0#vEvYMn`0@_|qlkQxr1c;vv$Idn5Zi;>4RY7(PL@a${NZi3vbwcB zp&D3WgZ(-`c&bqBLVt`3s2S#yyxrek4M!uYjMD_e=GnV)4_zxCs~Isbyl-*Mn{aA9 zmBzBc0<$V-aUU_vk~@E6dE6lctFM5%p*wW#J|f?yJ6kU3R%=bz;nz@@R%5~Qadbz2 zG#^*?MLztp_Ql(n$=Tn**}!_nVZ_xXsB#NKA__8Jw9-UM-rHh9E8vsB$V#N7#g z_WMx(>h$V;LeaMj0!a?0*^y-L0YdR*l5O5s+{XK;?NOb<7AU~TAYNoD^Nrc~XGihx zkHNz%7wpNL*_~}GY!jdVh`K9AykXjvE|(X}GBK{XXd>?k5uyo&<5Im`P}@Hwf9b3^ z28jSfEtelrsRCqZ2twCV=$@YIz1PR_h_5A6W>I1;6wQN@nj%5>AW?uO{L;2Vc(do0 zRT?(Sn{XWecqvi7O0>@+Q`<2{sPYmsaEx|JP-N;@(0b6Is7emj{*(EEac_`ovcO_3 zdN5=eIO)CU8K?OXJxDcDO)=4;UEPEXlCKru&{tQ6F@WN__!3-oXfW+->TFL+$okZ# zbxnArOrl@?&de$j=nvNq`K45kX`}{VGCvr*h&?;|kza;&**r9*ZYTiwH1xoH;rtw~ zG5oLF7p|xCpW+qIis<85XA}{!mwTc98Tk%g_II(_-xdRtN5n|hU9%bcyS6BQ4K36F z-9Bm&n#82$TM$(y+NEnUouMdm{fbhuhsgKNoLKUv*=(R8j$bIg?a^i(gvf8Wj@pWN zuvW{44mgmm_x3Z;v1O8?lnN<5aXjim<1YUlR5H-YF&m!=?nRZ95+|l(r^jZcj@1$n2CE;u?M8E&}U-IM!YlVISw)U zRQlALGST@^q~%*C&2`ECI~Sa1#w8UqRWq6)MAqK{n&fTa?_vjhZ;h*|?!=0B28M4J zFSHjz1drrt0^$SS4iyi$zjarinIuYGnXX$U<-4`J@{k8@ph(RJ*J@1^#>G0n5;559%kX?3T^sLE| z$!FodNtG9xDtqV_Hd7yZ1nd*`E(Qd%1VQoJ&UrmJaUt9npjXb`Z|hNN{k1sz z+z37MPswy^cT?q6Gb4X`XWoclw9ac2vA z{`{?Q9A>vGgM4O(w#oVt$EmqUfX& zGydZO`RlH;{tJl)cAlEs-QUjJE@U`9xSG6ROa|gLqMkh)MpnCzJ|egj%_I!M8%s4E zpxT#XwBL{OJF!u$i;wF!^wv<}cDbX4g%ESa95XFiuYKiuOl9^hr5=QdybThn_)2lo zrny+@Df4V#+@kqN){-x5I`g5*{U9Gm`-zwcYVsV`LQKq<$#={$Jg zF%y-Z3b8Ki)N`o|gZ^mhH81ie8>iI~+jHSY4K#z!tzMN}$vyu>hbm6P`_t$g-ymWdFdmA_8|#;$NhYz% z_nZB2-@2*E56H{|DQi_nbNwMs+*gBbsLK6I-_A*}NgUHr9ERkg`YA1g>H1!Dvj*o$qvy1%&DJpu8t*lfe?!<`dxA{f-!Diw z2QXdROFrD7}#;xtx<{U_)aDTN9B`jV$~c6dr=MGd~XntI2j*pTuqaYNp5JPb50ZEUO%jp%v1HQ zGmD9Tasr&cf@aXy!l&n{Pt1u>2vdm+$C`T|BL)I_r@|zN&n+UZh#Ejz-NJA8W$v8y z$sYxVPUY)la3cAj_RCCYMdlCvCKL>^o$unhEUfi%Ox@H949{#+#7%zNYH;w)QrI(- z##K5fciTk!epSS`p^EuLNtDqGps4wrt=lk_X*YZfP6;5Q`A9ypF#93pp+XiyDUGE% z&Rp>~&TP_Z)akb}#do4W)q?GJc1lsrJ@jL1@5IzB$dYe~H#r%`JDR6*+gXD7+s5(# z{7SQFXZ^1IIG!ioy{bWx=r=F(bVEYl4cs9IoOP*GIxFED=c2Q95&4SG7iLegrfk2r zZ(xhz5^h0|-IVy%TpFjInY^F+prdzBFNLOKqvJHpbvd702x}@_^X@Mbmo8LsyFE=m zIvGfT^YP{(jabHtW+Vdsh8ib?-5>5tE>W4PG&dsyI)X~uurK5OTms}0@ta}KX_cfv zL92@V{5w~#Ib&RLC$Q-hn_K7N9CfxXT+SmG6Cj$8U3G$aF%Fu-sufGuG9Z5&U70P?MgkK!VmQk;w75Z%W zaJJsUq8%hB1VlOO9v(wv42XU{B=6_hij4`jh9#dH-mgimLb}Vuqq(hb zjukH_|2|>!gu~=ie_N@#cFI1<~J(Zty?Wq!S?GT%S0gg+|=Q#F?t60AC&4gPuq;nicm=V0XD^9Z_&I_^f4$ZsGbl7w@HH|`dT5EMx z9>pTj=FTodsuM(qa1o+m=i`NXSi9}$FCvvkd>*syTL*pu<7x?`_}{4_HZKyVanv6f!#N*Q6>-YS?bUv-H^== zdJRUeu2PTQZu^T{U>^az2}51IzsSV7fBVjfC-_Mup~4n&6( zveW%T8_JZ-FM>$tV+Q# z&hioDLc+rX;O8!MGf2s20B{>3Eq25jaJ zelbeIXC8H{a#psNMsTCyqtFrU0YF4SSZeJEvk$>DDT^PL8-5*vwEJ)Jko%qH!$wE0 zkX7&|Ob&nA74PGP_tnKj_<}D02}hz%!jUHtnobC$E*!3lfUAHj99~goZ(URYo73BX)Y3yr|`P93iRSz!k2 zawKUNh+S&-X z_*Z>75aIuqk7GbANFd>wNE4F;13U0$6R`YO+S$Ph@Cyj?1&^<$9vK!C>g9t|M{BBE z+Mpb~9ntD29Ryk(?}ZBv#d~=Kg