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): """Always listening; on scan -> ask Part-DB; route to View or Create.""" def __init__(self, master, app): super().__init__(master) self.app = app 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.view = ViewPage(self, self) self.create = CreatePage(self, self) for f in (self.home, 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.home.on_scan(flat) def goto_home(self): self.home.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()