diff --git a/docs/docs/faq.md b/docs/docs/faq.md
index 0a5d5df6f5..7956835a05 100644
--- a/docs/docs/faq.md
+++ b/docs/docs/faq.md
@@ -129,6 +129,14 @@ Files must be uploaded after enabling these settings. See
See [Slicer Uploads](/features/slicer-uploads) for setup instructions
covering OrcaSlicer, PrusaSlicer, SuperSlicer, and Cura.
+### My Spoolman spool weight stopped updating after a Spoolman upgrade
+
+Spoolman 0.26 added browser origin checks that block the direct connection
+Fluidd uses for live spool updates, even though spool selection and everything
+else still work. Add Fluidd's address to `SPOOLMAN_CORS_ORIGIN` on your
+Spoolman host and restart it. See
+[Live updates](/features/multi-material#live-updates) for details.
+
## System
### The host reboot / shutdown commands don't work
diff --git a/docs/docs/features/multi-material.md b/docs/docs/features/multi-material.md
index 0e1521a1ab..8dd1568856 100644
--- a/docs/docs/features/multi-material.md
+++ b/docs/docs/features/multi-material.md
@@ -26,6 +26,36 @@ Pressure Advance values.
Fluidd integrates with
[Spoolman](https://github.com/Donkie/Spoolman) for filament spool tracking.
+### Live updates
+
+Fluidd connects to Spoolman in two different ways: through Moonraker, for
+loading spool data, and directly from your browser, for live updates (spool
+weight, filament, and vendor changes). Only the direct browser connection is
+subject to your browser's cross-origin rules.
+
+Spoolman 0.26 and newer reject that direct connection unless Fluidd's origin
+is explicitly allowed, which breaks live updates while everything else
+(spool selection, sanity checks) keeps working normally. If your spool
+weight stops updating during a print after upgrading Spoolman, add the
+`SPOOLMAN_CORS_ORIGIN` environment variable to your Spoolman host and
+restart it:
+
+```text title=".env"
+SPOOLMAN_CORS_ORIGIN=http://fluidd.local,http://192.168.1.50
+```
+
+Each entry is the scheme, host, and port shown in your browser's address bar
+when you access Fluidd (no path), separated by commas if you use more than
+one address.
+
+!!! warning "Avoid the wildcard"
+ `SPOOLMAN_CORS_ORIGIN=*` allows every origin and turns off Spoolman's
+ origin checks entirely. Only use it on a trusted network.
+
+See the
+[`.env.example` file](https://github.com/Donkie/Spoolman/blob/master/.env.example)
+in the Spoolman repository for more examples.
+
### Print start
On print start, Fluidd shows a modal asking you to select a spool. You can
diff --git a/docs/docs/features/third-party-integrations.md b/docs/docs/features/third-party-integrations.md
index d1bd2e7f04..e3d2887a17 100644
--- a/docs/docs/features/third-party-integrations.md
+++ b/docs/docs/features/third-party-integrations.md
@@ -123,6 +123,12 @@ Filament spool tracking with QR code scanning, toolchanger support, and
print-start sanity checks. See [Spool Management](/features/multi-material#spool-management-spoolman)
for details.
+!!! warning "Spoolman 0.26+ requires a CORS configuration change"
+ Fluidd connects to Spoolman directly from your browser for live spool
+ updates. Spoolman 0.26 and newer block that connection unless you add
+ Fluidd's address to `SPOOLMAN_CORS_ORIGIN`. See
+ [Live updates](/features/multi-material#live-updates) for details.
+
[Spoolman on GitHub](https://github.com/Donkie/Spoolman){.md-button}
### Timelapse
diff --git a/src/components/widgets/spoolman/SpoolmanCard.vue b/src/components/widgets/spoolman/SpoolmanCard.vue
index 7dedc0b823..502ce95312 100644
--- a/src/components/widgets/spoolman/SpoolmanCard.vue
+++ b/src/components/widgets/spoolman/SpoolmanCard.vue
@@ -99,74 +99,67 @@
/>
-
-
-
-
-
-
-
- {{ getFormattedField('remaining_weight') }}
- / {{ getFormattedField('initial_weight') }}
-
-
- {{ getFormattedField('remaining_length') }}
- / {{ getFormattedField('initial_length') }}
-
-
-
-
-
- {{ getFormattedField('used_weight') }}
- / {{ getFormattedField('initial_weight') }}
-
-
- {{ getFormattedField('used_length') }}
- / {{ getFormattedField('initial_length') }}
-
-
-
-
-
-
-
- {{ getFormattedField(field) }}
-
-
-
- {{ getTooltipField(field) }}
-
-
-
- {{ getFormattedField(field) }}
-
-
-
-
-
-
- {{ $t('app.spoolman.msg.tracking_inactive') }}
-
+
+ {{ socketDiagnosticMessage }}
+
+
- {{ $t('app.spoolman.msg.not_connected') }}
+
+
+
+
+ {{ getFormattedField('remaining_weight') }}
+ / {{ getFormattedField('initial_weight') }}
+
+
+ {{ getFormattedField('remaining_length') }}
+ / {{ getFormattedField('initial_length') }}
+
+
+
+
+
+ {{ getFormattedField('used_weight') }}
+ / {{ getFormattedField('initial_weight') }}
+
+
+ {{ getFormattedField('used_length') }}
+ / {{ getFormattedField('initial_length') }}
+
+
+
+
+
+
+
+ {{ getFormattedField(field) }}
+
+
+
+ {{ getTooltipField(field) }}
+
+
+
+ {{ getFormattedField(field) }}
+
+
-
- $progressQuestion
-
-
+
+
+
+ {{ $t('app.spoolman.msg.tracking_inactive') }}
+
+
+
+
+
- $warning
-
+ {{ $t('app.spoolman.msg.not_connected') }}
+
@@ -265,6 +267,29 @@ export default class SpoolmanCard extends Mixins(StateMixin) {
return this.$typedState.spoolman.connected
}
+ get socketDiagnosticMessage (): string | null {
+ const diagnostic = this.$typedState.spoolman.socketDiagnostic
+
+ if (!this.isConnected || !diagnostic) return null
+
+ switch (diagnostic) {
+ case 'mixed-content':
+ return this.$t('app.spoolman.msg.live_updates.mixed_content').toString()
+
+ case 'cors':
+ return this.$t('app.spoolman.msg.live_updates.cors').toString()
+
+ case 'unreachable':
+ return this.$t('app.spoolman.msg.live_updates.unreachable').toString()
+
+ case 'reachable':
+ return this.$t('app.spoolman.msg.live_updates.failed').toString()
+
+ case 'cancelled':
+ return null
+ }
+ }
+
get targetableMacros () {
const macros: Macro[] = this.$typedGetters['macros/getMacros']
diff --git a/src/locales/en.yaml b/src/locales/en.yaml
index 30d308eda6..c30e6abd09 100644
--- a/src/locales/en.yaml
+++ b/src/locales/en.yaml
@@ -1168,6 +1168,21 @@ app:
Filament tracking is inactive.
To get started, please select a spool.
not_connected: Spoolman server not available.
+ live_updates:
+ cors: >-
+ Live updates from Spoolman aren't working. Spoolman is blocking
+ Fluidd's browser origin - check your Spoolman configuration to
+ allow it.
+ mixed_content: >-
+ Live updates from Spoolman aren't working. Fluidd is served over
+ HTTPS but the Spoolman address uses HTTP, so the browser blocks the
+ direct connection.
+ unreachable: >-
+ Live updates from Spoolman aren't working. Fluidd's browser can't
+ reach the Spoolman server directly.
+ failed: >-
+ Live updates from Spoolman aren't working. The direct connection to
+ Spoolman failed for an unknown reason.
info:
howto: >-
Show your spool's QR code to the camera.
diff --git a/src/store/spoolman/actions.ts b/src/store/spoolman/actions.ts
index 65159f973a..38f1c2abeb 100644
--- a/src/store/spoolman/actions.ts
+++ b/src/store/spoolman/actions.ts
@@ -11,6 +11,7 @@ import { SocketActions } from '@/api/socketActions'
import { consola } from 'consola'
import { EventBus } from '@/eventBus'
import { gte, valid } from 'semver'
+import diagnoseHttpEndpoint from '@/util/http-endpoint-diagnostics'
const logPrefix = '[SPOOLMAN]'
@@ -49,6 +50,8 @@ const createSpoolmanSocket = (spoolmanUrl: string): WebSocket | undefined => {
}
}
+let diagnoseSocketFailureAbortController: AbortController | undefined
+
export const actions = {
/**
* Reset our store
@@ -209,10 +212,26 @@ export const actions = {
if (socket == null) {
commit('setSocket', null)
+ commit('setSocketDiagnostic', 'unreachable')
return
}
+ let opened = false
+
+ socket.onopen = () => {
+ opened = true
+ diagnoseSocketFailureAbortController?.abort()
+ commit('setSocketDiagnostic', null)
+ }
+
socket.onerror = err => consola.warn(`${logPrefix} received websocket error`, err)
+
+ socket.onclose = () => {
+ if (!opened && state.socket === socket) {
+ dispatch('diagnoseSocketFailure')
+ }
+ }
+
socket.onmessage = event => {
let data: WebsocketBasePayload
@@ -245,5 +264,28 @@ export const actions = {
} else {
commit('setSocket', null)
}
+ },
+
+ async diagnoseSocketFailure ({ getters, commit }) {
+ const spoolmanUrl: string | undefined = getters.getSpoolmanUrl
+
+ if (!spoolmanUrl) {
+ return
+ }
+
+ diagnoseSocketFailureAbortController?.abort()
+ diagnoseSocketFailureAbortController = new AbortController()
+
+ const result = await diagnoseHttpEndpoint(spoolmanUrl, {
+ probePath: 'api/v1/info',
+ timeout: 5000,
+ signal: diagnoseSocketFailureAbortController.signal
+ })
+
+ if (result.kind === 'cancelled') {
+ return
+ }
+
+ commit('setSocketDiagnostic', result.kind)
}
} satisfies ActionTree
diff --git a/src/store/spoolman/mutations.ts b/src/store/spoolman/mutations.ts
index b50858c6f8..361e593d49 100644
--- a/src/store/spoolman/mutations.ts
+++ b/src/store/spoolman/mutations.ts
@@ -5,6 +5,7 @@ import type {
SpoolmanState,
SpoolSelectionDialogState
} from '@/store/spoolman/types'
+import type { HttpDiagnosticResult } from '@/util/http-endpoint-diagnostics'
export const mutations = {
/**
@@ -42,5 +43,9 @@ export const mutations = {
setSocket (state, payload: WebSocket | null) {
state.socket?.close()
state.socket = payload != null ? markRaw(payload) : null
+ },
+
+ setSocketDiagnostic (state, payload: HttpDiagnosticResult['kind'] | null) {
+ state.socketDiagnostic = payload
}
} satisfies MutationTree
diff --git a/src/store/spoolman/state.ts b/src/store/spoolman/state.ts
index e1505d5040..5301580b59 100644
--- a/src/store/spoolman/state.ts
+++ b/src/store/spoolman/state.ts
@@ -10,7 +10,8 @@ export const defaultState = (): SpoolmanState => {
dialog: {
show: false
},
- socket: null
+ socket: null,
+ socketDiagnostic: null
}
}
diff --git a/src/store/spoolman/types.ts b/src/store/spoolman/types.ts
index aec4bc9c4b..28df7e1d2a 100644
--- a/src/store/spoolman/types.ts
+++ b/src/store/spoolman/types.ts
@@ -1,3 +1,5 @@
+import type { HttpDiagnosticResult } from '@/util/http-endpoint-diagnostics'
+
export interface SpoolmanState {
info: Readonly | null;
spools: readonly Moonraker.Spoolman.Spool[];
@@ -6,6 +8,7 @@ export interface SpoolmanState {
connected: boolean;
dialog: SpoolSelectionDialogState;
socket: WebSocket | null;
+ socketDiagnostic: HttpDiagnosticResult['kind'] | null;
}
export interface Spool extends Omit {
diff --git a/src/util/__tests__/http-endpoint-diagnostics.spec.ts b/src/util/__tests__/http-endpoint-diagnostics.spec.ts
index cd588059c8..1dc3063a96 100644
--- a/src/util/__tests__/http-endpoint-diagnostics.spec.ts
+++ b/src/util/__tests__/http-endpoint-diagnostics.spec.ts
@@ -66,6 +66,18 @@ describe('diagnoseHttpEndpoint', () => {
expect(fetchMock).toHaveBeenCalledTimes(2)
})
+ it('probes a custom path when probePath is provided', async () => {
+ setProtocol('http:')
+
+ const fetchMock = vi.fn().mockResolvedValue({ status: 200 })
+ vi.stubGlobal('fetch', fetchMock)
+
+ const result = await diagnoseHttpEndpoint('http://spoolman.local/', { probePath: 'api/v1/info' })
+
+ expect(result).toEqual({ kind: 'reachable', status: 200 })
+ expect(fetchMock.mock.calls[0][0]).toMatch(/^http:\/\/spoolman\.local\/api\/v1\/info\?t=\d+$/)
+ })
+
it('returns cancelled when the signal aborts before the cors fetch settles', async () => {
setProtocol('http:')
diff --git a/src/util/http-endpoint-diagnostics.ts b/src/util/http-endpoint-diagnostics.ts
index 3fea01f19f..632ec47f0e 100644
--- a/src/util/http-endpoint-diagnostics.ts
+++ b/src/util/http-endpoint-diagnostics.ts
@@ -10,12 +10,13 @@ export type HttpDiagnosticResult =
export interface DiagnoseOptions {
timeout?: number;
signal?: AbortSignal;
+ probePath?: string;
}
const diagnoseHttpEndpoint = async (apiUrl: string, options: DiagnoseOptions = {}): Promise => {
const debug = (message: string, ...args: unknown[]) => consola.debug(`[diagnoseHttpEndpoint] ${apiUrl} ${message}`, ...args)
- const { timeout, signal } = options
+ const { timeout, signal, probePath = '/server/info' } = options
// A secure page can't open an insecure connection; detected before any request.
if (window.location.protocol === 'https:' && apiUrl.startsWith('http://')) {
@@ -35,7 +36,13 @@ const diagnoseHttpEndpoint = async (apiUrl: string, options: DiagnoseOptions = {
.filter(Boolean)
)
- const probeUrl = `${apiUrl}/server/info?t=${Date.now()}`
+ const baseUrl = apiUrl.endsWith('/')
+ ? apiUrl.slice(0, -1)
+ : apiUrl
+ const path = probePath.startsWith('/')
+ ? probePath
+ : `/${probePath}`
+ const probeUrl = `${baseUrl}${path}?t=${Date.now()}`
try {
const response = await fetch(probeUrl, {