diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d7fb9..e130c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ JSON field names and exit codes are part of the public API. ## [Unreleased] ### Added +- `timelapse list` and `timelapse pull |--latest [-o path]` read SD-card videos over FTPS with the existing access-code resolver. Listings include byte counts and modification times; downloads verify size and stable metadata, refuse overwrites, and never modify printer files. - `ams set` records non-RFID spool metadata while idle, with physical filament detection, confirmation, dry-run support, and verified read-back. Status trays include nullable `present` physical detection without changing `loaded`. - Repeatable `--filament-remaining A2=50g` on `preflight` and `print send` for a checked spool when AMS remaining telemetry is wrong. Sufficient estimates WARN with the stated amount and original AMS reading; insufficient estimates still FAIL against the existing 15% + 5 g reserve, including with `--strict`. Estimates apply only to the current invocation. JSON amount gates include an optional `operator_estimate` with both sources and the required grams. - Operator parity with `bambu-op`: verified `light status|on|off`, chamber-light telemetry, and `--light` snapshots that restore the previous light mode even on failure or interruption. diff --git a/README.md b/README.md index f32df8b..8b6af06 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,30 @@ the tray's flow calibration unchanged and reports any calibration telemetry befo and after. Only use it after a human identifies the physical spool, never to bypass a preflight mismatch. +### Download timelapses + +```bash +bambu timelapse list --json +bambu timelapse pull --latest -o ./latest-print.mp4 +bambu timelapse pull video_2026-01-02_03-04-05.mp4 -o ./videos/ +``` + +These commands read `.mp4` and `.avi` files directly under the printer's +`/timelapse/` directory using the configured LAN access code. Lists are ordered by +modification time, then filename, newest first. JSON includes `printer` and +`timelapses` entries with `name`, `bytes`, and UTC `mtime`; an empty list exits 3. +Use `--quiet` for filenames or the absolute downloaded path, or `--plain` for TSV. +`--latest` skips zero-byte recording placeholders; requesting an empty video by +name exits 1. + +`-o` accepts a file path or an existing directory; without it, the video is saved +in the current directory under its original name. Downloads never overwrite local +files or change printer files. A failed or changing transfer leaves no completed +file and exits 8; retry after recording finishes. Publishing the verified download +requires a local filesystem with hard-link support. Recording must have been +enabled when sending the job (`print send --timelapse`). Downloaded files are +owner-only (0600). + ### Safety model 1. **`preflight` checks before anything is sent:** diff --git a/SPEC.md b/SPEC.md index 0b2a0a9..9324387 100644 --- a/SPEC.md +++ b/SPEC.md @@ -38,7 +38,7 @@ Out of scope for v0.1: | Secrets | `zalando/go-keyring`; service `bambu`, account `` | pure Go; on macOS it writes via `security -i` on stdin, so the secret is never in argv. Keyed by serial so renaming a printer keeps its code | | Access code resolution | keychain → `BAMBU_ACCESS_CODE` → (macOS/Windows/Linux) Bambu Studio's `BambuStudio.conf` (`access_code`/`user_access_code` by serial), used only as a fallback | explicit, discoverable; source is reported by `auth status` | | MQTT | `eclipse/paho.mqtt.golang` | stable, small | -| FTPS | small built-in implicit-TLS client (`internal/ftps`) | the printer requires TLS session reuse on data connections and implicit TLS on 990; we need STOR, RETR, NLST, SIZE only | +| FTPS | small built-in implicit-TLS client (`internal/ftps`) | the printer requires TLS session reuse on data connections and implicit TLS on 990; STOR, RETR, NLST, SIZE, MDTM | | Camera | shell out to `ffmpeg` (`-rtsp_transport tcp -tls_verify 0`) | ffmpeg ≥ 8 verifies TLS by default; the printer cert is self-signed | | Slicer | Bambu Studio CLI, presets flattened by us (§8) | the CLI does not resolve `inherits`/`include` | | Colors | `muesli/termenv`; honours `NO_COLOR` / `--no-color` | qbo-cli pattern | @@ -59,6 +59,8 @@ bambu filament load --slot S --confirm [--timeout 5m] # paused or idle; exit 0 bambu ams set --slot S --type PLA --filament-id GFL99 --color RRGGBB --nozzle-temp-min T --nozzle-temp-max T (--dry-run | --confirm) [--wait 10s] bambu monitor [--snapshot-at-layer N] [--snapshot-dir D] [--exec CMD] [--timeout 2h] bambu camera snapshot [-o file.jpg] +bambu timelapse list +bambu timelapse pull [ | --latest] [-o file-or-existing-directory] bambu printer add --host H --serial S [--model X1C] [--nozzle 0.4] [--plate textured_plate] [--default] bambu printer list | remove --confirm | default | discover [--timeout 10s] bambu auth set # access code from stdin (or TTY prompt) @@ -88,6 +90,8 @@ Slots: `1`–`4` (first AMS) or `A1`…`D4`, plus `ext` for the external spool ( - status: state - slice: the path of the .gcode.3mf - snapshot: the path of the image + - timelapse list: video basenames, one per line + - timelapse pull: the absolute downloaded path - ams set: the normalized slot label - send: state - preflight: PASS/FAIL @@ -148,6 +152,30 @@ Precedence: - Implicit TLS on port 990, user `bblp`. Sequence: `PBSZ 0`, `PROT P`, `TYPE I`, `PASV`. - Data connections resume the control TLS session. Connect to the configured host, not the PASV IP. - Upload to `/.gcode.3mf`, then read it back (`RETR`) and compare MD5. +- Timelapses: `NLST /timelapse/`, then `SIZE` and `MDTM` for immediate `.mp4`/`.avi` + entries (case insensitive, no hidden files or recursion). `MDTM` is interpreted + as UTC per RFC 3659 and emitted as RFC 3339; X1C reports seconds, fractional + seconds are preserved if provided. Sort by mtime descending, then name descending. + Listing JSON is `{"printer":"name","timelapses":[{"name":"video.mp4","bytes":123,"mtime":"2026-01-02T03:04:05Z"}]}`. + Empty is `timelapses:[]` with exit 3. An unavailable directory or invalid metadata + is an error (exit 8), never an empty result. +- Pull requires exactly one basename or `--latest` (invalid selection exits 2; + named file absent exits 5; a named zero-byte file exits 1; latest with no + nonempty candidates exits 3). `--latest` skips zero-byte placeholders. + Output defaults to the + basename in cwd; `-o` is a file or existing directory, never stdout (`-`). + Stream `RETR` into a same-directory private temp file. Require successful + transfer completion, nonzero bytes matching the listed size, and unchanged + `SIZE`/`MDTM` afterward. Changed or incomplete transfers exit 8 and remove the + temp file. Flush and close before publishing with a non-overwriting hard link; + existing destinations or local filesystem failures exit 1. Temp files are also + removed on interruption. No remote writes or deletes are issued. + Pull JSON is `{"printer":"name","name":"video.mp4","path":"/absolute/video.mp4","bytes":123,"mtime":"2026-01-02T03:04:05Z"}`; + plain columns are printer, name, path, bytes, mtime. List plain columns are + name, bytes, mtime. File integrity is verified by byte count and stable metadata; + playback is not validated by the CLI. + Downloads use a 60-second read idle timeout; context deadlines and cancellation + still apply. Local write failures exit 1, separately from network failures (8). **`project_file` payload** (verified against X1C firmware 01.12 on 2026-09-23 → `result: SUCCESS`, then RUNNING): diff --git a/docs/protocol.md b/docs/protocol.md index 9dabe5f..667828f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -53,11 +53,18 @@ Repeating the same settings published nothing. Other printer models remain unver - **`ams_mapping`:** Bambu Studio's "v0" format (`SelectMachine.cpp::get_ams_mapping_result`). One entry per project filament; the value is the global tray id (`ams_index*4 + slot`, 0-based); `-1` = unused. `[3]` = first AMS, slot 4 (verified). A two-filament job sends `[3, 0]` for A4 then A1 (from Studio's source; not yet verified on a real print). Studio also sends `ams_mapping2` (`[{ams_id, slot_id}]`); the verified single-filament payload omits it. - **Without Developer Mode**, the reply is a failure mentioning verification and HMS `0500_0500_0001_0007`. `bambu` maps that to exit 6. -## FTPS (upload) +## FTPS (upload and read-only downloads) - **Connection:** implicit TLS on `:990`, user `bblp`. The sequence is `PBSZ 0` → `PROT P` → `TYPE I` → `PASV`. - **Data connections must resume the control connection's TLS session.** `bambu` shares one `tls.Config` with a session cache and an explicit `ServerName`, and does the data-channel TLS handshake after sending the transfer command. (verified: `NLST`, `SIZE`, `RETR` against the printer; `STOR` shares the same code path) - **Upload location:** jobs go to the SD root, `/.gcode.3mf`, the same place Bambu Studio puts them. +- **Timelapses:** `NLST /timelapse/` includes video files and a `thumbnail` + subdirectory. `timelapse list` reads immediate `.mp4`/`.avi` entries with `SIZE` + and `MDTM` (RFC 3659 UTC timestamps). The list never treats inaccessible metadata + as an empty directory. `pull` streams `RETR`, requires a successful final reply, + then compares the byte count and fresh `SIZE`/`MDTM` before publishing a local + file without overwriting. These commands issue no remote write or delete. + `NLST`, `SIZE`, and `MDTM` verified on an X1 Carbon on 2026-11-06. ## Camera diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 51c694f..231fa7c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -32,6 +32,7 @@ type CLI struct { AMS AMSCmd `cmd:"" help:"Configure the material physically loaded in an AMS slot."` Monitor MonitorCmd `cmd:"" help:"Follow the current job until it ends; exit code reflects the outcome."` Camera CameraCmd `cmd:"" help:"Printer camera."` + Timelapse TimelapseCmd `cmd:"" help:"Printer timelapse videos (read-only)."` Light LightCmd `cmd:"" help:"Read or switch the chamber light (verified by read-back)."` Printers PrinterCmd `cmd:"" name:"printer" help:"Manage configured printers."` Auth AuthCmd `cmd:"" help:"Access codes (OS keychain)."` diff --git a/internal/cmd/timelapse.go b/internal/cmd/timelapse.go new file mode 100644 index 0000000..9bbf09a --- /dev/null +++ b/internal/cmd/timelapse.go @@ -0,0 +1,258 @@ +package cmd + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/ftps" + "github.com/voska/bambu/internal/output" +) + +const timelapseDir = "/timelapse/" + +// TimelapseCmd groups read-only timelapse commands. +type TimelapseCmd struct { + List TimelapseListCmd `cmd:"" help:"List timelapse videos, newest first. Exit 3 if none."` + Pull TimelapsePullCmd `cmd:"" help:"Download one timelapse by name or --latest; never overwrites or deletes from the printer."` +} + +// TimelapseListCmd lists the printer's timelapses. +type TimelapseListCmd struct{} + +type timelapseFile struct { + Name string `json:"name"` + Bytes int64 `json:"bytes"` + MTime time.Time `json:"mtime"` +} + +func openTimelapse(g *Globals) (*ftps.Client, string, error) { + p, err := g.Target() + if err != nil { + return nil, "", err + } + code, _, err := g.Code(p) + if err != nil { + return nil, "", err + } + f, err := ftps.Dial(g.Ctx, p.Host, g.FTPSPort, "bblp", code) + return f, p.Name, err +} + +func validTimelapseName(name string) bool { + if name == "" || strings.HasPrefix(name, ".") || strings.ContainsAny(name, `/\`) { + return false + } + for _, r := range name { + if r < 0x20 || r == 0x7f { + return false + } + } + ext := strings.ToLower(filepath.Ext(name)) + return ext == ".mp4" || ext == ".avi" +} + +func timelapseMetadata(f *ftps.Client, name string) (timelapseFile, error) { + file := timelapseFile{Name: name} + var err error + file.Bytes, err = f.Size(timelapseDir + name) + if err != nil { + return file, errfmt.Wrap(errfmt.ExitRetryable, err, "read timelapse size for %q", name) + } + if file.Bytes < 0 { + return file, errfmt.New(errfmt.ExitRetryable, "invalid timelapse size for %q", name) + } + file.MTime, err = f.ModTime(timelapseDir + name) + if err != nil { + return file, errfmt.Wrap(errfmt.ExitRetryable, err, "read timelapse modification time for %q", name) + } + return file, nil +} + +func listTimelapses(g *Globals, f *ftps.Client, wanted string) ([]timelapseFile, error) { + names, err := f.List(g.Ctx, timelapseDir) + if err != nil { + return nil, errfmt.Wrap(errfmt.ExitRetryable, err, "list printer timelapses"). + WithHint("check the SD card is available and has a /timelapse/ directory") + } + files := []timelapseFile{} + for _, name := range names { + if strings.HasPrefix(name, timelapseDir) { + name = strings.TrimPrefix(name, timelapseDir) + } else { + name = strings.TrimPrefix(name, "timelapse/") + } + if !validTimelapseName(name) || (wanted != "" && wanted != name) { + continue + } + file, err := timelapseMetadata(f, name) + if err != nil { + return nil, err + } + files = append(files, file) + } + sort.Slice(files, func(i, j int) bool { + if files[i].MTime.Equal(files[j].MTime) { + return files[i].Name > files[j].Name + } + return files[i].MTime.After(files[j].MTime) + }) + return files, nil +} + +// Run executes the command. +func (c *TimelapseListCmd) Run(g *Globals) error { + f, printerName, err := openTimelapse(g) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + files, err := listTimelapses(g, f, "") + if err != nil { + return err + } + rows := [][]string{{"name", "bytes", "mtime"}} + names := make([]string, 0, len(files)) + for _, file := range files { + rows = append(rows, []string{file.Name, strconv.FormatInt(file.Bytes, 10), file.MTime.Format(time.RFC3339Nano)}) + names = append(names, file.Name) + } + if err := g.Out.Print(output.View{ + Data: map[string]any{"printer": printerName, "timelapses": files}, + Human: func(h *output.Human) { + if len(files) == 0 { + h.Line("No timelapse videos on %s. Record the next print with: bambu print send ... --timelapse", printerName) + return + } + h.Table(rows) + }, + Plain: rows, + Quiet: strings.Join(names, "\n"), + }); err != nil { + return err + } + if len(files) == 0 { + return errfmt.Exit(errfmt.ExitEmpty) + } + return nil +} + +// TimelapsePullCmd downloads one video without changing the printer. +type TimelapsePullCmd struct { + Name string `arg:"" optional:"" help:"Video basename from timelapse list (.mp4 or .avi); omit with --latest."` + Latest bool `help:"Pull the newest nonempty video (by mtime, then name)."` + Output string `short:"o" help:"Output file, or an existing directory (default ./). Never overwrites." type:"path"` +} + +// Run executes the command. +func (c *TimelapsePullCmd) Run(g *Globals) error { + if c.Latest == (c.Name != "") || (c.Name != "" && !validTimelapseName(c.Name)) || c.Output == "-" { + return errfmt.New(errfmt.ExitUsage, "use one timelapse basename or --latest, and a local output path"). + WithHint("bambu timelapse list; bambu timelapse pull --latest -o ./video.mp4") + } + f, printerName, err := openTimelapse(g) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + files, err := listTimelapses(g, f, c.Name) + if err != nil { + return err + } + if c.Latest { + for _, file := range files { + if file.Bytes > 0 { + c.Name = file.Name + break + } + } + if c.Name == "" { + return errfmt.New(errfmt.ExitEmpty, "no nonempty timelapse videos on %s", printerName) + } + } + for _, file := range files { + if file.Name != c.Name { + continue + } + if file.Bytes == 0 { + return errfmt.New(errfmt.ExitError, "timelapse %q is empty", file.Name). + WithHint("choose another video, or wait for recording to finish") + } + out := c.Output + if out == "" { + out = file.Name + } else if info, err := os.Stat(out); err == nil && info.IsDir() { + out = filepath.Join(out, file.Name) + } else if strings.HasSuffix(out, "/") || strings.HasSuffix(out, string(os.PathSeparator)) { + return errfmt.New(errfmt.ExitUsage, "output directory %q must already exist", out) + } + out, err = filepath.Abs(out) + if err != nil { + return errfmt.Wrap(errfmt.ExitError, err, "resolve output path") + } + if err := pullTimelapse(g, f, file, out); err != nil { + return err + } + return g.Out.Print(output.View{ + Data: map[string]any{"printer": printerName, "name": file.Name, "path": out, "bytes": file.Bytes, "mtime": file.MTime}, + Human: func(h *output.Human) { h.Line("%s (%d bytes)", out, file.Bytes) }, + Plain: [][]string{{"printer", "name", "path", "bytes", "mtime"}, {printerName, file.Name, out, strconv.FormatInt(file.Bytes, 10), file.MTime.Format(time.RFC3339Nano)}}, + Quiet: out, + }) + } + return errfmt.New(errfmt.ExitNotFound, "timelapse %q not found", c.Name).WithHint("run: bambu timelapse list") +} + +func pullTimelapse(g *Globals, f *ftps.Client, file timelapseFile, out string) error { + if _, err := os.Lstat(out); !os.IsNotExist(err) { + return errfmt.New(errfmt.ExitError, "cannot create %s: destination exists or is inaccessible", out). + WithHint("choose an unused output filename with -o") + } + tmp, err := os.CreateTemp(filepath.Dir(out), "."+filepath.Base(out)+".*.part") + if err != nil { + return errfmt.Wrap(errfmt.ExitError, err, "create download file") + } + defer func() { _ = os.Remove(tmp.Name()) }() + defer func() { _ = tmp.Close() }() + g.Out.Hint("Downloading %s (%d bytes)", file.Name, file.Bytes) + n, err := f.Retrieve(g.Ctx, timelapseDir+file.Name, tmp) + if err != nil { + code := errfmt.ExitRetryable + var typed *errfmt.Error + if errors.As(err, &typed) { + code = typed.Code + } + return errfmt.Wrap(code, err, "download timelapse %q", file.Name) + } + after, err := timelapseMetadata(f, file.Name) + if err != nil { + return err + } + if n == 0 || n != file.Bytes || after.Bytes != file.Bytes || !after.MTime.Equal(file.MTime) { + return errfmt.New(errfmt.ExitRetryable, "timelapse %q changed or download was incomplete (%d of %d bytes)", file.Name, n, file.Bytes). + WithHint("the video may still be recording; retry after the print finishes") + } + if err := tmp.Sync(); err != nil { + return errfmt.Wrap(errfmt.ExitError, err, "flush timelapse download") + } + if err := tmp.Close(); err != nil { + return errfmt.Wrap(errfmt.ExitError, err, "close timelapse download") + } + if err := g.Ctx.Err(); err != nil { + return errfmt.Wrap(errfmt.ExitRetryable, err, "timelapse download interrupted") + } + if err := os.Link(tmp.Name(), out); err != nil { + return errfmt.Wrap(errfmt.ExitError, err, "publish timelapse download"). + WithHint("choose an unused filename on a filesystem that supports hard links") + } + info, err := os.Stat(out) + if err != nil || info.Size() != n { + return errfmt.New(errfmt.ExitError, "download at %s could not be verified", out).WithData("path", out) + } + return nil +} diff --git a/internal/cmd/timelapse_test.go b/internal/cmd/timelapse_test.go new file mode 100644 index 0000000..9d10409 --- /dev/null +++ b/internal/cmd/timelapse_test.go @@ -0,0 +1,258 @@ +package cmd + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/ftps" +) + +func TestTimelapseList(t *testing.T) { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/old.avi"] = []byte("old") + h.ftp.Files["/timelapse/new video.mp4"] = []byte("new video") + h.ftp.ModTimes["/timelapse/new video.mp4"] = time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC) + h.ftp.Files["/timelapse/preview.jpg"] = []byte("preview") + h.ftp.Files["/other/video.mp4"] = []byte("elsewhere") + if code := h.run("timelapse", "list", "--json"); code != 0 { + t.Fatalf("list exit %d: %s", code, h.stdout.String()) + } + var result struct { + Printer string `json:"printer"` + Rows []struct { + Name string `json:"name"` + Size int64 `json:"bytes"` + MTime time.Time `json:"mtime"` + } `json:"timelapses"` + } + h.json(&result) + rows := result.Rows + if result.Printer != "shop" || len(rows) != 2 || rows[0].Name != "new video.mp4" || rows[0].Size != 9 || rows[0].MTime.IsZero() { + t.Fatalf("files: %+v", rows) + } + if code := h.run("timelapse", "list", "--plain"); code != 0 || !strings.Contains(h.stdout.String(), "name\tbytes\tmtime\n") { + t.Fatalf("plain exit %d: %s", code, h.stdout.String()) + } + if code := h.run("timelapse", "list", "-q"); code != 0 || h.stdout.String() != "new video.mp4\nold.avi\n" { + t.Fatalf("quiet exit %d: %q", code, h.stdout.String()) + } + if len(h.conn.Commands) != 0 { + t.Fatal("listing published MQTT") + } +} + +func TestTimelapseEmpty(t *testing.T) { + h := newHarness(t, nil) + if code := h.run("timelapse", "list", "--json"); code != errfmt.ExitEmpty || !strings.Contains(h.stdout.String(), `"timelapses": []`) { + t.Fatalf("empty exit %d: %s", code, h.stdout.String()) + } + if code := h.run("timelapse", "pull", "--latest", "-o", filepath.Join(h.dir, "video.mp4"), "--json"); code != errfmt.ExitEmpty { + t.Fatalf("latest empty exit %d: %s", code, h.stdout.String()) + } +} + +func TestTimelapsePull(t *testing.T) { + for _, latest := range []bool{false, true} { + t.Run(map[bool]string{false: "named", true: "latest"}[latest], func(t *testing.T) { + h := newHarness(t, nil) + payload := bytes.Repeat([]byte("video"), 1000) + h.ftp.Files["/timelapse/video.mp4"] = payload + out := filepath.Join(h.dir, "video.mp4") + args := []string{"timelapse", "pull", "video.mp4"} + if latest { + args = []string{"timelapse", "pull", "--latest"} + } + args = append(args, "-o", out, "--json") + if code := h.run(args...); code != 0 { + t.Fatalf("pull exit %d: %s", code, h.stdout.String()) + } + got, err := os.ReadFile(out) + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("download mismatch: %v", err) + } + var result struct { + Name, Path string + Bytes int64 + } + h.json(&result) + if result.Path != out || result.Name != "video.mp4" || result.Bytes != int64(len(payload)) { + t.Fatalf("result: %+v", result) + } + if code := h.run(args...); code != errfmt.ExitError { + t.Fatalf("existing destination must fail: %d %s", code, h.stdout.String()) + } + got, _ = os.ReadFile(out) + if !bytes.Equal(got, payload) || len(h.conn.Commands) != 0 { + t.Fatal("retry changed the destination or published MQTT") + } + }) + } +} + +func TestTimelapseInvalidSelection(t *testing.T) { + for _, selection := range [][]string{ + {}, {"clip.mp4", "--latest"}, {"../clip.mp4"}, {"/clip.mp4"}, {"dir/clip.mp4"}, {`dir\clip.mp4`}, {"clip\r\nDELE x.mp4"}, {"preview.jpg"}, + } { + h := newHarness(t, nil) + args := append([]string{"timelapse", "pull"}, selection...) + if code := h.run(append(args, "--json")...); code != errfmt.ExitUsage { + t.Errorf("%q exit %d: %s", selection, code, h.stdout.String()) + } + } + h := newHarness(t, nil) + if code := h.run("timelapse", "pull", "missing.mp4", "-o", filepath.Join(h.dir, "out.mp4"), "--json"); code != errfmt.ExitNotFound { + t.Fatalf("missing file exit %d: %s", code, h.stdout.String()) + } +} + +func TestTimelapseFailedDownloadLeavesNoFile(t *testing.T) { + for _, reply := range []string{"213 100", "213 -1", "213 invalid"} { + t.Run(reply, func(t *testing.T) { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/video.mp4"] = []byte("short") + h.ftp.Replies["SIZE /timelapse/video.mp4"] = reply + out := filepath.Join(h.dir, "download.mp4") + if code := h.run("timelapse", "pull", "--latest", "-o", out, "--json"); code != errfmt.ExitRetryable { + t.Fatalf("incomplete download exit %d: %s", code, h.stdout.String()) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatal("failed download published a destination") + } + parts, _ := filepath.Glob(filepath.Join(h.dir, ".*.part")) + if len(parts) != 0 { + t.Fatalf("temporary files left behind: %v", parts) + } + }) + } +} + +func TestTimelapseMetadataFailureIsNotEmpty(t *testing.T) { + for _, command := range []string{"NLST /timelapse/", "MDTM /timelapse/video.mp4"} { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/video.mp4"] = []byte("video") + h.ftp.Replies[command] = "550 unavailable" + if code := h.run("timelapse", "list", "--json"); code != errfmt.ExitRetryable { + t.Fatalf("%s failure exit %d: %s", command, code, h.stdout.String()) + } + } +} + +func TestTimelapseOutputPaths(t *testing.T) { + h := newHarness(t, nil) + t.Chdir(h.dir) + h.ftp.Files["/timelapse/a.mp4"] = []byte("older") + h.ftp.Files["/timelapse/z.MP4"] = []byte("latest") + if code := h.run("timelapse", "pull", "--latest", "-q"); code != 0 || strings.TrimSpace(h.stdout.String()) != filepath.Join(h.dir, "z.MP4") || h.stderr.Len() != 0 { + t.Fatalf("default output exit %d: %s %s", code, h.stdout.String(), h.stderr.String()) + } + if code := h.run("timelapse", "pull", "a.mp4", "-o", h.dir, "--plain"); code != 0 || !strings.Contains(h.stdout.String(), filepath.Join(h.dir, "a.mp4")) { + t.Fatalf("directory output exit %d: %s %s", code, h.stdout.String(), h.stderr.String()) + } + if code := h.run("timelapse", "pull", "a.mp4", "-o", "-", "--json"); code != errfmt.ExitUsage { + t.Fatalf("stdout output exit %d: %s", code, h.stdout.String()) + } +} + +func TestTimelapseAuthAndSchema(t *testing.T) { + h := newHarness(t, nil) + h.kr = memKeyring{} + if code := h.run("timelapse", "list", "--json"); code != errfmt.ExitAuth { + t.Fatalf("missing access code exit %d: %s", code, h.stdout.String()) + } + if code := h.run("schema", "timelapse", "pull"); code != 0 || !strings.Contains(h.stdout.String(), `"name": "--latest"`) { + t.Fatalf("schema exit %d: %s", code, h.stdout.String()) + } +} + +func TestTimelapsePullRequiresSuccessfulTransferReply(t *testing.T) { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/video.mp4"] = []byte("video") + h.ftp.RetrieveReply = "426 transfer aborted" + out := filepath.Join(h.dir, "video.mp4") + if code := h.run("timelapse", "pull", "--latest", "-o", out, "--json"); code != errfmt.ExitRetryable { + t.Fatalf("rejected transfer exit %d: %s", code, h.stdout.String()) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatal("rejected transfer published a file") + } +} + +func TestTimelapsePullRejectsChangedMetadata(t *testing.T) { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/video.mp4"] = []byte("video") + g := NewGlobals(context.Background(), &CLI{}, BuildInfo{}) + g.Out.Stdout, g.Out.Stderr = &h.stdout, &h.stderr + f, err := ftps.Dial(g.Ctx, "127.0.0.1", h.ftp.Port(), "bblp", "12345678") + if err != nil { + t.Fatal(err) + } + defer f.Close() + before := timelapseFile{Name: "video.mp4", Bytes: 5, MTime: time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)} + out := filepath.Join(h.dir, "video.mp4") + if err := pullTimelapse(g, f, before, out); err == nil || errfmt.As(err).Code != errfmt.ExitRetryable { + t.Fatalf("changed mtime: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatal("changed source published a file") + } +} + +func TestTimelapseEmptyVideo(t *testing.T) { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/z.mp4"] = nil + out := filepath.Join(h.dir, "out.mp4") + if code := h.run("timelapse", "pull", "z.mp4", "-o", out, "--json"); code != errfmt.ExitError { + t.Fatalf("empty named video exit %d: %s", code, h.stdout.String()) + } + if code := h.run("timelapse", "pull", "--latest", "-o", out, "--json"); code != errfmt.ExitEmpty { + t.Fatalf("only empty videos exit %d: %s", code, h.stdout.String()) + } + h.ftp.Files["/timelapse/a.mp4"] = []byte("valid") + if code := h.run("timelapse", "pull", "--latest", "-o", out, "--json"); code != 0 { + t.Fatalf("latest nonempty exit %d: %s", code, h.stdout.String()) + } + got, _ := os.ReadFile(out) + if string(got) != "valid" { + t.Fatalf("downloaded %q", got) + } +} + +func TestTimelapseNamedPullIgnoresOtherMetadata(t *testing.T) { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/broken.mp4"] = []byte("other") + h.ftp.Files["/timelapse/video.mp4"] = []byte("video") + h.ftp.Replies["MDTM /timelapse/broken.mp4"] = "550 unavailable" + if code := h.run("timelapse", "pull", "video.mp4", "-o", filepath.Join(h.dir, "video.mp4"), "--json"); code != 0 { + t.Fatalf("unrelated metadata blocked named pull: %d %s", code, h.stdout.String()) + } +} + +func TestTimelapsePullRefusesMissingOutputDirectory(t *testing.T) { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/video.mp4"] = []byte("video") + if code := h.run("timelapse", "pull", "--latest", "-o", filepath.Join(h.dir, "missing")+string(os.PathSeparator), "--json"); code != errfmt.ExitUsage { + t.Fatalf("missing output directory exit %d: %s", code, h.stdout.String()) + } +} + +func TestTimelapsePullPreservesExistingFile(t *testing.T) { + h := newHarness(t, nil) + h.ftp.Files["/timelapse/video.mp4"] = []byte("video") + out := filepath.Join(h.dir, "video.mp4") + if err := os.WriteFile(out, []byte("keep this"), 0o600); err != nil { + t.Fatal(err) + } + if code := h.run("timelapse", "pull", "--latest", "-o", out, "--json"); code != errfmt.ExitError { + t.Fatalf("existing output exit %d: %s", code, h.stdout.String()) + } + got, _ := os.ReadFile(out) + if string(got) != "keep this" { + t.Fatalf("overwrote existing file: %q", got) + } +} diff --git a/internal/ftps/ftps.go b/internal/ftps/ftps.go index bcc7bf2..daa951d 100644 --- a/internal/ftps/ftps.go +++ b/internal/ftps/ftps.go @@ -10,6 +10,7 @@ import ( "crypto/md5" //nolint:gosec // MD5 is the printer's integrity checksum, not a security primitive "crypto/tls" "encoding/hex" + "errors" "fmt" "io" "net" @@ -23,11 +24,13 @@ import ( // Client is an authenticated FTPS session. type Client struct { - host string - cfg *tls.Config - ctrl *textproto.Conn - raw net.Conn - timeout time.Duration + host string + cfg *tls.Config + ctrl *textproto.Conn + raw net.Conn + timeout time.Duration + deadline time.Time + stop func() bool } // Dial connects with implicit TLS, logs in and enables protected binary transfers. @@ -45,6 +48,8 @@ func Dial(ctx context.Context, host string, port int, user, pass string) (*Clien WithHint("check the printer is reachable and in LAN mode") } c := &Client{host: host, cfg: cfg, raw: conn, ctrl: textproto.NewConn(conn), timeout: 60 * time.Second} + c.deadline, _ = ctx.Deadline() + c.stop = context.AfterFunc(ctx, func() { _ = conn.Close() }) steps := []struct { cmd string code int @@ -57,6 +62,7 @@ func Dial(ctx context.Context, host string, port int, user, pass string) (*Clien _, err = c.cmd(s.code, "%s", s.cmd) } if err != nil { + c.stop() _ = conn.Close() if s.code == 230 { return nil, errfmt.Wrap(errfmt.ExitAuth, err, "FTPS login refused"). @@ -77,7 +83,11 @@ func redact(cmd string) string { // cmd sends a command (empty = just read) and expects a reply code (prefix match: 2 accepts any 2xx). func (c *Client) cmd(expect int, format string, args ...any) (string, error) { - _ = c.raw.SetDeadline(time.Now().Add(c.timeout)) + deadline := time.Now().Add(c.timeout) + if !c.deadline.IsZero() && c.deadline.Before(deadline) { + deadline = c.deadline + } + _ = c.raw.SetDeadline(deadline) if format != "" { if err := c.ctrl.PrintfLine(format, args...); err != nil { return "", fmt.Errorf("send: %w", err) @@ -107,6 +117,9 @@ func (c *Client) pasv(ctx context.Context) (net.Conn, error) { return nil, fmt.Errorf("data connection: %w", err) } _ = conn.SetDeadline(time.Now().Add(10 * time.Minute)) + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } // The TLS handshake happens lazily, after the transfer command is sent (servers accept the // data connection's TLS only once they have the command). return tls.Client(conn, c.cfg), nil @@ -135,6 +148,8 @@ func (c *Client) transfer(ctx context.Context, command string, fn func(net.Conn) if err != nil { return err } + stop := context.AfterFunc(ctx, func() { _ = data.Close() }) + defer stop() if _, err := c.cmd(1, "%s", command); err != nil { // 125/150 _ = data.Close() return err @@ -181,6 +196,64 @@ func (c *Client) MD5(ctx context.Context, path string) (string, error) { return hex.EncodeToString(h.Sum(nil)), err } +// Retrieve downloads path to w without changing the remote file. +func (c *Client) Retrieve(ctx context.Context, path string, w io.Writer) (int64, error) { + if err := checkPath(path); err != nil { + return 0, err + } + var n int64 + err := c.transfer(ctx, "RETR "+path, func(conn net.Conn) error { + // Large videos need an idle timeout, not a fixed transfer deadline. + if err := conn.SetDeadline(time.Time{}); err != nil { + return fmt.Errorf("set download deadline: %w", err) + } + buf := make([]byte, 32*1024) + for { + deadline := time.Now().Add(c.timeout) + if limit, ok := ctx.Deadline(); ok && limit.Before(deadline) { + deadline = limit + } + if err := conn.SetReadDeadline(deadline); err != nil { + return fmt.Errorf("set download read deadline: %w", err) + } + nr, readErr := conn.Read(buf) + if nr > 0 { + nw, writeErr := w.Write(buf[:nr]) + n += int64(nw) + if writeErr == nil && nw != nr { + writeErr = io.ErrShortWrite + } + if writeErr != nil { + return errfmt.Wrap(errfmt.ExitError, writeErr, "write timelapse download") + } + } + if errors.Is(readErr, io.EOF) { + return nil + } + if readErr != nil { + return fmt.Errorf("read download: %w", readErr) + } + } + }) + return n, err +} + +// ModTime returns path's modification time in UTC (RFC 3659 MDTM). +func (c *Client) ModTime(path string) (time.Time, error) { + if err := checkPath(path); err != nil { + return time.Time{}, err + } + msg, err := c.cmd(213, "MDTM %s", path) + if err != nil { + return time.Time{}, err + } + t, err := time.Parse("20060102150405", strings.TrimSpace(msg)) + if err != nil { + return time.Time{}, fmt.Errorf("bad MDTM reply %q: %w", msg, err) + } + return t, nil +} + // List returns the names in dir (NLST). func (c *Client) List(ctx context.Context, dir string) ([]string, error) { if err := checkPath(dir); err != nil { @@ -218,6 +291,7 @@ func (c *Client) Size(path string) (int64, error) { // Close ends the session. func (c *Client) Close() error { + defer c.stop() _, _ = c.cmd(221, "QUIT") return c.raw.Close() //nolint:wrapcheck // best-effort close } diff --git a/internal/ftps/ftps_test.go b/internal/ftps/ftps_test.go index 0ff5aa1..da0b3fa 100644 --- a/internal/ftps/ftps_test.go +++ b/internal/ftps/ftps_test.go @@ -5,9 +5,14 @@ import ( "context" "crypto/md5" "encoding/hex" + "errors" + "io" + "net" "strings" "testing" + "time" + "github.com/voska/bambu/internal/errfmt" "github.com/voska/bambu/internal/testutil" ) @@ -67,3 +72,105 @@ func TestParsePASV(t *testing.T) { } } } + +func TestRetrieveAndModTime(t *testing.T) { + s := testutil.NewFTPSServer(t) + s.ModTimes["/existing.gcode.3mf"] = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + c, err := Dial(context.Background(), "127.0.0.1", s.Port(), "bblp", "12345678") + if err != nil { + t.Fatal(err) + } + defer c.Close() + var buf bytes.Buffer + n, err := c.Retrieve(context.Background(), "/existing.gcode.3mf", &buf) + if err != nil || n != 5 || buf.String() != "hello" { + t.Fatalf("retrieve: %d %q %v", n, buf.String(), err) + } + mtime, err := c.ModTime("/existing.gcode.3mf") + if err != nil || !mtime.Equal(s.ModTimes["/existing.gcode.3mf"]) { + t.Fatalf("mtime: %v %v", mtime, err) + } + if _, err := c.Retrieve(context.Background(), "/bad\r\nname", &buf); err == nil { + t.Fatal("accepted command injection") + } + if _, err := c.ModTime("/bad\nname"); err == nil { + t.Fatal("accepted command injection") + } +} + +func TestModTimeRejectsMalformedReply(t *testing.T) { + s := testutil.NewFTPSServer(t) + s.Replies["MDTM /existing.gcode.3mf"] = "213 yesterday" + c, err := Dial(context.Background(), "127.0.0.1", s.Port(), "bblp", "12345678") + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err := c.ModTime("/existing.gcode.3mf"); err == nil { + t.Fatal("accepted malformed MDTM") + } +} + +func TestSessionCancellation(t *testing.T) { + s := testutil.NewFTPSServer(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c, err := Dial(ctx, "127.0.0.1", s.Port(), "bblp", "12345678") + if err != nil { + t.Fatal(err) + } + defer c.Close() + cancel() + // The context callback closes the control socket, interrupting blocked I/O. + deadline := time.Now().Add(time.Second) + for { + if _, err := c.ModTime("/existing.gcode.3mf"); err != nil { + break + } + if time.Now().After(deadline) { + t.Fatal("cancelled session still usable") + } + } +} + +type failedDownloadWriter struct{ short bool } + +func (w failedDownloadWriter) Write(p []byte) (int, error) { + if w.short { + return len(p) - 1, nil + } + return 0, io.ErrClosedPipe +} + +func TestRetrieveLocalWriteFailure(t *testing.T) { + for _, short := range []bool{false, true} { + s := testutil.NewFTPSServer(t) + c, err := Dial(context.Background(), "127.0.0.1", s.Port(), "bblp", "12345678") + if err != nil { + t.Fatal(err) + } + _, err = c.Retrieve(context.Background(), "/existing.gcode.3mf", failedDownloadWriter{short: short}) + var typed *errfmt.Error + if !errors.As(err, &typed) || typed.Code != errfmt.ExitError { + t.Errorf("local write failure must be distinguishable from network failure: %v", err) + } + c.Close() + } +} + +func TestRetrieveReadIdleTimeout(t *testing.T) { + s := testutil.NewFTPSServer(t) + s.RetrieveDelay = 500 * time.Millisecond + c, err := Dial(context.Background(), "127.0.0.1", s.Port(), "bblp", "12345678") + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.timeout = 100 * time.Millisecond + var buf bytes.Buffer + _, err = c.Retrieve(context.Background(), "/existing.gcode.3mf", &buf) + var timeout net.Error + if !errors.As(err, &timeout) || !timeout.Timeout() || !strings.Contains(err.Error(), "read download") { + t.Fatalf("stalled read must time out: %v", err) + } +} diff --git a/internal/testutil/ftpsserver.go b/internal/testutil/ftpsserver.go index 86cec03..32d0b3e 100644 --- a/internal/testutil/ftpsserver.go +++ b/internal/testutil/ftpsserver.go @@ -12,6 +12,7 @@ import ( "io" "math/big" "net" + "path" "strings" "sync" "testing" @@ -21,12 +22,16 @@ import ( // FTPSServer is a tiny implicit-FTPS server that, like the printer, refuses data connections // that do not resume the control connection's TLS session. type FTPSServer struct { - t *testing.T - ln net.Listener - cfg *tls.Config - mu sync.Mutex - Files map[string][]byte - Code string + t *testing.T + ln net.Listener + cfg *tls.Config + mu sync.Mutex + Files map[string][]byte + Code string + ModTimes map[string]time.Time + Replies map[string]string + RetrieveReply string + RetrieveDelay time.Duration } // NewFTPSServer starts a fake printer FTPS server on 127.0.0.1 (password 12345678). @@ -46,7 +51,7 @@ func NewFTPSServer(t *testing.T) *FTPSServer { if err != nil { t.Fatal(err) } - s := &FTPSServer{t: t, ln: ln, cfg: cfg, Files: map[string][]byte{"/existing.gcode.3mf": []byte("hello")}, Code: "12345678"} + s := &FTPSServer{t: t, ln: ln, cfg: cfg, Files: map[string][]byte{"/existing.gcode.3mf": []byte("hello")}, Code: "12345678", ModTimes: map[string]time.Time{}, Replies: map[string]string{}} go s.serve() t.Cleanup(func() { _ = ln.Close() }) return s @@ -91,6 +96,10 @@ func (s *FTPSServer) handle(c net.Conn) { return } cmd, arg, _ := strings.Cut(strings.TrimSpace(line), " ") + if reply, ok := s.Replies[strings.TrimSpace(line)]; ok { + say("%s", reply) + continue + } switch cmd { case "USER": say("331 password please") @@ -126,16 +135,23 @@ func (s *FTPSServer) handle(c net.Conn) { } say("150 ok") if dc, ok := accept(); ok { + time.Sleep(s.RetrieveDelay) _, _ = dc.Write(b) _ = dc.Close() - say("226 done") + if s.RetrieveReply != "" { + say("%s", s.RetrieveReply) + } else { + say("226 done") + } } case "NLST": say("150 ok") if dc, ok := accept(); ok { s.mu.Lock() for n := range s.Files { - fmt.Fprintf(dc, "%s\r\n", n) + if path.Dir(n) == path.Clean(arg) { + fmt.Fprintf(dc, "%s\r\n", n) + } } s.mu.Unlock() _ = dc.Close() @@ -150,6 +166,19 @@ func (s *FTPSServer) handle(c net.Conn) { continue } say("213 %d", len(b)) + case "MDTM": + s.mu.Lock() + _, ok := s.Files[arg] + mtime := s.ModTimes[arg] + s.mu.Unlock() + if !ok { + say("550 not found") + continue + } + if mtime.IsZero() { + mtime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + } + say("213 %s", mtime.UTC().Format("20060102150405")) case "QUIT": say("221 bye") return