Skip to content

Commit 5b6a44e

Browse files
matmenpedrolamas
andauthored
feat(spoolman): QR code scanning support (#1149)
Signed-off-by: Mathis Mensing <github@matmen.dev> Co-authored-by: Pedro Lamas <pedrolamas@gmail.com>
1 parent 6e57724 commit 5b6a44e

File tree

11 files changed

+150
-18
lines changed

11 files changed

+150
-18
lines changed

docs/features/spoolman.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ Fluidd offers support for the [Spoolman](https://github.com/Donkie/Spoolman) fil
2222
### Print start
2323
On print start, Fluidd will show a modal asking you to select the spool you want to use for printing.
2424
The modal shows all available (i.e. not archived) spools.
25-
<!-- TODO uncomment when QR scanning is available
2625
A spool can either be selected by selecting it directly, or by scanning an associated QR code using an attached webcam.
2726

2827
![screenshot](/assets/images/spoolman-scan-spool.png)
29-
-->
3028

3129
Automatically opening the spool selection modal can be disabled from the Fluidd settings.
3230

src/components/settings/SpoolmanSettings.vue

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
/>
1919
</app-setting>
2020

21-
<!-- TODO uncomment when QR scanning is available
2221
<v-divider />
2322
<app-setting
2423
:title="$tc('app.spoolman.setting.auto_open_qr_camera')"
@@ -32,7 +31,28 @@
3231
:items="supportedCameras"
3332
/>
3433
</app-setting>
35-
-->
34+
35+
<v-divider />
36+
<app-setting
37+
:title="$t('app.spoolman.setting.prefer_device_camera')"
38+
>
39+
<v-switch
40+
v-model="preferDeviceCamera"
41+
hide-details
42+
class="mt-0 mb-4"
43+
/>
44+
</app-setting>
45+
46+
<v-divider />
47+
<app-setting
48+
:title="$t('app.spoolman.setting.auto_select_spool_on_match')"
49+
>
50+
<v-switch
51+
v-model="autoSelectSpoolOnMatch"
52+
hide-details
53+
class="mt-0 mb-4"
54+
/>
55+
</app-setting>
3656

3757
<v-divider />
3858
<app-setting :title="$t('app.setting.label.reset')">
@@ -91,6 +111,30 @@ export default class SpoolmanSettings extends Mixins(StateMixin) {
91111
})
92112
}
93113
114+
get preferDeviceCamera () {
115+
return this.$store.state.config.uiSettings.spoolman.preferDeviceCamera
116+
}
117+
118+
set preferDeviceCamera (value: boolean) {
119+
this.$store.dispatch('config/saveByPath', {
120+
path: 'uiSettings.spoolman.preferDeviceCamera',
121+
value,
122+
server: true
123+
})
124+
}
125+
126+
get autoSelectSpoolOnMatch () {
127+
return this.$store.state.config.uiSettings.spoolman.autoSelectSpoolOnMatch
128+
}
129+
130+
set autoSelectSpoolOnMatch (value: boolean) {
131+
this.$store.dispatch('config/saveByPath', {
132+
path: 'uiSettings.spoolman.autoSelectSpoolOnMatch',
133+
value,
134+
server: true
135+
})
136+
}
137+
94138
handleReset () {
95139
this.$store.dispatch('config/saveByPath', {
96140
path: 'uiSettings.spoolman',

src/components/widgets/camera/CameraItem.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
class="camera-image"
1414
@raw-camera-url="rawCameraUrl = $event"
1515
@frames-per-second="framesPerSecond = $event"
16+
@playback="setupFrameEvents()"
1617
/>
1718
</template>
1819
<div v-else>
@@ -33,7 +34,7 @@
3334
</div>
3435

3536
<div
36-
v-if="!fullscreen && (fullscreenMode === 'embed' || !rawCameraUrl)"
37+
v-if="!fullscreen && (fullscreenMode === 'embed' || !rawCameraUrl) && camera.service !== 'device'"
3738
class="camera-fullscreen"
3839
>
3940
<a :href="`/#/camera/${camera.id}`">
@@ -79,7 +80,11 @@ export default class CameraItem extends Vue {
7980
framesPerSecond : string | null = null
8081
8182
mounted () {
82-
if (this.$listeners?.frame) {
83+
this.setupFrameEvents()
84+
}
85+
86+
setupFrameEvents () {
87+
if (this.$listeners?.frame && this.componentInstance) {
8388
if (this.componentInstance.streamingElement instanceof HTMLImageElement) {
8489
this.componentInstance.streamingElement.addEventListener('load', () => this.handleFrame())
8590
} else if (this.componentInstance.streamingElement instanceof HTMLVideoElement) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<template>
2+
<video
3+
ref="streamingElement"
4+
autoplay
5+
muted
6+
:style="cameraStyle"
7+
/>
8+
</template>
9+
10+
<script lang="ts">
11+
import { Component, Ref, Mixins } from 'vue-property-decorator'
12+
import CameraMixin from '@/mixins/camera'
13+
14+
@Component({})
15+
export default class DeviceCamera extends Mixins(CameraMixin) {
16+
@Ref('streamingElement')
17+
readonly cameraVideo!: HTMLVideoElement
18+
19+
startPlayback () {
20+
navigator.mediaDevices.getUserMedia({ video: true })
21+
.then(stream => (this.cameraVideo.srcObject = stream))
22+
.then(() => this.$emit('playback'))
23+
}
24+
25+
stopPlayback () {
26+
this.cameraVideo.srcObject = null
27+
}
28+
}
29+
</script>

src/components/widgets/spoolman/QRReader.vue

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<CameraItem
1717
:camera="camera"
1818
:embedded="true"
19+
crossorigin="anonymous"
1920
@frame="handlePrinterCameraFrame"
2021
/>
2122
</v-card-text>
@@ -34,12 +35,14 @@ import BrowserMixin from '@/mixins/browser'
3435
components: { CameraItem }
3536
})
3637
export default class QRReader extends Mixins(StateMixin, BrowserMixin) {
37-
dataPatterns = [/\/spool\/show\/(\d+)\/?/]
38+
dataPatterns = [
39+
/web\+spoolman:s-(\d+)/,
40+
/\/spool\/show\/(\d+)\/?/
41+
]
42+
3843
statusMessage = 'info.howto'
3944
lastScanTimestamp = Date.now()
4045
processing = false
41-
42-
video!: HTMLVideoElement | HTMLImageElement
4346
context!: CanvasRenderingContext2D
4447
4548
@VModel({ type: String, default: null })
@@ -49,6 +52,9 @@ export default class QRReader extends Mixins(StateMixin, BrowserMixin) {
4952
canvas!: HTMLCanvasElement
5053
5154
get camera () {
55+
if (this.source === 'device') {
56+
return { name: this.$t('app.spoolman.label.device_camera'), service: 'device' }
57+
}
5258
return this.$store.getters['cameras/getCameraById'](this.source)
5359
}
5460
@@ -90,13 +96,23 @@ export default class QRReader extends Mixins(StateMixin, BrowserMixin) {
9096
this.canvas.height = image.naturalHeight
9197
}
9298
99+
if (!this.canvas.width || !this.canvas.height) {
100+
// no image drawn yet
101+
this.processing = false
102+
return
103+
}
104+
93105
try {
94106
this.context.drawImage(image, 0, 0, this.canvas.width, this.canvas.height)
95107
const result = await QrScanner.scanImage(this.canvas, { returnDetailedScanResult: true })
96108
if (result.data) { this.handleCodeFound(result.data) }
97109
} catch (err) {
98110
if (err instanceof DOMException) {
99-
this.statusMessage = 'error.no_image_data'
111+
if (err.name === 'SecurityError') {
112+
this.statusMessage = 'error.cors'
113+
} else {
114+
this.statusMessage = 'error.no_image_data'
115+
}
100116
}
101117
102118
// no QR code found

src/components/widgets/spoolman/SpoolSelectionDialog.vue

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
<v-spacer />
2020

21-
<!-- TODO uncomment when QR scanning is available
2221
<v-menu
2322
v-if="cameras.length > 1"
2423
v-model="cameraSelectionMenuOpen"
@@ -74,7 +73,6 @@
7473
{{ $t('app.spoolman.btn.scan_code') }}
7574
</template>
7675
</app-btn>
77-
-->
7876

7977
<v-text-field
8078
v-model="search"
@@ -130,6 +128,7 @@
130128
</div>
131129
</div>
132130
</td>
131+
<td>{{ item.id }}</td>
133132
<td>{{ item.filament.material }}</td>
134133
<td>{{ item.location }}</td>
135134
<td>{{ item.comment }}</td>
@@ -196,6 +195,7 @@ import { Spool } from '@/store/spoolman/types'
196195
import BrowserMixin from '@/mixins/browser'
197196
import QRReader from '@/components/widgets/spoolman/QRReader.vue'
198197
import { CameraConfig } from '@/store/cameras/types'
198+
import QrScanner from 'qr-scanner'
199199
200200
@Component({
201201
components: { QRReader }
@@ -207,6 +207,12 @@ export default class SpoolSelectionDialog extends Mixins(StateMixin, BrowserMixi
207207
cameraScanSource: null | string = null
208208
cameraSelectionMenuOpen = false
209209
210+
hasDeviceCamera = false
211+
212+
async mounted () {
213+
this.hasDeviceCamera = await QrScanner.hasCamera()
214+
}
215+
210216
@Watch('open')
211217
onOpen () {
212218
if (this.open) {
@@ -217,9 +223,13 @@ export default class SpoolSelectionDialog extends Mixins(StateMixin, BrowserMixi
217223
SocketActions.serverFilesMetadata(this.currentFileName)
218224
}
219225
220-
const autoOpenCameraId = this.$store.state.config.uiSettings.spoolman.autoOpenQRDetectionCamera
221-
if (this.$store.getters['cameras/getCameraById'](autoOpenCameraId)) {
222-
this.$nextTick(() => (this.cameraScanSource = autoOpenCameraId))
226+
if (this.hasDeviceCamera && this.$store.state.config.uiSettings.spoolman.preferDeviceCamera) {
227+
this.$nextTick(() => (this.cameraScanSource = 'device'))
228+
} else {
229+
const autoOpenCameraId = this.$store.state.config.uiSettings.spoolman.autoOpenQRDetectionCamera
230+
if (this.$store.getters['cameras/getCameraById'](autoOpenCameraId)) {
231+
this.$nextTick(() => (this.cameraScanSource = autoOpenCameraId))
232+
}
223233
}
224234
}
225235
}
@@ -260,6 +270,7 @@ export default class SpoolSelectionDialog extends Mixins(StateMixin, BrowserMixi
260270
get headers () {
261271
return [
262272
'filament_name',
273+
'id',
263274
'material',
264275
'location',
265276
'comment',
@@ -302,8 +313,15 @@ export default class SpoolSelectionDialog extends Mixins(StateMixin, BrowserMixi
302313
}
303314
304315
get cameras () {
305-
return this.$store.getters['cameras/getEnabledCameras']
316+
const cameras = this.$store.getters['cameras/getEnabledCameras']
306317
.filter((camera: CameraConfig) => camera.service !== 'iframe')
318+
319+
if (this.hasDeviceCamera) {
320+
// always show device camera first
321+
cameras.unshift({ name: this.$t('app.spoolman.label.device_camera'), id: 'device' })
322+
}
323+
324+
return cameras
307325
}
308326
309327
get scanSource () {
@@ -325,6 +343,10 @@ export default class SpoolSelectionDialog extends Mixins(StateMixin, BrowserMixi
325343
// clear filter if selected spool isn't in filter results
326344
this.search = ''
327345
}
346+
347+
if (this.$store.state.config.uiSettings.spoolman.autoSelectSpoolOnMatch) {
348+
this.handleSelectSpool()
349+
}
328350
}
329351
330352
async handleSelectSpool () {

src/locales/de.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@ app:
706706
label:
707707
change_spool: Suple wechseln
708708
comment: Kommentar
709+
device_camera: Gerät
709710
filament_name: Filament
710711
first_used: Zuerst genutzt
711712
last_used: Zuletzt genutzt
@@ -739,11 +740,17 @@ app:
739740
code_not_recognized: Dieser Code sieht nicht nach einem kompatiblen QR-Code aus.
740741
invalid_spool_id: Die Spulen-ID in diesem QR-Code ist ungültig.
741742
error:
743+
cors: >-
744+
Es ist ein Fehler beim Abrufen des Kamerabilds aufgetreten.
745+
Bitte stellen Sie sicher, dass Ihr Kameraserver CORS-Zugriff erlaubt.
746+
Please make sure your webcam server allows CORS access.
742747
spool_not_existant: Die von Ihnen gescannte Spule existiert nicht in der Datenbank.
743748
no_image_data: >-
744749
Es ist ein Fehler beim Abrufen des Kamerabilds aufgetreten.
745750
Bitte überprüfen Sie Ihre Kameraeinstellungen oder versuchen Sie,
746751
eine andere Kameraquelle zu verwenden.
747752
setting:
748753
auto_open_qr_camera: Kamera automatisch zur QR-Code- Erkennung öffnen
754+
auto_select_spool_on_match: Spulenauswahl bei QR-Code- Übereinstimmung automatisch übernehmen
755+
prefer_device_camera: Gerätekamera wenn verfügbar zur QR-Code- Erkennung verwenden
749756
show_spool_selection_dialog_on_print_start: Spulenauswahl-Dialog automatisch bei Druckstart anzeigen

src/locales/en.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,8 +769,10 @@ app:
769769
label:
770770
change_spool: Change Spool
771771
comment: Comment
772+
device_camera: Device
772773
filament_name: Filament
773774
first_used: First Used
775+
id: ID
774776
last_used: Last Used
775777
location: Location
776778
lot_nr: Lot Nr
@@ -802,10 +804,15 @@ app:
802804
code_not_recognized: This code doesn't look like a compatible QR code.
803805
invalid_spool_id: The spool ID contained in this QR code is invalid.
804806
error:
807+
cors: >-
808+
There was an error accessing the cameras feed.
809+
Please make sure your webcam server allows CORS access.
805810
spool_not_existant: The spool you scanned doesn't exist in the database.
806811
no_image_data: >-
807812
There was an error accessing the cameras feed.
808813
Please check your camera configuration or try another camera source.
809814
setting:
810815
auto_open_qr_camera: Automatically open camera for QR code detection
816+
auto_select_spool_on_match: Automatically commit spool selection on QR code match
817+
prefer_device_camera: Use device camera for QR code detection if available
811818
show_spool_selection_dialog_on_print_start: Show spool selection dialog on print start

src/store/cameras/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export interface CameraConfig extends CameraConfigWithoutId {
3333
id: string;
3434
}
3535

36-
export type CameraService = 'mjpegstreamer' | 'mjpegstreamer-adaptive' | 'ipstream' | 'iframe' | 'hlsstream' | 'webrtc-camerastreamer'
36+
export type CameraService = 'mjpegstreamer' | 'mjpegstreamer-adaptive' | 'ipstream' | 'iframe' | 'hlsstream' | 'webrtc-camerastreamer' | 'device'
3737

3838
export interface LegacyCamerasState {
3939
activeCamera: string;

src/store/config/state.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ export const defaultState = (): ConfigState => {
143143
},
144144
spoolman: {
145145
autoSpoolSelectionDialog: true,
146-
autoOpenQRDetectionCamera: null
146+
autoOpenQRDetectionCamera: null,
147+
autoSelectSpoolOnMatch: false,
148+
preferDeviceCamera: false
147149
}
148150
}
149151
}

0 commit comments

Comments
 (0)