From cb6b4279902e86f684115d6fa05f4bc8b3aac209 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 31 May 2026 14:19:34 -0700 Subject: [PATCH] read spool weight from edit dialog and write measured total back when it differs --- main.go | 108 +++++++++++++++++++--------- spooldb/client.go | 157 +++++++++++++++++++++++++++++++++++------ spooldb/client_test.go | 7 +- 3 files changed, 212 insertions(+), 60 deletions(-) diff --git a/main.go b/main.go index 716bf35..bda42ac 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "image/png" "io" "log" + "math" "net/http" "os" "os/exec" @@ -27,16 +28,18 @@ import ( ) type result struct { - Image string `json:"image"` - SpoolID string `json:"spool_id,omitempty"` - URL string `json:"url,omitempty"` - Location string `json:"location,omitempty"` - Weight *float64 `json:"weight,omitempty"` - Unit string `json:"unit,omitempty"` - Confidence *float64 `json:"confidence,omitempty"` - Weights []float64 `json:"weights,omitempty"` - ModelConfidences []string `json:"model_confidences,omitempty"` - Error string `json:"error,omitempty"` + Image string `json:"image"` + SpoolID string `json:"spool_id,omitempty"` + URL string `json:"url,omitempty"` + Location string `json:"location,omitempty"` + SiteRemainingGrams *float64 `json:"site_remaining_grams,omitempty"` + Weight *float64 `json:"weight,omitempty"` + Unit string `json:"unit,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + Weights []float64 `json:"weights,omitempty"` + ModelConfidences []string `json:"model_confidences,omitempty"` + Updated bool `json:"updated,omitempty"` + Error string `json:"error,omitempty"` } // spoolURLPrefix is the only QR payload we accept. Anything else (a different @@ -46,8 +49,9 @@ const spoolURLPrefix = "https://spooldb.com/s/" func main() { log.SetFlags(0) verbose := flag.Bool("v", false, "print the auth mechanism being used to stderr") + dryRun := flag.Bool("n", false, "dry run: read and report, but do not write weight changes back to the site") flag.Usage = func() { - log.Printf("usage: %s [-v] ...", os.Args[0]) + log.Printf("usage: %s [-v] [-n] ...", os.Args[0]) flag.PrintDefaults() } flag.Parse() @@ -64,12 +68,12 @@ func main() { log.Printf("auth: %s", auth.name) } - locator := &spoolLocator{} - defer locator.close() + sp := &spoolSync{} + defer sp.close() results := make([]result, 0, flag.NArg()) for _, path := range flag.Args() { - results = append(results, processImage(path, auth, locator)) + results = append(results, processImage(path, auth, sp, *dryRun)) } enc := json.NewEncoder(os.Stdout) @@ -77,17 +81,17 @@ func main() { enc.Encode(results) } -// spoolLocator lazily logs into spooldb on first use, so its (slow) browser -// login overlaps with the first image's weight read rather than blocking it. -// Location lookup is best-effort: any failure is logged and the location is -// simply omitted from the output. -type spoolLocator struct { +// spoolSync lazily logs into spooldb on first use, so its (slow) browser login +// overlaps with the first image's weight read rather than blocking it. Every +// site interaction is best-effort: failures are logged and the corresponding +// output fields are simply omitted. +type spoolSync struct { once sync.Once client *spooldb.Client err error } -func (s *spoolLocator) login() (*spooldb.Client, error) { +func (s *spoolSync) login() (*spooldb.Client, error) { s.once.Do(func() { user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS") if user == "" || pass == "" { @@ -111,24 +115,40 @@ func (s *spoolLocator) login() (*spooldb.Client, error) { return s.client, s.err } -// location returns the spool's storage location, or "" if it can't be looked up. -func (s *spoolLocator) location(spoolID string) string { +// info reads the spool's current location and weights, or nil if unavailable. +func (s *spoolSync) info(spoolID string) *spooldb.SpoolInfo { client, err := s.login() if err != nil { - log.Printf("spooldb: skipping location lookup: %v", err) - return "" + log.Printf("spooldb: skipping spool lookup: %v", err) + return nil } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - loc, err := client.SpoolLocation(ctx, spoolID) + info, err := client.SpoolInfo(ctx, spoolID) if err != nil { - log.Printf("spooldb: location lookup for %s: %v", spoolID, err) - return "" + log.Printf("spooldb: lookup for %s: %v", spoolID, err) + return nil } - return loc + return &info } -func (s *spoolLocator) close() { +// setTotal writes a new total (with-spool) weight back to the site. +func (s *spoolSync) setTotal(spoolID string, grams float64) bool { + client, err := s.login() + if err != nil { + log.Printf("spooldb: skipping weight update: %v", err) + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if err := client.SetTotalWeight(ctx, spoolID, grams); err != nil { + log.Printf("spooldb: weight update for %s: %v", spoolID, err) + return false + } + return true +} + +func (s *spoolSync) close() { if s.client != nil { s.client.Close() } @@ -136,7 +156,7 @@ func (s *spoolLocator) close() { // processImage reads one photo, capturing any failure in the result's Error // field so a single bad image doesn't abort the whole batch. -func processImage(path string, auth authInfo, locator *spoolLocator) result { +func processImage(path string, auth authInfo, sp *spoolSync, dryRun bool) result { r := result{Image: path} img, err := loadImage(path) @@ -157,18 +177,23 @@ func processImage(path string, auth authInfo, locator *spoolLocator) result { r.SpoolID = spoolID(url) r.URL = url - // Look up the spool's location concurrently with the (slower) weight read. - var loc string + // Read the spool's current location/weight from the site concurrently with + // the (slower) weight read off the photo. + var info *spooldb.SpoolInfo var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() - loc = locator.location(r.SpoolID) + info = sp.info(r.SpoolID) }() w, err := readWeight(img, auth) wg.Wait() - r.Location = loc + if info != nil { + r.Location = info.Location + rem := info.RemainingGrams + r.SiteRemainingGrams = &rem + } if err != nil { r.Error = fmt.Sprintf("read weight: %v", err) @@ -183,6 +208,21 @@ func processImage(path string, auth authInfo, locator *spoolLocator) result { r.Confidence = &w.confidence r.Weights = w.weights r.ModelConfidences = w.modelConfidences + + // The photo gives total weight (filament + spool); the site stores remaining + // filament. Write the measured total back when it differs from the site's + // total — the site recomputes remaining using the empty-spool weight. + if info != nil && math.Abs(w.weight-info.TotalGrams) >= 1 { + newRemaining := w.weight - info.EmptySpoolGrams + if dryRun { + log.Printf("spooldb: %s would update remaining %.0fg -> %.0fg (measured total %.0fg)", + r.SpoolID, info.RemainingGrams, newRemaining, w.weight) + } else if sp.setTotal(r.SpoolID, w.weight) { + r.Updated = true + log.Printf("spooldb: %s updated remaining %.0fg -> %.0fg (measured total %.0fg)", + r.SpoolID, info.RemainingGrams, newRemaining, w.weight) + } + } return r } diff --git a/spooldb/client.go b/spooldb/client.go index c94db68..c92974b 100644 --- a/spooldb/client.go +++ b/spooldb/client.go @@ -8,10 +8,13 @@ package spooldb import ( "context" + "encoding/json" "errors" "fmt" + "log" "os" "path/filepath" + "strconv" "strings" "time" @@ -71,7 +74,7 @@ func New(opts ...Option) (*Client, error) { } allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), execOpts...) - browserCtx, browserCancel := chromedp.NewContext(allocCtx) + browserCtx, browserCancel := chromedp.NewContext(allocCtx, chromedp.WithErrorf(chromeErrorf)) // Allocate the browser bound to the long-lived browserCtx. chromedp ties the // Chrome process lifetime to the context of the first Run, so this must not @@ -88,6 +91,16 @@ func New(opts ...Option) (*Client, error) { }, nil } +// chromeErrorf is chromedp's error logger. It drops the benign "unhandled ... +// event" diagnostics (chromedp doesn't handle every CDP event the page emits, +// e.g. DOM.topLayerElementsUpdated from the edit dialog) and forwards the rest. +func chromeErrorf(format string, args ...any) { + if strings.HasPrefix(format, "unhandled ") { + return + } + log.Printf("chromedp: "+format, args...) +} + // Close shuts down the browser. func (c *Client) Close() { for _, cancel := range c.cancels { @@ -163,44 +176,142 @@ func (c *Client) Login(ctx context.Context, email, password string) error { return errors.New("login did not complete (check credentials)") } -// SpoolLocation returns the storage location assigned to a spool (e.g. "H"), -// or "" if the spool has no location. spoolID is the code from the QR URL -// (spooldb.com/s/). -func (c *Client) SpoolLocation(ctx context.Context, spoolID string) (string, error) { +// SpoolInfo is the current state of a spool, read from its edit dialog. +type SpoolInfo struct { + Location string // location label, e.g. "H" (empty if unset) + TotalGrams float64 // weight including the spool (what the scale reads) + RemainingGrams float64 // remaining filament the site tracks + EmptySpoolGrams float64 // empty-spool weight used to derive remaining +} + +// openEdit navigates to a spool and opens its "Edit Spool" dialog. There is no +// direct URL for the dialog; it is opened from the spool page by the pencil +// button, which sits between the QR-code and delete buttons in the card header. +func (c *Client) openEdit(ctx context.Context, spoolID string) error { if !c.loggedIn { - return "", errors.New("not logged in") + return errors.New("not logged in") } if err := c.run(ctx, 45*time.Second, chromedp.Navigate(baseURL+"/my/spool/"+spoolID)); err != nil { - return "", fmt.Errorf("navigate to spool: %w", err) + return fmt.Errorf("navigate to spool: %w", err) } if err := c.awaitChallenge(ctx); err != nil { - return "", err + return err } - // The spool's status card has a panel header whose first line is the - // location (a box icon followed by the location name). - const js = `(() => { - const hdr = [...document.querySelectorAll('div.bg-panel')].find(h => h.querySelector('div.font-medium')); - if (!hdr) return null; - return (hdr.querySelector('div.font-medium').textContent || '').trim(); + const clickJS = `(() => { + if (document.getElementById('weight_with_spool')) return 'open'; + const qr = document.querySelector('button[aria-label="barcode"]'); + if (!qr) return 'loading'; + const group = qr.parentElement; + const btns = [...group.querySelectorAll(':scope > button')]; + const pencil = btns[btns.indexOf(qr) + 1]; + if (!pencil) return 'no-pencil'; + pencil.click(); + return 'clicked'; })()` - - var loc *string - deadline := time.Now().Add(20 * time.Second) + deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { - if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(js, &loc)); err != nil { - return "", err + var state string + if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(clickJS, &state)); err != nil { + return err } - if loc != nil { - return strings.TrimSpace(*loc), nil + switch state { + case "open": + return nil + case "no-pencil": + return errors.New("edit button not found on spool page") } select { case <-ctx.Done(): - return "", ctx.Err() + return ctx.Err() case <-time.After(time.Second): } } - return "", errors.New("spool page did not render (wrong id or not logged in?)") + return errors.New("spool edit dialog did not open") +} + +// SpoolInfo opens a spool's edit dialog and reads its location and weights. +func (c *Client) SpoolInfo(ctx context.Context, spoolID string) (SpoolInfo, error) { + if err := c.openEdit(ctx, spoolID); err != nil { + return SpoolInfo{}, err + } + const js = `(() => { + const val = id => { const e = document.getElementById(id); return e ? e.value : ''; }; + const ph = id => { const e = document.getElementById(id); return e ? e.placeholder : ''; }; + return JSON.stringify({ + location: val('location_label'), + total: val('weight_with_spool'), + remaining: val('remaining_grams'), + empty: val('empty_spool_weight') || ph('empty_spool_weight'), + }); + })()` + var out string + if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(js, &out)); err != nil { + return SpoolInfo{}, err + } + var raw struct{ Location, Total, Remaining, Empty string } + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return SpoolInfo{}, fmt.Errorf("parse spool fields: %w", err) + } + return SpoolInfo{ + Location: strings.TrimSpace(raw.Location), + TotalGrams: parseGrams(raw.Total), + RemainingGrams: parseGrams(raw.Remaining), + EmptySpoolGrams: parseGrams(raw.Empty), + }, nil +} + +// SetTotalWeight opens a spool's edit dialog, sets the total (with-spool) weight +// — the site recomputes remaining filament from the empty-spool weight — and +// saves. grams is the weight read off the scale (filament plus spool). +func (c *Client) SetTotalWeight(ctx context.Context, spoolID string, grams float64) error { + if err := c.openEdit(ctx, spoolID); err != nil { + return err + } + // Set the value the way React expects so the form recomputes remaining_grams. + setJS := fmt.Sprintf(`(() => { + const el = document.getElementById('weight_with_spool'); + if (!el) return false; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, %q); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })()`, fmt.Sprintf("%.0f", grams)) + var ok bool + if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(setJS, &ok)); err != nil { + return err + } + if !ok { + return errors.New("weight field not found") + } + if err := c.run(ctx, 15*time.Second, + chromedp.Click(`//button[normalize-space()='Save Changes']`, chromedp.BySearch), + ); err != nil { + return fmt.Errorf("click save: %w", err) + } + // Saving closes the dialog; wait for it to disappear to confirm success. + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + var stillOpen bool + if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(`!!document.getElementById('weight_with_spool')`, &stillOpen)); err != nil { + return err + } + if !stillOpen { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + } + return errors.New("save did not complete") +} + +func parseGrams(s string) float64 { + v, _ := strconv.ParseFloat(strings.TrimSpace(strings.ReplaceAll(s, ",", "")), 64) + return v } // resolveChrome finds a Chrome/Chromium binary: explicit path, then $CHROME, diff --git a/spooldb/client_test.go b/spooldb/client_test.go index bdd9f01..23eccc8 100644 --- a/spooldb/client_test.go +++ b/spooldb/client_test.go @@ -32,9 +32,10 @@ func TestSpoolLocation(t *testing.T) { if err := c.Login(ctx, email, pass); err != nil { t.Fatalf("login: %v", err) } - loc, err := c.SpoolLocation(ctx, spoolID) + info, err := c.SpoolInfo(ctx, spoolID) if err != nil { - t.Fatalf("spool location: %v", err) + t.Fatalf("spool info: %v", err) } - t.Logf("spool %s location: %q", spoolID, loc) + t.Logf("spool %s: location=%q total=%.0fg remaining=%.0fg emptySpool=%.0fg", + spoolID, info.Location, info.TotalGrams, info.RemainingGrams, info.EmptySpoolGrams) }