Initial commit for i0T.app and migration here with a fresh repo
This commit is contained in:
142
go/internal/printer/parse.go
Normal file
142
go/internal/printer/parse.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
tempRe = regexp.MustCompile(`(T\d*|B|C):\s*(-?\d+(?:\.\d+)?)\s*/\s*(-?\d+(?:\.\d+)?)`)
|
||||
sdRe = regexp.MustCompile(`(?i)SD printing byte\s+(\d+)\s*/\s*(\d+)`)
|
||||
notSDRe = regexp.MustCompile(`(?i)Not SD printing`)
|
||||
sdDoneRe = regexp.MustCompile(`(?i)Done printing file`)
|
||||
fileOpen = regexp.MustCompile(`(?i)File opened:\s*(\S+)`)
|
||||
sdListStart = regexp.MustCompile(`(?i)^Begin file list`)
|
||||
sdListEnd = regexp.MustCompile(`(?i)^End file list`)
|
||||
sdListLongRe = regexp.MustCompile(`"([^"]+)"`)
|
||||
)
|
||||
|
||||
// Parse one line of printer output and mutate s. Returns true if anything changed.
|
||||
func (s *State) Parse(line string) bool {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
|
||||
for _, m := range tempRe.FindAllStringSubmatch(line, -1) {
|
||||
tool := m[1]
|
||||
cur, _ := strconv.ParseFloat(m[2], 64)
|
||||
tgt, _ := strconv.ParseFloat(m[3], 64)
|
||||
if s.Temps == nil {
|
||||
s.Temps = map[string]TempPair{}
|
||||
}
|
||||
s.Temps[tool] = TempPair{Current: cur, Target: tgt}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if s.Firmware == "" {
|
||||
if idx := strings.Index(line, "FIRMWARE_NAME:"); idx >= 0 {
|
||||
rest := line[idx+len("FIRMWARE_NAME:"):]
|
||||
// Cut at the next CAPS_TOKEN: marker that Marlin emits after FIRMWARE_NAME
|
||||
for _, sep := range []string{" SOURCE_CODE_URL:", " PROTOCOL_VERSION:", " MACHINE_TYPE:", " EXTRUDER_COUNT:"} {
|
||||
if i := strings.Index(rest, sep); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
}
|
||||
s.Firmware = strings.TrimSpace(rest)
|
||||
if len(s.Firmware) > 48 {
|
||||
s.Firmware = s.Firmware[:48]
|
||||
}
|
||||
if s.Firmware != "" {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m := fileOpen.FindStringSubmatch(line); m != nil {
|
||||
s.SDFilename = m[1]
|
||||
changed = true
|
||||
}
|
||||
|
||||
// SD card directory listing (M20). The firmware emits "Begin file list",
|
||||
// one entry per line, then "End file list". While we're inside that
|
||||
// block, every non-marker line is a file entry; we accumulate into
|
||||
// SDFiles. When the block ends we flip SDListing off and leave the
|
||||
// completed slice in place for the UI to consume.
|
||||
if sdListStart.MatchString(line) {
|
||||
s.SDListing = true
|
||||
s.SDFiles = s.SDFiles[:0]
|
||||
changed = true
|
||||
} else if sdListEnd.MatchString(line) {
|
||||
if s.SDListing {
|
||||
s.SDListing = false
|
||||
changed = true
|
||||
}
|
||||
} else if s.SDListing {
|
||||
if f, ok := parseSDFileEntry(line); ok {
|
||||
s.SDFiles = append(s.SDFiles, f)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if m := sdRe.FindStringSubmatch(line); m != nil {
|
||||
byteN, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
total, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
s.SDByte = byteN
|
||||
s.SDTotal = total
|
||||
if byteN > 0 && total > 0 {
|
||||
if s.PrintState != StateSDPrinting && s.PrintState != StatePaused {
|
||||
s.PrintState = StateSDPrinting
|
||||
if s.SDStart.IsZero() {
|
||||
s.SDStart = nowFn()
|
||||
}
|
||||
} else if s.SDStart.IsZero() {
|
||||
s.SDStart = nowFn()
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
} else if notSDRe.MatchString(line) {
|
||||
if s.PrintState == StateSDPrinting {
|
||||
s.PrintState = StateIdle
|
||||
s.SDByte, s.SDTotal = 0, 0
|
||||
s.SDStart = zeroTime
|
||||
changed = true
|
||||
}
|
||||
} else if sdDoneRe.MatchString(line) {
|
||||
s.PrintState = StateIdle
|
||||
s.SDByte = s.SDTotal
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// parseSDFileEntry tries to read a single line from inside the
|
||||
// "Begin file list" / "End file list" block. Marlin emits
|
||||
// "FILENAME.GCO 123456" or "FILENAME.GCO 123456 \"Long Name.gcode\"".
|
||||
// Directories are reported with a trailing "/" — we surface them as
|
||||
// entries too so the user can spot them, even though M23 won't enter them.
|
||||
func parseSDFileEntry(line string) (SDFile, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return SDFile{}, false
|
||||
}
|
||||
// Strip a trailing long-filename if present, capture it.
|
||||
long := ""
|
||||
if m := sdListLongRe.FindStringSubmatch(line); m != nil {
|
||||
long = m[1]
|
||||
line = strings.TrimSpace(line[:strings.Index(line, "\"")])
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return SDFile{}, false
|
||||
}
|
||||
name := fields[0]
|
||||
var size int64
|
||||
if len(fields) >= 2 {
|
||||
size, _ = strconv.ParseInt(fields[len(fields)-1], 10, 64)
|
||||
}
|
||||
return SDFile{Name: name, LongName: long, Size: size}, true
|
||||
}
|
||||
81
go/internal/printer/parse_test.go
Normal file
81
go/internal/printer/parse_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package printer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTemps(t *testing.T) {
|
||||
s := NewState()
|
||||
s.Parse("ok T:201.3 /210.0 B:60.2 /60.0 @:127 B@:0")
|
||||
if got := s.Temps["T"]; got.Current != 201.3 || got.Target != 210.0 {
|
||||
t.Fatalf("hotend got %+v", got)
|
||||
}
|
||||
if got := s.Temps["B"]; got.Current != 60.2 || got.Target != 60.0 {
|
||||
t.Fatalf("bed got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSDProgress(t *testing.T) {
|
||||
s := NewState()
|
||||
s.Parse("SD printing byte 12345/100000")
|
||||
if s.SDByte != 12345 || s.SDTotal != 100000 {
|
||||
t.Fatalf("got byte=%d total=%d", s.SDByte, s.SDTotal)
|
||||
}
|
||||
if s.PrintState != StateSDPrinting {
|
||||
t.Fatalf("expected SD printing state, got %v", s.PrintState)
|
||||
}
|
||||
if p := s.Progress(); p < 0.12 || p > 0.13 {
|
||||
t.Fatalf("progress %f", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNotSDPrinting(t *testing.T) {
|
||||
s := NewState()
|
||||
s.SDByte, s.SDTotal = 1, 2
|
||||
s.PrintState = StateSDPrinting
|
||||
s.Parse("Not SD printing")
|
||||
if s.PrintState != StateIdle {
|
||||
t.Fatalf("expected idle, got %v", s.PrintState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFirmware(t *testing.T) {
|
||||
s := NewState()
|
||||
s.Parse("FIRMWARE_NAME:Marlin 2.1.2 SOURCE_CODE_URL:github.com/foo")
|
||||
if s.Firmware != "Marlin 2.1.2" {
|
||||
t.Fatalf("got firmware %q", s.Firmware)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileOpened(t *testing.T) {
|
||||
s := NewState()
|
||||
s.Parse("File opened: benchy.gcode Size: 1234567")
|
||||
if s.SDFilename != "benchy.gcode" {
|
||||
t.Fatalf("got %q", s.SDFilename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSDFileListing(t *testing.T) {
|
||||
s := NewState()
|
||||
for _, line := range []string{
|
||||
"Begin file list",
|
||||
"BENCHY.GCO 1234567",
|
||||
"XYZ_CAL.GCO 50000 \"XYZ Calibration.gcode\"",
|
||||
"End file list",
|
||||
"ok",
|
||||
} {
|
||||
s.Parse(line)
|
||||
}
|
||||
if s.SDListing {
|
||||
t.Fatalf("expected SDListing false after End")
|
||||
}
|
||||
if len(s.SDFiles) != 2 {
|
||||
t.Fatalf("got %d files: %+v", len(s.SDFiles), s.SDFiles)
|
||||
}
|
||||
if s.SDFiles[0].Name != "BENCHY.GCO" || s.SDFiles[0].Size != 1234567 {
|
||||
t.Fatalf("entry 0: %+v", s.SDFiles[0])
|
||||
}
|
||||
if s.SDFiles[1].Name != "XYZ_CAL.GCO" ||
|
||||
s.SDFiles[1].Size != 50000 ||
|
||||
s.SDFiles[1].LongName != "XYZ Calibration.gcode" {
|
||||
t.Fatalf("entry 1: %+v", s.SDFiles[1])
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
115
go/internal/printer/state.go
Normal file
115
go/internal/printer/state.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package printer
|
||||
|
||||
import "time"
|
||||
|
||||
type PrintState int
|
||||
|
||||
const (
|
||||
StateIdle PrintState = iota
|
||||
StateSDPrinting
|
||||
StateHostPrinting
|
||||
StatePaused
|
||||
StateError
|
||||
)
|
||||
|
||||
func (p PrintState) String() string {
|
||||
switch p {
|
||||
case StateIdle:
|
||||
return "IDLE"
|
||||
case StateSDPrinting:
|
||||
return "SD PRINTING"
|
||||
case StateHostPrinting:
|
||||
return "PRINTING"
|
||||
case StatePaused:
|
||||
return "PAUSED"
|
||||
case StateError:
|
||||
return "ERROR"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
type TempPair struct {
|
||||
Current float64
|
||||
Target float64
|
||||
}
|
||||
|
||||
type SDFile struct {
|
||||
Name string // 8.3 short filename, what M23 takes
|
||||
LongName string // long filename if firmware reports one
|
||||
Size int64
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Connected bool
|
||||
Online bool
|
||||
Firmware string
|
||||
Port string
|
||||
Baud int
|
||||
|
||||
PrintState PrintState
|
||||
ErrorMsg string
|
||||
|
||||
Temps map[string]TempPair
|
||||
|
||||
SDByte int64
|
||||
SDTotal int64
|
||||
SDFilename string
|
||||
SDStart time.Time
|
||||
|
||||
FeedratePct int
|
||||
FlowPct int
|
||||
FanPct int
|
||||
BabystepZ float64
|
||||
|
||||
// SD card listing. SDListing is true while M20 output is being
|
||||
// collected; SDFiles is the most recently completed listing.
|
||||
SDFiles []SDFile
|
||||
SDListing bool
|
||||
}
|
||||
|
||||
func NewState() State {
|
||||
return State{
|
||||
Temps: map[string]TempPair{},
|
||||
FeedratePct: 100,
|
||||
FlowPct: 100,
|
||||
FanPct: 0,
|
||||
PrintState: StateIdle,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) Progress() float64 {
|
||||
if s.SDTotal <= 0 {
|
||||
return 0
|
||||
}
|
||||
p := float64(s.SDByte) / float64(s.SDTotal)
|
||||
if p > 1 {
|
||||
return 1
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *State) ETA() (time.Duration, bool) {
|
||||
if s.PrintState != StateSDPrinting || s.SDByte <= 0 || s.SDStart.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
elapsed := nowFn().Sub(s.SDStart)
|
||||
if elapsed < 5*time.Second {
|
||||
return 0, false
|
||||
}
|
||||
rate := float64(s.SDByte) / elapsed.Seconds()
|
||||
if rate <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
remaining := float64(s.SDTotal - s.SDByte)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return time.Duration(remaining/rate) * time.Second, true
|
||||
}
|
||||
|
||||
// indirection so tests can fix time
|
||||
var (
|
||||
nowFn = time.Now
|
||||
zeroTime = time.Time{}
|
||||
)
|
||||
596
go/internal/tui/model.go
Normal file
596
go/internal/tui/model.go
Normal file
@@ -0,0 +1,596 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/lwoodard/printcontrol/internal/printer"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
Baud int
|
||||
AutoConnect bool
|
||||
}
|
||||
|
||||
type focusArea int
|
||||
|
||||
const (
|
||||
focusNone focusArea = iota
|
||||
focusHotendInput
|
||||
focusBedInput
|
||||
focusGcodeInput
|
||||
focusSDPicker
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
cfg Config
|
||||
printer *printer.Printer
|
||||
state printer.State
|
||||
|
||||
width, height int
|
||||
|
||||
hotendInput string
|
||||
bedInput string
|
||||
gcodeInput string
|
||||
focus focusArea
|
||||
|
||||
// SD picker state. Populated when the user opens it with [s];
|
||||
// refreshed whenever the printer reports a fresh listing.
|
||||
sdPickerFiles []printer.SDFile
|
||||
sdPickerIdx int
|
||||
sdPickerWait bool // waiting for M20 reply
|
||||
|
||||
log []string
|
||||
logMax int
|
||||
flashUntil time.Time
|
||||
flashMsg string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Model {
|
||||
return &Model{
|
||||
cfg: cfg,
|
||||
printer: printer.New(),
|
||||
state: printer.NewState(),
|
||||
logMax: 200,
|
||||
}
|
||||
}
|
||||
|
||||
// -- bubbletea Model interface ---------------------------------------------
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{tickCmd(), waitEvent(m.printer)}
|
||||
if m.cfg.AutoConnect {
|
||||
cmds = append(cmds, m.connectCmd())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
type eventMsg printer.Event
|
||||
type connectResultMsg struct{ err error }
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||
}
|
||||
|
||||
func waitEvent(p *printer.Printer) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
evt, ok := <-p.Events()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return eventMsg(evt)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) connectCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := m.printer.Connect(m.cfg.Port, m.cfg.Baud)
|
||||
return connectResultMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
return m, tickCmd()
|
||||
|
||||
case eventMsg:
|
||||
evt := printer.Event(msg)
|
||||
if evt.State.Port != "" || evt.State.Connected || evt.State.PrintState != 0 || len(evt.State.Temps) > 0 {
|
||||
m.state = evt.State
|
||||
}
|
||||
if evt.RecvLine != "" {
|
||||
m.appendLog("< " + evt.RecvLine)
|
||||
}
|
||||
if evt.SentLine != "" {
|
||||
m.appendLog("> " + evt.SentLine)
|
||||
}
|
||||
// If we were waiting on an M20 listing and one just completed,
|
||||
// populate the picker.
|
||||
if m.sdPickerWait && !evt.State.SDListing && len(evt.State.SDFiles) > 0 {
|
||||
m.sdPickerFiles = append(m.sdPickerFiles[:0], evt.State.SDFiles...)
|
||||
m.sdPickerIdx = 0
|
||||
m.sdPickerWait = false
|
||||
m.focus = focusSDPicker
|
||||
}
|
||||
return m, waitEvent(m.printer)
|
||||
|
||||
case connectResultMsg:
|
||||
if msg.err != nil {
|
||||
m.flash("connect failed: " + msg.err.Error())
|
||||
} else {
|
||||
snap := m.printer.Snapshot()
|
||||
m.state = snap
|
||||
m.flash(fmt.Sprintf("connected: %s @ %d", snap.Port, snap.Baud))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// SD picker swallows all key input while open: arrows to navigate,
|
||||
// Enter to start the highlighted print, Esc to dismiss.
|
||||
if m.focus == focusSDPicker {
|
||||
switch k.Type {
|
||||
case tea.KeyEsc:
|
||||
m.focus = focusNone
|
||||
return m, nil
|
||||
case tea.KeyUp:
|
||||
if m.sdPickerIdx > 0 {
|
||||
m.sdPickerIdx--
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyDown:
|
||||
if m.sdPickerIdx < len(m.sdPickerFiles)-1 {
|
||||
m.sdPickerIdx++
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
if m.sdPickerIdx < len(m.sdPickerFiles) {
|
||||
f := m.sdPickerFiles[m.sdPickerIdx]
|
||||
m.printer.StartSDPrint(f.Name)
|
||||
m.flash("start: " + f.Name)
|
||||
}
|
||||
m.focus = focusNone
|
||||
return m, nil
|
||||
}
|
||||
// Letters j/k as a vim-style fallback for users without arrow keys.
|
||||
switch k.String() {
|
||||
case "k":
|
||||
if m.sdPickerIdx > 0 {
|
||||
m.sdPickerIdx--
|
||||
}
|
||||
case "j":
|
||||
if m.sdPickerIdx < len(m.sdPickerFiles)-1 {
|
||||
m.sdPickerIdx++
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// When a text input is focused, capture printable text + enter/esc
|
||||
if m.focus != focusNone {
|
||||
switch k.Type {
|
||||
case tea.KeyEsc:
|
||||
m.focus = focusNone
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
return m, m.commitInput()
|
||||
case tea.KeyBackspace, tea.KeyDelete:
|
||||
m.editInput(func(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return s[:len(s)-1]
|
||||
})
|
||||
return m, nil
|
||||
case tea.KeyRunes, tea.KeySpace:
|
||||
m.editInput(func(s string) string { return s + k.String() })
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch k.String() {
|
||||
case "q", "ctrl+c":
|
||||
m.printer.Disconnect()
|
||||
return m, tea.Quit
|
||||
case "c":
|
||||
m.flash("connecting…")
|
||||
return m, m.connectCmd()
|
||||
case "d":
|
||||
m.printer.Disconnect()
|
||||
return m, nil
|
||||
case "p":
|
||||
m.printer.Pause()
|
||||
case "r":
|
||||
m.printer.Resume()
|
||||
case "x":
|
||||
m.printer.Cancel()
|
||||
case "h":
|
||||
m.focus = focusHotendInput
|
||||
case "b":
|
||||
m.focus = focusBedInput
|
||||
case "g":
|
||||
m.focus = focusGcodeInput
|
||||
case "s":
|
||||
m.printer.ListSDFiles()
|
||||
m.sdPickerWait = true
|
||||
m.flash("listing SD card…")
|
||||
case "f":
|
||||
m.printer.SetFan(m.state.FanPct + 10)
|
||||
case "F":
|
||||
m.printer.SetFan(m.state.FanPct - 10)
|
||||
case "+", "=":
|
||||
m.printer.SetFeedrate(m.state.FeedratePct + 10)
|
||||
case "-", "_":
|
||||
m.printer.SetFeedrate(m.state.FeedratePct - 10)
|
||||
case "0":
|
||||
m.printer.SetFeedrate(100)
|
||||
case "[":
|
||||
m.printer.SetFlow(m.state.FlowPct - 5)
|
||||
case "]":
|
||||
m.printer.SetFlow(m.state.FlowPct + 5)
|
||||
case "{":
|
||||
m.printer.BabystepZ(-0.05)
|
||||
case "}":
|
||||
m.printer.BabystepZ(0.05)
|
||||
case "Z":
|
||||
m.printer.ResetBabystep()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) editInput(fn func(string) string) {
|
||||
switch m.focus {
|
||||
case focusHotendInput:
|
||||
m.hotendInput = fn(m.hotendInput)
|
||||
case focusBedInput:
|
||||
m.bedInput = fn(m.bedInput)
|
||||
case focusGcodeInput:
|
||||
m.gcodeInput = fn(m.gcodeInput)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) commitInput() tea.Cmd {
|
||||
switch m.focus {
|
||||
case focusHotendInput:
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(m.hotendInput)); err == nil {
|
||||
m.printer.SetHotend(v, 0)
|
||||
m.flash(fmt.Sprintf("hotend → %d °C", v))
|
||||
}
|
||||
m.hotendInput = ""
|
||||
case focusBedInput:
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(m.bedInput)); err == nil {
|
||||
m.printer.SetBed(v)
|
||||
m.flash(fmt.Sprintf("bed → %d °C", v))
|
||||
}
|
||||
m.bedInput = ""
|
||||
case focusGcodeInput:
|
||||
if line := strings.TrimSpace(m.gcodeInput); line != "" {
|
||||
m.printer.Send(line)
|
||||
}
|
||||
m.gcodeInput = ""
|
||||
}
|
||||
m.focus = focusNone
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) flash(msg string) {
|
||||
m.flashMsg = msg
|
||||
m.flashUntil = time.Now().Add(3 * time.Second)
|
||||
m.appendLog("· " + msg)
|
||||
}
|
||||
|
||||
func (m *Model) appendLog(line string) {
|
||||
m.log = append(m.log, line)
|
||||
if len(m.log) > m.logMax {
|
||||
m.log = m.log[len(m.log)-m.logMax:]
|
||||
}
|
||||
}
|
||||
|
||||
// -- view ------------------------------------------------------------------
|
||||
|
||||
func (m *Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "starting…"
|
||||
}
|
||||
|
||||
// Two columns (status+controls vs temps) above a wide log
|
||||
leftW := (m.width - 4) / 2
|
||||
rightW := m.width - 4 - leftW
|
||||
if leftW < 30 {
|
||||
leftW = m.width - 4
|
||||
rightW = 0
|
||||
}
|
||||
|
||||
status := panelStyle.Width(leftW).Render(m.renderStatus())
|
||||
temps := panelStyle.Width(rightW).Render(m.renderTemps())
|
||||
controls := panelStyle.Width(m.width - 4).Render(m.renderControls())
|
||||
|
||||
top := lipgloss.JoinHorizontal(lipgloss.Top, status, temps)
|
||||
|
||||
header := titleStyle.Render(" printcontrol ") + dimStyle.Render(" · 3D printer TUI")
|
||||
footer := m.renderFooter()
|
||||
|
||||
// Reserve room for header + top + controls + footer (plus panel chrome).
|
||||
// Whatever rows are left go to the log; if the terminal is short we keep
|
||||
// the log to two lines so the footer never falls off.
|
||||
reserved := lipgloss.Height(header) +
|
||||
lipgloss.Height(top) +
|
||||
lipgloss.Height(controls) +
|
||||
lipgloss.Height(footer)
|
||||
logRows := m.height - reserved - 3 // 3 = log panel border + title
|
||||
if logRows < 2 {
|
||||
logRows = 2
|
||||
}
|
||||
if logRows > 12 {
|
||||
logRows = 12
|
||||
}
|
||||
logBox := panelStyle.Width(m.width - 4).Render(m.renderLog(logRows))
|
||||
|
||||
// SD picker takes over the log slot when open — keeps the rest of the
|
||||
// status panels visible while the user is choosing.
|
||||
if m.focus == focusSDPicker {
|
||||
logBox = panelStyle.Width(m.width - 4).Render(m.renderSDPicker(logRows))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, top, controls, logBox, footer)
|
||||
}
|
||||
|
||||
func (m *Model) renderStatus() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("● Status") + "\n")
|
||||
|
||||
conn := "disconnected"
|
||||
if m.state.Connected {
|
||||
conn = fmt.Sprintf("%s @ %d", m.state.Port, m.state.Baud)
|
||||
if m.state.Firmware != "" {
|
||||
conn += dimStyle.Render(" · " + m.state.Firmware)
|
||||
}
|
||||
}
|
||||
b.WriteString(kv("Connection:", conn) + " " + statePill(m.state.PrintState) + "\n")
|
||||
|
||||
file := m.state.SDFilename
|
||||
if file == "" {
|
||||
file = "—"
|
||||
}
|
||||
b.WriteString(kv("File:", file) + "\n")
|
||||
|
||||
prog := int(m.state.Progress() * 100)
|
||||
bar := progressBar(prog, 30)
|
||||
b.WriteString(kv("Progress:", fmt.Sprintf("%s %3d%%", bar, prog)) + "\n")
|
||||
|
||||
etaStr := "—"
|
||||
if eta, ok := m.state.ETA(); ok {
|
||||
etaStr = formatDuration(eta)
|
||||
}
|
||||
b.WriteString(kv("ETA:", etaStr))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Model) renderTemps() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("● Temperatures") + "\n")
|
||||
|
||||
hot := m.state.Temps["T"]
|
||||
if (hot == printer.TempPair{}) {
|
||||
hot = m.state.Temps["T0"]
|
||||
}
|
||||
b.WriteString(kv("Hotend:", tempStr(hot)) + "\n")
|
||||
|
||||
bed := m.state.Temps["B"]
|
||||
b.WriteString(kv("Bed:", tempStr(bed)) + "\n")
|
||||
|
||||
chamber, hasChamber := m.state.Temps["C"]
|
||||
if hasChamber {
|
||||
b.WriteString(kv("Chamber:", tempStr(chamber)) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
// Input lines
|
||||
hotPrompt := " set hotend: "
|
||||
if m.focus == focusHotendInput {
|
||||
hotPrompt += valueStyle.Render(m.hotendInput + "▏")
|
||||
} else {
|
||||
hotPrompt += dimStyle.Render("[h]")
|
||||
}
|
||||
b.WriteString(hotPrompt + "\n")
|
||||
|
||||
bedPrompt := " set bed: "
|
||||
if m.focus == focusBedInput {
|
||||
bedPrompt += valueStyle.Render(m.bedInput + "▏")
|
||||
} else {
|
||||
bedPrompt += dimStyle.Render("[b]")
|
||||
}
|
||||
b.WriteString(bedPrompt)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Model) renderControls() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("● Controls") + "\n")
|
||||
b.WriteString(fmt.Sprintf(" Feedrate: %s %s Flow: %s %s Fan: %s %s Babystep Z: %s %s\n",
|
||||
valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FeedratePct)),
|
||||
dimStyle.Render("[-/+] [0]"),
|
||||
valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FlowPct)),
|
||||
dimStyle.Render("[ [ / ] ]"),
|
||||
valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FanPct)),
|
||||
dimStyle.Render("[f/F]"),
|
||||
valueStyle.Render(fmt.Sprintf("%+.3f", m.state.BabystepZ)),
|
||||
dimStyle.Render("[{ / }] [Z]"),
|
||||
))
|
||||
if m.focus == focusGcodeInput {
|
||||
b.WriteString(" > " + valueStyle.Render(m.gcodeInput + "▏"))
|
||||
} else {
|
||||
b.WriteString(dimStyle.Render(" press [g] to send raw G-code"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Model) renderSDPicker(rows int) string {
|
||||
title := titleStyle.Render("● SD card") + dimStyle.Render(" ↑/↓ select ⏎ print esc cancel")
|
||||
if len(m.sdPickerFiles) == 0 {
|
||||
return title + "\n" + dimStyle.Render(" (no files reported)")
|
||||
}
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
// Scroll window around the selected index.
|
||||
from := 0
|
||||
to := len(m.sdPickerFiles)
|
||||
if to > rows {
|
||||
from = m.sdPickerIdx - rows/2
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if from+rows > len(m.sdPickerFiles) {
|
||||
from = len(m.sdPickerFiles) - rows
|
||||
}
|
||||
to = from + rows
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(title)
|
||||
for i := from; i < to; i++ {
|
||||
f := m.sdPickerFiles[i]
|
||||
label := f.Name
|
||||
if f.LongName != "" {
|
||||
label = f.LongName + dimStyle.Render(" ("+f.Name+")")
|
||||
}
|
||||
size := ""
|
||||
if f.Size > 0 {
|
||||
size = dimStyle.Render(" " + humanSize(f.Size))
|
||||
}
|
||||
row := " " + label + size
|
||||
if i == m.sdPickerIdx {
|
||||
row = valueStyle.Render("▸ ") + valueStyle.Render(label) + size
|
||||
}
|
||||
b.WriteString("\n" + row)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func humanSize(n int64) string {
|
||||
const k = 1024
|
||||
switch {
|
||||
case n < k:
|
||||
return fmt.Sprintf("%dB", n)
|
||||
case n < k*k:
|
||||
return fmt.Sprintf("%.1fK", float64(n)/k)
|
||||
case n < k*k*k:
|
||||
return fmt.Sprintf("%.1fM", float64(n)/(k*k))
|
||||
default:
|
||||
return fmt.Sprintf("%.1fG", float64(n)/(k*k*k))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) renderLog(rows int) string {
|
||||
title := titleStyle.Render("● Log")
|
||||
if len(m.log) == 0 {
|
||||
return title + "\n" + dimStyle.Render(" (no messages yet)")
|
||||
}
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
from := 0
|
||||
if len(m.log) > rows {
|
||||
from = len(m.log) - rows
|
||||
}
|
||||
body := strings.Join(m.log[from:], "\n")
|
||||
return title + "\n" + dimStyle.Render(body)
|
||||
}
|
||||
|
||||
func (m *Model) renderFooter() string {
|
||||
keys := []string{
|
||||
key("c") + " connect",
|
||||
key("d") + " disconnect",
|
||||
key("s") + " sd start",
|
||||
key("p") + " pause",
|
||||
key("r") + " resume",
|
||||
key("x") + " cancel",
|
||||
key("h") + " hotend",
|
||||
key("b") + " bed",
|
||||
key("g") + " gcode",
|
||||
key("q") + " quit",
|
||||
}
|
||||
hints := keyHintStyle.Render(strings.Join(keys, " "))
|
||||
if time.Now().Before(m.flashUntil) && m.flashMsg != "" {
|
||||
return hints + "\n" + valueStyle.Render(m.flashMsg)
|
||||
}
|
||||
if m.state.ErrorMsg != "" {
|
||||
return hints + "\n" + pillError.Render("⚠ "+m.state.ErrorMsg)
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
// -- view helpers ----------------------------------------------------------
|
||||
|
||||
func kv(label, value string) string {
|
||||
return labelStyle.Render(fmt.Sprintf("%-12s", label)) + " " + valueStyle.Render(value)
|
||||
}
|
||||
|
||||
func key(k string) string { return keyStyle.Render("[" + k + "]") }
|
||||
|
||||
func tempStr(p printer.TempPair) string {
|
||||
if p.Current == 0 && p.Target == 0 {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%5.1f / %5.1f °C", p.Current, p.Target)
|
||||
}
|
||||
|
||||
func statePill(s printer.PrintState) string {
|
||||
switch s {
|
||||
case printer.StateSDPrinting, printer.StateHostPrinting:
|
||||
return pillPrint.Render(s.String())
|
||||
case printer.StatePaused:
|
||||
return pillPause.Render(s.String())
|
||||
case printer.StateError:
|
||||
return pillError.Render(s.String())
|
||||
default:
|
||||
return pillIdle.Render(s.String())
|
||||
}
|
||||
}
|
||||
|
||||
func progressBar(pct, width int) string {
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
filled := pct * width / 100
|
||||
return "[" + strings.Repeat("█", filled) + strings.Repeat("·", width-filled) + "]"
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
mm := d / time.Minute
|
||||
d -= mm * time.Minute
|
||||
s := d / time.Second
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh %02dm", h, mm)
|
||||
}
|
||||
if mm > 0 {
|
||||
return fmt.Sprintf("%dm %02ds", mm, s)
|
||||
}
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
32
go/internal/tui/style.go
Normal file
32
go/internal/tui/style.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Colours are deliberately ANSI 16-colour where possible so the terminal theme
|
||||
// (Tokyo Night / Catppuccin / whatever Omarchy is wearing today) drives the look.
|
||||
var (
|
||||
colAccent = lipgloss.AdaptiveColor{Light: "5", Dark: "13"} // magenta
|
||||
colOK = lipgloss.AdaptiveColor{Light: "2", Dark: "10"} // green
|
||||
colWarn = lipgloss.AdaptiveColor{Light: "3", Dark: "11"} // yellow
|
||||
colErr = lipgloss.AdaptiveColor{Light: "1", Dark: "9"} // red
|
||||
colMuted = lipgloss.AdaptiveColor{Light: "8", Dark: "8"} // bright black
|
||||
colInfo = lipgloss.AdaptiveColor{Light: "4", Dark: "12"} // blue
|
||||
|
||||
panelStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colAccent).
|
||||
Padding(0, 1)
|
||||
|
||||
titleStyle = lipgloss.NewStyle().Foreground(colAccent).Bold(true)
|
||||
labelStyle = lipgloss.NewStyle().Foreground(colMuted)
|
||||
valueStyle = lipgloss.NewStyle().Bold(true)
|
||||
dimStyle = lipgloss.NewStyle().Foreground(colMuted)
|
||||
|
||||
pillIdle = lipgloss.NewStyle().Padding(0, 1).Foreground(colMuted)
|
||||
pillPrint = lipgloss.NewStyle().Padding(0, 1).Foreground(colOK).Bold(true)
|
||||
pillPause = lipgloss.NewStyle().Padding(0, 1).Foreground(colWarn).Bold(true)
|
||||
pillError = lipgloss.NewStyle().Padding(0, 1).Foreground(colErr).Bold(true)
|
||||
|
||||
keyHintStyle = lipgloss.NewStyle().Foreground(colMuted)
|
||||
keyStyle = lipgloss.NewStyle().Foreground(colInfo).Bold(true)
|
||||
)
|
||||
Reference in New Issue
Block a user