Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions docs/docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/docs/features/multi-material.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/features/third-party-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 102 additions & 77 deletions src/components/widgets/spoolman/SpoolmanCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,74 +99,67 @@
/>

<v-card-text>
<v-row>
<template v-if="activeSpool">
<v-col
v-for="(fields, i) in selectedCardFields"
:key="`spoolman-card-col-${i}`"
align-self="center"
>
<template v-for="field in fields">
<status-label
:key="`spoolman-card-${field}`"
:label="getFieldLabel(field)"
:label-width="86"
>
<template v-if="field === 'remaining_weight'">
<span v-if="remainingFilamentUnit === 'weight'">
{{ getFormattedField('remaining_weight') }}
<small>/ {{ getFormattedField('initial_weight') }}</small>
</span>
<span v-else-if="remainingFilamentUnit === 'length'">
{{ getFormattedField('remaining_length') }}
<small>/ {{ getFormattedField('initial_length') }}</small>
</span>
</template>

<template v-else-if="field === 'used_weight'">
<span v-if="remainingFilamentUnit === 'weight'">
{{ getFormattedField('used_weight') }}
<small>/ {{ getFormattedField('initial_weight') }}</small>
</span>
<span v-else-if="remainingFilamentUnit === 'length'">
{{ getFormattedField('used_length') }}
<small>/ {{ getFormattedField('initial_length') }}</small>
</span>
</template>

<template v-else-if="getTooltipField(field) != null">
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<span
v-bind="attrs"
v-on="on"
>
{{ getFormattedField(field) }}
</span>
</template>

{{ getTooltipField(field) }}
</v-tooltip>
</template>

<span v-else>{{ getFormattedField(field) }}</span>
</status-label>
</template>
</v-col>
</template>

<v-col
v-else-if="isConnected"
align-self="center"
>
{{ $t('app.spoolman.msg.tracking_inactive') }}
</v-col>
<v-alert
v-if="socketDiagnosticMessage"
type="warning"
dense
text
>
{{ socketDiagnosticMessage }}
</v-alert>

<v-row v-if="activeSpool">
<v-col
v-else
v-for="fields in selectedCardFields"
:key="`spoolman-card-col-${fields.join(',')}`"
align-self="center"
>
{{ $t('app.spoolman.msg.not_connected') }}
<template v-for="field in fields">
<status-label
:key="`spoolman-card-${field}`"
:label="getFieldLabel(field)"
:label-width="86"
>
<template v-if="field === 'remaining_weight'">
<span v-if="remainingFilamentUnit === 'weight'">
{{ getFormattedField('remaining_weight') }}
<small>/ {{ getFormattedField('initial_weight') }}</small>
</span>
<span v-else-if="remainingFilamentUnit === 'length'">
{{ getFormattedField('remaining_length') }}
<small>/ {{ getFormattedField('initial_length') }}</small>
</span>
</template>

<template v-else-if="field === 'used_weight'">
<span v-if="remainingFilamentUnit === 'weight'">
{{ getFormattedField('used_weight') }}
<small>/ {{ getFormattedField('initial_weight') }}</small>
</span>
<span v-else-if="remainingFilamentUnit === 'length'">
{{ getFormattedField('used_length') }}
<small>/ {{ getFormattedField('initial_length') }}</small>
</span>
</template>

<template v-else-if="getTooltipField(field) != null">
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<span
v-bind="attrs"
v-on="on"
>
{{ getFormattedField(field) }}
</span>
</template>

{{ getTooltipField(field) }}
</v-tooltip>
</template>

<span v-else>{{ getFormattedField(field) }}</span>
</status-label>
</template>
</v-col>

<v-col
Expand All @@ -176,7 +169,6 @@
class="pa-0"
>
<v-progress-circular
v-if="activeSpool"
:rotate="-90"
:size="102"
:width="7"
Expand All @@ -192,19 +184,29 @@
$filament
</v-icon>
</v-progress-circular>
<v-icon
v-else-if="isConnected"
size="55"
>
$progressQuestion
</v-icon>
<v-icon
v-else
color="warning"
size="55"
</v-col>
</v-row>

<v-alert
v-else-if="isConnected"
type="info"
dense
text
class="mb-0"
>
{{ $t('app.spoolman.msg.tracking_inactive') }}
</v-alert>

<v-row v-else>
<v-col>
<v-alert
type="warning"
dense
text
class="mb-0"
>
$warning
</v-icon>
{{ $t('app.spoolman.msg.not_connected') }}
</v-alert>
</v-col>
</v-row>
</v-card-text>
Expand Down Expand Up @@ -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']

Expand Down
15 changes: 15 additions & 0 deletions src/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions src/store/spoolman/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]'

Expand Down Expand Up @@ -49,6 +50,8 @@ const createSpoolmanSocket = (spoolmanUrl: string): WebSocket | undefined => {
}
}

let diagnoseSocketFailureAbortController: AbortController | undefined

export const actions = {
/**
* Reset our store
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<SpoolmanState, RootState>
5 changes: 5 additions & 0 deletions src/store/spoolman/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
SpoolmanState,
SpoolSelectionDialogState
} from '@/store/spoolman/types'
import type { HttpDiagnosticResult } from '@/util/http-endpoint-diagnostics'

export const mutations = {
/**
Expand Down Expand Up @@ -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<SpoolmanState>
3 changes: 2 additions & 1 deletion src/store/spoolman/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const defaultState = (): SpoolmanState => {
dialog: {
show: false
},
socket: null
socket: null,
socketDiagnostic: null
}
}

Expand Down
Loading