511 lines
20 KiB
Python
511 lines
20 KiB
Python
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()
|