From d1379d27656db17718e28a5a28b93f6afb152b87 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:17:50 -0500 Subject: [PATCH 01/21] fix(web): remove debug log leaking printer access code --- web/src/lib/components/CreatePrinterDialog.svelte | 2 -- 1 file changed, 2 deletions(-) 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(); From 37bb626c0ced7624dbb67ced872e58b4d3c54506 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:17:57 -0500 Subject: [PATCH 02/21] fix(web): correct broken responsive grid class --- web/src/routes/(app)/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)} Date: Tue, 14 Jul 2026 19:18:30 -0500 Subject: [PATCH 03/21] fix(web): treat main AMS unit (id 0) as loaded --- .../(app)/printer/[id]/MaterialsCard.svelte | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/web/src/routes/(app)/printer/[id]/MaterialsCard.svelte b/web/src/routes/(app)/printer/[id]/MaterialsCard.svelte index f57ec01..a97df77 100644 --- a/web/src/routes/(app)/printer/[id]/MaterialsCard.svelte +++ b/web/src/routes/(app)/printer/[id]/MaterialsCard.svelte @@ -12,6 +12,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,10 +43,12 @@ 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) - ); + 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; + }); function bambuColorToCss(color?: string): string { if (!color) return 'transparent'; @@ -62,7 +67,7 @@ } async function unload() { - if (!currentlyLoadedUnitID) return; + if (currentlyLoadedUnitID === undefined) return; await unloadMaterial(printer?.serial ?? '', currentlyLoadedUnitID.toString()); } @@ -73,7 +78,7 @@ @@ -164,8 +169,8 @@
{@render trayCard(tray, { slotLabel: '', - amsId: 255, - trayId: 254, + amsId: EXTERNAL_SPOOL_AMS_ID, + trayId: EXTERNAL_SPOOL_TRAY_ID, title: 'External Spool' })}
From 7462d8362c2da26faec16228422bddb46d222f36 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:18:41 -0500 Subject: [PATCH 04/21] fix(web): drop unsound non-null assertions in printer actions --- web/src/lib/components/actions/PlayPause.svelte | 2 +- web/src/lib/components/actions/Stop.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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; From edb1998c1c6f4d88382be424ddb45eb29f9ad198 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:19:01 -0500 Subject: [PATCH 05/21] fix(web): cache filament catalog to avoid refetch --- web/src/lib/filaments.svelte.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; } From ca0c15edefba1988d5cad646e3001e502dfab156 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:19:20 -0500 Subject: [PATCH 06/21] refactor(web): remove dead printer manager code --- web/src/lib/managers/bambu_printer.svelte.ts | 11 ----------- web/src/lib/managers/printers.manager.svelte.ts | 5 ----- 2 files changed, 16 deletions(-) delete mode 100644 web/src/lib/managers/bambu_printer.svelte.ts 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(); } } From c29c28cba60b3c1f66461892e2ffa7e51a84b625 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:20:13 -0500 Subject: [PATCH 07/21] refactor(web): consolidate bambu color parsing into shared util --- web/src/lib/utils/bambu-color.ts | 29 +++++++++++++++++++ .../(app)/printer/[id]/FilamentDialog.svelte | 13 ++------- .../(app)/printer/[id]/MaterialsCard.svelte | 17 +---------- 3 files changed, 32 insertions(+), 27 deletions(-) create mode 100644 web/src/lib/utils/bambu-color.ts 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)/printer/[id]/FilamentDialog.svelte b/web/src/routes/(app)/printer/[id]/FilamentDialog.svelte index 2f9a987..04104b2 100644 --- a/web/src/routes/(app)/printer/[id]/FilamentDialog.svelte +++ b/web/src/routes/(app)/printer/[id]/FilamentDialog.svelte @@ -8,6 +8,7 @@ import { setFilament, type Filament, type Printer, type PrinterStatus } from '$lib/sdk'; import { Spinner } from '$lib/components/ui/spinner'; import { cn } from '$lib/utils'; + import { toBambuColor, toHexInput } from '$lib/utils/bambu-color'; import { ChevronDownIcon } from '@lucide/svelte'; import { onMount } from 'svelte'; @@ -33,16 +34,6 @@ onMount(() => 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 a97df77..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'; @@ -50,22 +51,6 @@ return undefined; }); - 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; - } - async function unload() { if (currentlyLoadedUnitID === undefined) return; await unloadMaterial(printer?.serial ?? '', currentlyLoadedUnitID.toString()); From 39b2548dc917ae91687fa1ab48a1c7ab93cfd2a9 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:23:11 -0500 Subject: [PATCH 08/21] refactor(web): dedup Go2RTCPlayer helpers and drop any casts --- web/src/app.d.ts | 10 ++++ web/src/lib/components/Go2RTCPlayer.svelte | 70 ++++++++++------------ 2 files changed, 43 insertions(+), 37 deletions(-) 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/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; From daa5c29be3e5abd3dad93e4e0d3258e12d613da7 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:26:36 -0500 Subject: [PATCH 09/21] fix(server): guard against panic on empty go2rtc producers --- server/internal/services/printer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/internal/services/printer.go b/server/internal/services/printer.go index 7cf89fe..6d610d2 100644 --- a/server/internal/services/printer.go +++ b/server/internal/services/printer.go @@ -319,7 +319,7 @@ func (s *PrinterService) reconcileCameraStreams() { 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) From 96600627e830c60bd0511278829971dde2779f9a Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:27:10 -0500 Subject: [PATCH 10/21] fix(server): add timeout and status check to go2rtc camera client --- server/internal/repositories/camera.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/server/internal/repositories/camera.go b/server/internal/repositories/camera.go index 2fad451..d4a7d59 100644 --- a/server/internal/repositories/camera.go +++ b/server/internal/repositories/camera.go @@ -7,14 +7,16 @@ import ( "net/http" "net/url" "os" + "time" ) type CameraRepository struct { baseURL string + client *http.Client } func (r *CameraRepository) GetStreams() (map[string]dtos.Go2RTCStream, error) { - res, err := http.Get(r.baseURL + "/api/streams") + res, err := r.client.Get(r.baseURL + "/api/streams") if err != nil { return nil, err } @@ -34,13 +36,16 @@ func (r *CameraRepository) DeleteStream(id 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 delete stream for printer %s: %s", id, res.Status) + } + return nil } @@ -56,7 +61,7 @@ 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 } @@ -80,7 +85,7 @@ 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 } @@ -99,5 +104,8 @@ func NewCameraRepository() *CameraRepository { baseURL = "http://localhost:1984" } - return &CameraRepository{baseURL: baseURL} + return &CameraRepository{ + baseURL: baseURL, + client: &http.Client{Timeout: 10 * time.Second}, + } } From 2a1c14d682ee536fcd0635e4d9c4c75f0f313c18 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:27:42 -0500 Subject: [PATCH 11/21] fix(server): correct accesCode typo --- server/internal/bambu/client.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/internal/bambu/client.go b/server/internal/bambu/client.go index d3eeb7c..8ee067d 100644 --- a/server/internal/bambu/client.go +++ b/server/internal/bambu/client.go @@ -32,7 +32,7 @@ type StatusUpdateHandler func(serial string, state *dtos.BambuPrintState) type BambuClient struct { ip string - accesCode string + accessCode string serial string mqttClient mqtt.Client @@ -258,10 +258,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 +270,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}) From cfaaf16021d5b177b87ae917f4707878aa867759 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:29:38 -0500 Subject: [PATCH 12/21] refactor(server): standardize logging on slog --- server/internal/bambu/client.go | 13 +++++++------ server/internal/services/notification.go | 11 ++++++----- server/internal/services/printer.go | 20 ++++++++++---------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/server/internal/bambu/client.go b/server/internal/bambu/client.go index 8ee067d..17af323 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" @@ -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() { @@ -282,7 +283,7 @@ func NewBambuClient(ip string, accessCode string, serial string, onStatusUpdate 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/services/notification.go b/server/internal/services/notification.go index 7914b73..dd8388d 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" @@ -80,13 +81,13 @@ func (s *NotificationService) Notify(serial, event string) { 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 +134,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/printer.go b/server/internal/services/printer.go index 6d610d2..227dbd6 100644 --- a/server/internal/services/printer.go +++ b/server/internal/services/printer.go @@ -8,6 +8,7 @@ import ( "crosshatch/internal/repositories" "crosshatch/internal/socketio" "fmt" + "log/slog" "sync" "go.uber.org/fx" @@ -226,7 +227,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 +284,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,18 +303,17 @@ 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() 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 } @@ -322,11 +322,11 @@ func (s *PrinterService) reconcileCameraStreams() { 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) + 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) + slog.Error("failed to add camera stream", "serial", printer.Serial, "error", err) } } } @@ -335,7 +335,7 @@ 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) + slog.Error("failed to delete camera stream", "serial", serial, "error", err) } } } From 13042cb26b38566407e905b074c01e8d647dad26 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:30:52 -0500 Subject: [PATCH 13/21] refactor(server): typed constants for print/notification states --- server/internal/dtos/notification.go | 8 ++++++ server/internal/dtos/printer_status.go | 7 ++++++ server/internal/repositories/notification.go | 10 ++++---- server/internal/services/notification.go | 25 +++++++++---------- server/internal/services/notification_test.go | 8 +++--- 5 files changed, 36 insertions(+), 22 deletions(-) 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/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/services/notification.go b/server/internal/services/notification.go index dd8388d..5c0a873 100644 --- a/server/internal/services/notification.go +++ b/server/internal/services/notification.go @@ -26,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 @@ -61,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{ @@ -71,10 +70,10 @@ 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" } 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 { From a6798c901202b828c9f6e717707341ef98d35b0a Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:31:40 -0500 Subject: [PATCH 14/21] refactor(server): collapse duplicated printer control methods --- server/internal/services/printer.go | 68 +++++++++-------------------- 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/server/internal/services/printer.go b/server/internal/services/printer.go index 227dbd6..d06690d 100644 --- a/server/internal/services/printer.go +++ b/server/internal/services/printer.go @@ -124,68 +124,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{ @@ -199,19 +179,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 From 8460487a7ab5c1c0442002f2202dcd267707c875 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:32:19 -0500 Subject: [PATCH 15/21] refactor(server): use requireAdmin helper consistently --- server/internal/controllers/users.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 } From 8eeb56ff062e718b87ffa4d1b369aa4c438dc058 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:32:49 -0500 Subject: [PATCH 16/21] refactor(server): support multiple socketio connect handlers --- server/internal/socketio/socketio.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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) { From 6322e9f75fb1a56fcb6db845bf2c369d16c6d832 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:34:20 -0500 Subject: [PATCH 17/21] refactor(server): move go2rtc camera client out of repositories --- .../camera.go => go2rtc/client.go} | 26 ++++++++++++------- server/internal/repositories/repositories.go | 1 - server/internal/services/printer.go | 15 ++++++----- server/main.go | 2 ++ 4 files changed, 26 insertions(+), 18 deletions(-) rename server/internal/{repositories/camera.go => go2rtc/client.go} (72%) diff --git a/server/internal/repositories/camera.go b/server/internal/go2rtc/client.go similarity index 72% rename from server/internal/repositories/camera.go rename to server/internal/go2rtc/client.go index d4a7d59..6d4fe4e 100644 --- a/server/internal/repositories/camera.go +++ b/server/internal/go2rtc/client.go @@ -1,4 +1,6 @@ -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/dtos" @@ -8,14 +10,16 @@ import ( "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) { +func (r *Client) GetStreams() (map[string]dtos.Go2RTCStream, error) { res, err := r.client.Get(r.baseURL + "/api/streams") if err != nil { return nil, err @@ -30,7 +34,7 @@ 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 @@ -49,7 +53,7 @@ func (r *CameraRepository) DeleteStream(id string) error { 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( @@ -68,13 +72,13 @@ func (r *CameraRepository) AddStream(id string, streamURL string) error { 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, @@ -92,20 +96,22 @@ func (r *CameraRepository) UpdateStream(id string, streamURL string) error { 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 { +func NewClient() *Client { baseURL := os.Getenv("GO2RTC_API_URL") if baseURL == "" { baseURL = "http://localhost:1984" } - return &CameraRepository{ + return &Client{ baseURL: baseURL, client: &http.Client{Timeout: 10 * time.Second}, } } + +var Module = fx.Provide(NewClient) 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/printer.go b/server/internal/services/printer.go index d06690d..1467b47 100644 --- a/server/internal/services/printer.go +++ b/server/internal/services/printer.go @@ -5,6 +5,7 @@ import ( "crosshatch/internal/bambu" "crosshatch/internal/database/models" "crosshatch/internal/dtos" + "crosshatch/internal/go2rtc" "crosshatch/internal/repositories" "crosshatch/internal/socketio" "fmt" @@ -16,7 +17,7 @@ import ( type PrinterService struct { printerRepo *repositories.PrinterRepository - cameraRepo *repositories.CameraRepository + camera *go2rtc.Client filamentRepo *repositories.FilamentRepository socketio *socketio.SocketIO @@ -285,7 +286,7 @@ func (s *PrinterService) reconcileCameraStreams() { return } - streams, err := s.cameraRepo.GetStreams() + streams, err := s.camera.GetStreams() if err != nil { slog.Error("failed to fetch camera streams for reconciliation", "error", err) return @@ -295,11 +296,11 @@ func (s *PrinterService) reconcileCameraStreams() { stream, exists := streams[printer.Serial] 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 { + 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 { + if err := s.camera.AddStream(printer.Serial, printer.CameraURL()); err != nil { slog.Error("failed to add camera stream", "serial", printer.Serial, "error", err) } } @@ -308,17 +309,17 @@ func (s *PrinterService) reconcileCameraStreams() { for serial := range streams { if !containsPrinter(printers, serial) { - if err := s.cameraRepo.DeleteStream(serial); err != nil { + 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/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), ) From 7d6956b09d223e97a44847370fafb384677655f4 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:37:01 -0500 Subject: [PATCH 18/21] style(server): gofmt struct alignment --- server/internal/bambu/client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/internal/bambu/client.go b/server/internal/bambu/client.go index 17af323..9bfa44d 100644 --- a/server/internal/bambu/client.go +++ b/server/internal/bambu/client.go @@ -32,9 +32,9 @@ const ( type StatusUpdateHandler func(serial string, state *dtos.BambuPrintState) type BambuClient struct { - ip string + ip string accessCode string - serial string + serial string mqttClient mqtt.Client @@ -262,7 +262,7 @@ func (c *BambuClient) UnloadMaterial(amsID int) error { func NewBambuClient(ip string, accessCode string, serial string, onStatusUpdate StatusUpdateHandler) *BambuClient { client := &BambuClient{ ip: ip, - accessCode: accessCode, + accessCode: accessCode, serial: serial, onStatusUpdate: onStatusUpdate, } From 41fe4e66f29f7762009d4467ebca91f569848c67 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:37:07 -0500 Subject: [PATCH 19/21] refactor(server): centralize environment configuration --- .env.example | 25 ++++++++++++ server/internal/config/config.go | 68 ++++++++++++++++++++++++++++--- server/internal/go2rtc/client.go | 9 +--- server/internal/proxy/go2rtc.go | 7 +--- server/internal/services/vapid.go | 12 ++---- server/internal/utils/origin.go | 7 ++-- server/server.go | 6 +-- server/static.go | 4 +- 8 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf467f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Copy to .env and adjust for your environment. + +# 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/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/go2rtc/client.go b/server/internal/go2rtc/client.go index 6d4fe4e..950b205 100644 --- a/server/internal/go2rtc/client.go +++ b/server/internal/go2rtc/client.go @@ -3,12 +3,12 @@ package go2rtc import ( + "crosshatch/internal/config" "crosshatch/internal/dtos" "encoding/json" "fmt" "net/http" "net/url" - "os" "time" "go.uber.org/fx" @@ -103,13 +103,8 @@ func (r *Client) UpdateStream(id string, streamURL string) error { } func NewClient() *Client { - baseURL := os.Getenv("GO2RTC_API_URL") - if baseURL == "" { - baseURL = "http://localhost:1984" - } - return &Client{ - baseURL: baseURL, + baseURL: config.Go2RTCAPIURL(), client: &http.Client{Timeout: 10 * time.Second}, } } 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/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/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/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 } From 349447419f545831a4a498990e7605d1e63eea6e Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:38:02 -0500 Subject: [PATCH 20/21] chore: move hardcoded docker-compose values to env --- .env.example | 6 +++++- docker-compose.yml | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index cf467f0..102b7be 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ -# Copy to .env and adjust for your environment. +# 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 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 From 0119871cd557e2fd70e1403e8ce5ab4d296891c5 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 14 Jul 2026 19:40:19 -0500 Subject: [PATCH 21/21] ci: run lint, typecheck, and tests --- .github/workflows/build.yml | 4 ++++ .github/workflows/lint.yml | 6 ++++++ mise.toml | 28 ++++++++++++++++++++++++++++ web/eslint.config.js | 2 ++ 4 files changed, 40 insertions(+) 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/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/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,