fix qr decoding, spool edit dialog, and opus 5 token budget
This commit is contained in:
+17
-2
@@ -161,8 +161,8 @@ func readOnce(img image.Image, auth Auth) (float64, string, string, error) {
|
|||||||
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
reqBody := map[string]interface{}{
|
||||||
"model": "claude-opus-4-8",
|
"model": "claude-opus-5",
|
||||||
"max_tokens": 600,
|
"max_tokens": 4000,
|
||||||
"messages": []map[string]interface{}{{
|
"messages": []map[string]interface{}{{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": []map[string]interface{}{
|
"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")
|
req.Header.Set("anthropic-version", "2023-06-01")
|
||||||
auth.apply(req)
|
auth.apply(req)
|
||||||
|
|
||||||
|
endPost := trace("claude.post")
|
||||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||||
|
endPost()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", err
|
return 0, "", "", err
|
||||||
}
|
}
|
||||||
@@ -196,15 +198,28 @@ func readOnce(img image.Image, auth Auth) (float64, string, string, error) {
|
|||||||
}
|
}
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Content []struct {
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
} `json:"content"`
|
} `json:"content"`
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
Usage struct {
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
return 0, "", "", err
|
return 0, "", "", err
|
||||||
}
|
}
|
||||||
var text string
|
var text string
|
||||||
|
var blocks []string
|
||||||
for _, c := range parsed.Content {
|
for _, c := range parsed.Content {
|
||||||
text += c.Text
|
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 {
|
var wr struct {
|
||||||
Weight float64 `json:"weight"`
|
Weight float64 `json:"weight"`
|
||||||
|
|||||||
@@ -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)
|
r.Error = fmt.Sprintf("scale is set to imperial units (%s); switch it to grams", reading.Unit)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
if reading.Weight < 0 {
|
if reading.Weight <= 0 {
|
||||||
r.Error = fmt.Sprintf("scale read a negative weight (%g g); refusing to update", reading.Weight)
|
r.Error = fmt.Sprintf("scale read a non-positive weight (%g g); refusing to update", reading.Weight)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
r.Unit = reading.Unit
|
r.Unit = reading.Unit
|
||||||
@@ -234,8 +234,8 @@ func processImg(img image.Image, name string, auth claude.Auth, dryRun bool) res
|
|||||||
if info != nil {
|
if info != nil {
|
||||||
r.NewWeight.Spool = ptr(info.EmptySpoolGrams)
|
r.NewWeight.Spool = ptr(info.EmptySpoolGrams)
|
||||||
r.NewWeight.Filament = ptr(reading.Weight - info.EmptySpoolGrams)
|
r.NewWeight.Filament = ptr(reading.Weight - info.EmptySpoolGrams)
|
||||||
if reading.Weight-info.EmptySpoolGrams < 0 {
|
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)
|
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
|
return r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,12 @@ func spoolID(url string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeQR(img image.Image) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-15
@@ -32,6 +32,9 @@ func trace(op string) func() {
|
|||||||
const (
|
const (
|
||||||
baseURL = "https://3dfilamentprofiles.com"
|
baseURL = "https://3dfilamentprofiles.com"
|
||||||
challengeText = "Security Checkpoint"
|
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
|
// Client is a logged-in session against 3dfilamentprofiles.com. It owns a
|
||||||
@@ -45,7 +48,6 @@ type Client struct {
|
|||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
chromePath string
|
chromePath string
|
||||||
headless bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option configures a Client.
|
// Option configures a Client.
|
||||||
@@ -54,12 +56,9 @@ type Option func(*config)
|
|||||||
// WithChromePath sets an explicit Chrome/Chromium executable path.
|
// WithChromePath sets an explicit Chrome/Chromium executable path.
|
||||||
func WithChromePath(p string) Option { return func(c *config) { c.chromePath = p } }
|
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.
|
// New launches a browser and returns a Client. It does not log in yet.
|
||||||
func New(opts ...Option) (*Client, error) {
|
func New(opts ...Option) (*Client, error) {
|
||||||
cfg := config{headless: true}
|
var cfg config
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(&cfg)
|
o(&cfg)
|
||||||
}
|
}
|
||||||
@@ -74,12 +73,8 @@ func New(opts ...Option) (*Client, error) {
|
|||||||
// A real-looking UA; the default headless UA advertises "HeadlessChrome".
|
// 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.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.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...)
|
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), execOpts...)
|
||||||
browserCtx, browserCancel := chromedp.NewContext(allocCtx, chromedp.WithErrorf(chromeErrorf))
|
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
|
// 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
|
// 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).
|
// 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")()
|
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()
|
browserCancel()
|
||||||
allocCancel()
|
allocCancel()
|
||||||
return nil, fmt.Errorf("start browser: %w", err)
|
return nil, fmt.Errorf("start browser: timed out after %s", browserLaunchTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
@@ -214,7 +220,7 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
|
|||||||
|
|
||||||
const clickJS = `(() => {
|
const clickJS = `(() => {
|
||||||
if (document.getElementById('weight_with_spool')) return 'open';
|
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';
|
if (!qr) return 'loading';
|
||||||
const group = qr.parentElement;
|
const group = qr.parentElement;
|
||||||
const btns = [...group.querySelectorAll(':scope > button')];
|
const btns = [...group.querySelectorAll(':scope > button')];
|
||||||
@@ -224,11 +230,16 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
|
|||||||
return 'clicked';
|
return 'clicked';
|
||||||
})()`
|
})()`
|
||||||
deadline := time.Now().Add(30 * time.Second)
|
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
|
var state string
|
||||||
if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(clickJS, &state)); err != nil {
|
if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(clickJS, &state)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if state != lastState {
|
||||||
|
slog.Debug("openEdit poll", "spool", spoolID, "poll", poll, "state", state)
|
||||||
|
}
|
||||||
|
lastState = state
|
||||||
switch state {
|
switch state {
|
||||||
case "open":
|
case "open":
|
||||||
return nil
|
return nil
|
||||||
@@ -241,7 +252,61 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
|
|||||||
case <-time.After(time.Second):
|
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.
|
// SpoolInfo opens a spool's edit dialog and reads its location and weights.
|
||||||
|
|||||||
Reference in New Issue
Block a user