From 8c19ffc7e6b46d0f078cbb2ccb0a4defb04fb285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0tetiar?= Date: Thu, 8 Oct 2026 07:17:26 +0200 Subject: [PATCH] feat: show time left, elapsed and estimated finish for a running print MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status line for a running print showed only the progress. To learn when the print ends, you had to read the printer screen or calculate from the percentage. Both routes already report `time_remaining` and `time_printing` in seconds. The status line now adds the time left, the finish time on the local clock, and the time elapsed: PRINTING 89%, 23m left (ends ~12:41), 2h10m elapsed, nozzle 250/250°C `get_printer` and `list_printers` gain `time_elapsed` and `ends_at` next to `time_remaining`. The `internal/jobtime` package reads the fields, so the CLI line and the MCP summary leave out the same values: a value that is missing, negative or not a number. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Petr Štetiar --- CHANGELOG.md | 5 +++ cmd/prusactl/main.go | 2 + cmd/prusactl/timing.go | 45 +++++++++++++++++++++ cmd/prusactl/timing_test.go | 33 ++++++++++++++++ internal/jobtime/jobtime.go | 52 +++++++++++++++++++++++++ internal/jobtime/jobtime_test.go | 40 +++++++++++++++++++ internal/server/printer_summary.go | 39 ++++++++++++++++--- internal/server/printer_summary_test.go | 39 ++++++++++++++++--- internal/server/tools_printers.go | 4 +- 9 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 cmd/prusactl/timing.go create mode 100644 cmd/prusactl/timing_test.go create mode 100644 internal/jobtime/jobtime.go create mode 100644 internal/jobtime/jobtime_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e18387d..936c72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- `prusactl status` and the printer summary show how long a running print has + left, how long it has run, and when it should finish on the local clock, for + example `PRINTING 89%, 23m left (ends ~12:41), 2h10m elapsed, nozzle 250/250°C, + bed 85/85°C`. `get_printer` and `list_printers` gain `time_elapsed` and + `ends_at` next to `time_remaining`. Missing or negative values are left out. - **Camera snapshots work again.** Around 2026-09-30 Prusa moved cameras to a new camera service, and the endpoints `get_camera_snapshot` and `prusactl camera` used stopped serving images (404), which prusactl reported diff --git a/cmd/prusactl/main.go b/cmd/prusactl/main.go index 225f45d..dc76265 100644 --- a/cmd/prusactl/main.go +++ b/cmd/prusactl/main.go @@ -334,6 +334,7 @@ func status(ctx context.Context, srv *server.Server, lc *link.Client) error { if p, ok := s.Job["progress"]; ok { line += fmt.Sprintf(" %v%%", p) } + line += jobTiming(s.Job, time.Now()) if _, ok := s.Printer["temp_nozzle"]; ok { line += fmt.Sprintf(", nozzle %s/%s°C, bed %s/%s°C", num(s.Printer["temp_nozzle"]), num(s.Printer["target_nozzle"]), num(s.Printer["temp_bed"]), num(s.Printer["target_bed"])) @@ -412,6 +413,7 @@ func connectLine(p map[string]any) string { if pr, ok := job["progress"]; ok { line += fmt.Sprintf(" %v%%", pr) } + line += jobTiming(job, time.Now()) } if t, ok := p["temp"].(map[string]any); ok { line += fmt.Sprintf(", nozzle %s/%s°C, bed %s/%s°C", num(t["temp_nozzle"]), num(t["target_nozzle"]), num(t["temp_bed"]), num(t["target_bed"])) diff --git a/cmd/prusactl/timing.go b/cmd/prusactl/timing.go new file mode 100644 index 0000000..be03117 --- /dev/null +++ b/cmd/prusactl/timing.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + "time" + + "github.com/trevin-lee/prusactl/internal/jobtime" +) + +// jobTiming says how long a print has run, how long is left and when it should +// end on the local clock, e.g. ", 23m left (ends ~12:41), 2h10m elapsed". Both +// routes name the fields the same way (time_remaining, time_printing, in +// seconds). A value that is missing, negative or not a number is left out, and +// it is "" when the job reports neither. +func jobTiming(job map[string]any, now time.Time) string { + t := jobtime.Read(job, now) + out := "" + if t.Remaining != nil { + out += ", " + shortDuration(*t.Remaining) + " left" + if !t.Ends.IsZero() { + out += " (ends ~" + t.Ends.Local().Format("15:04") + ")" + } + } + if t.Elapsed != nil { + out += ", " + shortDuration(*t.Elapsed) + " elapsed" + } + return out +} + +// shortDuration writes 45s, 23m, 2h10m or 1d3h. +func shortDuration(d time.Duration) string { + s := int64(d / time.Second) + switch { + case s < 60: + return fmt.Sprintf("%ds", s) + case s < 3600: + return fmt.Sprintf("%dm", s/60) + case s < 86400: + if m := s % 3600 / 60; m > 0 { + return fmt.Sprintf("%dh%02dm", s/3600, m) + } + return fmt.Sprintf("%dh", s/3600) + } + return fmt.Sprintf("%dd%dh", s/86400, s%86400/3600) +} diff --git a/cmd/prusactl/timing_test.go b/cmd/prusactl/timing_test.go new file mode 100644 index 0000000..4ddb5df --- /dev/null +++ b/cmd/prusactl/timing_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestJobTiming(t *testing.T) { + at := time.Date(2026, 10, 4, 12, 0, 0, 0, time.Local) + want := ", 23m left (ends ~12:23), 2h10m elapsed" + cases := map[string]struct { + job string + want string + }{ + "both": {`{"time_remaining":1380,"time_printing":7800}`, want}, + "missing": {`{"progress":5}`, ""}, + "zero left": {`{"time_remaining":0,"time_printing":59}`, ", 0s left, 59s elapsed"}, + "only left": {`{"time_remaining":90000}`, ", 1d1h left (ends ~13:00)"}, + } + for name, c := range cases { + var job map[string]any + dec := json.NewDecoder(strings.NewReader(c.job)) + dec.UseNumber() + if err := dec.Decode(&job); err != nil { + t.Fatal(err) + } + if got := jobTiming(job, at); got != c.want { + t.Errorf("%s: got %q, want %q", name, got, c.want) + } + } +} diff --git a/internal/jobtime/jobtime.go b/internal/jobtime/jobtime.go new file mode 100644 index 0000000..4008f8b --- /dev/null +++ b/internal/jobtime/jobtime.go @@ -0,0 +1,52 @@ +// Package jobtime reads a print's timing from a job object, which both routes +// name the same way (time_remaining, time_printing, in seconds). +package jobtime + +import ( + "encoding/json" + "strconv" + "time" +) + +// Timing is how long a print has run, how long is left and when it should end. +// A value that is missing, negative or not a number is nil; Ends is the zero +// time unless some time is left. +type Timing struct { + Remaining, Elapsed *time.Duration + Ends time.Time +} + +// Read takes the timing from job, working the finish out from now. +func Read(job map[string]any, now time.Time) Timing { + t := Timing{Remaining: seconds(job["time_remaining"]), Elapsed: seconds(job["time_printing"])} + if t.Remaining != nil && *t.Remaining > 0 { + t.Ends = now.Add(*t.Remaining) + } + return t +} + +func seconds(v any) *time.Duration { + var f float64 + switch n := v.(type) { + case float64: + f = n + case json.Number: + var err error + if f, err = n.Float64(); err != nil { + return nil + } + case string: + var err error + if f, err = strconv.ParseFloat(n, 64); err != nil { + return nil + } + default: + return nil + } + // Firmware sends huge placeholders for "unknown"; a year is past believing. + if f != f || f < 0 || f > 366*24*3600 { + return nil + } + d := time.Duration(f) * time.Second + return &d +} diff --git a/internal/jobtime/jobtime_test.go b/internal/jobtime/jobtime_test.go new file mode 100644 index 0000000..7f6ab21 --- /dev/null +++ b/internal/jobtime/jobtime_test.go @@ -0,0 +1,40 @@ +package jobtime + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestRead(t *testing.T) { + at := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) + cases := map[string]struct { + job string + left, done time.Duration + hasLeft, hasDone bool + ends time.Time + }{ + "both": {`{"time_remaining":1380,"time_printing":7800}`, 23 * time.Minute, 130 * time.Minute, true, true, at.Add(23 * time.Minute)}, + "strings": {`{"time_remaining":"1380","time_printing":"7800"}`, 23 * time.Minute, 130 * time.Minute, true, true, at.Add(23 * time.Minute)}, + "fraction": {`{"time_remaining":59.9}`, 59 * time.Second, 0, true, false, at.Add(59 * time.Second)}, + "missing": {`{"progress":5}`, 0, 0, false, false, time.Time{}}, + "negative": {`{"time_remaining":-1,"time_printing":-5}`, 0, 0, false, false, time.Time{}}, + "junk": {`{"time_remaining":"soon","time_printing":null}`, 0, 0, false, false, time.Time{}}, + "huge": {`{"time_remaining":4294967295}`, 0, 0, false, false, time.Time{}}, + "zero left": {`{"time_remaining":0,"time_printing":59}`, 0, 59 * time.Second, true, true, time.Time{}}, + } + for name, c := range cases { + var job map[string]any + dec := json.NewDecoder(strings.NewReader(c.job)) + dec.UseNumber() + if err := dec.Decode(&job); err != nil { + t.Fatal(err) + } + got := Read(job, at) + if (got.Remaining != nil) != c.hasLeft || (got.Remaining != nil && *got.Remaining != c.left) || + (got.Elapsed != nil) != c.hasDone || (got.Elapsed != nil && *got.Elapsed != c.done) || !got.Ends.Equal(c.ends) { + t.Errorf("%s: got %+v", name, got) + } + } +} diff --git a/internal/server/printer_summary.go b/internal/server/printer_summary.go index dd601e8..b3d5b25 100644 --- a/internal/server/printer_summary.go +++ b/internal/server/printer_summary.go @@ -3,6 +3,9 @@ package server import ( "encoding/json" "strings" + "time" + + "github.com/trevin-lee/prusactl/internal/jobtime" ) // Reading the printer means reading whatever JSON the route returned: the @@ -30,6 +33,28 @@ type jobBrief struct { Name string `json:"name,omitempty"` Progress *float64 `json:"progress,omitempty"` // percent TimeRemaining *float64 `json:"time_remaining,omitempty"` // seconds + TimeElapsed *float64 `json:"time_elapsed,omitempty"` // seconds, the printer's time_printing + EndsAt string `json:"ends_at,omitempty"` // estimated finish, RFC 3339, local clock +} + +// timing fills the time fields from a job object, working the finish out from +// now. See jobtime for what is left out. +func (b *jobBrief) timing(job map[string]any, now time.Time) { + t := jobtime.Read(job, now) + if t.Remaining != nil { + b.TimeRemaining = secs(*t.Remaining) + } + if t.Elapsed != nil { + b.TimeElapsed = secs(*t.Elapsed) + } + if !t.Ends.IsZero() { + b.EndsAt = t.Ends.Format(time.RFC3339) + } +} + +func secs(d time.Duration) *float64 { + f := d.Seconds() + return &f } // num reads a JSON number from a nested path, e.g. num(m, "temp", "temp_bed"). @@ -74,16 +99,17 @@ func temp(actual, target *float64) *reading { } // summarizeDirect reads PrusaLink's /api/v1/status and /api/v1/job. -func summarizeDirect(status, job map[string]any) *summary { +func summarizeDirect(status, job map[string]any, now time.Time) *summary { s := &summary{State: str(status, "printer", "state")} s.Nozzle = temp(num(status, "printer", "temp_nozzle"), num(status, "printer", "target_nozzle")) s.Bed = temp(num(status, "printer", "temp_bed"), num(status, "printer", "target_bed")) if len(job) > 0 { - b := &jobBrief{Progress: num(job, "progress"), TimeRemaining: num(job, "time_remaining")} + b := &jobBrief{Progress: num(job, "progress")} + b.timing(job, now) if b.Name = str(job, "file", "display_name"); b.Name == "" { b.Name = str(job, "file", "name") } - if b.Name != "" || b.Progress != nil { + if b.Name != "" || b.Progress != nil || b.TimeRemaining != nil { s.Job = b } } @@ -91,17 +117,18 @@ func summarizeDirect(status, job map[string]any) *summary { } // summarizeConnect reads a Prusa Connect printer record. -func summarizeConnect(p map[string]any) *summary { +func summarizeConnect(p map[string]any, now time.Time) *summary { s := &summary{State: stateOf(p)} s.Nozzle = temp(num(p, "temp", "temp_nozzle"), num(p, "temp", "target_nozzle")) s.Bed = temp(num(p, "temp", "temp_bed"), num(p, "temp", "target_bed")) s.Chamber = temp(num(p, "chamber", "temp"), num(p, "chamber", "target_temp")) if job, ok := p["job_info"].(map[string]any); ok && len(job) > 0 { - b := &jobBrief{Progress: num(job, "progress"), TimeRemaining: num(job, "time_remaining")} + b := &jobBrief{Progress: num(job, "progress")} + b.timing(job, now) if b.Name = str(job, "display_name"); b.Name == "" { b.Name = strings.TrimPrefix(str(job, "path"), "/") } - if b.Name != "" || b.Progress != nil { + if b.Name != "" || b.Progress != nil || b.TimeRemaining != nil { s.Job = b } } diff --git a/internal/server/printer_summary_test.go b/internal/server/printer_summary_test.go index 2c387af..823039a 100644 --- a/internal/server/printer_summary_test.go +++ b/internal/server/printer_summary_test.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "testing" + "time" ) // The point of summary is that one reading of the answer works whichever route @@ -11,13 +12,13 @@ import ( func TestSummaryReadsBothRoutesTheSameWay(t *testing.T) { status := decode(json.RawMessage(`{"printer":{"state":"PRINTING","temp_nozzle":215.4,"target_nozzle":215,"temp_bed":60.1,"target_bed":60}}`)) job := decode(json.RawMessage(`{"progress":42.5,"time_remaining":1800,"file":{"name":"PART~1.BGC","display_name":"part.bgcode"}}`)) - direct := summarizeDirect(status, job) + direct := summarizeDirect(status, job, time.Now()) connect := summarizeConnect(decode(json.RawMessage(`{ "connect_state":"PRINTING", "temp":{"temp_nozzle":215.4,"target_nozzle":215,"temp_bed":60.1,"target_bed":60}, "job_info":{"progress":42.5,"time_remaining":1800,"display_name":"part.bgcode","path":"/usb/PART~1.BGC"} - }`))) + }`)), time.Now()) for name, s := range map[string]*summary{"direct": direct, "connect": connect} { if s.State != "PRINTING" { @@ -41,7 +42,7 @@ func TestSummaryReadsBothRoutesTheSameWay(t *testing.T) { // PrusaLink has no chamber field at all, so its absence is the printer's limit // and not something to invent a zero for. func TestSummaryLeavesOutWhatTheRouteDoesNotReport(t *testing.T) { - direct := summarizeDirect(decode(json.RawMessage(`{"printer":{"state":"IDLE","temp_nozzle":26}}`)), nil) + direct := summarizeDirect(decode(json.RawMessage(`{"printer":{"state":"IDLE","temp_nozzle":26}}`)), nil, time.Now()) if direct.Chamber != nil { t.Errorf("direct invented a chamber reading: %+v", direct.Chamber) } @@ -55,7 +56,7 @@ func TestSummaryLeavesOutWhatTheRouteDoesNotReport(t *testing.T) { t.Errorf("nozzle target should stay absent, not become 0: %+v", direct.Nozzle) } - conn := summarizeConnect(decode(json.RawMessage(`{"connect_state":"IDLE","chamber":{"temp":24.7}}`))) + conn := summarizeConnect(decode(json.RawMessage(`{"connect_state":"IDLE","chamber":{"temp":24.7}}`)), time.Now()) if conn.Chamber == nil || conn.Chamber.Actual != 24.7 { t.Errorf("connect chamber = %+v", conn.Chamber) } @@ -63,13 +64,13 @@ func TestSummaryLeavesOutWhatTheRouteDoesNotReport(t *testing.T) { // A job Connect names only by path still gets a name a person can read. func TestSummaryFallsBackToTheFileName(t *testing.T) { - conn := summarizeConnect(decode(json.RawMessage(`{"connect_state":"PRINTING","job_info":{"path":"/usb/PART~1.BGC","progress":1}}`))) + conn := summarizeConnect(decode(json.RawMessage(`{"connect_state":"PRINTING","job_info":{"path":"/usb/PART~1.BGC","progress":1}}`)), time.Now()) if conn.Job == nil || conn.Job.Name != "usb/PART~1.BGC" { t.Errorf("job = %+v", conn.Job) } direct := summarizeDirect( decode(json.RawMessage(`{"printer":{"state":"PRINTING"}}`)), - decode(json.RawMessage(`{"progress":1,"file":{"name":"PART~1.BGC"}}`))) + decode(json.RawMessage(`{"progress":1,"file":{"name":"PART~1.BGC"}}`)), time.Now()) if direct.Job == nil || direct.Job.Name != "PART~1.BGC" { t.Errorf("job = %+v", direct.Job) } @@ -82,3 +83,29 @@ func TestDecodeKeepsLongNumbersExact(t *testing.T) { t.Errorf("id = %s", got) } } + +// Both routes name the timing fields the same way; the finish is worked out +// from the remaining time, and nonsense values are left out. +func TestSummaryJobTiming(t *testing.T) { + at := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) + + direct := summarizeDirect(decode(json.RawMessage(`{"printer":{"state":"PRINTING"}}`)), + decode(json.RawMessage(`{"progress":89,"time_remaining":1380,"time_printing":7800,"file":{"name":"a.gcode"}}`)), at) + conn := summarizeConnect(decode(json.RawMessage( + `{"connect_state":"PRINTING","job_info":{"progress":89,"time_remaining":1380,"time_printing":7800,"path":"/a.gcode"}}`)), at) + for name, s := range map[string]*summary{"direct": direct, "connect": conn} { + j := s.Job + if j == nil || j.TimeRemaining == nil || *j.TimeRemaining != 1380 || j.TimeElapsed == nil || *j.TimeElapsed != 7800 { + t.Fatalf("%s job = %+v", name, j) + } + if j.EndsAt != "2026-10-04T12:23:00Z" { + t.Errorf("%s ends_at = %q", name, j.EndsAt) + } + } + + bad := summarizeConnect(decode(json.RawMessage( + `{"connect_state":"PRINTING","job_info":{"progress":3,"time_remaining":-1,"time_printing":-1,"path":"/a.gcode"}}`)), at) + if j := bad.Job; j.TimeRemaining != nil || j.TimeElapsed != nil || j.EndsAt != "" { + t.Errorf("negative times were kept: %+v", j) + } +} diff --git a/internal/server/tools_printers.go b/internal/server/tools_printers.go index 85c41c7..7fea2ed 100644 --- a/internal/server/tools_printers.go +++ b/internal/server/tools_printers.go @@ -72,7 +72,7 @@ func (s *Server) directStatus(ctx context.Context) (map[string]any, error) { if hasJob { jobMap = decode(job) } - out["summary"] = summarizeDirect(decode(status), jobMap) + out["summary"] = summarizeDirect(decode(status), jobMap, time.Now()) return out, nil } @@ -148,7 +148,7 @@ func (s *Server) addPrinterTools() { } return jsonResult(withVia(t, map[string]any{ "status": redactRaw(out), - "summary": summarizeConnect(decode(out)), + "summary": summarizeConnect(decode(out), time.Now()), })) })