diff --git a/claude/claude.go b/claude/claude.go index 4b96f60..874d35a 100644 --- a/claude/claude.go +++ b/claude/claude.go @@ -161,8 +161,8 @@ func readOnce(img image.Image, auth Auth) (float64, string, string, error) { b64 := base64.StdEncoding.EncodeToString(buf.Bytes()) reqBody := map[string]interface{}{ - "model": "claude-opus-4-8", - "max_tokens": 600, + "model": "claude-opus-5", + "max_tokens": 4000, "messages": []map[string]interface{}{{ "role": "user", "content": []map[string]interface{}{ @@ -185,7 +185,9 @@ func readOnce(img image.Image, auth Auth) (float64, string, string, error) { req.Header.Set("anthropic-version", "2023-06-01") auth.apply(req) + endPost := trace("claude.post") resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req) + endPost() if err != nil { return 0, "", "", err } @@ -196,15 +198,28 @@ func readOnce(img image.Image, auth Auth) (float64, string, string, error) { } var parsed struct { Content []struct { + Type string `json:"type"` Text string `json:"text"` } `json:"content"` + StopReason string `json:"stop_reason"` + Usage struct { + OutputTokens int `json:"output_tokens"` + } `json:"usage"` } if err := json.Unmarshal(respBody, &parsed); err != nil { return 0, "", "", err } var text string + var blocks []string for _, c := range parsed.Content { text += c.Text + blocks = append(blocks, c.Type) + } + slog.Debug("claude reply", "stopReason", parsed.StopReason, + "blocks", strings.Join(blocks, ","), "outputTokens", parsed.Usage.OutputTokens) + if strings.TrimSpace(text) == "" { + return 0, "", "", fmt.Errorf("empty reply (stopReason %q, blocks %q, outputTokens %d)", + parsed.StopReason, strings.Join(blocks, ","), parsed.Usage.OutputTokens) } var wr struct { Weight float64 `json:"weight"` diff --git a/main.go b/main.go index 96ca095..783e7dd 100644 --- a/main.go +++ b/main.go @@ -219,8 +219,8 @@ func processImg(img image.Image, name string, auth claude.Auth, dryRun bool) res r.Error = fmt.Sprintf("scale is set to imperial units (%s); switch it to grams", reading.Unit) return r } - if reading.Weight < 0 { - r.Error = fmt.Sprintf("scale read a negative weight (%g g); refusing to update", reading.Weight) + if reading.Weight <= 0 { + r.Error = fmt.Sprintf("scale read a non-positive weight (%g g); refusing to update", reading.Weight) return r } r.Unit = reading.Unit @@ -234,8 +234,8 @@ func processImg(img image.Image, name string, auth claude.Auth, dryRun bool) res if info != nil { r.NewWeight.Spool = ptr(info.EmptySpoolGrams) r.NewWeight.Filament = ptr(reading.Weight - info.EmptySpoolGrams) - if reading.Weight-info.EmptySpoolGrams < 0 { - r.Error = fmt.Sprintf("measured total %g g is below the empty-spool weight %g g (negative filament); refusing to update", reading.Weight, info.EmptySpoolGrams) + if reading.Weight-info.EmptySpoolGrams <= 0 { + r.Error = fmt.Sprintf("measured total %g g leaves no filament above the empty-spool weight %g g; refusing to update", reading.Weight, info.EmptySpoolGrams) return r } } @@ -291,7 +291,12 @@ func spoolID(url string) string { } func decodeQR(img image.Image) (string, error) { - bmp, err := gozxing.NewBinaryBitmapFromImage(img) + // gozxing's NewBinaryBitmapFromImage uses a HybridBinarizer, whose adaptive + // per-block thresholding turns the paper texture of a full-resolution photo + // into noise and loses the code. The global-histogram binarizer thresholds + // the whole frame at once and reads these photos fine. + src := gozxing.NewLuminanceSourceFromImage(img) + bmp, err := gozxing.NewBinaryBitmap(gozxing.NewGlobalHistgramBinarizer(src)) if err != nil { return "", err } diff --git a/spooldb/client.go b/spooldb/client.go index 52650fd..af3ad62 100644 --- a/spooldb/client.go +++ b/spooldb/client.go @@ -32,6 +32,9 @@ func trace(op string) func() { const ( baseURL = "https://3dfilamentprofiles.com" challengeText = "Security Checkpoint" + // browserLaunchTimeout bounds Chrome startup, which otherwise has no + // deadline and would hang the caller indefinitely. + browserLaunchTimeout = 30 * time.Second ) // Client is a logged-in session against 3dfilamentprofiles.com. It owns a @@ -45,7 +48,6 @@ type Client struct { type config struct { chromePath string - headless bool } // Option configures a Client. @@ -54,12 +56,9 @@ type Option func(*config) // WithChromePath sets an explicit Chrome/Chromium executable path. func WithChromePath(p string) Option { return func(c *config) { c.chromePath = p } } -// WithHeadful runs a visible browser window (useful for debugging). -func WithHeadful() Option { return func(c *config) { c.headless = false } } - // New launches a browser and returns a Client. It does not log in yet. func New(opts ...Option) (*Client, error) { - cfg := config{headless: true} + var cfg config for _, o := range opts { o(&cfg) } @@ -74,12 +73,8 @@ func New(opts ...Option) (*Client, error) { // A real-looking UA; the default headless UA advertises "HeadlessChrome". chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"), chromedp.WindowSize(1280, 900), + chromedp.Flag("headless", "new"), ) - if cfg.headless { - execOpts = append(execOpts, chromedp.Flag("headless", "new")) - } else { - execOpts = append(execOpts, chromedp.Flag("headless", false)) - } allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), execOpts...) browserCtx, browserCancel := chromedp.NewContext(allocCtx, chromedp.WithErrorf(chromeErrorf)) @@ -87,11 +82,22 @@ func New(opts ...Option) (*Client, error) { // 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 // be a short-lived per-call context (otherwise the browser dies after it). + // That rules out a WithTimeout wrapper here, so bound the wait instead: if + // Chrome never comes up we tear the whole thing down rather than hang. defer trace("spooldb.launchBrowser")() - if err := chromedp.Run(browserCtx, chromedp.Navigate("about:blank")); err != nil { + launched := make(chan error, 1) + go func() { launched <- chromedp.Run(browserCtx, chromedp.Navigate("about:blank")) }() + select { + case err := <-launched: + if err != nil { + browserCancel() + allocCancel() + return nil, fmt.Errorf("start browser: %w", err) + } + case <-time.After(browserLaunchTimeout): browserCancel() allocCancel() - return nil, fmt.Errorf("start browser: %w", err) + return nil, fmt.Errorf("start browser: timed out after %s", browserLaunchTimeout) } return &Client{ @@ -214,7 +220,7 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error { const clickJS = `(() => { if (document.getElementById('weight_with_spool')) return 'open'; - const qr = document.querySelector('button[aria-label="barcode"]'); + const qr = document.querySelector('button[aria-label="barcode" i]'); if (!qr) return 'loading'; const group = qr.parentElement; const btns = [...group.querySelectorAll(':scope > button')]; @@ -224,11 +230,16 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error { return 'clicked'; })()` deadline := time.Now().Add(30 * time.Second) - for time.Now().Before(deadline) { + lastState := "none" + for poll := 0; time.Now().Before(deadline); poll++ { var state string if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(clickJS, &state)); err != nil { return err } + if state != lastState { + slog.Debug("openEdit poll", "spool", spoolID, "poll", poll, "state", state) + } + lastState = state switch state { case "open": return nil @@ -241,7 +252,61 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error { case <-time.After(time.Second): } } - return errors.New("spool edit dialog did not open") + return fmt.Errorf("spool edit dialog did not open (last state %q; %s)", lastState, c.diagnose(ctx, spoolID)) +} + +// diagnose captures why a page interaction failed: where the browser actually +// ended up, which landmark elements are present, and a screenshot plus page text +// written to temp files. It returns a one-line summary for the error message. +func (c *Client) diagnose(ctx context.Context, spoolID string) string { + const js = `(() => JSON.stringify({ + login: !!document.getElementById('email'), + qr: !!document.querySelector('button[aria-label="barcode" i]'), + dialog: !!document.getElementById('weight_with_spool'), + buttons: [...document.querySelectorAll('button[aria-label]')].map(b => b.getAttribute('aria-label')).join(','), + text: document.body ? document.body.innerText : '', + }))()` + + var loc, title, out string + if err := c.run(ctx, 15*time.Second, + chromedp.Location(&loc), + chromedp.Title(&title), + chromedp.Evaluate(js, &out), + ); err != nil { + return fmt.Sprintf("diagnostics unavailable: %v", err) + } + var d struct { + Login, QR, Dialog bool + Buttons, Text string + } + if err := json.Unmarshal([]byte(out), &d); err != nil { + return fmt.Sprintf("diagnostics unparsable: %v", err) + } + + slog.Warn("spooldb: openEdit failed", + "spool", spoolID, "url", loc, "title", title, + "loginFormPresent", d.Login, "qrButtonPresent", d.QR, "dialogPresent", d.Dialog, + "ariaButtons", d.Buttons) + + stem := filepath.Join(os.TempDir(), "spooldb-fail-"+spoolID) + if err := os.WriteFile(stem+".txt", []byte("url: "+loc+"\ntitle: "+title+"\n\n"+d.Text), 0o600); err == nil { + slog.Warn("spooldb: wrote page text", "path", stem+".txt") + } + var shot []byte + if err := c.run(ctx, 15*time.Second, chromedp.CaptureScreenshot(&shot)); err == nil { + if err := os.WriteFile(stem+".png", shot, 0o600); err == nil { + slog.Warn("spooldb: wrote screenshot", "path", stem+".png") + } + } + + switch { + case d.Login: + return "browser is on the login page — session not established" + case !d.QR: + return "spool page never rendered (no barcode button); url " + loc + default: + return "clicked the edit button but the dialog never appeared; url " + loc + } } // SpoolInfo opens a spool's edit dialog and reads its location and weights.