diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..102b7be --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Copy to .env and adjust for your environment. docker-compose reads this file +# too, so values here also override the compose defaults. + +# Timezone for the go2rtc container (used by docker-compose). +TZ=UTC + +# TCP port the server listens on. +PORT=3000 + +# Directory for persistent data (the SQLite database). +DATA_DIR=./data + +# Directory of the built web frontend to serve. Leave unset in dev (Vite +# serves the frontend); set in production so the Go server serves both. +# WEB_STATIC_PATH=/app/web + +# go2rtc endpoints for camera streaming. +GO2RTC_WS_URL=ws://localhost:1984 +GO2RTC_API_URL=http://localhost:1984 + +# Extra comma-separated origins allowed for WebSocket upgrades, beyond +# same-origin. Set this to your web origin when the app is served elsewhere. +# WS_ALLOWED_ORIGINS=http://localhost:5173 + +# Web push (VAPID). Leave the keys unset to have the server generate and +# persist a keypair on first run. Subject is a mailto: or URL contact. +VAPID_SUBJECT=crosshatch@bwees.io +# VAPID_PUBLIC_KEY= +# VAPID_PRIVATE_KEY= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e2f529..5ea40ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,10 @@ jobs: working-directory: server run: go build ./... + - name: Test server + working-directory: server + run: go test ./... + web: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e72a470..a2a7326 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,3 +22,9 @@ jobs: - name: Fail if formatting changed files run: git diff --exit-code + + - name: Lint + run: mise run lint + + - name: Type-check + run: mise run check diff --git a/docker-compose.yml b/docker-compose.yml index d262a92..f43efc6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - "8555:8555" # webrtc restart: unless-stopped environment: - - TZ=America/Chicago + - TZ=${TZ:-UTC} server: build: @@ -20,8 +20,9 @@ services: - GO2RTC_WS_URL=ws://go2rtc:1984 - GO2RTC_API_URL=http://go2rtc:1984 - DATA_DIR=/data - # Trust the Vite dev server for Websocket connections (CORS). - - WS_ALLOWED_ORIGINS=http://localhost:5173,https://brandon-macbook-pro.tail72746.ts.net + # Trust the Vite dev server for Websocket connections (CORS). Set + # WS_ALLOWED_ORIGINS in a local .env to add your own origins. + - WS_ALLOWED_ORIGINS=${WS_ALLOWED_ORIGINS:-http://localhost:5173} volumes: - ./server:/app/server - ./docker/data:/data diff --git a/mise.toml b/mise.toml index c778b48..4f0fdb9 100644 --- a/mise.toml +++ b/mise.toml @@ -52,3 +52,31 @@ run = "pnpm format" [tasks.format] description = "Format the server and web codebases" depends = ["format:server", "format:web"] + +[tasks."lint:server"] +description = "Vet the Go server codebase" +dir = "server" +run = "go vet ./..." + +[tasks."lint:web"] +description = "Lint the web codebase (prettier + eslint)" +dir = "web" +run = "pnpm lint" + +[tasks.lint] +description = "Lint the server and web codebases" +depends = ["lint:server", "lint:web"] + +[tasks.check] +description = "Type-check the web codebase" +dir = "web" +run = "pnpm check" + +[tasks."test:server"] +description = "Run the Go server tests" +dir = "server" +run = "go test ./..." + +[tasks.test] +description = "Run all tests" +depends = ["test:server"] diff --git a/server/internal/bambu/client.go b/server/internal/bambu/client.go index d3eeb7c..9bfa44d 100644 --- a/server/internal/bambu/client.go +++ b/server/internal/bambu/client.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "log/slog" "math" "strconv" "sync" @@ -31,9 +32,9 @@ const ( type StatusUpdateHandler func(serial string, state *dtos.BambuPrintState) type BambuClient struct { - ip string - accesCode string - serial string + ip string + accessCode string + serial string mqttClient mqtt.Client @@ -62,10 +63,10 @@ func (c *BambuClient) State() *dtos.BambuPrintState { } func (c *BambuClient) onConnect(client mqtt.Client) { - fmt.Printf("Connected to Bambu printer %s\n", c.serial) + slog.Info("connected to bambu printer", "serial", c.serial) if token := client.Subscribe(c.reportTopic(), 0, c.onMessage); token.Wait() && token.Error() != nil { - fmt.Printf("Failed to subscribe to %q: %v\n", c.reportTopic(), token.Error()) + slog.Error("failed to subscribe to report topic", "topic", c.reportTopic(), "error", token.Error()) } } @@ -74,7 +75,7 @@ func (c *BambuClient) onMessage(_ mqtt.Client, msg mqtt.Message) { Print json.RawMessage `json:"print"` } if err := json.Unmarshal(msg.Payload(), &envelope); err != nil { - fmt.Printf("Received invalid MQTT message on %q: %v\n", c.reportTopic(), err) + slog.Error("received invalid MQTT message", "topic", c.reportTopic(), "error", err) return } @@ -91,7 +92,7 @@ func (c *BambuClient) onMessage(_ mqtt.Client, msg mqtt.Message) { // replaced wholesale. if err := json.Unmarshal(envelope.Print, c.state); err != nil { c.stateMu.Unlock() - fmt.Printf("Failed to decode print state on %q: %v\n", c.reportTopic(), err) + slog.Error("failed to decode print state", "topic", c.reportTopic(), "error", err) return } state := c.state @@ -103,7 +104,7 @@ func (c *BambuClient) onMessage(_ mqtt.Client, msg mqtt.Message) { } func (c *BambuClient) onDisconnect(client mqtt.Client, err error) { - fmt.Printf("Disconnected: %v\n", err) + slog.Warn("disconnected from bambu printer", "serial", c.serial, "error", err) } func (c *BambuClient) Close() { @@ -258,10 +259,10 @@ func (c *BambuClient) UnloadMaterial(amsID int) error { }) } -func NewBambuClient(ip string, accesCode string, serial string, onStatusUpdate StatusUpdateHandler) *BambuClient { +func NewBambuClient(ip string, accessCode string, serial string, onStatusUpdate StatusUpdateHandler) *BambuClient { client := &BambuClient{ ip: ip, - accesCode: accesCode, + accessCode: accessCode, serial: serial, onStatusUpdate: onStatusUpdate, } @@ -270,7 +271,7 @@ func NewBambuClient(ip string, accesCode string, serial string, onStatusUpdate S opts.AddBroker(fmt.Sprintf("mqtts://%s:%d", ip, 8883)) opts.SetClientID(fmt.Sprintf("crosshatch-%s", serial)) opts.SetUsername("bblp") - opts.SetPassword(accesCode) + opts.SetPassword(accessCode) opts.SetKeepAlive(60) opts.SetAutoReconnect(true) opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) @@ -282,7 +283,7 @@ func NewBambuClient(ip string, accesCode string, serial string, onStatusUpdate S go func() { if token := client.mqttClient.Connect(); token.Wait() && token.Error() != nil { - fmt.Printf("Error connecting to MQTT broker: %v\n", token.Error()) + slog.Error("failed to connect to MQTT broker", "serial", serial, "error", token.Error()) } }() diff --git a/server/internal/config/config.go b/server/internal/config/config.go index a03ca61..c1caf8a 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -1,14 +1,70 @@ +// Package config is the single source of truth for environment-derived +// configuration. Every setting is read here so defaults live in one place. package config -import "os" +import ( + "os" + "strings" +) -// DataDir returns the directory where persistent data (the SQLite database) is +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// DataDir is the directory where persistent data (the SQLite database) is // stored. Defaults to the current directory for local development; production // sets DATA_DIR to a mounted volume. func DataDir() string { - dir := os.Getenv("DATA_DIR") - if dir == "" { - return "." + return env("DATA_DIR", ".") +} + +// Port is the TCP port the HTTP server listens on. +func Port() string { + return env("PORT", "3000") +} + +// WebStaticPath is the directory of the built web frontend to serve. When +// empty (the dev default) the server does not serve the frontend. +func WebStaticPath() string { + return os.Getenv("WEB_STATIC_PATH") +} + +// Go2RTCWSURL is the go2rtc WebSocket base URL the camera proxy relays to. +func Go2RTCWSURL() string { + return env("GO2RTC_WS_URL", "ws://localhost:1984") +} + +// Go2RTCAPIURL is the go2rtc HTTP API base URL used to manage streams. +func Go2RTCAPIURL() string { + return env("GO2RTC_API_URL", "http://localhost:1984") +} + +// VapidSubject is the "sub" claim (a mailto: or URL) sent with web push. +func VapidSubject() string { + return env("VAPID_SUBJECT", "crosshatch@bwees.io") +} + +// VapidPublicKey and VapidPrivateKey are the web-push VAPID keys. When either +// is empty they are loaded from, or generated and persisted to, the database. +func VapidPublicKey() string { return os.Getenv("VAPID_PUBLIC_KEY") } +func VapidPrivateKey() string { return os.Getenv("VAPID_PRIVATE_KEY") } + +// AllowedOrigins is the list of extra origins permitted for WebSocket upgrades, +// beyond same-origin requests. +func AllowedOrigins() []string { + raw := os.Getenv("WS_ALLOWED_ORIGINS") + if raw == "" { + return nil + } + + var origins []string + for _, o := range strings.Split(raw, ",") { + if o = strings.TrimSpace(o); o != "" { + origins = append(origins, o) + } } - return dir + return origins } diff --git a/server/internal/controllers/users.go b/server/internal/controllers/users.go index a3ab7fa..c6df8b4 100644 --- a/server/internal/controllers/users.go +++ b/server/internal/controllers/users.go @@ -1,6 +1,8 @@ package controllers import ( + "context" + "crosshatch/internal/dtos" "crosshatch/internal/services" @@ -11,8 +13,8 @@ type UsersController struct { svc *services.AuthService } -func (c *UsersController) requireAdmin(ctx fuego.ContextNoBody) error { - user := userFromContext(ctx.Request().Context()) +func (c *UsersController) requireAdmin(ctx context.Context) error { + user := userFromContext(ctx) if user == nil || !user.IsAdmin { return services.ErrForbidden } @@ -23,7 +25,7 @@ func (c *UsersController) Register(api *fuego.Server) { route := fuego.Group(api, "/users") fuego.Get(route, "/", func(ctx fuego.ContextNoBody) ([]dtos.UserDto, error) { - if err := c.requireAdmin(ctx); err != nil { + if err := c.requireAdmin(ctx.Request().Context()); err != nil { return nil, err } @@ -42,9 +44,8 @@ func (c *UsersController) Register(api *fuego.Server) { ) fuego.Post(route, "/", func(ctx fuego.ContextWithBody[dtos.CreateUserDto]) (dtos.UserDto, error) { - user := userFromContext(ctx.Request().Context()) - if user == nil || !user.IsAdmin { - return dtos.UserDto{}, services.ErrForbidden + if err := c.requireAdmin(ctx.Request().Context()); err != nil { + return dtos.UserDto{}, err } dto, err := ctx.Body() @@ -63,7 +64,7 @@ func (c *UsersController) Register(api *fuego.Server) { ) fuego.Delete(route, "/{id}", func(ctx fuego.ContextNoBody) (any, error) { - if err := c.requireAdmin(ctx); err != nil { + if err := c.requireAdmin(ctx.Request().Context()); err != nil { return nil, err } diff --git a/server/internal/dtos/notification.go b/server/internal/dtos/notification.go index 6e00f80..3295512 100644 --- a/server/internal/dtos/notification.go +++ b/server/internal/dtos/notification.go @@ -1,5 +1,13 @@ package dtos +// NotificationEvent identifies a printer status change worth notifying about. +type NotificationEvent string + +const ( + EventComplete NotificationEvent = "complete" + EventError NotificationEvent = "error" +) + type VapidDto struct { PublicKey string `json:"publicKey" validate:"required"` } diff --git a/server/internal/dtos/printer_status.go b/server/internal/dtos/printer_status.go index bb72691..2d3f381 100644 --- a/server/internal/dtos/printer_status.go +++ b/server/internal/dtos/printer_status.go @@ -8,6 +8,13 @@ import ( // PrinterStage mirrors the numeric stage codes reported by the printer. type PrinterStage int +// Gcode states reported by the printer in its "gcode_state" field. +const ( + GcodeRunning = "RUNNING" + GcodeFinish = "FINISH" + GcodeFailed = "FAILED" +) + type Temperature struct { Temperature float64 `json:"temperature" validate:"required"` TargetTemperature float64 `json:"targetTemperature" validate:"required"` diff --git a/server/internal/repositories/camera.go b/server/internal/go2rtc/client.go similarity index 53% rename from server/internal/repositories/camera.go rename to server/internal/go2rtc/client.go index 2fad451..950b205 100644 --- a/server/internal/repositories/camera.go +++ b/server/internal/go2rtc/client.go @@ -1,20 +1,26 @@ -package repositories +// Package go2rtc is an HTTP client for managing camera streams on a go2rtc +// instance (the live-view backend the printers' cameras are published to). +package go2rtc import ( + "crosshatch/internal/config" "crosshatch/internal/dtos" "encoding/json" "fmt" "net/http" "net/url" - "os" + "time" + + "go.uber.org/fx" ) -type CameraRepository struct { +type Client struct { baseURL string + client *http.Client } -func (r *CameraRepository) GetStreams() (map[string]dtos.Go2RTCStream, error) { - res, err := http.Get(r.baseURL + "/api/streams") +func (r *Client) GetStreams() (map[string]dtos.Go2RTCStream, error) { + res, err := r.client.Get(r.baseURL + "/api/streams") if err != nil { return nil, err } @@ -28,23 +34,26 @@ func (r *CameraRepository) GetStreams() (map[string]dtos.Go2RTCStream, error) { return streams, nil } -func (r *CameraRepository) DeleteStream(id string) error { +func (r *Client) DeleteStream(id string) error { req, err := http.NewRequest(http.MethodDelete, r.baseURL+"/api/streams/"+id, nil) if err != nil { return err } - res, err := http.DefaultClient.Do(req) + res, err := r.client.Do(req) if err != nil { return err } - defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("failed to delete stream for printer %s: %s", id, res.Status) + } + return nil } -func (r *CameraRepository) AddStream(id string, streamURL string) error { +func (r *Client) AddStream(id string, streamURL string) error { encodedURL := url.QueryEscape(streamURL) req, err := http.NewRequest( @@ -56,20 +65,20 @@ func (r *CameraRepository) AddStream(id string, streamURL string) error { return err } - res, err := http.DefaultClient.Do(req) + res, err := r.client.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode < 200 || res.StatusCode >= 300 { - return fmt.Errorf("Failed to create stream for printer %s: %s", id, res.Status) + return fmt.Errorf("failed to create stream for printer %s: %s", id, res.Status) } return nil } -func (r *CameraRepository) UpdateStream(id string, streamURL string) error { +func (r *Client) UpdateStream(id string, streamURL string) error { encodedURL := url.QueryEscape(streamURL) req, err := http.NewRequest( http.MethodPatch, @@ -80,24 +89,24 @@ func (r *CameraRepository) UpdateStream(id string, streamURL string) error { return err } - res, err := http.DefaultClient.Do(req) + res, err := r.client.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode < 200 || res.StatusCode >= 300 { - return fmt.Errorf("Failed to update stream for printer %s: %s", id, res.Status) + return fmt.Errorf("failed to update stream for printer %s: %s", id, res.Status) } return nil } -func NewCameraRepository() *CameraRepository { - baseURL := os.Getenv("GO2RTC_API_URL") - if baseURL == "" { - baseURL = "http://localhost:1984" +func NewClient() *Client { + return &Client{ + baseURL: config.Go2RTCAPIURL(), + client: &http.Client{Timeout: 10 * time.Second}, } - - return &CameraRepository{baseURL: baseURL} } + +var Module = fx.Provide(NewClient) diff --git a/server/internal/proxy/go2rtc.go b/server/internal/proxy/go2rtc.go index 5898795..a859654 100644 --- a/server/internal/proxy/go2rtc.go +++ b/server/internal/proxy/go2rtc.go @@ -6,9 +6,9 @@ import ( "log/slog" "net/http" "net/url" - "os" "time" + "crosshatch/internal/config" "crosshatch/internal/utils" "github.com/gorilla/websocket" @@ -39,10 +39,7 @@ type Go2RTCProxy struct { // NewGo2RTCProxy reads the upstream URL from GO2RTC_WS_URL, falling back to // defaultTarget. func NewGo2RTCProxy() *Go2RTCProxy { - raw := os.Getenv("GO2RTC_WS_URL") - if raw == "" { - raw = defaultTarget - } + raw := config.Go2RTCWSURL() target, err := url.Parse(raw) if err != nil { diff --git a/server/internal/repositories/notification.go b/server/internal/repositories/notification.go index 613dcf0..fa8355b 100644 --- a/server/internal/repositories/notification.go +++ b/server/internal/repositories/notification.go @@ -2,6 +2,7 @@ package repositories import ( "crosshatch/internal/database/models" + "crosshatch/internal/dtos" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -73,17 +74,16 @@ func (r *NotificationRepository) DeleteSettingsForUser(userID string) error { } // SubscriptionsForEvent returns the push subscriptions of every device whose -// setting for the serial is enabled and opts into the given event -// ("complete" or "error"). -func (r *NotificationRepository) SubscriptionsForEvent(serial, event string) ([]models.PushSubscription, error) { +// setting for the serial is enabled and opts into the given event. +func (r *NotificationRepository) SubscriptionsForEvent(serial string, event dtos.NotificationEvent) ([]models.PushSubscription, error) { query := r.db. Joins("JOIN notification_setting ns ON ns.device_id = push_subscription.device_id"). Where("ns.printer_serial = ? AND ns.enabled = ?", serial, true) switch event { - case "complete": + case dtos.EventComplete: query = query.Where("ns.notify_complete = ?", true) - case "error": + case dtos.EventError: query = query.Where("ns.notify_error = ?", true) default: return []models.PushSubscription{}, nil diff --git a/server/internal/repositories/repositories.go b/server/internal/repositories/repositories.go index 0a8ca97..86816aa 100644 --- a/server/internal/repositories/repositories.go +++ b/server/internal/repositories/repositories.go @@ -4,7 +4,6 @@ import "go.uber.org/fx" var Module = fx.Provide( NewPrinterRepository, - NewCameraRepository, NewFilamentRepository, NewUserRepository, NewSessionRepository, diff --git a/server/internal/services/notification.go b/server/internal/services/notification.go index 7914b73..5c0a873 100644 --- a/server/internal/services/notification.go +++ b/server/internal/services/notification.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "crosshatch/internal/dtos" @@ -25,20 +26,19 @@ type pushPayload struct { Tag string `json:"tag"` } -// classifyTransition maps a status change to a notification event. It returns -// ("complete"|"error", true) when the transition warrants a notification, and -// ("", false) otherwise. -func classifyTransition(prev, next *dtos.PrinterStatus) (string, bool) { +// classifyTransition maps a status change to a notification event. The second +// return is true when the transition warrants a notification. +func classifyTransition(prev, next *dtos.PrinterStatus) (dtos.NotificationEvent, bool) { if next == nil { return "", false } - if prev != nil && prev.State == "RUNNING" && next.State == "FINISH" { - return "complete", true + if prev != nil && prev.State == dtos.GcodeRunning && next.State == dtos.GcodeFinish { + return dtos.EventComplete, true } - if next.State == "FAILED" && (prev == nil || prev.State != "FAILED") { - return "error", true + if next.State == dtos.GcodeFailed && (prev == nil || prev.State != dtos.GcodeFailed) { + return dtos.EventError, true } return "", false @@ -60,9 +60,9 @@ func (s *NotificationService) printerName(serial string) string { return serial } -// Notify builds and delivers a push notification for the given event -// ("complete" or "error") to every device subscribed to it for this printer. -func (s *NotificationService) Notify(serial, event string) { +// Notify builds and delivers a push notification for the given event to every +// device subscribed to it for this printer. +func (s *NotificationService) Notify(serial string, event dtos.NotificationEvent) { name := s.printerName(serial) payload := pushPayload{ @@ -70,23 +70,23 @@ func (s *NotificationService) Notify(serial, event string) { Tag: "crosshatch-" + serial, } switch event { - case "complete": + case dtos.EventComplete: payload.Title = "Print complete" payload.Body = name + " finished printing" - case "error": + case dtos.EventError: payload.Title = "Print error" payload.Body = name + " reported an error" } body, err := json.Marshal(payload) if err != nil { - fmt.Printf("Error marshaling notification payload: %v\n", err) + slog.Error("failed to marshal notification payload", "error", err) return } subs, err := s.notifications.SubscriptionsForEvent(serial, event) if err != nil { - fmt.Printf("Error finding subscriptions for printer %s: %v\n", serial, err) + slog.Error("failed to find subscriptions", "serial", serial, "error", err) return } @@ -133,21 +133,21 @@ func (s *NotificationService) send(endpoint, p256dh, auth string, body []byte) { TTL: 60, }) if err != nil { - fmt.Printf("Error sending web push to %s: %v\n", endpoint, err) + slog.Error("failed to send web push", "endpoint", endpoint, "error", err) return } defer res.Body.Close() if res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone { if err := s.notifications.DeleteSubscriptionByEndpoint(endpoint); err != nil { - fmt.Printf("Error deleting stale subscription %s: %v\n", endpoint, err) + slog.Error("failed to delete stale subscription", "endpoint", endpoint, "error", err) } return } if res.StatusCode >= 300 { responseBody, _ := io.ReadAll(res.Body) - fmt.Printf("Web push to %s rejected: %d %s\n", endpoint, res.StatusCode, string(responseBody)) + slog.Warn("web push rejected", "endpoint", endpoint, "status", res.StatusCode, "body", string(responseBody)) } } diff --git a/server/internal/services/notification_test.go b/server/internal/services/notification_test.go index 5ceb066..85e59ea 100644 --- a/server/internal/services/notification_test.go +++ b/server/internal/services/notification_test.go @@ -15,15 +15,15 @@ func TestClassifyTransition(t *testing.T) { name string prev *dtos.PrinterStatus next *dtos.PrinterStatus - wantEvent string + wantEvent dtos.NotificationEvent wantOk bool }{ - {"running to finish is complete", status("RUNNING"), status("FINISH"), "complete", true}, - {"running to failed is error", status("RUNNING"), status("FAILED"), "error", true}, + {"running to finish is complete", status("RUNNING"), status("FINISH"), dtos.EventComplete, true}, + {"running to failed is error", status("RUNNING"), status("FAILED"), dtos.EventError, true}, {"failed to failed is none", status("FAILED"), status("FAILED"), "", false}, {"running to pause is none", status("RUNNING"), status("PAUSE"), "", false}, {"nil prev to finish is none", nil, status("FINISH"), "", false}, - {"nil prev to failed is error", nil, status("FAILED"), "error", true}, + {"nil prev to failed is error", nil, status("FAILED"), dtos.EventError, true}, } for _, tc := range cases { diff --git a/server/internal/services/printer.go b/server/internal/services/printer.go index 7cf89fe..1467b47 100644 --- a/server/internal/services/printer.go +++ b/server/internal/services/printer.go @@ -5,9 +5,11 @@ import ( "crosshatch/internal/bambu" "crosshatch/internal/database/models" "crosshatch/internal/dtos" + "crosshatch/internal/go2rtc" "crosshatch/internal/repositories" "crosshatch/internal/socketio" "fmt" + "log/slog" "sync" "go.uber.org/fx" @@ -15,7 +17,7 @@ import ( type PrinterService struct { printerRepo *repositories.PrinterRepository - cameraRepo *repositories.CameraRepository + camera *go2rtc.Client filamentRepo *repositories.FilamentRepository socketio *socketio.SocketIO @@ -123,68 +125,48 @@ func (s *PrinterService) client(serial string) (*bambu.BambuClient, error) { return client, nil } -func (s *PrinterService) StopPrint(serial string) error { +// withClient resolves the Bambu client for a serial and runs fn against it, +// so the control methods below don't each repeat the lookup-and-check. +func (s *PrinterService) withClient(serial string, fn func(*bambu.BambuClient) error) error { client, err := s.client(serial) if err != nil { return err } - return client.StopPrint() + return fn(client) +} + +func (s *PrinterService) StopPrint(serial string) error { + return s.withClient(serial, func(c *bambu.BambuClient) error { return c.StopPrint() }) } func (s *PrinterService) PausePrint(serial string) error { - client, err := s.client(serial) - if err != nil { - return err - } - return client.PausePrint() + return s.withClient(serial, func(c *bambu.BambuClient) error { return c.PausePrint() }) } func (s *PrinterService) ResumePrint(serial string) error { - client, err := s.client(serial) - if err != nil { - return err - } - return client.ResumePrint() + return s.withClient(serial, func(c *bambu.BambuClient) error { return c.ResumePrint() }) } func (s *PrinterService) SetLight(serial string, on bool) error { - client, err := s.client(serial) - if err != nil { - return err - } - return client.SetLight(on) + return s.withClient(serial, func(c *bambu.BambuClient) error { return c.SetLight(on) }) } func (s *PrinterService) UnloadMaterial(serial string, amsID int) error { - client, err := s.client(serial) - if err != nil { - return err - } - return client.UnloadMaterial(amsID) + return s.withClient(serial, func(c *bambu.BambuClient) error { return c.UnloadMaterial(amsID) }) } func (s *PrinterService) StartDrying(serial string, amsID int, dto dtos.StartDryingDto) error { - client, err := s.client(serial) - if err != nil { - return err - } - return client.StartDrying(amsID, dto.Temperature, dto.Duration, dto.CoolingTemp, dto.Filament, dto.RotateTray) + return s.withClient(serial, func(c *bambu.BambuClient) error { + return c.StartDrying(amsID, dto.Temperature, dto.Duration, dto.CoolingTemp, dto.Filament, dto.RotateTray) + }) } func (s *PrinterService) StopDrying(serial string, amsID int) error { - client, err := s.client(serial) - if err != nil { - return err - } - return client.StopDrying(amsID) + return s.withClient(serial, func(c *bambu.BambuClient) error { return c.StopDrying(amsID) }) } func (s *PrinterService) SetPrintSpeed(serial string, level int) error { - client, err := s.client(serial) - if err != nil { - return err - } - return client.SetPrintSpeed(level) + return s.withClient(serial, func(c *bambu.BambuClient) error { return c.SetPrintSpeed(level) }) } var fanNodes = map[string]int{ @@ -198,19 +180,13 @@ func (s *PrinterService) SetFan(serial string, fan string, speed int) error { if !ok { return fmt.Errorf("unknown fan %q", fan) } - client, err := s.client(serial) - if err != nil { - return err - } - return client.SetFanSpeed(node, speed) + return s.withClient(serial, func(c *bambu.BambuClient) error { return c.SetFanSpeed(node, speed) }) } func (s *PrinterService) SetFilament(serial string, dto dtos.SetFilamentDto) error { - client, err := s.client(serial) - if err != nil { - return err - } - return client.SetFilament(dto.AmsID, dto.TrayID, dto.TrayInfoIdx, dto.TrayColor, dto.TrayType, dto.NozzleTempMin, dto.NozzleTempMax) + return s.withClient(serial, func(c *bambu.BambuClient) error { + return c.SetFilament(dto.AmsID, dto.TrayID, dto.TrayInfoIdx, dto.TrayColor, dto.TrayType, dto.NozzleTempMin, dto.NozzleTempMax) + }) } // printerStatusPayload flattens the printer status alongside its serial, so the @@ -226,7 +202,7 @@ func (s *PrinterService) onPrinterStatusUpdate(serial string, state *dtos.BambuP status := dtos.StatusFromMQTT(state) if err := s.filamentRepo.CreateMissing(filamentsFromStatus(status)); err != nil { - fmt.Printf("Error recording filaments for printer %s: %v\n", serial, err) + slog.Error("failed to record filaments", "serial", serial, "error", err) } s.statusMu.RLock() @@ -283,7 +259,7 @@ func (s *PrinterService) replayStatus(emit socketio.EmitFunc) { func (s *PrinterService) connectPrinterClients() { printers, err := s.GetPrinters() if err != nil { - fmt.Printf("Error fetching printers: %v\n", err) + slog.Error("failed to fetch printers", "error", err) return } @@ -302,31 +278,30 @@ func containsPrinter(printers []models.Printer, serial string) bool { } func (s *PrinterService) reconcileCameraStreams() { - printers, err := s.GetPrinters() - - fmt.Println("Reconciling camera streams with printers...") + slog.Info("reconciling camera streams with printers") + printers, err := s.GetPrinters() if err != nil { - fmt.Printf("Error fetching printers for stream reconciliation: %v\n", err) + slog.Error("failed to fetch printers for stream reconciliation", "error", err) return } - streams, err := s.cameraRepo.GetStreams() + streams, err := s.camera.GetStreams() if err != nil { - fmt.Printf("Error fetching camera streams for reconciliation: %v\n", err) + slog.Error("failed to fetch camera streams for reconciliation", "error", err) return } for _, printer := range printers { stream, exists := streams[printer.Serial] - if !exists || stream.Producers[0].URL != printer.CameraURL() { + if !exists || len(stream.Producers) == 0 || stream.Producers[0].URL != printer.CameraURL() { if exists { - if err := s.cameraRepo.UpdateStream(printer.Serial, printer.CameraURL()); err != nil { - fmt.Printf("Error updating stream for printer %s: %v\n", printer.Serial, err) + if err := s.camera.UpdateStream(printer.Serial, printer.CameraURL()); err != nil { + slog.Error("failed to update camera stream", "serial", printer.Serial, "error", err) } } else { - if err := s.cameraRepo.AddStream(printer.Serial, printer.CameraURL()); err != nil { - fmt.Printf("Error adding stream for printer %s: %v\n", printer.Serial, err) + if err := s.camera.AddStream(printer.Serial, printer.CameraURL()); err != nil { + slog.Error("failed to add camera stream", "serial", printer.Serial, "error", err) } } } @@ -334,17 +309,17 @@ func (s *PrinterService) reconcileCameraStreams() { for serial := range streams { if !containsPrinter(printers, serial) { - if err := s.cameraRepo.DeleteStream(serial); err != nil { - fmt.Printf("Error deleting stream for printer %s: %v\n", serial, err) + if err := s.camera.DeleteStream(serial); err != nil { + slog.Error("failed to delete camera stream", "serial", serial, "error", err) } } } } -func NewPrinterService(lc fx.Lifecycle, printerRepo *repositories.PrinterRepository, cameraRepo *repositories.CameraRepository, filamentRepo *repositories.FilamentRepository, socket *socketio.SocketIO) *PrinterService { +func NewPrinterService(lc fx.Lifecycle, printerRepo *repositories.PrinterRepository, camera *go2rtc.Client, filamentRepo *repositories.FilamentRepository, socket *socketio.SocketIO) *PrinterService { svc := &PrinterService{ printerRepo: printerRepo, - cameraRepo: cameraRepo, + camera: camera, filamentRepo: filamentRepo, socketio: socket, clients: make(map[string]*bambu.BambuClient), diff --git a/server/internal/services/vapid.go b/server/internal/services/vapid.go index 316fbbd..9d654d0 100644 --- a/server/internal/services/vapid.go +++ b/server/internal/services/vapid.go @@ -1,8 +1,7 @@ package services import ( - "os" - + "crosshatch/internal/config" "crosshatch/internal/database/models" "crosshatch/internal/dtos" @@ -26,13 +25,10 @@ func (s *VapidService) PrivateKey() string { return s.privateKey } func (s *VapidService) Subject() string { return s.subject } func NewVapidService(db *gorm.DB) (*VapidService, error) { - subject := os.Getenv("VAPID_SUBJECT") - if subject == "" { - subject = "crosshatch@bwees.io" - } + subject := config.VapidSubject() - public := os.Getenv("VAPID_PUBLIC_KEY") - private := os.Getenv("VAPID_PRIVATE_KEY") + public := config.VapidPublicKey() + private := config.VapidPrivateKey() if public == "" || private == "" { stored, err := loadVapidKeys(db) diff --git a/server/internal/socketio/socketio.go b/server/internal/socketio/socketio.go index 3afed4a..0747811 100644 --- a/server/internal/socketio/socketio.go +++ b/server/internal/socketio/socketio.go @@ -20,7 +20,7 @@ type EmitFunc func(event string, payload any) type SocketIO struct { io *socket.Server - onConnect func(emit EmitFunc) + onConnect []func(emit EmitFunc) } func NewSocketIO(lc fx.Lifecycle) *SocketIO { @@ -37,10 +37,11 @@ func NewSocketIO(lc fx.Lifecycle) *SocketIO { s.io.On("connection", func(clients ...any) { client := clients[0].(*socket.Socket) - if s.onConnect != nil { - s.onConnect(func(event string, payload any) { - emitTo(client, event, payload) - }) + emit := func(event string, payload any) { + emitTo(client, event, payload) + } + for _, fn := range s.onConnect { + fn(emit) } }) @@ -55,7 +56,7 @@ func NewSocketIO(lc fx.Lifecycle) *SocketIO { } func (s *SocketIO) OnConnect(fn func(emit EmitFunc)) { - s.onConnect = fn + s.onConnect = append(s.onConnect, fn) } func (s *SocketIO) Emit(event string, payload any) { diff --git a/server/internal/utils/origin.go b/server/internal/utils/origin.go index 4d3b0b6..c3ba26a 100644 --- a/server/internal/utils/origin.go +++ b/server/internal/utils/origin.go @@ -3,8 +3,9 @@ package utils import ( "net/http" "net/url" - "os" "strings" + + "crosshatch/internal/config" ) func AllowedOrigin(r *http.Request) bool { @@ -21,8 +22,8 @@ func AllowedOrigin(r *http.Request) bool { return true } - for _, allowed := range strings.Split(os.Getenv("WS_ALLOWED_ORIGINS"), ",") { - if allowed = strings.TrimSpace(allowed); allowed != "" && strings.EqualFold(allowed, origin) { + for _, allowed := range config.AllowedOrigins() { + if strings.EqualFold(allowed, origin) { return true } } diff --git a/server/main.go b/server/main.go index 52aa3d1..5dba54c 100644 --- a/server/main.go +++ b/server/main.go @@ -3,6 +3,7 @@ package main import ( "crosshatch/internal/controllers" "crosshatch/internal/database" + "crosshatch/internal/go2rtc" "crosshatch/internal/proxy" "crosshatch/internal/repositories" "crosshatch/internal/services" @@ -20,6 +21,7 @@ func main() { repositories.Module, socketio.Module, proxy.Module, + go2rtc.Module, fx.Invoke(NewServer), ) diff --git a/server/server.go b/server/server.go index 898a50d..d369a3c 100644 --- a/server/server.go +++ b/server/server.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" + "crosshatch/internal/config" "crosshatch/internal/controllers" "github.com/getkin/kin-openapi/openapi3" @@ -22,10 +23,7 @@ type Controllers struct { } func NewServer(lc fx.Lifecycle, controllers Controllers, authMiddleware *controllers.AuthMiddleware) *fuego.Server { - addr := ":3000" - if port := os.Getenv("PORT"); port != "" { - addr = ":" + port - } + addr := ":" + config.Port() server := fuego.NewServer( fuego.WithLoggingMiddleware(fuego.LoggingConfig{ diff --git a/server/static.go b/server/static.go index a273e4e..caad6fc 100644 --- a/server/static.go +++ b/server/static.go @@ -7,11 +7,13 @@ import ( "path/filepath" "strings" + "crosshatch/internal/config" + "github.com/go-fuego/fuego" ) func registerStaticWeb(server *fuego.Server) { - root := os.Getenv("WEB_STATIC_PATH") + root := config.WebStaticPath() if root == "" { return } diff --git a/web/eslint.config.js b/web/eslint.config.js index e498a63..bedaba5 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -12,6 +12,8 @@ const gitignorePath = path.resolve(import.meta.dirname, '.gitignore'); export default defineConfig( includeIgnoreFile(gitignorePath), + // Generated by oazapfts from the server's OpenAPI spec — not hand-edited. + { ignores: ['src/lib/sdk/client.ts'] }, js.configs.recommended, ts.configs.recommended, svelte.configs.recommended, diff --git a/web/src/app.d.ts b/web/src/app.d.ts index da08e6d..f42c00a 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -8,6 +8,16 @@ declare global { // interface PageState {} // interface Platform {} } + + // Not yet in lib.dom; supported on iOS Safari for MSE playback. + interface ManagedMediaSourceCtor { + new (): MediaSource; + isTypeSupported(type: string): boolean; + } + + interface Window { + ManagedMediaSource?: ManagedMediaSourceCtor; + } } export {}; diff --git a/web/src/lib/components/CreatePrinterDialog.svelte b/web/src/lib/components/CreatePrinterDialog.svelte index 02c085b..6981e8c 100644 --- a/web/src/lib/components/CreatePrinterDialog.svelte +++ b/web/src/lib/components/CreatePrinterDialog.svelte @@ -34,8 +34,6 @@ error = ''; loading = true; - console.log('Creating printer with', { serial, name, hostIp, accessCode }); - try { await createPrinter({ serial, name, hostIp, accessCode }); await printerManager.refreshPrinters(); diff --git a/web/src/lib/components/Go2RTCPlayer.svelte b/web/src/lib/components/Go2RTCPlayer.svelte index 9a67e4a..76dd109 100644 --- a/web/src/lib/components/Go2RTCPlayer.svelte +++ b/web/src/lib/components/Go2RTCPlayer.svelte @@ -8,7 +8,6 @@ activeMode?: string; } - // eslint-disable-next-line no-useless-assignment let { url, mode = 'webrtc,mse', activeMode = $bindable('negotiating') }: Props = $props(); let loading = $state(true); @@ -41,6 +40,23 @@ clearTimeout(timeoutId); } + function playMuted(el: HTMLVideoElement) { + el.play().catch(() => { + el.muted = true; + el.play().catch(console.warn); + }); + } + + // WebRTC often hangs in 'connecting' behind NAT/firewalls and never + // transitions to 'failed', so once MSE is playable we tear WebRTC down + // rather than wait on it. + function commitToMSE() { + pc?.close(); + pc = null; + activeMode = 'mse'; + markConnected(); + } + $effect(() => { const currentUrl = url; @@ -86,10 +102,6 @@ } }); - ws.addEventListener('close', () => { - console.log('go2rtc WebSocket closed'); - }); - return () => { clearTimeout(timeoutId); if (ws) { @@ -114,9 +126,8 @@ ) { let ms: MediaSource; - if ('ManagedMediaSource' in window) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const MMS = (window as any).ManagedMediaSource; + if (window.ManagedMediaSource) { + const MMS = window.ManagedMediaSource; ms = new MMS(); // On iOS Safari, ManagedMediaSource defers `sourceopen` until the // element actually demands data — the `videoEl.play()` below forces @@ -130,8 +141,7 @@ { once: true } ); videoEl.disableRemotePlayback = true; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (videoEl as any).srcObject = ms; + videoEl.srcObject = ms; } else { ms = new MediaSource(); ms.addEventListener( @@ -151,10 +161,7 @@ videoEl.srcObject = null; } - videoEl.play().catch(() => { - videoEl.muted = true; - videoEl.play().catch(console.warn); - }); + playMuted(videoEl); onmessage['mse'] = (msg) => { if (msg.type !== 'mse') return; @@ -199,14 +206,9 @@ videoEl.playbackRate = Math.max(end - videoEl.currentTime, 0.1); // MSE is producing playable data. If WebRTC hasn't already taken - // over, commit to MSE — WebRTC often hangs in 'connecting' behind - // NAT/firewalls and never transitions to 'failed', so waiting on - // it would just time out despite MSE working fine. + // over, commit to MSE. if (activeMode !== 'webrtc' && pc && pc.connectionState !== 'connected') { - pc.close(); - pc = null; - activeMode = 'mse'; - markConnected(); + commitToMSE(); } } }); @@ -266,10 +268,7 @@ if (rtcPriority >= msePriority) { videoEl.srcObject = stream; - videoEl.play().catch(() => { - videoEl.muted = true; - videoEl.play().catch(console.warn); - }); + playMuted(videoEl); activeMode = 'webrtc'; markConnected(); @@ -281,10 +280,7 @@ } } else { // MSE won — close WebRTC - activeMode = 'mse'; - markConnected(); - pc?.close(); - pc = null; + commitToMSE(); } video2.srcObject = null; @@ -293,11 +289,11 @@ ); video2.srcObject = new MediaStream(tracks); } else if (pc?.connectionState === 'failed' || pc?.connectionState === 'disconnected') { - pc.close(); - pc = null; if (mseCodecs) { - activeMode = 'mse'; - markConnected(); + commitToMSE(); + } else { + pc.close(); + pc = null; } } }); @@ -312,11 +308,11 @@ break; case 'error': if (msg.value.includes('webrtc/offer')) { - pc?.close(); - pc = null; if (mseCodecs) { - activeMode = 'mse'; - markConnected(); + commitToMSE(); + } else { + pc?.close(); + pc = null; } } break; diff --git a/web/src/lib/components/actions/PlayPause.svelte b/web/src/lib/components/actions/PlayPause.svelte index 19262be..b7dae6a 100644 --- a/web/src/lib/components/actions/PlayPause.svelte +++ b/web/src/lib/components/actions/PlayPause.svelte @@ -9,7 +9,7 @@ }; let { printerSerial = $bindable() }: Props = $props(); - let printerState = $derived(printerManager.printerState.get(printerSerial)!); + let printerState = $derived(printerManager.printerState.get(printerSerial)); async function resume() { if (!printerSerial) return; diff --git a/web/src/lib/components/actions/Stop.svelte b/web/src/lib/components/actions/Stop.svelte index 5a76645..da674fa 100644 --- a/web/src/lib/components/actions/Stop.svelte +++ b/web/src/lib/components/actions/Stop.svelte @@ -9,7 +9,7 @@ }; let { printerSerial = $bindable() }: Props = $props(); - let printerState = $derived(printerManager.printerState.get(printerSerial)!); + let printerState = $derived(printerManager.printerState.get(printerSerial)); async function stop() { if (!printerSerial) return; diff --git a/web/src/lib/filaments.svelte.ts b/web/src/lib/filaments.svelte.ts index 1b901d4..0da8f54 100644 --- a/web/src/lib/filaments.svelte.ts +++ b/web/src/lib/filaments.svelte.ts @@ -6,13 +6,15 @@ class FilamentManager { brands = $derived(new SvelteSet(this.presets.map((p) => p.brand))); loading = $state(false); + loaded = $state(false); async init() { - if (this.loading) return; + if (this.loading || this.loaded) return; this.loading = true; try { this.presets = await getFilaments(); + this.loaded = true; } finally { this.loading = false; } diff --git a/web/src/lib/managers/bambu_printer.svelte.ts b/web/src/lib/managers/bambu_printer.svelte.ts deleted file mode 100644 index ddbf156..0000000 --- a/web/src/lib/managers/bambu_printer.svelte.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Printer } from '$lib/sdk'; - -export class BambuPrinterManager { - serial: string = $state(''); - name: string = $state(''); - access: string = $state(''); - - constructor(public printer: Printer) { - this.serial = printer.serial; - } -} diff --git a/web/src/lib/managers/printers.manager.svelte.ts b/web/src/lib/managers/printers.manager.svelte.ts index de7e4f1..7bb0a67 100644 --- a/web/src/lib/managers/printers.manager.svelte.ts +++ b/web/src/lib/managers/printers.manager.svelte.ts @@ -1,12 +1,10 @@ import { getPrinters, type Printer, type PrinterStatus } from '$lib/sdk'; import { io, type Socket } from 'socket.io-client'; import { SvelteMap } from 'svelte/reactivity'; -import { BambuPrinterManager } from './bambu_printer.svelte'; class PrinterManager { private stateSocket?: Socket; private initialized = false; - private printerManagers: Map = new Map(); printers: Map = new SvelteMap(); printerState: Map = new SvelteMap(); @@ -14,9 +12,7 @@ class PrinterManager { async refreshPrinters() { const printers = await getPrinters(); this.printers.clear(); - this.printerManagers.clear(); for (const printer of printers) { - this.printerManagers.set(printer.serial, new BambuPrinterManager(printer)); this.printers.set(printer.serial, printer); } } @@ -49,7 +45,6 @@ class PrinterManager { this.stateSocket = undefined; this.initialized = false; this.printers.clear(); - this.printerManagers.clear(); this.printerState.clear(); } } diff --git a/web/src/lib/utils/bambu-color.ts b/web/src/lib/utils/bambu-color.ts new file mode 100644 index 0000000..3cf2c3d --- /dev/null +++ b/web/src/lib/utils/bambu-color.ts @@ -0,0 +1,29 @@ +// Bambu reports tray colors as 8-char ARGB hex (e.g. "RRGGBBFF"); the CSS/UI +// side only ever wants the 6-char RGB portion. +function stripAlpha(color: string): string { + return color.length === 8 ? color.slice(0, 6) : color; +} + +export function bambuColorToCss(color?: string): string { + if (!color) return 'transparent'; + return `#${stripAlpha(color)}`; +} + +export function isLightColor(color?: string): boolean { + if (!color) return true; + const hex = stripAlpha(color); + if (hex.length !== 6) return true; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return r * 0.299 + g * 0.587 + b * 0.114 > 150; +} + +export function toHexInput(color: string | undefined, fallback: string): string { + if (!color) return fallback; + return `#${stripAlpha(color).toUpperCase()}`; +} + +export function toBambuColor(hex: string): string { + return (hex.replace('#', '') + 'FF').toUpperCase(); +} diff --git a/web/src/routes/(app)/+page.svelte b/web/src/routes/(app)/+page.svelte index ba7ecf8..5a2d364 100644 --- a/web/src/routes/(app)/+page.svelte +++ b/web/src/routes/(app)/+page.svelte @@ -22,7 +22,7 @@ -
+
{#each printerManager.printers.entries() as [serial, printer] (serial)} filamentManager.init()); - function toHexInput(c?: string): string { - if (!c) return FILAMENT_COLORS[0]; - const hex = c.length >= 6 ? c.slice(0, 6) : c; - return `#${hex.toUpperCase()}`; - } - - function toBambuColor(hex: string): string { - return (hex.replace('#', '') + 'FF').toUpperCase(); - } - // Prefer an exact match on the reported filament id, falling back to the // material family when the loaded spool isn't a known preset. const defaultMatch = $derived.by(() => { @@ -61,7 +52,7 @@ const presetsForBrand = $derived(filamentManager.presets.filter((p) => p.brand === brand)); const selectedIdx = $derived(idxOverride ?? defaultMatch?.trayInfoIdx); const selectedPreset = $derived(presetsForBrand.find((p) => p.trayInfoIdx === selectedIdx)); - const color = $derived(colorOverride ?? toHexInput(tray?.color)); + const color = $derived(colorOverride ?? toHexInput(tray?.color, FILAMENT_COLORS[0])); $effect(() => { if (!open) { diff --git a/web/src/routes/(app)/printer/[id]/MaterialsCard.svelte b/web/src/routes/(app)/printer/[id]/MaterialsCard.svelte index f57ec01..48a6193 100644 --- a/web/src/routes/(app)/printer/[id]/MaterialsCard.svelte +++ b/web/src/routes/(app)/printer/[id]/MaterialsCard.svelte @@ -3,6 +3,7 @@ import Card from '$lib/components/ui/card/card.svelte'; import { unloadMaterial, type Printer, type PrinterStatus } from '$lib/sdk'; import { cn } from '$lib/utils'; + import { bambuColorToCss, isLightColor } from '$lib/utils/bambu-color'; import { DropletIcon, SunIcon } from '@lucide/svelte'; import { Duration } from 'luxon'; import Separator from '$lib/components/ui/separator/separator.svelte'; @@ -12,6 +13,9 @@ type AMSUnit = PrinterStatus['ams'][number]; type Tray = AMSUnit['trays'][number]; + const EXTERNAL_SPOOL_AMS_ID = 255; + const EXTERNAL_SPOOL_TRAY_ID = 254; + type Props = { state: PrinterStatus | undefined; printer: Printer | undefined; @@ -40,29 +44,15 @@ let dryingUnit = $derived(ams.find((unit) => unit.id === dryingTarget?.amsId)); - let currentlyLoadedUnitID = $derived( - ams.find((unit) => unit.trays.some((tray) => tray.loaded))?.id ?? - (externalSpool?.loaded ? 255 : 0) - ); - - function bambuColorToCss(color?: string): string { - if (!color) return 'transparent'; - const hex = color.length === 8 ? color.slice(0, 6) : color; - return `#${hex}`; - } - - function isLightColor(color?: string): boolean { - if (!color) return true; - const hex = color.length === 8 ? color.slice(0, 6) : color; - if (hex.length !== 6) return true; - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - return r * 0.299 + g * 0.587 + b * 0.114 > 150; - } + let currentlyLoadedUnitID = $derived.by(() => { + const loadedUnit = ams.find((unit) => unit.trays.some((tray) => tray.loaded)); + if (loadedUnit) return loadedUnit.id; + if (externalSpool?.loaded) return EXTERNAL_SPOOL_AMS_ID; + return undefined; + }); async function unload() { - if (!currentlyLoadedUnitID) return; + if (currentlyLoadedUnitID === undefined) return; await unloadMaterial(printer?.serial ?? '', currentlyLoadedUnitID.toString()); } @@ -73,7 +63,7 @@ @@ -164,8 +154,8 @@
{@render trayCard(tray, { slotLabel: '', - amsId: 255, - trayId: 254, + amsId: EXTERNAL_SPOOL_AMS_ID, + trayId: EXTERNAL_SPOOL_TRAY_ID, title: 'External Spool' })}