From ebfac076a9f5d5b37e076f425fa96b3deaac9517 Mon Sep 17 00:00:00 2001 From: Robert Ramsey Date: Sat, 25 Jul 2026 20:12:21 -0400 Subject: [PATCH] =?UTF-8?q?release:=20Helix=201.2.8=20=E2=80=94=20re-slice?= =?UTF-8?q?=20on=20print,=20stale-screen=20fix,=20multi-color,=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prepare&Slice: pick any loaded material in the print dialog; Helix re-slices into it before sending (single-material swap, multi-color collapse, and per-color remap). Re-slice preserves supports/brim/infill. - Multi-color: per-object extruder honored via model_settings.config; preview + slice colors now correct. - Stale-screen: idempotent Moonraker config writes (app + relay) so Helix no longer restarts Moonraker on every launch/FCM registration. - PlateExtractor: streaming rewrite (fixes OOM on huge 3MFs) + external part-file mesh inlining + multicolor extruder config generation. - i18n: full coverage for es/de/fr/zh/pl (0 missing). --- android/app/build.gradle | 4 +- .../slicing/HelixModelPreviewActivity.kt | 2 +- .../u1control/slicing/HelixSliceRunner.kt | 19 +- .../u1control/slicing/HelixSliceSettings.kt | 46 + .../u1control/slicing/HelixSlicerModule.kt | 103 +- .../u1control/slicing/LastSliceStore.kt | 21 +- .../u1control/slicing/PlateExtractor.kt | 205 +- app.json | 4 +- app/(tabs)/settings.tsx | 8 +- app/(tabs)/slicer.tsx | 106 +- app/(tabs)/spoolman.tsx | 7 +- components/PrintPreprocessDialog.tsx | 13 +- functions/src/index.ts | 31 +- package.json | 2 +- services/i18n.ts | 1766 ++++++++++++++--- services/moonraker.ts | 24 + services/nativeSlicer.ts | 43 + services/notifications.ts | 26 +- 18 files changed, 2075 insertions(+), 355 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 89ce4d9..18e609b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { applicationId 'org.crabcore.u1control' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 17 - versionName "1.2.7" + versionCode 18 + versionName "1.2.8" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixModelPreviewActivity.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixModelPreviewActivity.kt index 4132f34..2e7584c 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixModelPreviewActivity.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixModelPreviewActivity.kt @@ -1342,7 +1342,7 @@ class HelixModelPreviewActivity : Activity() { }, initialTool = initialTool, sliceSettings = sliceSettings, - materialProfiles = HelixSliceRunner.parseMaterialProfiles(materialProfilesJson), + materialProfilesJson = materialProfilesJson, ) val result = outcome.result runOnUiThread { diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSliceRunner.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSliceRunner.kt index e1b4fb1..88bf52c 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSliceRunner.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSliceRunner.kt @@ -71,7 +71,8 @@ object HelixSliceRunner { onProgress: (Int, String) -> Unit, initialTool: Int = 0, sliceSettings: HelixSliceSettings = HelixSliceSettings(), - materialProfiles: List = emptyList(), + materialProfilesJson: String? = null, + forceExtruderCount: Int? = null, configure: SliceConfig.() -> Unit = {}, ): Outcome { if (!slicing.compareAndSet(false, true)) throw BusyError() @@ -100,12 +101,16 @@ object HelixSliceRunner { // Multicolor models carry more than one filament in their project config. // For those we keep the engine's multi-tool output; only single-colour - // prints get pinned to the user's chosen loaded head. + // prints get pinned to the user's chosen loaded head. forceExtruderCount + // (per-colour remap) lifts the tool count to cover every mapped slot even + // when the file declares fewer filaments than the user routed to. val filamentCount = readFilamentCount(lib) - val multicolor = filamentCount > 1 + val effectiveCount = maxOf(filamentCount, forceExtruderCount ?: 1).coerceIn(1, 4) + val multicolor = effectiveCount > 1 val (startGcode, endGcode) = readMachineGcode(context) val selectedTool = initialTool.coerceIn(0, 3) + val materialProfiles = parseMaterialProfiles(materialProfilesJson) val config = SliceConfig( machineStartGcode = startGcode, machineEndGcode = endGcode, @@ -113,7 +118,7 @@ object HelixSliceRunner { sliceSettings.applyTo(this) configure() applyMaterialProfiles(materialProfiles, selectedTool) - if (multicolor) configureMultiTool(filamentCount) else forceSingleTool(selectedTool) + if (multicolor) configureMultiTool(effectiveCount) else forceSingleTool(selectedTool) // Wipe tower position shown/dragged on the prepare screen. PrepareSession.towerPositionFor(path)?.let { (tx, ty) -> wipeTowerX = tx @@ -160,7 +165,11 @@ object HelixSliceRunner { false } if (result != null && result.success) { - LastSliceStore.record(path, result, selectedTool, toolResult.usedToolMask) + LastSliceStore.record( + path, result, selectedTool, toolResult.usedToolMask, + sliceSettingsJson = sliceSettings.toJson(), + materialProfilesJson = materialProfilesJson, + ) } return Outcome(result, thumbnailsInjected, selectedTool, toolResult.usedToolMask) } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSliceSettings.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSliceSettings.kt index 3317282..4d2502c 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSliceSettings.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSliceSettings.kt @@ -13,6 +13,7 @@ import android.widget.TextView import android.widget.ArrayAdapter import com.u1.slicer.data.SliceConfig import org.crabcore.u1control.R +import org.json.JSONObject /** User-facing slice overrides on the prepare screen (Phase 1 — SliceConfig fields only). */ data class HelixSliceSettings( @@ -49,6 +50,26 @@ data class HelixSliceSettings( fun hasInfillOverride(): Boolean = infillDensity >= 0f || infillPattern != "default" + /** Round-trip serialization so a background re-slice can replay the exact + * prepare-screen overrides (supports/brim/infill/ironing). */ + fun toJson(): String = JSONObject().apply { + put("supportsEnabled", supportsEnabled) + put("supportType", supportType) + put("supportAngle", supportAngle) + put("supportFilament", supportFilament) + put("supportInterfaceFilament", supportInterfaceFilament) + put("supportBuildPlateOnly", supportBuildPlateOnly) + put("supportPattern", supportPattern) + put("brimWidthMm", brimWidthMm.toDouble()) + put("infillDensity", infillDensity.toDouble()) + put("infillPattern", infillPattern) + put("ironingType", ironingType) + put("ironingPattern", ironingPattern) + put("ironingFlow", ironingFlow) + put("ironingSpacing", ironingSpacing.toDouble()) + put("ironingSpeed", ironingSpeed) + }.toString() + fun applyTo(config: SliceConfig) { config.supportEnabled = supportsEnabled if (supportsEnabled) { @@ -127,6 +148,31 @@ data class HelixSliceSettings( brimWidth?.let { settings.brimWidthMm = it.toFloat() } return settings } + + /** Reconstruct from [toJson] output; null/blank/garbage → defaults. */ + fun fromJson(json: String?): HelixSliceSettings { + if (json.isNullOrBlank()) return HelixSliceSettings() + return runCatching { + val o = JSONObject(json) + HelixSliceSettings( + supportsEnabled = o.optBoolean("supportsEnabled"), + supportType = o.optString("supportType", "normal(auto)"), + supportAngle = o.optInt("supportAngle", 30).coerceIn(0, 90), + supportFilament = o.optInt("supportFilament", 0), + supportInterfaceFilament = o.optInt("supportInterfaceFilament", -1), + supportBuildPlateOnly = o.optBoolean("supportBuildPlateOnly"), + supportPattern = o.optString("supportPattern", "default"), + brimWidthMm = o.optDouble("brimWidthMm", 0.0).toFloat(), + infillDensity = o.optDouble("infillDensity", -1.0).toFloat(), + infillPattern = o.optString("infillPattern", "default"), + ironingType = o.optString("ironingType", "no ironing"), + ironingPattern = o.optString("ironingPattern", "zig-zag"), + ironingFlow = o.optInt("ironingFlow", 10), + ironingSpacing = o.optDouble("ironingSpacing", 0.15).toFloat(), + ironingSpeed = o.optInt("ironingSpeed", 30), + ) + }.getOrDefault(HelixSliceSettings()) + } } } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt index f6d1f67..76b6d95 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt @@ -354,6 +354,71 @@ class HelixSlicerModule( } } + /** + * Repacks [path] (any 3MF the engine accepts — typically an extracted plate) + * into a temp 3MF where every object is reassigned to [targetTool] (0-based) + * and the multi-filament project config is stripped, so a re-slice produces + * single-tool gcode in that one material. Resolves the new filePath. + */ + @ReactMethod + fun collapseModel(path: String, targetTool: Int, promise: Promise) { + try { + val file = File(path.removePrefix("file://")) + if (!file.exists()) { + promise.reject("E_NO_FILE", "Model file not found: ${file.absolutePath}") + return + } + val outFile = File(reactApplicationContext.filesDir, "collapsed_tool_${targetTool}.3mf") + PlateExtractor.collapseToSingleExtruder(file, targetTool, outFile) + if (!outFile.exists() || outFile.length() == 0L) { + promise.reject("E_COLLAPSE", "Failed to collapse model to tool $targetTool.") + return + } + promise.resolve(outFile.absolutePath) + } catch (error: Throwable) { + promise.reject("E_COLLAPSE", error.message, error) + } + } + + /** + * Repacks [path] so each object's extruder is remapped per [extruderMapJson] + * (JSON object { "<1-based source extruder>": <0-based target slot>, ... }), + * keeping the file multi-filament so the re-slice stays multi-colour. Used for + * per-colour remap (e.g. red->slot 2, blue->slot 3). Resolves the new filePath. + */ + @ReactMethod + fun remapModel(path: String, extruderMapJson: String, promise: Promise) { + try { + val file = File(path.removePrefix("file://")) + if (!file.exists()) { + promise.reject("E_NO_FILE", "Model file not found: ${file.absolutePath}") + return + } + val map = HashMap() + val obj = JSONObject(extruderMapJson) + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + key.toIntOrNull()?.let { srcExtruder -> + map[srcExtruder] = obj.getInt(key).coerceIn(0, 3) + } + } + if (map.isEmpty()) { + promise.reject("E_REMAP", "Extruder map is empty.") + return + } + val outFile = File(reactApplicationContext.filesDir, "remapped_${System.nanoTime()}.3mf") + PlateExtractor.remapExtruders(file, map, outFile) + if (!outFile.exists() || outFile.length() == 0L) { + promise.reject("E_REMAP", "Failed to remap model.") + return + } + promise.resolve(outFile.absolutePath) + } catch (error: Throwable) { + promise.reject("E_REMAP", error.message, error) + } + } + private fun emitExtractProgress(percent: Int, phase: String) { val params = Arguments.createMap().apply { putInt("percent", percent) @@ -591,6 +656,8 @@ class HelixSlicerModule( putDouble("estimatedFilamentGrams", LastSliceStore.estimatedFilamentGrams.toDouble()) putInt("initialTool", LastSliceStore.initialTool) putInt("usedToolMask", LastSliceStore.usedToolMask) + LastSliceStore.sliceSettingsJson?.let { putString("sliceSettings", it) } + LastSliceStore.materialProfilesJson?.let { putString("materialProfiles", it) } }, ) } @@ -709,18 +776,28 @@ class HelixSlicerModule( ?.coerceIn(0, 3) ?: 0 val o = options - val sliceSettings = HelixSliceSettings.fromBridgeOptions( - supportEnabled = o?.takeIf { it.hasKey("supportEnabled") }?.getBoolean("supportEnabled"), - supportType = o?.takeIf { it.hasKey("supportType") }?.getString("supportType"), - supportAngle = o?.takeIf { it.hasKey("supportAngle") }?.getDouble("supportAngle"), - supportFilament = o?.takeIf { it.hasKey("supportFilament") }?.getInt("supportFilament"), - supportInterfaceFilament = o?.takeIf { it.hasKey("supportInterfaceFilament") } - ?.getInt("supportInterfaceFilament"), - supportBuildPlateOnly = o?.takeIf { it.hasKey("supportBuildPlateOnly") } - ?.getBoolean("supportBuildPlateOnly"), - supportPattern = o?.takeIf { it.hasKey("supportPattern") }?.getString("supportPattern"), - brimWidth = o?.takeIf { it.hasKey("brimWidth") }?.getDouble("brimWidth"), - ) + // Re-slice path: RN may pass the full sliceSettings JSON captured from + // the prepare screen (covers supports/brim/infill/ironing), which takes + // precedence over the granular bridge keys. Individual keys still layer + // on top for callers that use them. + val sliceSettings = if (o?.hasKey("sliceSettings") == true) { + HelixSliceSettings.fromJson(o.getString("sliceSettings")) + } else { + HelixSliceSettings.fromBridgeOptions( + supportEnabled = o?.takeIf { it.hasKey("supportEnabled") }?.getBoolean("supportEnabled"), + supportType = o?.takeIf { it.hasKey("supportType") }?.getString("supportType"), + supportAngle = o?.takeIf { it.hasKey("supportAngle") }?.getDouble("supportAngle"), + supportFilament = o?.takeIf { it.hasKey("supportFilament") }?.getInt("supportFilament"), + supportInterfaceFilament = o?.takeIf { it.hasKey("supportInterfaceFilament") } + ?.getInt("supportInterfaceFilament"), + supportBuildPlateOnly = o?.takeIf { it.hasKey("supportBuildPlateOnly") } + ?.getBoolean("supportBuildPlateOnly"), + supportPattern = o?.takeIf { it.hasKey("supportPattern") }?.getString("supportPattern"), + brimWidth = o?.takeIf { it.hasKey("brimWidth") }?.getDouble("brimWidth"), + ) + } + val materialProfilesJson = o?.takeIf { it.hasKey("materialProfiles") }?.getString("materialProfiles") + val forceExtruderCount = o?.takeIf { it.hasKey("forceExtruderCount") }?.getInt("forceExtruderCount") val outcome = HelixSliceRunner.run( reactApplicationContext, lib, @@ -728,6 +805,8 @@ class HelixSlicerModule( onProgress = { pct, stage -> emitProgress(pct, stage) }, initialTool = initialTool, sliceSettings = sliceSettings, + materialProfilesJson = materialProfilesJson, + forceExtruderCount = forceExtruderCount, ) { o?.let { opts -> if (opts.hasKey("layerHeight")) layerHeight = opts.getDouble("layerHeight").toFloat() diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/LastSliceStore.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/LastSliceStore.kt index f641f26..481d4cf 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/LastSliceStore.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/LastSliceStore.kt @@ -3,7 +3,11 @@ package org.crabcore.u1control.slicing import com.u1.slicer.data.SliceResult /** Last successful slice — lets the RN Slice Lab tab offer upload/print after - * slicing from the native prepare screen (which never calls back into JS). */ + * slicing from the native prepare screen (which never calls back into JS). + * + * Also keeps the sliceSettings + materialProfiles JSON so a background + * re-slice (e.g. "print this in a different loaded filament") can replay the + * exact prepare-screen overrides and material temperatures. */ object LastSliceStore { @Volatile var modelPath: String? = null @Volatile var gcodePath: String? = null @@ -12,8 +16,17 @@ object LastSliceStore { @Volatile var estimatedFilamentGrams: Float = 0f @Volatile var initialTool: Int = 0 @Volatile var usedToolMask: Int = 1 + @Volatile var sliceSettingsJson: String? = null + @Volatile var materialProfilesJson: String? = null - fun record(model: String, result: SliceResult, initialTool: Int = 0, usedToolMask: Int = 1) { + fun record( + model: String, + result: SliceResult, + initialTool: Int = 0, + usedToolMask: Int = 1, + sliceSettingsJson: String? = null, + materialProfilesJson: String? = null, + ) { if (!result.success || result.gcodePath.isBlank()) return modelPath = model gcodePath = result.gcodePath @@ -22,6 +35,8 @@ object LastSliceStore { estimatedFilamentGrams = result.estimatedFilamentGrams this.initialTool = initialTool this.usedToolMask = usedToolMask + this.sliceSettingsJson = sliceSettingsJson + this.materialProfilesJson = materialProfilesJson } fun clear() { @@ -32,5 +47,7 @@ object LastSliceStore { estimatedFilamentGrams = 0f initialTool = 0 usedToolMask = 1 + sliceSettingsJson = null + materialProfilesJson = null } } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt index 72ca815..c017fdf 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt @@ -134,6 +134,13 @@ object PlateExtractor { if (items.isEmpty()) throw IllegalStateException("No items in ${src.name}") onProgress?.invoke(31, "Resolving objects…") + // Per-object filament/extruder lives ONLY in Metadata/model_settings.config + // (Bambu omits it from ), as (1-based). The native engine ignores ; it reads + // per-object extruder from this config. We capture source object->extruder + // here and (below) emit a config keyed by the RENUMBERED output object ids. + val extruders = readObjectExtruders(zip) + // Resolve the plate's leaves: DFS from kept build items, composing 3MF // row-vector transforms (combined = component x item). One output object // per UNIQUE source mesh (shared geometry -> one + many s); @@ -141,7 +148,7 @@ object PlateExtractor { val meshOutId = LinkedHashMap() val outItems = ArrayList() var nextId = 0 - fun expand(objId: Int, xform: DoubleArray, printable: String?, partPath: String?, path: HashSet) { + fun expand(objId: Int, xform: DoubleArray, printable: String?, partPath: String?, path: HashSet, extruder: String?) { if (objId in path) return // cycle guard (3MF forbids cycles; defensive) val node = graph[objId] val subs = node?.components @@ -150,16 +157,16 @@ object PlateExtractor { // Pure group object: recurse into its components, composing transforms. val nextPath = HashSet(path).apply { add(objId) } for (c in subs) { - expand(c.sub, composeTransforms(parseTransform(c.transform), xform), printable, c.partPath, nextPath) + expand(c.sub, composeTransforms(parseTransform(c.transform), xform), printable, c.partPath, nextPath, extruder) } } else if (hasOwnMesh || partPath != null) { // Leaf mesh object: emit it (de-duplicated by source mesh). val outId = meshOutId.getOrPut(MeshSrc(partPath, objId)) { ++nextId } - outItems.add(OutItem(outId, xform, printable)) + outItems.add(OutItem(outId, xform, printable, extruder)) } } for (bi in items) { - if (bi.objid in keep) expand(bi.objid, parseTransform(bi.transform), bi.printable, null, hashSetOf()) + if (bi.objid in keep) expand(bi.objid, parseTransform(bi.transform), bi.printable, null, hashSetOf(), extruders[bi.objid]) } if (outItems.isEmpty()) throw IllegalStateException("Plate ${plate.id} has no printable objects") @@ -167,6 +174,40 @@ object PlateExtractor { ZipOutputStream(outFile.outputStream().buffered()).use { zos -> writeEntry(zos, "[Content_Types].xml", MINIMAL_CONTENT_TYPES) writeEntry(zos, "_rels/.rels", MINIMAL_RELS) + // Carry over the source's project settings (filament colours/types) so the + // native engine's project config (nativeGetProjectConfig) knows this is a + // multi-material project with the right palette — otherwise every object + // collapses to extruder 0 / machine-slot colours. Streamed verbatim. + zip.getEntry("Metadata/project_settings.config")?.let { entry -> + zos.putNextEntry(ZipEntry("Metadata/project_settings.config")) + zip.getInputStream(entry).use { input -> + val buf = ByteArray(64 * 1024) + while (true) { val n = input.read(buf); if (n < 0) break; zos.write(buf, 0, n) } + } + zos.closeEntry() + } + // Per-object extruder, keyed by the RENUMBERED output object ids. The + // engine ignores ; it reads extruder from this config's + // top-level blocks + // (1-based), matching the source layout: objects are DIRECT children of + // (NOT wrapped in — wrapping changes the plate count + // the engine validates and makes it reject the archive). + val outExtruders = LinkedHashMap() + for (oi in outItems) { + val ex = oi.extruder ?: continue + outExtruders.putIfAbsent(oi.outId, ex) + } + if (outExtruders.isNotEmpty()) { + val sb = StringBuilder() + sb.append("\n\n") + for ((outId, ex) in outExtruders) { + sb.append(" \n") + sb.append(" \n") + sb.append(" \n") + } + sb.append("\n") + writeEntry(zos, "Metadata/model_settings.config", sb.toString()) + } zos.putNextEntry(ZipEntry("3D/3dmodel.model")) val w = OutputStreamWriter(zos, Charsets.UTF_8) val langAttr = if (rootUnit[1].isNotEmpty()) " xml:lang=\"${rootUnit[1]}\"" else "" @@ -214,6 +255,128 @@ object PlateExtractor { return outFile } + /** + * Collapses [src] (any 3MF the engine accepts — typically an extracted plate) + * into [outFile] with EVERY object reassigned to [targetTool] (0-based), and + * the multi-filament project config stripped so loadModel sees a single + * filament. Used to re-slice a multi-color model in one loaded material. + * + * PrusaSlicer's per-object extruder is read at loadModel() time from + * Metadata/model_settings.config (1-based) — there is no setObjectExtruder + * JNI, and forceSingleTool() does not remap existing tags. So a faithful + * collapse must rewrite that config (all objects -> targetTool+1) AND drop + * Metadata/project_settings.config (so readFilamentCount() == 1 and the + * engine takes its single-tool path instead of the multi-tool one). + */ + fun collapseToSingleExtruder(src: File, targetTool: Int, outFile: File): File { + val tool = targetTool.coerceIn(0, 3) + val extruderValue = (tool + 1).toString() + val ids = readObjectIdsFromModel(src) + check(ids.isNotEmpty()) { "No objects found in ${src.name}" } + val configXml = buildString { + append("\n\n") + for (id in ids) { + append(" \n") + append(" \n") + append(" \n") + } + append("\n") + } + val buf = ByteArray(64 * 1024) + ZipFile(src).use { zip -> + ZipOutputStream(outFile.outputStream().buffered()).use { zos -> + for (entry in zip.entries()) { + val n = entry.name + when { + n.equals("Metadata/project_settings.config", ignoreCase = true) -> { + // Drop: forces readFilamentCount() == 1 -> single-tool slice. + } + n.equals("Metadata/model_settings.config", ignoreCase = true) -> { + zos.putNextEntry(ZipEntry(n)) + zos.write(configXml.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + else -> { + zos.putNextEntry(ZipEntry(n)) + zip.getInputStream(entry).use { input -> + while (true) { + val read = input.read(buf) + if (read < 0) break + zos.write(buf, 0, read) + } + } + zos.closeEntry() + } + } + } + } + } + return outFile + } + + /** + * Repacks [src] into a temp 3MF where each object's extruder is remapped per + * [extruderMap]: { original extruder value (1-based) -> target ACE slot (0-based) }. + * Objects whose extruder isn't a key keep their original assignment. Unlike + * [collapseToSingleExtruder] this KEEPS Metadata/project_settings.config so the + * engine stays multi-filament (configureMultiTool) — the slice remains + * multi-colour, just with each colour routed to the user's chosen loaded slot. + * Caller must pass forceExtruderCount = (max target slot + 1) to the slice so + * configureMultiTool sizes the extruder array to cover every mapped tool. + */ + fun remapExtruders(src: File, extruderMap: Map, outFile: File): File { + require(extruderMap.isNotEmpty()) { "extruderMap must not be empty" } + val buf = ByteArray(64 * 1024) + ZipFile(src).use { zip -> + val ids = readZipText(zip, "3D/3dmodel.model") + ?.let { OBJECT_BLOCK.findAll(it).mapNotNull { g -> g.groupValues[1].toIntOrNull() }.distinct().toList() } + ?: emptyList() + check(ids.isNotEmpty()) { "No objects found in ${src.name}" } + val current = readObjectExtruders(zip) + val configXml = buildString { + append("\n\n") + for (id in ids) { + val raw = current[id]?.toIntOrNull() + val targetSlot = if (raw != null && extruderMap.containsKey(raw)) { + extruderMap.getValue(raw).coerceIn(0, 3) + } else { + ((raw ?: 1) - 1).coerceIn(0, 3) + } + append(" \n") + append(" \n") + append(" \n") + } + append("\n") + } + ZipOutputStream(outFile.outputStream().buffered()).use { zos -> + for (entry in zip.entries()) { + val n = entry.name + if (n.equals("Metadata/model_settings.config", ignoreCase = true)) { + zos.putNextEntry(ZipEntry(n)) + zos.write(configXml.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } else { + zos.putNextEntry(ZipEntry(n)) + zip.getInputStream(entry).use { input -> + while (true) { + val read = input.read(buf) + if (read < 0) break + zos.write(buf, 0, read) + } + } + zos.closeEntry() + } + } + } + } + return outFile + } + + private fun readObjectIdsFromModel(file: File): List { + val xml = readEntryText(file, "3D/3dmodel.model") ?: return emptyList() + return OBJECT_BLOCK.findAll(xml).mapNotNull { it.groupValues[1].toIntOrNull() }.distinct().toList() + } + private fun writeEntry(zos: ZipOutputStream, name: String, text: String) { zos.putNextEntry(ZipEntry(name)) zos.write(text.toByteArray(Charsets.UTF_8)) @@ -225,6 +388,7 @@ object PlateExtractor { + """ private val MINIMAL_RELS = @@ -266,7 +430,7 @@ object PlateExtractor { private data class ObjNode(val components: List, val hasMesh: Boolean) private data class BuildItem(val objid: Int, val transform: String?, val printable: String?) private data class MeshSrc(val partPath: String?, val objId: Int) - private data class OutItem(val outId: Int, val xform: DoubleArray, val printable: String?) + private data class OutItem(val outId: Int, val xform: DoubleArray, val printable: String?, val extruder: String?) /** * Maps bytes read out of a stream to a determinate [base..base+span] percent @@ -361,6 +525,37 @@ object PlateExtractor { } /** Streams [entry] (3D/3dmodel.model) copying each object whose id is in [pending] (id -> outId). */ + /** + * Bambu stores each object's filament/extruder assignment in + * Metadata/model_settings.config (``), NOT on 3D/3dmodel.model's ``. The native PrusaSlicer + * engine reads extruder only from ``, so without this remap every + * extracted object collapses to extruder 0 and multi-colour prints lose their + * colour split. Bambu's value is 1-based (filament 1 = first slot); 3MF's + * `` and the engine are 0-based, so we subtract 1. Returns + * object id -> extruder value in engine (0-based) indexing. + */ + private fun readObjectExtruders(zip: ZipFile): Map { + val cfg = readZipText(zip, "Metadata/model_settings.config") ?: return emptyMap() + val objectBlock = Regex( + "]*\\bid=\"(\\d+)\"[^>]*>(.*?)", + setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE), + ) + val extruderMeta = Regex("() + for (m in objectBlock.findAll(cfg)) { + val oid = m.groupValues[1].toIntOrNull() ?: continue + val ex = extruderMeta.find(m.groupValues[2])?.groupValues?.get(1) ?: continue + out[oid] = ex + } + return out + } + + private fun readZipText(zip: ZipFile, name: String): String? { + val entry = zip.getEntry(name) ?: return null + return zip.getInputStream(entry).bufferedReader().use { it.readText() } + } + private fun streamInlineMeshes(zip: ZipFile, entry: ZipEntry, pending: Map, w: Writer, meter: Meter?) { CountingInputStream(zip.getInputStream(entry), meter).use { input -> val parser = newParser(input.buffered()) diff --git a/app.json b/app.json index b6ce797..057deaa 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Helix", "slug": "u1-control", - "version": "1.2.6", + "version": "1.2.8", "orientation": "portrait", "scheme": "u1control", "userInterfaceStyle": "dark", @@ -24,7 +24,7 @@ }, "android": { "package": "org.crabcore.u1control", - "versionCode": 17, + "versionCode": 18, "softwareKeyboardLayoutMode": "resize", "permissions": [ "android.permission.POST_NOTIFICATIONS" diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 08dba14..d143652 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -42,11 +42,10 @@ import { LANGUAGES, t } from '../../services/i18n'; import { colors, spacing } from '../../constants/theme'; import { api, + applyConfigIfChanged, normalizeBaseUrl, normalizeMoonrakerUrl, printerConnectionUrl, - restartMoonraker, - uploadConfigFile, validatePrinterConnectionTarget, } from '../../services/moonraker'; import { getMakerWorldCookies } from '../../services/nativeSlicer'; @@ -769,14 +768,13 @@ function SpoolmanCard({ activeUrl }: { activeUrl: string }) { if (!server) return; setBusy(true); try { - await uploadConfigFile( + const changed = await applyConfigIfChanged( activeUrl, 'extended/moonraker', 'spoolman.cfg', `# Spoolman filament tracking (written by Helix)\n[spoolman]\nserver: ${server}\nsync_rate: 5\n` ); - await restartMoonraker(activeUrl); - await new Promise((r) => setTimeout(r, 8000)); + if (changed) await new Promise((r) => setTimeout(r, 8000)); setCurrent(server); Alert.alert(t('Saved'), t('Printer now reports filament usage to this Spoolman server.')); } catch (e: unknown) { diff --git a/app/(tabs)/slicer.tsx b/app/(tabs)/slicer.tsx index 4094d67..b8af267 100644 --- a/app/(tabs)/slicer.tsx +++ b/app/(tabs)/slicer.tsx @@ -34,6 +34,9 @@ import { injectTimelapseMacros, pickModelFile, setFilamentSlotColors, + collapseModelToTool, + remapModelExtruders, + sliceModelFile, setNativePrinters, SharedMakerWorldLink, uploadGcodeToPrinter, @@ -144,6 +147,12 @@ export default function SliceLabScreen() { const [extracting, setExtracting] = useState(false); const [extractProgress, setExtractProgress] = useState<{ percent: number; phase: string } | null>(null); const [sayingIdx, setSayingIdx] = useState(0); + // When the user remaps a single-material slice to a different loaded slot in + // the print dialog, this holds { fileTool -> chosenLoadedSlot }. Print then + // re-slices: if every file tool routes to the SAME slot the model is collapsed + // to single-material; otherwise each colour is remapped to its own slot + // (multi-colour stays multi-colour). null = use the sliced tools as-is. + const [toolRemap, setToolRemap] = useState | null>(null); const [preprocessOpen, setPreprocessOpen] = useState(false); const [sendProgress, setSendProgress] = useState(0); const [perToolGrams, setPerToolGrams] = useState([]); @@ -746,8 +755,28 @@ export default function SliceLabScreen() { setPrintStart({ state: 'error', message: 'Printer URL is blank.' }); return; } - const initialTool = slice.result.initialTool ?? toolLoad.selectedTool; - const requiredToolMask = slice.result.usedToolMask ?? (1 << initialTool); + const slicedTool = slice.result.initialTool ?? toolLoad.selectedTool; + const fileRequiredMask = slice.result.usedToolMask ?? (1 << slicedTool); + const fileTools = [0, 1, 2, 3].filter((t) => (fileRequiredMask & (1 << t)) !== 0); + + // Resolve the full per-file-tool target map (default identity) from toolRemap. + const remap = toolRemap ?? {}; + const fullTarget: Record = {}; + for (const ft of fileTools) fullTarget[ft] = remap[ft] ?? ft; + const targets = fileTools.map((ft) => fullTarget[ft]); + const allSameTarget = targets.length > 0 && targets.every((t) => t === targets[0]); + const collapseSlot = allSameTarget ? targets[0] : -1; + + // Re-slice when the user routed any file tool to a different loaded slot. + const wantsReslice = + fileTools.some((ft) => fullTarget[ft] !== ft) && + Boolean(slice.result.modelPath) && + Boolean(slice.result.sliceSettings) && + Boolean(slice.result.materialProfiles); + + const requiredToolMask = wantsReslice + ? targets.reduce((mask, t) => mask | (1 << t), 0) + : fileRequiredMask; const usedExtruders = [0, 1, 2, 3].filter((tool) => (requiredToolMask & (1 << tool)) !== 0); const missingTools = missingLoadedTools(toolLoad, requiredToolMask); if (missingTools) { @@ -755,11 +784,58 @@ export default function SliceLabScreen() { return; } - const gcodePath = slice.result.gcodePath; const sourceName = download.state === 'success' ? download.result.fileName : null; - setPrintStart({ state: 'starting', message: 'Uploading…' }); - setSendProgress(0.12); try { + let gcodePath = slice.result.gcodePath; + if (wantsReslice) { + const onResliceProgress = (percentage: number) => + setSendProgress(0.05 + (Math.max(0, Math.min(100, percentage)) / 100) * 0.06); + if (allSameTarget) { + // Collapse: every object -> one material (single-tool slice). + const chosenMaterial = + filamentSlots.find((s) => s.index === collapseSlot)?.material ?? `slot ${collapseSlot}`; + setPrintStart({ state: 'starting', message: `Re-slicing for ${chosenMaterial}…` }); + setSendProgress(0.05); + const collapsedPath = await collapseModelToTool(slice.result.modelPath as string, collapseSlot); + const resliced = await sliceModelFile( + collapsedPath, + { + initialTool: collapseSlot, + sliceSettings: slice.result.sliceSettings, + materialProfiles: slice.result.materialProfiles, + }, + onResliceProgress, + ); + if (!resliced.success || !resliced.gcodePath) { + throw new Error(resliced.errorMessage || 'Re-slice failed.'); + } + gcodePath = resliced.gcodePath; + } else { + // Per-colour remap: keep multi-colour, route each file colour to its slot. + setPrintStart({ state: 'starting', message: 'Re-slicing per-colour…' }); + setSendProgress(0.05); + const extruderMap: Record = {}; + for (const ft of fileTools) extruderMap[ft + 1] = fullTarget[ft]; + const forceExtruderCount = Math.max(...targets) + 1; + const remappedPath = await remapModelExtruders(slice.result.modelPath as string, extruderMap); + const resliced = await sliceModelFile( + remappedPath, + { + initialTool: targets[0], + sliceSettings: slice.result.sliceSettings, + materialProfiles: slice.result.materialProfiles, + forceExtruderCount, + }, + onResliceProgress, + ); + if (!resliced.success || !resliced.gcodePath) { + throw new Error(resliced.errorMessage || 'Re-slice failed.'); + } + gcodePath = resliced.gcodePath; + } + } + setPrintStart({ state: 'starting', message: 'Uploading…' }); + setSendProgress(0.12); // Timelapse is gcode-driven: the printer only records frames if the gcode // itself calls the TIMELAPSE_* macros at each layer. Inject them before // upload when the toggle is on (SET_PRINT_PREFERENCES below just arms the @@ -829,7 +905,7 @@ export default function SliceLabScreen() { message: `Send failed: ${error instanceof Error ? error.message : String(error)}`, }); } - }, [activeUrl, slice, toolLoad, download, router]); + }, [activeUrl, slice, toolLoad, download, router, toolRemap, filamentSlots]); const refresh = async () => { setRefreshing(true); @@ -852,6 +928,13 @@ export default function SliceLabScreen() { () => filamentSlots.filter((slot) => (slicedRequiredToolMask & (1 << slot.index)) !== 0), [filamentSlots, slicedRequiredToolMask], ); + // Tool-remap for the print dialog: each required (file) tool defaults to + // itself; toolRemap overrides individual tools to a different loaded slot. + const printDialogAssignments = useMemo(() => { + const m: Record = {}; + for (const slot of printDialogSlots) m[slot.index] = toolRemap?.[slot.index] ?? slot.index; + return m; + }, [printDialogSlots, toolRemap]); // Pull the render thumbnail baked into the sliced gcode (shows in the card // immediately, before any upload — same preview the home card uses). @@ -1102,7 +1185,10 @@ export default function SliceLabScreen() { setPreprocessOpen(false)} + onClose={() => { + setPreprocessOpen(false); + setToolRemap(null); + }} fileName={download.state === 'success' ? download.result.fileName : 'print.gcode'} estTimeSeconds={slice.state === 'success' ? slice.result.estimatedTimeSeconds : 0} estGramsTotal={slice.state === 'success' ? slice.result.estimatedFilamentGrams : 0} @@ -1111,11 +1197,17 @@ export default function SliceLabScreen() { activePrinterId={settings.activePrinterId} onSelectPrinter={selectPrinter} slots={printDialogSlots} + availableSlots={filamentSlots} + assignments={printDialogAssignments} + onAssignSlot={(_fileTool, loadedSlot) => + setToolRemap((prev) => ({ ...(prev ?? {}), [_fileTool]: loadedSlot })) + } perToolGrams={perToolGrams} prefs={printPrefs} onTogglePref={(pref) => setPrintPrefs((prev) => ({ ...prev, [pref]: !prev[pref] }))} sending={printStart.state === 'starting'} progress={sendProgress} + statusMessage={printStart.state === 'starting' ? printStart.message : null} errorMessage={printStart.state === 'error' ? printStart.message : null} onSend={uploadAndPrint} /> diff --git a/app/(tabs)/spoolman.tsx b/app/(tabs)/spoolman.tsx index 371180d..b1acf7b 100644 --- a/app/(tabs)/spoolman.tsx +++ b/app/(tabs)/spoolman.tsx @@ -17,7 +17,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useMoonraker } from '../../hooks/useMoonraker'; import { useSettings } from '../../hooks/useSettings'; -import { api, normalizeBaseUrl, restartMoonraker, uploadConfigFile } from '../../services/moonraker'; +import { api, applyConfigIfChanged, normalizeBaseUrl } from '../../services/moonraker'; import Dropdown from '../../components/Dropdown'; import SpoolLabel from '../../components/SpoolLabel'; import SpoolScanner from '../../components/SpoolScanner'; @@ -174,14 +174,13 @@ export default function SpoolmanScreen() { if (!server) return; setConfiguring(true); try { - await uploadConfigFile( + const changed = await applyConfigIfChanged( activeUrl, 'extended/moonraker', 'spoolman.cfg', `# Spoolman filament tracking (written by Helix)\n[spoolman]\nserver: ${server}\nsync_rate: 5\n` ); - await restartMoonraker(activeUrl); - await new Promise((r) => setTimeout(r, 8000)); + if (changed) await new Promise((r) => setTimeout(r, 8000)); await refresh(); } catch (e: any) { Alert.alert(t('Error'), String(e?.message ?? e)); diff --git a/components/PrintPreprocessDialog.tsx b/components/PrintPreprocessDialog.tsx index 1145d18..a5c9e49 100644 --- a/components/PrintPreprocessDialog.tsx +++ b/components/PrintPreprocessDialog.tsx @@ -38,6 +38,7 @@ type Props = { onTogglePref: (pref: PrintPref) => void; sending: boolean; progress: number; + statusMessage?: string | null; errorMessage?: string | null; onSend: (prefs: Readonly>) => void; sendLabel?: string; @@ -69,6 +70,7 @@ export default function PrintPreprocessDialog({ onTogglePref, sending, progress, + statusMessage, errorMessage, onSend, sendLabel = 'Print', @@ -131,10 +133,11 @@ export default function PrintPreprocessDialog({ {slots.map((slot) => { const grams = perToolGrams[slot.index]; - const empty = slot.status === 'empty'; - const displayColor = requiredColors?.[slot.index] ?? slot.color; const assignedIndex = assignments[slot.index] ?? slot.index; const assignedSlot = availableSlots.find((candidate) => candidate.index === assignedIndex); + const effectiveSlot = assignedSlot ?? slot; + const empty = effectiveSlot.status === 'empty'; + const displayColor = effectiveSlot.color ?? requiredColors?.[slot.index] ?? slot.color; return ( - {slot.material || 'PLA'} + {assignedSlot?.material || slot.material || 'PLA'} {typeof grams === 'number' && grams > 0 ? `${grams.toFixed(2)}g` : '—'} @@ -184,6 +187,9 @@ export default function PrintPreprocessDialog({ {errorMessage ? {errorMessage} : null} + {sending && statusMessage ? ( + {statusMessage} + ) : null} @@ -478,6 +484,7 @@ const styles = StyleSheet.create({ }, progressFill: { height: '100%', borderRadius: 3, backgroundColor: colors.primary }, progressPct: { color: colors.subtext, fontSize: 12, fontWeight: '700', minWidth: 34, textAlign: 'right' }, + statusText: { color: colors.text, fontSize: 13, fontWeight: '700', marginBottom: spacing.xs }, send: { minHeight: 48, borderRadius: radius.md, diff --git a/functions/src/index.ts b/functions/src/index.ts index 54bc0e5..ad0f36a 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -55,16 +55,45 @@ export const relay = onRequest(async (req, res) => { return; } + const base = `${req.protocol}://${req.get('host')}`; + + // Idempotent registration: a given (printerId, deviceToken) always resolves + // to the SAME webhook URL, so re-registering (app relaunch, reinstall with a + // stable token) does not change helix-push.cfg and therefore does not force + // a Moonraker restart on the printer. Legacy docs created before the stored + // `secret` field have nothing to reuse, so they fall through to a fresh mint. + // A genuinely new deviceToken (Android rotated it) is a new pair and + // legitimately mints a new secret (one restart, expected). + const existing = await devices + .where('printerId', '==', printerId) + .where('deviceToken', '==', deviceToken) + .limit(1) + .get(); + if (!existing.empty) { + const snap = existing.docs[0]; + const stored = snap.data() as { secret?: unknown }; + if (typeof stored.secret === 'string' && stored.secret) { + await snap.ref.set( + { enabled: true, updatedAt: FieldValue.serverTimestamp() }, + { merge: true }, + ); + json(res, 200, { + webhookUrl: `${base}/relay/v1/events/${encodeURIComponent(stored.secret)}`, + }); + return; + } + } + const secret = randomBytes(32).toString('base64url'); await devices.doc(hashSecret(secret)).set({ deviceToken, printerId, + secret, enabled: true, createdAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), }); - const base = `${req.protocol}://${req.get('host')}`; json(res, 200, { webhookUrl: `${base}/relay/v1/events/${encodeURIComponent(secret)}`, }); diff --git a/package.json b/package.json index 7798bfd..946cda2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "u1-control", - "version": "1.2.7", + "version": "1.2.8", "license": "AGPL-3.0-or-later", "main": "expo-router/entry", "private": true, diff --git a/services/i18n.ts b/services/i18n.ts index 72d872a..8905019 100644 --- a/services/i18n.ts +++ b/services/i18n.ts @@ -25,435 +25,1613 @@ export function t(key: string): string { const DICT: Record> = { pl: { - Home: 'Strona główna', Mesh: 'Siatka', Macros: 'Makra', Console: 'Konsola', Files: 'Pliki', - Settings: 'Ustawienia', 'Bed Mesh': 'Siatka stołu', History: 'Historia', - Temperatures: 'Temperatury', Camera: 'Kamera', 'EMERGENCY STOP': 'AWARYJNE ZATRZYMANIE', - Pause: 'Pauza', Resume: 'Wznów', Cancel: 'Anuluj', 'Home All': 'Bazuj wszystko', - 'Dock Toolhead': 'Odłóż głowicę', Retry: 'Spróbuj ponownie', 'Printer offline': 'Drukarka offline', - 'Cancel print?': 'Anulować drukowanie?', 'This stops the current print.': 'To zatrzyma bieżące drukowanie.', + Home: 'Strona główna', + Mesh: 'Siatka', + Macros: 'Makra', + Console: 'Konsola', + Files: 'Pliki', + Settings: 'Ustawienia', + 'Bed Mesh': 'Siatka stołu', + History: 'Historia', + Temperatures: 'Temperatury', + Camera: 'Kamera', + 'EMERGENCY STOP': 'AWARYJNE ZATRZYMANIE', + Pause: 'Pauza', + Resume: 'Wznów', + Cancel: 'Anuluj', + 'Home All': 'Bazuj wszystko', + 'Dock Toolhead': 'Odłóż głowicę', + Retry: 'Spróbuj ponownie', + 'Printer offline': 'Drukarka offline', + 'Cancel print?': 'Anulować drukowanie?', + 'This stops the current print.': 'To zatrzyma bieżące drukowanie.', 'Emergency stop?': 'Awaryjne zatrzymanie?', 'Halts the printer immediately. Requires a firmware restart before printing again.': 'Natychmiast zatrzymuje drukarkę. Przed ponownym drukowaniem wymagany jest restart firmware.', - "Don't ask again": 'Nie pytaj ponownie', 'Stop now': 'Zatrzymaj teraz', - 'Confirm emergency stop': 'Potwierdź awaryjne zatrzymanie', No: 'Nie', Yes: 'Tak', - 'Yes, cancel': 'Tak, anuluj', Error: 'Błąd', - 'Print Control': 'Sterowanie drukiem', 'Tool Change': 'Zmiana narzędzia', - 'ACE & Filament': 'ACE i filament', Calibration: 'Kalibracja', Cleaning: 'Czyszczenie', - 'Homing & Motion': 'Bazowanie i ruch', Other: 'Inne', - 'Send G-code…': 'Wyślij G-code…', 'Printer command…': 'Polecenie drukarki…', Send: 'Wyślij', Enter: 'Wprowadź', Clear: 'Wyczyść', - 'Printer commands': 'Polecenia drukarki', 'Search commands…': 'Szukaj poleceń…', - 'No matching commands': 'Brak pasujących poleceń', 'Start print?': 'Rozpocząć drukowanie?', Print: 'Drukuj', - 'Printer busy': 'Drukarka zajęta', 'A print is already in progress.': 'Drukowanie już trwa.', 'Print started': 'Drukowanie rozpoczęte', - 'No G-code files on printer': 'Brak plików G-code na drukarce', 'Load more': 'Załaduj więcej', - 'Printer Stats': 'Statystyki drukarki', 'Total print jobs': 'Łączna liczba wydruków', - 'Total time': 'Łączny czas', 'Total print time': 'Łączny czas drukowania', 'Filament used': 'Zużyty filament', - 'No print history': 'Brak historii drukowania', Lane: 'Kanał', Unload: 'Wyładuj', Dryer: 'Suszarka', Start: 'Start', Stop: 'Stop', - Connection: 'Połączenie', 'Reconnect now': 'Połącz ponownie', 'ACE units': 'Jednostki ACE', - 'Notify on': 'Powiadamiaj o', 'Print complete': 'Drukowanie zakończone', 'Print failed': 'Drukowanie nieudane', - 'Filament runout': 'Koniec filamentu', 'Filament swap complete': 'Zmiana filamentu zakończona', - 'Send test notification': 'Wyślij powiadomienie testowe', 'Save & Apply': 'Zapisz i zastosuj', - 'Check for updates': 'Sprawdź aktualizacje', 'Checking for updates...': 'Sprawdzanie aktualizacji...', - 'Up to date': 'Aktualna wersja', "What's changed": 'Co się zmieniło', 'Update available': 'Dostępna aktualizacja', - 'Latest APK available': 'Dostępny najnowszy APK', 'Not now': 'Nie teraz', 'Download APK': 'Pobierz APK', - 'Downloading update...': 'Pobieranie aktualizacji...', 'Update download failed': 'Pobieranie aktualizacji nie powiodło się', - 'Open in browser': 'Otwórz w przeglądarce', OK: 'OK', 'Installed build': 'Zainstalowana wersja', - Saved: 'Zapisano', Theme: 'Motyw', 'Accent color': 'Kolor akcentu', Language: 'Język', About: 'Informacje', - 'Report a bug': 'Zgłoś błąd', Controls: 'Sterowanie', Bed: 'Stół', 'Part fan': 'Wentylator części', - 'Cavity fan': 'Wentylator komory', Purifier: 'Oczyszczacz', Set: 'Ustaw', Off: 'Wyłącz', On: 'Włącz', - Printers: 'Drukarki', 'Add printer': 'Dodaj drukarkę', Remove: 'Usuń', Name: 'Nazwa', - Progress: 'Postęp', 'Quick actions': 'Szybkie akcje', 'Emergency stop': 'Awaryjne zatrzymanie', - 'Home & Dock': 'Bazowanie i odkładanie', 'Last print': 'Ostatni wydruk', Download: 'Pobierz', + "Don't ask again": 'Nie pytaj ponownie', + 'Stop now': 'Zatrzymaj teraz', + 'Confirm emergency stop': 'Potwierdź awaryjne zatrzymanie', + No: 'Nie', + Yes: 'Tak', + 'Yes, cancel': 'Tak, anuluj', + Error: 'Błąd', + 'Print Control': 'Sterowanie drukiem', + 'Tool Change': 'Zmiana narzędzia', + 'ACE & Filament': 'ACE i filament', + Calibration: 'Kalibracja', + Cleaning: 'Czyszczenie', + 'Homing & Motion': 'Bazowanie i ruch', + Other: 'Inne', + 'Send G-code…': 'Wyślij G-code…', + 'Printer command…': 'Polecenie drukarki…', + Send: 'Wyślij', + Enter: 'Wprowadź', + Clear: 'Wyczyść', + 'Printer commands': 'Polecenia drukarki', + 'Search commands…': 'Szukaj poleceń…', + 'No matching commands': 'Brak pasujących poleceń', + 'Start print?': 'Rozpocząć drukowanie?', + Print: 'Drukuj', + 'Printer busy': 'Drukarka zajęta', + 'A print is already in progress.': 'Drukowanie już trwa.', + 'Print started': 'Drukowanie rozpoczęte', + 'No G-code files on printer': 'Brak plików G-code na drukarce', + 'Load more': 'Załaduj więcej', + 'Printer Stats': 'Statystyki drukarki', + 'Total print jobs': 'Łączna liczba wydruków', + 'Total time': 'Łączny czas', + 'Total print time': 'Łączny czas drukowania', + 'Filament used': 'Zużyty filament', + 'No print history': 'Brak historii drukowania', + Lane: 'Kanał', + Unload: 'Wyładuj', + Dryer: 'Suszarka', + Start: 'Start', + Stop: 'Stop', + Connection: 'Połączenie', + 'Reconnect now': 'Połącz ponownie', + 'ACE units': 'Jednostki ACE', + 'Notify on': 'Powiadamiaj o', + 'Print complete': 'Drukowanie zakończone', + 'Print failed': 'Drukowanie nieudane', + 'Filament runout': 'Koniec filamentu', + 'Filament swap complete': 'Zmiana filamentu zakończona', + 'Send test notification': 'Wyślij powiadomienie testowe', + 'Save & Apply': 'Zapisz i zastosuj', + 'Check for updates': 'Sprawdź aktualizacje', + 'Checking for updates...': 'Sprawdzanie aktualizacji...', + 'Up to date': 'Aktualna wersja', + "What's changed": 'Co się zmieniło', + 'Update available': 'Dostępna aktualizacja', + 'Latest APK available': 'Dostępny najnowszy APK', + 'Not now': 'Nie teraz', + 'Download APK': 'Pobierz APK', + 'Downloading update...': 'Pobieranie aktualizacji...', + 'Update download failed': 'Pobieranie aktualizacji nie powiodło się', + 'Open in browser': 'Otwórz w przeglądarce', + OK: 'OK', + 'Installed build': 'Zainstalowana wersja', + Saved: 'Zapisano', + Theme: 'Motyw', + 'Accent color': 'Kolor akcentu', + Language: 'Język', + About: 'Informacje', + 'Report a bug': 'Zgłoś błąd', + Controls: 'Sterowanie', + Bed: 'Stół', + 'Part fan': 'Wentylator części', + 'Cavity fan': 'Wentylator komory', + Purifier: 'Oczyszczacz', + Set: 'Ustaw', + Off: 'Wyłącz', + On: 'Włącz', + Printers: 'Drukarki', + 'Add printer': 'Dodaj drukarkę', + Remove: 'Usuń', + Name: 'Nazwa', + Progress: 'Postęp', + 'Quick actions': 'Szybkie akcje', + 'Emergency stop': 'Awaryjne zatrzymanie', + 'Home & Dock': 'Bazowanie i odkładanie', + 'Last print': 'Ostatni wydruk', + Download: 'Pobierz', + 'A mobile Fluidd-style controller for PAXX/Moonraker so you do not have to open the printer web UI every time.': 'Mobilny kontroler w stylu Fluidd dla PAXX/Moonraker — nie musisz za każdym razem otwierać interfejsu WWW drukarki.', + 'Active': 'Aktywna', + 'Active spool': 'Aktywna szpula', + 'Add filament': 'Dodaj filament', + 'Add spool': 'Dodaj szpulę', + 'Allow camera': 'Zezwól na kamerę', + 'Apply to printer': 'Zastosuj na drukarce', + 'Archive': 'Archiwizuj', + 'Archived': 'Zarchiwizowana', + 'Auto': 'Auto', + 'Backup': 'Kopia zapasowa', + 'Busy': 'Zajęta', + 'Calibrate': 'Kalibruj', + 'Camera access needed': 'Wymagany dostęp do kamery', + 'Camera stream (path or full URL)': 'Strumień kamery (ścieżka lub pełny URL)', + 'Cancelled': 'Anulowano', + 'Checking': 'Sprawdzanie', + 'Checking connection': 'Sprawdzanie połączenia', + 'Choose a drying preset.': 'Wybierz preset suszenia.', + 'Color': 'Kolor', + 'Configure printer': 'Skonfiguruj drukarkę', + 'Configuring…': 'Konfigurowanie…', + 'Connect to a printer to choose macros.': 'Połącz się z drukarką, aby wybrać makra.', + 'Connect to a ready printer to load command help.': 'Połącz się z gotową drukarką, aby załadować pomoc poleceń.', + 'Connected': 'Połączono', + 'Connected to': 'Połączono z', + 'Connection mode': 'Tryb połączenia', + 'Console output appears here': 'Tutaj pojawia się wynik konsoli', + 'Could not compare versions': 'Nie udało się porównać wersji', + 'Cross-ACE switching': 'Przełączanie między ACE', + 'Custom material': 'Własny materiał', + 'Dashboard sections': 'Sekcje panelu', + 'Delete': 'Usuń', + 'Delete spool?': 'Usunąć szpulę?', + 'Density (g/cm³)': 'Gęstość (g/cm³)', + 'Devices': 'Urządzenia', + 'Diameter (mm)': 'Średnica (mm)', + 'Dismiss': 'Odrzuć', + 'Downloading update': 'Pobieranie aktualizacji', + 'Drag to rotate, pinch to zoom': 'Przeciągnij, aby obrócić; uszczypnij, aby powiększyć', + 'Dry': 'Osusz', + 'Dry filament': 'Osusz filament', + 'Edit filament': 'Edytuj filament', + 'Edit printer': 'Edytuj drukarkę', + 'Edit spool': 'Edytuj szpulę', + 'Enter the printer IP or Moonraker URL.': 'Wprowadź adres IP drukarki lub URL Moonraker.', + 'Enter your Spoolman server address and Helix will configure the printer for you.': 'Wprowadź adres swojego serwera Spoolman, a Helix skonfiguruje za Ciebie drukarkę.', + 'Est. remaining': 'Pozostało (szac.)', + 'Export failed': 'Eksport nieudany', + 'Export settings': 'Eksportuj ustawienia', + 'Failed': 'Niepowodzenie', + 'Filament': 'Filament', + 'Filament usage': 'Zużycie filamentu', + 'Filter macros…': 'Filtruj makra…', + 'Gross weight (g)': 'Waga brutto (g)', + 'Height diff': 'Różnica wysokości', + 'Height diff is the gap between the highest and lowest measured points.': 'Różnica wysokości to odstęp między najwyższym a najniższym zmierzonym punktem.', + 'Helix needs the camera to scan the QR label on your spool.': 'Helix potrzebuje kamery do skanowania etykiety QR na Twojej szpuli.', + 'Hide': 'Ukryj', + 'Idle': 'Bezczynna', + 'Import': 'Importuj', + 'Import settings': 'Importuj ustawienia', + 'Install over existing app to keep settings.': 'Zainstaluj nad istniejącą aplikację, aby zachować ustawienia.', + 'Label saved to your gallery — print it from there.': 'Etykieta zapisana w galerii — wydrukuj ją stamtąd.', + 'Layer': 'Warstwa', + 'Load': 'Załaduj', + 'Location': 'Lokalizacja', + 'Longest job': 'Najdłuższe zadanie', + 'Longest print': 'Najdłuższy wydruk', + 'Lot number': 'Numer partii', + 'Macro display': 'Wyświetlanie makr', + 'Manual': 'Ręcznie', + 'Material': 'Materiał', + 'Max': 'Maks.', + 'Measure': 'Zmierz', + 'Min': 'Min.', + 'Missing Tailscale URL': 'Brak adresu URL Tailscale', + 'Missing printer URL': 'Brak adresu URL drukarki', + 'Moonraker is configured for Spoolman but the server is not responding. Check that the Spoolman container is running, or point the printer at a different address.': 'Moonraker jest skonfigurowany dla Spoolman, ale serwer nie odpowiada. Sprawdź, czy kontener Spoolman działa, lub wskaż inny adres.', + 'Name is required': 'Nazwa jest wymagana', + 'Net weight (g)': 'Waga netto (g)', + 'New vendor': 'Nowy producent', + 'No ACE hardware detected': 'Nie wykryto sprzętu ACE', + 'No RFID data': 'Brak danych RFID', + 'No URL set': 'Brak ustawionego URL', + 'No active mesh': 'Brak aktywnej siatki', + 'No active mesh or saved profile points were found. Run a calibration to create one.': 'Nie znaleziono aktywnej siatki ani punktów zapisanego profilu. Uruchom kalibrację, aby ją utworzyć.', + 'No bed mesh data': 'Brak danych siatki stołu', + 'No filaments yet — add one first': 'Brak filamentów — najpierw dodaj jakiś', + 'No file': 'Brak pliku', + 'No macros found': 'Nie znaleziono makr', + 'No macros selected in Settings': 'Brak zaznaczonych makr w Ustawieniach', + 'No matches': 'Brak dopasowań', + 'No mesh loaded': 'Brak załadowanej siatki', + 'No spool active': 'Brak aktywnej szpuli', + 'No spool with ID': 'Brak szpuli o ID', + 'No spools in Spoolman yet': 'Nie ma jeszcze szpul w Spoolman', + 'No timelapse videos yet': 'Nie ma jeszcze filmów timelapse', + 'Not a Spoolman QR code — try another label': 'To nie jest kod QR Spoolman — spróbuj innej etykiety', + 'Not configured on this printer': 'Nie skonfigurowano na tej drukarce', + 'Not connected': 'Nie połączono', + 'Nothing new yet': 'Jeszcze nic nowego', + 'Notifications': 'Powiadomienia', + 'Offline': 'Offline', + 'Online': 'Online', + 'Open Settings': 'Otwórz Ustawienia', + 'Open release': 'Otwórz wydanie', + 'Open the latest APK download?': 'Otworzyć pobieranie najnowszego APK?', + 'Open your backup file, copy everything, and paste it here.': 'Otwórz swój plik kopii zapasowej, skopiuj całość i wklej tutaj.', + 'Optional. Only add Tailscale if you already use it for remote access.': 'Opcjonalne. Dodaj Tailscale tylko wtedy, gdy używasz go już do zdalnego dostępu.', + 'Panda Breath': 'Panda Breath', + 'Paused': 'Wstrzymano', + 'Per layer': 'Na warstwę', + 'Photo library permission denied': 'Odmowa dostępu do galerii', + 'Pick a filament first': 'Najpierw wybierz filament', + 'Pick from catalog': 'Wybierz z katalogu', + 'Point at a spool label': 'Skieruj na etykietę szpuli', + 'Points': 'Punkty', + 'Preview only. Load the profile to apply it to the printer.': 'Tylko podgląd. Załaduj profil, aby zastosować go na drukarce.', + 'Previewing': 'Podgląd', + 'Price': 'Cena', + 'Print again': 'Drukuj ponownie', + 'Print cancelled': 'Drukowanie anulowane', + 'Print paused': 'Drukowanie wstrzymane', + 'Print progress (every 10%)': 'Postęp druku (co 10%)', + 'Printer': 'Drukarka', + 'Printer IP or Moonraker URL': 'Adres IP drukarki lub URL Moonraker', + 'Printer IP or Moonraker URL (optional)': 'Adres IP drukarki lub URL Moonraker (opcjonalnie)', + 'Printer URL (LAN)': 'URL drukarki (LAN)', + 'Printer URL (LAN, optional)': 'URL drukarki (LAN, opcjonalnie)', + 'Printer URL (Tailscale)': 'URL drukarki (Tailscale)', + 'Printer URL (Tailscale, optional)': 'URL drukarki (Tailscale, opcjonalnie)', + 'Printer disconnected': 'Drukarka rozłączona', + 'Printer error': 'Błąd drukarki', + 'Printer name': 'Nazwa drukarki', + 'Printer now reports filament usage to this Spoolman server.': 'Drukarka zgłasza teraz zużycie filamentu do tego serwera Spoolman.', + 'Printers, connection and notification settings were imported. MakerWorld needs a fresh login.': 'Zaimportowano ustawienia drukarek, połączenia i powiadomień. MakerWorld wymaga ponownego logowania.', + 'Printing': 'Drukowanie', + 'Put the whole spool on a scale — remaining filament is computed from the empty-spool weight.': 'Połóż całą szpulę na wadze — pozostały filament zostanie obliczony na podstawie wagi pustej szpuli.', + 'Ready': 'Gotowa', + 'Ready to print': 'Gotowa do druku', + 'Remaining': 'Pozostało', + 'Remaining (g)': 'Pozostało (g)', + 'Remove printer?': 'Usunąć drukarkę?', + 'Run': 'Uruchom', + 'Run ACE macro?': 'Uruchomić makro ACE?', + 'Run bed mesh?': 'Uruchomić siatkę stołu?', + 'Save': 'Zapisz', + 'Save PNG': 'Zapisz PNG', + 'Save printer': 'Zapisz drukarkę', + 'Save your printers and settings to a file, or restore them after a reinstall.': 'Zapisz swoje drukarki i ustawienia do pliku albo przywróć je po ponownej instalacji.', + 'Saved profiles': 'Zapisane profile', + 'Saving...': 'Zapisywanie...', + 'Scan': 'Skanuj', + 'Search 7000+ commercial filaments…': 'Szukaj wśród 7000+ komercyjnych filamentów…', + 'Search…': 'Szukaj…', + 'Select all': 'Zaznacz wszystko', + 'Selected only': 'Tylko zaznaczone', + 'Select…': 'Wybierz…', + 'Set active spool?': 'Ustawić aktywną szpulę?', + 'Settings applied. Connection will use the new URLs.': 'Ustawienia zastosowane. Połączenie będzie używać nowych adresów URL.', + 'Settings restored': 'Ustawienia przywrócone', + 'Share / Print': 'Udostępnij / Drukuj', + 'Show active': 'Pokaż aktywne', + 'Show all': 'Pokaż wszystkie', + 'Slicer remaining': 'Pozostało (slicer)', + 'Spool label': 'Etykieta szpuli', + 'Spoolman not configured': 'Spoolman nie skonfigurowany', + 'Spoolman server URL': 'URL serwera Spoolman', + 'Spoolman server unreachable': 'Serwer Spoolman nieosiągalny', + 'Start using Helix': 'Zacznij korzystać z Helix', + 'Success': 'Sukces', + 'Support Helix': 'Wesprzyj Helix', + 'Surface': 'Powierzchnia', + 'Tailscale URL': 'URL Tailscale', + 'Tailscale URL (optional)': 'URL Tailscale (opcjonalnie)', + 'Tailscale only never falls back to Wi-Fi. Auto tries LAN first.': 'Tylko Tailscale nigdy nie przechodzi na Wi-Fi. Auto próbuje najpierw LAN.', + 'Tailscale-only mode needs a Tailscale URL.': 'Tryb Tailscale-only wymaga adresu URL Tailscale.', + 'Tap to preview': 'Dotknij, aby zobaczyć podgląd', + 'Temperature units': 'Jednostki temperatury', + 'Temperature warning': 'Ostrzeżenie o temperaturze', + 'The Spoolman address is stored on the printer itself, so every device using it stays in sync.': 'Adres Spoolman jest przechowywany na samej drukarce, więc każde urządzenie z niego korzystające pozostaje zsynchronizowane.', + 'The installer opens when the download finishes.': 'Instalator otworzy się po zakończeniu pobierania.', + 'The printer will clean the nozzle, heat the bed to 55°C, and run bed mesh calibration.': 'Drukarka wyczyści dyszę, nagrzeje stół do 55°C i uruchomi kalibrację siatki stołu.', + 'Timelapse': 'Timelapse', + 'Totally optional. Helix is free and always will be, but if you want to chip in it really helps me out as a college student.': 'Całkowicie opcjonalne. Helix jest darmowy i taki pozostanie, ale jeśli chcesz się dorzucić, bardzo pomaga mi to jako studentowi.', + 'Type /help or /help BED in the console.': 'Wpisz /help lub /help BED w konsoli.', + 'Unarchive': 'Cofnij archiwizację', + 'Update check failed': 'Sprawdzanie aktualizacji nie powiodło się', + 'Vendor': 'Producent', + 'Waiting for printer': 'Oczekiwanie na drukarkę', + 'Weigh the spool': 'Zważ szpulę', + 'Welcome to Helix': 'Witaj w Helix', + 'avg': 'śr.', + 'job': 'zadanie', + 'jobs': 'zadania', + 'leave empty for a full spool': 'puste = pełna szpula', + 'left': 'pozostało', + 'new': 'nowy', + 'not detected': 'nie wykryto', + 'off': 'wyłączony', + 'offline': 'offline', + 'selected': 'zaznaczone', }, es: { - 'Pick from catalog': 'Elegir del catálogo', 'Search 7000+ commercial filaments…': 'Busca entre más de 7000 filamentos…', - 'Weigh the spool': 'Pesar la bobina', 'Gross weight (g)': 'Peso bruto (g)', Measure: 'Medir', Remaining: 'Restante', + 'Pick from catalog': 'Elegir del catálogo', + 'Search 7000+ commercial filaments…': 'Busca entre más de 7000 filamentos…', + 'Weigh the spool': 'Pesar la bobina', + 'Gross weight (g)': 'Peso bruto (g)', + Measure: 'Medir', + Remaining: 'Restante', 'Put the whole spool on a scale — remaining filament is computed from the empty-spool weight.': 'Pon la bobina entera en una báscula — el filamento restante se calcula con el peso de la bobina vacía.', - 'Connected to': 'Conectado a', 'Not configured on this printer': 'No configurado en esta impresora', - 'Spoolman server URL': 'URL del servidor Spoolman', 'Apply to printer': 'Aplicar a la impresora', + 'Connected to': 'Conectado a', + 'Not configured on this printer': 'No configurado en esta impresora', + 'Spoolman server URL': 'URL del servidor Spoolman', + 'Apply to printer': 'Aplicar a la impresora', 'Printer now reports filament usage to this Spoolman server.': 'La impresora ahora reporta el uso de filamento a este servidor Spoolman.', 'The Spoolman address is stored on the printer itself, so every device using it stays in sync.': 'La dirección de Spoolman se guarda en la impresora, así todos los dispositivos quedan sincronizados.', - 'Select…': 'Seleccionar…', 'Search…': 'Buscar…', 'No matches': 'Sin coincidencias', new: 'nuevo', + 'Select…': 'Seleccionar…', + 'Search…': 'Buscar…', + 'No matches': 'Sin coincidencias', + new: 'nuevo', 'Custom material': 'Material personalizado', - Scan: 'Escanear', 'Spool label': 'Etiqueta de bobina', 'Save PNG': 'Guardar PNG', 'Share / Print': 'Compartir / Imprimir', + Scan: 'Escanear', + 'Spool label': 'Etiqueta de bobina', + 'Save PNG': 'Guardar PNG', + 'Share / Print': 'Compartir / Imprimir', 'Label saved to your gallery — print it from there.': 'Etiqueta guardada en la galería — imprímela desde ahí.', - 'Photo library permission denied': 'Permiso de galería denegado', 'Camera access needed': 'Se necesita acceso a la cámara', + 'Photo library permission denied': 'Permiso de galería denegado', + 'Camera access needed': 'Se necesita acceso a la cámara', 'Helix needs the camera to scan the QR label on your spool.': 'Helix necesita la cámara para escanear la etiqueta QR de tu bobina.', - 'Allow camera': 'Permitir cámara', 'Point at a spool label': 'Apunta a una etiqueta de bobina', - 'Not a Spoolman QR code — try another label': 'No es un QR de Spoolman — prueba otra etiqueta', 'No spool with ID': 'No hay bobina con ID', + 'Allow camera': 'Permitir cámara', + 'Point at a spool label': 'Apunta a una etiqueta de bobina', + 'Not a Spoolman QR code — try another label': 'No es un QR de Spoolman — prueba otra etiqueta', + 'No spool with ID': 'No hay bobina con ID', 'Filament usage': 'Uso de filamento', - 'Add spool': 'Añadir bobina', 'Add filament': 'Añadir filamento', 'Edit spool': 'Editar bobina', 'Edit filament': 'Editar filamento', - Filament: 'Filamento', Vendor: 'Fabricante', 'New vendor': 'Nuevo fabricante', Material: 'Material', Color: 'Color', - 'Net weight (g)': 'Peso neto (g)', 'Remaining (g)': 'Restante (g)', 'leave empty for a full spool': 'vacío = bobina llena', - Location: 'Ubicación', 'Lot number': 'Lote', Price: 'Precio', 'Diameter (mm)': 'Diámetro (mm)', 'Density (g/cm³)': 'Densidad (g/cm³)', - Archive: 'Archivar', Unarchive: 'Desarchivar', Delete: 'Eliminar', 'Delete spool?': '¿Eliminar bobina?', Save: 'Guardar', Archived: 'Archivada', - 'Pick a filament first': 'Elige un filamento primero', 'Name is required': 'El nombre es obligatorio', 'No filaments yet — add one first': 'Sin filamentos — añade uno primero', + 'Add spool': 'Añadir bobina', + 'Add filament': 'Añadir filamento', + 'Edit spool': 'Editar bobina', + 'Edit filament': 'Editar filamento', + Filament: 'Filamento', + Vendor: 'Fabricante', + 'New vendor': 'Nuevo fabricante', + Material: 'Material', + Color: 'Color', + 'Net weight (g)': 'Peso neto (g)', + 'Remaining (g)': 'Restante (g)', + 'leave empty for a full spool': 'vacío = bobina llena', + Location: 'Ubicación', + 'Lot number': 'Lote', + Price: 'Precio', + 'Diameter (mm)': 'Diámetro (mm)', + 'Density (g/cm³)': 'Densidad (g/cm³)', + Archive: 'Archivar', + Unarchive: 'Desarchivar', + Delete: 'Eliminar', + 'Delete spool?': '¿Eliminar bobina?', + Save: 'Guardar', + Archived: 'Archivada', + 'Pick a filament first': 'Elige un filamento primero', + 'Name is required': 'El nombre es obligatorio', + 'No filaments yet — add one first': 'Sin filamentos — añade uno primero', 'Enter your Spoolman server address and Helix will configure the printer for you.': 'Introduce la dirección de tu servidor Spoolman y Helix configurará la impresora por ti.', - 'Configure printer': 'Configurar impresora', 'Configuring…': 'Configurando…', + 'Configure printer': 'Configurar impresora', + 'Configuring…': 'Configurando…', 'Moonraker is configured for Spoolman but the server is not responding. Check that the Spoolman container is running, or point the printer at a different address.': 'Moonraker está configurado para Spoolman pero el servidor no responde. Comprueba que el contenedor esté en marcha o apunta a otra dirección.', - Spoolman: 'Spoolman', 'Active spool': 'Bobina activa', 'No spool active': 'Sin bobina activa', - 'Set active spool?': '¿Fijar bobina activa?', left: 'restante', 'No spools in Spoolman yet': 'Aún no hay bobinas en Spoolman', - 'Spoolman not configured': 'Spoolman no configurado', 'Spoolman server unreachable': 'Servidor Spoolman inaccesible', - Home: 'Inicio', Mesh: 'Malla', Macros: 'Macros', Console: 'Consola', Files: 'Archivos', - Settings: 'Ajustes', 'Bed Mesh': 'Malla de cama', History: 'Historial', - Temperatures: 'Temperaturas', Camera: 'Cámara', 'EMERGENCY STOP': 'PARADA DE EMERGENCIA', - Pause: 'Pausar', Resume: 'Reanudar', Cancel: 'Cancelar', 'Home All': 'Home total', - 'Dock Toolhead': 'Aparcar cabezal', Retry: 'Reintentar', 'Printer offline': 'Impresora desconectada', - 'Cancel print?': '¿Cancelar impresión?', 'This stops the current print.': 'Esto detiene la impresión actual.', + Spoolman: 'Spoolman', + 'Active spool': 'Bobina activa', + 'No spool active': 'Sin bobina activa', + 'Set active spool?': '¿Fijar bobina activa?', + left: 'restante', + 'No spools in Spoolman yet': 'Aún no hay bobinas en Spoolman', + 'Spoolman not configured': 'Spoolman no configurado', + 'Spoolman server unreachable': 'Servidor Spoolman inaccesible', + Home: 'Inicio', + Mesh: 'Malla', + Macros: 'Macros', + Console: 'Consola', + Files: 'Archivos', + Settings: 'Ajustes', + 'Bed Mesh': 'Malla de cama', + History: 'Historial', + Temperatures: 'Temperaturas', + Camera: 'Cámara', + 'EMERGENCY STOP': 'PARADA DE EMERGENCIA', + Pause: 'Pausar', + Resume: 'Reanudar', + Cancel: 'Cancelar', + 'Home All': 'Home total', + 'Dock Toolhead': 'Aparcar cabezal', + Retry: 'Reintentar', + 'Printer offline': 'Impresora desconectada', + 'Cancel print?': '¿Cancelar impresión?', + 'This stops the current print.': 'Esto detiene la impresión actual.', 'Emergency stop?': '¿Parada de emergencia?', 'Halts the printer immediately. Requires a firmware restart before printing again.': 'Detiene la impresora de inmediato. Requiere reiniciar el firmware antes de volver a imprimir.', - "Don't ask again": 'No volver a preguntar', 'Stop now': 'Detener ahora', + "Don't ask again": 'No volver a preguntar', + 'Stop now': 'Detener ahora', 'Confirm emergency stop': 'Confirmar parada de emergencia', - No: 'No', 'Yes, cancel': 'Sí, cancelar', Error: 'Error', - Profile: 'Perfil', Range: 'Rango', Min: 'Mín', Max: 'Máx', 'Saved profiles': 'Perfiles guardados', - Load: 'Cargar', 'Drag to rotate · pinch to zoom': 'Arrastra para rotar · pellizca para zoom', - 'No bed mesh data': 'Sin datos de malla', active: 'activo', 'Profile (saved)': 'Perfil (guardado)', + No: 'No', + 'Yes, cancel': 'Sí, cancelar', + Error: 'Error', + Profile: 'Perfil', + Range: 'Rango', + Min: 'Mín', + Max: 'Máx', + 'Saved profiles': 'Perfiles guardados', + Load: 'Cargar', + 'Drag to rotate · pinch to zoom': 'Arrastra para rotar · pellizca para zoom', + 'No bed mesh data': 'Sin datos de malla', + active: 'activo', + 'Profile (saved)': 'Perfil (guardado)', 'No mesh active — showing saved profile points. Load it to apply.': 'Sin malla activa — mostrando perfil guardado. Cárgalo para aplicar.', - 'Filter macros…': 'Filtrar macros…', 'No macros found': 'No hay macros', 'Not connected': 'Sin conexión', - 'Print Control': 'Control de impresión', 'Tool Change': 'Cambio de herramienta', - 'ACE & Filament': 'ACE y filamento', Calibration: 'Calibración', Cleaning: 'Limpieza', - 'Homing & Motion': 'Home y movimiento', Other: 'Otros', - 'Send G-code…': 'Enviar G-code…', 'Printer command…': 'Comando de impresora…', Send: 'Enviar', Enter: 'Entrar', Clear: 'Borrar', + 'Filter macros…': 'Filtrar macros…', + 'No macros found': 'No hay macros', + 'Not connected': 'Sin conexión', + 'Print Control': 'Control de impresión', + 'Tool Change': 'Cambio de herramienta', + 'ACE & Filament': 'ACE y filamento', + Calibration: 'Calibración', + Cleaning: 'Limpieza', + 'Homing & Motion': 'Home y movimiento', + Other: 'Otros', + 'Send G-code…': 'Enviar G-code…', + 'Printer command…': 'Comando de impresora…', + Send: 'Enviar', + Enter: 'Entrar', + Clear: 'Borrar', 'Console output appears here': 'La salida de la consola aparece aquí', 'Printer commands': 'Comandos de impresora', 'Type /help or /help BED in the console.': 'Escribe /help o /help BED en la consola.', 'Search commands…': 'Buscar comandos…', 'No matching commands': 'Sin comandos coincidentes', 'Connect to a ready printer to load command help.': 'Conéctate a una impresora lista para cargar la ayuda de comandos.', - 'Start print?': '¿Iniciar impresión?', Print: 'Imprimir', 'Printer busy': 'Impresora ocupada', - 'A print is already in progress.': 'Ya hay una impresión en curso.', 'Print started': 'Impresión iniciada', - 'No G-code files on printer': 'No hay archivos G-code', 'Load more': 'Cargar más', - 'Printer Stats': 'Estadísticas', 'Total print jobs': 'Trabajos totales', job: 'trabajo', jobs: 'trabajos', 'Longest job': 'Trabajo más largo', - 'Total time': 'Tiempo total', 'Total print time': 'Tiempo de impresión', 'Filament used': 'Filamento usado', - 'Longest print': 'Impresión más larga', 'No print history': 'Sin historial', avg: 'prom', - Lane: 'Carril', Unload: 'Descargar', Dryer: 'Secador', Start: 'Iniciar', Stop: 'Parar', - 'Cross-ACE switching': 'Cambio entre ACE', 'No RFID data': 'Sin datos RFID', off: 'apagado', - Connection: 'Conexión', 'Reconnect now': 'Reconectar ahora', 'ACE units': 'Unidades ACE', - 'Notify on': 'Notificar en', 'Print complete': 'Impresión completa', 'Print failed': 'Impresión fallida', - 'Filament runout': 'Fin de filamento', 'Filament swap complete': 'Cambio de filamento completo', - 'Send test notification': 'Enviar notificación de prueba', 'Save & Apply': 'Guardar y aplicar', - 'Macro display': 'Mostrar macros', 'Show all': 'Mostrar todo', 'Selected only': 'Solo seleccionadas', - selected: 'seleccionadas', 'Select all': 'Seleccionar todo', + 'Start print?': '¿Iniciar impresión?', + Print: 'Imprimir', + 'Printer busy': 'Impresora ocupada', + 'A print is already in progress.': 'Ya hay una impresión en curso.', + 'Print started': 'Impresión iniciada', + 'No G-code files on printer': 'No hay archivos G-code', + 'Load more': 'Cargar más', + 'Printer Stats': 'Estadísticas', + 'Total print jobs': 'Trabajos totales', + job: 'trabajo', + jobs: 'trabajos', + 'Longest job': 'Trabajo más largo', + 'Total time': 'Tiempo total', + 'Total print time': 'Tiempo de impresión', + 'Filament used': 'Filamento usado', + 'Longest print': 'Impresión más larga', + 'No print history': 'Sin historial', + avg: 'prom', + Lane: 'Carril', + Unload: 'Descargar', + Dryer: 'Secador', + Start: 'Iniciar', + Stop: 'Parar', + 'Cross-ACE switching': 'Cambio entre ACE', + 'No RFID data': 'Sin datos RFID', + off: 'apagado', + Connection: 'Conexión', + 'Reconnect now': 'Reconectar ahora', + 'ACE units': 'Unidades ACE', + 'Notify on': 'Notificar en', + 'Print complete': 'Impresión completa', + 'Print failed': 'Impresión fallida', + 'Filament runout': 'Fin de filamento', + 'Filament swap complete': 'Cambio de filamento completo', + 'Send test notification': 'Enviar notificación de prueba', + 'Save & Apply': 'Guardar y aplicar', + 'Macro display': 'Mostrar macros', + 'Show all': 'Mostrar todo', + 'Selected only': 'Solo seleccionadas', + selected: 'seleccionadas', + 'Select all': 'Seleccionar todo', 'Connect to a printer to choose macros.': 'Conéctate a una impresora para elegir macros.', 'No macros selected in Settings': 'No hay macros seleccionadas en Ajustes', - 'Check for updates': 'Buscar actualizaciones', 'Checking for updates...': 'Buscando actualizaciones...', - 'Up to date': 'Actualizado', "What's changed": 'Novedades', 'Update available': 'Actualización disponible', + 'Check for updates': 'Buscar actualizaciones', + 'Checking for updates...': 'Buscando actualizaciones...', + 'Up to date': 'Actualizado', + "What's changed": 'Novedades', + 'Update available': 'Actualización disponible', 'Latest APK available': 'APK más reciente disponible', 'Open the latest APK download?': '¿Abrir la descarga del APK más reciente?', - 'Not now': 'Ahora no', 'Download APK': 'Descargar APK', - 'Downloading update...': 'Descargando actualización...', 'Update download failed': 'No se pudo descargar la actualización', + 'Not now': 'Ahora no', + 'Download APK': 'Descargar APK', + 'Downloading update...': 'Descargando actualización...', + 'Update download failed': 'No se pudo descargar la actualización', 'Open in browser': 'Abrir en navegador', - OK: 'Aceptar', 'Installed build': 'Versión instalada', + OK: 'Aceptar', + 'Installed build': 'Versión instalada', 'Install over existing app to keep settings.': 'Instala encima de la app actual para conservar los ajustes.', 'Update check failed': 'No se pudo buscar actualizaciones', - Saved: 'Guardado', Theme: 'Tema', 'Accent color': 'Color de acento', Language: 'Idioma', - About: 'Acerca de', 'Report a bug': 'Reportar un error', 'GitHub — FatBoy721': 'GitHub — FatBoy721', + Saved: 'Guardado', + Theme: 'Tema', + 'Accent color': 'Color de acento', + Language: 'Idioma', + About: 'Acerca de', + 'Report a bug': 'Reportar un error', + 'GitHub — FatBoy721': 'GitHub — FatBoy721', 'Settings applied. Connection will use the new URLs.': 'Ajustes aplicados. La conexión usará las nuevas URL.', - 'Slicer remaining': 'Restante (slicer)', 'Est. remaining': 'Restante (real)', - 'Finishes at': 'Termina a las', 'Per layer': 'Por capa', - Timelapse: 'Timelapse', Download: 'Descargar', 'No timelapse videos yet': 'Aún no hay vídeos timelapse', - Controls: 'Controles', Bed: 'Cama', 'Part fan': 'Ventilador de pieza', 'Cavity fan': 'Ventilador de cámara', - Purifier: 'Purificador', Set: 'Fijar', Off: 'Apagado', On: 'Encendido', + 'Slicer remaining': 'Restante (slicer)', + 'Est. remaining': 'Restante (real)', + 'Finishes at': 'Termina a las', + 'Per layer': 'Por capa', + Timelapse: 'Timelapse', + Download: 'Descargar', + 'No timelapse videos yet': 'Aún no hay vídeos timelapse', + Controls: 'Controles', + Bed: 'Cama', + 'Part fan': 'Ventilador de pieza', + 'Cavity fan': 'Ventilador de cámara', + Purifier: 'Purificador', + Set: 'Fijar', + Off: 'Apagado', + On: 'Encendido', 'Printer error': 'Error de impresora', - Printers: 'Impresoras', 'Add printer': 'Añadir impresora', 'Remove printer?': '¿Eliminar impresora?', - Remove: 'Eliminar', Name: 'Nombre', 'Dashboard sections': 'Secciones del panel', - Progress: 'Progreso', 'Quick actions': 'Acciones rápidas', 'Emergency stop': 'Parada de emergencia', - 'Home & Dock': 'Home y aparcar', 'Last print': 'Última impresión', + Printers: 'Impresoras', + 'Add printer': 'Añadir impresora', + 'Remove printer?': '¿Eliminar impresora?', + Remove: 'Eliminar', + Name: 'Nombre', + 'Dashboard sections': 'Secciones del panel', + Progress: 'Progreso', + 'Quick actions': 'Acciones rápidas', + 'Emergency stop': 'Parada de emergencia', + 'Home & Dock': 'Home y aparcar', + 'Last print': 'Última impresión', 'No ACE hardware detected': 'No se detecta hardware ACE', "The printer reports 0 connected ACE units. If you haven't installed multiACE yet, that's expected — this tab lights up once a unit is wired in.": 'La impresora reporta 0 unidades ACE conectadas. Si aún no instalaste multiACE, es normal — esta pestaña se activa al conectar una unidad.', + 'A mobile Fluidd-style controller for PAXX/Moonraker so you do not have to open the printer web UI every time.': 'Un controlador móvil estilo Fluidd para PAXX/Moonraker para que no tengas que abrir la interfaz web de la impresora cada vez.', + 'Active': 'Activo', + 'Auto': 'Auto', + 'Backup': 'Copia de seguridad', + 'Busy': 'Ocupado', + 'Calibrate': 'Calibrar', + 'Camera stream (path or full URL)': 'Transmisión de cámara (ruta o URL completa)', + 'Cancelled': 'Cancelada', + 'Checking': 'Comprobando', + 'Checking connection': 'Comprobando conexión', + 'Choose a drying preset.': 'Elige un preset de secado.', + 'Connected': 'Conectado', + 'Connection mode': 'Modo de conexión', + 'Could not compare versions': 'No se pudieron comparar versiones', + 'Devices': 'Dispositivos', + 'Dismiss': 'Descartar', + 'Downloading update': 'Descargando actualización', + 'Drag to rotate, pinch to zoom': 'Arrastra para rotar, pellizca para zoom', + 'Dry': 'Secar', + 'Dry filament': 'Secar filamento', + 'Edit printer': 'Editar impresora', + 'Enter the printer IP or Moonraker URL.': 'Introduce la IP de la impresora o la URL de Moonraker.', + 'Export failed': 'Exportación fallida', + 'Export settings': 'Exportar ajustes', + 'Failed': 'Fallida', + 'Height diff': 'Diferencia de altura', + 'Height diff is the gap between the highest and lowest measured points.': 'La diferencia de altura es el hueco entre los puntos más alto y más bajo medidos.', + 'Hide': 'Ocultar', + 'Idle': 'Inactivo', + 'Import': 'Importar', + 'Import settings': 'Importar ajustes', + 'Layer': 'Capa', + 'Manual': 'Manual', + 'Missing Tailscale URL': 'Falta la URL de Tailscale', + 'Missing printer URL': 'Falta la URL de la impresora', + 'No URL set': 'Sin URL configurada', + 'No active mesh': 'Sin malla activa', + 'No active mesh or saved profile points were found. Run a calibration to create one.': 'No se encontró malla activa ni puntos de perfil guardados. Ejecuta una calibración para crear una.', + 'No file': 'Sin archivo', + 'No mesh loaded': 'Sin malla cargada', + 'Nothing new yet': 'Nada nuevo todavía', + 'Notifications': 'Notificaciones', + 'Offline': 'Desconectado', + 'Online': 'Conectado', + 'Open Settings': 'Abrir ajustes', + 'Open release': 'Abrir release', + 'Open your backup file, copy everything, and paste it here.': 'Abre tu archivo de copia, copia todo y pégalo aquí.', + 'Optional. Only add Tailscale if you already use it for remote access.': 'Opcional. Añade Tailscale solo si ya lo usas para acceso remoto.', + 'Panda Breath': 'Panda Breath', + 'Paused': 'Pausada', + 'Points': 'Puntos', + 'Preview only. Load the profile to apply it to the printer.': 'Solo vista previa. Carga el perfil para aplicarlo a la impresora.', + 'Previewing': 'Vista previa', + 'Print again': 'Imprimir de nuevo', + 'Print cancelled': 'Impresión cancelada', + 'Print paused': 'Impresión pausada', + 'Print progress (every 10%)': 'Progreso de impresión (cada 10%)', + 'Printer': 'Impresora', + 'Printer IP or Moonraker URL': 'IP de impresora o URL de Moonraker', + 'Printer IP or Moonraker URL (optional)': 'IP de impresora o URL de Moonraker (opcional)', + 'Printer URL (LAN)': 'URL de impresora (LAN)', + 'Printer URL (LAN, optional)': 'URL de impresora (LAN, opcional)', + 'Printer URL (Tailscale)': 'URL de impresora (Tailscale)', + 'Printer URL (Tailscale, optional)': 'URL de impresora (Tailscale, opcional)', + 'Printer disconnected': 'Impresora desconectada', + 'Printer name': 'Nombre de impresora', + 'Printers, connection and notification settings were imported. MakerWorld needs a fresh login.': 'Se importaron impresoras, conexión y ajustes de notificaciones. MakerWorld necesita un nuevo inicio de sesión.', + 'Printing': 'Imprimiendo', + 'Ready': 'Listo', + 'Ready to print': 'Listo para imprimir', + 'Run': 'Ejecutar', + 'Run ACE macro?': '¿Ejecutar macro ACE?', + 'Run bed mesh?': '¿Ejecutar malla de cama?', + 'Save printer': 'Guardar impresora', + 'Save your printers and settings to a file, or restore them after a reinstall.': 'Guarda tus impresoras y ajustes en un archivo, o restáuralos tras una reinstalación.', + 'Saving...': 'Guardando...', + 'Settings restored': 'Ajustes restaurados', + 'Show active': 'Mostrar activa', + 'Start using Helix': 'Empezar a usar Helix', + 'Success': 'Éxito', + 'Support Helix': 'Apoya a Helix', + 'Surface': 'Superficie', + 'Tailscale URL': 'URL de Tailscale', + 'Tailscale URL (optional)': 'URL de Tailscale (opcional)', + 'Tailscale only never falls back to Wi-Fi. Auto tries LAN first.': 'Solo Tailscale nunca pasa a Wi-Fi. Auto prueba primero la LAN.', + 'Tailscale-only mode needs a Tailscale URL.': 'El modo solo Tailscale necesita una URL de Tailscale.', + 'Tap to preview': 'Toca para vista previa', + 'Temperature units': 'Unidades de temperatura', + 'Temperature warning': 'Aviso de temperatura', + 'The installer opens when the download finishes.': 'El instalador se abre al terminar la descarga.', + 'The printer will clean the nozzle, heat the bed to 55°C, and run bed mesh calibration.': 'La impresora limpiará la boquilla, calentará la cama a 55°C y ejecutará la calibración de malla de cama.', + 'Totally optional. Helix is free and always will be, but if you want to chip in it really helps me out as a college student.': 'Totalmente opcional. Helix es gratis y siempre lo será, pero si quieres colaborar me ayudas mucho como estudiante universitario.', + 'Waiting for printer': 'Esperando a la impresora', + 'Welcome to Helix': 'Bienvenido a Helix', + 'not detected': 'no detectado', + 'offline': 'desconectado', }, de: { - 'Pick from catalog': 'Aus Katalog wählen', 'Search 7000+ commercial filaments…': '7000+ Filamente durchsuchen…', - 'Weigh the spool': 'Spule wiegen', 'Gross weight (g)': 'Bruttogewicht (g)', Measure: 'Messen', Remaining: 'Restmenge', + 'Pick from catalog': 'Aus Katalog wählen', + 'Search 7000+ commercial filaments…': '7000+ Filamente durchsuchen…', + 'Weigh the spool': 'Spule wiegen', + 'Gross weight (g)': 'Bruttogewicht (g)', + Measure: 'Messen', + Remaining: 'Restmenge', 'Put the whole spool on a scale — remaining filament is computed from the empty-spool weight.': 'Ganze Spule auf die Waage — Restfilament wird über das Leerspulengewicht berechnet.', - 'Connected to': 'Verbunden mit', 'Not configured on this printer': 'Auf diesem Drucker nicht konfiguriert', - 'Spoolman server URL': 'Spoolman-Server-URL', 'Apply to printer': 'Auf Drucker anwenden', + 'Connected to': 'Verbunden mit', + 'Not configured on this printer': 'Auf diesem Drucker nicht konfiguriert', + 'Spoolman server URL': 'Spoolman-Server-URL', + 'Apply to printer': 'Auf Drucker anwenden', 'Printer now reports filament usage to this Spoolman server.': 'Der Drucker meldet den Filamentverbrauch jetzt an diesen Spoolman-Server.', 'The Spoolman address is stored on the printer itself, so every device using it stays in sync.': 'Die Spoolman-Adresse wird auf dem Drucker gespeichert — alle Geräte bleiben synchron.', - 'Select…': 'Auswählen…', 'Search…': 'Suchen…', 'No matches': 'Keine Treffer', new: 'neu', + 'Select…': 'Auswählen…', + 'Search…': 'Suchen…', + 'No matches': 'Keine Treffer', + new: 'neu', 'Custom material': 'Eigenes Material', - Scan: 'Scannen', 'Spool label': 'Spulen-Etikett', 'Save PNG': 'PNG speichern', 'Share / Print': 'Teilen / Drucken', + Scan: 'Scannen', + 'Spool label': 'Spulen-Etikett', + 'Save PNG': 'PNG speichern', + 'Share / Print': 'Teilen / Drucken', 'Label saved to your gallery — print it from there.': 'Etikett in der Galerie gespeichert — von dort drucken.', - 'Photo library permission denied': 'Galerie-Berechtigung verweigert', 'Camera access needed': 'Kamerazugriff erforderlich', + 'Photo library permission denied': 'Galerie-Berechtigung verweigert', + 'Camera access needed': 'Kamerazugriff erforderlich', 'Helix needs the camera to scan the QR label on your spool.': 'Helix braucht die Kamera, um das QR-Etikett der Spule zu scannen.', - 'Allow camera': 'Kamera erlauben', 'Point at a spool label': 'Auf ein Spulen-Etikett richten', - 'Not a Spoolman QR code — try another label': 'Kein Spoolman-QR-Code — anderes Etikett versuchen', 'No spool with ID': 'Keine Spule mit ID', + 'Allow camera': 'Kamera erlauben', + 'Point at a spool label': 'Auf ein Spulen-Etikett richten', + 'Not a Spoolman QR code — try another label': 'Kein Spoolman-QR-Code — anderes Etikett versuchen', + 'No spool with ID': 'Keine Spule mit ID', 'Filament usage': 'Filamentverbrauch', - 'Add spool': 'Spule hinzufügen', 'Add filament': 'Filament hinzufügen', 'Edit spool': 'Spule bearbeiten', 'Edit filament': 'Filament bearbeiten', - Filament: 'Filament', Vendor: 'Hersteller', 'New vendor': 'Neuer Hersteller', Material: 'Material', Color: 'Farbe', - 'Net weight (g)': 'Nettogewicht (g)', 'Remaining (g)': 'Restmenge (g)', 'leave empty for a full spool': 'leer = volle Spule', - Location: 'Ort', 'Lot number': 'Charge', Price: 'Preis', 'Diameter (mm)': 'Durchmesser (mm)', 'Density (g/cm³)': 'Dichte (g/cm³)', - Archive: 'Archivieren', Unarchive: 'Wiederherstellen', Delete: 'Löschen', 'Delete spool?': 'Spule löschen?', Save: 'Speichern', Archived: 'Archiviert', - 'Pick a filament first': 'Zuerst ein Filament wählen', 'Name is required': 'Name ist erforderlich', 'No filaments yet — add one first': 'Noch keine Filamente — zuerst eines anlegen', + 'Add spool': 'Spule hinzufügen', + 'Add filament': 'Filament hinzufügen', + 'Edit spool': 'Spule bearbeiten', + 'Edit filament': 'Filament bearbeiten', + Filament: 'Filament', + Vendor: 'Hersteller', + 'New vendor': 'Neuer Hersteller', + Material: 'Material', + Color: 'Farbe', + 'Net weight (g)': 'Nettogewicht (g)', + 'Remaining (g)': 'Restmenge (g)', + 'leave empty for a full spool': 'leer = volle Spule', + Location: 'Ort', + 'Lot number': 'Charge', + Price: 'Preis', + 'Diameter (mm)': 'Durchmesser (mm)', + 'Density (g/cm³)': 'Dichte (g/cm³)', + Archive: 'Archivieren', + Unarchive: 'Wiederherstellen', + Delete: 'Löschen', + 'Delete spool?': 'Spule löschen?', + Save: 'Speichern', + Archived: 'Archiviert', + 'Pick a filament first': 'Zuerst ein Filament wählen', + 'Name is required': 'Name ist erforderlich', + 'No filaments yet — add one first': 'Noch keine Filamente — zuerst eines anlegen', 'Enter your Spoolman server address and Helix will configure the printer for you.': 'Spoolman-Serveradresse eingeben — Helix konfiguriert den Drucker automatisch.', - 'Configure printer': 'Drucker konfigurieren', 'Configuring…': 'Konfiguriere…', + 'Configure printer': 'Drucker konfigurieren', + 'Configuring…': 'Konfiguriere…', 'Moonraker is configured for Spoolman but the server is not responding. Check that the Spoolman container is running, or point the printer at a different address.': 'Moonraker ist für Spoolman konfiguriert, aber der Server antwortet nicht. Container prüfen oder andere Adresse eintragen.', - Spoolman: 'Spoolman', 'Active spool': 'Aktive Spule', 'No spool active': 'Keine Spule aktiv', - 'Set active spool?': 'Aktive Spule setzen?', left: 'übrig', 'No spools in Spoolman yet': 'Noch keine Spulen in Spoolman', - 'Spoolman not configured': 'Spoolman nicht konfiguriert', 'Spoolman server unreachable': 'Spoolman-Server nicht erreichbar', - Home: 'Start', Mesh: 'Netz', Macros: 'Makros', Console: 'Konsole', Files: 'Dateien', - Settings: 'Einstellungen', 'Bed Mesh': 'Bett-Netz', History: 'Verlauf', - Temperatures: 'Temperaturen', Camera: 'Kamera', 'EMERGENCY STOP': 'NOT-HALT', - Pause: 'Pause', Resume: 'Fortsetzen', Cancel: 'Abbrechen', 'Home All': 'Alles homen', - 'Dock Toolhead': 'Werkzeugkopf parken', Retry: 'Erneut', 'Printer offline': 'Drucker offline', - 'Cancel print?': 'Druck abbrechen?', 'This stops the current print.': 'Dies stoppt den aktuellen Druck.', + Spoolman: 'Spoolman', + 'Active spool': 'Aktive Spule', + 'No spool active': 'Keine Spule aktiv', + 'Set active spool?': 'Aktive Spule setzen?', + left: 'übrig', + 'No spools in Spoolman yet': 'Noch keine Spulen in Spoolman', + 'Spoolman not configured': 'Spoolman nicht konfiguriert', + 'Spoolman server unreachable': 'Spoolman-Server nicht erreichbar', + Home: 'Start', + Mesh: 'Netz', + Macros: 'Makros', + Console: 'Konsole', + Files: 'Dateien', + Settings: 'Einstellungen', + 'Bed Mesh': 'Bett-Netz', + History: 'Verlauf', + Temperatures: 'Temperaturen', + Camera: 'Kamera', + 'EMERGENCY STOP': 'NOT-HALT', + Pause: 'Pause', + Resume: 'Fortsetzen', + Cancel: 'Abbrechen', + 'Home All': 'Alles homen', + 'Dock Toolhead': 'Werkzeugkopf parken', + Retry: 'Erneut', + 'Printer offline': 'Drucker offline', + 'Cancel print?': 'Druck abbrechen?', + 'This stops the current print.': 'Dies stoppt den aktuellen Druck.', 'Emergency stop?': 'Not-Halt?', 'Halts the printer immediately. Requires a firmware restart before printing again.': 'Stoppt den Drucker sofort. Vor dem nächsten Druck ist ein Firmware-Neustart nötig.', - "Don't ask again": 'Nicht mehr fragen', 'Stop now': 'Jetzt stoppen', + "Don't ask again": 'Nicht mehr fragen', + 'Stop now': 'Jetzt stoppen', 'Confirm emergency stop': 'Not-Halt bestätigen', - No: 'Nein', 'Yes, cancel': 'Ja, abbrechen', Error: 'Fehler', - Profile: 'Profil', Range: 'Bereich', Min: 'Min', Max: 'Max', 'Saved profiles': 'Gespeicherte Profile', - Load: 'Laden', 'Drag to rotate · pinch to zoom': 'Ziehen zum Drehen · Kneifen zum Zoomen', - 'No bed mesh data': 'Keine Netzdaten', active: 'aktiv', 'Profile (saved)': 'Profil (gespeichert)', + No: 'Nein', + 'Yes, cancel': 'Ja, abbrechen', + Error: 'Fehler', + Profile: 'Profil', + Range: 'Bereich', + Min: 'Min', + Max: 'Max', + 'Saved profiles': 'Gespeicherte Profile', + Load: 'Laden', + 'Drag to rotate · pinch to zoom': 'Ziehen zum Drehen · Kneifen zum Zoomen', + 'No bed mesh data': 'Keine Netzdaten', + active: 'aktiv', + 'Profile (saved)': 'Profil (gespeichert)', 'No mesh active — showing saved profile points. Load it to apply.': 'Kein Netz aktiv — gespeichertes Profil wird angezeigt. Zum Anwenden laden.', - 'Filter macros…': 'Makros filtern…', 'No macros found': 'Keine Makros gefunden', 'Not connected': 'Nicht verbunden', - 'Print Control': 'Drucksteuerung', 'Tool Change': 'Werkzeugwechsel', - 'ACE & Filament': 'ACE & Filament', Calibration: 'Kalibrierung', Cleaning: 'Reinigung', - 'Homing & Motion': 'Homing & Bewegung', Other: 'Sonstiges', - 'Send G-code…': 'G-Code senden…', 'Printer command…': 'Druckerbefehl…', Send: 'Senden', Enter: 'Eingeben', Clear: 'Leeren', + 'Filter macros…': 'Makros filtern…', + 'No macros found': 'Keine Makros gefunden', + 'Not connected': 'Nicht verbunden', + 'Print Control': 'Drucksteuerung', + 'Tool Change': 'Werkzeugwechsel', + 'ACE & Filament': 'ACE & Filament', + Calibration: 'Kalibrierung', + Cleaning: 'Reinigung', + 'Homing & Motion': 'Homing & Bewegung', + Other: 'Sonstiges', + 'Send G-code…': 'G-Code senden…', + 'Printer command…': 'Druckerbefehl…', + Send: 'Senden', + Enter: 'Eingeben', + Clear: 'Leeren', 'Console output appears here': 'Konsolenausgabe erscheint hier', 'Printer commands': 'Druckerbefehle', 'Type /help or /help BED in the console.': 'Gib /help oder /help BED in der Konsole ein.', 'Search commands…': 'Befehle suchen…', 'No matching commands': 'Keine passenden Befehle', 'Connect to a ready printer to load command help.': 'Mit einem bereiten Drucker verbinden, um Befehlshilfe zu laden.', - 'Start print?': 'Druck starten?', Print: 'Drucken', 'Printer busy': 'Drucker beschäftigt', - 'A print is already in progress.': 'Ein Druck läuft bereits.', 'Print started': 'Druck gestartet', - 'No G-code files on printer': 'Keine G-Code-Dateien', 'Load more': 'Mehr laden', - 'Printer Stats': 'Druckerstatistik', 'Total print jobs': 'Druckaufträge gesamt', job: 'Job', jobs: 'Jobs', 'Longest job': 'Längster Auftrag', - 'Total time': 'Gesamtzeit', 'Total print time': 'Druckzeit gesamt', 'Filament used': 'Filament verbraucht', - 'Longest print': 'Längster Druck', 'No print history': 'Kein Verlauf', avg: 'Ø', - Lane: 'Spur', Unload: 'Entladen', Dryer: 'Trockner', Start: 'Start', Stop: 'Stopp', - 'Cross-ACE switching': 'ACE-Wechsel', 'No RFID data': 'Keine RFID-Daten', off: 'aus', - Connection: 'Verbindung', 'Reconnect now': 'Jetzt neu verbinden', 'ACE units': 'ACE-Einheiten', - 'Notify on': 'Benachrichtigen bei', 'Print complete': 'Druck fertig', 'Print failed': 'Druck fehlgeschlagen', - 'Filament runout': 'Filamentende', 'Filament swap complete': 'Filamentwechsel fertig', - 'Send test notification': 'Testbenachrichtigung senden', 'Save & Apply': 'Speichern & Anwenden', - 'Macro display': 'Makroanzeige', 'Show all': 'Alle anzeigen', 'Selected only': 'Nur ausgewählte', - selected: 'ausgewählt', 'Select all': 'Alle auswählen', + 'Start print?': 'Druck starten?', + Print: 'Drucken', + 'Printer busy': 'Drucker beschäftigt', + 'A print is already in progress.': 'Ein Druck läuft bereits.', + 'Print started': 'Druck gestartet', + 'No G-code files on printer': 'Keine G-Code-Dateien', + 'Load more': 'Mehr laden', + 'Printer Stats': 'Druckerstatistik', + 'Total print jobs': 'Druckaufträge gesamt', + job: 'Job', + jobs: 'Jobs', + 'Longest job': 'Längster Auftrag', + 'Total time': 'Gesamtzeit', + 'Total print time': 'Druckzeit gesamt', + 'Filament used': 'Filament verbraucht', + 'Longest print': 'Längster Druck', + 'No print history': 'Kein Verlauf', + avg: 'Ø', + Lane: 'Spur', + Unload: 'Entladen', + Dryer: 'Trockner', + Start: 'Start', + Stop: 'Stopp', + 'Cross-ACE switching': 'ACE-Wechsel', + 'No RFID data': 'Keine RFID-Daten', + off: 'aus', + Connection: 'Verbindung', + 'Reconnect now': 'Jetzt neu verbinden', + 'ACE units': 'ACE-Einheiten', + 'Notify on': 'Benachrichtigen bei', + 'Print complete': 'Druck fertig', + 'Print failed': 'Druck fehlgeschlagen', + 'Filament runout': 'Filamentende', + 'Filament swap complete': 'Filamentwechsel fertig', + 'Send test notification': 'Testbenachrichtigung senden', + 'Save & Apply': 'Speichern & Anwenden', + 'Macro display': 'Makroanzeige', + 'Show all': 'Alle anzeigen', + 'Selected only': 'Nur ausgewählte', + selected: 'ausgewählt', + 'Select all': 'Alle auswählen', 'Connect to a printer to choose macros.': 'Mit einem Drucker verbinden, um Makros auszuwählen.', 'No macros selected in Settings': 'Keine Makros in den Einstellungen ausgewählt', - 'Check for updates': 'Nach Updates suchen', 'Checking for updates...': 'Suche nach Updates...', - 'Up to date': 'Aktuell', "What's changed": 'Was ist neu', 'Update available': 'Update verfügbar', + 'Check for updates': 'Nach Updates suchen', + 'Checking for updates...': 'Suche nach Updates...', + 'Up to date': 'Aktuell', + "What's changed": 'Was ist neu', + 'Update available': 'Update verfügbar', 'Latest APK available': 'Neueste APK verfügbar', 'Open the latest APK download?': 'Neueste APK herunterladen?', - 'Not now': 'Nicht jetzt', 'Download APK': 'APK herunterladen', - 'Downloading update...': 'Update wird heruntergeladen...', 'Update download failed': 'Update-Download fehlgeschlagen', + 'Not now': 'Nicht jetzt', + 'Download APK': 'APK herunterladen', + 'Downloading update...': 'Update wird heruntergeladen...', + 'Update download failed': 'Update-Download fehlgeschlagen', 'Open in browser': 'Im Browser öffnen', - OK: 'OK', 'Installed build': 'Installierter Build', + OK: 'OK', + 'Installed build': 'Installierter Build', 'Install over existing app to keep settings.': 'Über die vorhandene App installieren, um Einstellungen zu behalten.', 'Update check failed': 'Updateprüfung fehlgeschlagen', - Saved: 'Gespeichert', Theme: 'Design', 'Accent color': 'Akzentfarbe', Language: 'Sprache', - About: 'Über', 'Report a bug': 'Fehler melden', 'GitHub — FatBoy721': 'GitHub — FatBoy721', + Saved: 'Gespeichert', + Theme: 'Design', + 'Accent color': 'Akzentfarbe', + Language: 'Sprache', + About: 'Über', + 'Report a bug': 'Fehler melden', + 'GitHub — FatBoy721': 'GitHub — FatBoy721', 'Settings applied. Connection will use the new URLs.': 'Einstellungen übernommen. Verbindung nutzt die neuen URLs.', - 'Slicer remaining': 'Restzeit (Slicer)', 'Est. remaining': 'Restzeit (real)', - 'Finishes at': 'Fertig um', 'Per layer': 'Pro Schicht', - Timelapse: 'Zeitraffer', Download: 'Herunterladen', 'No timelapse videos yet': 'Noch keine Zeitraffer-Videos', - Controls: 'Steuerung', Bed: 'Bett', 'Part fan': 'Bauteillüfter', 'Cavity fan': 'Gehäuselüfter', - Purifier: 'Luftfilter', Set: 'Setzen', Off: 'Aus', On: 'An', + 'Slicer remaining': 'Restzeit (Slicer)', + 'Est. remaining': 'Restzeit (real)', + 'Finishes at': 'Fertig um', + 'Per layer': 'Pro Schicht', + Timelapse: 'Zeitraffer', + Download: 'Herunterladen', + 'No timelapse videos yet': 'Noch keine Zeitraffer-Videos', + Controls: 'Steuerung', + Bed: 'Bett', + 'Part fan': 'Bauteillüfter', + 'Cavity fan': 'Gehäuselüfter', + Purifier: 'Luftfilter', + Set: 'Setzen', + Off: 'Aus', + On: 'An', 'Printer error': 'Druckerfehler', - Printers: 'Drucker', 'Add printer': 'Drucker hinzufügen', 'Remove printer?': 'Drucker entfernen?', - Remove: 'Entfernen', Name: 'Name', 'Dashboard sections': 'Dashboard-Bereiche', - Progress: 'Fortschritt', 'Quick actions': 'Schnellaktionen', 'Emergency stop': 'Not-Halt', - 'Home & Dock': 'Homen & Parken', 'Last print': 'Letzter Druck', + Printers: 'Drucker', + 'Add printer': 'Drucker hinzufügen', + 'Remove printer?': 'Drucker entfernen?', + Remove: 'Entfernen', + Name: 'Name', + 'Dashboard sections': 'Dashboard-Bereiche', + Progress: 'Fortschritt', + 'Quick actions': 'Schnellaktionen', + 'Emergency stop': 'Not-Halt', + 'Home & Dock': 'Homen & Parken', + 'Last print': 'Letzter Druck', 'No ACE hardware detected': 'Keine ACE-Hardware erkannt', "The printer reports 0 connected ACE units. If you haven't installed multiACE yet, that's expected — this tab lights up once a unit is wired in.": 'Der Drucker meldet 0 verbundene ACE-Einheiten. Falls multiACE noch nicht installiert ist, ist das normal — dieser Tab aktiviert sich, sobald eine Einheit angeschlossen ist.', + 'A mobile Fluidd-style controller for PAXX/Moonraker so you do not have to open the printer web UI every time.': 'Ein mobiler Fluidd-artiger Controller für PAXX/Moonraker, damit du nicht jedes Mal die Drucker-WebUI öffnen musst.', + 'Active': 'Aktiv', + 'Auto': 'Auto', + 'Backup': 'Backup', + 'Busy': 'Beschäftigt', + 'Calibrate': 'Kalibrieren', + 'Camera stream (path or full URL)': 'Kamera-Stream (Pfad oder vollständige URL)', + 'Cancelled': 'Abgebrochen', + 'Checking': 'Prüfe', + 'Checking connection': 'Verbindung wird geprüft', + 'Choose a drying preset.': 'Trocknungsvorgabe wählen.', + 'Connected': 'Verbunden', + 'Connection mode': 'Verbindungsmodus', + 'Could not compare versions': 'Versionen konnten nicht verglichen werden', + 'Devices': 'Geräte', + 'Dismiss': 'Verwerfen', + 'Downloading update': 'Update wird heruntergeladen', + 'Drag to rotate, pinch to zoom': 'Ziehen zum Drehen, Kneifen zum Zoomen', + 'Dry': 'Trocknen', + 'Dry filament': 'Filament trocknen', + 'Edit printer': 'Drucker bearbeiten', + 'Enter the printer IP or Moonraker URL.': 'Drucker-IP oder Moonraker-URL eingeben.', + 'Export failed': 'Export fehlgeschlagen', + 'Export settings': 'Einstellungen exportieren', + 'Failed': 'Fehlgeschlagen', + 'Height diff': 'Höhendifferenz', + 'Height diff is the gap between the highest and lowest measured points.': 'Höhendifferenz ist der Abstand zwischen dem höchsten und niedrigsten Messpunkt.', + 'Hide': 'Ausblenden', + 'Idle': 'Leerlauf', + 'Import': 'Importieren', + 'Import settings': 'Einstellungen importieren', + 'Layer': 'Schicht', + 'Manual': 'Manuell', + 'Missing Tailscale URL': 'Tailscale-URL fehlt', + 'Missing printer URL': 'Drucker-URL fehlt', + 'No URL set': 'Keine URL gesetzt', + 'No active mesh': 'Kein aktives Netz', + 'No active mesh or saved profile points were found. Run a calibration to create one.': 'Kein aktives Netz oder gespeicherte Profildaten gefunden. Kalibrierung ausführen, um eines zu erstellen.', + 'No file': 'Keine Datei', + 'No mesh loaded': 'Kein Netz geladen', + 'Nothing new yet': 'Noch nichts Neues', + 'Notifications': 'Benachrichtigungen', + 'Offline': 'Offline', + 'Online': 'Online', + 'Open Settings': 'Einstellungen öffnen', + 'Open release': 'Release öffnen', + 'Open your backup file, copy everything, and paste it here.': 'Backup-Datei öffnen, alles kopieren und hier einfügen.', + 'Optional. Only add Tailscale if you already use it for remote access.': 'Optional. Tailscale nur eintragen, wenn du es bereits für Fernzugriff nutzt.', + 'Panda Breath': 'Panda Breath', + 'Paused': 'Pausiert', + 'Points': 'Punkte', + 'Preview only. Load the profile to apply it to the printer.': 'Nur Vorschau. Profil laden, um es auf den Drucker anzuwenden.', + 'Previewing': 'Vorschau', + 'Print again': 'Erneut drucken', + 'Print cancelled': 'Druck abgebrochen', + 'Print paused': 'Druck pausiert', + 'Print progress (every 10%)': 'Druckfortschritt (alle 10 %)', + 'Printer': 'Drucker', + 'Printer IP or Moonraker URL': 'Drucker-IP oder Moonraker-URL', + 'Printer IP or Moonraker URL (optional)': 'Drucker-IP oder Moonraker-URL (optional)', + 'Printer URL (LAN)': 'Drucker-URL (LAN)', + 'Printer URL (LAN, optional)': 'Drucker-URL (LAN, optional)', + 'Printer URL (Tailscale)': 'Drucker-URL (Tailscale)', + 'Printer URL (Tailscale, optional)': 'Drucker-URL (Tailscale, optional)', + 'Printer disconnected': 'Drucker getrennt', + 'Printer name': 'Druckername', + 'Printers, connection and notification settings were imported. MakerWorld needs a fresh login.': 'Drucker-, Verbindungs- und Benachrichtigungseinstellungen wurden importiert. MakerWorld erfordert eine neue Anmeldung.', + 'Printing': 'Druck läuft', + 'Ready': 'Bereit', + 'Ready to print': 'Druckbereit', + 'Run': 'Ausführen', + 'Run ACE macro?': 'ACE-Makro ausführen?', + 'Run bed mesh?': 'Bett-Netz ausführen?', + 'Save printer': 'Drucker speichern', + 'Save your printers and settings to a file, or restore them after a reinstall.': 'Drucker und Einstellungen in einer Datei speichern oder nach einer Neuinstallation wiederherstellen.', + 'Saving...': 'Speichern...', + 'Settings restored': 'Einstellungen wiederhergestellt', + 'Show active': 'Aktive anzeigen', + 'Start using Helix': 'Helix starten', + 'Success': 'Erfolg', + 'Support Helix': 'Helix unterstützen', + 'Surface': 'Oberfläche', + 'Tailscale URL': 'Tailscale-URL', + 'Tailscale URL (optional)': 'Tailscale-URL (optional)', + 'Tailscale only never falls back to Wi-Fi. Auto tries LAN first.': 'Nur Tailscale fällt nie auf Wi-Fi zurück. Auto versucht zuerst das LAN.', + 'Tailscale-only mode needs a Tailscale URL.': 'Tailscale-only-Modus benötigt eine Tailscale-URL.', + 'Tap to preview': 'Tippen für Vorschau', + 'Temperature units': 'Temperatureinheiten', + 'Temperature warning': 'Temperaturwarnung', + 'The installer opens when the download finishes.': 'Das Installationsprogramm öffnet sich, sobald der Download abgeschlossen ist.', + 'The printer will clean the nozzle, heat the bed to 55°C, and run bed mesh calibration.': 'Der Drucker reinigt die Düse, heizt das Bett auf 55 °C auf und führt die Bett-Netz-Kalibrierung aus.', + 'Totally optional. Helix is free and always will be, but if you want to chip in it really helps me out as a college student.': 'Völlig optional. Helix ist und bleibt kostenlos — wenn du aber etwas beitragen möchtest, hilft das mir als Student wirklich weiter.', + 'Waiting for printer': 'Warten auf Drucker', + 'Welcome to Helix': 'Willkommen bei Helix', + 'not detected': 'nicht erkannt', + 'offline': 'offline', }, fr: { - 'Pick from catalog': 'Choisir dans le catalogue', 'Search 7000+ commercial filaments…': 'Chercher parmi 7000+ filaments…', - 'Weigh the spool': 'Peser la bobine', 'Gross weight (g)': 'Poids brut (g)', Measure: 'Mesurer', Remaining: 'Restant', + 'Pick from catalog': 'Choisir dans le catalogue', + 'Search 7000+ commercial filaments…': 'Chercher parmi 7000+ filaments…', + 'Weigh the spool': 'Peser la bobine', + 'Gross weight (g)': 'Poids brut (g)', + Measure: 'Mesurer', + Remaining: 'Restant', 'Put the whole spool on a scale — remaining filament is computed from the empty-spool weight.': 'Posez la bobine entière sur une balance — le filament restant est calculé via le poids de la bobine vide.', - 'Connected to': 'Connecté à', 'Not configured on this printer': 'Non configuré sur cette imprimante', - 'Spoolman server URL': 'URL du serveur Spoolman', 'Apply to printer': "Appliquer à l'imprimante", + 'Connected to': 'Connecté à', + 'Not configured on this printer': 'Non configuré sur cette imprimante', + 'Spoolman server URL': 'URL du serveur Spoolman', + 'Apply to printer': "Appliquer à l'imprimante", 'Printer now reports filament usage to this Spoolman server.': "L'imprimante rapporte désormais la consommation de filament à ce serveur Spoolman.", 'The Spoolman address is stored on the printer itself, so every device using it stays in sync.': "L'adresse Spoolman est stockée sur l'imprimante — tous les appareils restent synchronisés.", - 'Select…': 'Sélectionner…', 'Search…': 'Rechercher…', 'No matches': 'Aucun résultat', new: 'nouveau', + 'Select…': 'Sélectionner…', + 'Search…': 'Rechercher…', + 'No matches': 'Aucun résultat', + new: 'nouveau', 'Custom material': 'Matériau personnalisé', - Scan: 'Scanner', 'Spool label': 'Étiquette de bobine', 'Save PNG': 'Enregistrer PNG', 'Share / Print': 'Partager / Imprimer', + Scan: 'Scanner', + 'Spool label': 'Étiquette de bobine', + 'Save PNG': 'Enregistrer PNG', + 'Share / Print': 'Partager / Imprimer', 'Label saved to your gallery — print it from there.': 'Étiquette enregistrée dans la galerie — imprimez-la depuis là.', - 'Photo library permission denied': 'Permission galerie refusée', 'Camera access needed': 'Accès caméra requis', + 'Photo library permission denied': 'Permission galerie refusée', + 'Camera access needed': 'Accès caméra requis', 'Helix needs the camera to scan the QR label on your spool.': "Helix a besoin de la caméra pour scanner l'étiquette QR de la bobine.", - 'Allow camera': 'Autoriser la caméra', 'Point at a spool label': 'Visez une étiquette de bobine', - 'Not a Spoolman QR code — try another label': 'Pas un QR Spoolman — essayez une autre étiquette', 'No spool with ID': 'Aucune bobine avec ID', + 'Allow camera': 'Autoriser la caméra', + 'Point at a spool label': 'Visez une étiquette de bobine', + 'Not a Spoolman QR code — try another label': 'Pas un QR Spoolman — essayez une autre étiquette', + 'No spool with ID': 'Aucune bobine avec ID', 'Filament usage': 'Consommation de filament', - 'Add spool': 'Ajouter une bobine', 'Add filament': 'Ajouter un filament', 'Edit spool': 'Modifier la bobine', 'Edit filament': 'Modifier le filament', - Filament: 'Filament', Vendor: 'Fabricant', 'New vendor': 'Nouveau fabricant', Material: 'Matériau', Color: 'Couleur', - 'Net weight (g)': 'Poids net (g)', 'Remaining (g)': 'Restant (g)', 'leave empty for a full spool': 'vide = bobine pleine', - Location: 'Emplacement', 'Lot number': 'Lot', Price: 'Prix', 'Diameter (mm)': 'Diamètre (mm)', 'Density (g/cm³)': 'Densité (g/cm³)', - Archive: 'Archiver', Unarchive: 'Désarchiver', Delete: 'Supprimer', 'Delete spool?': 'Supprimer la bobine ?', Save: 'Enregistrer', Archived: 'Archivée', - 'Pick a filament first': "Choisissez d'abord un filament", 'Name is required': 'Le nom est requis', 'No filaments yet — add one first': "Pas encore de filaments — ajoutez-en un d'abord", + 'Add spool': 'Ajouter une bobine', + 'Add filament': 'Ajouter un filament', + 'Edit spool': 'Modifier la bobine', + 'Edit filament': 'Modifier le filament', + Filament: 'Filament', + Vendor: 'Fabricant', + 'New vendor': 'Nouveau fabricant', + Material: 'Matériau', + Color: 'Couleur', + 'Net weight (g)': 'Poids net (g)', + 'Remaining (g)': 'Restant (g)', + 'leave empty for a full spool': 'vide = bobine pleine', + Location: 'Emplacement', + 'Lot number': 'Lot', + Price: 'Prix', + 'Diameter (mm)': 'Diamètre (mm)', + 'Density (g/cm³)': 'Densité (g/cm³)', + Archive: 'Archiver', + Unarchive: 'Désarchiver', + Delete: 'Supprimer', + 'Delete spool?': 'Supprimer la bobine ?', + Save: 'Enregistrer', + Archived: 'Archivée', + 'Pick a filament first': "Choisissez d'abord un filament", + 'Name is required': 'Le nom est requis', + 'No filaments yet — add one first': "Pas encore de filaments — ajoutez-en un d'abord", 'Enter your Spoolman server address and Helix will configure the printer for you.': "Entrez l'adresse de votre serveur Spoolman et Helix configurera l'imprimante pour vous.", - 'Configure printer': "Configurer l'imprimante", 'Configuring…': 'Configuration…', + 'Configure printer': "Configurer l'imprimante", + 'Configuring…': 'Configuration…', 'Moonraker is configured for Spoolman but the server is not responding. Check that the Spoolman container is running, or point the printer at a different address.': 'Moonraker est configuré pour Spoolman mais le serveur ne répond pas. Vérifiez le conteneur ou changez l’adresse.', - Spoolman: 'Spoolman', 'Active spool': 'Bobine active', 'No spool active': 'Aucune bobine active', - 'Set active spool?': 'Définir la bobine active ?', left: 'restant', 'No spools in Spoolman yet': 'Pas encore de bobines dans Spoolman', - 'Spoolman not configured': 'Spoolman non configuré', 'Spoolman server unreachable': 'Serveur Spoolman injoignable', - Home: 'Accueil', Mesh: 'Maillage', Macros: 'Macros', Console: 'Console', Files: 'Fichiers', - Settings: 'Réglages', 'Bed Mesh': 'Maillage du lit', History: 'Historique', - Temperatures: 'Températures', Camera: 'Caméra', 'EMERGENCY STOP': 'ARRÊT D’URGENCE', - Pause: 'Pause', Resume: 'Reprendre', Cancel: 'Annuler', 'Home All': 'Origine XYZ', - 'Dock Toolhead': 'Ranger la tête', Retry: 'Réessayer', 'Printer offline': 'Imprimante hors ligne', - 'Cancel print?': 'Annuler l’impression ?', 'This stops the current print.': 'Ceci arrête l’impression en cours.', + Spoolman: 'Spoolman', + 'Active spool': 'Bobine active', + 'No spool active': 'Aucune bobine active', + 'Set active spool?': 'Définir la bobine active ?', + left: 'restant', + 'No spools in Spoolman yet': 'Pas encore de bobines dans Spoolman', + 'Spoolman not configured': 'Spoolman non configuré', + 'Spoolman server unreachable': 'Serveur Spoolman injoignable', + Home: 'Accueil', + Mesh: 'Maillage', + Macros: 'Macros', + Console: 'Console', + Files: 'Fichiers', + Settings: 'Réglages', + 'Bed Mesh': 'Maillage du lit', + History: 'Historique', + Temperatures: 'Températures', + Camera: 'Caméra', + 'EMERGENCY STOP': 'ARRÊT D’URGENCE', + Pause: 'Pause', + Resume: 'Reprendre', + Cancel: 'Annuler', + 'Home All': 'Origine XYZ', + 'Dock Toolhead': 'Ranger la tête', + Retry: 'Réessayer', + 'Printer offline': 'Imprimante hors ligne', + 'Cancel print?': 'Annuler l’impression ?', + 'This stops the current print.': 'Ceci arrête l’impression en cours.', 'Emergency stop?': 'Arrêt d’urgence ?', 'Halts the printer immediately. Requires a firmware restart before printing again.': 'Arrête l’imprimante immédiatement. Un redémarrage du firmware sera nécessaire avant de réimprimer.', - "Don't ask again": 'Ne plus demander', 'Stop now': 'Arrêter maintenant', + "Don't ask again": 'Ne plus demander', + 'Stop now': 'Arrêter maintenant', 'Confirm emergency stop': 'Confirmer l’arrêt d’urgence', - No: 'Non', 'Yes, cancel': 'Oui, annuler', Error: 'Erreur', - Profile: 'Profil', Range: 'Plage', Min: 'Min', Max: 'Max', 'Saved profiles': 'Profils enregistrés', - Load: 'Charger', 'Drag to rotate · pinch to zoom': 'Glisser pour tourner · pincer pour zoomer', - 'No bed mesh data': 'Aucune donnée de maillage', active: 'actif', 'Profile (saved)': 'Profil (enregistré)', + No: 'Non', + 'Yes, cancel': 'Oui, annuler', + Error: 'Erreur', + Profile: 'Profil', + Range: 'Plage', + Min: 'Min', + Max: 'Max', + 'Saved profiles': 'Profils enregistrés', + Load: 'Charger', + 'Drag to rotate · pinch to zoom': 'Glisser pour tourner · pincer pour zoomer', + 'No bed mesh data': 'Aucune donnée de maillage', + active: 'actif', + 'Profile (saved)': 'Profil (enregistré)', 'No mesh active — showing saved profile points. Load it to apply.': 'Aucun maillage actif — profil enregistré affiché. Chargez-le pour l’appliquer.', - 'Filter macros…': 'Filtrer les macros…', 'No macros found': 'Aucune macro', 'Not connected': 'Non connecté', - 'Print Control': 'Contrôle d’impression', 'Tool Change': 'Changement d’outil', - 'ACE & Filament': 'ACE et filament', Calibration: 'Calibration', Cleaning: 'Nettoyage', - 'Homing & Motion': 'Origine et mouvement', Other: 'Autres', - 'Send G-code…': 'Envoyer G-code…', 'Printer command…': 'Commande imprimante…', Send: 'Envoyer', Enter: 'Entrer', Clear: 'Effacer', + 'Filter macros…': 'Filtrer les macros…', + 'No macros found': 'Aucune macro', + 'Not connected': 'Non connecté', + 'Print Control': 'Contrôle d’impression', + 'Tool Change': 'Changement d’outil', + 'ACE & Filament': 'ACE et filament', + Calibration: 'Calibration', + Cleaning: 'Nettoyage', + 'Homing & Motion': 'Origine et mouvement', + Other: 'Autres', + 'Send G-code…': 'Envoyer G-code…', + 'Printer command…': 'Commande imprimante…', + Send: 'Envoyer', + Enter: 'Entrer', + Clear: 'Effacer', 'Console output appears here': 'La sortie console apparaît ici', 'Printer commands': 'Commandes imprimante', 'Type /help or /help BED in the console.': 'Tapez /help ou /help BED dans la console.', 'Search commands…': 'Rechercher des commandes…', 'No matching commands': 'Aucune commande correspondante', 'Connect to a ready printer to load command help.': "Connectez-vous à une imprimante prête pour charger l'aide des commandes.", - 'Start print?': 'Lancer l’impression ?', Print: 'Imprimer', 'Printer busy': 'Imprimante occupée', - 'A print is already in progress.': 'Une impression est déjà en cours.', 'Print started': 'Impression lancée', - 'No G-code files on printer': 'Aucun fichier G-code', 'Load more': 'Charger plus', - 'Printer Stats': 'Statistiques', 'Total print jobs': 'Impressions totales', job: 'impression', jobs: 'impressions', 'Longest job': 'Plus long travail', - 'Total time': 'Temps total', 'Total print time': 'Temps d’impression', 'Filament used': 'Filament utilisé', - 'Longest print': 'Plus longue impression', 'No print history': 'Aucun historique', avg: 'moy', - Lane: 'Voie', Unload: 'Décharger', Dryer: 'Sécheur', Start: 'Démarrer', Stop: 'Arrêter', - 'Cross-ACE switching': 'Changement d’ACE', 'No RFID data': 'Pas de données RFID', off: 'éteint', - Connection: 'Connexion', 'Reconnect now': 'Reconnecter', 'ACE units': 'Unités ACE', - 'Notify on': 'Notifier pour', 'Print complete': 'Impression terminée', 'Print failed': 'Impression échouée', - 'Filament runout': 'Fin de filament', 'Filament swap complete': 'Changement de filament terminé', - 'Send test notification': 'Envoyer une notification test', 'Save & Apply': 'Enregistrer et appliquer', - 'Macro display': 'Affichage des macros', 'Show all': 'Tout afficher', 'Selected only': 'Sélection uniquement', - selected: 'sélectionnées', 'Select all': 'Tout sélectionner', + 'Start print?': 'Lancer l’impression ?', + Print: 'Imprimer', + 'Printer busy': 'Imprimante occupée', + 'A print is already in progress.': 'Une impression est déjà en cours.', + 'Print started': 'Impression lancée', + 'No G-code files on printer': 'Aucun fichier G-code', + 'Load more': 'Charger plus', + 'Printer Stats': 'Statistiques', + 'Total print jobs': 'Impressions totales', + job: 'impression', + jobs: 'impressions', + 'Longest job': 'Plus long travail', + 'Total time': 'Temps total', + 'Total print time': 'Temps d’impression', + 'Filament used': 'Filament utilisé', + 'Longest print': 'Plus longue impression', + 'No print history': 'Aucun historique', + avg: 'moy', + Lane: 'Voie', + Unload: 'Décharger', + Dryer: 'Sécheur', + Start: 'Démarrer', + Stop: 'Arrêter', + 'Cross-ACE switching': 'Changement d’ACE', + 'No RFID data': 'Pas de données RFID', + off: 'éteint', + Connection: 'Connexion', + 'Reconnect now': 'Reconnecter', + 'ACE units': 'Unités ACE', + 'Notify on': 'Notifier pour', + 'Print complete': 'Impression terminée', + 'Print failed': 'Impression échouée', + 'Filament runout': 'Fin de filament', + 'Filament swap complete': 'Changement de filament terminé', + 'Send test notification': 'Envoyer une notification test', + 'Save & Apply': 'Enregistrer et appliquer', + 'Macro display': 'Affichage des macros', + 'Show all': 'Tout afficher', + 'Selected only': 'Sélection uniquement', + selected: 'sélectionnées', + 'Select all': 'Tout sélectionner', 'Connect to a printer to choose macros.': 'Connectez-vous à une imprimante pour choisir les macros.', 'No macros selected in Settings': 'Aucune macro sélectionnée dans les réglages', - 'Check for updates': 'Rechercher des mises à jour', 'Checking for updates...': 'Recherche de mises à jour...', - 'Up to date': 'À jour', "What's changed": 'Nouveautés', 'Update available': 'Mise à jour disponible', + 'Check for updates': 'Rechercher des mises à jour', + 'Checking for updates...': 'Recherche de mises à jour...', + 'Up to date': 'À jour', + "What's changed": 'Nouveautés', + 'Update available': 'Mise à jour disponible', 'Latest APK available': 'Dernière APK disponible', 'Open the latest APK download?': 'Ouvrir le téléchargement de la dernière APK ?', - 'Not now': 'Pas maintenant', 'Download APK': 'Télécharger APK', + 'Not now': 'Pas maintenant', + 'Download APK': 'Télécharger APK', 'Downloading update...': 'Téléchargement de la mise à jour...', 'Update download failed': 'Échec du téléchargement de la mise à jour', 'Open in browser': 'Ouvrir dans le navigateur', - OK: 'OK', 'Installed build': 'Build installé', + OK: 'OK', + 'Installed build': 'Build installé', 'Install over existing app to keep settings.': "Installez par-dessus l'app actuelle pour conserver les réglages.", 'Update check failed': 'Échec de la recherche de mise à jour', - Saved: 'Enregistré', Theme: 'Thème', 'Accent color': 'Couleur d’accent', Language: 'Langue', - About: 'À propos', 'Report a bug': 'Signaler un bug', 'GitHub — FatBoy721': 'GitHub — FatBoy721', + Saved: 'Enregistré', + Theme: 'Thème', + 'Accent color': 'Couleur d’accent', + Language: 'Langue', + About: 'À propos', + 'Report a bug': 'Signaler un bug', + 'GitHub — FatBoy721': 'GitHub — FatBoy721', 'Settings applied. Connection will use the new URLs.': 'Réglages appliqués. La connexion utilisera les nouvelles URL.', - 'Slicer remaining': 'Restant (slicer)', 'Est. remaining': 'Restant (réel)', - 'Finishes at': 'Fin à', 'Per layer': 'Par couche', - Timelapse: 'Timelapse', Download: 'Télécharger', 'No timelapse videos yet': 'Pas encore de vidéos timelapse', - Controls: 'Contrôles', Bed: 'Plateau', 'Part fan': 'Ventilateur pièce', 'Cavity fan': 'Ventilateur caisson', - Purifier: 'Purificateur', Set: 'Régler', Off: 'Éteint', On: 'Allumé', + 'Slicer remaining': 'Restant (slicer)', + 'Est. remaining': 'Restant (réel)', + 'Finishes at': 'Fin à', + 'Per layer': 'Par couche', + Timelapse: 'Timelapse', + Download: 'Télécharger', + 'No timelapse videos yet': 'Pas encore de vidéos timelapse', + Controls: 'Contrôles', + Bed: 'Plateau', + 'Part fan': 'Ventilateur pièce', + 'Cavity fan': 'Ventilateur caisson', + Purifier: 'Purificateur', + Set: 'Régler', + Off: 'Éteint', + On: 'Allumé', 'Printer error': 'Erreur imprimante', - Printers: 'Imprimantes', 'Add printer': 'Ajouter une imprimante', 'Remove printer?': 'Supprimer l’imprimante ?', - Remove: 'Supprimer', Name: 'Nom', 'Dashboard sections': 'Sections du tableau de bord', - Progress: 'Progression', 'Quick actions': 'Actions rapides', 'Emergency stop': 'Arrêt d’urgence', - 'Home & Dock': 'Origine et rangement', 'Last print': 'Dernière impression', + Printers: 'Imprimantes', + 'Add printer': 'Ajouter une imprimante', + 'Remove printer?': 'Supprimer l’imprimante ?', + Remove: 'Supprimer', + Name: 'Nom', + 'Dashboard sections': 'Sections du tableau de bord', + Progress: 'Progression', + 'Quick actions': 'Actions rapides', + 'Emergency stop': 'Arrêt d’urgence', + 'Home & Dock': 'Origine et rangement', + 'Last print': 'Dernière impression', 'No ACE hardware detected': 'Aucun matériel ACE détecté', "The printer reports 0 connected ACE units. If you haven't installed multiACE yet, that's expected — this tab lights up once a unit is wired in.": "L'imprimante signale 0 unité ACE connectée. Si multiACE n'est pas encore installé, c'est normal — cet onglet s'active dès qu'une unité est branchée.", + 'A mobile Fluidd-style controller for PAXX/Moonraker so you do not have to open the printer web UI every time.': 'Un contrôleur mobile style Fluidd pour PAXX/Moonraker, pour ne pas avoir à ouvrir l’interface web de l’imprimante à chaque fois.', + 'Active': 'Actif', + 'Auto': 'Auto', + 'Backup': 'Sauvegarde', + 'Busy': 'Occupé', + 'Calibrate': 'Calibrer', + 'Camera stream (path or full URL)': 'Flux caméra (chemin ou URL complète)', + 'Cancelled': 'Annulé', + 'Checking': 'Vérification', + 'Checking connection': 'Vérification de la connexion', + 'Choose a drying preset.': 'Choisissez un préréglage de séchage.', + 'Connected': 'Connecté', + 'Connection mode': 'Mode de connexion', + 'Could not compare versions': 'Impossible de comparer les versions', + 'Devices': 'Appareils', + 'Dismiss': 'Ignorer', + 'Downloading update': 'Téléchargement de la mise à jour', + 'Drag to rotate, pinch to zoom': 'Glisser pour tourner, pincer pour zoomer', + 'Dry': 'Sécher', + 'Dry filament': 'Sécher le filament', + 'Edit printer': 'Modifier l’imprimante', + 'Enter the printer IP or Moonraker URL.': 'Entrez l’IP de l’imprimante ou l’URL Moonraker.', + 'Export failed': 'Échec de l’export', + 'Export settings': 'Exporter les réglages', + 'Failed': 'Échec', + 'Height diff': 'Écart de hauteur', + 'Height diff is the gap between the highest and lowest measured points.': 'L’écart de hauteur correspond à la différence entre le point mesuré le plus haut et le plus bas.', + 'Hide': 'Masquer', + 'Idle': 'Inactif', + 'Import': 'Importer', + 'Import settings': 'Importer les réglages', + 'Layer': 'Couche', + 'Manual': 'Manuel', + 'Missing Tailscale URL': 'URL Tailscale manquante', + 'Missing printer URL': 'URL d’imprimante manquante', + 'No URL set': 'Aucune URL définie', + 'No active mesh': 'Aucun maillage actif', + 'No active mesh or saved profile points were found. Run a calibration to create one.': 'Aucun maillage actif ou profil enregistré trouvé. Lancez une calibration pour en créer un.', + 'No file': 'Aucun fichier', + 'No mesh loaded': 'Aucun maillage chargé', + 'Nothing new yet': 'Rien de nouveau pour l’instant', + 'Notifications': 'Notifications', + 'Offline': 'Hors ligne', + 'Online': 'En ligne', + 'Open Settings': 'Ouvrir les réglages', + 'Open release': 'Ouvrir la version', + 'Open your backup file, copy everything, and paste it here.': 'Ouvrez votre fichier de sauvegarde, copiez tout et collez-le ici.', + 'Optional. Only add Tailscale if you already use it for remote access.': 'Optionnel. N’ajoutez Tailscale que si vous l’utilisez déjà pour l’accès à distance.', + 'Panda Breath': 'Panda Breath', + 'Paused': 'En pause', + 'Points': 'Points', + 'Preview only. Load the profile to apply it to the printer.': 'Aperçu uniquement. Chargez le profil pour l’appliquer à l’imprimante.', + 'Previewing': 'Aperçu', + 'Print again': 'Réimprimer', + 'Print cancelled': 'Impression annulée', + 'Print paused': 'Impression en pause', + 'Print progress (every 10%)': 'Progression d’impression (tous les 10 %)', + 'Printer': 'Imprimante', + 'Printer IP or Moonraker URL': 'IP de l’imprimante ou URL Moonraker', + 'Printer IP or Moonraker URL (optional)': 'IP de l’imprimante ou URL Moonraker (optionnel)', + 'Printer URL (LAN)': 'URL de l’imprimante (LAN)', + 'Printer URL (LAN, optional)': 'URL de l’imprimante (LAN, optionnel)', + 'Printer URL (Tailscale)': 'URL de l’imprimante (Tailscale)', + 'Printer URL (Tailscale, optional)': 'URL de l’imprimante (Tailscale, optionnel)', + 'Printer disconnected': 'Imprimante déconnectée', + 'Printer name': 'Nom de l’imprimante', + 'Printers, connection and notification settings were imported. MakerWorld needs a fresh login.': 'Les réglages d’imprimantes, de connexion et de notifications ont été importés. MakerWorld nécessite une nouvelle connexion.', + 'Printing': 'Impression en cours', + 'Ready': 'Prêt', + 'Ready to print': 'Prêt à imprimer', + 'Run': 'Exécuter', + 'Run ACE macro?': 'Exécuter la macro ACE ?', + 'Run bed mesh?': 'Exécuter le maillage du lit ?', + 'Save printer': 'Enregistrer l’imprimante', + 'Save your printers and settings to a file, or restore them after a reinstall.': 'Enregistrez vos imprimantes et réglages dans un fichier, ou restaurez-les après une réinstallation.', + 'Saving...': 'Enregistrement...', + 'Settings restored': 'Réglages restaurés', + 'Show active': 'Afficher l’actif', + 'Start using Helix': 'Commencer avec Helix', + 'Success': 'Succès', + 'Support Helix': 'Soutenir Helix', + 'Surface': 'Surface', + 'Tailscale URL': 'URL Tailscale', + 'Tailscale URL (optional)': 'URL Tailscale (optionnel)', + 'Tailscale only never falls back to Wi-Fi. Auto tries LAN first.': 'Tailscale uniquement ne bascule jamais vers le Wi-Fi. Auto essaie le LAN en premier.', + 'Tailscale-only mode needs a Tailscale URL.': 'Le mode Tailscale uniquement nécessite une URL Tailscale.', + 'Tap to preview': 'Toucher pour prévisualiser', + 'Temperature units': 'Unités de température', + 'Temperature warning': 'Alerte de température', + 'The installer opens when the download finishes.': 'L’installateur s’ouvre quand le téléchargement est terminé.', + 'The printer will clean the nozzle, heat the bed to 55°C, and run bed mesh calibration.': 'L’imprimante va nettoyer la buse, chauffer le plateau à 55°C et lancer la calibration du maillage du lit.', + 'Totally optional. Helix is free and always will be, but if you want to chip in it really helps me out as a college student.': 'Totalement optionnel. Helix est gratuit et le restera toujours, mais si vous souhaitez contribuer, ça m’aide vraiment en tant qu’étudiant.', + 'Waiting for printer': 'En attente de l’imprimante', + 'Welcome to Helix': 'Bienvenue sur Helix', + 'not detected': 'non détecté', + 'offline': 'hors ligne', }, zh: { - 'Pick from catalog': '从目录选择', 'Search 7000+ commercial filaments…': '搜索 7000+ 商用耗材…', - 'Weigh the spool': '称量线轴', 'Gross weight (g)': '总重 (g)', Measure: '测量', Remaining: '剩余', + 'Pick from catalog': '从目录选择', + 'Search 7000+ commercial filaments…': '搜索 7000+ 商用耗材…', + 'Weigh the spool': '称量线轴', + 'Gross weight (g)': '总重 (g)', + Measure: '测量', + Remaining: '剩余', 'Put the whole spool on a scale — remaining filament is computed from the empty-spool weight.': '把整个线轴放到秤上——剩余耗材会根据空轴重量自动计算。', - 'Connected to': '已连接到', 'Not configured on this printer': '此打印机未配置', - 'Spoolman server URL': 'Spoolman 服务器地址', 'Apply to printer': '应用到打印机', + 'Connected to': '已连接到', + 'Not configured on this printer': '此打印机未配置', + 'Spoolman server URL': 'Spoolman 服务器地址', + 'Apply to printer': '应用到打印机', 'Printer now reports filament usage to this Spoolman server.': '打印机现在会向此 Spoolman 服务器报告耗材用量。', 'The Spoolman address is stored on the printer itself, so every device using it stays in sync.': 'Spoolman 地址保存在打印机上,所有设备保持同步。', - 'Select…': '请选择…', 'Search…': '搜索…', 'No matches': '无匹配', new: '新建', + 'Select…': '请选择…', + 'Search…': '搜索…', + 'No matches': '无匹配', + new: '新建', 'Custom material': '自定义材料', - Scan: '扫码', 'Spool label': '线轴标签', 'Save PNG': '保存 PNG', 'Share / Print': '分享 / 打印', + Scan: '扫码', + 'Spool label': '线轴标签', + 'Save PNG': '保存 PNG', + 'Share / Print': '分享 / 打印', 'Label saved to your gallery — print it from there.': '标签已保存到相册——可从相册打印。', - 'Photo library permission denied': '相册权限被拒绝', 'Camera access needed': '需要相机权限', + 'Photo library permission denied': '相册权限被拒绝', + 'Camera access needed': '需要相机权限', 'Helix needs the camera to scan the QR label on your spool.': 'Helix 需要使用相机扫描线轴上的二维码标签。', - 'Allow camera': '允许使用相机', 'Point at a spool label': '对准线轴标签', - 'Not a Spoolman QR code — try another label': '不是 Spoolman 二维码——请换一个标签', 'No spool with ID': '找不到该 ID 的线轴', + 'Allow camera': '允许使用相机', + 'Point at a spool label': '对准线轴标签', + 'Not a Spoolman QR code — try another label': '不是 Spoolman 二维码——请换一个标签', + 'No spool with ID': '找不到该 ID 的线轴', 'Filament usage': '耗材用量', - 'Add spool': '添加线轴', 'Add filament': '添加耗材', 'Edit spool': '编辑线轴', 'Edit filament': '编辑耗材', - Filament: '耗材', Vendor: '厂商', 'New vendor': '新厂商', Material: '材料', Color: '颜色', - 'Net weight (g)': '净重 (g)', 'Remaining (g)': '剩余 (g)', 'leave empty for a full spool': '留空表示整卷', - Location: '位置', 'Lot number': '批号', Price: '价格', 'Diameter (mm)': '直径 (mm)', 'Density (g/cm³)': '密度 (g/cm³)', - Archive: '归档', Unarchive: '取消归档', Delete: '删除', 'Delete spool?': '删除线轴?', Save: '保存', Archived: '已归档', - 'Pick a filament first': '请先选择耗材', 'Name is required': '名称必填', 'No filaments yet — add one first': '还没有耗材——请先添加', + 'Add spool': '添加线轴', + 'Add filament': '添加耗材', + 'Edit spool': '编辑线轴', + 'Edit filament': '编辑耗材', + Filament: '耗材', + Vendor: '厂商', + 'New vendor': '新厂商', + Material: '材料', + Color: '颜色', + 'Net weight (g)': '净重 (g)', + 'Remaining (g)': '剩余 (g)', + 'leave empty for a full spool': '留空表示整卷', + Location: '位置', + 'Lot number': '批号', + Price: '价格', + 'Diameter (mm)': '直径 (mm)', + 'Density (g/cm³)': '密度 (g/cm³)', + Archive: '归档', + Unarchive: '取消归档', + Delete: '删除', + 'Delete spool?': '删除线轴?', + Save: '保存', + Archived: '已归档', + 'Pick a filament first': '请先选择耗材', + 'Name is required': '名称必填', + 'No filaments yet — add one first': '还没有耗材——请先添加', 'Enter your Spoolman server address and Helix will configure the printer for you.': '输入 Spoolman 服务器地址,Helix 会自动为打印机完成配置。', - 'Configure printer': '配置打印机', 'Configuring…': '配置中…', + 'Configure printer': '配置打印机', + 'Configuring…': '配置中…', 'Moonraker is configured for Spoolman but the server is not responding. Check that the Spoolman container is running, or point the printer at a different address.': 'Moonraker 已配置 Spoolman,但服务器无响应。请检查容器是否运行,或改用其他地址。', - Spoolman: 'Spoolman', 'Active spool': '当前线轴', 'No spool active': '无活动线轴', - 'Set active spool?': '设为当前线轴?', left: '剩余', 'No spools in Spoolman yet': 'Spoolman 中还没有线轴', - 'Spoolman not configured': '未配置 Spoolman', 'Spoolman server unreachable': '无法连接 Spoolman 服务器', - Home: '主页', Mesh: '网格', Macros: '宏', Console: '控制台', Files: '文件', - Settings: '设置', 'Bed Mesh': '热床网格', History: '历史', - Temperatures: '温度', Camera: '摄像头', 'EMERGENCY STOP': '紧急停止', - Pause: '暂停', Resume: '继续', Cancel: '取消', 'Home All': '全部归位', - 'Dock Toolhead': '停放工具头', Retry: '重试', 'Printer offline': '打印机离线', - 'Cancel print?': '取消打印?', 'This stops the current print.': '这将停止当前打印。', + Spoolman: 'Spoolman', + 'Active spool': '当前线轴', + 'No spool active': '无活动线轴', + 'Set active spool?': '设为当前线轴?', + left: '剩余', + 'No spools in Spoolman yet': 'Spoolman 中还没有线轴', + 'Spoolman not configured': '未配置 Spoolman', + 'Spoolman server unreachable': '无法连接 Spoolman 服务器', + Home: '主页', + Mesh: '网格', + Macros: '宏', + Console: '控制台', + Files: '文件', + Settings: '设置', + 'Bed Mesh': '热床网格', + History: '历史', + Temperatures: '温度', + Camera: '摄像头', + 'EMERGENCY STOP': '紧急停止', + Pause: '暂停', + Resume: '继续', + Cancel: '取消', + 'Home All': '全部归位', + 'Dock Toolhead': '停放工具头', + Retry: '重试', + 'Printer offline': '打印机离线', + 'Cancel print?': '取消打印?', + 'This stops the current print.': '这将停止当前打印。', 'Emergency stop?': '紧急停止?', 'Halts the printer immediately. Requires a firmware restart before printing again.': '立即停止打印机。需要重启固件后才能再次打印。', - "Don't ask again": '不再询问', 'Stop now': '立即停止', + "Don't ask again": '不再询问', + 'Stop now': '立即停止', 'Confirm emergency stop': '确认紧急停止', - No: '否', 'Yes, cancel': '是,取消', Error: '错误', - Profile: '配置', Range: '范围', Min: '最小', Max: '最大', 'Saved profiles': '已保存配置', - Load: '加载', 'Drag to rotate · pinch to zoom': '拖动旋转 · 捏合缩放', - 'No bed mesh data': '无网格数据', active: '当前', 'Profile (saved)': '配置(已保存)', + No: '否', + 'Yes, cancel': '是,取消', + Error: '错误', + Profile: '配置', + Range: '范围', + Min: '最小', + Max: '最大', + 'Saved profiles': '已保存配置', + Load: '加载', + 'Drag to rotate · pinch to zoom': '拖动旋转 · 捏合缩放', + 'No bed mesh data': '无网格数据', + active: '当前', + 'Profile (saved)': '配置(已保存)', 'No mesh active — showing saved profile points. Load it to apply.': '无活动网格 — 显示已保存配置。加载以应用。', - 'Filter macros…': '筛选宏…', 'No macros found': '未找到宏', 'Not connected': '未连接', - 'Print Control': '打印控制', 'Tool Change': '换工具', - 'ACE & Filament': 'ACE 与耗材', Calibration: '校准', Cleaning: '清洁', - 'Homing & Motion': '归位与运动', Other: '其他', - 'Send G-code…': '发送 G-code…', 'Printer command…': '打印机命令…', Send: '发送', Enter: '输入', Clear: '清空', + 'Filter macros…': '筛选宏…', + 'No macros found': '未找到宏', + 'Not connected': '未连接', + 'Print Control': '打印控制', + 'Tool Change': '换工具', + 'ACE & Filament': 'ACE 与耗材', + Calibration: '校准', + Cleaning: '清洁', + 'Homing & Motion': '归位与运动', + Other: '其他', + 'Send G-code…': '发送 G-code…', + 'Printer command…': '打印机命令…', + Send: '发送', + Enter: '输入', + Clear: '清空', 'Console output appears here': '控制台输出显示在此处', 'Printer commands': '打印机命令', 'Type /help or /help BED in the console.': '在控制台输入 /help 或 /help BED。', 'Search commands…': '搜索命令…', 'No matching commands': '没有匹配的命令', 'Connect to a ready printer to load command help.': '连接到已就绪的打印机以加载命令帮助。', - 'Start print?': '开始打印?', Print: '打印', 'Printer busy': '打印机忙', - 'A print is already in progress.': '已有打印正在进行。', 'Print started': '打印已开始', - 'No G-code files on printer': '打印机上没有 G-code 文件', 'Load more': '加载更多', - 'Printer Stats': '打印统计', 'Total print jobs': '总打印任务', job: '任务', jobs: '任务', 'Longest job': '最长任务', - 'Total time': '总时间', 'Total print time': '总打印时间', 'Filament used': '耗材用量', - 'Longest print': '最长打印', 'No print history': '无打印历史', avg: '平均', - Lane: '通道', Unload: '退料', Dryer: '烘干器', Start: '开始', Stop: '停止', - 'Cross-ACE switching': 'ACE 切换', 'No RFID data': '无 RFID 数据', off: '关闭', - Connection: '连接', 'Reconnect now': '立即重连', 'ACE units': 'ACE 数量', - 'Notify on': '通知条件', 'Print complete': '打印完成', 'Print failed': '打印失败', - 'Filament runout': '耗材用尽', 'Filament swap complete': '换料完成', - 'Send test notification': '发送测试通知', 'Save & Apply': '保存并应用', - 'Macro display': '宏显示', 'Show all': '显示全部', 'Selected only': '仅显示已选', - selected: '已选择', 'Select all': '全选', + 'Start print?': '开始打印?', + Print: '打印', + 'Printer busy': '打印机忙', + 'A print is already in progress.': '已有打印正在进行。', + 'Print started': '打印已开始', + 'No G-code files on printer': '打印机上没有 G-code 文件', + 'Load more': '加载更多', + 'Printer Stats': '打印统计', + 'Total print jobs': '总打印任务', + job: '任务', + jobs: '任务', + 'Longest job': '最长任务', + 'Total time': '总时间', + 'Total print time': '总打印时间', + 'Filament used': '耗材用量', + 'Longest print': '最长打印', + 'No print history': '无打印历史', + avg: '平均', + Lane: '通道', + Unload: '退料', + Dryer: '烘干器', + Start: '开始', + Stop: '停止', + 'Cross-ACE switching': 'ACE 切换', + 'No RFID data': '无 RFID 数据', + off: '关闭', + Connection: '连接', + 'Reconnect now': '立即重连', + 'ACE units': 'ACE 数量', + 'Notify on': '通知条件', + 'Print complete': '打印完成', + 'Print failed': '打印失败', + 'Filament runout': '耗材用尽', + 'Filament swap complete': '换料完成', + 'Send test notification': '发送测试通知', + 'Save & Apply': '保存并应用', + 'Macro display': '宏显示', + 'Show all': '显示全部', + 'Selected only': '仅显示已选', + selected: '已选择', + 'Select all': '全选', 'Connect to a printer to choose macros.': '连接到打印机后选择宏。', 'No macros selected in Settings': '设置中未选择宏', - 'Check for updates': '检查更新', 'Checking for updates...': '正在检查更新...', - 'Up to date': '已是最新', "What's changed": '更新内容', 'Update available': '有可用更新', + 'Check for updates': '检查更新', + 'Checking for updates...': '正在检查更新...', + 'Up to date': '已是最新', + "What's changed": '更新内容', + 'Update available': '有可用更新', 'Latest APK available': '有最新 APK', 'Open the latest APK download?': '打开最新 APK 下载?', - 'Not now': '暂不', 'Download APK': '下载 APK', - 'Downloading update...': '正在下载更新...', 'Update download failed': '更新下载失败', + 'Not now': '暂不', + 'Download APK': '下载 APK', + 'Downloading update...': '正在下载更新...', + 'Update download failed': '更新下载失败', 'Open in browser': '在浏览器中打开', - OK: '确定', 'Installed build': '已安装版本', + OK: '确定', + 'Installed build': '已安装版本', 'Install over existing app to keep settings.': '覆盖安装现有应用以保留设置。', 'Update check failed': '检查更新失败', - Saved: '已保存', Theme: '主题', 'Accent color': '强调色', Language: '语言', - About: '关于', 'Report a bug': '报告问题', 'GitHub — FatBoy721': 'GitHub — FatBoy721', + Saved: '已保存', + Theme: '主题', + 'Accent color': '强调色', + Language: '语言', + About: '关于', + 'Report a bug': '报告问题', + 'GitHub — FatBoy721': 'GitHub — FatBoy721', 'Settings applied. Connection will use the new URLs.': '设置已应用。连接将使用新地址。', - 'Slicer remaining': '切片剩余', 'Est. remaining': '实际剩余', - 'Finishes at': '完成时间', 'Per layer': '每层用时', - Timelapse: '延时摄影', Download: '下载', 'No timelapse videos yet': '暂无延时视频', - Controls: '控制', Bed: '热床', 'Part fan': '模型风扇', 'Cavity fan': '腔体风扇', - Purifier: '净化器', Set: '设置', Off: '关', On: '开', + 'Slicer remaining': '切片剩余', + 'Est. remaining': '实际剩余', + 'Finishes at': '完成时间', + 'Per layer': '每层用时', + Timelapse: '延时摄影', + Download: '下载', + 'No timelapse videos yet': '暂无延时视频', + Controls: '控制', + Bed: '热床', + 'Part fan': '模型风扇', + 'Cavity fan': '腔体风扇', + Purifier: '净化器', + Set: '设置', + Off: '关', + On: '开', 'Printer error': '打印机错误', - Printers: '打印机', 'Add printer': '添加打印机', 'Remove printer?': '删除打印机?', - Remove: '删除', Name: '名称', 'Dashboard sections': '主页板块', - Progress: '进度', 'Quick actions': '快捷操作', 'Emergency stop': '紧急停止', - 'Home & Dock': '归位与停放', 'Last print': '上次打印', + Printers: '打印机', + 'Add printer': '添加打印机', + 'Remove printer?': '删除打印机?', + Remove: '删除', + Name: '名称', + 'Dashboard sections': '主页板块', + Progress: '进度', + 'Quick actions': '快捷操作', + 'Emergency stop': '紧急停止', + 'Home & Dock': '归位与停放', + 'Last print': '上次打印', 'No ACE hardware detected': '未检测到 ACE 硬件', "The printer reports 0 connected ACE units. If you haven't installed multiACE yet, that's expected — this tab lights up once a unit is wired in.": '打印机报告已连接 0 个 ACE 单元。如果尚未安装 multiACE,这是正常的——连接设备后此标签页会自动激活。', + 'A mobile Fluidd-style controller for PAXX/Moonraker so you do not have to open the printer web UI every time.': '移动端 Fluidd 风格的控制器,对接 PAXX/Moonraker,让你不必每次都打开打印机网页界面。', + 'Active': '活动中', + 'Auto': '自动', + 'Backup': '备份', + 'Busy': '忙碌', + 'Calibrate': '校准', + 'Camera stream (path or full URL)': '摄像头视频流(路径或完整 URL)', + 'Cancelled': '已取消', + 'Checking': '检查中', + 'Checking connection': '正在检查连接', + 'Choose a drying preset.': '选择一个烘干预设。', + 'Connected': '已连接', + 'Connection mode': '连接模式', + 'Could not compare versions': '无法比较版本', + 'Devices': '设备', + 'Dismiss': '忽略', + 'Downloading update': '正在下载更新', + 'Drag to rotate, pinch to zoom': '拖动旋转,捏合缩放', + 'Dry': '烘干', + 'Dry filament': '烘干耗材', + 'Edit printer': '编辑打印机', + 'Enter the printer IP or Moonraker URL.': '输入打印机 IP 或 Moonraker 地址。', + 'Export failed': '导出失败', + 'Export settings': '导出设置', + 'Failed': '失败', + 'Height diff': '高度差', + 'Height diff is the gap between the highest and lowest measured points.': '高度差即最高与最低测量点之间的差距。', + 'Hide': '隐藏', + 'Idle': '空闲', + 'Import': '导入', + 'Import settings': '导入设置', + 'Layer': '层', + 'Manual': '手动', + 'Missing Tailscale URL': '缺少 Tailscale 地址', + 'Missing printer URL': '缺少打印机地址', + 'No URL set': '未设置地址', + 'No active mesh': '无活动网格', + 'No active mesh or saved profile points were found. Run a calibration to create one.': '未找到活动网格或已保存的配置点。请运行校准以创建。', + 'No file': '无文件', + 'No mesh loaded': '未加载网格', + 'Nothing new yet': '暂无新内容', + 'Notifications': '通知', + 'Offline': '离线', + 'Online': '在线', + 'Open Settings': '打开设置', + 'Open release': '打开发布页', + 'Open your backup file, copy everything, and paste it here.': '打开你的备份文件,复制全部内容并粘贴到此处。', + 'Optional. Only add Tailscale if you already use it for remote access.': '可选。仅当你已使用 Tailscale 进行远程访问时才需要填写。', + 'Panda Breath': 'Panda Breath', + 'Paused': '已暂停', + 'Points': '点数', + 'Preview only. Load the profile to apply it to the printer.': '仅预览。加载配置以应用到打印机。', + 'Previewing': '预览中', + 'Print again': '重新打印', + 'Print cancelled': '打印已取消', + 'Print paused': '打印已暂停', + 'Print progress (every 10%)': '打印进度(每 10%)', + 'Printer': '打印机', + 'Printer IP or Moonraker URL': '打印机 IP 或 Moonraker 地址', + 'Printer IP or Moonraker URL (optional)': '打印机 IP 或 Moonraker 地址(可选)', + 'Printer URL (LAN)': '打印机地址(局域网)', + 'Printer URL (LAN, optional)': '打印机地址(局域网,可选)', + 'Printer URL (Tailscale)': '打印机地址(Tailscale)', + 'Printer URL (Tailscale, optional)': '打印机地址(Tailscale,可选)', + 'Printer disconnected': '打印机已断开', + 'Printer name': '打印机名称', + 'Printers, connection and notification settings were imported. MakerWorld needs a fresh login.': '已导入打印机、连接和通知设置。MakerWorld 需要重新登录。', + 'Printing': '打印中', + 'Ready': '就绪', + 'Ready to print': '准备打印', + 'Run': '运行', + 'Run ACE macro?': '运行 ACE 宏?', + 'Run bed mesh?': '运行热床网格?', + 'Save printer': '保存打印机', + 'Save your printers and settings to a file, or restore them after a reinstall.': '将打印机和设置保存到文件,或重新安装后恢复。', + 'Saving...': '保存中...', + 'Settings restored': '设置已恢复', + 'Show active': '显示当前', + 'Start using Helix': '开始使用 Helix', + 'Success': '成功', + 'Support Helix': '支持 Helix', + 'Surface': '表面', + 'Tailscale URL': 'Tailscale 地址', + 'Tailscale URL (optional)': 'Tailscale 地址(可选)', + 'Tailscale only never falls back to Wi-Fi. Auto tries LAN first.': '仅 Tailscale 模式不会回退到 Wi-Fi。自动模式优先尝试局域网。', + 'Tailscale-only mode needs a Tailscale URL.': '仅 Tailscale 模式需要 Tailscale 地址。', + 'Tap to preview': '点击预览', + 'Temperature units': '温度单位', + 'Temperature warning': '温度警告', + 'The installer opens when the download finishes.': '下载完成后将打开安装程序。', + 'The printer will clean the nozzle, heat the bed to 55°C, and run bed mesh calibration.': '打印机将清洁喷嘴、加热热床至 55°C,并运行热床网格校准。', + 'Totally optional. Helix is free and always will be, but if you want to chip in it really helps me out as a college student.': '完全可选。Helix 永久免费,但如果你愿意支持一下,对作为大学生的我真的很有帮助。', + 'Waiting for printer': '等待打印机', + 'Welcome to Helix': '欢迎使用 Helix', + 'not detected': '未检测到', + 'offline': '离线', }, }; diff --git a/services/moonraker.ts b/services/moonraker.ts index 30db0b3..6ac7c9a 100644 --- a/services/moonraker.ts +++ b/services/moonraker.ts @@ -222,6 +222,30 @@ export async function restartMoonraker(base: string): Promise { await fetch(`${base}/server/restart`, { method: 'POST' }).catch(() => {}); } +// Writes a config file to Moonraker and restarts the service ONLY when the new +// content differs from the file already on disk. Restarting Moonraker drops the +// /usr/bin/gui MQTT feed and freezes the printer's touchscreen for ~20-30s, so +// callers must avoid spurious restarts. Returns true when a restart actually +// ran (so callers can gate any post-restart settle delay on it). +export async function applyConfigIfChanged( + base: string, + dirPath: string, + filename: string, + content: string +): Promise { + let unchanged = false; + try { + const existing = await fetch(fileUrl(base, 'config', `${dirPath}/${filename}`)); + if (existing.ok) unchanged = (await existing.text()) === content; + } catch { + // Read failed (network/auth) — fall through to upload+restart to be safe. + } + if (unchanged) return false; + await uploadConfigFile(base, dirPath, filename, content); + await restartMoonraker(base); + return true; +} + function filamentText(value: unknown, fallback: string, field: string): string { const text = String(value ?? '').trim() || fallback; if (/["\\\r\n]/.test(text)) { diff --git a/services/nativeSlicer.ts b/services/nativeSlicer.ts index 008892f..6a48224 100644 --- a/services/nativeSlicer.ts +++ b/services/nativeSlicer.ts @@ -51,6 +51,14 @@ export type SliceOptions = { brimWidth?: number; skirtLoops?: number; initialTool?: number; + // Re-slice support: full prepare-screen settings + loaded-slot material + // profiles (both JSON strings produced by the native side). When present + // the native sliceFile replays them so a background re-slice is faithful. + sliceSettings?: string; + materialProfiles?: string; + // Per-colour remap: forces configureMultiTool to size the extruder array to + // cover every mapped slot, even when the file declares fewer filaments. + forceExtruderCount?: number; }; export type NativeSliceResult = { @@ -65,6 +73,8 @@ export type NativeSliceResult = { estimatedFilamentGrams: number; initialTool?: number; usedToolMask?: number; + sliceSettings?: string; + materialProfiles?: string; }; export type MakerWorldCookies = { @@ -98,6 +108,8 @@ type HelixSlicerModule = { pickModelFile: () => Promise; getModelPlates: (path: string) => Promise; extractPlate: (path: string, plateId: number) => Promise; + collapseModel: (path: string, targetTool: number) => Promise; + remapModel: (path: string, extruderMapJson: string) => Promise; sliceFile: (path: string, options: SliceOptions | null) => Promise; cancelSlice: () => Promise; captureMakerWorldCookies: () => Promise; @@ -241,6 +253,35 @@ export async function extractModelPlate(path: string, plateId: number): Promise< return nativeModule.extractPlate(path.replace(/^file:\/\//, ''), plateId); } +/** + * Repacks a model 3MF into a temp file where every object is reassigned to + * [targetTool] (0-based) and the multi-filament project config is dropped, so a + * re-slice produces single-tool gcode in that one loaded material. Used to + * "print everything in [chosen material]" for multi-color files. Accepts file://. + */ +export async function collapseModelToTool(uriOrPath: string, targetTool: number): Promise { + if (Platform.OS !== 'android' || !nativeModule) { + throw new Error('Model collapse is Android-only.'); + } + return nativeModule.collapseModel(uriOrPath.replace(/^file:\/\//, ''), targetTool); +} + +/** + * Repacks a 3MF so each object's extruder is routed to a user-chosen loaded slot, + * KEEPING the file multi-filament so the re-slice stays multi-colour (per-colour + * remap). extruderMap keys are 1-based source extruder values, values are 0-based + * target ACE slots. Accepts file://. + */ +export async function remapModelExtruders( + uriOrPath: string, + extruderMap: Record, +): Promise { + if (Platform.OS !== 'android' || !nativeModule) { + throw new Error('Model remap is Android-only.'); + } + return nativeModule.remapModel(uriOrPath.replace(/^file:\/\//, ''), JSON.stringify(extruderMap)); +} + export type ExtractProgress = { percent: number; phase: string }; /** @@ -406,6 +447,8 @@ export async function getLastSliceResult(): Promise { estimatedFilamentGrams: raw.estimatedFilamentGrams, initialTool: raw.initialTool, usedToolMask: raw.usedToolMask, + sliceSettings: raw.sliceSettings, + materialProfiles: raw.materialProfiles, }; } diff --git a/services/notifications.ts b/services/notifications.ts index 7d66851..6fabb05 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -7,7 +7,7 @@ import { FCM_ANNOUNCEMENTS_KEY, type AppNotification, } from '../constants/changelog'; -import { restartMoonraker, uploadConfigFile } from './moonraker'; +import { applyConfigIfChanged } from './moonraker'; import { withQueryParameter } from './notificationEvents'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -210,6 +210,7 @@ export async function configureFcmForPrinter( return false; } + const webhookUrl = payload.webhookUrl as string; const notifier = [ ['complete', 'Print complete', '{event_args[1].filename} finished'], ['error', 'Print failed', '{event_args[1].filename} failed'], @@ -217,37 +218,40 @@ export async function configureFcmForPrinter( ['paused', 'Print paused', '{event_args[1].filename} is paused'], ].map(([event, title, body]) => [ `[notifier helix_${event}]`, - `url: ${moonrakerWebhookUrl(payload.webhookUrl as string, event)}`, + `url: ${moonrakerWebhookUrl(webhookUrl, event)}`, `events: ${event}`, `title: ${title}`, `body: ${body}`, '', ].join('\n')).join('\n'); + const content = `# Managed by Helix. Do not edit manually.\n${notifier}`; + const registrationMarker = `${printerId}:${token}`; + try { - await uploadConfigFile( + // Idempotency: restarting Moonraker drops the /usr/bin/gui MQTT feed and + // freezes the printer's touchscreen for ~20-30s, so only touch the printer + // when the notifier config actually changed (new token / webhook / printer). + await applyConfigIfChanged( printerBaseUrl, 'extended/moonraker', 'helix-push.cfg', - `# Managed by Helix. Do not edit manually.\n${notifier}` + content ); - await restartMoonraker(printerBaseUrl); - const registrationMarker = `${printerId}:${token}`; + + await SecureStore.setItemAsync(FCM_CONFIGURED_TOKEN_KEY, registrationMarker); + if (options?.sendTest === false) { - await SecureStore.setItemAsync(FCM_CONFIGURED_TOKEN_KEY, registrationMarker); return true; } - const test = await fetch(withQueryParameter(payload.webhookUrl, 'event', 'complete'), { + const test = await fetch(withQueryParameter(webhookUrl, 'event', 'complete'), { method: 'POST', headers: { 'X-Title': 'Helix test', 'X-Message': 'Firebase push notifications are working.', }, }); - if (test.ok) { - await SecureStore.setItemAsync(FCM_CONFIGURED_TOKEN_KEY, registrationMarker); - } return test.ok; } catch { return false;