Initial move to app, scanning and reading basic part info works, updating info starting to work
This commit is contained in:
340
ui/app_tk.py
Normal file
340
ui/app_tk.py
Normal file
@@ -0,0 +1,340 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user