fix qr decoding, spool edit dialog, and opus 5 token budget

This commit is contained in:
Ian Gulliver
2026-07-25 09:15:32 -07:00
parent 700b53d50c
commit 82b652d7e4
3 changed files with 107 additions and 22 deletions
+17 -2
View File
@@ -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"`
+10 -5
View File
@@ -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
}
+80 -15
View File
@@ -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.