read spool weight from edit dialog and write measured total back when it differs
This commit is contained in:
+134
-23
@@ -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/<id>).
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user