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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ JSON field names and exit codes are part of the public API.
## [Unreleased]

### Added
- `timelapse list` and `timelapse pull <name>|--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.
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
30 changes: 29 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Out of scope for v0.1:
| Secrets | `zalando/go-keyring`; service `bambu`, account `<serial>` | 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 |
Expand All @@ -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 [<name> | --latest] [-o file-or-existing-directory]
bambu printer add <name> --host H --serial S [--model X1C] [--nozzle 0.4] [--plate textured_plate] [--default]
bambu printer list | remove <name> --confirm | default <name> | discover [--timeout 10s]
bambu auth set <printer> # access code from stdin (or TTY prompt)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `/<name>.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):

Expand Down
9 changes: 8 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<host>: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, `/<name>.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

Expand Down
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)."`
Expand Down
258 changes: 258 additions & 0 deletions internal/cmd/timelapse.go
Original file line number Diff line number Diff line change
@@ -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 ./<name>). 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
}
Loading
Loading