Files
PartDB_Helper_App/ui/app_tk.py

341 lines
13 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):
"""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()