Initial commit for i0T.app and migration here with a fresh repo
This commit is contained in:
529
go/internal/printer/printer.go
Normal file
529
go/internal/printer/printer.go
Normal file
@@ -0,0 +1,529 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user