Initial commit for i0T.app and migration here with a fresh repo

This commit is contained in:
2026-05-17 13:51:17 -06:00
commit 0f66fbd9d8
12 changed files with 2146 additions and 0 deletions

View 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
}

View 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])
}
}

View 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
}

View 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{}
)