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

### Added
- Multi-model `slice` keeps separate objects and stored orientation, using Studio's arrange with `--part-spacing` (default/minimum 3 mm). Batches must fit on one plate. Summaries add `models` and `part_spacing_mm`; `model` remains the first input.
- X1C `start_line_clearance` preflight gate checks first-layer object lines and arcs against the front startup zones with a 1 mm margin. Collisions fail; unverified toolpaths fail in strict mode and send dry runs. Job JSON adds `first_layer_clearance` evidence.
- `[print] timelapse` sets the recording default (on when omitted). `print send --no-timelapse` opts out for one print; the existing `--timelapse` explicitly opts in.
- `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.
Expand All @@ -26,6 +29,7 @@ JSON field names and exit codes are part of the public API.
- job: `filament_changes`

### Changed
- `print send` records a timelapse by default.
- `preflight` `single_filament` now fails only when the number of `--slot`s differs from the number of filaments in the plate. Single-filament jobs send the same payload as before.

### Fixed
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,30 @@ It is never printed, and never written to the config.
bambu recipe list # prototype-pla, solid-pla, functional-pla, functional-petg
bambu slice part.3mf --recipe solid-pla # STL / 3MF / OBJ
bambu slice part.stl --recipe prototype-pla --set wall_loops=3 --filament "Bambu PLA Matte"
bambu slice one.stl two.stl three.stl --recipe solid-pla --part-spacing 3
bambu camera snapshot -o plate.jpg # look at the plate
bambu preflight part-solid-pla.gcode.3mf --slot 4 # PASS/WARN/FAIL gates
bambu print send part-solid-pla.gcode.3mf --slot 4 --confirm
bambu monitor --snapshot-at-layer 2 --exec 'notify-send "$BAMBU_EVENT $BAMBU_STATE"'
```

Multiple inputs remain separate objects. Studio arranges them on one plate with at least
3 mm between parts; `--part-spacing` can increase that gap. Orientation is preserved
unless `--auto-orient` is given. Batches that need several plates fail before publishing.

`print send` records a timelapse by default. `--no-timelapse` disables it for one print;
`--timelapse` explicitly enables it. To change the default in `config.toml`:

```toml
[print]
timelapse = false
```

X1C preflight checks first-layer object extrusion against the startup lines at
X18–240 / Y0–12 and the X0–18 / Y0–28 corner, with a 1 mm margin. Collisions fail.
Missing or unsupported toolpaths report NOT VERIFIED and fail with `--strict` or
`print send --dry-run`. Travel and startup extrusion are excluded.

### Two colours

A two-filament job changes colour at a layer height, and the AMS swaps by itself: no pause, nobody at the printer.
Expand Down
30 changes: 27 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ Top-level verbs are kept for the high-frequency, single-purpose actions an opera

```
bambu status [--watch] # read-only; --watch streams NDJSON until Ctrl-C
bambu slice <model> --recipe R [--filament PRESET] [--set k=v]... [--name N] [--out DIR] [--auto-orient]
bambu slice <model>... --recipe R [--part-spacing 3] [--filament PRESET] [--set k=v]... [--name N] [--out DIR] [--auto-orient]
bambu preflight <file.gcode.3mf> --slot S [--slot S2]... [--no-ftp]
bambu print send <file.gcode.3mf> --slot S [--slot S2]... (--dry-run | --confirm) [--timelapse] [--wait 180s]
bambu print send <file.gcode.3mf> --slot S [--slot S2]... (--dry-run | --confirm) [--timelapse | --no-timelapse] [--wait 180s]
bambu print pause|stop --confirm
bambu print resume --confirm [--wait 30s] # exit 0 only once the printer reports RUNNING
bambu filament load --slot S --confirm [--timeout 5m] # paused or idle; exit 0 only once S is in the toolhead
Expand Down Expand Up @@ -183,7 +183,7 @@ Precedence:
{"print":{"sequence_id":"<n>","command":"project_file","param":"Metadata/plate_1.gcode",
"url":"file:///sdcard/<name>.gcode.3mf","subtask_name":"<name>","md5":"<md5 of Metadata/plate_1.gcode>",
"project_id":"0","profile_id":"0","task_id":"0","subtask_id":"0","bed_type":"auto",
"timelapse":false,"bed_leveling":true,"flow_cali":true,"vibration_cali":true,"layer_inspect":true,
"timelapse":true,"bed_leveling":true,"flow_cali":true,"vibration_cali":true,"layer_inspect":true,
"use_ams":true,"ams_mapping":[<tray id per project filament, -1 unused>]}}
```

Expand Down Expand Up @@ -231,6 +231,15 @@ Missing or malformed detection remains unknown, rather than evidence of an empty

## 8. Slicing

Multiple inputs stay separate objects. Studio arranges with rotations disabled and
`--orient 0` unless explicitly requested. `--part-spacing` defaults to 3 mm and
must be finite and at least 3. Studio's CLI exposes spacing through sequential
object clearance, so a batch first exports an arrange-only project using that
clearance, then slices with the original machine/process presets, `--arrange 0`
and `--orient 0`. Temporary sequential settings never reach the sliced file.
A batch requiring multiple sliced plates fails before publishing. `model` in the
summary remains the first input; `models` lists all absolute input paths.

**Slicer discovery**, in order:
1. `BAMBU_STUDIO_PATH` / `slicer.path`
2. macOS: `/Applications/BambuStudio.app`, `~/Applications/BambuStudio.app`
Expand Down Expand Up @@ -362,3 +371,18 @@ internal/errfmt/ exit codes, typed errors with hints
4. 3MF inspection + preflight + payload; `print send --dry-run`.
5. FTPS client; `print send`, control commands; `monitor`, `camera`.
6. Discovery, schema, docs, site, skill, release config.

### Recording defaults and X1C startup clearance

`print send` resolves timelapse as explicit `--timelapse` / `--no-timelapse`, then
`[print] timelapse`, then true. Giving both flags is a usage error. Config saves
preserve an explicit false; the MQTT `timelapse` field remains boolean.

`job.first_layer_clearance` reports verified status, object extrusion and collision
counts, the 1 mm margin, and an optional unverified reason. The parser follows
layer-1 `OBJECT_ID` markers, G90/G91 XY, M82/M83 E, G92 E resets and G0/G1/G2/G3
paths (relative I/J arc centres). Startup, travel, retractions and non-object
paths are excluded. X1C `start_line_clearance` fails intersections with X18–240,
Y0–12 or X0–18, Y0–28, expanded by 1 mm. Missing markers or unsupported object
arcs are NOT VERIFIED: WARN normally, FAIL in strict mode or send dry runs.
Other printer models do not acquire these X1C zones.
2 changes: 1 addition & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Repeating the same settings published nothing. Other printer models remain unver
{"print":{"sequence_id":"<n>","command":"project_file","param":"Metadata/plate_1.gcode",
"url":"file:///sdcard/<name>.gcode.3mf","subtask_name":"<name>","md5":"<md5 of Metadata/plate_1.gcode>",
"project_id":"0","profile_id":"0","task_id":"0","subtask_id":"0","bed_type":"auto",
"timelapse":false,"bed_leveling":true,"flow_cali":true,"vibration_cali":true,"layer_inspect":true,
"timelapse":true,"bed_leveling":true,"flow_cali":true,"vibration_cali":true,"layer_inspect":true,
"use_ams":true,"ams_mapping":[3]}}
```

Expand Down
31 changes: 24 additions & 7 deletions internal/cmd/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"strings"
"time"

"github.com/alecthomas/kong"

"github.com/voska/bambu/internal/config"
"github.com/voska/bambu/internal/errfmt"
"github.com/voska/bambu/internal/ftps"
Expand All @@ -35,7 +37,7 @@ type PreflightCmd struct {

// Run executes the command.
func (c *PreflightCmd) Run(g *Globals) error {
pc, err := runPreflight(g, c.File, c.Slot, c.FilamentRemaining, !c.NoFTP, c.Strict)
pc, err := runPreflight(g, c.File, c.Slot, c.FilamentRemaining, !c.NoFTP, c.Strict, false)
if err != nil {
return err
}
Expand All @@ -58,7 +60,7 @@ type preflightCtx struct {
code string
}

func runPreflight(g *Globals, file string, slots, estimates []string, checkFTP, strict bool) (*preflightCtx, error) {
func runPreflight(g *Globals, file string, slots, estimates []string, checkFTP, strict, requireClearance bool) (*preflightCtx, error) {
trays := make([]int, 0, len(slots))
for _, slot := range slots {
tray, err := printer.ParseSlot(slot)
Expand Down Expand Up @@ -96,7 +98,7 @@ func runPreflight(g *Globals, file string, slots, estimates []string, checkFTP,
conn.Close()
return nil, err
}
in := preflight.Input{Printer: p, Job: j, Status: printer.Summarize(raw), TrayIDs: trays, Strict: strict, Remaining: remaining}
in := preflight.Input{Printer: p, Job: j, Status: printer.Summarize(raw), TrayIDs: trays, Strict: strict, RequireClearance: requireClearance, Remaining: remaining}
if checkFTP {
in.FTPSCheck = func() error {
ctx, cancel := context.WithTimeout(g.Ctx, 30*time.Second)
Expand Down Expand Up @@ -192,7 +194,8 @@ type SendCmd struct {
Slot []string `short:"s" required:"" sep:"none" help:"AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1)."`
Confirm bool `help:"Required to actually print. Only pass it after a human approved this exact file and confirmed the plate is clear."`
DryRun bool `short:"n" name:"dry-run" help:"Run preflight and show the exact upload path and MQTT payload; send nothing."`
Timelapse bool `help:"Record a timelapse."`
Timelapse bool `xor:"timelapse" help:"Record a timelapse (default: on, or [print] timelapse in config)."`
NoTimelapse bool `xor:"timelapse" help:"Disable timelapse recording for this print."`
Wait time.Duration `default:"180s" help:"How long to wait for the job to start."`
Strict bool `help:"Require exact filament material and valid temperature bounds (bambu-op operator gates)."`
FilamentRemaining []string `sep:"none" placeholder:"SLOT=Ng" help:"Grams left on a spool you checked, e.g. A2=50g, for this invocation only. Repeat per slot; sufficient estimates WARN (also with --strict), insufficient estimates FAIL."`
Expand Down Expand Up @@ -226,12 +229,12 @@ type sendResult struct {
}

// Run executes the command.
func (c *SendCmd) Run(g *Globals) error {
func (c *SendCmd) Run(g *Globals, parsed *kong.Context) error {
if !c.DryRun && !c.Confirm {
return errfmt.New(errfmt.ExitGate, "refusing to print without --confirm").
WithHint("preview with --dry-run; pass --confirm only after a human approved this file and confirmed the plate is clear")
}
pc, err := runPreflight(g, c.File, c.Slot, c.FilamentRemaining, true, c.Strict)
pc, err := runPreflight(g, c.File, c.Slot, c.FilamentRemaining, true, c.Strict, c.DryRun)
if err != nil {
return err
}
Expand All @@ -243,7 +246,21 @@ func (c *SendCmd) Run(g *Globals) error {
if err != nil {
return errfmt.Wrap(errfmt.ExitError, err, "read %s", pc.job.File)
}
body := job.ProjectFile(pc.job, remote, pc.result.TrayIDs, c.Timelapse)
cfg, err := g.ConfigFile()
if err != nil {
return err
}
timelapse := cfg.Print.RecordTimelapse()
for _, trace := range parsed.Path {
if trace.Flag != nil && trace.Flag.Name == "timelapse" {
timelapse = c.Timelapse
break
}
}
if c.NoTimelapse {
timelapse = false
}
body := job.ProjectFile(pc.job, remote, pc.result.TrayIDs, timelapse)
plan.MQTT.Topic, plan.MQTT.QoS = "device/"+pc.p.Serial+"/request", 1
plan.MQTT.Payload = map[string]any{"print": withSeq(body)}
res := sendResult{Printer: pc.p.Name, DryRun: c.DryRun, Preflight: pc.result, Plan: plan}
Expand Down
81 changes: 81 additions & 0 deletions internal/cmd/print_defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package cmd

import (
"os"
"testing"

"github.com/voska/bambu/internal/errfmt"
"github.com/voska/bambu/internal/testutil"
)

func TestSendTimelapseDefaults(t *testing.T) {
for _, tt := range []struct {
name, config string
flags []string
want bool
code int
}{
{name: "default", want: true},
{name: "opt out", flags: []string{"--no-timelapse"}},
{name: "explicit false", flags: []string{"--timelapse=false"}},
{name: "configured off", config: "[print]\ntimelapse = false\n"},
{name: "configured on", config: "[print]\ntimelapse = true\n", want: true},
{name: "explicit on", config: "[print]\ntimelapse = false\n", flags: []string{"--timelapse"}, want: true},
{name: "explicit off", config: "[print]\ntimelapse = true\n", flags: []string{"--no-timelapse"}},
{name: "conflicting flags", flags: []string{"--timelapse", "--no-timelapse"}, code: errfmt.ExitUsage},
} {
t.Run(tt.name, func(t *testing.T) {
h := newHarness(t, nil)
f := h.job(testutil.Opts{Filaments: []string{"PLA:GFL99:13.5"}})
cfg, err := os.OpenFile(os.Getenv("BAMBU_CONFIG"), os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
_, err = cfg.WriteString(tt.config)
if err != nil {
t.Fatal(err)
}
_ = cfg.Close()
args := append([]string{"print", "send", f, "--slot", "A4", "--dry-run", "--json"}, tt.flags...)
if code := h.run(args...); code != tt.code {
t.Fatalf("exit=%d want=%d: %s %s", code, tt.code, h.stdout.String(), h.stderr.String())
}
if tt.code != 0 {
return
}
var r sendResult
h.json(&r)
if got := r.Plan.MQTT.Payload["print"].(map[string]any)["timelapse"]; got != tt.want {
t.Fatalf("timelapse=%v want=%v", got, tt.want)
}
if len(h.conn.Commands) != 0 {
t.Fatal("dry run published")
}
if _, ok := h.ftp.File("/part.gcode.3mf"); ok {
t.Fatal("dry run uploaded")
}
})
}
}

func TestSendClearanceBlocksWrites(t *testing.T) {
for _, gcode := range []string{
"G90\nM83\n; layer num/total_layer_count: 1/2\n; OBJECT_ID: 1\nG1 X50 Y10\nG1 X60 Y10 E1\n",
"G28\n",
} {
for _, flags := range [][]string{{"--dry-run"}, {"--dry-run", "--strict"}, {"--confirm", "--strict"}} {
h := newHarness(t, nil)
f := h.job(testutil.Opts{Gcode: gcode, Filaments: []string{"PLA:GFL99:13.5"}})
args := append([]string{"print", "send", f, "--slot", "A4", "--json"}, flags...)
if code := h.run(args...); code != errfmt.ExitGate {
t.Fatalf("exit %d: %s %s", code, h.stdout.String(), h.stderr.String())
}
if len(h.conn.Commands) != 0 {
t.Fatal("rejected send published")
}
if _, ok := h.ftp.File("/part.gcode.3mf"); ok {
t.Fatal("rejected send uploaded")
}
}
}
}
Loading
Loading