Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d1379d2
fix(web): remove debug log leaking printer access code
bwees Jul 15, 2026
37bb626
fix(web): correct broken responsive grid class
bwees Jul 15, 2026
b489c7f
fix(web): treat main AMS unit (id 0) as loaded
bwees Jul 15, 2026
7462d83
fix(web): drop unsound non-null assertions in printer actions
bwees Jul 15, 2026
edb1998
fix(web): cache filament catalog to avoid refetch
bwees Jul 15, 2026
ca0c15e
refactor(web): remove dead printer manager code
bwees Jul 15, 2026
c29c28c
refactor(web): consolidate bambu color parsing into shared util
bwees Jul 15, 2026
39b2548
refactor(web): dedup Go2RTCPlayer helpers and drop any casts
bwees Jul 15, 2026
daa5c29
fix(server): guard against panic on empty go2rtc producers
bwees Jul 15, 2026
9660062
fix(server): add timeout and status check to go2rtc camera client
bwees Jul 15, 2026
2a1c14d
fix(server): correct accesCode typo
bwees Jul 15, 2026
cfaaf16
refactor(server): standardize logging on slog
bwees Jul 15, 2026
13042cb
refactor(server): typed constants for print/notification states
bwees Jul 15, 2026
a6798c9
refactor(server): collapse duplicated printer control methods
bwees Jul 15, 2026
8460487
refactor(server): use requireAdmin helper consistently
bwees Jul 15, 2026
8eeb56f
refactor(server): support multiple socketio connect handlers
bwees Jul 15, 2026
6322e9f
refactor(server): move go2rtc camera client out of repositories
bwees Jul 15, 2026
7d6956b
style(server): gofmt struct alignment
bwees Jul 15, 2026
41fe4e6
refactor(server): centralize environment configuration
bwees Jul 15, 2026
3494474
chore: move hardcoded docker-compose values to env
bwees Jul 15, 2026
0119871
ci: run lint, typecheck, and tests
bwees Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 4 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ services:
- "8555:8555" # webrtc
restart: unless-stopped
environment:
- TZ=America/Chicago
- TZ=${TZ:-UTC}

server:
build:
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
25 changes: 13 additions & 12 deletions server/internal/bambu/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"math"
"strconv"
"sync"
Expand All @@ -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

Expand Down Expand Up @@ -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())
}
}

Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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,
}
Expand All @@ -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})
Expand All @@ -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())
}
}()

Expand Down
68 changes: 62 additions & 6 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 8 additions & 7 deletions server/internal/controllers/users.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package controllers

import (
"context"

"crosshatch/internal/dtos"
"crosshatch/internal/services"

Expand All @@ -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
}
Expand All @@ -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
}

Expand All @@ -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()
Expand All @@ -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
}

Expand Down
8 changes: 8 additions & 0 deletions server/internal/dtos/notification.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Expand Down
7 changes: 7 additions & 0 deletions server/internal/dtos/printer_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading
Loading