import os, threading, tkinter as tk from tkinter import ttk, messagebox,Toplevel from config import ( WINDOW_GEOM, COM_PORT, BAUD_RATE, HEADLESS_CONTROLLER, PARTDB_BASE, PARTDB_TOKEN, ) from apis.partdb_api import PartDB from parsers.digikey_mh10 import parse_digikey from devices.scanner_serial import scan_loop # continuous loop (background) from provider.selenium_flow import update_part_from_providers_once from apis.digikey_api import suggest_category_from_digikey # ------------ Pages ------------ class HomePage(ttk.Frame): """Home page with tools and settings.""" def __init__(self, master, app): super().__init__(master) self.app = app ttk.Label(self, text="Part-DB Helper", font=("Segoe UI", 16, "bold")).pack(pady=(18,8)) # Settings section settings_frame = ttk.LabelFrame(self, text="Settings", padding=10) settings_frame.pack(fill="x", padx=20, pady=(0,10)) # Mode toggle (Live/Dry Run) mode_frame = ttk.Frame(settings_frame) mode_frame.pack(fill="x", pady=2) ttk.Label(mode_frame, text="Standardization Mode:", width=20).pack(side="left") self.mode_var = tk.StringVar(value="live") ttk.Radiobutton(mode_frame, text="Live (Make Changes)", variable=self.mode_var, value="live").pack(side="left", padx=5) ttk.Radiobutton(mode_frame, text="Dry Run (Preview Only)", variable=self.mode_var, value="dry").pack(side="left", padx=5) # Provider update toggle provider_frame = ttk.Frame(settings_frame) provider_frame.pack(fill="x", pady=2) ttk.Label(provider_frame, text="Provider Updates:", width=20).pack(side="left") self.provider_var = tk.BooleanVar(value=True) ttk.Checkbutton(provider_frame, text="Update from information providers (Digi-Key, etc.)", variable=self.provider_var).pack(side="left", padx=5) # Tools section ttk.Separator(self, orient="horizontal").pack(fill="x", pady=10, padx=20) ttk.Label(self, text="Tools", font=("Segoe UI", 14, "bold")).pack(pady=(0,10)) tools_frame = ttk.Frame(self) tools_frame.pack(pady=(0,10)) ttk.Button(tools_frame, text="Scanner", command=self.goto_scanner, width=25).pack(pady=4) ttk.Button(tools_frame, text="Accept Import Jobs", command=self.run_accept_jobs, width=25).pack(pady=4) ttk.Button(tools_frame, text="Run Bulk Add", command=self.run_bulk_add, width=25).pack(pady=4) ttk.Button(tools_frame, text="Import from CSV Files", command=self.run_import_csv, width=25).pack(pady=4) ttk.Button(tools_frame, text="Update Components", command=self.run_standardize_components, width=25).pack(pady=4) def goto_scanner(self): """Navigate to scanner page.""" self.app.goto_scanner() def run_accept_jobs(self): """Launch the accept import jobs automation in a new thread.""" from workflows.accept_import_jobs import run_accept_import_jobs def work(): run_accept_import_jobs(auto_close=True) t = threading.Thread(target=work, daemon=True) t.start() messagebox.showinfo("Started", "Import job acceptance automation started in browser.\nCheck the browser window for progress.") def run_bulk_add(self): """Launch the bulk add workflow.""" from workflows.bulk_add import run_bulk_add def work(): run_bulk_add() t = threading.Thread(target=work, daemon=True) t.start() messagebox.showinfo("Started", "Bulk add workflow started in browser.\nCheck the browser window for progress.") def run_import_csv(self): """Launch the CSV import workflow.""" update_providers = self.provider_var.get() from workflows.import_from_csv import run_import_from_csv from ui.progress_dialog import ProgressDialog # Create progress dialog progress = ProgressDialog(self.app, "Importing from CSV") def progress_callback(current, total, status): progress.update(current, total, status) return progress.is_cancelled() def work(): try: run_import_from_csv(update_providers=update_providers, progress_callback=progress_callback) finally: progress.close() # Show completion message self.app.after(0, lambda: messagebox.showinfo("Import Complete", "CSV import finished!\nCheck console for details.")) t = threading.Thread(target=work, daemon=True) t.start() def run_standardize_components(self): """Launch the component standardization workflow.""" dry_run = (self.mode_var.get() == "dry") from workflows.standardize_components import run_standardize_components from ui.progress_dialog import ProgressDialog # Create progress dialog - self.app is the Tk root progress = ProgressDialog(self.app, "Standardizing Components") def progress_callback(current, total, status): progress.update(current, total, status) return progress.is_cancelled() def work(): try: run_standardize_components(dry_run=dry_run, progress_callback=progress_callback) finally: progress.close() t = threading.Thread(target=work, daemon=True) t.start() def run_standardize_passives(self): """Launch the passive components standardization workflow.""" dry_run = (self.mode_var.get() == "dry") from workflows.standardize_passives import run_standardize_passives from ui.progress_dialog import ProgressDialog # Create progress dialog - self.app is the Tk root progress = ProgressDialog(self.app, "Standardizing Passives") def progress_callback(current, total, status): progress.update(current, total, status) return progress.is_cancelled() def work(): try: run_standardize_passives(category_name="Passives", dry_run=dry_run, progress_callback=progress_callback) finally: progress.close() t = threading.Thread(target=work, daemon=True) t.start() def run_standardize_asdmb(self): """Launch the ASDMB crystal standardization workflow.""" dry_run = (self.mode_var.get() == "dry") provider_update = self.provider_var.get() and not dry_run from workflows.standardize_asdmb import run_standardize_asdmb from ui.progress_dialog import ProgressDialog # Create progress dialog - self.app is the Tk root progress = ProgressDialog(self.app, "Standardizing ASDMB Crystals") def progress_callback(current, total, status): progress.update(current, total, status) return progress.is_cancelled() def work(): try: run_standardize_asdmb(category_name="Clock - ASDMB", dry_run=dry_run, update_providers=provider_update, progress_callback=progress_callback) finally: progress.close() t = threading.Thread(target=work, daemon=True) t.start() class ScannerPage(ttk.Frame): """Scanner page for scanning Digi-Key labels.""" def __init__(self, master, app): super().__init__(master) self.app = app # Back button ttk.Button(self, text="← Back to Home", command=self.app.goto_home).pack(anchor="w", padx=10, pady=10) ttk.Label(self, text="Scan a Digi-Key code", font=("Segoe UI", 16, "bold")).pack(pady=(18,8)) ttk.Label(self, text=f"Listening on {COM_PORT} @ {BAUD_RATE}").pack(pady=(0,12)) # Last scan preview wrap = ttk.Frame(self); wrap.pack(fill="x", padx=10, pady=(0,10)) ttk.Label(wrap, text="Last scan:").pack(side="left") self.last_var = tk.StringVar() ttk.Entry(wrap, textvariable=self.last_var, state="readonly").pack(side="left", fill="x", expand=True, padx=(8,0)) ttk.Label(self, text="(This page listens continuously. Just scan another label.)", foreground="#666").pack() def on_scan(self, raw: str): self.last_var.set(raw) fields = parse_digikey(raw) mpn = (fields.get("MfrPart") or "").strip() if not mpn: messagebox.showwarning("No MPN", "Could not read MPN from the scan.") return # Lookup in Part-DB pid = self.app.pdb.find_part_exact( dkpn=fields.get("DigiKeyPart"), mpn=fields.get("MfrPart"), ) if pid: # Show details page summary = self.app.pdb.summarize_part(pid) self.app.goto_view(summary) else: # No part -> prepare creation self.app.goto_create(fields, raw) class ViewPage(ttk.Frame): """Shows existing part summary + button to force Digi-Key provider update.""" def __init__(self, master, app): super().__init__(master) self.app = app self.summary = {} ttk.Label(self, text="Part in Part-DB", font=("Segoe UI", 16, "bold")).grid(row=0, column=0, columnspan=4, sticky="w", pady=(16,10)) self.grid_rowconfigure(99, weight=1) self.grid_columnconfigure(1, weight=1) self._add_row("ID:", "id", 1) self._add_row("Name:", "name", 2) self._add_row("MPN:", "mpn", 3) self._add_row("Category:", "category", 4) self._add_row("Footprint:", "footprint", 5) self._add_row("EDA value:", "eda_value", 6) self._add_row("Stock:", "stock", 7) self._add_row("Locations:", "locations", 8, join_list=True) ttk.Label(self, text="Description:").grid(row=9, column=0, sticky="ne", padx=8, pady=6) self.desc = tk.Text(self, height=6, wrap="word") self.desc.grid(row=9, column=1, columnspan=3, sticky="nsew", padx=8, pady=6) self.desc.configure(state="disabled") btns = ttk.Frame(self); btns.grid(row=10, column=0, columnspan=4, sticky="e", pady=(6,12)) ttk.Button(btns, text="Force Digi-Key update", command=self.do_update).pack(side="left", padx=(0,6)) ttk.Button(btns, text="Back to scan", command=self.app.goto_home).pack(side="left") def _add_row(self, label, key, row, join_list=False): ttk.Label(self, text=label).grid(row=row, column=0, sticky="e", padx=8, pady=4) var = tk.StringVar() ent = ttk.Entry(self, textvariable=var, state="readonly") ent.grid(row=row, column=1, columnspan=3, sticky="we", padx=8, pady=4) setattr(self, f"var_{key}", var) setattr(self, f"is_list_{key}", join_list) def set_summary(self, summary: dict): self.summary = summary for key in ("id","name","mpn","category","footprint","eda_value","stock","locations"): var = getattr(self, f"var_{key}") is_list = getattr(self, f"is_list_{key}", False) val = summary.get(key, "") if is_list and isinstance(val, list): val = ", ".join(val) var.set("" if val is None else str(val)) self.desc.configure(state="normal") self.desc.delete("1.0", "end") self.desc.insert("1.0", summary.get("description","") or "") self.desc.configure(state="disabled") def do_update(self): pid = self.summary.get("id") if not pid: messagebox.showwarning("No ID", "Missing part id.") return # prevent new scans while updating self.app.busy = True dlg = BusyDialog(self.app, "Updating from Digi-Key…") def work(): return update_part_from_providers_once(pid, headless=HEADLESS_CONTROLLER) def done(res): try: dlg.destroy() except Exception: pass self.app.busy = False if not res: messagebox.showwarning("Update failed", "Unknown error.") return ok, where = res if ok: summary = self.app.pdb.summarize_part(pid) self.set_summary(summary) messagebox.showinfo("Updated", "Provider update completed.") else: messagebox.showwarning("Update failed", f"Provider update failed at: {where}") run_in_thread(work, lambda r: self.after(0, done, r)) class CreatePage(ttk.Frame): """Create a new part; auto-suggest category; then run provider update.""" def __init__(self, master, app): super().__init__(master) self.app = app self.fields = {} self.cat_map = {} ttk.Label(self, text="Create Part", font=("Segoe UI", 16, "bold")).grid(row=0, column=0, columnspan=4, sticky="w", pady=(16,10)) self.grid_columnconfigure(1, weight=1) ttk.Label(self, text="DKPN:").grid(row=1, column=0, sticky="e", padx=8, pady=4) self.dkpn = tk.StringVar() ttk.Entry(self, textvariable=self.dkpn, state="readonly").grid(row=1, column=1, columnspan=3, sticky="we", padx=8, pady=4) ttk.Label(self, text="MPN:").grid(row=2, column=0, sticky="e", padx=8, pady=4) self.mpn = tk.StringVar() ttk.Entry(self, textvariable=self.mpn).grid(row=2, column=1, columnspan=3, sticky="we", padx=8, pady=4) ttk.Label(self, text="Category:").grid(row=3, column=0, sticky="e", padx=8, pady=4) self.cat = tk.StringVar() self.cat_combo = ttk.Combobox(self, textvariable=self.cat, state="readonly", width=40) self.cat_combo.grid(row=3, column=1, sticky="w", padx=8, pady=4) ttk.Button(self, text="Create new…", command=self.create_new_category).grid(row=3, column=2, sticky="w", padx=4, pady=4) btns = ttk.Frame(self); btns.grid(row=4, column=0, columnspan=4, sticky="e", pady=(8,12)) ttk.Button(btns, text="Back", command=self.app.goto_home).pack(side="left", padx=(0,6)) ttk.Button(btns, text="Create + Update", command=self.create_part).pack(side="left") def set_fields(self, fields: dict, raw: str): self.fields = dict(fields) self.dkpn.set(fields.get("DigiKeyPart","")) self.mpn.set(fields.get("MfrPart","") or fields.get("DigiKeyPart","")) # load categories cats = self.app.pdb.list_categories() names = [] self.cat_map = {} for c in sorted(cats, key=lambda x: (x.get("name") or "").lower()): nm = (c.get("name") or "").strip() cid = self.app.pdb._extract_id(c) if nm and cid: names.append(nm); self.cat_map[nm] = cid self.cat_combo["values"] = names # suggest category suggestion = suggest_category_from_digikey(self.mpn.get() or self.dkpn.get()) if not suggestion: s = self.mpn.get().lower() if any(x in s for x in ("res", "rc0603", "rc0805", "ohm")): suggestion = "Resistor" elif any(x in s for x in ("cap", "x7r", "x5r", "uf", "nf", "pf")): suggestion = "Capacitor" # select if exists for nm in names: if suggestion and nm.lower() == suggestion.lower(): self.cat.set(nm) break if not self.cat.get() and suggestion: # leave suggestion as text, user can “Create new…” self.cat.set(suggestion) def create_new_category(self): name = tk.simpledialog.askstring("New category", "Category name:") if not name: return cid = self.app.pdb.create_category_if_missing(name.strip()) # refresh & select cats = self.app.pdb.list_categories() names = [] self.cat_map = {} for c in sorted(cats, key=lambda x: (x.get("name") or "").lower()): nm = (c.get("name") or "").strip() c_id = self.app.pdb._extract_id(c) if nm and c_id: names.append(nm); self.cat_map[nm] = c_id self.cat_combo["values"] = names for nm in names: if nm.lower() == name.strip().lower(): self.cat.set(nm); break messagebox.showinfo("OK", f"Category created (id={cid}).") def create_part(self): mpn = (self.mpn.get() or "").strip() if not mpn: messagebox.showwarning("Missing MPN", "Please enter a manufacturer part number.") return # category id nm = (self.cat.get() or "").strip() if nm in self.cat_map: cat_id = self.cat_map[nm] else: cat_id = self.app.pdb.create_category_if_missing(nm or "Uncategorized") manu_id = self.app.pdb.ensure_manufacturer("Unknown") pid = self.app.pdb.create_part( name=mpn, category_id=cat_id, manufacturer_id=manu_id, mpn=mpn, description="", product_url=None, footprint_id=None, ) self.app.busy = True dlg = BusyDialog(self.app, "Creating part and updating from Digi-Key…") def work(): return update_part_from_providers_once(pid, headless=HEADLESS_CONTROLLER ) def done(res): try: dlg.destroy() except Exception: pass self.app.busy = False ok, where = (res or (False, "unknown")) if not ok: messagebox.showwarning("Created, but update failed", f"Part ID {pid} created.\nProvider update failed at: {where}") else: messagebox.showinfo("Success", f"Part created and updated (id={pid}).") summary = self.app.pdb.summarize_part(pid) self.app.goto_view(summary) run_in_thread(work, lambda r: self.after(0, done, r)) # ------------ App ------------ class App(tk.Tk): def __init__(self): self.busy = False super().__init__() self.title("Part-DB Helper") self.geometry(WINDOW_GEOM) self.pdb = PartDB(PARTDB_BASE, PARTDB_TOKEN) self.home = HomePage(self, self) self.scanner = ScannerPage(self, self) self.view = ViewPage(self, self) self.create = CreatePage(self, self) for f in (self.home, self.scanner, self.view, self.create): f.grid(row=0, column=0, sticky="nsew") # start listening thread once t = threading.Thread(target=scan_loop, args=(COM_PORT, BAUD_RATE, self.on_scan), daemon=True) t.start() self.goto_home() def on_scan(self, flat: str): if self.busy: return # ignore scans during provider update self.scanner.on_scan(flat) def goto_home(self): self.home.tkraise() def goto_scanner(self): self.scanner.tkraise() def goto_view(self, summary: dict): self.view.set_summary(summary) self.view.tkraise() def goto_create(self, fields: dict, raw: str): self.create.set_fields(fields, raw) self.create.tkraise() class BusyDialog(Toplevel): def __init__(self, parent, text="Working…"): super().__init__(parent) self.title("") self.resizable(False, False) self.transient(parent) self.grab_set() # modal ttk.Label(self, text=text).pack(padx=16, pady=(14, 6)) pb = ttk.Progressbar(self, mode="indeterminate", length=220) pb.pack(padx=16, pady=(0,12)) pb.start(12) # center self.update_idletasks() x = parent.winfo_rootx() + (parent.winfo_width() - self.winfo_width()) // 2 y = parent.winfo_rooty() + (parent.winfo_height() - self.winfo_height()) // 2 self.geometry(f"+{x}+{y}") def run_in_thread(func, done_cb): """Run func() in a daemon thread, call done_cb(result) on the Tk thread.""" def worker(): res = None try: res = func() finally: # schedule callback on Tk thread try: done_cb(res) except Exception: pass t = threading.Thread(target=worker, daemon=True) t.start() return t def run(): App().mainloop()