read spool weight from edit dialog and write measured total back when it differs
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
|||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -27,16 +28,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
SpoolID string `json:"spool_id,omitempty"`
|
SpoolID string `json:"spool_id,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
Weight *float64 `json:"weight,omitempty"`
|
SiteRemainingGrams *float64 `json:"site_remaining_grams,omitempty"`
|
||||||
Unit string `json:"unit,omitempty"`
|
Weight *float64 `json:"weight,omitempty"`
|
||||||
Confidence *float64 `json:"confidence,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
Weights []float64 `json:"weights,omitempty"`
|
Confidence *float64 `json:"confidence,omitempty"`
|
||||||
ModelConfidences []string `json:"model_confidences,omitempty"`
|
Weights []float64 `json:"weights,omitempty"`
|
||||||
Error string `json:"error,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
|
// spoolURLPrefix is the only QR payload we accept. Anything else (a different
|
||||||
@@ -46,8 +49,9 @@ const spoolURLPrefix = "https://spooldb.com/s/"
|
|||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(0)
|
log.SetFlags(0)
|
||||||
verbose := flag.Bool("v", false, "print the auth mechanism being used to stderr")
|
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() {
|
flag.Usage = func() {
|
||||||
log.Printf("usage: %s [-v] <image>...", os.Args[0])
|
log.Printf("usage: %s [-v] [-n] <image>...", os.Args[0])
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -64,12 +68,12 @@ func main() {
|
|||||||
log.Printf("auth: %s", auth.name)
|
log.Printf("auth: %s", auth.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
locator := &spoolLocator{}
|
sp := &spoolSync{}
|
||||||
defer locator.close()
|
defer sp.close()
|
||||||
|
|
||||||
results := make([]result, 0, flag.NArg())
|
results := make([]result, 0, flag.NArg())
|
||||||
for _, path := range flag.Args() {
|
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)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
@@ -77,17 +81,17 @@ func main() {
|
|||||||
enc.Encode(results)
|
enc.Encode(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
// spoolLocator lazily logs into spooldb on first use, so its (slow) browser
|
// spoolSync lazily logs into spooldb on first use, so its (slow) browser login
|
||||||
// login overlaps with the first image's weight read rather than blocking it.
|
// overlaps with the first image's weight read rather than blocking it. Every
|
||||||
// Location lookup is best-effort: any failure is logged and the location is
|
// site interaction is best-effort: failures are logged and the corresponding
|
||||||
// simply omitted from the output.
|
// output fields are simply omitted.
|
||||||
type spoolLocator struct {
|
type spoolSync struct {
|
||||||
once sync.Once
|
once sync.Once
|
||||||
client *spooldb.Client
|
client *spooldb.Client
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spoolLocator) login() (*spooldb.Client, error) {
|
func (s *spoolSync) login() (*spooldb.Client, error) {
|
||||||
s.once.Do(func() {
|
s.once.Do(func() {
|
||||||
user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS")
|
user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS")
|
||||||
if user == "" || pass == "" {
|
if user == "" || pass == "" {
|
||||||
@@ -111,24 +115,40 @@ func (s *spoolLocator) login() (*spooldb.Client, error) {
|
|||||||
return s.client, s.err
|
return s.client, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// location returns the spool's storage location, or "" if it can't be looked up.
|
// info reads the spool's current location and weights, or nil if unavailable.
|
||||||
func (s *spoolLocator) location(spoolID string) string {
|
func (s *spoolSync) info(spoolID string) *spooldb.SpoolInfo {
|
||||||
client, err := s.login()
|
client, err := s.login()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("spooldb: skipping location lookup: %v", err)
|
log.Printf("spooldb: skipping spool lookup: %v", err)
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
loc, err := client.SpoolLocation(ctx, spoolID)
|
info, err := client.SpoolInfo(ctx, spoolID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("spooldb: location lookup for %s: %v", spoolID, err)
|
log.Printf("spooldb: lookup for %s: %v", spoolID, err)
|
||||||
return ""
|
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 {
|
if s.client != nil {
|
||||||
s.client.Close()
|
s.client.Close()
|
||||||
}
|
}
|
||||||
@@ -136,7 +156,7 @@ func (s *spoolLocator) close() {
|
|||||||
|
|
||||||
// processImage reads one photo, capturing any failure in the result's Error
|
// processImage reads one photo, capturing any failure in the result's Error
|
||||||
// field so a single bad image doesn't abort the whole batch.
|
// 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}
|
r := result{Image: path}
|
||||||
|
|
||||||
img, err := loadImage(path)
|
img, err := loadImage(path)
|
||||||
@@ -157,18 +177,23 @@ func processImage(path string, auth authInfo, locator *spoolLocator) result {
|
|||||||
r.SpoolID = spoolID(url)
|
r.SpoolID = spoolID(url)
|
||||||
r.URL = url
|
r.URL = url
|
||||||
|
|
||||||
// Look up the spool's location concurrently with the (slower) weight read.
|
// Read the spool's current location/weight from the site concurrently with
|
||||||
var loc string
|
// the (slower) weight read off the photo.
|
||||||
|
var info *spooldb.SpoolInfo
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
loc = locator.location(r.SpoolID)
|
info = sp.info(r.SpoolID)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
w, err := readWeight(img, auth)
|
w, err := readWeight(img, auth)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
r.Location = loc
|
if info != nil {
|
||||||
|
r.Location = info.Location
|
||||||
|
rem := info.RemainingGrams
|
||||||
|
r.SiteRemainingGrams = &rem
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Error = fmt.Sprintf("read weight: %v", err)
|
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.Confidence = &w.confidence
|
||||||
r.Weights = w.weights
|
r.Weights = w.weights
|
||||||
r.ModelConfidences = w.modelConfidences
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+134
-23
@@ -8,10 +8,13 @@ package spooldb
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ func New(opts ...Option) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), execOpts...)
|
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
|
// 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
|
||||||
@@ -88,6 +91,16 @@ func New(opts ...Option) (*Client, error) {
|
|||||||
}, nil
|
}, 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.
|
// Close shuts down the browser.
|
||||||
func (c *Client) Close() {
|
func (c *Client) Close() {
|
||||||
for _, cancel := range c.cancels {
|
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)")
|
return errors.New("login did not complete (check credentials)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpoolLocation returns the storage location assigned to a spool (e.g. "H"),
|
// SpoolInfo is the current state of a spool, read from its edit dialog.
|
||||||
// or "" if the spool has no location. spoolID is the code from the QR URL
|
type SpoolInfo struct {
|
||||||
// (spooldb.com/s/<id>).
|
Location string // location label, e.g. "H" (empty if unset)
|
||||||
func (c *Client) SpoolLocation(ctx context.Context, spoolID string) (string, error) {
|
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 {
|
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 {
|
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 {
|
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
|
const clickJS = `(() => {
|
||||||
// location (a box icon followed by the location name).
|
if (document.getElementById('weight_with_spool')) return 'open';
|
||||||
const js = `(() => {
|
const qr = document.querySelector('button[aria-label="barcode"]');
|
||||||
const hdr = [...document.querySelectorAll('div.bg-panel')].find(h => h.querySelector('div.font-medium'));
|
if (!qr) return 'loading';
|
||||||
if (!hdr) return null;
|
const group = qr.parentElement;
|
||||||
return (hdr.querySelector('div.font-medium').textContent || '').trim();
|
const btns = [...group.querySelectorAll(':scope > button')];
|
||||||
|
const pencil = btns[btns.indexOf(qr) + 1];
|
||||||
|
if (!pencil) return 'no-pencil';
|
||||||
|
pencil.click();
|
||||||
|
return 'clicked';
|
||||||
})()`
|
})()`
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
var loc *string
|
|
||||||
deadline := time.Now().Add(20 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(js, &loc)); err != nil {
|
var state string
|
||||||
return "", err
|
if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(clickJS, &state)); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if loc != nil {
|
switch state {
|
||||||
return strings.TrimSpace(*loc), nil
|
case "open":
|
||||||
|
return nil
|
||||||
|
case "no-pencil":
|
||||||
|
return errors.New("edit button not found on spool page")
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return "", ctx.Err()
|
return ctx.Err()
|
||||||
case <-time.After(time.Second):
|
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,
|
// 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 {
|
if err := c.Login(ctx, email, pass); err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
loc, err := c.SpoolLocation(ctx, spoolID)
|
info, err := c.SpoolInfo(ctx, spoolID)
|
||||||
if err != nil {
|
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