package printer import ( "bufio" "context" "fmt" "path/filepath" "regexp" "strings" "sync" "time" "go.bug.st/serial" ) // Event is a state snapshot published whenever the printer state changes, // plus a raw recv line (RecvLine != "" means "log this; State unchanged is allowed"). type Event struct { State State RecvLine string SentLine string } type Printer struct { mu sync.Mutex state State port serial.Port events chan Event writes chan string // outbound G-code (best-effort, non-blocking) cancel context.CancelFunc stopped chan struct{} } func New() *Printer { return &Printer{ state: NewState(), events: make(chan Event, 128), writes: make(chan string, 64), } } func (p *Printer) Events() <-chan Event { return p.events } func (p *Printer) Snapshot() State { p.mu.Lock() defer p.mu.Unlock() return p.state } // ListPorts returns plausible printer serial devices. func ListPorts() []string { out := []string{} for _, pat := range []string{"/dev/ttyACM*", "/dev/ttyUSB*"} { m, _ := filepath.Glob(pat) out = append(out, m...) } return out } // CommonBauds is the ordered list of baud rates tried during auto-detection. // 250000 is Marlin's historical AVR default; 115200 covers STM32 builds, // Klipper-prep firmwares, and RepRapFirmware. var CommonBauds = []int{250000, 115200, 230400, 500000, 57600} // probeBaud opens the port at each candidate baud and looks for a // recognisable Marlin/RepRap reply. Returns the working baud and an open // port handle on success. // // We deliberately require an *unambiguous* match (firmware name or a // well-formed temperature line). Short tokens like "ok" or "start" appear // too easily in misaligned-baud garbage and would yield a false positive, // so they are not accepted on their own. func probeBaud(portName string, candidates []int) (int, serial.Port, error) { tempLine := regexp.MustCompile(`(?i)(?:^|\s)(?:T\d*|B|C):\s*-?\d+(?:\.\d+)?\s*/\s*-?\d+`) for _, b := range candidates { sp, err := serial.Open(portName, &serial.Mode{BaudRate: b}) if err != nil { continue } _ = sp.SetReadTimeout(100 * time.Millisecond) // Many boards reset on DTR assertion and need ~2 s to boot. // Wait, then prod the firmware twice (once early in case the boot // already finished, once later in case the first prod was eaten). time.Sleep(400 * time.Millisecond) _, _ = sp.Write([]byte("\nM115\n")) time.Sleep(1200 * time.Millisecond) _, _ = sp.Write([]byte("\nM115\nM105\n")) deadline := time.Now().Add(1500 * time.Millisecond) buf := make([]byte, 256) var acc []byte ok := false for time.Now().Before(deadline) { n, _ := sp.Read(buf) if n == 0 { continue } acc = append(acc, buf[:n]...) low := strings.ToLower(string(acc)) if strings.Contains(low, "marlin") || strings.Contains(low, "firmware_name") || strings.Contains(low, "reprapfirmware") || strings.Contains(low, "klipper") || tempLine.MatchString(string(acc)) { ok = true break } // Cap accumulator so we don't grow unbounded on garbage. if len(acc) > 4096 { acc = acc[len(acc)-2048:] } } if ok { return b, sp, nil } _ = sp.Close() } return 0, nil, fmt.Errorf("could not auto-detect baud rate; tried %v", candidates) } func (p *Printer) Connect(portName string, baud int) error { p.Disconnect() // Candidate ports: caller-specified, or everything that looks like a // printer. We probe each in turn so that systems with multiple ACM/USB // devices (modems, debug probes, etc.) don't trap us on the wrong one. var candidates []string if portName != "" { candidates = []string{portName} } else { candidates = ListPorts() if len(candidates) == 0 { return fmt.Errorf("no serial port found") } } var sp serial.Port var chosenPort string if baud <= 0 { var lastErr error for _, pn := range candidates { detected, opened, err := probeBaud(pn, CommonBauds) if err != nil { lastErr = err continue } sp = opened baud = detected chosenPort = pn break } if sp == nil { if lastErr != nil { return fmt.Errorf("no responsive printer on %v: %w", candidates, lastErr) } return fmt.Errorf("no responsive printer on %v", candidates) } } else { // Explicit baud: still try each candidate port until one opens and // looks alive at this baud. var lastErr error for _, pn := range candidates { opened, err := serial.Open(pn, &serial.Mode{BaudRate: baud}) if err != nil { lastErr = err continue } sp = opened chosenPort = pn break } if sp == nil { return fmt.Errorf("could not open any of %v: %w", candidates, lastErr) } } portName = chosenPort _ = sp.SetReadTimeout(500 * time.Millisecond) p.mu.Lock() p.port = sp p.state.Connected = true p.state.Port = portName p.state.Baud = baud p.state.ErrorMsg = "" snap := p.state p.mu.Unlock() p.publish(Event{State: snap}) ctx, cancel := context.WithCancel(context.Background()) p.cancel = cancel p.stopped = make(chan struct{}) go p.run(ctx) return nil } func (p *Printer) Disconnect() { if p.cancel != nil { p.cancel() <-p.stopped p.cancel = nil } p.mu.Lock() if p.port != nil { _ = p.port.Close() p.port = nil } p.state.Connected = false p.state.Online = false p.state.PrintState = StateIdle snap := p.state p.mu.Unlock() p.publish(Event{State: snap}) } // run is the connection goroutine. It owns the serial port for the duration // of the connection and serialises reads/writes through this single goroutine. func (p *Printer) run(ctx context.Context) { defer close(p.stopped) port := p.port if port == nil { return } reader := bufio.NewReader(port) lineCh := make(chan string, 32) // reader goroutine go func() { for { line, err := reader.ReadString('\n') if err != nil { if ctx.Err() != nil { close(lineCh) return } // timeout — ReadString returns partial+err; just retry if line == "" { continue } } line = strings.TrimRight(line, "\r\n") if line == "" { continue } select { case lineCh <- line: case <-ctx.Done(): close(lineCh) return } } }() // Probe — these prime auto-reporting on firmwares that support it. primed := false primeAfter := time.NewTimer(2 * time.Second) defer primeAfter.Stop() pollTemp := time.NewTicker(2 * time.Second) defer pollTemp.Stop() pollSD := time.NewTicker(5 * time.Second) defer pollSD.Stop() prime := func() { if primed { return } primed = true p.writeRaw("M115") // Disable any auto-reporting Marlin may have on — our own polling is // the single source of truth. Otherwise the two streams collide and // produce mangled output like "TT::57.4957.49". p.writeRaw("M155 S0") p.writeRaw("M27 S0") // First explicit poll so users see data fast p.writeRaw("M105") p.writeRaw("M27") } for { select { case <-ctx.Done(): return case <-primeAfter.C: // Some firmwares emit "start" / "Marlin" before being ready; just prime now. prime() // Mark online if we got *anything* by now p.setOnline(true) case line, ok := <-lineCh: if !ok { return } // "start" / "Marlin" / "ok" gates online low := strings.ToLower(line) if strings.Contains(low, "start") || strings.HasPrefix(low, "marlin") { prime() p.setOnline(true) } if strings.HasPrefix(low, "ok") { p.setOnline(true) } if strings.HasPrefix(low, "error") || strings.HasPrefix(low, "!!") { p.setError(line) } p.mu.Lock() p.state.Parse(line) snap := p.state p.mu.Unlock() p.publish(Event{RecvLine: line, State: snap}) case <-pollTemp.C: if !primed { continue } p.writeRaw("M105") case <-pollSD.C: if !primed { continue } p.writeRaw("M27") case gcode := <-p.writes: p.writeRaw(gcode) } } } func (p *Printer) writeRaw(gcode string) { p.mu.Lock() port := p.port p.mu.Unlock() if port == nil { return } line := strings.TrimSpace(gcode) if line == "" { return } _, err := port.Write([]byte(line + "\n")) if err != nil { p.setError(err.Error()) return } p.publish(Event{SentLine: line, State: p.Snapshot()}) } // Send enqueues a raw G-code line. Non-blocking; drops if buffer full. func (p *Printer) Send(gcode string) { select { case p.writes <- gcode: default: } } // -- high-level commands -------------------------------------------------- func (p *Printer) SetHotend(temp int, tool int) { p.Send(fmt.Sprintf("M104 T%d S%d", tool, temp)) } func (p *Printer) SetBed(temp int) { p.Send(fmt.Sprintf("M140 S%d", temp)) } func (p *Printer) SetFan(pct int) { pct = clamp(pct, 0, 100) if pct == 0 { p.Send("M107") } else { p.Send(fmt.Sprintf("M106 S%d", pct*255/100)) } p.mu.Lock() p.state.FanPct = pct snap := p.state p.mu.Unlock() p.publish(Event{State: snap}) } func (p *Printer) SetFeedrate(pct int) { pct = clamp(pct, 10, 300) p.Send(fmt.Sprintf("M220 S%d", pct)) p.mu.Lock() p.state.FeedratePct = pct snap := p.state p.mu.Unlock() p.publish(Event{State: snap}) } func (p *Printer) SetFlow(pct int) { pct = clamp(pct, 50, 150) p.Send(fmt.Sprintf("M221 S%d", pct)) p.mu.Lock() p.state.FlowPct = pct snap := p.state p.mu.Unlock() p.publish(Event{State: snap}) } func (p *Printer) BabystepZ(delta float64) { p.Send(fmt.Sprintf("M290 Z%.3f", delta)) p.mu.Lock() p.state.BabystepZ = roundTo(p.state.BabystepZ+delta, 3) snap := p.state p.mu.Unlock() p.publish(Event{State: snap}) } func (p *Printer) ResetBabystep() { p.mu.Lock() delta := -p.state.BabystepZ p.state.BabystepZ = 0 snap := p.state p.mu.Unlock() p.Send(fmt.Sprintf("M290 Z%.3f", delta)) p.publish(Event{State: snap}) } func (p *Printer) Pause() { p.mu.Lock() if p.state.PrintState == StateSDPrinting { p.state.PrintState = StatePaused } snap := p.state p.mu.Unlock() p.Send("M25") p.publish(Event{State: snap}) } func (p *Printer) Resume() { p.mu.Lock() if p.state.PrintState == StatePaused && p.state.SDTotal > 0 { p.state.PrintState = StateSDPrinting } snap := p.state p.mu.Unlock() p.Send("M24") p.publish(Event{State: snap}) } // ListSDFiles asks the printer to enumerate its SD card. The reply is // parsed asynchronously; the next State snapshot whose SDListing flips // false carries the complete listing in SDFiles. func (p *Printer) ListSDFiles() { p.Send("M21") // init / mount SD (no-op if already mounted) p.Send("M20") } // StartSDPrint selects the given filename on the SD card and starts it. // M23 selects, M24 begins. Filename must be the 8.3 short name that // Marlin's M20 listing reports. func (p *Printer) StartSDPrint(filename string) { filename = strings.TrimSpace(filename) if filename == "" { return } p.Send("M23 " + filename) p.Send("M24") } func (p *Printer) Cancel() { p.Send("M524") p.mu.Lock() p.state.PrintState = StateIdle p.state.SDByte, p.state.SDTotal = 0, 0 p.state.SDStart = zeroTime snap := p.state p.mu.Unlock() p.publish(Event{State: snap}) } // -- internals ------------------------------------------------------------ func (p *Printer) setOnline(v bool) { p.mu.Lock() if p.state.Online == v { p.mu.Unlock() return } p.state.Online = v snap := p.state p.mu.Unlock() p.publish(Event{State: snap}) } func (p *Printer) setError(msg string) { p.mu.Lock() p.state.ErrorMsg = msg p.state.PrintState = StateError snap := p.state p.mu.Unlock() p.publish(Event{State: snap, RecvLine: "ERR " + msg}) } func (p *Printer) publish(e Event) { select { case p.events <- e: default: // drop on full channel — UI will catch up on the next event } } func clamp(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v } func roundTo(v float64, places int) float64 { pow := 1.0 for i := 0; i < places; i++ { pow *= 10 } return float64(int64(v*pow+0.5*sign(v))) / pow } func sign(v float64) float64 { if v < 0 { return -1 } return 1 }