Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cmd/prusactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"]))
Expand Down Expand Up @@ -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"]))
Expand Down
45 changes: 45 additions & 0 deletions cmd/prusactl/timing.go
Original file line number Diff line number Diff line change
@@ -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)
}
33 changes: 33 additions & 0 deletions cmd/prusactl/timing_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
52 changes: 52 additions & 0 deletions internal/jobtime/jobtime.go
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 40 additions & 0 deletions internal/jobtime/jobtime_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
39 changes: 33 additions & 6 deletions internal/server/printer_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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").
Expand Down Expand Up @@ -74,34 +99,36 @@ 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
}
}
return s
}

// 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
}
}
Expand Down
39 changes: 33 additions & 6 deletions internal/server/printer_summary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" {
Expand All @@ -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)
}
Expand All @@ -55,21 +56,21 @@ 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)
}
}

// 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)
}
Expand All @@ -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)
}
}
4 changes: 2 additions & 2 deletions internal/server/tools_printers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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()),
}))
})

Expand Down
Loading