feat: add spoolman support (#1542)

This commit is contained in:
Stefan Dej 2023-10-06 21:22:46 +02:00 committed by GitHub
parent 5734f1cd1d
commit d8430f54ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1076 additions and 11 deletions

View File

@ -277,6 +277,7 @@ export default class App extends Mixins(BaseMixin) {
@Watch('print_percent')
print_percentChanged(newVal: number): void {
this.drawFavicon(newVal)
this.refreshSpoolman()
}
@Watch('printerIsPrinting')
@ -284,6 +285,12 @@ export default class App extends Mixins(BaseMixin) {
this.drawFavicon(this.print_percent)
}
refreshSpoolman(): void {
if (this.moonrakerComponents.includes('spoolman')) {
this.$store.dispatch('server/spoolman/refreshActiveSpool', null, { root: true })
}
}
appHeight() {
this.$nextTick(() => {
const doc = document.documentElement

View File

@ -0,0 +1,174 @@
<template>
<div>
<v-dialog v-model="showDialog" width="800" persistent :fullscreen="isMobile">
<panel
:title="$t('Panels.SpoolmanPanel.ChangeSpool')"
:icon="mdiAdjust"
card-class="spoolman-change-spool-dialog"
:margin-bottom="false">
<template #buttons>
<v-btn icon tile @click="close">
<v-icon>{{ mdiCloseThick }}</v-icon>
</v-btn>
</template>
<v-card-title>
<v-text-field
v-model="search"
:append-icon="mdiMagnify"
:label="$t('Panels.SpoolmanPanel.Search')"
outlined
dense
hide-details
style="max-width: 300px" />
<v-spacer />
<v-btn
:title="$t('Panels.SpoolmanPanel.Refresh')"
class="px-2 minwidth-0 ml-3"
:loading="loadings.includes('refreshSpools')"
@click="refreshSpools">
<v-icon>{{ mdiRefresh }}</v-icon>
</v-btn>
<v-btn
:title="$t('Panels.SpoolmanPanel.OpenSpoolManager')"
class="px-2 minwidth-0 ml-3"
@click="openSpoolManager">
<v-icon>{{ mdiDatabase }}</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="px-0 pb-0">
<v-data-table
:headers="headers"
:items="spools"
item-key="id"
:search="search"
sort-by="last_used"
:sort-desc="true"
:custom-filter="customFilter">
<template #no-data>
<div class="text-center">{{ $t('Panels.SpoolmanPanel.NoSpools') }}</div>
</template>
<template #no-results>
<div class="text-center">{{ $t('Panels.SpoolmanPanel.NoResults') }}</div>
</template>
<template #item="{ item }">
<SpoolmanChangeSpoolDialogRow :key="item.id" :spool="item" @set-spool="setSpool" />
</template>
</v-data-table>
</v-card-text>
</panel>
</v-dialog>
</div>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import { Mixins, Prop, Watch } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import Panel from '@/components/ui/Panel.vue'
import { mdiCloseThick, mdiAdjust, mdiDatabase, mdiMagnify, mdiRefresh } from '@mdi/js'
import { ServerSpoolmanStateSpool } from '@/store/server/spoolman/types'
import SpoolmanChangeSpoolDialogRow from '@/components/dialogs/SpoolmanChangeSpoolDialogRow.vue'
@Component({
components: { SpoolmanChangeSpoolDialogRow, Panel },
})
export default class SpoolmanChangeSpoolDialog extends Mixins(BaseMixin) {
mdiAdjust = mdiAdjust
mdiCloseThick = mdiCloseThick
mdiDatabase = mdiDatabase
mdiMagnify = mdiMagnify
mdiRefresh = mdiRefresh
@Prop({ required: true }) declare readonly showDialog: boolean
search = ''
get spools(): ServerSpoolmanStateSpool[] {
return this.$store.state.server.spoolman.spools ?? []
}
get headers() {
return [
{
text: ' ',
align: 'start',
sortable: false,
},
{
text: this.$t('Panels.SpoolmanPanel.Filament'),
align: 'start',
value: 'filament.name',
sortable: false,
},
{
text: this.$t('Panels.SpoolmanPanel.Material'),
align: 'center',
value: 'filament.material',
},
{
text: this.$t('Panels.SpoolmanPanel.LastUsed'),
align: 'end',
value: 'last_used',
},
{
text: this.$t('Panels.SpoolmanPanel.Weight'),
align: 'end',
value: 'remaining_weight',
},
]
}
get spoolManagerUrl() {
return this.$store.state.server.config.config?.spoolman?.server ?? null
}
openSpoolManager() {
window.open(this.spoolManagerUrl, '_blank')
}
mounted() {
this.refresh()
}
refresh() {
this.$store.dispatch('server/spoolman/refreshSpools')
}
close() {
this.$emit('close')
}
refreshSpools() {
this.$store.dispatch('server/spoolman/refreshSpools')
}
customFilter(value: any, search: string, item: ServerSpoolmanStateSpool): boolean {
const querySplits = search.toLowerCase().split(' ')
const searchArray = [
item.comment,
item.filament.name,
item.filament.vendor.name,
item.filament.material,
item.location,
]
for (const query of querySplits) {
const result = searchArray.some((q) => q?.toLowerCase().includes(query))
if (!result) return false
}
return true
}
setSpool(spool: ServerSpoolmanStateSpool) {
this.$store.dispatch('server/spoolman/setActiveSpool', spool.id)
this.close()
}
@Watch('showDialog')
onShowDialogChanged(newVal: boolean) {
if (newVal) this.search = ''
}
}
</script>

View File

@ -0,0 +1,104 @@
<template>
<tr class="cursor-pointer" @click="setSpoolRow">
<td style="width: 50px" class="pr-0 py-2">
<spool-icon :color="color" style="width: 50px; float: left" class="mr-3" />
</td>
<td class="py-2" style="min-width: 300px">
<strong class="text-no-wrap">{{ vendor }} - {{ name }}</strong>
<template v-if="location">
<br />
{{ $t('Panels.SpoolmanPanel.Location') }}: {{ location }}
</template>
<template v-if="spool.comment">
<br />
{{ spool.comment }}
</template>
</td>
<td class="text-center text-no-wrap">{{ material }}</td>
<td class="text-right text-no-wrap">{{ last_used }}</td>
<td class="text-right text-no-wrap">
<strong>{{ remaining_weight_format }}</strong>
<small class="ml-1">/ {{ total_weight_format }}</small>
</td>
</tr>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import { Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import { ServerSpoolmanStateSpool } from '@/store/server/spoolman/types'
@Component({})
export default class SpoolmanChangeSpoolDialogRow extends Mixins(BaseMixin) {
@Prop({ required: true }) declare readonly spool: ServerSpoolmanStateSpool
get color() {
const color = this.spool.filament?.color_hex ?? '000'
return `#${color}`
}
get vendor() {
return this.spool.filament?.vendor?.name ?? 'Unknown'
}
get name() {
return this.spool.filament?.name ?? 'Unknown'
}
get location() {
return this.spool.location
}
get material() {
return this.spool.filament?.material ?? '--'
}
get remaining_weight() {
return this.spool.remaining_weight ?? 0
}
get total_weight() {
return this.spool.filament?.weight ?? 0
}
get remaining_weight_format() {
return `${this.remaining_weight.toFixed(0)}g`
}
get total_weight_format() {
if (this.total_weight < 1000) {
return `${this.total_weight.toFixed(0)}g`
}
let totalRound = Math.round(this.total_weight / 1000)
if (totalRound !== this.total_weight / 1000) {
totalRound = Math.round(this.total_weight / 100) / 10
}
return `${totalRound}kg`
}
get last_used() {
const last_used = this.spool.last_used ?? null
if (!last_used) return this.$t('Panels.SpoolmanPanel.Never')
const date = new Date(this.spool.last_used)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff <= 1000 * 60 * 60 * 24) return this.$t('Panels.SpoolmanPanel.Today')
if (diff <= 1000 * 60 * 60 * 24 * 2) return this.$t('Panels.SpoolmanPanel.Yesterday')
if (diff <= 1000 * 60 * 60 * 24 * 14) {
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
return this.$t('Panels.SpoolmanPanel.DaysAgo', { days })
}
return date.toLocaleDateString()
}
setSpoolRow() {
this.$emit('set-spool', this.spool)
}
}
</script>

View File

@ -0,0 +1,55 @@
<template>
<v-dialog v-model="showDialog" width="400" persistent :fullscreen="isMobile">
<panel
:title="$t('Panels.SpoolmanPanel.EjectSpool')"
:icon="mdiEject"
card-class="spoolman-eject-spool-dialog"
:margin-bottom="false">
<template #buttons>
<v-btn icon tile @click="close">
<v-icon>{{ mdiCloseThick }}</v-icon>
</v-btn>
</template>
<v-card-text>
<v-row>
<v-col>
<p class="body-2">{{ $t('Panels.SpoolmanPanel.EjectSpoolQuestion') }}</p>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="close">{{ $t('Panels.SpoolmanPanel.Cancel') }}</v-btn>
<v-btn color="primary" text @click="removeSpool">
{{ $t('Panels.SpoolmanPanel.EjectSpool') }}
</v-btn>
</v-card-actions>
</panel>
</v-dialog>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import { Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import Panel from '@/components/ui/Panel.vue'
import { mdiCloseThick, mdiEject } from '@mdi/js'
@Component({
components: { Panel },
})
export default class SpoolmanEjectSpoolDialog extends Mixins(BaseMixin) {
mdiEject = mdiEject
mdiCloseThick = mdiCloseThick
@Prop({ required: true }) declare readonly showDialog: boolean
close() {
this.$emit('close')
}
removeSpool() {
this.$store.dispatch('server/spoolman/setActiveSpool', 0)
this.close()
}
}
</script>

View File

@ -1,23 +1,28 @@
<template>
<v-dialog v-model="bool" :max-width="dialogWidth">
<v-dialog v-model="bool" :max-width="dialogWidth" @click:outside="closeDialog" @keydown.esc="closeDialog">
<v-card>
<v-img v-if="file.big_thumbnail" contain :src="file.big_thumbnail"></v-img>
<v-card-title class="headline">{{ $t('Dialogs.StartPrint.Headline') }}</v-card-title>
<v-img v-if="file.big_thumbnail" contain :src="file.big_thumbnail" />
<v-card-title class="text-h5">
{{ $t('Dialogs.StartPrint.Headline') }}
</v-card-title>
<v-card-text class="pb-0">
{{ $t('Dialogs.StartPrint.DoYouWantToStartFilename', { filename: file.filename }) }}
<p class="body-2">
{{ question }}
</p>
</v-card-text>
<start-print-dialog-spoolman v-if="moonrakerComponents.includes('spoolman')" :file="file" />
<template v-if="moonrakerComponents.includes('timelapse')">
<v-divider class="mt-3 mb-2"></v-divider>
<v-card-text class="pb-0">
<v-divider v-if="!moonrakerComponents.includes('spoolman')" class="mt-3 mb-2" />
<v-card-text class="py-0">
<settings-row :title="$t('Dialogs.StartPrint.Timelapse')">
<v-switch v-model="timelapseEnabled" hide-details class="mt-0"></v-switch>
<v-switch v-model="timelapseEnabled" hide-details class="mt-0" />
</settings-row>
</v-card-text>
<v-divider class="mt-2 mb-0"></v-divider>
<v-divider class="mt-2 mb-0" />
</template>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="" text @click="closeDialog">{{ $t('Dialogs.StartPrint.Cancel') }}</v-btn>
<v-spacer />
<v-btn text @click="closeDialog">{{ $t('Dialogs.StartPrint.Cancel') }}</v-btn>
<v-btn
color="primary"
text
@ -35,6 +40,8 @@ import { Component, Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import { FileStateGcodefile } from '@/store/files/types'
import SettingsRow from '@/components/settings/SettingsRow.vue'
import { mdiPrinter3d } from '@mdi/js'
import { ServerSpoolmanStateSpool } from '@/store/server/spoolman/types'
@Component({
components: {
@ -42,6 +49,8 @@ import SettingsRow from '@/components/settings/SettingsRow.vue'
},
})
export default class StartPrintDialog extends Mixins(BaseMixin) {
mdiPrinter3d = mdiPrinter3d
@Prop({ required: true, default: false })
declare readonly bool: boolean
@ -67,6 +76,31 @@ export default class StartPrintDialog extends Mixins(BaseMixin) {
return this.file.big_thumbnail_width ?? 400
}
get active_spool(): ServerSpoolmanStateSpool | null {
return this.$store.state.server.spoolman.active_spool ?? null
}
get filamentVendor() {
return this.active_spool?.filament?.vendor?.name ?? 'Unknown'
}
get filamentName() {
return this.active_spool?.filament.name ?? 'Unknown'
}
get filament() {
return `${this.filamentVendor} - ${this.filamentName}`
}
get question() {
if (this.active_spool)
return this.$t('Dialogs.StartPrint.DoYouWantToStartFilenameFilament', {
filename: this.file?.filename ?? 'unknown',
})
return this.$t('Dialogs.StartPrint.DoYouWantToStartFilename', { filename: this.file?.filename ?? 'unknown' })
}
startPrint(filename = '') {
filename = (this.currentPath + '/' + filename).substring(1)
this.closeDialog()

View File

@ -0,0 +1,105 @@
<template>
<div>
<v-divider class="mt-3 mb-0" />
<v-card-text class="py-0 px-2">
<spoolman-panel-active-spool
v-if="activeSpoolId !== null"
:small="true"
class="my-0"
@change-spool="showChangeSpoolDialog = true" />
<v-alert v-for="alert in alerts" :key="alert.text" text :color="alert.color" class="mt-4 mx-3">
{{ alert.text }}
</v-alert>
<div class="text-center">
<v-btn color="primary" small class="mx-auto" @click="showChangeSpoolDialog = true">
{{ buttonText }}
</v-btn>
</div>
</v-card-text>
<v-divider :class="classSecondDivider" />
<spoolman-change-spool-dialog :show-dialog="showChangeSpoolDialog" @close="showChangeSpoolDialog = false" />
</div>
</template>
<script lang="ts">
import { Component, Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import SpoolmanPanelActiveSpool from '@/components/panels/Spoolman/SpoolmanPanelActiveSpool.vue'
import { FileStateGcodefile } from '@/store/files/types'
@Component({
components: { SpoolmanPanelActiveSpool },
})
export default class StartPrintDialogSpoolman extends Mixins(BaseMixin) {
@Prop({ required: true }) readonly file!: FileStateGcodefile
showChangeSpoolDialog = false
get activeSpoolId() {
let spoolId = this.$store.state.server.spoolman?.active_spool_id ?? null
if (spoolId === 0) spoolId = null
return spoolId
}
get activeSpool() {
return this.$store.state.server.spoolman?.active_spool ?? null
}
get classSecondDivider() {
const classes = ['mt-4']
classes.push(this.moonrakerComponents.includes('timelapse') ? 'mb-2' : 'mb-0')
return classes
}
get buttonText() {
if (this.activeSpoolId === null) return this.$t('Panels.SpoolmanPanel.SelectSpool') as string
return this.$t('Panels.SpoolmanPanel.ChangeSpool') as string
}
get alerts() {
let alerts = []
if (this.activeSpoolId === null) {
alerts.push({
text: this.$t('Panels.SpoolmanPanel.NoSpoolSelected'),
color: 'orange',
})
// No need to check for filament type mismatch if no spool is selected
return alerts
}
const gcodeFilamentType = this.file.filament_type ?? ''
if (
gcodeFilamentType !== '' &&
this.activeSpool?.filament?.material?.toLowerCase() !== gcodeFilamentType.toLowerCase()
) {
alerts.push({
text: this.$t('Panels.SpoolmanPanel.FilamentTypeMismatch', {
fileType: gcodeFilamentType,
spoolType: this.activeSpool?.filament?.material,
}),
color: 'warning',
})
}
const fileWeight = Math.round(this.file.filament_weight_total ?? 0)
const spoolWeight = Math.round(this.activeSpool?.remaining_weight ?? 0)
if (spoolWeight < fileWeight) {
alerts.push({
text: this.$t('Panels.SpoolmanPanel.TooLessFilament', {
fileWeight,
spoolWeight,
}),
color: 'warning',
})
}
return alerts
}
}
</script>

View File

@ -0,0 +1,129 @@
<template>
<v-list-item three-line>
<v-list-item-content :class="listItemContentClass">
<div :class="overlineClass">#{{ id }} | {{ vendor }}</div>
<v-list-item-title :class="listItemTitleClass">
<span class="cursor-pointer" @click="clickSpool">{{ name }}</span>
</v-list-item-title>
<v-list-item-subtitle>{{ subtitle }}</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-avatar tile :size="avatarSize">
<spool-icon :color="color" @click-spool="clickSpool" />
</v-list-item-avatar>
</v-list-item>
</template>
<script lang="ts">
import { Component, Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import Panel from '@/components/ui/Panel.vue'
import SpoolmanChangeSpoolDialog from '@/components/dialogs/SpoolmanChangeSpoolDialog.vue'
import SpoolmanEjectSpoolDialog from '@/components/dialogs/SpoolmanEjectSpoolDialog.vue'
import { ServerSpoolmanStateSpool } from '@/store/server/spoolman/types'
@Component({
components: { Panel, SpoolmanChangeSpoolDialog, SpoolmanEjectSpoolDialog },
})
export default class SpoolmanPanelActiveSpool extends Mixins(BaseMixin) {
@Prop({ required: false, default: false }) readonly small!: boolean
get listItemContentClass() {
if (this.small) return 'my-0'
return ''
}
get overlineClass() {
const classes = ['text-overline', 'mb-1']
if (this.small) classes.push('line-height-auto')
return classes
}
get listItemTitleClass() {
if (this.small) return ['text-h6', 'mb-1']
return ['text-h5', 'mb-1']
}
get avatarSize() {
if (this.small) return 60
return 80
}
get active_spool(): ServerSpoolmanStateSpool | null {
return this.$store.state.server.spoolman.active_spool ?? null
}
get color() {
const color = this.active_spool?.filament.color_hex ?? null
if (color === null) return '#000'
return `#${color}`
}
get id() {
return this.active_spool?.id ?? 'XX'
}
get vendor() {
return this.active_spool?.filament?.vendor?.name ?? 'Unknown'
}
get name() {
return this.active_spool?.filament.name ?? 'Unknown'
}
get materialOutput() {
const material = this.active_spool?.filament.material ?? null
if (material === null) return null
return material
}
get weightOutput() {
let remaining = this.active_spool?.remaining_weight ?? null
let total = this.active_spool?.filament.weight ?? null
let unit = 'g'
if (remaining === null || total === null) return null
remaining = Math.round(remaining)
let totalRound = Math.floor(total / 1000)
if (total >= 1000) {
if (totalRound !== total / 1000) {
totalRound = Math.round(total / 100) / 10
}
return `${remaining}g / ${totalRound}kg`
}
return `${remaining} / ${total}${unit}`
}
get lengthOutput() {
let remaining = this.active_spool?.remaining_length ?? null
if (remaining === null) return null
remaining = Math.round(remaining / 1000)
return `${remaining}m`
}
get subtitle() {
return [this.materialOutput, this.weightOutput, this.lengthOutput].filter((v) => v !== null).join(' | ')
}
clickSpool() {
this.$emit('change-spool')
}
}
</script>
<style scoped>
.line-height-auto {
line-height: 1;
}
</style>

View File

@ -0,0 +1,102 @@
<template>
<div>
<panel :icon="mdiAdjust" :title="title" card-class="spoolman-panel" :collapsible="true">
<template #buttons>
<v-btn icon tile :title="changeSpoolTooltip" @click="showChangeSpoolDialog = true">
<v-icon>{{ mdiSwapVertical }}</v-icon>
</v-btn>
<v-menu :offset-y="true" :close-on-content-click="false" left>
<template #activator="{ on, attrs }">
<v-btn icon tile v-bind="attrs" v-on="on">
<v-icon>{{ mdiDotsVertical }}</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item>
<v-btn small style="width: 100%" @click="showEjectSpoolDialog = true">
<v-icon left>{{ mdiEject }}</v-icon>
{{ $t('Panels.SpoolmanPanel.EjectSpool') }}
</v-btn>
</v-list-item>
<v-list-item>
<v-btn small style="width: 100%" @click="openSpoolManager">
<v-icon left>{{ mdiOpenInNew }}</v-icon>
{{ $t('Panels.SpoolmanPanel.OpenSpoolManager') }}
</v-btn>
</v-list-item>
</v-list>
</v-menu>
</template>
<v-card-text v-if="active_spool === null">
<v-row>
<v-col class="text-center">
<p class="text--disabled">{{ $t('Panels.SpoolmanPanel.NoActiveSpool') }}</p>
<v-btn small color="primary" @click="showChangeSpoolDialog = true">
{{ $t('Panels.SpoolmanPanel.SelectSpool') }}
</v-btn>
</v-col>
</v-row>
</v-card-text>
<spoolman-panel-active-spool v-else @change-spool="showChangeSpoolDialog = true" />
</panel>
<spoolman-change-spool-dialog :show-dialog="showChangeSpoolDialog" @close="showChangeSpoolDialog = false" />
<spoolman-eject-spool-dialog :show-dialog="showEjectSpoolDialog" @close="showEjectSpoolDialog = false" />
</div>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import Panel from '@/components/ui/Panel.vue'
import { mdiAdjust, mdiDotsVertical, mdiEject, mdiOpenInNew, mdiSwapVertical } from '@mdi/js'
import SpoolmanChangeSpoolDialog from '@/components/dialogs/SpoolmanChangeSpoolDialog.vue'
import SpoolmanEjectSpoolDialog from '@/components/dialogs/SpoolmanEjectSpoolDialog.vue'
import { ServerSpoolmanStateSpool } from '@/store/server/spoolman/types'
import SpoolmanPanelActiveSpool from '@/components/panels/Spoolman/SpoolmanPanelActiveSpool.vue'
@Component({
components: { SpoolmanPanelActiveSpool, Panel, SpoolmanChangeSpoolDialog, SpoolmanEjectSpoolDialog },
})
export default class SpoolmanPanel extends Mixins(BaseMixin) {
mdiAdjust = mdiAdjust
mdiDotsVertical = mdiDotsVertical
mdiEject = mdiEject
mdiOpenInNew = mdiOpenInNew
mdiSwapVertical = mdiSwapVertical
showChangeSpoolDialog = false
showEjectSpoolDialog = false
get health() {
return this.$store.state.server.spoolman.health ?? ''
}
get title() {
const headline = this.$t('Panels.SpoolmanPanel.Headline') as string
if (this.health === '' || this.health === 'healthy') return headline
return `${headline} (${this.health})`
}
get changeSpoolTooltip(): string {
if (this.active_spool === null) return this.$t('Panels.SpoolmanPanel.SelectSpool') as string
return this.$t('Panels.SpoolmanPanel.ChangeSpool') as string
}
get active_spool(): ServerSpoolmanStateSpool | null {
return this.$store.state.server.spoolman.active_spool ?? null
}
get spoolManagerUrl() {
return this.$store.state.server.config.config?.spoolman?.server ?? null
}
openSpoolManager() {
window.open(this.spoolManagerUrl, '_blank')
}
}
</script>
<style scoped></style>

View File

@ -0,0 +1,48 @@
<template>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 487.04 487.04"
xml:space="preserve"
class="cursor-pointer"
@click="clickSpool">
<g>
<circle :style="styleCircle1" cx="243.52" cy="243.52" r="232.97" />
<circle :style="styleCircle2" cx="243.52" cy="243.52" r="112.5" />
<path
:style="styleCircle3"
d="M0,243.52c0,134.42,109.1,243.52,243.52,243.52,134.42,0,243.52-109.1,243.52-243.52S377.95,0,243.52,0C109.1,0,0,109.1,0,243.52Zm115.73,181.78c-52.4-39.5-86.52-98.59-94.52-163.72v-.09c-.68-5.43,1-10.89,4.6-15,3.6-4.12,8.79-6.51,14.26-6.57l118.36-1.33c18.99-.21,36.63,9.83,46.12,26.29,9.5,16.45,9.38,36.74-.3,53.09l-60.29,101.76c-2.8,4.73-7.48,8.03-12.87,9.1-5.39,1.06-10.98-.22-15.36-3.52ZM450.22,238.8c5.49,.06,10.7,2.46,14.31,6.59,3.62,4.13,5.3,9.61,4.63,15.06-8.01,65.13-42.12,124.22-94.52,163.72l-.07,.05c-4.37,3.29-9.93,4.57-15.3,3.51-5.37-1.06-10.03-4.36-12.82-9.06l-60.33-101.84c-9.68-16.34-9.8-36.64-.3-53.09,9.5-16.45,27.13-26.5,46.12-26.29l118.27,1.33ZM338.12,40.02c5.04,2.14,8.92,6.32,10.69,11.49,1.77,5.18,1.24,10.86-1.44,15.63l-58.03,103.17c-9.31,16.56-26.83,26.8-45.83,26.8-19,0-36.51-10.25-45.83-26.8l-57.99-103.09c-2.69-4.79-3.22-10.49-1.45-15.69,1.77-5.2,5.68-9.4,10.73-11.54,60.41-25.63,128.64-25.63,189.05,0l.08,.04Z" />
</g>
</svg>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import { Mixins, Prop } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
@Component({})
export default class SpoolIcon extends Mixins(BaseMixin) {
@Prop({ required: false, default: '#ff0' })
declare readonly color: string
get styleCircle1() {
return { fill: this.color }
}
get styleCircle2() {
return { fill: '#bebebe' }
}
get styleCircle3() {
return { fill: '#343434' }
}
clickSpool() {
this.$emit('click-spool')
}
}
</script>

View File

@ -149,6 +149,7 @@
"StartPrint": {
"Cancel": "Cancel",
"DoYouWantToStartFilename": "Do you want to start {filename}?",
"DoYouWantToStartFilenameFilament": "Do you want to start {filename} with the following filament?",
"Headline": "Start Job",
"Print": "print",
"Timelapse": "Timelapse"
@ -622,6 +623,33 @@
"On": "On",
"PowerControl": "Power Control"
},
"SpoolmanPanel": {
"Cancel": "Cancel",
"ChangeSpool": "Change Spool",
"DaysAgo": "{days} days ago",
"EjectSpool": "Eject spool",
"EjectSpoolQuestion": "Are you sure to eject the filament spool?",
"Filament": "Filament",
"FilamentTypeMismatch": "The material of the active spool ({spoolType}) does not match the material of the G-Code ({fileType}).",
"FirstUsedOutput": "First used: {firstUsed}",
"Headline": "Spoolman",
"LastUsed": "Last Used",
"Location": "Location",
"Material": "Material",
"Never": "Never",
"NoActiveSpool": "Filament tracking is inactive. To get started, please select a spool.",
"NoResults": "No spool found with the current search criteria.",
"NoSpools": "No spools available",
"NoSpoolSelected": "No spool selected. Please select a spool or this print will not be tracked.",
"OpenSpoolManager": "open Spool Manager",
"Refresh": "refresh",
"Search": "Search",
"SelectSpool": "Select Spool",
"Today": "Today",
"TooLessFilament": "The current spool may not have enough filament for this print. ({spoolWeight}g of {fileWeight}g)",
"Weight": "Weight",
"Yesterday": "Yesterday"
},
"StatusPanel": {
"CancelPrint": "Cancel print",
"ClearPrintStats": "Clear print stats",

View File

@ -91,6 +91,7 @@ import MacrosPanel from '@/components/panels/MacrosPanel.vue'
import MiniconsolePanel from '@/components/panels/MiniconsolePanel.vue'
import MinSettingsPanel from '@/components/panels/MinSettingsPanel.vue'
import MiscellaneousPanel from '@/components/panels/MiscellaneousPanel.vue'
import SpoolmanPanel from '@/components/panels/SpoolmanPanel.vue'
import StatusPanel from '@/components/panels/StatusPanel.vue'
import ToolheadControlPanel from '@/components/panels/ToolheadControlPanel.vue'
import TemperaturePanel from '@/components/panels/TemperaturePanel.vue'
@ -106,6 +107,7 @@ import WebcamPanel from '@/components/panels/WebcamPanel.vue'
MiniconsolePanel,
MinSettingsPanel,
MiscellaneousPanel,
SpoolmanPanel,
StatusPanel,
ToolheadControlPanel,
TemperaturePanel,

View File

@ -72,6 +72,11 @@ export const getters: GetterTree<GuiState, any> = {
allPanels = allPanels.filter((name) => name !== 'webcam')
}
// remove spoolman panel, if no spoolman component exists in moonraker
if (!rootState.server.components.includes('spoolman')) {
allPanels = allPanels.filter((name) => name !== 'spoolman')
}
return allPanels
},

View File

@ -11,6 +11,7 @@ import { history } from '@/store/server/history'
import { timelapse } from '@/store/server/timelapse'
import { jobQueue } from '@/store/server/jobQueue'
import { announcements } from '@/store/server/announcements'
import { spoolman } from '@/store/server/spoolman'
// create getDefaultState
export const getDefaultState = (): ServerState => {
@ -59,5 +60,6 @@ export const server: Module<ServerState, any> = {
timelapse,
jobQueue,
announcements,
spoolman,
},
}

View File

@ -0,0 +1,132 @@
import Vue from 'vue'
import { ActionTree } from 'vuex'
import { RootState } from '@/store/types'
import { ServerSpoolmanState } from '@/store/server/spoolman/types'
export const actions: ActionTree<ServerSpoolmanState, RootState> = {
reset({ commit }) {
commit('reset')
},
init({ dispatch }) {
Vue.$socket.emit('server.spoolman.get_spool_id', {}, { action: 'server/spoolman/getActiveSpoolId' })
Vue.$socket.emit(
'server.spoolman.proxy',
{
request_method: 'GET',
path: '/v1/info',
},
{ action: 'server/spoolman/getInfo' }
)
Vue.$socket.emit(
'server.spoolman.proxy',
{
request_method: 'GET',
path: '/v1/health',
},
{ action: 'server/spoolman/getHealth' }
)
Vue.$socket.emit(
'server.spoolman.proxy',
{
request_method: 'GET',
path: '/v1/vendor',
},
{ action: 'server/spoolman/getVendors' }
)
dispatch('socket/addInitModule', 'server/spoolman/getActiveSpoolId', { root: true })
dispatch('socket/addInitModule', 'server/spoolman/getHealth', { root: true })
dispatch('socket/addInitModule', 'server/spoolman/getInfo', { root: true })
dispatch('socket/addInitModule', 'server/spoolman/getVendors', { root: true })
dispatch('socket/removeInitModule', 'server/spoolman/init', { root: true })
},
getActiveSpoolId({ commit, dispatch }, payload) {
commit('setActiveSpoolId', payload.spool_id)
dispatch('socket/removeInitModule', 'server/spoolman/getActiveSpoolId', { root: true })
// also set active spool to null, if spool_id is 0
if (payload.spool_id === 0) {
commit('setActiveSpool', null)
return
}
Vue.$socket.emit(
'server.spoolman.proxy',
{
request_method: 'GET',
path: `/v1/spool/${payload.spool_id}`,
},
{ action: 'server/spoolman/getActiveSpool' }
)
},
getActiveSpool({ commit }, payload) {
if ('requestParams' in payload) delete payload.requestParams
commit('setActiveSpool', payload)
},
getHealth({ commit, dispatch }, payload) {
delete payload.requestParams
commit('setHealth', payload.status)
dispatch('socket/removeInitModule', 'server/spoolman/getHealth', { root: true })
},
getInfo({ commit, dispatch }, payload) {
delete payload.requestParams
commit('setInfo', payload)
dispatch('socket/removeInitModule', 'server/spoolman/getInfo', { root: true })
},
getVendors({ commit, dispatch }, payload) {
delete payload.requestParams
commit(
'setVendors',
Object.entries(payload).map((value) => value)
)
dispatch('socket/removeInitModule', 'server/spoolman/getVendors', { root: true })
},
refreshSpools({ dispatch }) {
Vue.$socket.emit(
'server.spoolman.proxy',
{
request_method: 'GET',
path: '/v1/spool',
},
{ action: 'server/spoolman/getSpools' }
)
dispatch('socket/addLoading', 'refreshSpools', { root: true })
},
getSpools({ commit, dispatch }, payload) {
if ('requestParams' in payload) delete payload.requestParams
const spools = Object.entries(payload).map((value) => value[1])
commit('setSpools', spools)
dispatch('socket/removeLoading', 'refreshSpools', { root: true })
},
setActiveSpool(_, id: number | null) {
Vue.$socket.emit('server.spoolman.post_spool_id', {
spool_id: id,
})
},
refreshActiveSpool({ state }) {
if (state.active_spool_id === null) return
Vue.$socket.emit(
'server.spoolman.proxy',
{
request_method: 'GET',
path: `/v1/spool/${state.active_spool_id}`,
},
{ action: 'server/spoolman/getActiveSpool' }
)
},
}

View File

@ -0,0 +1,5 @@
import { GetterTree } from 'vuex'
import { ServerSpoolmanState } from './types'
// eslint-disable-next-line
export const getters: GetterTree<ServerSpoolmanState, any> = {}

View File

@ -0,0 +1,34 @@
import { Module } from 'vuex'
import { ServerSpoolmanState } from '@/store/server/spoolman/types'
import { actions } from '@/store/server/spoolman/actions'
import { mutations } from '@/store/server/spoolman/mutations'
import { getters } from '@/store/server/spoolman/getters'
export const getDefaultState = (): ServerSpoolmanState => {
return {
health: '',
info: {
automatic_backups: false,
backups_dir: '',
data_dir: '',
debug_mode: false,
version: '',
},
active_spool_id: null,
active_spool: null,
vendors: [],
feeds: [],
}
}
// initial state
const state = getDefaultState()
// eslint-disable-next-line
export const spoolman: Module<ServerSpoolmanState, any> = {
namespaced: true,
state,
getters,
actions,
mutations,
}

View File

@ -0,0 +1,34 @@
import { getDefaultState } from './index'
import { MutationTree } from 'vuex'
import { ServerSpoolmanState } from './types'
import Vue from 'vue'
export const mutations: MutationTree<ServerSpoolmanState> = {
reset(state) {
Object.assign(state, getDefaultState())
},
setActiveSpoolId(state, payload) {
Vue.set(state, 'active_spool_id', payload)
},
setActiveSpool(state, payload) {
Vue.set(state, 'active_spool', payload)
},
setHealth(state, payload) {
Vue.set(state, 'health', payload)
},
setInfo(state, payload) {
Vue.set(state, 'info', payload)
},
setVendors(state, payload) {
Vue.set(state, 'vendors', payload)
},
setSpools(state, payload) {
Vue.set(state, 'spools', payload)
},
}

View File

@ -0,0 +1,52 @@
export interface ServerSpoolmanState {
health: string
info: {
automatic_backups: boolean
backups_dir: string
data_dir: string
debug_mode: boolean
version: string
}
active_spool_id: number | null
active_spool: ServerSpoolmanStateSpool | null
vendors: ServerSpoolmanStateVendor[]
feeds: string[]
}
export interface ServerSpoolmanStateVendor {
id: number
registered: string
name: string
}
export interface ServerSpoolmanStateFilament {
id: number
registered: string
name: string
comment?: string
color_hex: string
density: number
diameter: number
material: string
price: number
settings_bed_temp: number
settings_extruder_temp: number
spool_weight: number
weight: number
vendor: ServerSpoolmanStateVendor
}
export interface ServerSpoolmanStateSpool {
id: number
registered: string
archived: boolean
filament: ServerSpoolmanStateFilament
first_used: string
last_used: string
remaining_length: number
remaining_weight: number
used_length: number
used_weight: number
location?: string
comment?: string
}

View File

@ -128,6 +128,10 @@ export const actions: ActionTree<SocketState, RootState> = {
dispatch('gui/webcams/initStore', payload.params[0], { root: true })
break
case 'notify_active_spool_set':
dispatch('server/spoolman/getActiveSpoolId', payload.params[0], { root: true })
break
default:
window.console.debug(payload)
}

View File

@ -25,7 +25,15 @@ export const validGcodeExtensions = ['.gcode', '.g', '.gco', '.ufp', '.nc']
/*
* List of initable server components
*/
export const initableServerComponents = ['history', 'power', 'updateManager', 'timelapse', 'jobQueue', 'announcements']
export const initableServerComponents = [
'history',
'power',
'updateManager',
'timelapse',
'jobQueue',
'announcements',
'spoolman',
]
/*
* List of required klipper config modules
@ -78,6 +86,7 @@ export const allDashboardPanels = [
'machine-settings',
'miniconsole',
'miscellaneous',
'spoolman',
'temperature',
'webcam',
]