diff --git a/.gitignore b/.gitignore index 224f8a1..6873e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ android/app/google-services.json tools/ .omc/ functions/lib/ + +# goldie: generated store assets (captures + config are tracked) +goldie/out/ +goldie/goldie.design.json diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5493a01..ac69410 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -16,7 +16,8 @@ Relevant paths in this repository: - `android/app/src/main/java/com/u1/slicer/` — JNI bridge, G-code parser, 3D viewers - `android/app/src/main/java/org/crabcore/u1control/slicing/` — Helix integration layer - `android/app/src/main/jniLibs/arm64-v8a/libprusaslicer-jni.so` — prebuilt slicing engine -- `android/app/src/main/assets/` — Orca U1 machine/process profiles and GL shaders +- `android/app/src/main/assets/` — Orca machine/process profiles, vendor build-plate + models under `bed/`, and GL shaders ## Slicing engine lineage @@ -37,6 +38,20 @@ MakerWorld is a third-party service operated by Bambu Lab. Helix opens MakerWorl pages in a WebView for user-initiated downloads; Helix does not redistribute MakerWorld content. +## Bespok3d bootstrap packages + +The Android app contains the unmodified bootstrap packages distributed with the +official Bespok3d Desktop v0.7.3 release: + +- `bespok3d-daemon` 0.12.24 — https://github.com/Bespok3d/daemon +- `bespok3d-jinni-snapmaker-u1` 0.1.10 — https://github.com/Bespok3d/adapters + +Both projects are licensed AGPL-3.0-or-later. Their signed manifests, +documentation, dependency metadata, and upstream copyright notices remain +inside `android/app/src/main/assets/bespok3d/bootstrap-v0.7.3.zip`. Helix checks +the packages against Bespok3d's pinned OpenPGP publisher key and verifies every +manifest SHA-256 before an enrollment operation can read the payload. + ## Your obligations when distributing Helix If you distribute a build that includes the native slicer (release APK, CI diff --git a/android/app/build.gradle b/android/app/build.gradle index f964e14..4b27c9c 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 19 - versionName "3.0.0" + versionCode 20 + versionName "3.0.1" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } @@ -139,11 +139,31 @@ android { } } packagingOptions { + resources { + // HiveMQ pulls in half a dozen Netty modules and each ships its own + // copy of these, which AGP will not merge. They are a jar index and + // a build stamp — nothing needs them at runtime. + excludes += [ + 'META-INF/INDEX.LIST', + 'META-INF/io.netty.versions.properties', + // Several Java-versioned dependencies carry the same OSGi + // descriptor. Android does not use OSGi metadata at runtime. + 'META-INF/versions/**/OSGI-INF/MANIFEST.MF', + ] + } jniLibs { def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' useLegacyPackaging enableLegacyPackaging.toBoolean() } } + testOptions { + unitTests { + // The Bambu transport logs through android.util.Log, whose methods + // throw "not mocked" in a plain JVM test. The live tests exercise + // real sockets, not the framework, so returning defaults is enough. + returnDefaultValues = true + } + } androidResources { ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' } @@ -169,10 +189,38 @@ android { } } +// The live Bambu tests talk to a real printer, so they read its address and +// credentials from the environment rather than from anything committed here. +// Without these the tests skip, which keeps CI and clean checkouts green. +tasks.withType(Test).configureEach { + systemProperty "helix.appProjectDir", project.projectDir.absolutePath + [ + "BAMBU_HOST", + "BAMBU_SERIAL", + "BAMBU_ACCESS_CODE", + "BAMBU_DUMP_DIR", + "BESPOK3D_U1_HOST", + "BESPOK3D_U1_PASSWORD", + ].each { key -> + def value = System.getenv(key) + if (value) { + environment key, value + } + } + testLogging { + showStandardStreams = true + events "passed", "skipped", "failed" + } +} + dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") testImplementation("junit:junit:4.13.2") + // The Android stub jar plus returnDefaultValues makes every JSONObject read + // return a default, so JSON parsers silently "pass" against nothing. A real + // implementation on the test classpath makes them testable. + testImplementation("org.json:json:20231013") implementation platform('com.google.firebase:firebase-bom:34.16.0') implementation 'com.google.firebase:firebase-messaging' @@ -180,6 +228,24 @@ dependencies { // Encrypted MakerWorld session-cookie storage (Android Keystore-backed). implementation("androidx.security:security-crypto:1.1.0-alpha06") + // Bambu Lab printers speak MQTT over TLS on 8883 with a self-signed cert. + // HiveMQ is the only maintained Java client that lets us swap in a + // permissive TrustManagerFactory — see bambu/BambuMqttModule.kt. + implementation("com.hivemq:hivemq-mqtt-client:1.3.17") + + // Bambu P1/X1 printers use implicit FTPS and require the data connection + // to resume the control connection's TLS session. Commons Net supplies the + // FTP state machine; BCJSSE is used because its public API can explicitly + // select the session to resume (Android's JSSE API cannot). + implementation("commons-net:commons-net:3.13.0") + implementation("org.bouncycastle:bcprov-jdk18on:1.83") + implementation("org.bouncycastle:bctls-jdk18on:1.83") + implementation("org.bouncycastle:bcpg-jdk18on:1.83") + + // Bespok3d enrollment begins with a read-only SSH preflight against the U1. + // This maintained JSch fork supports the stock firmware's Ed25519 host key. + implementation("com.github.mwiede:jsch:2.28.6") + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 551eb41..efaca9e 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -11,4 +11,13 @@ -keep class com.swmansion.reanimated.** { *; } -keep class com.facebook.react.turbomodule.** { *; } +# HiveMQ MQTT client (Bambu Lab LAN transport). It sits on Netty, which +# references optional native transports and JDK-only classes that do not exist +# on Android — R8 must not treat those as missing-class errors. +-dontwarn io.netty.** +-dontwarn com.hivemq.client.** +-dontwarn org.slf4j.** +-dontwarn java.lang.management.** +-keep class io.netty.channel.socket.nio.** { *; } + # Add any project specific keep options here: diff --git a/android/app/src/main/assets/bed/ad5x_bed.stl b/android/app/src/main/assets/bed/ad5x_bed.stl new file mode 100644 index 0000000..9b8b425 Binary files /dev/null and b/android/app/src/main/assets/bed/ad5x_bed.stl differ diff --git a/android/app/src/main/assets/bed/bambu_a1_mini_bed.stl b/android/app/src/main/assets/bed/bambu_a1_mini_bed.stl new file mode 100644 index 0000000..55f1d21 Binary files /dev/null and b/android/app/src/main/assets/bed/bambu_a1_mini_bed.stl differ diff --git a/android/app/src/main/assets/bed/bambu_x1_bed.stl b/android/app/src/main/assets/bed/bambu_x1_bed.stl new file mode 100644 index 0000000..d142afc Binary files /dev/null and b/android/app/src/main/assets/bed/bambu_x1_bed.stl differ diff --git a/android/app/src/main/assets/bespok3d/bootstrap-v0.7.3.zip b/android/app/src/main/assets/bespok3d/bootstrap-v0.7.3.zip new file mode 100644 index 0000000..25184f0 Binary files /dev/null and b/android/app/src/main/assets/bespok3d/bootstrap-v0.7.3.zip differ diff --git a/android/app/src/main/assets/bespok3d/helixscreen-ui-0.1.0.b3 b/android/app/src/main/assets/bespok3d/helixscreen-ui-0.1.0.b3 new file mode 100644 index 0000000..b0f9af0 Binary files /dev/null and b/android/app/src/main/assets/bespok3d/helixscreen-ui-0.1.0.b3 differ diff --git a/android/app/src/main/assets/orca_profiles/printer/bambu_a1.json b/android/app/src/main/assets/orca_profiles/printer/bambu_a1.json new file mode 100644 index 0000000..2645dd9 --- /dev/null +++ b/android/app/src/main/assets/orca_profiles/printer/bambu_a1.json @@ -0,0 +1,1659 @@ +{ + "accel_to_decel_enable": "0", + "accel_to_decel_factor": "50%", + "activate_air_filtration": [ + "0", + "0", + "0", + "0" + ], + "additional_cooling_fan_speed": [ + "70", + "0", + "70", + "0" + ], + "additional_fan_full_speed_layer": [ + "0", + "0", + "0", + "0" + ], + "alternate_extra_wall": "0", + "apply_scarf_seam_on_circles": "1", + "auxiliary_fan": "0", + "avoid_crossing_wall_includes_support": "0", + "bed_custom_model": "", + "bed_custom_texture": "", + "bed_exclude_area": [], + "bed_temperature_formula": "by_first_filament", + "before_layer_change_gcode": "", + "best_object_pos": "0.5x0.5", + "bottom_color_penetration_layers": "3", + "bottom_shell_layers": "3", + "bottom_shell_thickness": "0", + "bottom_surface_density": "100%", + "bottom_surface_pattern": "monotonic", + "bridge_angle": "0", + "bridge_flow": "1", + "bridge_no_support": "0", + "bridge_speed": [ + "50", + "50" + ], + "brim_object_gap": "0.1", + "brim_type": "auto_brim", + "brim_width": "5", + "chamber_temperatures": [ + "0", + "0", + "0", + "0" + ], + "change_filament_gcode": ";===== A1 20250822 =======================\nM1007 S0 ; turn off mass estimation\nG392 S0\nM620 S[next_extruder]A\nM204 S9000\nG1 Z{max_layer_z + 3.0} F1200\n\nM400\nM106 P1 S0\nM106 P2 S0\n{if nozzle_temperature[previous_extruder] > 142 && next_extruder < 255}\nM104 S{nozzle_temperature[previous_extruder]}\n{endif}\n\nG1 X267 F18000\n\n{if long_retractions_when_cut[previous_extruder]}\nM620.11 S1 I[previous_extruder] E-{retraction_distances_when_cut[previous_extruder]} F1200\n{else}\nM620.11 S0\n{endif}\nM400\n\nM620.1 E F{flush_volumetric_speeds[previous_extruder]/2.4053*60} T{flush_temperatures[previous_extruder]}\nM620.10 A0 F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\nT[next_extruder]\nM620.1 E F{flush_volumetric_speeds[next_extruder]/2.4053*60} T{flush_temperatures[next_extruder]}\nM620.10 A1 F{flush_volumetric_speeds[next_extruder]/2.4053*60} L[flush_length] H[nozzle_diameter] T{flush_temperatures[next_extruder]}\n\nG1 Y128 F9000\n\n{if next_extruder < 255}\n\n{if long_retractions_when_cut[previous_extruder]}\nM620.11 S1 I[previous_extruder] E{retraction_distances_when_cut[previous_extruder]} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\nM628 S1\nG92 E0\nG1 E{retraction_distances_when_cut[previous_extruder]} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\nM400\nM629 S1\n{else}\nM620.11 S0\n{endif}\n\nM400\nG92 E0\nM628 S0\n\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM1002 set_filament_type:UNKNOWN\nM109 S[flush_temperatures[next_extruder]]\nM106 P1 S60\n{if flush_length_1 > 23.7}\nG1 E23.7 F{flush_volumetric_speeds[previous_extruder]/2.4053*60} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\n{else}\nG1 E{flush_length_1} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\nM400\nM1002 set_filament_type:{filament_type[next_extruder]}\n{endif}\n\n{if flush_length_1 > 45 && flush_length_2 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_2 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 45 && flush_length_3 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_3 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 45 && flush_length_4 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_4 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n\nM629\n\nM400\nM106 P1 S60\nM109 S{nozzle_temperature[next_extruder]}\nG1 E6 F{flush_volumetric_speeds[next_extruder]/2.4053*60} ;Compensate for filament spillage during waiting temperature\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nG1 Z{max_layer_z + 3.0} F3000\nM106 P1 S0\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\n\nM622.1 S0\nM9833 F{outer_wall_volumetric_speed/2.4} A0.3 ; cali dynamic extrusion compensation\nM1002 judge_flag filament_need_cali_flag\nM622 J1\n G92 E0\n G1 E-[new_retract_length_toolchange] F1800\n M400\n \n M106 P1 S178\n M400 S4\n G1 X-38.2 F18000\n G1 X-48.2 F3000\n G1 X-38.2 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-38.2 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0 \nM623\n\nM621 S[next_extruder]A\nG392 S0\n\nM1007 S1\n", + "circle_compensation_manual_offset": "0", + "circle_compensation_speed": [ + "200", + "200", + "200", + "200" + ], + "close_additional_fan_first_x_layers": [ + "1", + "3", + "1", + "3" + ], + "close_fan_the_first_x_layers": [ + "1", + "3", + "1", + "3" + ], + "complete_print_exhaust_fan_speed": [ + "70", + "70", + "70", + "70" + ], + "cool_plate_temp": [ + "35", + "0", + "35", + "0" + ], + "cool_plate_temp_initial_layer": [ + "35", + "0", + "35", + "0" + ], + "cooling_filter_enabled": "0", + "cooling_perimeter_transition_distance": [ + "10", + "10", + "10", + "10" + ], + "cooling_slowdown_logic": [ + "uniform_cooling", + "uniform_cooling", + "uniform_cooling", + "uniform_cooling" + ], + "counter_coef_1": [ + "0", + "0", + "0", + "0" + ], + "counter_coef_2": [ + "0.008", + "0.008", + "0.008", + "0.008" + ], + "counter_coef_3": [ + "-0.041", + "-0.041", + "-0.041", + "-0.041" + ], + "counter_limit_max": [ + "0.033", + "0.033", + "0.033", + "0.033" + ], + "counter_limit_min": [ + "-0.035", + "-0.035", + "-0.035", + "-0.035" + ], + "curr_bed_type": "Supertack Plate", + "default_acceleration": [ + "10000", + "10000" + ], + "default_filament_colour": [ + "", + "", + "", + "" + ], + "default_filament_profile": [ + "Bambu PLA Basic @BBL A1" + ], + "default_jerk": "0", + "default_nozzle_volume_type": [ + "Standard" + ], + "default_print_profile": "0.20mm Standard @BBL A1", + "deretraction_speed": [ + "30" + ], + "detect_floating_vertical_shell": "1", + "detect_narrow_internal_solid_infill": "1", + "detect_overhang_wall": "1", + "detect_thin_wall": "0", + "diameter_limit": [ + "50", + "50", + "50", + "50" + ], + "draft_shield": "disabled", + "during_print_exhaust_fan_speed": [ + "70", + "70", + "70", + "70" + ], + "elefant_foot_compensation": "0.15", + "embedding_wall_into_infill": "0", + "enable_arc_fitting": "1", + "enable_circle_compensation": "0", + "enable_filament_dynamic_map": "0", + "enable_height_slowdown": [ + "0", + "0" + ], + "enable_long_retraction_when_cut": "2", + "enable_mixed_color_sublayer": "0", + "enable_order_independent_overlap_carving": "0", + "enable_overhang_bridge_fan": [ + "1", + "1", + "1", + "1" + ], + "enable_overhang_speed": [ + "1", + "1" + ], + "enable_pre_heating": "0", + "enable_pressure_advance": [ + "0", + "0", + "0", + "0" + ], + "enable_prime_tower": "1", + "enable_support": "0", + "enable_support_ironing": "0", + "enable_tower_interface_features": "0", + "enable_wrapping_detection": "0", + "enforce_support_layers": "0", + "eng_plate_temp": [ + "0", + "70", + "0", + "70" + ], + "eng_plate_temp_initial_layer": [ + "0", + "70", + "0", + "70" + ], + "ensure_vertical_shell_thickness": "enabled", + "exclude_object": "1", + "extruder_ams_count": [ + "1#0|4#0", + "1#0|4#0" + ], + "extruder_clearance_dist_to_rod": "56.5", + "extruder_clearance_height_to_lid": "256", + "extruder_clearance_height_to_rod": "25", + "extruder_clearance_max_radius": "73", + "extruder_colour": [ + "#018001" + ], + "extruder_max_nozzle_count": [ + "1" + ], + "extruder_nozzle_stats": [ + "Standard#1" + ], + "extruder_offset": [ + "0x0" + ], + "extruder_printable_area": [], + "extruder_printable_height": [], + "extruder_type": [ + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive Standard" + ], + "fan_cooling_layer_time": [ + "80", + "15", + "80", + "15" + ], + "fan_direction": "undefine", + "fan_max_speed": [ + "80", + "50", + "80", + "50" + ], + "fan_min_speed": [ + "60", + "30", + "60", + "30" + ], + "filament_adaptive_volumetric_speed": [ + "0", + "0", + "0", + "0" + ], + "filament_adhesiveness_category": [ + "100", + "300", + "100", + "300" + ], + "filament_bridge_speed": [ + "25", + "25", + "25", + "25" + ], + "filament_change_length": [ + "5", + "12", + "5", + "12" + ], + "filament_change_length_nc": [ + "10", + "10", + "10", + "10" + ], + "filament_colour": [ + "#E4BD68", + "#FFFFFF", + "#000000", + "#BC0900" + ], + "filament_colour_type": [ + "1", + "0", + "1", + "1" + ], + "filament_cooling_before_tower": [ + "0", + "0", + "0", + "0" + ], + "filament_cost": [ + "24.99", + "24.99", + "24.99", + "24.99" + ], + "filament_density": [ + "1.26", + "1.28", + "1.26", + "1.28" + ], + "filament_deretraction_speed": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0", + "1", + "0", + "1", + "0", + "1", + "0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "45", + "75", + "45", + "75" + ], + "filament_dev_ams_drying_temperature": [ + "45", + "45", + "45", + "45", + "65", + "65", + "55", + "55", + "45", + "45", + "45", + "45", + "65", + "65", + "55", + "55" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12", + "12" + ], + "filament_dev_chamber_drying_bed_temperature": [ + "70", + "80", + "70", + "80" + ], + "filament_dev_chamber_drying_time": [ + "12.0", + "12.0", + "12.0", + "12.0" + ], + "filament_dev_drying_cooling_temperature": [ + "45", + "55", + "45", + "55" + ], + "filament_dev_drying_softening_temperature": [ + "50", + "60", + "50", + "60" + ], + "filament_diameter": [ + "1.75", + "1.75", + "1.75", + "1.75" + ], + "filament_enable_overhang_speed": [ + "1", + "1", + "1", + "1" + ], + "filament_end_gcode": [ + "; filament end gcode \n\n", + "; filament end gcode \n\n", + "; filament end gcode \n\n", + "; filament end gcode \n\n" + ], + "filament_extruder_compatibility": [ + "0", + "0", + "0", + "0" + ], + "filament_extruder_variant": [ + "Direct Drive Standard", + "Direct Drive Standard", + "Direct Drive Standard", + "Direct Drive Standard" + ], + "filament_flow_ratio": [ + "0.98", + "0.94", + "0.98", + "0.94" + ], + "filament_flush_temp": [ + "0", + "0", + "0", + "0" + ], + "filament_flush_temp_fast": [ + "0", + "0", + "0", + "0" + ], + "filament_flush_volumetric_speed": [ + "0", + "0", + "0", + "0" + ], + "filament_ids": [ + "GFA00", + "GFG02", + "GFA00", + "GFG02" + ], + "filament_is_mixed": [ + "0", + "0", + "0", + "0" + ], + "filament_is_support": [ + "0", + "0", + "0", + "0" + ], + "filament_long_retractions_when_cut": [ + "1", + "1", + "1", + "1" + ], + "filament_map": [ + "1", + "1", + "1", + "1" + ], + "filament_map_mode": "Auto For Flush", + "filament_max_volumetric_speed": [ + "21", + "18", + "21", + "18" + ], + "filament_metal_stickiness": [ + "None", + "High", + "None", + "High" + ], + "filament_minimal_purge_on_wipe_tower": [ + "15", + "15", + "15", + "15" + ], + "filament_mixed_components": [ + "" + ], + "filament_mixed_gradient": [ + "0", + "0", + "0", + "0" + ], + "filament_mixed_gradient_curve": [ + "" + ], + "filament_mixed_gradient_per_part": [ + "0" + ], + "filament_mixed_gradient_range": [ + "" + ], + "filament_mixed_sublayer_ratios": [ + "" + ], + "filament_multi_colour": [ + "#E4BD68", + "#FFFFFF", + "#000000", + "#BC0900" + ], + "filament_notes": "", + "filament_nozzle_map": [ + "0", + "0", + "0", + "0" + ], + "filament_overhang_1_4_speed": [ + "0", + "0", + "0", + "0" + ], + "filament_overhang_2_4_speed": [ + "50", + "50", + "50", + "50" + ], + "filament_overhang_3_4_speed": [ + "30", + "30", + "30", + "30" + ], + "filament_overhang_4_4_speed": [ + "10", + "10", + "10", + "10" + ], + "filament_overhang_totally_speed": [ + "10", + "10", + "10", + "10" + ], + "filament_pre_cooling_temperature": [ + "0", + "0", + "0", + "0" + ], + "filament_pre_cooling_temperature_nc": [ + "0", + "0", + "0", + "0" + ], + "filament_preheat_temperature_delta": [ + "0", + "0", + "0", + "0" + ], + "filament_prime_volume": [ + "30", + "30", + "30", + "30" + ], + "filament_prime_volume_nc": [ + "60", + "60", + "60", + "60" + ], + "filament_printable": [ + "3", + "3", + "3", + "3" + ], + "filament_ramming_travel_time": [ + "0", + "0", + "0", + "0" + ], + "filament_ramming_travel_time_nc": [ + "0", + "0", + "0", + "0" + ], + "filament_ramming_volumetric_speed": [ + "-1", + "-1", + "-1", + "-1" + ], + "filament_ramming_volumetric_speed_nc": [ + "-1", + "-1", + "-1", + "-1" + ], + "filament_retract_before_wipe": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_retract_length_nc": [ + "14", + "14", + "14", + "14" + ], + "filament_retract_restart_extra": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_retract_when_changing_layer": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_retraction_distances_when_cut": [ + "18", + "18", + "18", + "18" + ], + "filament_retraction_length": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_retraction_minimum_travel": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_retraction_speed": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_scarf_gap": [ + "0%", + "0%", + "0%", + "0%" + ], + "filament_scarf_height": [ + "10%", + "10%", + "10%", + "10%" + ], + "filament_scarf_length": [ + "10", + "10", + "10", + "10" + ], + "filament_scarf_seam_type": [ + "none", + "none", + "none", + "none" + ], + "filament_self_index": [ + "1", + "1", + "2", + "2", + "3", + "3", + "4", + "4" + ], + "filament_settings_id": [ + "Bambu PLA Basic @BBL A1", + "Bambu PETG HF @BBL A1", + "Bambu PLA Basic @BBL A1", + "Bambu PETG HF @BBL A1" + ], + "filament_shrink": [ + "100%", + "100%", + "100%", + "100%" + ], + "filament_soluble": [ + "0", + "0", + "0", + "0" + ], + "filament_start_gcode": [ + "; filament start gcode\n{if (bed_temperature[current_extruder] >55)||(bed_temperature_initial_layer[current_extruder] >55)}M106 P3 S200\n{elsif(bed_temperature[current_extruder] >50)||(bed_temperature_initial_layer[current_extruder] >50)}M106 P3 S150\n{elsif(bed_temperature[current_extruder] >45)||(bed_temperature_initial_layer[current_extruder] >45)}M106 P3 S50\n{endif}\n\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}", + "; filament start gcode\n{if (bed_temperature[current_extruder] >80)||(bed_temperature_initial_layer[current_extruder] >80)}M106 P3 S255\n{elsif (bed_temperature[current_extruder] >60)||(bed_temperature_initial_layer[current_extruder] >60)}M106 P3 S180\n{endif}\n\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}", + "; filament start gcode\n{if (bed_temperature[current_extruder] >55)||(bed_temperature_initial_layer[current_extruder] >55)}M106 P3 S200\n{elsif(bed_temperature[current_extruder] >50)||(bed_temperature_initial_layer[current_extruder] >50)}M106 P3 S150\n{elsif(bed_temperature[current_extruder] >45)||(bed_temperature_initial_layer[current_extruder] >45)}M106 P3 S50\n{endif}\n\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}", + "; filament start gcode\n{if (bed_temperature[current_extruder] >80)||(bed_temperature_initial_layer[current_extruder] >80)}M106 P3 S255\n{elsif (bed_temperature[current_extruder] >60)||(bed_temperature_initial_layer[current_extruder] >60)}M106 P3 S180\n{endif}\n\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}" + ], + "filament_tower_interface_pre_extrusion_dist": [ + "10", + "10", + "10", + "10" + ], + "filament_tower_interface_pre_extrusion_length": [ + "0", + "0", + "0", + "0" + ], + "filament_tower_interface_print_temp": [ + "-1", + "-1", + "-1", + "-1" + ], + "filament_tower_interface_purge_volume": [ + "20", + "20", + "20", + "20" + ], + "filament_tower_ironing_area": [ + "4", + "4", + "4", + "4" + ], + "filament_type": [ + "PLA", + "PETG", + "PLA", + "PETG" + ], + "filament_velocity_adaptation_factor": [ + "1", + "1", + "1", + "1" + ], + "filament_vendor": [ + "Bambu Lab", + "Bambu Lab", + "Bambu Lab", + "Bambu Lab" + ], + "filament_volume_map": [ + "0", + "0", + "0", + "0" + ], + "filament_wipe": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_wipe_distance": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_z_hop": [ + "nil", + "nil", + "nil", + "nil" + ], + "filament_z_hop_types": [ + "nil", + "nil", + "nil", + "nil" + ], + "filename_format": "{input_filename_base}_{filament_type[0]}_{print_time}.gcode", + "fill_multiline": "1", + "filter_out_gap_fill": "0", + "first_layer_print_sequence": [ + "0" + ], + "first_x_layer_fan_speed": [ + "0", + "0", + "0", + "0" + ], + "first_x_layer_part_fan_speed": [ + "0", + "0", + "0", + "0" + ], + "flush_into_infill": "0", + "flush_into_objects": "0", + "flush_into_support": "1", + "flush_multiplier": [ + "1" + ], + "flush_multiplier_fast": [ + "1.2" + ], + "flush_volumes_matrix": [ + "0", + "273", + "173", + "256", + "700", + "0", + "700", + "269", + "629", + "598", + "0", + "355", + "700", + "628", + "700", + "0" + ], + "flush_volumes_vector": [ + "140", + "140", + "140", + "140", + "140", + "140", + "140", + "140" + ], + "full_fan_speed_layer": [ + "0", + "0", + "0", + "0" + ], + "fuzzy_skin": "none", + "fuzzy_skin_first_layer": "0", + "fuzzy_skin_mode": "displacement", + "fuzzy_skin_noise_type": "classic", + "fuzzy_skin_octaves": "4", + "fuzzy_skin_persistence": "0.5", + "fuzzy_skin_point_distance": "0.8", + "fuzzy_skin_scale": "1", + "fuzzy_skin_thickness": "0.3", + "gap_infill_speed": [ + "250", + "250" + ], + "gcode_add_line_number": "0", + "gcode_flavor": "marlin", + "grab_length": [ + "17.4" + ], + "group_algo_with_time": "0", + "has_filament_switcher": "0", + "has_scarf_joint_seam": "0", + "head_wrap_detect_zone": [ + "226x224", + "256x224", + "256x256", + "226x256" + ], + "hole_coef_1": [ + "0", + "0", + "0", + "0" + ], + "hole_coef_2": [ + "-0.008", + "-0.008", + "-0.008", + "-0.008" + ], + "hole_coef_3": [ + "0.23415", + "0.23415", + "0.23415", + "0.23415" + ], + "hole_limit_max": [ + "0.22", + "0.22", + "0.22", + "0.22" + ], + "hole_limit_min": [ + "0.088", + "0.088", + "0.088", + "0.088" + ], + "host_type": "octoprint", + "hot_plate_temp": [ + "65", + "70", + "65", + "70" + ], + "hot_plate_temp_initial_layer": [ + "65", + "70", + "65", + "70" + ], + "hotend_cooling_rate": [ + "2" + ], + "hotend_heating_rate": [ + "2" + ], + "impact_strength_z": [ + "13.8", + "10.6", + "13.8", + "10.6" + ], + "independent_support_layer_height": "1", + "infill_combination": "0", + "infill_direction": "45", + "infill_instead_top_bottom_surfaces": "0", + "infill_jerk": "9", + "infill_lock_depth": "1", + "infill_rotate_step": "0", + "infill_shift_step": "0.4", + "infill_wall_overlap": "15%", + "initial_layer_acceleration": [ + "500", + "500" + ], + "initial_layer_flow_ratio": "1", + "initial_layer_infill_speed": [ + "105", + "105" + ], + "initial_layer_jerk": "9", + "initial_layer_line_width": "0.5", + "initial_layer_print_height": "0.2", + "initial_layer_speed": [ + "50", + "50" + ], + "initial_layer_travel_acceleration": [ + "6000", + "6000" + ], + "inner_wall_acceleration": [ + "0", + "0" + ], + "inner_wall_jerk": "9", + "inner_wall_line_width": "0.45", + "inner_wall_speed": [ + "300", + "400" + ], + "interface_shells": "0", + "interlocking_beam": "0", + "interlocking_beam_layer_count": "2", + "interlocking_beam_width": "0.8", + "interlocking_boundary_avoidance": "2", + "interlocking_depth": "2", + "interlocking_orientation": "22.5", + "internal_bridge_support_thickness": "0.8", + "internal_solid_infill_line_width": "0.42", + "internal_solid_infill_pattern": "zig-zag", + "internal_solid_infill_speed": [ + "250", + "350" + ], + "ironing_direction": "45", + "ironing_fan_speed": [ + "-1", + "-1", + "-1", + "-1" + ], + "ironing_flow": "10%", + "ironing_inset": "0.21", + "ironing_pattern": "zig-zag", + "ironing_spacing": "0.15", + "ironing_speed": "30", + "ironing_type": "no ironing", + "is_infill_first": "0", + "layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change", + "layer_height": "0.2", + "line_width": "0.42", + "locked_skeleton_infill_pattern": "zigzag", + "locked_skin_infill_pattern": "crosszag", + "long_retractions_when_cut": [ + "0" + ], + "long_retractions_when_ec": [ + "0", + "0", + "0", + "0" + ], + "machine_bed_mass_Y": "0", + "machine_end_gcode": ";===== date: 20231229 =====================\nG392 S0 ;turn off nozzle clog detect\n\nM400 ; wait for buffer to clear\nG92 E0 ; zero the extruder\nG1 E-0.8 F1800 ; retract\nG1 Z{max_layer_z + 0.5} F900 ; lower z a little\nG1 X0 Y{first_layer_center_no_wipe_tower[1]} F18000 ; move to safe pos\nG1 X-13.0 F3000 ; move to safe pos\n{if !spiral_mode && print_sequence != \"by object\"}\nM1002 judge_flag timelapse_record_flag\nM622 J1\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM991 S0 P-1 ;end timelapse at safe pos\nM623\n{endif}\n\nM140 S0 ; turn off bed\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off remote part cooling fan\nM106 P3 S0 ; turn off chamber cooling fan\n\n;G1 X27 F15000 ; wipe\n\n; pull back filament to AMS\nM620 S255\nG1 X267 F15000\nT255\nG1 X-28.5 F18000\nG1 X-48.2 F3000\nG1 X-28.5 F18000\nG1 X-48.2 F3000\nM621 S255\n\nM104 S0 ; turn off hotend\n\nM400 ; wait all motion done\nM17 S\nM17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom\n{if (max_layer_z + 100.0) < 256}\n G1 Z{max_layer_z + 100.0} F600\n G1 Z{max_layer_z +98.0}\n{else}\n G1 Z256 F600\n G1 Z256\n{endif}\nM400 P100\nM17 R ; restore z current\n\nG90\nG1 X-48 Y180 F3600\n\nM220 S100 ; Reset feedrate magnitude\nM201.2 K1.0 ; Reset acc magnitude\nM73.2 R1.0 ;Reset left time magnitude\nM1002 set_gcode_claim_speed_level : 0\n\n;=====printer finish sound=========\nM17\nM400 S1\nM1006 S1\nM1006 A0 B20 L100 C37 D20 M40 E42 F20 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C46 D10 M80 E46 F10 N80\nM1006 A44 B20 L100 C39 D20 M60 E48 F20 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C39 D10 M60 E39 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C39 D10 M60 E39 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C48 D10 M60 E44 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A44 B20 L100 C49 D20 M80 E41 F20 N80\nM1006 A0 B20 L100 C0 D20 M60 E0 F20 N80\nM1006 A0 B20 L100 C37 D20 M30 E37 F20 N60\nM1006 W\n;=====printer finish sound=========\n\n;M17 X0.8 Y0.8 Z0.5 ; lower motor current to 45% power\nM400\nM18 X Y Z\n\n", + "machine_hotend_change_time": "0", + "machine_load_filament_time": "25", + "machine_max_acceleration_e": [ + "5000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "12000", + "12000" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "9000", + "9000" + ], + "machine_max_acceleration_x": [ + "12000", + "12000" + ], + "machine_max_acceleration_y": [ + "12000", + "12000" + ], + "machine_max_acceleration_z": [ + "1500", + "1500" + ], + "machine_max_force_Y": "0", + "machine_max_jerk_e": [ + "3", + "3" + ], + "machine_max_jerk_x": [ + "9", + "9" + ], + "machine_max_jerk_y": [ + "9", + "9" + ], + "machine_max_jerk_z": [ + "3", + "3" + ], + "machine_max_printed_mass": "0", + "machine_max_speed_e": [ + "30", + "30" + ], + "machine_max_speed_x": [ + "500", + "200" + ], + "machine_max_speed_y": [ + "500", + "200" + ], + "machine_max_speed_z": [ + "30", + "30" + ], + "machine_min_extruding_rate": [ + "0", + "0" + ], + "machine_min_travel_rate": [ + "0", + "0" + ], + "machine_pause_gcode": "M400 U1", + "machine_prepare_compensation_time": "260", + "machine_start_gcode": ";===== machine: A1 =========================\n;===== date: 20250822 ==================\nG392 S0\nM9833.2\n;M400\n;M73 P1.717\n\n;===== start to heat heatbead&hotend==========\nM1002 gcode_claim_action : 2\nM1002 set_filament_type:{filament_type[initial_no_support_extruder]}\nM104 S140\nM140 S[bed_temperature_initial_layer_single]\n\n;=====start printer sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A0 B10 L100 C37 D10 M60 E37 F10 N60\nM1006 A0 B10 L100 C41 D10 M60 E41 F10 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A43 B10 L100 C46 D10 M70 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C43 D10 M60 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C41 D10 M80 E41 F10 N80\nM1006 A0 B10 L100 C44 D10 M80 E44 F10 N80\nM1006 A0 B10 L100 C49 D10 M80 E49 F10 N80\nM1006 A0 B10 L100 C0 D10 M80 E0 F10 N80\nM1006 A44 B10 L100 C48 D10 M60 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C44 D10 M80 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A43 B10 L100 C46 D10 M60 E39 F10 N80\nM1006 W\nM18 \n;=====start printer sound ===================\n\n;=====avoid end stop =================\nG91\nG380 S2 Z40 F1200\nG380 S3 Z-15 F1200\nG90\n\n;===== reset machine status =================\n;M290 X39 Y39 Z8\nM204 S6000\n\nM630 S0 P0\nG91\nM17 Z0.3 ; lower the z-motor current\n\nG90\nM17 X0.65 Y1.2 Z0.6 ; reset motor current to default\nM960 S5 P1 ; turn on logo lamp\nG90\nM220 S100 ;Reset Feedrate\nM221 S100 ;Reset Flowrate\nM73.2 R1.0 ;Reset left time magnitude\n;M211 X0 Y0 Z0 ; turn off soft endstop to prevent protential logic problem\n\n;====== cog noise reduction=================\nM982.2 S1 ; turn on cog noise reduction\n\nM1002 gcode_claim_action : 13\n\nG28 X\nG91\nG1 Z5 F1200\nG90\nG0 X128 F30000\nG0 Y254 F3000\nG91\nG1 Z-5 F1200\n\nM109 S25 H140\n\nM17 E0.3\nM83\nG1 E10 F1200\nG1 E-0.5 F30\nM17 D\n\nG28 Z P0 T140; home z with low precision,permit 300deg temperature\nM104 S{nozzle_temperature_initial_layer[initial_extruder]}\n\nM1002 judge_flag build_plate_detect_flag\nM622 S1\n G39.4\n G90\n G1 Z5 F1200\nM623\n\n;M400\n;M73 P1.717\n\n;===== prepare print temperature and material ==========\nM1002 gcode_claim_action : 24\n\nM400\n;G392 S1\nM211 X0 Y0 Z0 ;turn off soft endstop\nM975 S1 ; turn on\n\nG90\nG1 X-28.5 F30000\nG1 X-48.2 F3000\n\nM620 M ;enable remap\nM620 S[initial_no_support_extruder]A ; switch material if AMS exist\n M1002 gcode_claim_action : 4\n M400\n M1002 set_filament_type:UNKNOWN\n M109 S[nozzle_temperature_initial_layer]\n M104 S250\n M400\n T[initial_no_support_extruder]\n G1 X-48.2 F3000\n M400\n\n M620.1 E F{flush_volumetric_speeds[initial_no_support_extruder]/2.4053*60} T{flush_temperatures[initial_no_support_extruder]}\n M109 S250 ;set nozzle to common flush temp\n M106 P1 S0\n G92 E0\n G1 E50 F200\n M400\n M1002 set_filament_type:{filament_type[initial_no_support_extruder]}\nM621 S[initial_no_support_extruder]A\n\nM109 S{flush_temperatures[initial_no_support_extruder]} H300\nG92 E0\nG1 E50 F200 ; lower extrusion speed to avoid clog\nM400\nM106 P1 S178\nG92 E0\nG1 E5 F200\nM104 S{nozzle_temperature_initial_layer[initial_no_support_extruder]}\nG92 E0\nG1 E-0.5 F300\n\nG1 X-28.5 F30000\nG1 X-48.2 F3000\nG1 X-28.5 F30000 ;wipe and shake\nG1 X-48.2 F3000\nG1 X-28.5 F30000 ;wipe and shake\nG1 X-48.2 F3000\n\n;G392 S0\n\nM400\nM106 P1 S0\n;===== prepare print temperature and material end =====\n\n;M400\n;M73 P1.717\n\n;===== auto extrude cali start =========================\nM975 S1\n;G392 S1\n\nG90\nM83\nT1000\nG1 X-48.2 Y0 Z10 F10000\nM400\nM1002 set_filament_type:UNKNOWN\n\nM412 S1 ; ===turn on filament runout detection===\nM400 P10\nM620.3 W1; === turn on filament tangle detection===\nM400 S2\n\nM1002 set_filament_type:{filament_type[initial_no_support_extruder]}\n\n;M1002 set_flag extrude_cali_flag=1\nM1002 judge_flag extrude_cali_flag\n\nM622 J1\n M1002 gcode_claim_action : 8\n\n M109 S{nozzle_temperature[initial_extruder]}\n G1 E10 F{outer_wall_volumetric_speed/2.4*60}\n M983 F{outer_wall_volumetric_speed/2.4} A0.3 H[nozzle_diameter]; cali dynamic extrusion compensation\n\n M106 P1 S255\n M400 S5\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0\n\n M1002 judge_last_extrude_cali_success\n M622 J0\n M983 F{outer_wall_volumetric_speed/2.4} A0.3 H[nozzle_diameter]; cali dynamic extrusion compensation\n M106 P1 S255\n M400 S5\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n M400\n M106 P1 S0\n M623\n \n G1 X-48.2 F3000\n M400\n M984 A0.1 E1 S1 F{outer_wall_volumetric_speed/2.4} H[nozzle_diameter]\n M106 P1 S178\n M400 S7\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0\nM623 ; end of \"draw extrinsic para cali paint\"\n\n;G392 S0\n;===== auto extrude cali end ========================\n\n;M400\n;M73 P1.717\n\nM104 S170 ; prepare to wipe nozzle\nM106 S255 ; turn on fan\n\n;===== mech mode fast check start =====================\nM1002 gcode_claim_action : 3\n\nG1 X128 Y128 F20000\nG1 Z5 F1200\nM400 P200\nM970.3 Q1 A5 K0 O3\nM974 Q1 S2 P0\n\nM970.2 Q1 K1 W58 Z0.1\nM974 S2\n\nG1 X128 Y128 F20000\nG1 Z5 F1200\nM400 P200\nM970.3 Q0 A10 K0 O1\nM974 Q0 S2 P0\n\nM970.2 Q0 K1 W78 Z0.1\nM974 S2\n\nM975 S1\nG1 F30000\nG1 X0 Y5\nG28 X ; re-home XY\n\nG1 Z4 F1200\n\n;===== mech mode fast check end =======================\n\n;M400\n;M73 P1.717\n\n;===== wipe nozzle ===============================\nM1002 gcode_claim_action : 14\n\nM975 S1\nM106 S255 ; turn on fan (G28 has turn off fan)\nM211 S; push soft endstop status\nM211 X0 Y0 Z0 ;turn off Z axis endstop\n\n;===== remove waste by touching start =====\n\nM104 S170 ; set temp down to heatbed acceptable\n\nM83\nG1 E-1 F500\nG90\nM83\n\nM109 S170\nG0 X108 Y-0.5 F30000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X110 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X112 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X114 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X116 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X118 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X120 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X122 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X124 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X126 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X128 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X130 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X132 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X134 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X136 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X138 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X140 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X142 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X144 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X146 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X148 F10000\nG380 S3 Z-5 F1200\n\nG1 Z5 F30000\n;===== remove waste by touching end =====\n\nG1 Z10 F1200\nG0 X118 Y261 F30000\nG1 Z5 F1200\nM109 S{nozzle_temperature_initial_layer[initial_extruder]-50}\n\nG28 Z P0 T300; home z with low precision,permit 300deg temperature\nG29.2 S0 ; turn off ABL\nM104 S140 ; prepare to abl\nG0 Z5 F20000\n\nG0 X128 Y261 F20000 ; move to exposed steel surface\nG0 Z-1.01 F1200 ; stop the nozzle\n\nG91\nG2 I1 J0 X2 Y0 F2000.1\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\n\nG90\nG1 Z10 F1200\n\n;===== brush material wipe nozzle =====\n\nG90\nG1 Y250 F30000\nG1 X55\nG1 Z1.300 F1200\nG1 Y262.5 F6000\nG91\nG1 X-35 F30000\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Z5.000 F1200\n\nG90\nG1 X30 Y250.000 F30000\nG1 Z1.300 F1200\nG1 Y262.5 F6000\nG91\nG1 X35 F30000\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Z10.000 F1200\n\n;===== brush material wipe nozzle end =====\n\nG90\n;G0 X128 Y261 F20000 ; move to exposed steel surface\nG1 Y250 F30000\nG1 X138\nG1 Y261\nG0 Z-1.01 F1200 ; stop the nozzle\n\nG91\nG2 I1 J0 X2 Y0 F2000.1\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\n\nM109 S140\nM106 S255 ; turn on fan (G28 has turn off fan)\n\nM211 R; pop softend status\n\n;===== wipe nozzle end ================================\n\n;M400\n;M73 P1.717\n\n;===== bed leveling ==================================\nM1002 judge_flag g29_before_print_flag\n\nG90\nG1 Z5 F1200\nG1 X0 Y0 F30000\nG29.2 S1 ; turn on ABL\n\nM190 S[bed_temperature_initial_layer_single]; ensure bed temp\nM109 S140\nM106 S0 ; turn off fan , too noisy\n\nM622 J1\n M1002 gcode_claim_action : 1\n G29 A1 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]}\n M400\n M500 ; save cali data\nM623\n;===== bed leveling end ================================\n\n;===== home after wipe mouth============================\nM1002 judge_flag g29_before_print_flag\nM622 J0\n\n M1002 gcode_claim_action : 13\n G28\n\nM623\n\n;===== home after wipe mouth end =======================\n\n;M400\n;M73 P1.717\n\nG1 X108.000 Y-0.500 F30000\nG1 Z0.300 F1200\nM400\nG2814 Z0.32\n\nM104 S{nozzle_temperature_initial_layer[initial_extruder]} ; prepare to print\n\n;===== nozzle load line ===============================\n;G90\n;M83\n;G1 Z5 F1200\n;G1 X88 Y-0.5 F20000\n;G1 Z0.3 F1200\n\n;M109 S{nozzle_temperature_initial_layer[initial_extruder]}\n\n;G1 E2 F300\n;G1 X168 E4.989 F6000\n;G1 Z1 F1200\n;===== nozzle load line end ===========================\n\n;===== extrude cali test ===============================\n\nM400\n M900 S\n M900 C\n G90\n M83\n\n M109 S{nozzle_temperature_initial_layer[initial_extruder]}\n G0 X128 E8 F{outer_wall_volumetric_speed/(24/20) * 60}\n G0 X133 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X138 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X143 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X148 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X153 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G91\n G1 X1 Z-0.300\n G1 X4\n G1 Z1 F1200\n G90\n M400\n\nM900 R\n\nM1002 judge_flag extrude_cali_flag\nM622 J1\n G90\n G1 X108.000 Y1.000 F30000\n G91\n G1 Z-0.700 F1200\n G90\n M83\n G0 X128 E10 F{outer_wall_volumetric_speed/(24/20) * 60}\n G0 X133 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X138 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X143 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X148 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X153 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G91\n G1 X1 Z-0.300\n G1 X4\n G1 Z1 F1200\n G90\n M400\nM623\n\nG1 Z0.2\n\n;M400\n;M73 P1.717\n\n;========turn off light and wait extrude temperature =============\nM1002 gcode_claim_action : 0\nM400\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n;curr_bed_type={curr_bed_type}\n{if curr_bed_type==\"Textured PEI Plate\"}\nG29.1 Z{-0.02} ; for Textured PEI Plate\n{endif}\n\nM960 S1 P0 ; turn off laser\nM960 S2 P0 ; turn off laser\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off big fan\nM106 P3 S0 ; turn off chamber fan\n\nM975 S1 ; turn on mech mode supression\nG90\nM83\nT1000\n\nM211 X0 Y0 Z0 ;turn off soft endstop\n;G392 S1 ; turn on clog detection\nM1007 S1 ; turn on mass estimation\nG29.4\n", + "machine_switch_extruder_time": "0", + "machine_unload_filament_time": "29", + "master_extruder_id": "1", + "max_bridge_length": "0", + "max_layer_height": [ + "0.28" + ], + "max_travel_detour_distance": "0", + "min_bead_width": "85%", + "min_feature_size": "25%", + "min_layer_height": [ + "0.08" + ], + "minimum_sparse_infill_area": "15", + "mmu_segmented_region_interlocking_depth": "0", + "mmu_segmented_region_max_width": "0", + "monotonic_travel_into_wall": "0%", + "name": "project_settings", + "no_slow_down_for_cooling_on_outwalls": [ + "0", + "0", + "0", + "0" + ], + "nozzle_diameter": [ + "0.4" + ], + "nozzle_flush_dataset": [ + "0" + ], + "nozzle_height": "4.76", + "nozzle_temperature": [ + "220", + "240", + "220", + "240" + ], + "nozzle_temperature_initial_layer": [ + "220", + "230", + "220", + "230" + ], + "nozzle_temperature_range_high": [ + "240", + "270", + "240", + "270" + ], + "nozzle_temperature_range_low": [ + "190", + "230", + "190", + "230" + ], + "nozzle_type": [ + "stainless_steel" + ], + "nozzle_volume": [ + "92" + ], + "nozzle_volume_type": [ + "Standard" + ], + "only_one_wall_first_layer": "0", + "ooze_prevention": "0", + "other_layers_print_sequence": [ + "0" + ], + "other_layers_print_sequence_nums": "0", + "outer_wall_acceleration": [ + "5000", + "5000" + ], + "outer_wall_jerk": "9", + "outer_wall_line_width": "0.42", + "outer_wall_speed": [ + "200", + "350" + ], + "overhang_1_4_speed": [ + "0", + "0" + ], + "overhang_2_4_speed": [ + "50", + "50" + ], + "overhang_3_4_speed": [ + "30", + "30" + ], + "overhang_4_4_speed": [ + "10", + "10" + ], + "overhang_fan_speed": [ + "100", + "100", + "100", + "100" + ], + "overhang_fan_threshold": [ + "50%", + "10%", + "50%", + "10%" + ], + "overhang_threshold_participating_cooling": [ + "95%", + "95%", + "95%", + "95%" + ], + "overhang_totally_speed": [ + "10", + "30" + ], + "override_filament_scarf_seam_setting": "0", + "override_process_overhang_speed": [ + "0", + "0", + "0", + "0" + ], + "physical_extruder_map": [ + "0" + ], + "post_process": [], + "pre_start_fan_time": [ + "2", + "2", + "2", + "2" + ], + "precise_outer_wall": "0", + "precise_z_height": "0", + "pressure_advance": [ + "0.02", + "0.02", + "0.02", + "0.02" + ], + "prime_tower_brim_width": "3", + "prime_tower_enable_framework": "0", + "prime_tower_extra_rib_length": "0", + "prime_tower_fillet_wall": "1", + "prime_tower_flat_ironing": "0", + "prime_tower_infill_gap": "150%", + "prime_tower_lift_height": "-1", + "prime_tower_lift_speed": "90", + "prime_tower_max_speed": "90", + "prime_tower_rib_wall": "1", + "prime_tower_rib_width": "8", + "prime_tower_skip_points": "1", + "prime_tower_width": "35", + "prime_volume_mode": "Default", + "print_compatible_printers": [ + "Bambu Lab X1 Carbon 0.4 nozzle", + "Bambu Lab X1 0.4 nozzle", + "Bambu Lab P1S 0.4 nozzle", + "Bambu Lab X1E 0.4 nozzle" + ], + "print_extruder_id": [ + "1", + "1" + ], + "print_extruder_variant": [ + "Direct Drive Standard", + "Direct Drive High Flow" + ], + "print_flow_ratio": "1", + "print_in_clockwise": "0", + "print_sequence": "by layer", + "print_settings_id": "0.20mm Standard @BBL A1", + "printable_area": [ + "0x0", + "256x0", + "256x256", + "0x256" + ], + "printable_height": "256", + "printer_extruder_id": [ + "1" + ], + "printer_extruder_variant": [ + "Direct Drive Standard" + ], + "printer_model": "Bambu Lab A1", + "printer_notes": "", + "printer_settings_id": "Bambu Lab A1 0.4 nozzle", + "printer_structure": "i3", + "printer_technology": "FFF", + "printer_variant": "0.4", + "printhost_authorization_type": "key", + "printhost_ssl_ignore_revoke": "0", + "printing_by_object_gcode": "", + "process_notes": "", + "raft_contact_distance": "0.1", + "raft_expansion": "1.5", + "raft_first_layer_density": "90%", + "raft_first_layer_expansion": "-1", + "raft_layers": "0", + "reduce_crossing_wall": "0", + "reduce_fan_stop_start_freq": [ + "1", + "1", + "1", + "1" + ], + "reduce_infill_retraction_mode": "Auto", + "required_nozzle_HRC": [ + "3", + "3", + "3", + "3" + ], + "resolution": "0.012", + "retract_before_wipe": [ + "0%" + ], + "retract_length_toolchange": [ + "2" + ], + "retract_lift_above": [ + "0" + ], + "retract_lift_below": [ + "255" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_distances_when_cut": [ + "18" + ], + "retraction_distances_when_ec": [ + "0", + "0", + "0", + "0" + ], + "retraction_length": [ + "0.8" + ], + "retraction_minimum_travel": [ + "1" + ], + "retraction_speed": [ + "30" + ], + "role_base_wipe_speed": "1", + "scan_first_layer": "0", + "scarf_angle_threshold": "155", + "seam_gap": "15%", + "seam_placement_away_from_overhangs": "0", + "seam_position": "aligned", + "seam_slope_conditional": "1", + "seam_slope_entire_loop": "0", + "seam_slope_gap": "0", + "seam_slope_inner_walls": "1", + "seam_slope_min_length": "10", + "seam_slope_start_height": "10%", + "seam_slope_steps": "10", + "seam_slope_type": "none", + "silent_mode": "0", + "single_extruder_multi_material": "1", + "skeleton_infill_density": "15%", + "skeleton_infill_line_width": "0.45", + "skin_infill_density": "15%", + "skin_infill_depth": "2", + "skin_infill_line_width": "0.45", + "skirt_distance": "2", + "skirt_height": "1", + "skirt_loops": "0", + "skirt_per_object": "1", + "slice_closing_radius": "0.049", + "slicing_mode": "regular", + "slow_down_for_layer_cooling": [ + "1", + "1", + "1", + "1" + ], + "slow_down_layer_time": [ + "6", + "7", + "6", + "7" + ], + "slow_down_min_speed": [ + "20", + "10", + "20", + "10" + ], + "slowdown_end_acc": [ + "100000", + "100000" + ], + "slowdown_end_height": [ + "400", + "400" + ], + "slowdown_end_speed": [ + "1000", + "1000" + ], + "slowdown_start_acc": [ + "100000", + "100000" + ], + "slowdown_start_height": [ + "0", + "0" + ], + "slowdown_start_speed": [ + "1000", + "1000" + ], + "small_perimeter_speed": [ + "50%", + "50%" + ], + "small_perimeter_threshold": [ + "0", + "0" + ], + "smooth_coefficient": "150", + "smooth_speed_discontinuity_area": "1", + "solid_infill_filament": "0", + "sparse_infill_acceleration": [ + "100%", + "100%" + ], + "sparse_infill_anchor": "400%", + "sparse_infill_anchor_max": "20", + "sparse_infill_density": "15%", + "sparse_infill_filament": "0", + "sparse_infill_lattice_angle_1": "-45", + "sparse_infill_lattice_angle_2": "45", + "sparse_infill_line_width": "0.45", + "sparse_infill_pattern": "grid", + "sparse_infill_speed": [ + "270", + "370" + ], + "spiral_mode": "0", + "spiral_mode_max_xy_smoothing": "200%", + "spiral_mode_smooth": "0", + "standby_temperature_delta": "-5", + "start_end_points": [ + "30x-3", + "54x245" + ], + "supertack_plate_temp": [ + "45", + "70", + "45", + "70" + ], + "supertack_plate_temp_initial_layer": [ + "45", + "70", + "45", + "70" + ], + "support_air_filtration": "0", + "support_angle": "0", + "support_base_pattern": "default", + "support_base_pattern_spacing": "2.5", + "support_bottom_interface_spacing": "0.5", + "support_bottom_z_distance": "0.2", + "support_chamber_temp_control": "0", + "support_cooling_filter": "0", + "support_critical_regions_only": "0", + "support_expansion": "0", + "support_fast_purge_mode": "0", + "support_filament": "0", + "support_interface_bottom_layers": "2", + "support_interface_filament": "0", + "support_interface_loop_pattern": "0", + "support_interface_not_for_body": "1", + "support_interface_pattern": "auto", + "support_interface_spacing": "0.5", + "support_interface_speed": [ + "80", + "80" + ], + "support_interface_top_layers": "2", + "support_ironing_direction": "0", + "support_ironing_flow": "10%", + "support_ironing_inset": "0", + "support_ironing_pattern": "zig-zag", + "support_ironing_spacing": "0.15", + "support_ironing_speed": "30", + "support_line_width": "0.42", + "support_object_first_layer_gap": "0.2", + "support_object_skip_flush": "0", + "support_object_xy_distance": "0.35", + "support_on_build_plate_only": "0", + "support_remove_small_overhang": "1", + "support_speed": [ + "150", + "150" + ], + "support_style": "default", + "support_threshold_angle": "30", + "support_top_z_distance": "0.2", + "support_type": "tree(auto)", + "symmetric_infill_y_axis": "0", + "temperature_vitrification": [ + "45", + "70", + "45", + "70" + ], + "template_custom_gcode": "", + "textured_plate_temp": [ + "65", + "70", + "65", + "70" + ], + "textured_plate_temp_initial_layer": [ + "65", + "70", + "65", + "70" + ], + "thick_bridges": "0", + "thumbnail_size": [ + "50x50" + ], + "time_lapse_gcode": ";===================== date: 20250206 =====================\n{if !spiral_mode && print_sequence != \"by object\"}\n; don't support timelapse gcode in spiral_mode and by object sequence for I3 structure printer\n; SKIPPABLE_START\n; SKIPTYPE: timelapse\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\nG92 E0\nG1 Z{max_layer_z + 0.4}\nG1 X0 Y{first_layer_center_no_wipe_tower[1]} F18000 ; move to safe pos\nG1 X-48.2 F3000 ; move to safe pos\nM400\nM1004 S5 P1 ; external shutter\nM400 P300\nM971 S11 C11 O0\nG92 E0\nG1 X0 F18000\nM623\n\n; SKIPTYPE: head_wrap_detect\nM622.1 S1\nM1002 judge_flag g39_3rd_layer_detect_flag\nM622 J1\n ; enable nozzle clog detect at 3rd layer\n {if layer_num == 2}\n M400\n G90\n M83\n M204 S5000\n G0 Z2 F4000\n G0 X261 Y250 F20000\n M400 P200\n G39 S1\n G0 Z2 F4000\n {endif}\n\n\n M622.1 S1\n M1002 judge_flag g39_detection_flag\n M622 J1\n {if !in_head_wrap_detect_zone}\n M622.1 S0\n M1002 judge_flag g39_mass_exceed_flag\n M622 J1\n {if layer_num > 2}\n G392 S0\n M400\n G90\n M83\n M204 S5000\n G0 Z{max_layer_z + 0.4} F4000\n G39.3 S1\n G0 Z{max_layer_z + 0.4} F4000\n G392 S0\n {endif}\n M623\n {endif}\n M623\nM623\n; SKIPPABLE_END\n{endif}\n", + "timelapse_type": "0", + "top_area_threshold": "200%", + "top_color_penetration_layers": "5", + "top_one_wall_type": "all top", + "top_shell_layers": "5", + "top_shell_thickness": "1", + "top_solid_infill_flow_ratio": [ + "1", + "1" + ], + "top_surface_acceleration": [ + "2000", + "2000" + ], + "top_surface_density": "100%", + "top_surface_jerk": "9", + "top_surface_line_width": "0.42", + "top_surface_pattern": "monotonicline", + "top_surface_speed": [ + "200", + "300" + ], + "top_z_overrides_xy_distance": "0", + "travel_acceleration": [ + "10000", + "10000" + ], + "travel_jerk": "9", + "travel_short_distance_acceleration": [ + "250", + "250" + ], + "travel_speed": [ + "500", + "500" + ], + "travel_speed_z": [ + "0", + "0" + ], + "tree_support_branch_angle": "45", + "tree_support_branch_diameter": "2", + "tree_support_branch_diameter_angle": "5", + "tree_support_branch_distance": "5", + "tree_support_wall_count": "-1", + "use_firmware_retraction": "0", + "use_relative_e_distances": "1", + "version": "02.07.01.62", + "vertical_shell_speed": [ + "80%", + "80%" + ], + "volumetric_speed_coefficients": [ + "0 0 0 0 0 0", + "0 0 0 0 0 0", + "0 0 0 0 0 0", + "0 0 0 0 0 0" + ], + "wall_distribution_count": "1", + "wall_filament": "0", + "wall_generator": "classic", + "wall_loops": "2", + "wall_sequence": "inner wall/outer wall", + "wall_transition_angle": "10", + "wall_transition_filter_deviation": "25%", + "wall_transition_length": "100%", + "wipe": [ + "1" + ], + "wipe_distance": [ + "2" + ], + "wipe_speed": "80%", + "wipe_tower_no_sparse_layers": "0", + "wipe_tower_rotation_angle": "0", + "wipe_tower_x": [ + "15" + ], + "wipe_tower_y": [ + "220" + ], + "wrapping_detection_gcode": "", + "wrapping_detection_layers": "20", + "wrapping_exclude_area": [], + "xy_contour_compensation": "0", + "xy_hole_compensation": "0", + "z_direction_outwall_speed_continuous": "0", + "z_hop": [ + "0.4" + ], + "z_hop_types": [ + "Auto Lift" + ], + "apply_top_surface_compensation": "0", + "extruder_clearance_radius": "57" +} diff --git a/android/app/src/main/assets/orca_profiles/printer/bambu_p1s.json b/android/app/src/main/assets/orca_profiles/printer/bambu_p1s.json new file mode 100644 index 0000000..3ad79f0 --- /dev/null +++ b/android/app/src/main/assets/orca_profiles/printer/bambu_p1s.json @@ -0,0 +1 @@ +{"accel_to_decel_enable":"0","accel_to_decel_factor":"50%","activate_air_filtration":["0","0","0","0"],"additional_cooling_fan_speed":["70","0","70","0"],"additional_fan_full_speed_layer":["0","0","0","0"],"alternate_extra_wall":"0","apply_scarf_seam_on_circles":"1","auxiliary_fan":"1","avoid_crossing_wall_includes_support":"0","bed_custom_model":"","bed_custom_texture":"","bed_exclude_area":["0x0","18x0","18x28","0x28"],"bed_temperature_formula":"by_first_filament","before_layer_change_gcode":"","best_object_pos":"0.5,0.5","bottom_color_penetration_layers":"3","bottom_shell_layers":"3","bottom_shell_thickness":"0","bottom_surface_density":"100%","bottom_surface_pattern":"monotonic","bridge_angle":"0","bridge_flow":"1","bridge_no_support":"0","bridge_speed":["50","50"],"brim_object_gap":"0.1","brim_type":"auto_brim","brim_width":"5","chamber_temperatures":["0","0","0","0"],"change_filament_gcode":";=P1S 20251031=\nM620 S[next_extruder]A\nM204 S9000\nG1 Z{max_layer_z + 3.0} F1200\n\nG1 X70 F21000\nG1 Y245\nG1 Y265 F3000\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\n{if long_retractions_when_cut[previous_extruder]}\nM620.11 S1 I[previous_extruder] E-{retraction_distances_when_cut[previous_extruder]} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\n{else}\nM620.11 S0\n{endif}\nM400\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 X120 F15000\nG1 X20 Y50 F21000\nG1 Y-3\n{if toolchange_count == 2}\n; get travel path for change filament\nM620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\nM620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\nM620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\nM620.1 E F{flush_volumetric_speeds[previous_extruder]/2.4053*60} T{flush_temperatures[previous_extruder]}\nT[next_extruder]\nM620.1 E F{flush_volumetric_speeds[next_extruder]/2.4053*60} T{flush_temperatures[next_extruder]}\n\n{if next_extruder < 255}\n{if long_retractions_when_cut[previous_extruder]}\nM620.11 S1 I[previous_extruder] E{retraction_distances_when_cut[previous_extruder]} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\nM628 S1\nG92 E0\nG1 E{retraction_distances_when_cut[previous_extruder]} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\nM400\nM629 S1\n{else}\nM620.11 S0\n{endif}\nG92 E0\n{if flush_length_1 > 1}\nM83\n; FLUSH_START\n; always use highest temperature to flush\nM400\n{if filament_type[next_extruder] == \"PETG\"}\nM109 S260\n{elsif filament_type[next_extruder] == \"PVA\"}\nM109 S210\n{else}\nM109 S{flush_temperatures[next_extruder]}\n{endif}\n{if flush_length_1 > 23.7}\nG1 E23.7 F{flush_volumetric_speeds[previous_extruder]/2.4053*60} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\n{else}\nG1 E{flush_length_1} F{flush_volumetric_speeds[previous_extruder]/2.4053*60}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 1}\n\nG91\nG1 X3 F12000; move aside to extrude\nG90\nM83\n\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 1}\n\nG91\nG1 X3 F12000; move aside to extrude\nG90\nM83\n\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_4 > 1}\n\nG91\nG1 X3 F12000; move aside to extrude\nG90\nM83\n\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{flush_volumetric_speeds[next_extruder]/2.4053*60}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n; FLUSH_START\nM400\nM109 S[new_filament_temp]\nG1 E2 F{flush_volumetric_speeds[next_extruder]/2.4053*60} ;Compensate for filament spillage during waiting temperature\n; FLUSH_END\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM106 P1 S255\nM400 S3\n\nG1 X70 F5000\nG1 X90 F3000\nG1 Y255 F4000\nG1 X105 F5000\nG1 Y265 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X70 F10000\nG1 X100 F5000\n\nG1 X70 F10000\nG1 X80 F15000\nG1 X60\nG1 X80\nG1 X60\nG1 X80 ; shake to put down garbage\nG1 X100 F5000\nG1 X165 F15000; wipe and shake\nG1 Y256 ; move Y to aside, prevent collision\nM400\nG1 Z{max_layer_z + 3.0} F3000\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\nM621 S[next_extruder]A\n","circle_compensation_manual_offset":"0","circle_compensation_speed":["200","200","200","200"],"close_additional_fan_first_x_layers":["1","3","1","3"],"close_fan_the_first_x_layers":["1","3","1","3"],"complete_print_exhaust_fan_speed":["70","70","70","70"],"cool_plate_temp":["35","0","35","0"],"cool_plate_temp_initial_layer":["35","0","35","0"],"cooling_filter_enabled":"0","cooling_perimeter_transition_distance":["10","10","10","10"],"cooling_slowdown_logic":["uniform_cooling","uniform_cooling","uniform_cooling","uniform_cooling"],"counter_coef_1":["0","0","0","0"],"counter_coef_2":["0.008","0.008","0.008","0.008"],"counter_coef_3":["-0.041","-0.041","-0.041","-0.041"],"counter_limit_max":["0.033","0.033","0.033","0.033"],"counter_limit_min":["-0.035","-0.035","-0.035","-0.035"],"curr_bed_type":"Supertack Plate","default_acceleration":["10000","10000"],"default_filament_colour":["","","",""],"default_filament_profile":["Bambu PLA Basic @BBL P1S 0.4 nozzle"],"default_jerk":"0","default_nozzle_volume_type":["Standard"],"default_print_profile":"0.20mm Standard @BBL X1C","deretraction_speed":["30","30"],"detect_floating_vertical_shell":"1","detect_narrow_internal_solid_infill":"1","detect_overhang_wall":"1","detect_thin_wall":"0","diameter_limit":["50","50","50","50"],"draft_shield":"disabled","during_print_exhaust_fan_speed":["70","70","70","70"],"elefant_foot_compensation":"0.15","embedding_wall_into_infill":"0","enable_arc_fitting":"1","enable_circle_compensation":"0","enable_filament_dynamic_map":"0","enable_height_slowdown":["0","0"],"enable_long_retraction_when_cut":"2","enable_mixed_color_sublayer":"0","enable_order_independent_overlap_carving":"0","enable_overhang_bridge_fan":["1","1","1","1"],"enable_overhang_speed":["1","1"],"enable_pre_heating":"0","enable_pressure_advance":["0","0","0","0"],"enable_prime_tower":"1","enable_support":"0","enable_support_ironing":"0","enable_tower_interface_features":"0","enable_wrapping_detection":"0","enforce_support_layers":"0","eng_plate_temp":["0","70","0","70"],"eng_plate_temp_initial_layer":["0","70","0","70"],"ensure_vertical_shell_thickness":"enabled","exclude_object":"1","extruder_ams_count":["1#0|4#0","1#0|4#0"],"extruder_clearance_dist_to_rod":"33","extruder_clearance_height_to_lid":"90","extruder_clearance_height_to_rod":"34","extruder_clearance_max_radius":"68","extruder_colour":["#018001"],"extruder_max_nozzle_count":["1"],"extruder_nozzle_stats":["Standard#1"],"extruder_offset":["0x2"],"extruder_printable_area":[],"extruder_printable_height":[],"extruder_type":["Direct Drive"],"extruder_variant_list":["Direct Drive Standard,Direct Drive High Flow"],"fan_cooling_layer_time":["100","20","100","20"],"fan_direction":"left","fan_max_speed":["100","40","100","40"],"fan_min_speed":["100","20","100","20"],"filament_adaptive_volumetric_speed":["0","0","0","0","0","0","0","0"],"filament_adhesiveness_category":["100","300","100","300"],"filament_bridge_speed":["25","25","25","25","25","25","25","25"],"filament_change_length":["5","12","5","12"],"filament_change_length_nc":["10","10","10","10"],"filament_colour":["#E4BD68","#FFFFFF","#000000","#BC0900"],"filament_colour_type":["1","0","1","1"],"filament_cooling_before_tower":["0","0","0","0","0","0","0","0"],"filament_cost":["24.99","24.99","24.99","24.99"],"filament_density":["1.26","1.28","1.26","1.28"],"filament_deretraction_speed":["nil","50","nil","nil","nil","50","nil","nil"],"filament_dev_ams_drying_ams_limitations":["1","0","1","0","1","0","1","0"],"filament_dev_ams_drying_heat_distortion_temperature":["45","75","45","75"],"filament_dev_ams_drying_temperature":["45","45","45","45","65","65","55","55","45","45","45","45","65","65","55","55"],"filament_dev_ams_drying_time":["12","12","12","12","12","12","12","12","12","12","12","12","12","12","12","12"],"filament_dev_chamber_drying_bed_temperature":["70","80","70","80"],"filament_dev_chamber_drying_time":["12","12","12","12"],"filament_dev_drying_cooling_temperature":["45","55","45","55"],"filament_dev_drying_softening_temperature":["50","60","50","60"],"filament_diameter":["1.75","1.75","1.75","1.75"],"filament_enable_overhang_speed":["1","1","1","1","1","1","1","1"],"filament_end_gcode":["; filament end gcode \n\n","; filament end gcode \n\n","; filament end gcode \n\n","; filament end gcode \n\n"],"filament_extruder_compatibility":["0","0","0","0"],"filament_extruder_variant":["Direct Drive Standard","Direct Drive High Flow","Direct Drive Standard","Direct Drive High Flow","Direct Drive Standard","Direct Drive High Flow","Direct Drive Standard","Direct Drive High Flow"],"filament_flow_ratio":["0.98","0.985","0.95","0.952","0.98","0.985","0.95","0.952"],"filament_flush_temp":["0","0","0","0","0","0","0","0"],"filament_flush_temp_fast":["0","0","0","0","0","0","0","0"],"filament_flush_volumetric_speed":["0","0","0","0","0","0","0","0"],"filament_ids":["GFA00","GFG02","GFA00","GFG02"],"filament_is_mixed":["0","0","0","0"],"filament_is_support":["0","0","0","0"],"filament_long_retractions_when_cut":["1","1","1","1","1","1","1","1"],"filament_map":["1","1","1","1"],"filament_map_mode":"Auto For Flush","filament_max_volumetric_speed":["21","29","21","29","21","29","21","29"],"filament_metal_stickiness":["None","High","None","High"],"filament_minimal_purge_on_wipe_tower":["15","15","15","15"],"filament_mixed_components":[""],"filament_mixed_gradient":["0","0","0","0"],"filament_mixed_gradient_curve":[""],"filament_mixed_gradient_per_part":["0"],"filament_mixed_gradient_range":[""],"filament_mixed_sublayer_ratios":[""],"filament_multi_colour":["#E4BD68","#FFFFFF","#000000","#BC0900"],"filament_notes":"","filament_nozzle_map":["0","0","0","0"],"filament_overhang_1_4_speed":["0","0","0","0","0","0","0","0"],"filament_overhang_2_4_speed":["50","50","50","50","50","50","50","50"],"filament_overhang_3_4_speed":["30","30","30","30","30","30","30","30"],"filament_overhang_4_4_speed":["10","10","10","10","10","10","10","10"],"filament_overhang_totally_speed":["10","10","10","10","10","10","10","10"],"filament_pre_cooling_temperature":["0","0","0","0","0","0","0","0"],"filament_pre_cooling_temperature_nc":["0","0","0","0","0","0","0","0"],"filament_preheat_temperature_delta":["10","10","10","10","10","10","10","10"],"filament_prime_volume":["30","30","30","30"],"filament_prime_volume_nc":["60","60","60","60"],"filament_printable":["3","3","3","3"],"filament_ramming_travel_time":["0","0","0","0","0","0","0","0"],"filament_ramming_travel_time_nc":["0","0","0","0","0","0","0","0"],"filament_ramming_volumetric_speed":["-1","-1","-1","-1","-1","-1","-1","-1"],"filament_ramming_volumetric_speed_nc":["-1","-1","-1","-1","-1","-1","-1","-1"],"filament_retract_before_wipe":["nil","nil","nil","nil","nil","nil","nil","nil"],"filament_retract_length_nc":["14","14","14","14","14","14","14","14"],"filament_retract_restart_extra":["nil","nil","nil","nil","nil","nil","nil","nil"],"filament_retract_when_changing_layer":["nil","nil","nil","nil","nil","nil","nil","nil"],"filament_retraction_distances_when_cut":["18","18","18","18","18","18","18","18"],"filament_retraction_length":["nil","0.4","nil","0.4","nil","0.4","nil","0.4"],"filament_retraction_minimum_travel":["nil","nil","nil","nil","nil","nil","nil","nil"],"filament_retraction_speed":["nil","50","nil","nil","nil","50","nil","nil"],"filament_scarf_gap":["0%","0%","0%","0%"],"filament_scarf_height":["10%","10%","10%","10%"],"filament_scarf_length":["10","10","10","10"],"filament_scarf_seam_type":["none","none","none","none"],"filament_self_index":["1","1","2","2","3","3","4","4"],"filament_settings_id":["Bambu PLA Basic @BBL P1S 0.4 nozzle","Bambu PETG HF @BBL P1S 0.4 nozzle","Bambu PLA Basic @BBL P1S 0.4 nozzle","Bambu PETG HF @BBL P1S 0.4 nozzle"],"filament_shrink":["100%","100%","100%","100%"],"filament_soluble":["0","0","0","0"],"filament_start_gcode":["; filament start gcode\n{if (bed_temperature[current_extruder] >55)||(bed_temperature_initial_layer[current_extruder] >55)}M106 P3 S200\n{elsif(bed_temperature[current_extruder] >50)||(bed_temperature_initial_layer[current_extruder] >50)}M106 P3 S150\n{elsif(bed_temperature[current_extruder] >45)||(bed_temperature_initial_layer[current_extruder] >45)}M106 P3 S50\n{endif}\nM142 P1 R35 S40\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}","; filament start gcode\n{if (bed_temperature[current_extruder] >80)||(bed_temperature_initial_layer[current_extruder] >80)}M106 P3 S255\n{elsif (bed_temperature[current_extruder] >60)||(bed_temperature_initial_layer[current_extruder] >60)}M106 P3 S180\n{endif}\n\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}","; filament start gcode\n{if (bed_temperature[current_extruder] >55)||(bed_temperature_initial_layer[current_extruder] >55)}M106 P3 S200\n{elsif(bed_temperature[current_extruder] >50)||(bed_temperature_initial_layer[current_extruder] >50)}M106 P3 S150\n{elsif(bed_temperature[current_extruder] >45)||(bed_temperature_initial_layer[current_extruder] >45)}M106 P3 S50\n{endif}\nM142 P1 R35 S40\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}","; filament start gcode\n{if (bed_temperature[current_extruder] >80)||(bed_temperature_initial_layer[current_extruder] >80)}M106 P3 S255\n{elsif (bed_temperature[current_extruder] >60)||(bed_temperature_initial_layer[current_extruder] >60)}M106 P3 S180\n{endif}\n\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}"],"filament_tower_interface_pre_extrusion_dist":["10","10","10","10"],"filament_tower_interface_pre_extrusion_length":["0","0","0","0"],"filament_tower_interface_print_temp":["-1","-1","-1","-1"],"filament_tower_interface_purge_volume":["20","20","20","20"],"filament_tower_ironing_area":["4","4","4","4"],"filament_type":["PLA","PETG","PLA","PETG"],"filament_velocity_adaptation_factor":["1","1","1","1"],"filament_vendor":["Bambu Lab","Bambu Lab","Bambu Lab","Bambu Lab"],"filament_volume_map":["0","0","0","0"],"filament_wipe":["nil","nil","nil","nil","nil","nil","nil","nil"],"filament_wipe_distance":["nil","nil","nil","nil","nil","nil","nil","nil"],"filament_z_hop":["nil","0.6","nil","0.6","nil","0.6","nil","0.6"],"filament_z_hop_types":["nil","nil","nil","nil","nil","nil","nil","nil"],"filename_format":"{input_filename_base}_{filament_type[0]}_{print_time}.gcode","fill_multiline":"1","filter_out_gap_fill":"0","first_layer_print_sequence":["0"],"first_x_layer_fan_speed":["0","0","0","0"],"first_x_layer_part_fan_speed":["0","0","0","0"],"flush_into_infill":"0","flush_into_objects":"0","flush_into_support":"1","flush_multiplier":["1"],"flush_multiplier_fast":["1.2"],"flush_volumes_matrix":["0","273","173","256","700","0","700","269","629","598","0","355","700","628","700","0"],"flush_volumes_vector":["140","140","140","140","140","140","140","140"],"from":"project","full_fan_speed_layer":["0","0","0","0"],"fuzzy_skin":"none","fuzzy_skin_first_layer":"0","fuzzy_skin_mode":"displacement","fuzzy_skin_noise_type":"classic","fuzzy_skin_octaves":"4","fuzzy_skin_persistence":"0.5","fuzzy_skin_point_distance":"0.8","fuzzy_skin_scale":"1","fuzzy_skin_thickness":"0.3","gap_infill_speed":["250","250"],"gcode_add_line_number":"0","gcode_flavor":"marlin","grab_length":["0"],"group_algo_with_time":"0","has_filament_switcher":"0","has_scarf_joint_seam":"0","head_wrap_detect_zone":[],"hole_coef_1":["0","0","0","0"],"hole_coef_2":["-0.008","-0.008","-0.008","-0.008"],"hole_coef_3":["0.23415","0.23415","0.23415","0.23415"],"hole_limit_max":["0.22","0.22","0.22","0.22"],"hole_limit_min":["0.088","0.088","0.088","0.088"],"host_type":"octoprint","hot_plate_temp":["55","70","55","70"],"hot_plate_temp_initial_layer":["55","70","55","70"],"hotend_cooling_rate":["2","2"],"hotend_heating_rate":["2","2"],"impact_strength_z":["13.8","10.6","13.8","10.6"],"independent_support_layer_height":"1","infill_combination":"0","infill_direction":"45","infill_instead_top_bottom_surfaces":"0","infill_jerk":"9","infill_lock_depth":"1","infill_rotate_step":"0","infill_shift_step":"0.4","infill_wall_overlap":"15%","initial_layer_acceleration":["500","500"],"initial_layer_flow_ratio":"1","initial_layer_infill_speed":["105","105"],"initial_layer_jerk":"9","initial_layer_line_width":"0.5","initial_layer_print_height":"0.2","initial_layer_speed":["50","50"],"initial_layer_travel_acceleration":["6000","6000"],"inner_wall_acceleration":["0","0"],"inner_wall_jerk":"9","inner_wall_line_width":"0.45","inner_wall_speed":["300","400"],"interface_shells":"0","interlocking_beam":"0","interlocking_beam_layer_count":"2","interlocking_beam_width":"0.8","interlocking_boundary_avoidance":"2","interlocking_depth":"2","interlocking_orientation":"22.5","internal_bridge_support_thickness":"0.8","internal_solid_infill_line_width":"0.42","internal_solid_infill_pattern":"zig-zag","internal_solid_infill_speed":["250","350"],"ironing_direction":"45","ironing_fan_speed":["-1","-1","-1","-1"],"ironing_flow":"10%","ironing_inset":"0.21","ironing_pattern":"zig-zag","ironing_spacing":"0.15","ironing_speed":"30","ironing_type":"no ironing","is_infill_first":"0","layer_change_gcode":"; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change","layer_height":"0.2","line_width":"0.42","locked_skeleton_infill_pattern":"zigzag","locked_skin_infill_pattern":"crosszag","long_retractions_when_cut":["0","0"],"long_retractions_when_ec":["0","0","0","0","0","0","0","0"],"machine_bed_mass_Y":"0","machine_end_gcode":";===== date: 20230428 =====================\nM400 ; wait for buffer to clear\nG92 E0 ; zero the extruder\nG1 E-0.8 F1800 ; retract\nG1 Z{max_layer_z + 0.5} F900 ; lower z a little\nG1 X65 Y245 F12000 ; move to safe pos \nG1 Y265 F3000\n\nG1 X65 Y245 F12000\nG1 Y265 F3000\nM140 S0 ; turn off bed\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off remote part cooling fan\nM106 P3 S0 ; turn off chamber cooling fan\n\nG1 X100 F12000 ; wipe\n; pull back filament to AMS\nM620 S255\nG1 X20 Y50 F12000\nG1 Y-3\nT255\nG1 X65 F12000\nG1 Y265\nG1 X100 F12000 ; wipe\nM621 S255\nM104 S0 ; turn off hotend\n\nM622.1 S1 ; for prev firware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\n M400 ; wait all motion done\n M991 S0 P-1 ;end smooth timelapse at safe pos\n M400 S3 ;wait for last picture to be taken\nM623; end of \"timelapse_record_flag\"\n\nM400 ; wait all motion done\nM17 S\nM17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom\n{if (max_layer_z + 100.0) < 250}\n G1 Z{max_layer_z + 100.0} F600\n G1 Z{max_layer_z +98.0}\n{else}\n G1 Z250 F600\n G1 Z248\n{endif}\nM400 P100\nM17 R ; restore z current\n\nM220 S100 ; Reset feedrate magnitude\nM201.2 K1.0 ; Reset acc magnitude\nM73.2 R1.0 ;Reset left time magnitude\nM1002 set_gcode_claim_speed_level : 0\n\nM17 X0.8 Y0.8 Z0.5 ; lower motor current to 45% power\n","machine_hotend_change_time":"0","machine_load_filament_time":"29","machine_max_acceleration_e":["5000","5000","5000","5000"],"machine_max_acceleration_extruding":["20000","20000","20000","20000"],"machine_max_acceleration_retracting":["5000","5000","5000","5000"],"machine_max_acceleration_travel":["9000","9000","9000","9000"],"machine_max_acceleration_x":["20000","20000","20000","20000"],"machine_max_acceleration_y":["20000","20000","20000","20000"],"machine_max_acceleration_z":["500","500","500","500"],"machine_max_force_Y":"0","machine_max_jerk_e":["2.5","2.5","2.5","2.5"],"machine_max_jerk_x":["9","9","9","9"],"machine_max_jerk_y":["9","9","9","9"],"machine_max_jerk_z":["3","3","3","3"],"machine_max_printed_mass":"0","machine_max_speed_e":["30","30","30","30"],"machine_max_speed_x":["500","500","500","500"],"machine_max_speed_y":["500","500","500","500"],"machine_max_speed_z":["20","20","20","20"],"machine_min_extruding_rate":["0"],"machine_min_travel_rate":["0"],"machine_pause_gcode":"M400 U1","machine_prepare_compensation_time":"260","machine_start_gcode":";===== machine: P1S-0.4 ========================\n;===== date: 20251031 =====================\n;===== turn on the HB fan & MC board fan =================\nM104 S75 ;set extruder temp to turn on the HB fan and prevent filament oozing from nozzle\nM710 A1 S255 ;turn on MC fan by default(P1S)\n;===== reset machine status =================\nM290 X40 Y40 Z2.6666666\nG91\nM17 Z0.4 ; lower the z-motor current\nG380 S2 Z30 F300 ; G380 is same as G38; lower the hotbed , to prevent the nozzle is below the hotbed\nG380 S2 Z-25 F300 ;\nG1 Z5 F300;\nG90\nM17 X1.2 Y1.2 Z0.75 ; reset motor current to default\nM960 S5 P1 ; turn on logo lamp\nG90\nM220 S100 ;Reset Feedrate\nM221 S100 ;Reset Flowrate\nM73.2 R1.0 ;Reset left time magnitude\nM1002 set_gcode_claim_speed_level : 5\nM221 X0 Y0 Z0 ; turn off soft endstop to prevent protential logic problem\nG29.1 Z{+0.0} ; clear z-trim value first\nM204 S10000 ; init ACC set to 10m/s^2\n\n;===== heatbed preheat ====================\nM1002 gcode_claim_action:54\nM140 S[bed_temperature_initial_layer_single] ;set bed temp\nM190 S[bed_temperature_initial_layer_single] ;wait for bed temp\n\n\n\n;=============turn on fans to prevent PLA jamming=================\n{if filament_type[initial_extruder]==\"PLA\"}\n {if (bed_temperature[initial_extruder] >45)||(bed_temperature_initial_layer[initial_extruder] >45)}\n M106 P3 S180\n {endif};Prevent PLA from jamming\n{endif}\nM106 P2 S100 ; turn on big fan ,to cool down toolhead\n\n;===== prepare print temperature and material ==========\nM104 S[nozzle_temperature_initial_layer] ;set extruder temp\nG91\nG0 Z10 F1200\nG90\nG28 X\nM975 S1 ; turn on\nG1 X60 F12000\nG1 Y245\nG1 Y265 F3000\nM620 M\nM620 S[initial_extruder]A ; switch material if AMS exist\n M109 S[nozzle_temperature_initial_layer]\n G1 X120 F12000\n\n G1 X20 Y50 F12000\n G1 Y-3\n T[initial_extruder]\n G1 X54 F12000\n G1 Y265\n M400\nM621 S[initial_extruder]A\nM620.1 E F{flush_volumetric_speeds[initial_no_support_extruder]/2.4053*60} T{flush_temperatures[initial_no_support_extruder]}\n\n\nM412 S1 ; ===turn on filament runout detection===\n\nM109 S250 ;set nozzle to common flush temp\nM106 P1 S0\nG92 E0\nG1 E50 F200\nM400\nM104 S[nozzle_temperature_initial_layer]\nG92 E0\nG1 E50 F200\nM400\nM106 P1 S255\nG92 E0\nG1 E5 F300\nM109 S{nozzle_temperature_initial_layer[initial_extruder]-20} ; drop nozzle temp, make filament shink a bit\nG92 E0\nG1 E-0.5 F300\n\nG1 X70 F9000\nG1 X76 F15000\nG1 X65 F15000\nG1 X76 F15000\nG1 X65 F15000; shake to put down garbage\nG1 X80 F6000\nG1 X95 F15000\nG1 X80 F15000\nG1 X165 F15000; wipe and shake\nM400\nM106 P1 S0\n;===== prepare print temperature and material end =====\n\n\n;===== wipe nozzle ===============================\nM1002 gcode_claim_action : 14\nM975 S1\nM106 S255\nG1 X65 Y230 F18000\nG1 Y264 F6000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]-20}\nG1 X100 F18000 ; first wipe mouth\n\nG0 X135 Y253 F20000 ; move to exposed steel surface edge\nG28 Z P0 T300; home z with low precision,permit 300deg temperature\nG29.2 S0 ; turn off ABL\nG0 Z5 F20000\n\nG1 X60 Y265\nG92 E0\nG1 E-0.5 F300 ; retrack more\nG1 X100 F5000; second wipe mouth\nG1 X70 F15000\nG1 X100 F5000\nG1 X70 F15000\nG1 X100 F5000\nG1 X70 F15000\nG1 X100 F5000\nG1 X70 F15000\nG1 X90 F5000\nG0 X128 Y261 Z-1.5 F20000 ; move to exposed steel surface and stop the nozzle\nM104 S140 ; set temp down to heatbed acceptable\nM106 S255 ; turn on fan (G28 has turn off fan)\n\nM221 S; push soft endstop status\nM221 Z0 ;turn off Z axis endstop\nG0 Z0.5 F20000\nG0 X125 Y259.5 Z-1.01\nG0 X131 F211\nG0 X124\nG0 Z0.5 F20000\nG0 X125 Y262.5\nG0 Z-1.01\nG0 X131 F211\nG0 X124\nG0 Z0.5 F20000\nG0 X125 Y260.0\nG0 Z-1.01\nG0 X131 F211\nG0 X124\nG0 Z0.5 F20000\nG0 X125 Y262.0\nG0 Z-1.01\nG0 X131 F211\nG0 X124\nG0 Z0.5 F20000\nG0 X125 Y260.5\nG0 Z-1.01\nG0 X131 F211\nG0 X124\nG0 Z0.5 F20000\nG0 X125 Y261.5\nG0 Z-1.01\nG0 X131 F211\nG0 X124\nG0 Z0.5 F20000\nG0 X125 Y261.0\nG0 Z-1.01\nG0 X131 F211\nG0 X124\nG0 X128\nG2 I0.5 J0 F300\nG2 I0.5 J0 F300\nG2 I0.5 J0 F300\nG2 I0.5 J0 F300\n\nM109 S140 ; wait nozzle temp down to heatbed acceptable\nG2 I0.5 J0 F3000\nG2 I0.5 J0 F3000\nG2 I0.5 J0 F3000\nG2 I0.5 J0 F3000\n\nM221 R; pop softend status\nG1 Z10 F1200\nM400\nG1 Z10\nG1 F30000\nG1 X230 Y15\nG29.2 S1 ; turn on ABL\n;G28 ; home again after hard wipe mouth\nM106 S0 ; turn off fan , too noisy\n;===== wipe nozzle end ================================\n\n\n;===== bed leveling ==================================\nM1002 judge_flag g29_before_print_flag\nM622 J1\n\n M1002 gcode_claim_action : 1\n G29 A X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]}\n M400\n M500 ; save cali data\n\nM623\n;===== bed leveling end ================================\n\n;===== home after wipe mouth============================\nM1002 judge_flag g29_before_print_flag\nM622 J0\n\n M1002 gcode_claim_action : 13\n G28\n\nM623\n;===== home after wipe mouth end =======================\n\nM975 S1 ; turn on vibration supression\n\n\n;=============turn on fans to prevent PLA jamming=================\n{if filament_type[initial_extruder]==\"PLA\"}\n {if (bed_temperature[initial_extruder] >45)||(bed_temperature_initial_layer[initial_extruder] >45)}\n M106 P3 S180\n {endif};Prevent PLA from jamming\n{endif}\nM106 P2 S100 ; turn on big fan ,to cool down toolhead\n\n\nM104 S{nozzle_temperature_initial_layer[initial_extruder]} ; set extrude temp earlier, to reduce wait time\n\n;===== mech mode fast check============================\nG1 X128 Y128 Z10 F20000\nM400 P200\nM970.3 Q1 A7 B30 C80 H15 K0\nM974 Q1 S2 P0\n\nG1 X128 Y128 Z10 F20000\nM400 P200\nM970.3 Q0 A7 B30 C90 Q0 H15 K0\nM974 Q0 S2 P0\n\nM975 S1\nG1 F30000\nG1 X230 Y15\nG28 X ; re-home XY\n;===== fmech mode fast check============================\n\n\n;===== nozzle load line ===============================\nM975 S1\nG90\nM83\nT1000\nG1 X18.0 Y1.0 Z0.8 F18000;Move to start position\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nG0 E2 F300\nG0 X240 E15 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\nG0 Y11 E0.700 F{outer_wall_volumetric_speed/(0.3*0.5)/ 4 * 60}\nG0 X239.5\nG0 E0.2\nG0 Y1.5 E0.700\nG0 X18 E15 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\nM400\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n;curr_bed_type={curr_bed_type}\n{if curr_bed_type==\"Textured PEI Plate\"}\nG29.1 Z{-0.04} ; for Textured PEI Plate\n{endif}\n;========turn off light and wait extrude temperature =============\nM1002 gcode_claim_action : 0\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off big fan\nM106 P3 S0 ; turn off chamber fan\n\nM975 S1 ; turn on mech mode supression\n","machine_switch_extruder_time":"0","machine_unload_filament_time":"28","master_extruder_id":"1","max_bridge_length":"0","max_layer_height":["0.28"],"max_travel_detour_distance":"0","min_bead_width":"85%","min_feature_size":"25%","min_layer_height":["0.08"],"minimum_sparse_infill_area":"15","mmu_segmented_region_interlocking_depth":"0","mmu_segmented_region_max_width":"0","monotonic_travel_into_wall":"0%","name":"project_settings","no_slow_down_for_cooling_on_outwalls":["0","0","0","0"],"nozzle_diameter":["0.4"],"nozzle_flush_dataset":["0","0"],"nozzle_height":"4.2","nozzle_temperature":["220","220","245","245","220","220","245","245"],"nozzle_temperature_initial_layer":["220","220","230","230","220","220","230","230"],"nozzle_temperature_range_high":["240","270","240","270"],"nozzle_temperature_range_low":["190","230","190","230"],"nozzle_type":["stainless_steel","stainless_steel"],"nozzle_volume":["107","107"],"nozzle_volume_type":["Standard"],"only_one_wall_first_layer":"0","ooze_prevention":"0","other_layers_print_sequence":["0"],"other_layers_print_sequence_nums":"0","outer_wall_acceleration":["5000","5000"],"outer_wall_jerk":"9","outer_wall_line_width":"0.42","outer_wall_speed":["200","350"],"overhang_1_4_speed":["0","0"],"overhang_2_4_speed":["50","50"],"overhang_3_4_speed":["30","30"],"overhang_4_4_speed":["10","10"],"overhang_fan_speed":["100","100","100","100"],"overhang_fan_threshold":["50%","10%","50%","10%"],"overhang_threshold_participating_cooling":["95%","95%","95%","95%"],"overhang_totally_speed":["10","30"],"override_filament_scarf_seam_setting":"0","override_process_overhang_speed":["0","0","0","0","0","0","0","0"],"physical_extruder_map":["0"],"post_process":[],"pre_start_fan_time":["0","0","0","0"],"precise_outer_wall":"0","precise_z_height":"0","pressure_advance":["0.02","0.02","0.02","0.02"],"prime_tower_brim_width":"3","prime_tower_enable_framework":"0","prime_tower_extra_rib_length":"0","prime_tower_fillet_wall":"1","prime_tower_flat_ironing":"0","prime_tower_infill_gap":"150%","prime_tower_lift_height":"-1","prime_tower_lift_speed":"90","prime_tower_max_speed":"90","prime_tower_rib_wall":"1","prime_tower_rib_width":"8","prime_tower_skip_points":"1","prime_tower_width":"35","prime_volume_mode":"Default","print_compatible_printers":["Bambu Lab X1 Carbon 0.4 nozzle","Bambu Lab X1 0.4 nozzle","Bambu Lab P1S 0.4 nozzle","Bambu Lab X1E 0.4 nozzle"],"print_extruder_id":["1","1"],"print_extruder_variant":["Direct Drive Standard","Direct Drive High Flow"],"print_flow_ratio":"1","print_in_clockwise":"0","print_sequence":"by layer","print_settings_id":"0.20mm Standard @BBL X1C","printable_area":["0x0","256x0","256x256","0x256"],"printable_height":"250","printer_extruder_id":["1","1"],"printer_extruder_variant":["Direct Drive Standard","Direct Drive High Flow"],"printer_model":"Bambu Lab P1S","printer_notes":"","printer_settings_id":"Bambu Lab P1S 0.4 nozzle","printer_structure":"corexy","printer_technology":"FFF","printer_variant":"0.4","printhost_authorization_type":"key","printhost_ssl_ignore_revoke":"0","printing_by_object_gcode":"","process_notes":"","raft_contact_distance":"0.1","raft_expansion":"1.5","raft_first_layer_density":"90%","raft_first_layer_expansion":"-1","raft_layers":"0","reduce_crossing_wall":"0","reduce_fan_stop_start_freq":["1","1","1","1"],"reduce_infill_retraction_mode":"Auto","required_nozzle_HRC":["3","3","3","3"],"resolution":"0.012","retract_before_wipe":["0%","0%"],"retract_length_toolchange":["2","2"],"retract_lift_above":["0","0"],"retract_lift_below":["249","249"],"retract_restart_extra":["0","0"],"retract_restart_extra_toolchange":["0","0"],"retract_when_changing_layer":["1","1"],"retraction_distances_when_cut":["18","18"],"retraction_distances_when_ec":["0","0","0","0","0","0","0","0"],"retraction_length":["0.8","0.8"],"retraction_minimum_travel":["1","1"],"retraction_speed":["30","30"],"role_base_wipe_speed":"1","scan_first_layer":"0","scarf_angle_threshold":"155","seam_gap":"15%","seam_placement_away_from_overhangs":"0","seam_position":"aligned","seam_slope_conditional":"1","seam_slope_entire_loop":"0","seam_slope_gap":"0","seam_slope_inner_walls":"1","seam_slope_min_length":"10","seam_slope_start_height":"10%","seam_slope_steps":"10","seam_slope_type":"none","silent_mode":"0","single_extruder_multi_material":"1","skeleton_infill_density":"15%","skeleton_infill_line_width":"0.45","skin_infill_density":"15%","skin_infill_depth":"2","skin_infill_line_width":"0.45","skirt_distance":"2","skirt_height":"1","skirt_loops":"0","skirt_per_object":"1","slice_closing_radius":"0.049","slicing_mode":"regular","slow_down_for_layer_cooling":["1","1","1","1"],"slow_down_layer_time":["4","10","4","10"],"slow_down_min_speed":["20","20","20","20","20","20","20","20"],"slowdown_end_acc":["100000","100000"],"slowdown_end_height":["400","400"],"slowdown_end_speed":["1000","1000"],"slowdown_start_acc":["100000","100000"],"slowdown_start_height":["0","0"],"slowdown_start_speed":["1000","1000"],"small_perimeter_speed":["50%","50%"],"small_perimeter_threshold":["0","0"],"smooth_coefficient":"150","smooth_speed_discontinuity_area":"1","solid_infill_filament":"0","sparse_infill_acceleration":["100%","100%"],"sparse_infill_anchor":"400%","sparse_infill_anchor_max":"20","sparse_infill_density":"15%","sparse_infill_filament":"0","sparse_infill_lattice_angle_1":"-45","sparse_infill_lattice_angle_2":"45","sparse_infill_line_width":"0.45","sparse_infill_pattern":"grid","sparse_infill_speed":["270","370"],"spiral_mode":"0","spiral_mode_max_xy_smoothing":"200%","spiral_mode_smooth":"0","standby_temperature_delta":"-5","start_end_points":["30x-3","54x245"],"supertack_plate_temp":["45","70","45","70"],"supertack_plate_temp_initial_layer":["45","70","45","70"],"support_air_filtration":"0","support_angle":"0","support_base_pattern":"default","support_base_pattern_spacing":"2.5","support_bottom_interface_spacing":"0.5","support_bottom_z_distance":"0.2","support_chamber_temp_control":"0","support_cooling_filter":"0","support_critical_regions_only":"0","support_expansion":"0","support_fast_purge_mode":"0","support_filament":"0","support_interface_bottom_layers":"2","support_interface_filament":"0","support_interface_loop_pattern":"0","support_interface_not_for_body":"1","support_interface_pattern":"auto","support_interface_spacing":"0.5","support_interface_speed":["80","80"],"support_interface_top_layers":"2","support_ironing_direction":"0","support_ironing_flow":"10%","support_ironing_inset":"0","support_ironing_pattern":"zig-zag","support_ironing_spacing":"0.15","support_ironing_speed":"30","support_line_width":"0.42","support_object_first_layer_gap":"0.2","support_object_skip_flush":"0","support_object_xy_distance":"0.35","support_on_build_plate_only":"0","support_remove_small_overhang":"1","support_speed":["150","150"],"support_style":"default","support_threshold_angle":"30","support_top_z_distance":"0.2","support_type":"tree(auto)","symmetric_infill_y_axis":"0","temperature_vitrification":["45","70","45","70"],"template_custom_gcode":"","textured_plate_temp":["55","70","55","70"],"textured_plate_temp_initial_layer":["55","70","55","70"],"thick_bridges":"0","thumbnail_size":["50x50"],"time_lapse_gcode":";========Date 20250206========\n; SKIPPABLE_START\n; SKIPTYPE: timelapse\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\n{if timelapse_type == 0} ; timelapse without wipe tower\nM971 S11 C10 O0\nM1004 S5 P1 ; external shutter\n{elsif timelapse_type == 1} ; timelapse with wipe tower\nG92 E0\nG1 X65 Y245 F20000 ; move to safe pos\nG17\nG2 Z{layer_z} I0.86 J0.86 P1 F20000\nG1 Y265 F3000\nM400\nM1004 S5 P1 ; external shutter\nM400 P300\nM971 S11 C11 O0\nG92 E0\nG1 X100 F5000\nG1 Y255 F20000\n{endif}\nM623\n; SKIPPABLE_END","timelapse_type":"0","top_area_threshold":"200%","top_color_penetration_layers":"5","top_one_wall_type":"all top","top_shell_layers":"5","top_shell_thickness":"1","top_solid_infill_flow_ratio":["1","1"],"top_surface_acceleration":["2000","2000"],"top_surface_density":"100%","top_surface_jerk":"9","top_surface_line_width":"0.42","top_surface_pattern":"monotonicline","top_surface_speed":["200","300"],"top_z_overrides_xy_distance":"0","travel_acceleration":["10000","10000"],"travel_jerk":"9","travel_short_distance_acceleration":["250","250"],"travel_speed":["500","500"],"travel_speed_z":["0","0"],"tree_support_branch_angle":"45","tree_support_branch_diameter":"2","tree_support_branch_diameter_angle":"5","tree_support_branch_distance":"5","tree_support_wall_count":"-1","upward_compatible_machine":["Bambu Lab P1P 0.4 nozzle","Bambu Lab X1 0.4 nozzle","Bambu Lab X1 Carbon 0.4 nozzle","Bambu Lab X1E 0.4 nozzle","Bambu Lab A1 0.4 nozzle","Bambu Lab H2D 0.4 nozzle","Bambu Lab H2D Pro 0.4 nozzle","Bambu Lab H2S 0.4 nozzle","Bambu Lab P2S 0.4 nozzle","Bambu Lab H2C 0.4 nozzle","Bambu Lab X2D 0.4 nozzle","Bambu Lab A2L 0.4 nozzle"],"use_firmware_retraction":"0","use_relative_e_distances":"1","version":"02.07.01.62","vertical_shell_speed":["80%","80%"],"volumetric_speed_coefficients":["0 0 0 0 0 0","0 0 0 0 0 0","0 0 0 0 0 0","0 0 0 0 0 0","0 0 0 0 0 0","0 0 0 0 0 0","0 0 0 0 0 0","0 0 0 0 0 0"],"wall_distribution_count":"1","wall_filament":"0","wall_generator":"classic","wall_loops":"2","wall_sequence":"inner wall/outer wall","wall_transition_angle":"10","wall_transition_filter_deviation":"25%","wall_transition_length":"100%","wipe":["1","1"],"wipe_distance":["2","2"],"wipe_speed":"80%","wipe_tower_no_sparse_layers":"0","wipe_tower_rotation_angle":"0","wipe_tower_x":["15"],"wipe_tower_y":["220"],"wrapping_detection_gcode":"","wrapping_detection_layers":"20","wrapping_exclude_area":[],"xy_contour_compensation":"0","xy_hole_compensation":"0","z_direction_outwall_speed_continuous":"0","z_hop":["0.4","0.4"],"z_hop_types":["Auto Lift","Auto Lift"]} diff --git a/android/app/src/main/assets/orca_profiles/printer/flashforge_ad5x.json b/android/app/src/main/assets/orca_profiles/printer/flashforge_ad5x.json new file mode 100644 index 0000000..d2a03b2 --- /dev/null +++ b/android/app/src/main/assets/orca_profiles/printer/flashforge_ad5x.json @@ -0,0 +1,21 @@ +{ + "_comment": "Extracted from OrcaSlicer resources/profiles/Flashforge (AGPL-3.0) by resolving the inherits chain of 'Flashforge AD5X 0.4 nozzle'. Helix reads the machine G-code and compatibility flags from here; see HelixSliceRunner.readMachineProfile.", + "name": "FlashForge AD5X (0.4 nozzle)", + "printer_model": "Flashforge AD5X", + "printer_technology": "FFF", + "gcode_flavor": "klipper", + "helix_translate_marlin_machine_limits": true, + "helix_strip_m486": true, + "printable_area": [ + "0x0", + "220x0", + "220x220", + "0x220" + ], + "printable_height": "220", + "nozzle_diameter": [ + "0.4" + ], + "machine_start_gcode": "M190 S[bed_temperature_initial_layer_single]\nM104 S[nozzle_temperature_initial_layer]\nG90\nM83\nG1 Z2 F6000\nG1 X50 Y220 Z0.25 F4800\nG1 X70 E3 F900\nG1 X200 E10 F1800\nG0 Y219.6\nG1 X130 E5 F1800\nG92 E0", + "machine_end_gcode": "G1 E-3 F3600\nG0 X50 Y50 F30000\nM104 S0 ; turn off temperature" +} diff --git a/android/app/src/main/java/com/u1/slicer/gcode/GcodeParser.kt b/android/app/src/main/java/com/u1/slicer/gcode/GcodeParser.kt index b7630cf..de6e744 100644 --- a/android/app/src/main/java/com/u1/slicer/gcode/GcodeParser.kt +++ b/android/app/src/main/java/com/u1/slicer/gcode/GcodeParser.kt @@ -273,8 +273,15 @@ object GcodeParser { raw = raw * 10 + (l[i] - '0') i++ } - currentExtruder = raw.coerceIn(0, 31) // safety cap - ensureExtruderCapacity(currentExtruder) + // Bambu uses T255 for unload/no-tool and T1000 for its + // nozzle-load-line routine. They are machine operations, + // not printable filament indices. Clamping either to 31 + // made every following extrusion use the last (usually + // white) palette entry in the G-code preview. + if (raw in 0..31) { + currentExtruder = raw + ensureExtruderCapacity(currentExtruder) + } } } } diff --git a/android/app/src/main/java/com/u1/slicer/viewer/BedDrawable.kt b/android/app/src/main/java/com/u1/slicer/viewer/BedDrawable.kt index d91451d..8ae4622 100644 --- a/android/app/src/main/java/com/u1/slicer/viewer/BedDrawable.kt +++ b/android/app/src/main/java/com/u1/slicer/viewer/BedDrawable.kt @@ -6,9 +6,11 @@ import java.nio.ByteBuffer import java.nio.ByteOrder /** - * Shared bed rendering: U1 bed fill mesh, minor grid (10mm), major grid (50mm), border. + * Shared bed rendering: plate fill mesh, minor grid (10mm), major grid (50mm), border. * Instantiated by both ModelRenderer and GcodeRenderer so camera/gesture fixes * automatically apply to both viewers. + * + * Geometry comes from [bedProfile] rather than the U1 constants this started as. */ class BedDrawable(private val context: Context) { @@ -22,16 +24,59 @@ class BedDrawable(private val context: Context) { private var majorGridVertexCount = 0 private var bedBorderVAO = 0 + /** Geometry built into the VAOs; null until the first [setup]. */ + private var builtProfile: BedProfile? = null + + /** + * Buffers backing the current bed. Deleting a VAO does not delete the VBOs + * attached to it, so they are tracked here and freed on rebuild. + */ + private val ownedVBOs = mutableListOf() + + /** + * The bed to draw. Set from the UI thread before or after the surface + * exists — VAOs cannot be built off the GL thread, so a change here is only + * recorded, and [draw] rebuilds on the next frame when it notices the + * built geometry is stale. + */ + @Volatile + var bedProfile: BedProfile = BedProfile.U1 + /** Call from onSurfaceCreated on the GL thread. */ fun setup(context: Context) { gridShader = ShaderProgram(context, "shaders/grid.vert", "shaders/grid.frag") - setupBedMesh(context) - setupGrid() + rebuild(context, bedProfile) + } + + private fun rebuild(context: Context, profile: BedProfile) { + releaseGeometry() + setupBedMesh(context, profile) + setupGrid(profile) + builtProfile = profile + } + + /** Drops the VAOs/VBOs backing the current bed so a rebuild does not leak them. */ + private fun releaseGeometry() { + val vaos = intArrayOf(bedFillVAO, gridVAO, majorGridVAO, bedBorderVAO).filter { it != 0 } + if (vaos.isNotEmpty()) GLES30.glDeleteVertexArrays(vaos.size, vaos.toIntArray(), 0) + if (ownedVBOs.isNotEmpty()) { + GLES30.glDeleteBuffers(ownedVBOs.size, ownedVBOs.toIntArray(), 0) + ownedVBOs.clear() + } + bedFillVAO = 0; bedFillVertexCount = 0 + gridVAO = 0; gridVertexCount = 0 + majorGridVAO = 0; majorGridVertexCount = 0 + bedBorderVAO = 0 } /** Call from onDrawFrame on the GL thread. */ fun draw(camera: Camera) { val shader = gridShader ?: return + + // Picked up here rather than in the setter: this is the GL thread. + val wanted = bedProfile + if (builtProfile != wanted) rebuild(context, wanted) + shader.use() camera.computeMVP() GLES30.glUniformMatrix4fv(shader.getUniformLocation("u_MVPMatrix"), 1, false, camera.mvpMatrix, 0) @@ -66,22 +111,25 @@ class BedDrawable(private val context: Context) { GLES30.glBindVertexArray(0) } - private fun setupBedMesh(context: Context) { + private fun setupBedMesh(context: Context, profile: BedProfile) { + val asset = profile.modelAsset ?: return val bytes = try { - context.assets.open("bed/u1_bed.stl").readBytes() + context.assets.open("bed/$asset").readBytes() } catch (_: Exception) { return } if (bytes.size < 84) return val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) buf.position(80) val triCount = buf.int + // Guard a truncated or ASCII STL before trusting the triangle count. + if (bytes.size < 84 + triCount * 50) return val verts = mutableListOf() repeat(triCount) { buf.position(buf.position() + 12) // skip normal repeat(3) { - val x = buf.float + 135f - val y = buf.float + 135f + val x = buf.float + profile.centerX + val y = buf.float + profile.centerY buf.float // Z — flatten to 0 verts.add(x); verts.add(y); verts.add(0f) } @@ -94,7 +142,7 @@ class BedDrawable(private val context: Context) { fbuf.put(verts.toFloatArray()); fbuf.flip() val vaos = IntArray(1); GLES30.glGenVertexArrays(1, vaos, 0); bedFillVAO = vaos[0] - val vbos = IntArray(1); GLES30.glGenBuffers(1, vbos, 0) + val vbos = IntArray(1); GLES30.glGenBuffers(1, vbos, 0); ownedVBOs += vbos[0] GLES30.glBindVertexArray(bedFillVAO) GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, vbos[0]) GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER, verts.size * 4, fbuf, GLES30.GL_STATIC_DRAW) @@ -103,8 +151,8 @@ class BedDrawable(private val context: Context) { GLES30.glBindVertexArray(0) } - private fun setupGrid() { - val bedW = 270f; val bedH = 270f + private fun setupGrid(profile: BedProfile) { + val bedW = profile.sizeX; val bedH = profile.sizeY fun makeLineVao(step: Float): Pair { val lines = mutableListOf() @@ -114,7 +162,7 @@ class BedDrawable(private val context: Context) { val buf = ByteBuffer.allocateDirect(lines.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer() buf.put(lines.toFloatArray()); buf.flip() val vaos = IntArray(1); GLES30.glGenVertexArrays(1, vaos, 0) - val vbos = IntArray(1); GLES30.glGenBuffers(1, vbos, 0) + val vbos = IntArray(1); GLES30.glGenBuffers(1, vbos, 0); ownedVBOs += vbos[0] GLES30.glBindVertexArray(vaos[0]) GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, vbos[0]) GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER, lines.size * 4, buf, GLES30.GL_STATIC_DRAW) @@ -137,7 +185,7 @@ class BedDrawable(private val context: Context) { val borderBuf = ByteBuffer.allocateDirect(border.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer() borderBuf.put(border); borderBuf.flip() val bVaos = IntArray(1); GLES30.glGenVertexArrays(1, bVaos, 0); bedBorderVAO = bVaos[0] - val bVbos = IntArray(1); GLES30.glGenBuffers(1, bVbos, 0) + val bVbos = IntArray(1); GLES30.glGenBuffers(1, bVbos, 0); ownedVBOs += bVbos[0] GLES30.glBindVertexArray(bedBorderVAO) GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, bVbos[0]) GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER, border.size * 4, borderBuf, GLES30.GL_STATIC_DRAW) diff --git a/android/app/src/main/java/com/u1/slicer/viewer/BedProfile.kt b/android/app/src/main/java/com/u1/slicer/viewer/BedProfile.kt new file mode 100644 index 0000000..9eae6ca --- /dev/null +++ b/android/app/src/main/java/com/u1/slicer/viewer/BedProfile.kt @@ -0,0 +1,184 @@ +package com.u1.slicer.viewer + +import org.json.JSONObject + +/** + * Build volume plus the plate drawn beneath the model in the 3D viewers. + * + * Mirrors `BedProfile` in services/printerProfiles.ts: the RN layer resolves the + * active printer and hands these numbers to the preview Activity, so the bed you + * see is the bed the engine slices against. + * + * Helix shipped U1-only, so the viewers had 270 and 135 written into them in a + * dozen places. Everything bed-shaped now reads off one of these instead. + */ +data class BedProfile( + /** Printable area in mm — Orca's `printable_area`. */ + @JvmField val sizeX: Float, + @JvmField val sizeY: Float, + /** Max Z in mm — Orca's `printable_height`. */ + @JvmField val height: Float, + /** + * Plate mesh filename inside the `bed/` asset dir, or null to draw a plain + * rectangle. Binary STL only — the loader does not parse ASCII. + * + * Authored in Orca's convention: about the centre of the printable area, so + * the loader shifts by (sizeX/2, sizeY/2). These meshes are deliberately not + * symmetric — the material hanging off the front is the plate's grab handle + * and is meant to overhang the bed. + */ + @JvmField val modelAsset: String?, + /** + * Wordmark painted on the plate. Null leaves the plate bare, which is the + * right answer for any machine whose mark we do not have — stamping + * "snapmaker" on a FlashForge plate is worse than stamping nothing. + */ + @JvmField val logoText: String?, +) { + /** Bed centre, i.e. what the camera looks at and what meshes are shifted by. */ + val centerX: Float get() = sizeX / 2f + val centerY: Float get() = sizeY / 2f + + /** + * How far back the default plate-overview camera sits. Tuned on the U1 + * (270mm bed at distance 500) and scaled from there, so a small bed is not + * framed from orbit and a large one does not overflow the viewport. + */ + val defaultCameraDistance: Double get() = (maxOf(sizeX, sizeY) * 1.85f).toDouble() + + companion object { + /** + * Snapmaker U1. Also the fallback when no profile is supplied, so an + * older RN bundle that does not pass one keeps its existing bed. + * + * Orca's printable_area for the U1 is nominally 271x272 with a half-mm + * origin offset; the viewers and CopyArrangeCalculator have always used + * a flat 270x270 and the arrangement maths is tuned to it. + */ + @JvmField + val U1 = BedProfile( + sizeX = 270f, + sizeY = 270f, + height = 270f, + modelAsset = "u1_bed.stl", + logoText = "snapmaker", + ) + + /** + * Parses the bed the RN layer resolved for the active printer, as + * serialised by services/printerProfiles.ts. + * + * Falls back to [U1] on anything malformed or absent — including an + * older JS bundle running against a newer native build, which sends no + * bed at all. A wrong-sized bed is a visual bug; a crash on open is not + * a trade worth making. + */ + @JvmStatic + fun fromJson(json: String?): BedProfile { + if (json.isNullOrBlank()) return U1 + return try { + val obj = JSONObject(json) + val sizeX = obj.optDouble("sizeX", U1.sizeX.toDouble()).toFloat() + val sizeY = obj.optDouble("sizeY", U1.sizeY.toDouble()).toFloat() + val height = obj.optDouble("height", U1.height.toDouble()).toFloat() + // A zero or negative bed would divide the camera by nothing and + // build an empty grid, so treat it as unusable rather than + // rendering a degenerate plate. + if (sizeX <= 0f || sizeY <= 0f || height <= 0f) return U1 + BedProfile( + sizeX = sizeX, + sizeY = sizeY, + height = height, + modelAsset = obj.optStringOrNull("modelAsset"), + logoText = obj.optStringOrNull("logoText"), + ) + } catch (_: Throwable) { + U1 + } + } + + /** optString maps JSON null to "null"; this maps it to a Kotlin null. */ + internal fun JSONObject.optStringOrNull(key: String): String? { + if (!has(key) || isNull(key)) return null + return optString(key).takeIf { it.isNotBlank() } + } + } +} + +/** + * Everything about the target machine that the slice needs: how big its bed is, + * and which Orca printer profile supplies its start/end G-code. + * + * Separate from [BedProfile] because the renderers genuinely only need the bed, + * while the slice needs the machine. Mirrors `MachineProfile` in + * services/printerProfiles.ts. + */ +data class MachineProfile( + @JvmField val bed: BedProfile, + /** + * Printer profile under the `orca_profiles/printer/` asset dir, or null to + * let the engine fall back to its own defaults (a bare G28). + * + * This is what stops an AD5X job going out with the U1's start G-code — + * the two prime on opposite sides of the bed. + */ + @JvmField val sliceProfileAsset: String?, + /** + * The machine exposes PAXX's print_task_config object and its + * SET_PRINT_PREFERENCES macros. U1 firmware only — see the TS profile. + */ + @JvmField val supportsPrintPreferences: Boolean, + /** + * Preparation toggles the preprocess sheet should offer, by the same keys + * services/printerProfiles.ts uses. Empty means the RN layer sent none, in + * which case the sheet keeps its own full list rather than showing nothing. + */ + @JvmField val printPrefs: List, + /** + * How the machine names its material feeds: "lane" shows Lane 1–4 (AD5X, + * Bambu), "tool" shows T0–T3 feeding lanes (U1, everything else). Mirrors + * `laneNaming` in services/printerProfiles.ts. + */ + @JvmField val laneNaming: String, +) { + companion object { + /** Snapmaker U1 — the machine Helix shipped with, and the fallback. */ + @JvmField + val U1 = MachineProfile( + bed = BedProfile.U1, + sliceProfileAsset = "snapmaker_u1.json", + supportsPrintPreferences = true, + printPrefs = listOf("autoLevel", "flowCal", "timelapse"), + laneNaming = "tool", + ) + + /** + * Parses what the RN layer resolved for the active printer. Falls back + * to [U1] on anything malformed, matching BedProfile.fromJson. + */ + @JvmStatic + fun fromJson(json: String?): MachineProfile { + if (json.isNullOrBlank()) return U1 + return try { + val obj = JSONObject(json) + // Accept a bare bed object too, so a JS bundle that predates the + // slice plumbing still positions the bed correctly. + val bedJson = if (obj.has("bed")) obj.optJSONObject("bed")?.toString() else json + MachineProfile( + bed = BedProfile.fromJson(bedJson), + sliceProfileAsset = with(BedProfile) { obj.optStringOrNull("sliceProfileAsset") }, + // Absent means an older JS bundle; the U1 was the only + // machine then, so its behaviour is the right default. + supportsPrintPreferences = obj.optBoolean("supportsPrintPreferences", true), + printPrefs = obj.optJSONArray("printPrefs")?.let { arr -> + (0 until arr.length()).mapNotNull { arr.optString(it).takeIf(String::isNotBlank) } + } ?: emptyList(), + // Absent for the same reason, and the U1 names tools. + laneNaming = obj.optString("laneNaming", "tool").ifBlank { "tool" }, + ) + } catch (_: Throwable) { + U1 + } + } + } +} diff --git a/android/app/src/main/java/com/u1/slicer/viewer/GcodeRenderer.kt b/android/app/src/main/java/com/u1/slicer/viewer/GcodeRenderer.kt index 8ac5b09..29431c8 100644 --- a/android/app/src/main/java/com/u1/slicer/viewer/GcodeRenderer.kt +++ b/android/app/src/main/java/com/u1/slicer/viewer/GcodeRenderer.kt @@ -35,6 +35,18 @@ class GcodeRenderer(private val context: Context) : GLSurfaceView.Renderer { private var toolpathShader: ShaderProgram? = null private val bed = BedDrawable(context) + /** + * Build volume of the printer this gcode is headed for. Safe to set from the + * UI thread at any point — BedDrawable rebuilds its geometry on the next + * frame, and the camera picks it up at the next reset. + */ + @Volatile + var bedProfile: BedProfile = BedProfile.U1 + set(value) { + field = value + bed.bedProfile = value + } + // Segment template: 24 vertex IDs for 8 triangles (created once) private var templateVAO = 0 private var templateVBO = 0 @@ -175,8 +187,8 @@ class GcodeRenderer(private val context: Context) : GLSurfaceView.Renderer { if (preserveRestoredCameraOnSurfaceInit) { preserveRestoredCameraOnSurfaceInit = false } else { - camera.setTarget(135.0, 135.0, 0.0) - camera.distance = 500.0 + camera.setTarget(bedProfile.centerX.toDouble(), bedProfile.centerY.toDouble(), 0.0) + camera.distance = bedProfile.defaultCameraDistance camera.elevation = 62.0 camera.azimuth = -90.0 } @@ -273,8 +285,8 @@ class GcodeRenderer(private val context: Context) : GLSurfaceView.Renderer { } } if (minX == Float.MAX_VALUE) { - camera.setTarget(135.0, 135.0, 0.0) - camera.distance = 500.0 + camera.setTarget(bedProfile.centerX.toDouble(), bedProfile.centerY.toDouble(), 0.0) + camera.distance = bedProfile.defaultCameraDistance } else { val pad = 20f camera.setTarget(((minX + maxX) / 2f).toDouble(), ((minY + maxY) / 2f).toDouble(), 0.0) diff --git a/android/app/src/main/java/com/u1/slicer/viewer/ModelRenderer.kt b/android/app/src/main/java/com/u1/slicer/viewer/ModelRenderer.kt index 057f61f..e36de9f 100644 --- a/android/app/src/main/java/com/u1/slicer/viewer/ModelRenderer.kt +++ b/android/app/src/main/java/com/u1/slicer/viewer/ModelRenderer.kt @@ -24,6 +24,18 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { private val mainHandler = Handler(Looper.getMainLooper()) var meshData: MeshData? = null private set + + /** + * Build volume of the printer this model is headed for. Set before the GL + * surface is created (the Activity resolves it from its intent), so the + * first frame is already the right bed. + * + * This renderer builds its own bed/grid rather than sharing BedDrawable — + * see setupBedMesh. + */ + @Volatile + var bedProfile: BedProfile = BedProfile.U1 + private var modelShader: ShaderProgram? = null private var gridShader: ShaderProgram? = null private var textureShader: ShaderProgram? = null @@ -180,8 +192,8 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { } internal fun resetCameraToDefaultView() { - camera.setTarget(135.0, 135.0, 0.0) - camera.distance = 500.0 + camera.setTarget(bedProfile.centerX.toDouble(), bedProfile.centerY.toDouble(), 0.0) + camera.distance = bedProfile.defaultCameraDistance camera.elevation = 62.0 camera.azimuth = -90.0 } @@ -992,21 +1004,28 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { } private fun setupBedMesh() { - // Parse the U1 bed STL (binary format) from assets. - // The STL is centred at (0,0); our print area is (0..270, 0..270) centred at (135,135). - // Translate by (+135, +135). Flatten to Z=0 so grid lines at Z=0.1+ sit flush above. + // Parse the plate STL (binary format) from assets. The mesh is authored + // about the centre of the printable area, so translate by half the bed + // to put it in (0..sizeX, 0..sizeY). Flatten to Z=0 so grid lines at + // Z=0.1+ sit flush above it. + val profile = bedProfile + val bedW = profile.sizeX + val bedH = profile.sizeY val verts = mutableListOf() try { - context.assets.open("bed/u1_bed.stl").use { stream -> + val asset = profile.modelAsset ?: throw IllegalStateException("no plate mesh") + context.assets.open("bed/$asset").use { stream -> val bytes = stream.readBytes() val buf = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN) buf.position(80) // skip header val triCount = buf.int + // Guard a truncated or ASCII STL before trusting the count. + if (bytes.size < 84 + triCount * 50) throw IllegalStateException("not binary STL") repeat(triCount) { buf.position(buf.position() + 12) // skip normal repeat(3) { - val x = buf.float + 135f - val y = buf.float + 135f + val x = buf.float + profile.centerX + val y = buf.float + profile.centerY buf.float // discard original Z — flatten to Z=0 verts.add(x); verts.add(y); verts.add(0f) } @@ -1014,9 +1033,10 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { } } } catch (e: Exception) { - // Fallback: simple quad if STL fails to load - verts.addAll(listOf(0f,0f,0f, 270f,0f,0f, 270f,270f,0f, - 0f,0f,0f, 270f,270f,0f, 0f,270f,0f)) + // Fallback: plain rectangle. Also the intended path for a printer we + // have no plate mesh for, which declares a null modelAsset. + verts.addAll(listOf(0f,0f,0f, bedW,0f,0f, bedW,bedH,0f, + 0f,0f,0f, bedW,bedH,0f, 0f,bedH,0f)) } bedFillVertexCount = verts.size / 3 @@ -1034,7 +1054,11 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { } private fun setupLogoTexture() { - // Render "snapmaker" text to a bitmap, upload as GL texture + // Vendor wordmark on the plate. Absent for machines we have no mark for + // — a bare plate beats stamping "snapmaker" on a FlashForge bed. + val logoText = bedProfile.logoText ?: return + + // Render the wordmark to a bitmap, upload as GL texture val bmpW = 512; val bmpH = 128 val bitmap = Bitmap.createBitmap(bmpW, bmpH, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) @@ -1045,7 +1069,7 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { textAlign = Paint.Align.CENTER letterSpacing = 0.08f } - canvas.drawText("snapmaker", bmpW / 2f, bmpH * 0.72f, paint) + canvas.drawText(logoText, bmpW / 2f, bmpH * 0.72f, paint) val textures = IntArray(1) GLES30.glGenTextures(1, textures, 0) @@ -1058,9 +1082,13 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { GLUtils.texImage2D(GLES30.GL_TEXTURE_2D, 0, bitmap, 0) bitmap.recycle() - // Logo quad: 150mm wide × 37mm tall, centered on bed at (135, 135), Z=0 - val lx = 135f - 75f; val rx = 135f + 75f - val ly = 135f - 18.5f; val ry = 135f + 18.5f + // Logo quad, centred on the bed at Z=0. Sized as a fraction of the bed + // rather than a fixed 150mm so it reads the same on a 180mm plate as on + // the U1's 270mm one; the fractions reproduce the original U1 150x37mm. + val logoW = bedProfile.sizeX * (150f / 270f) + val logoH = logoW * (37f / 150f) + val lx = bedProfile.centerX - logoW / 2f; val rx = bedProfile.centerX + logoW / 2f + val ly = bedProfile.centerY - logoH / 2f; val ry = bedProfile.centerY + logoH / 2f val z = 0f // position (xyz) + texcoord (uv) — stride 20 bytes val verts = floatArrayOf( @@ -1085,8 +1113,8 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { } private fun setupGrid() { - val bedW = 270f - val bedH = 270f + val bedW = bedProfile.sizeX + val bedH = bedProfile.sizeY // Minor grid lines every 10mm — all at Z=0 (polygon offset pushes bed behind) val minorLines = mutableListOf() @@ -1115,12 +1143,20 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { GLES30.glEnableVertexAttribArray(0) GLES30.glBindVertexArray(0) - // Major grid lines every 50mm — also Z=0 - val majorLines = mutableListOf() - for (v in listOf(0f, 50f, 100f, 150f, 200f, 250f, 270f)) { - majorLines.addAll(listOf(v, 0f, 0f, v, bedH, 0f)) - majorLines.addAll(listOf(0f, v, 0f, bedW, v, 0f)) + // Major grid lines every 50mm — also Z=0. The far edge always gets a + // line even when it is not on a 50mm boundary (a 220mm bed ends at 220, + // not 200), which is why the U1 list used to carry a stray 270. + fun ticks(extent: Float): List { + val out = mutableListOf() + var v = 0f + while (v < extent) { out.add(v); v += 50f } + out.add(extent) + return out } + + val majorLines = mutableListOf() + for (v in ticks(bedW)) majorLines.addAll(listOf(v, 0f, 0f, v, bedH, 0f)) + for (v in ticks(bedH)) majorLines.addAll(listOf(0f, v, 0f, bedW, v, 0f)) majorGridVertexCount = majorLines.size / 3 val majBuf = ByteBuffer.allocateDirect(majorLines.size * 4) @@ -1191,7 +1227,9 @@ class ModelRenderer(private val context: Context) : GLSurfaceView.Renderer { GLES30.glDrawArrays(GLES30.GL_LINES, 0, 8) GLES30.glBindVertexArray(0) - // 5. Snapmaker logo — blended text texture + // 5. Vendor wordmark — blended text texture. Skipped entirely for a bed + // profile with no logo, which never builds the quad. + if (logoVAO == 0) return val texShader = textureShader ?: return texShader.use() GLES30.glUniformMatrix4fv(texShader.getUniformLocation("u_MVPMatrix"), 1, false, camera.mvpMatrix, 0) diff --git a/android/app/src/main/java/org/crabcore/u1control/MainApplication.kt b/android/app/src/main/java/org/crabcore/u1control/MainApplication.kt index 59624bc..ca50a32 100644 --- a/android/app/src/main/java/org/crabcore/u1control/MainApplication.kt +++ b/android/app/src/main/java/org/crabcore/u1control/MainApplication.kt @@ -12,6 +12,8 @@ import com.facebook.react.ReactHost import com.facebook.react.common.ReleaseLevel import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost +import org.crabcore.u1control.bambu.BambuMqttPackage +import org.crabcore.u1control.bespok3d.Bespok3dPackage import org.crabcore.u1control.slicing.HelixSlicerPackage import expo.modules.ApplicationLifecycleDispatcher @@ -26,6 +28,8 @@ class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: add(HelixSlicerPackage()) + add(BambuMqttPackage()) + add(Bespok3dPackage()) } override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuChamberCamera.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuChamberCamera.kt new file mode 100644 index 0000000..de48b41 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuChamberCamera.kt @@ -0,0 +1,370 @@ +// Bambu's chamber camera, republished as MJPEG on loopback. +// +// The P-series does not serve RTSP (that is X1-only, on 322) or anything a +// WebView can open. Port 6000 speaks a private protocol, verified against a +// P1S: +// +// client -> 80-byte auth packet: +// then username and access code, each NUL-padded to 32 bytes +// server -> repeating: 16-byte header whose first LE uint32 is the payload +// length, followed by that many bytes of a complete JPEG +// +// Rather than push ~90KB frames across the React Native bridge, this serves +// them as multipart/x-mixed-replace on 127.0.0.1. components/CameraFeed.tsx +// already drives an MJPEG player for Moonraker webcams, so it can point at the +// local URL and needs no Bambu-specific code. +// +// No React Native imports here, so it can be tested against a real printer from +// a plain JVM — see BambuLiveCameraTest. +// crabcore + +package org.crabcore.u1control.bambu + +import android.util.Log +import java.io.BufferedInputStream +import java.io.DataInputStream +import java.io.IOException +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLSocket + +/** + * @param onStopped fired when the printer's video stream ends for any reason. + * Without it a dropped stream leaves the UI showing a URL that will never + * serve another frame, and nothing to trigger a retry. + */ +class BambuChamberCamera(private val onStopped: (() -> Unit)? = null) { + + private val frameLock = Object() + private var latestFrame: ByteArray? = null + private var frameSequence = 0L + + @Volatile private var running = false + @Volatile private var failure: String? = null + private var server: ServerSocket? = null + private val threads = mutableListOf() + + /** + * Connects to the printer and starts serving. Blocks until the first frame + * arrives so the caller gets either a working URL or a real error, rather + * than a URL that quietly never produces an image. + */ + @Synchronized + fun start(host: String, serial: String, accessCode: String): String { + stop() + + // Loopback only: the stream is for this app, not for the network. The token + // keeps other apps on the device from guessing the URL. + val socket = ServerSocket(0, 2, InetAddress.getByName(LOOPBACK)) + server = socket + running = true + failure = null + + val token = randomToken() + val firstFrame = CountDownLatch(1) + + Log.i(TAG, "starting camera for $host on local port ${socket.localPort}") + spawn("helix-bambu-cam-reader") { readFrames(host, serial, accessCode, firstFrame) } + spawn("helix-bambu-cam-server") { acceptClients(socket, token) } + + // The reader releases this latch when it gives up as well as when a frame + // lands, so waiting on it alone would treat a refused handshake as success + // and hand back a URL that never produces an image. The frame itself is the + // only proof that matters. + val settled = firstFrame.await(FIRST_FRAME_TIMEOUT_SECONDS, TimeUnit.SECONDS) + val gotFrame = synchronized(frameLock) { latestFrame != null } + + if (!gotFrame) { + val reason = failure + ?: if (settled) "The printer closed the video stream without sending a frame" + else "The printer sent no video within ${FIRST_FRAME_TIMEOUT_SECONDS}s" + Log.w(TAG, "camera failed to start: $reason") + stop() + throw BambuConnectException(cameraErrorCode(reason), reason) + } + + return "http://$LOOPBACK:${socket.localPort}/stream/$token" + } + + @Synchronized + fun stop() { + running = false + try { + server?.close() + } catch (_: IOException) { + // Already closed. + } + server = null + threads.forEach(Thread::interrupt) + threads.clear() + synchronized(frameLock) { + latestFrame = null + frameLock.notifyAll() + } + } + + /** True while frames are still arriving from the printer. */ + fun isRunning(): Boolean = running + + private fun spawn(name: String, body: () -> Unit) { + val thread = Thread(body, name) + thread.isDaemon = true + threads.add(thread) + thread.start() + } + + private fun readFrames( + host: String, + serial: String, + accessCode: String, + firstFrame: CountDownLatch + ) { + var socket: SSLSocket? = null + try { + socket = (bambuSocketFactory(serial).createSocket() as SSLSocket).apply { + connect(InetSocketAddress(host, CAMERA_PORT), CONNECT_TIMEOUT_MS) + soTimeout = READ_TIMEOUT_MS + startHandshake() + } + + socket.outputStream.apply { + write(authPacket(accessCode)) + flush() + } + + val input = DataInputStream(BufferedInputStream(socket.inputStream)) + val header = ByteArray(HEADER_BYTES) + + while (running) { + input.readFully(header) + val size = ByteBuffer.wrap(header, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + // A bad access code shows up here as garbage rather than a refusal, so + // an implausible length is the tell. + if (size <= 0 || size > MAX_FRAME_BYTES) { + throw IOException("Frame length $size is out of range; check the access code") + } + + val frame = ByteArray(size) + input.readFully(frame) + publish(frame) + firstFrame.countDown() + } + } catch (e: Exception) { + if (running) { + failure = describeFailure(e) + Log.w(TAG, "camera reader stopped: $failure") + } + } finally { + running = false + firstFrame.countDown() + try { + socket?.close() + } catch (_: IOException) { + // Nothing left to do. + } + // Unblock the acceptor so it can release the port, and wake any viewer + // waiting on a frame that is never coming. + try { + server?.close() + } catch (_: IOException) { + // Already closed. + } + synchronized(frameLock) { frameLock.notifyAll() } + onStopped?.invoke() + } + } + + private fun publish(frame: ByteArray) { + synchronized(frameLock) { + latestFrame = frame + frameSequence += 1 + frameLock.notifyAll() + } + } + + private fun acceptClients(socket: ServerSocket, token: String) { + try { + while (running) { + val client = try { + socket.accept() + } catch (_: IOException) { + return + } + Log.i(TAG, "camera viewer connected") + spawn("helix-bambu-cam-client") { serveClient(client, token) } + } + } finally { + // Leaving this open once the loop ends would strand a bound port with + // nobody accepting: viewers would connect at the TCP level and then hang + // forever waiting for a reply that no thread is left to send. + try { + socket.close() + } catch (_: IOException) { + // Already closed. + } + Log.i(TAG, "camera server closed") + } + } + + private fun serveClient(client: Socket, token: String) { + try { + client.use { connection -> + val request = DataInputStream(BufferedInputStream(connection.inputStream)) + .readLine() + .orEmpty() + + Log.i(TAG, "camera request: $request") + when { + request.contains("/stream/$token") -> { + val out = connection.outputStream + out.write( + ( + "HTTP/1.0 200 OK\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n" + + "Content-Type: multipart/x-mixed-replace; boundary=$BOUNDARY\r\n\r\n" + ).toByteArray() + ) + out.flush() + streamTo(out) + } + // The newest frame is already in memory, so a still costs no printer + // connection — the app polls this instead of decoding the MJPEG stream + // in a WebView. + request.contains("/frame/$token") -> { + val frame = synchronized(frameLock) { latestFrame } + val out = connection.outputStream + if (frame == null) { + out.write("HTTP/1.0 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n".toByteArray()) + } else { + out.write( + ( + "HTTP/1.0 200 OK\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n" + + "Content-Type: image/jpeg\r\n" + + "Content-Length: ${frame.size}\r\n\r\n" + ).toByteArray() + ) + out.write(frame) + } + out.flush() + } + else -> { + connection.outputStream.write( + "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n".toByteArray() + ) + } + } + } + } catch (_: IOException) { + // The viewer went away; nothing to report. + } + } + + private fun streamTo(out: OutputStream) { + var sent = 0L + + while (running) { + val frame: ByteArray + synchronized(frameLock) { + while (running && frameSequence == sent) { + try { + frameLock.wait(FRAME_WAIT_MS) + } catch (_: InterruptedException) { + // stop() interrupts every camera worker to make printer switches + // immediate. That is normal cooperative shutdown, not a fatal + // thread failure Android should surface as an app crash. + Thread.currentThread().interrupt() + return + } + } + if (!running) return + frame = latestFrame ?: return + sent = frameSequence + } + + out.write( + ( + "--$BOUNDARY\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.size}\r\n\r\n" + ).toByteArray() + ) + out.write(frame) + out.write("\r\n".toByteArray()) + out.flush() + } + } + + /** + * A rejected certificate surfaces as an SSLHandshakeException whose own + * message says nothing useful — our trust manager's explanation is buried + * further down the chain, and it is the only part worth showing anyone. + */ + private fun describeFailure(error: Throwable): String { + var cursor: Throwable? = error + while (cursor != null) { + val message = cursor.message + if (cursor is CertificateException && !message.isNullOrBlank()) return message + cursor = cursor.cause + } + return error.message ?: error.javaClass.simpleName + } + + private fun cameraErrorCode(reason: String): String = when { + reason.contains(BAMBU_SERIAL_MISMATCH) -> "wrong-serial" + reason.contains("access code") -> "wrong-access-code" + else -> "camera-failed" + } + + private fun authPacket(accessCode: String): ByteArray = + ByteBuffer.allocate(AUTH_PACKET_BYTES).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(AUTH_MAGIC_SIZE) + putInt(AUTH_MAGIC_TYPE) + putInt(0) + putInt(0) + put(padded(LAN_USERNAME)) + put(padded(accessCode)) + }.array() + + /** Fixed-width NUL-padded field, as the printer's parser expects. */ + private fun padded(value: String): ByteArray = ByteArray(FIELD_BYTES).also { field -> + val bytes = value.toByteArray(StandardCharsets.US_ASCII) + System.arraycopy(bytes, 0, field, 0, minOf(bytes.size, FIELD_BYTES)) + } + + private fun randomToken(): String { + val bytes = ByteArray(9) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + companion object { + const val CAMERA_PORT = 6000 + private const val TAG = "HelixBambuCam" + private const val LOOPBACK = "127.0.0.1" + private const val LAN_USERNAME = "bblp" + private const val BOUNDARY = "helixframe" + private const val HEADER_BYTES = 16 + private const val FIELD_BYTES = 32 + private const val AUTH_PACKET_BYTES = 80 + private const val AUTH_MAGIC_SIZE = 0x40 + private const val AUTH_MAGIC_TYPE = 0x3000 + /** Observed frames are 80-100KB; this only needs to catch nonsense. */ + private const val MAX_FRAME_BYTES = 8 * 1024 * 1024 + private const val CONNECT_TIMEOUT_MS = 8000 + /** The P1S sends a frame roughly every second, so this is generous. */ + private const val READ_TIMEOUT_MS = 15000 + private const val FIRST_FRAME_TIMEOUT_SECONDS = 15L + private const val FRAME_WAIT_MS = 1000L + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuFtpsClient.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuFtpsClient.kt new file mode 100644 index 0000000..5d1be4f --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuFtpsClient.kt @@ -0,0 +1,181 @@ +package org.crabcore.u1control.bambu + +import org.apache.commons.net.ftp.FTP +import org.apache.commons.net.ftp.FTPSClient +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.jsse.BCExtendedSSLSession +import org.bouncycastle.jsse.BCSSLSocket +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +import java.io.Closeable +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.net.Socket +import java.security.SecureRandom +import java.time.Duration +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager + +data class BambuFtpsConfig( + val host: String, + val serial: String, + val accessCode: String, + val connectTimeoutMs: Int = 15_000, + val dataTimeoutMs: Int = 120_000, +) + +data class BambuFtpsUploadResult( + val remoteName: String, + val verifiedBytes: Long, +) + +/** + * Uploads one print-ready archive to the printer's FTPS root. + * + * P1/X1 firmware runs an implicit-TLS vsftpd on port 990. Its protected data + * connection must resume the control connection's TLS session. Android JSSE + * cannot request a specific session, so the production session uses BCJSSE's + * public [BCSSLSocket.setBCSessionToResume] API and pins TLS 1.2, where session + * resumption is synchronous. + */ +class BambuFtpsClient private constructor( + private val sessionFactory: BambuFtpsSessionFactory, +) { + constructor() : this(BcBambuFtpsSessionFactory) + + internal constructor(sessionFactory: (BambuFtpsConfig) -> BambuFtpsSession) : + this(BambuFtpsSessionFactory(sessionFactory)) + + fun upload(config: BambuFtpsConfig, artifact: File, remoteName: String = artifact.name): BambuFtpsUploadResult { + validate(config, artifact, remoteName) + val expectedSize = artifact.length() + sessionFactory.open(config).use { session -> + session.connect(config.host, FTPS_PORT, config.connectTimeoutMs, config.dataTimeoutMs) + check(session.login(USERNAME, config.accessCode)) { "Bambu FTPS login was rejected" } + check(session.useBinaryTransfers()) { "Bambu FTPS rejected binary transfer mode" } + session.protectDataChannel() + session.usePassiveMode() + FileInputStream(artifact).buffered().use { input -> + check(session.store(remoteName, input)) { "Bambu FTPS upload was rejected" } + } + val remoteSize = session.size(remoteName) + check(remoteSize == expectedSize) { + "Bambu FTPS size verification failed (expected $expectedSize bytes, got ${remoteSize ?: "no SIZE reply"})" + } + session.logout() + return BambuFtpsUploadResult(remoteName, remoteSize) + } + } + + private fun validate(config: BambuFtpsConfig, artifact: File, remoteName: String) { + require(config.host.isNotBlank()) { "Bambu printer address is required" } + require(config.serial.isNotBlank()) { "Bambu printer serial number is required" } + require(config.accessCode.isNotBlank()) { "Bambu printer access code is required" } + require(config.connectTimeoutMs in 1_000..120_000) { "Invalid FTPS connection timeout" } + require(config.dataTimeoutMs in 1_000..600_000) { "Invalid FTPS data timeout" } + require(artifact.isFile && artifact.length() > 0L) { "Bambu print artifact is missing or empty" } + require(remoteName == remoteName.substringAfterLast('/').substringAfterLast('\\')) { + "Bambu FTPS upload must target the printer root" + } + require(remoteName.matches(Regex("[A-Za-z0-9._-]{1,120}\\.gcode\\.3mf", RegexOption.IGNORE_CASE))) { + "Invalid Bambu print filename" + } + } + + private companion object { + const val FTPS_PORT = 990 + const val USERNAME = "bblp" + } +} + +internal fun interface BambuFtpsSessionFactory { + fun open(config: BambuFtpsConfig): BambuFtpsSession +} + +internal interface BambuFtpsSession : Closeable { + fun connect(host: String, port: Int, connectTimeoutMs: Int, dataTimeoutMs: Int) + fun login(username: String, password: String): Boolean + fun useBinaryTransfers(): Boolean + fun protectDataChannel() + fun usePassiveMode() + fun store(remoteName: String, input: java.io.InputStream): Boolean + fun size(remoteName: String): Long? + fun logout() +} + +private object BcBambuFtpsSessionFactory : BambuFtpsSessionFactory { + override fun open(config: BambuFtpsConfig): BambuFtpsSession = ApacheBambuFtpsSession(config.serial) +} + +private class ApacheBambuFtpsSession(expectedSerial: String) : BambuFtpsSession { + private val client = SessionResumingFtpsClient(sslContext(expectedSerial)).apply { + setEnabledProtocols(arrayOf("TLSv1.2")) + setIpAddressFromPasvResponse(false) + setRemoteVerificationEnabled(false) + setUseEPSVwithIPv4(false) + } + + override fun connect(host: String, port: Int, connectTimeoutMs: Int, dataTimeoutMs: Int) { + client.connectTimeout = connectTimeoutMs + client.defaultTimeout = connectTimeoutMs + client.setDataTimeout(Duration.ofMillis(dataTimeoutMs.toLong())) + client.connect(host, port) + check(client.replyCode in 200..299) { "Bambu FTPS connection was rejected (${client.replyCode})" } + } + + override fun login(username: String, password: String): Boolean = client.login(username, password) + + override fun useBinaryTransfers(): Boolean = client.setFileType(FTP.BINARY_FILE_TYPE) + + override fun protectDataChannel() { + client.execPBSZ(0) + client.execPROT("P") + } + + override fun usePassiveMode() = client.enterLocalPassiveMode() + + override fun store(remoteName: String, input: java.io.InputStream): Boolean = + client.storeFile(remoteName, input) + + override fun size(remoteName: String): Long? = client.getSize(remoteName)?.trim()?.toLongOrNull() + + override fun logout() { + if (client.isConnected && !client.logout()) throw IOException("Bambu FTPS logout was rejected") + } + + override fun close() { + if (client.isConnected) runCatching { client.disconnect() } + } + + private companion object { + fun sslContext(expectedSerial: String): SSLContext { + val cryptoProvider = BouncyCastleProvider() + val jsseProvider = BouncyCastleJsseProvider(cryptoProvider) + return SSLContext.getInstance("TLS", jsseProvider).apply { + init( + null, + arrayOf(SerialPinningTrustManager(expectedSerial)), + SecureRandom(), + ) + } + } + } +} + +private class SessionResumingFtpsClient(context: SSLContext) : FTPSClient(true, context) { + private var controlSession: BCExtendedSSLSession? = null + + override fun _connectAction_() { + super._connectAction_() + controlSession = (_socket_ as? BCSSLSocket)?.bcSession + ?: throw IOException("BCJSSE did not create the Bambu FTPS control socket") + } + + override fun _prepareDataSocket_(socket: Socket) { + val dataSocket = socket as? BCSSLSocket + ?: throw IOException("BCJSSE did not create the Bambu FTPS data socket") + val session = controlSession + ?: throw IOException("Bambu FTPS control session is unavailable for reuse") + dataSocket.setBCSessionToResume(session) + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttConnection.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttConnection.kt new file mode 100644 index 0000000..0b1ca79 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttConnection.kt @@ -0,0 +1,305 @@ +// The Bambu LAN transport itself, deliberately free of any React Native +// imports so it can run — and be tested — on a plain JVM. The bridge lives in +// BambuMqttModule; everything that could actually fail against a real printer +// lives here. +// +// TLS notes, verified against a P1S (firmware cert issued 2026-01): +// +// subject = CN=01P00C611300996 <- the printer's serial number +// issuer = C=CN, O=BBL Technologies Co., Ltd, CN=BBL CA +// +// The chain is self-signed and BBL CA is in no device trust store, so ordinary +// verification cannot work. Other clients (pybambu, Home Assistant, OctoApp) +// respond by trusting everything. We do better: the leaf CN carries the serial, +// so we accept the untrusted chain but require that CN to match the serial the +// user configured. That still needs no CA, yet binds the session to one +// specific machine instead of to whatever answers on port 8883. +// crabcore + +package org.crabcore.u1control.bambu + +import com.hivemq.client.mqtt.MqttClient +import com.hivemq.client.mqtt.datatypes.MqttQos +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient +import java.nio.charset.StandardCharsets +import java.security.cert.CertificateException +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** Failure with a stable code the JS layer can turn into a user-facing message. */ +class BambuConnectException( + val code: String, + message: String, + cause: Throwable? = null +) : Exception(message, cause) + +/** Invalidates callbacks belonging to a replaced or intentionally closed client. */ +internal class BambuConnectionSessionGate { + @Volatile private var current = 0L + + @Synchronized fun begin(): Long = ++current + @Synchronized fun invalidate() { current += 1 } + fun owns(session: Long): Boolean = current == session +} + +class BambuMqttConnection(private val listener: Listener) { + + interface Listener { + /** Raw JSON from `device/{serial}/report`. Parsing belongs to the caller. */ + fun onReport(payload: String) + fun onStateChange(state: String, message: String?) + } + + /** Read from Netty callback threads, so not guarded by the monitor. */ + @Volatile private var client: Mqtt3AsyncClient? = null + @Volatile private var serial: String = "" + + /** + * Gates automatic reconnection. HiveMQ's builder-level auto-reconnect also + * retries the *first* connect, which means a wrong access code or a typo'd + * address never fails — the future just hangs while it retries forever. So + * reconnection is opted into here, only once a session has been established. + */ + @Volatile private var connectedOnce = false + private val sessions = BambuConnectionSessionGate() + + @Synchronized + fun connect( + host: String, + port: Int, + printerSerial: String, + accessCode: String + ): CompletableFuture { + close() + val session = sessions.begin() + serial = printerSerial + + val result = CompletableFuture() + val mqtt = try { + buildClient(host, port, printerSerial, session) + } catch (e: Exception) { + result.completeExceptionally( + BambuConnectException("tls-setup-failed", e.message ?: "Could not configure TLS", e) + ) + return result + } + client = mqtt + + mqtt.connectWith() + .simpleAuth() + .username(LAN_USERNAME) + .password(accessCode.toByteArray(StandardCharsets.UTF_8)) + .applySimpleAuth() + // The printer drops idle sockets aggressively; pybambu and OctoApp both + // settled on 5s, and anything longer produces phantom disconnects. + .keepAlive(KEEP_ALIVE_SECONDS) + .cleanSession(true) + .send() + .whenComplete { _, error -> + if (!owns(session, mqtt)) { + result.completeExceptionally(cancelledConnection()) + return@whenComplete + } + if (error != null) { + // Auto-reconnect would otherwise hammer a bad host forever. + close() + result.completeExceptionally(toConnectException(error)) + } else { + subscribeToReports(mqtt, printerSerial, session, result) + } + } + + return result + } + + @Synchronized + fun publish(payload: String): CompletableFuture { + val mqtt = client + if (mqtt == null || serial.isEmpty()) { + val failed = CompletableFuture() + failed.completeExceptionally( + BambuConnectException("not-connected", "Not connected to a Bambu printer") + ) + return failed + } + + val result = CompletableFuture() + mqtt.publishWith() + .topic("device/$serial/request") + .qos(MqttQos.AT_MOST_ONCE) + .payload(payload.toByteArray(StandardCharsets.UTF_8)) + .send() + .whenComplete { _, error -> + if (error != null) { + result.completeExceptionally( + BambuConnectException("publish-failed", error.message ?: "Could not send command", error) + ) + } else { + result.complete(null) + } + } + return result + } + + @Synchronized + fun close() { + sessions.invalidate() + connectedOnce = false + val existing = client + client = null + serial = "" + existing?.let { + try { + it.disconnect() + } catch (_: Exception) { + // Already gone; nothing useful to report. + } + } + } + + private fun buildClient( + host: String, + port: Int, + printerSerial: String, + session: Long + ): Mqtt3AsyncClient { + lateinit var mqtt: Mqtt3AsyncClient + mqtt = MqttClient.builder() + .useMqttVersion3() + .identifier("helix-${UUID.randomUUID()}") + .serverHost(host) + .serverPort(port) + .sslConfig() + .trustManagerFactory(SerialPinningTrustManagerFactory(printerSerial)) + // The certificate names the serial, never the LAN address we dialled, + // so hostname matching can only ever fail. Identity is enforced by the + // trust manager above instead. + .hostnameVerifier { _, _ -> true } + .applySslConfig() + .addConnectedListener { + if (!owns(session, mqtt)) return@addConnectedListener + val recovered = connectedOnce + connectedOnce = true + listener.onStateChange(STATE_CONNECTED, null) + // cleanSession means the broker forgets our subscription across a drop, + // so a recovered link is live but silent until we ask again. + if (recovered) resubscribe(mqtt, printerSerial, session) + } + .addDisconnectedListener { context -> + // disconnect() from an older client can complete after its replacement + // is already Ready. Without this guard that stale callback marks the + // new session Offline and can even reanimate the old client. + if (!owns(session, mqtt)) return@addDisconnectedListener + listener.onStateChange(STATE_DISCONNECTED, context.cause.message) + if (!connectedOnce) return@addDisconnectedListener + // Exponential up to a ceiling: a printer that is simply switched off + // should not be polled every second for hours. + val delaySeconds = minOf( + MAX_RECONNECT_DELAY_SECONDS, + 1L shl minOf(context.reconnector.attempts, RECONNECT_BACKOFF_CAP) + ) + context.reconnector.reconnect(true).delay(delaySeconds, TimeUnit.SECONDS) + } + .buildAsync() + return mqtt + } + + /** + * Re-arms the report subscription after an automatic reconnect. Failures are + * reported as a state change rather than thrown: the initial connect has long + * since resolved, and there is no caller left to receive an exception. + */ + private fun resubscribe( + mqtt: Mqtt3AsyncClient, + printerSerial: String, + session: Long + ) { + subscribeWithCallback(mqtt, printerSerial).whenComplete { _, error -> + if (owns(session, mqtt) && error != null) { + listener.onStateChange(STATE_DISCONNECTED, "Lost the report feed: ${error.message}") + } + } + } + + private fun subscribeWithCallback(mqtt: Mqtt3AsyncClient, printerSerial: String) = + mqtt.subscribeWith() + .topicFilter("device/$printerSerial/report") + .qos(MqttQos.AT_MOST_ONCE) + .callback { message -> + listener.onReport(String(message.payloadAsBytes, StandardCharsets.UTF_8)) + } + .send() + + private fun subscribeToReports( + mqtt: Mqtt3AsyncClient, + printerSerial: String, + session: Long, + result: CompletableFuture + ) { + subscribeWithCallback(mqtt, printerSerial) + .whenComplete { _, error -> + if (!owns(session, mqtt)) { + result.completeExceptionally(cancelledConnection()) + return@whenComplete + } + if (error != null) { + close() + result.completeExceptionally( + BambuConnectException("subscribe-failed", error.message ?: "Could not subscribe", error) + ) + } else { + result.complete(null) + } + } + } + + private fun owns(session: Long, mqtt: Mqtt3AsyncClient): Boolean = + sessions.owns(session) && client === mqtt + + private fun cancelledConnection() = + BambuConnectException("connect-cancelled", "Bambu connection was replaced") + + private fun toConnectException(error: Throwable): BambuConnectException { + // A serial mismatch surfaces as a TLS failure wrapping our own message, + // several frames down. Recovering the code beats reporting "handshake + // failed" to someone who simply mistyped a digit. + var cursor: Throwable? = error + while (cursor != null) { + if (cursor is CertificateException && cursor.message?.contains(SERIAL_MISMATCH) == true) { + return BambuConnectException("wrong-serial", cursor.message ?: SERIAL_MISMATCH, error) + } + cursor = cursor.cause + } + + // Verified against a P1S: a bad access code comes back as + // "CONNECT failed as CONNACK contained an Error Code: NOT_AUTHORIZED." + if (error.message?.contains(NOT_AUTHORIZED, ignoreCase = true) == true) { + return BambuConnectException( + "wrong-access-code", + "The printer rejected the access code", + error + ) + } + + return BambuConnectException( + "connect-failed", + error.message ?: "Could not reach the printer", + error + ) + } + + companion object { + const val DEFAULT_PORT = 8883 + const val SERIAL_MISMATCH = BAMBU_SERIAL_MISMATCH + private const val NOT_AUTHORIZED = "NOT_AUTHORIZED" + private const val KEEP_ALIVE_SECONDS = 5 + private const val MAX_RECONNECT_DELAY_SECONDS = 30L + /** 2^5s already exceeds the ceiling; keeps the shift from overflowing. */ + private const val RECONNECT_BACKOFF_CAP = 5 + /** Fixed for every Bambu printer in LAN mode; the access code is the secret. */ + private const val LAN_USERNAME = "bblp" + private const val STATE_CONNECTED = "connected" + private const val STATE_DISCONNECTED = "disconnected" + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttModule.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttModule.kt new file mode 100644 index 0000000..79dda8e --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttModule.kt @@ -0,0 +1,317 @@ +// React Native bridge for the Bambu Lab LAN transport. All the protocol and +// TLS work lives in BambuMqttConnection, which has no RN dependencies so it can +// be exercised against a real printer from a plain JVM test. +// +// This module is deliberately dumb: it moves bytes and reports connection +// state. Parsing reports, and mapping them onto the Klipper-shaped status the +// dashboard consumes, happens in JS — see services/bambuMqtt.ts. +// crabcore + +package org.crabcore.u1control.bambu + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import java.io.File +import java.util.concurrent.CompletionException +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +class BambuMqttModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = NAME + + /** One printer at a time, mirroring MoonrakerProvider's single connection. */ + private val connection = BambuMqttConnection(object : BambuMqttConnection.Listener { + override fun onReport(payload: String) { + resolvePrintStart(payload) + emit(EVENT_MESSAGE, Arguments.createMap().apply { putString("payload", payload) }) + } + + override fun onStateChange(state: String, message: String?) { + emit( + EVENT_STATE, + Arguments.createMap().apply { + putString("state", state) + putString("message", message) + } + ) + } + }) + + private val camera = BambuChamberCamera { + emit(EVENT_CAMERA_STOPPED, Arguments.createMap()) + } + private val printUpload = BambuPrintUpload(reactContext) + private val startTimeouts = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "helix-bambu-print-ack").apply { isDaemon = true } + } + private val nextSequence = AtomicLong(System.currentTimeMillis()) + private val pendingStart = AtomicReference(null) + + @ReactMethod + fun connect(config: ReadableMap, promise: Promise) { + val host = config.getString("host")?.trim().orEmpty() + val serial = config.getString("serial")?.trim().orEmpty() + val accessCode = config.getString("accessCode")?.trim().orEmpty() + val port = if (config.hasKey("port")) config.getInt("port") else BambuMqttConnection.DEFAULT_PORT + + if (host.isEmpty() || serial.isEmpty() || accessCode.isEmpty()) { + promise.reject("bad-config", "host, serial and accessCode are all required") + return + } + + connection.connect(host, port, serial, accessCode).whenComplete { _, error -> + if (error != null) reject(promise, error) else promise.resolve(null) + } + } + + @ReactMethod + fun disconnect(promise: Promise) { + connection.close() + promise.resolve(null) + } + + /** Performs one independent, read-only status request for an inactive printer. */ + @ReactMethod + fun probeStatus(config: ReadableMap, promise: Promise) { + val request = runCatching { + BambuStatusProbeConfig( + host = config.requiredString("host"), + serial = config.requiredString("serial"), + accessCode = config.requiredString("accessCode"), + ) + }.getOrElse { error -> + promise.reject("bad-config", error.message ?: "Invalid Bambu status settings", error) + return + } + + BambuStatusProbe().probe(request).whenComplete { payload, error -> + if (error != null) reject(promise, error) else promise.resolve(payload) + } + } + + /** + * `payload` is a JSON string built in JS — the command vocabulary is Bambu's, + * not ours, so there is nothing for Kotlin to validate here. + */ + @ReactMethod + fun publish(payload: String, promise: Promise) { + connection.publish(payload).whenComplete { _, error -> + if (error != null) reject(promise, error) else promise.resolve(null) + } + } + + /** + * Converts one already-sliced, single-filament P1S job into a validated + * `.gcode.3mf` and uploads it to the printer's FTPS root. This does not start + * the print; MQTT acknowledgement handling remains a separate operation. + */ + @ReactMethod + fun uploadPrintArtifact(config: ReadableMap, promise: Promise) { + val request = runCatching { + BambuPrintUpload.Request( + host = config.requiredString("host"), + serial = config.requiredString("serial"), + accessCode = config.requiredString("accessCode"), + gcodeFile = File(config.requiredString("gcodePath")), + remoteName = config.requiredString("remoteName"), + usedToolMask = config.getInt("usedToolMask"), + predictionSeconds = config.getInt("predictionSeconds"), + weightGrams = config.getDouble("weightGrams"), + filamentType = config.requiredString("filamentType"), + filamentColor = config.requiredString("filamentColor"), + ) + }.getOrElse { error -> + promise.reject("bad-print-artifact", error.message ?: "Invalid Bambu upload settings", error) + return + } + + Thread({ + try { + val result = printUpload.upload(request) + promise.resolve(Arguments.createMap().apply { + putString("remoteName", result.remoteName) + putDouble("verifiedBytes", result.verifiedBytes.toDouble()) + putString("archiveMd5", result.archiveMd5) + putString("gcodeMd5", result.gcodeMd5) + putArray("objects", Arguments.createArray().apply { + result.objects.forEach { printableObject -> + pushMap(Arguments.createMap().apply { + putInt("identifyId", printableObject.identifyId) + putString("name", printableObject.name) + }) + } + }) + }) + } catch (e: BambuConnectException) { + promise.reject(e.code, e.message, e) + } catch (e: IllegalArgumentException) { + promise.reject("bad-print-artifact", e.message, e) + } catch (e: Exception) { + promise.reject("upload-failed", e.message ?: "Could not upload the Bambu print artifact", e) + } + }, "helix-bambu-print-upload").start() + } + + /** Publishes the verified `project_file` command and resolves only on its matching printer reply. */ + @ReactMethod + fun startProjectFile(config: ReadableMap, promise: Promise) { + val sequenceId = nextSequence.incrementAndGet().toString() + val payload = runCatching { + BambuPrintProtocol.buildProjectFilePayload( + BambuPrintProtocol.ProjectFileCommand( + sequenceId = sequenceId, + fileName = config.requiredString("remoteName"), + subtaskName = config.requiredString("subtaskName"), + md5 = config.requiredString("archiveMd5"), + toolToLane = config.requiredIntMap("toolToLane"), + bedType = config.requiredString("bedType"), + useAms = config.getBoolean("useAms"), + bedLeveling = config.getBoolean("bedLeveling"), + flowCalibration = config.getBoolean("flowCalibration"), + timelapse = config.getBoolean("timelapse"), + vibrationCalibration = config.getBoolean("vibrationCalibration"), + ) + ) + }.getOrElse { error -> + promise.reject("bad-print-command", error.message ?: "Invalid Bambu print command", error) + return + } + + val pending = PendingPrintStart(sequenceId, promise) + if (!pendingStart.compareAndSet(null, pending)) { + promise.reject("print-start-busy", "Another Bambu print start is awaiting confirmation") + return + } + pending.timeout = startTimeouts.schedule({ + if (pendingStart.compareAndSet(pending, null)) { + promise.reject("print-start-timeout", "The printer did not confirm the print start") + } + }, PRINT_START_TIMEOUT_SECONDS, TimeUnit.SECONDS) + + connection.publish(payload).whenComplete { _, error -> + if (error != null && pendingStart.compareAndSet(pending, null)) { + pending.timeout?.cancel(false) + reject(promise, error) + } + } + } + + /** + * Opens the chamber stream and returns a loopback MJPEG URL that + * components/CameraFeed.tsx can play like any Moonraker webcam. Blocks until + * a frame has actually arrived, so a resolved URL always shows a picture. + */ + @ReactMethod + fun startCamera(config: ReadableMap, promise: Promise) { + val host = config.getString("host")?.trim().orEmpty() + val serial = config.getString("serial")?.trim().orEmpty() + val accessCode = config.getString("accessCode")?.trim().orEmpty() + + if (host.isEmpty() || serial.isEmpty() || accessCode.isEmpty()) { + promise.reject("bad-config", "host, serial and accessCode are all required") + return + } + + // start() blocks on the first frame, so it must not run on the bridge thread. + Thread({ + try { + promise.resolve(camera.start(host, serial, accessCode)) + } catch (e: BambuConnectException) { + promise.reject(e.code, e.message, e) + } catch (e: Exception) { + promise.reject("camera-failed", e.message ?: "Could not open the chamber camera", e) + } + }, "helix-bambu-cam-start").start() + } + + @ReactMethod + fun stopCamera(promise: Promise) { + camera.stop() + promise.resolve(null) + } + + override fun invalidate() { + pendingStart.getAndSet(null)?.let { pending -> + pending.timeout?.cancel(false) + pending.promise.reject("print-start-cancelled", "Bambu connection closed before print confirmation") + } + startTimeouts.shutdownNow() + connection.close() + camera.stop() + super.invalidate() + } + + /** Unwraps CompletableFuture's wrapper so JS sees our own stable codes. */ + private fun reject(promise: Promise, error: Throwable) { + val cause = if (error is CompletionException && error.cause != null) error.cause!! else error + val code = (cause as? BambuConnectException)?.code ?: "unknown" + promise.reject(code, cause.message ?: "Bambu connection failed", cause) + } + + private fun emit(event: String, params: WritableMap) { + if (!reactContext.hasActiveReactInstance()) return + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(event, params) + } + + private fun ReadableMap.requiredString(key: String): String = + getString(key)?.trim()?.takeIf { it.isNotEmpty() } + ?: throw IllegalArgumentException("$key is required") + + private fun ReadableMap.requiredIntMap(key: String): Map { + val map = getMap(key) ?: throw IllegalArgumentException("$key is required") + val out = mutableMapOf() + val keys = map.keySetIterator() + while (keys.hasNextKey()) { + val rawKey = keys.nextKey() + val tool = rawKey.toIntOrNull() ?: throw IllegalArgumentException("$key has an invalid tool") + out[tool] = map.getInt(rawKey) + } + return out + } + + private fun resolvePrintStart(payload: String) { + val pending = pendingStart.get() ?: return + when (BambuPrintProtocol.acknowledgement(payload, pending.sequenceId)) { + BambuPrintProtocol.Acknowledgement.NOT_MATCHING -> return + BambuPrintProtocol.Acknowledgement.SUCCESS -> { + if (pendingStart.compareAndSet(pending, null)) { + pending.timeout?.cancel(false) + pending.promise.resolve(null) + } + } + BambuPrintProtocol.Acknowledgement.REJECTED -> { + if (pendingStart.compareAndSet(pending, null)) { + pending.timeout?.cancel(false) + pending.promise.reject("print-start-rejected", "The printer rejected the print start") + } + } + } + } + + private data class PendingPrintStart( + val sequenceId: String, + val promise: Promise, + var timeout: ScheduledFuture<*>? = null, + ) + + companion object { + const val NAME = "HelixBambuMqtt" + private const val EVENT_MESSAGE = "HelixBambuMessage" + private const val EVENT_STATE = "HelixBambuState" + private const val EVENT_CAMERA_STOPPED = "HelixBambuCameraStopped" + private const val PRINT_START_TIMEOUT_SECONDS = 20L + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttPackage.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttPackage.kt new file mode 100644 index 0000000..71428a1 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuMqttPackage.kt @@ -0,0 +1,15 @@ +package org.crabcore.u1control.bambu + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class BambuMqttPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(BambuMqttModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List> = emptyList() +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintArtifact.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintArtifact.kt new file mode 100644 index 0000000..3c77292 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintArtifact.kt @@ -0,0 +1,353 @@ +package org.crabcore.u1control.bambu + +import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Builds the sliced project archive consumed by Bambu's `project_file` command. + * + * The archive layout follows BambuStudio output and the independently + * hardware-tested MIT-licensed bambox packer. This builder deliberately does + * not translate generic G-code: it accepts only output that already contains + * Bambu's header blocks and a machine marker matching the bundled project + * settings. Wrapping U1 or another model's G-code in a Bambu-looking ZIP would + * send unsafe commands to hardware. + */ +object BambuPrintArtifact { + private const val GCODE_ENTRY = "Metadata/plate_1.gcode" + private const val MIN_PROJECT_SETTING_KEYS = 500 + private const val COPY_BUFFER_SIZE = 64 * 1024 + + private enum class Target( + val modelName: String, + val settingsToken: String, + val machineMarker: String, + val modelId: String, + ) { + P1S("Bambu Lab P1S", "P1S", "machine: P1S-0.4", "C12"), + // Trailing separators prevent the A1 marker from accepting "A1 mini". + A1("Bambu Lab A1", "A1", "machine: A1 ===", "N2S"), + } + + data class Metadata( + val predictionSeconds: Int, + val weightGrams: Double, + val filamentType: String, + val filamentColor: String, + val nozzleDiameter: Double = 0.4, + ) + + data class Result( + val file: File, + val archiveMd5: String, + val gcodeMd5: String, + val objects: List, + ) + + data class PrintableObject( + val identifyId: Int, + val name: String, + ) + + fun build( + gcodeFile: File, + outputFile: File, + projectSettingsJson: String, + thumbnailPng: ByteArray, + metadata: Metadata, + ): Result { + require(gcodeFile.isFile && gcodeFile.length() > 0L) { "Sliced G-code is missing or empty" } + require(outputFile.name.endsWith(".gcode.3mf", ignoreCase = true)) { + "Bambu print artifacts must end in .gcode.3mf" + } + require(!outputFile.exists()) { "Refusing to overwrite an existing Bambu print artifact" } + requireValidThumbnail(thumbnailPng) + val (projectSettings, target) = validatedProjectSettings(projectSettingsJson) + requireValidGcode(gcodeFile, target) + val cleanMetadata = validateMetadata(metadata) + val printableObjects = extractPrintableObjects(gcodeFile) + + outputFile.parentFile?.mkdirs() + val partial = File(outputFile.parentFile, ".${outputFile.name}.partial") + check(!partial.exists() || partial.delete()) { "Could not clear stale Bambu artifact staging file" } + val gcodeMd5 = md5(gcodeFile) + + try { + ZipOutputStream(FileOutputStream(partial).buffered()).use { zip -> + zip.writeText("[Content_Types].xml", CONTENT_TYPES_XML) + zip.writeText("_rels/.rels", RELATIONSHIPS_XML) + zip.writeText("3D/3dmodel.model", MODEL_XML) + zip.writeText("Metadata/project_settings.config", projectSettings.toString(2) + "\n") + zip.writeText("Metadata/plate_1.json", plateJson(cleanMetadata).toString()) + zip.writeText("Metadata/plate_1.gcode.md5", gcodeMd5) + zip.putNextEntry(ZipEntry(GCODE_ENTRY)) + BufferedInputStream(FileInputStream(gcodeFile)).use { input -> input.copyTo(zip, COPY_BUFFER_SIZE) } + zip.closeEntry() + zip.writeText("Metadata/_rels/model_settings.config.rels", MODEL_SETTINGS_RELS_XML) + zip.writeText("Metadata/model_settings.config", MODEL_SETTINGS_XML) + zip.writeText( + "Metadata/slice_info.config", + sliceInfoXml(cleanMetadata, target, printableObjects), + ) + THUMBNAIL_ENTRIES.forEach { entry -> zip.writeBytes(entry, thumbnailPng) } + } + check(partial.renameTo(outputFile)) { "Could not publish Bambu print artifact" } + return Result(outputFile, md5(outputFile), gcodeMd5, printableObjects) + } catch (error: Throwable) { + partial.delete() + throw error + } + } + + private fun requireValidGcode(file: File, target: Target) { + var headerStart = false + var headerEnd = false + var executableStart = false + var targetMachine = false + file.bufferedReader().useLines { lines -> + lines.take(2_000).forEach { line -> + headerStart = headerStart || line.trim() == "; HEADER_BLOCK_START" + headerEnd = headerEnd || line.trim() == "; HEADER_BLOCK_END" + executableStart = executableStart || line.trim() == "; EXECUTABLE_BLOCK_START" + targetMachine = targetMachine || line.contains(target.machineMarker, ignoreCase = true) + } + } + require(headerStart && headerEnd && executableStart) { + "G-code is not Bambu firmware-ready (required header blocks are missing)" + } + require(targetMachine) { "G-code was not sliced with the verified ${target.modelName} profile" } + } + + private fun validatedProjectSettings(raw: String): Pair { + val settings = JSONObject(raw) + require(settings.length() >= MIN_PROJECT_SETTING_KEYS) { + "Bambu project settings are incomplete (${settings.length()} keys)" + } + val target = Target.entries.singleOrNull { + settings.optString("printer_model") == it.modelName + } ?: throw IllegalArgumentException("Bambu project settings target an unsupported printer model") + require(settings.optString("printer_settings_id").contains(target.settingsToken, ignoreCase = true)) { + "Bambu project settings do not identify a ${target.modelName} printer profile" + } + require(settings.optString("machine_start_gcode").contains(target.machineMarker, ignoreCase = true)) { + "Bambu project settings do not contain the verified ${target.modelName} startup sequence" + } + return settings to target + } + + private fun validateMetadata(metadata: Metadata): Metadata { + require(metadata.predictionSeconds >= 0) { "Prediction cannot be negative" } + require(metadata.weightGrams.isFinite() && metadata.weightGrams >= 0.0) { + "Filament weight must be finite and non-negative" + } + require(metadata.nozzleDiameter in 0.2..1.0) { "Unsupported nozzle diameter" } + require(metadata.filamentType.matches(Regex("[A-Za-z0-9+_.-]{1,32}"))) { + "Invalid filament type" + } + val color = metadata.filamentColor.removePrefix("#").uppercase() + require(color.matches(Regex("[0-9A-F]{6}"))) { "Filament color must be #RRGGBB" } + return metadata.copy(filamentColor = "#$color") + } + + private fun requireValidThumbnail(png: ByteArray) { + require(png.size >= 24 && png.copyOfRange(0, 8).contentEquals(PNG_MAGIC)) { + "Bambu project thumbnail must be a PNG" + } + val width = png.readBigEndianInt(16) + val height = png.readBigEndianInt(20) + require(width > 1 && height > 1) { "Bambu project thumbnail must be larger than 1x1" } + } + + private fun plateJson(metadata: Metadata) = JSONObject() + .put("bed_type", "textured_plate") + .put("filament_colors", listOf(metadata.filamentColor)) + .put("filament_ids", listOf(0)) + .put("first_extruder", 0) + .put("is_seq_print", false) + .put("nozzle_diameter", metadata.nozzleDiameter) + .put("version", 2) + + private fun sliceInfoXml( + metadata: Metadata, + target: Target, + printableObjects: List, + ): String { + val type = xmlEscape(metadata.filamentType) + val color = xmlEscape(metadata.filamentColor) + val objects = printableObjects.joinToString( + separator = "\n", + postfix = if (printableObjects.isEmpty()) "" else "\n", + ) { + " " + } + return """ + +
+ + +
+ + + + + + + + + + + + + + +$objects + +
+""" + } + + /** + * Orca writes a readable object line immediately before Bambu's unique label + * ID. Pair those lines while streaming so even very large G-code files stay + * bounded in memory. Repeated layer markers are deduplicated by identify ID. + */ + internal fun extractPrintableObjects(gcodeFile: File): List { + data class ParsedObject(val identifyId: Int, val baseName: String, val copyIndex: Int) + + val parsed = linkedMapOf() + var pendingName: Pair? = null + gcodeFile.bufferedReader().useLines { lines -> + lines.forEach { line -> + PRINTING_OBJECT.matchEntire(line.trim())?.let { match -> + val name = match.groupValues[1].trim().take(MAX_OBJECT_NAME_LENGTH) + val copy = match.groupValues[2].toIntOrNull() + pendingName = if (name.isNotEmpty() && copy != null && copy >= 0) name to copy else null + return@forEach + } + + val id = UNIQUE_LABEL_ID.matchEntire(line.trim())?.groupValues?.get(1)?.toIntOrNull() + val pending = pendingName + if (id != null && id >= 0 && pending != null && parsed.size < MAX_PRINTABLE_OBJECTS) { + parsed.putIfAbsent(id, ParsedObject(id, pending.first, pending.second)) + pendingName = null + } + } + } + + val duplicateNames = parsed.values.groupingBy { it.baseName }.eachCount() + return parsed.values.map { item -> + val name = if ((duplicateNames[item.baseName] ?: 0) > 1) { + "${item.baseName} (copy ${item.copyIndex + 1})" + } else { + item.baseName + } + PrintableObject(item.identifyId, name) + } + } + + private fun ZipOutputStream.writeText(name: String, text: String) = + writeBytes(name, text.toByteArray(Charsets.UTF_8)) + + private fun ZipOutputStream.writeBytes(name: String, bytes: ByteArray) { + putNextEntry(ZipEntry(name)) + write(bytes) + closeEntry() + } + + private fun md5(file: File): String { + val digest = MessageDigest.getInstance("MD5") + BufferedInputStream(FileInputStream(file)).use { input -> + val buffer = ByteArray(COPY_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02X".format(it) } + } + + private fun ByteArray.readBigEndianInt(offset: Int): Int = + ((this[offset].toInt() and 0xff) shl 24) or + ((this[offset + 1].toInt() and 0xff) shl 16) or + ((this[offset + 2].toInt() and 0xff) shl 8) or + (this[offset + 3].toInt() and 0xff) + + private fun xmlEscape(value: String): String = value + .replace("&", "&") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">") + + private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + private const val MAX_PRINTABLE_OBJECTS = 64 + private const val MAX_OBJECT_NAME_LENGTH = 200 + private val PRINTING_OBJECT = Regex("; printing object (.+) id:\\d+ copy (\\d+)") + private val UNIQUE_LABEL_ID = Regex("; start printing object, unique label id: (\\d+)") + + private val THUMBNAIL_ENTRIES = listOf( + "Metadata/plate_1.png", + "Metadata/plate_1_small.png", + "Metadata/plate_no_light_1.png", + "Metadata/top_1.png", + "Metadata/pick_1.png", + ) + + private const val CONTENT_TYPES_XML = """ + + + + + +""" + + private const val RELATIONSHIPS_XML = """ + + + + + +""" + + private const val MODEL_XML = """ + + BambuStudio-02.07.01.62 + 1 + + + + + +""" + + private const val MODEL_SETTINGS_RELS_XML = """ + + +""" + + private const val MODEL_SETTINGS_XML = """ + + + + + + + + + + + + + + + + +""" +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintProtocol.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintProtocol.kt new file mode 100644 index 0000000..6a86458 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintProtocol.kt @@ -0,0 +1,115 @@ +package org.crabcore.u1control.bambu + +import org.json.JSONArray +import org.json.JSONObject + +/** Pure construction and acknowledgement parsing for Bambu `project_file`. */ +object BambuPrintProtocol { + + const val TOOL_COUNT = 4 + const val EXTERNAL_SPOOL = -1 + private const val UNUSED_AMS_ID = 255 + private const val UNUSED_SLOT_ID = 255 + private val MD5_PATTERN = Regex("^[0-9A-F]{32}$") + + data class ProjectFileCommand( + val sequenceId: String, + val fileName: String, + val subtaskName: String, + val md5: String, + /** Zero-based file tool -> zero-based global AMS lane; -1 is external spool. */ + val toolToLane: Map, + val bedType: String, + val useAms: Boolean, + val bedLeveling: Boolean, + val flowCalibration: Boolean, + val timelapse: Boolean, + val layerInspection: Boolean = true, + val vibrationCalibration: Boolean = false, + val autoBedLeveling: Int = 1, + val extrudeCalibrationFlag: Int = 2, + val extrudeCalibrationManualMode: Int = 0, + val nozzleOffsetCalibration: Int = 2, + ) + + enum class Acknowledgement { + NOT_MATCHING, + SUCCESS, + REJECTED, + } + + fun buildProjectFilePayload(command: ProjectFileCommand): String { + require(command.sequenceId.isNotBlank()) { "sequenceId is required" } + require(command.fileName.isSafeRootFileName()) { "fileName must be a root filename" } + require(command.fileName.endsWith(".gcode.3mf", ignoreCase = true)) { + "Bambu print artifacts must end in .gcode.3mf" + } + require(command.subtaskName.isNotBlank()) { "subtaskName is required" } + require(command.md5.uppercase().matches(MD5_PATTERN)) { "md5 must contain 32 hex digits" } + require(command.bedType.isNotBlank()) { "bedType is required" } + + val mapping = normalizedMapping(command.toolToLane, command.useAms) + val mapping2 = mapping.map(::detailedMapping) + + val print = JSONObject().apply { + put("ams_mapping", JSONArray(mapping)) + put("ams_mapping2", JSONArray(mapping2)) + put("auto_bed_leveling", command.autoBedLeveling) + put("bed_leveling", command.bedLeveling) + put("bed_type", command.bedType) + put("cfg", "0") + put("command", "project_file") + put("extrude_cali_flag", command.extrudeCalibrationFlag) + put("extrude_cali_manual_mode", command.extrudeCalibrationManualMode) + put("file", command.fileName) + put("flow_cali", command.flowCalibration) + put("layer_inspect", command.layerInspection) + put("md5", command.md5.uppercase()) + put("nozzle_offset_cali", command.nozzleOffsetCalibration) + put("param", "Metadata/plate_1.gcode") + put("profile_id", "0") + put("project_id", "0") + put("sequence_id", command.sequenceId) + put("subtask_id", "0") + put("subtask_name", command.subtaskName) + put("task_id", "0") + put("timelapse", command.timelapse) + put("use_ams", command.useAms) + put("vibration_cali", command.vibrationCalibration) + put("url", "ftp://${command.fileName}") + } + return JSONObject().put("print", print).toString() + } + + /** Only a matching `project_file` response can resolve a start request. */ + fun acknowledgement(payload: String, expectedSequenceId: String): Acknowledgement { + val print = runCatching { JSONObject(payload).optJSONObject("print") }.getOrNull() + ?: return Acknowledgement.NOT_MATCHING + if (print.optString("command") != "project_file") return Acknowledgement.NOT_MATCHING + if (print.optString("sequence_id") != expectedSequenceId) return Acknowledgement.NOT_MATCHING + return if (print.optString("result").equals("success", ignoreCase = true)) { + Acknowledgement.SUCCESS + } else { + Acknowledgement.REJECTED + } + } + + private fun normalizedMapping(toolToLane: Map, useAms: Boolean): List { + require(toolToLane.keys.all { it in 0 until TOOL_COUNT }) { "tool index must be 0..3" } + require(toolToLane.values.all { it == EXTERNAL_SPOOL || it >= 0 }) { + "lane must be zero-based or -1 for the external spool" + } + return List(TOOL_COUNT) { tool -> + if (useAms) toolToLane[tool] ?: EXTERNAL_SPOOL else EXTERNAL_SPOOL + } + } + + private fun detailedMapping(lane: Int): JSONObject = if (lane == EXTERNAL_SPOOL) { + JSONObject().put("ams_id", UNUSED_AMS_ID).put("slot_id", UNUSED_SLOT_ID) + } else { + JSONObject().put("ams_id", lane / 4).put("slot_id", lane % 4) + } + + private fun String.isSafeRootFileName(): Boolean = + isNotBlank() && this != "." && this != ".." && !contains('/') && !contains('\\') +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintUpload.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintUpload.kt new file mode 100644 index 0000000..ca1b933 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuPrintUpload.kt @@ -0,0 +1,144 @@ +package org.crabcore.u1control.bambu + +import android.content.Context +import java.io.File +import java.util.Base64 +import java.util.UUID + +/** Builds, uploads, and then removes one validated model-specific Bambu artifact. */ +class BambuPrintUpload internal constructor( + private val cacheDirectory: File, + private val loadProjectSettings: (String) -> String, + private val uploadArtifact: (BambuFtpsConfig, File, String) -> BambuFtpsUploadResult, +) { + constructor(context: Context) : this( + cacheDirectory = context.cacheDir, + loadProjectSettings = { serial -> + context.assets.open(projectSettingsAsset(serial)).bufferedReader().use { it.readText() } + }, + uploadArtifact = { config, artifact, remoteName -> + BambuFtpsClient().upload(config, artifact, remoteName) + }, + ) + + internal constructor( + cacheDirectory: File, + projectSettingsJson: String, + uploader: (BambuFtpsConfig, File, String) -> BambuFtpsUploadResult, + ) : this(cacheDirectory, { projectSettingsJson }, uploader) + + data class Request( + val host: String, + val serial: String, + val accessCode: String, + val gcodeFile: File, + val remoteName: String, + val usedToolMask: Int, + val predictionSeconds: Int, + val weightGrams: Double, + val filamentType: String, + val filamentColor: String, + ) + + data class Result( + val remoteName: String, + val verifiedBytes: Long, + val archiveMd5: String, + val gcodeMd5: String, + val objects: List, + ) + + fun upload(request: Request): Result { + require(request.usedToolMask == SINGLE_TOOL_ZERO_MASK) { + "Bambu LAN printing currently supports one logical filament (tool 0) only" + } + require(request.remoteName.matches(SAFE_ARTIFACT_NAME)) { + "Invalid Bambu print filename" + } + val projectSettings = loadProjectSettings(request.serial) + + val thumbnail = extractLargestThumbnail(request.gcodeFile) + ?: throw IllegalArgumentException("Sliced G-code has no valid embedded PNG preview") + cacheDirectory.mkdirs() + val artifact = File(cacheDirectory, "helix-bambu-${UUID.randomUUID()}.gcode.3mf") + + try { + val built = BambuPrintArtifact.build( + gcodeFile = request.gcodeFile, + outputFile = artifact, + projectSettingsJson = projectSettings, + thumbnailPng = thumbnail, + metadata = BambuPrintArtifact.Metadata( + predictionSeconds = request.predictionSeconds, + weightGrams = request.weightGrams, + filamentType = request.filamentType, + filamentColor = request.filamentColor, + ), + ) + val uploaded = uploadArtifact( + BambuFtpsConfig(request.host, request.serial, request.accessCode), + built.file, + request.remoteName, + ) + return Result( + uploaded.remoteName, + uploaded.verifiedBytes, + built.archiveMd5, + built.gcodeMd5, + built.objects, + ) + } finally { + artifact.delete() + } + } + + private fun extractLargestThumbnail(gcodeFile: File): ByteArray? { + if (!gcodeFile.isFile) return null + var collecting = false + var expectedLength = -1 + var largest: ByteArray? = null + val encoded = StringBuilder() + + gcodeFile.bufferedReader().useLines { lines -> + lines.take(MAX_HEADER_LINES).forEach { line -> + val match = THUMBNAIL_START.matchEntire(line.trim()) + if (match != null) { + collecting = true + expectedLength = match.groupValues[3].toIntOrNull() ?: -1 + encoded.clear() + } else if (collecting && line.trim() == "; thumbnail end") { + val text = encoded.toString() + val candidate = if (text.length == expectedLength) { + runCatching { Base64.getDecoder().decode(text) }.getOrNull() + } else { + null + } + if (candidate != null && isPng(candidate) && candidate.size > (largest?.size ?: -1)) { + largest = candidate + } + collecting = false + } else if (collecting) { + val trimmed = line.trim() + if (trimmed.startsWith(";")) encoded.append(trimmed.removePrefix(";").trim()) + } + } + } + return largest + } + + private fun isPng(bytes: ByteArray): Boolean = + bytes.size >= PNG_MAGIC.size && bytes.copyOfRange(0, PNG_MAGIC.size).contentEquals(PNG_MAGIC) + + private companion object { + fun projectSettingsAsset(serial: String): String = when { + serial.trim().uppercase().startsWith("01P") -> "orca_profiles/printer/bambu_p1s.json" + serial.trim().uppercase().startsWith("039") -> "orca_profiles/printer/bambu_a1.json" + else -> throw IllegalArgumentException("Bambu LAN printing supports the P1S and full-size A1 only") + } + const val SINGLE_TOOL_ZERO_MASK = 1 + const val MAX_HEADER_LINES = 8_000 + val SAFE_ARTIFACT_NAME = Regex("[A-Za-z0-9._-]{1,120}\\.gcode\\.3mf", RegexOption.IGNORE_CASE) + val THUMBNAIL_START = Regex("; thumbnail begin (\\d+)x(\\d+) (\\d+)") + val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuStatusProbe.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuStatusProbe.kt new file mode 100644 index 0000000..db38b04 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuStatusProbe.kt @@ -0,0 +1,106 @@ +package org.crabcore.u1control.bambu + +import org.json.JSONObject +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** One bounded, read-only MQTT status check for an inactive Bambu printer. */ +class BambuStatusProbe private constructor( + private val connectionFactory: BambuProbeConnectionFactory, + private val timeoutMs: Long, +) { + constructor() : this(BambuProbeConnectionFactory(::LiveBambuProbeConnection), DEFAULT_TIMEOUT_MS) + + internal constructor( + timeoutMs: Long, + connectionFactory: (BambuMqttConnection.Listener) -> BambuProbeConnection, + ) : this(BambuProbeConnectionFactory(connectionFactory), timeoutMs) + + fun probe(config: BambuStatusProbeConfig): CompletableFuture { + require(config.host.isNotBlank()) { "Bambu printer address is required" } + require(config.serial.isNotBlank()) { "Bambu printer serial number is required" } + require(config.accessCode.isNotBlank()) { "Bambu printer access code is required" } + + val result = CompletableFuture() + val timer = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "helix-bambu-status-timeout").apply { isDaemon = true } + } + lateinit var connection: BambuProbeConnection + val listener = object : BambuMqttConnection.Listener { + override fun onReport(payload: String) { + if (isFullStatus(payload)) result.complete(payload) + } + + override fun onStateChange(state: String, message: String?) { + if (state == "disconnected" && !result.isDone) { + result.completeExceptionally( + BambuConnectException("probe-disconnected", message ?: "Bambu status connection closed") + ) + } + } + } + connection = connectionFactory.create(listener) + val timeout = timer.schedule({ + result.completeExceptionally( + BambuConnectException("probe-timeout", "The Bambu printer did not return its status") + ) + }, timeoutMs, TimeUnit.MILLISECONDS) + + result.whenComplete { _, _ -> + timeout.cancel(false) + connection.close() + timer.shutdownNow() + } + connection.connect(config).whenComplete { _, connectError -> + if (connectError != null) { + result.completeExceptionally(connectError) + return@whenComplete + } + connection.publish(PUSH_ALL).whenComplete { _, publishError -> + if (publishError != null) result.completeExceptionally(publishError) + } + } + return result + } + + private fun isFullStatus(payload: String): Boolean = runCatching { + JSONObject(payload).optJSONObject("print")?.has("gcode_state") == true + }.getOrDefault(false) + + private companion object { + const val DEFAULT_TIMEOUT_MS = 4_000L + const val PUSH_ALL = "{\"pushing\":{\"sequence_id\":\"0\",\"command\":\"pushall\"}}" + } +} + +internal fun interface BambuProbeConnectionFactory { + fun create(listener: BambuMqttConnection.Listener): BambuProbeConnection +} + +internal interface BambuProbeConnection { + fun connect(config: BambuStatusProbeConfig): CompletableFuture + fun publish(payload: String): CompletableFuture + fun close() +} + +private class LiveBambuProbeConnection(listener: BambuMqttConnection.Listener) : BambuProbeConnection { + private val connection = BambuMqttConnection(listener) + + override fun connect(config: BambuStatusProbeConfig): CompletableFuture = connection.connect( + config.host, + BambuMqttConnection.DEFAULT_PORT, + config.serial, + config.accessCode, + ) + + override fun publish(payload: String): CompletableFuture = connection.publish(payload) + + override fun close() = connection.close() +} + +data class BambuStatusProbeConfig( + val host: String, + val serial: String, + val accessCode: String, +) diff --git a/android/app/src/main/java/org/crabcore/u1control/bambu/BambuTrust.kt b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuTrust.kt new file mode 100644 index 0000000..5bb0416 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bambu/BambuTrust.kt @@ -0,0 +1,80 @@ +// Shared TLS trust for Bambu's LAN services. Both MQTT (8883) and the chamber +// camera (6000) present the same device certificate: +// +// subject = CN= +// issuer = C=CN, O=BBL Technologies Co., Ltd, CN=BBL CA +// +// Self-signed against a CA no device trusts, so ordinary verification cannot +// work. Other Bambu clients answer that by trusting everything on the socket. +// We accept the untrusted chain but require the leaf CN to be the serial the +// user configured, which needs no CA yet still binds the session to one +// specific machine rather than to whatever is answering on that port. +// crabcore + +package org.crabcore.u1control.bambu + +import java.security.KeyStore +import java.security.Provider +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.ManagerFactoryParameters +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.TrustManagerFactorySpi +import javax.net.ssl.X509TrustManager + +/** Marker text shared with the JS layer's error classification. */ +const val BAMBU_SERIAL_MISMATCH = "serial number does not match" + +class SerialPinningTrustManagerFactory(expectedSerial: String) : TrustManagerFactory( + object : TrustManagerFactorySpi() { + override fun engineInit(keyStore: KeyStore?) = Unit + override fun engineInit(parameters: ManagerFactoryParameters?) = Unit + override fun engineGetTrustManagers(): Array = + arrayOf(SerialPinningTrustManager(expectedSerial)) + }, + BambuSecurityProvider, + "HelixBambuSerialPinning" +) + +class SerialPinningTrustManager(private val expectedSerial: String) : X509TrustManager { + + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + + override fun checkServerTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() + ?: throw CertificateException("The printer presented no certificate") + + // RFC 2253 form, e.g. "CN=01P00C611300996". Android has no javax.naming, + // so this is parsed directly rather than with LdapName. + val commonName = CN_PATTERN.find(leaf.subjectX500Principal.name)?.groupValues?.get(1)?.trim() + + if (commonName.isNullOrEmpty() || !commonName.equals(expectedSerial, ignoreCase = true)) { + throw CertificateException( + "This printer's $BAMBU_SERIAL_MISMATCH " + + "(expected $expectedSerial, got ${commonName ?: "nothing"})" + ) + } + } + + override fun getAcceptedIssuers(): Array = emptyArray() + + private companion object { + val CN_PATTERN = Regex("CN=([^,]+)") + } +} + +/** Socket factory for the plain-TLS services; MQTT takes the factory instead. */ +fun bambuSocketFactory(expectedSerial: String): SSLSocketFactory = + SSLContext.getInstance("TLS").apply { + init(null, arrayOf(SerialPinningTrustManager(expectedSerial)), null) + }.socketFactory + +@Suppress("DEPRECATION") // The (String, String, String) constructor needs API 30; Helix targets 24. +private object BambuSecurityProvider : Provider( + "HelixBambu", + 1.0, + "Serial-pinned trust for Bambu Lab printers on the LAN" +) diff --git a/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dBootstrapPackages.kt b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dBootstrapPackages.kt new file mode 100644 index 0000000..13c6121 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dBootstrapPackages.kt @@ -0,0 +1,284 @@ +package org.crabcore.u1control.bespok3d + +import android.content.Context +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSignatureList +import org.bouncycastle.openpgp.PGPUtil +import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.security.MessageDigest +import java.util.Locale +import java.util.zip.ZipInputStream + +data class Bespok3dBootstrapFile( + val path: String, + val mode: Int, + val bytes: ByteArray, +) + +data class Bespok3dBootstrapPackage( + val name: String, + val version: String, + val files: List, +) + +data class Bespok3dBootstrapSet( + val daemon: Bespok3dBootstrapPackage, + val jinni: Bespok3dBootstrapPackage, +) + +/** + * Opens only the release artifacts pinned into this Helix build. Both OpenPGP + * signatures and every signed payload hash are checked before bytes are exposed. + */ +object Bespok3dBootstrapPackages { + const val ASSET_PATH = "bespok3d/bootstrap-v0.7.3.zip" + + fun load(context: Context): Bespok3dBootstrapSet = + context.assets.open(ASSET_PATH).use(::load) + + fun load(input: InputStream): Bespok3dBootstrapSet { + val bundled = readZip(input, MAX_BUNDLE_BYTES, MAX_PACKAGE_BYTES) + require(bundled.keys == EXPECTED_BUNDLE_MEMBERS.keys) { + "Bespok3d bootstrap bundle contains unexpected files" + } + EXPECTED_BUNDLE_MEMBERS.forEach { (name, sha256) -> + require(sha256(bundled.getValue(name)) == sha256) { + "Bespok3d bootstrap artifact $name failed its release checksum" + } + } + + val indexBytes = bundled.getValue(INDEX_NAME) + verifySignature(indexBytes, bundled.getValue(INDEX_SIGNATURE_NAME)) + val releases = parseIndex(indexBytes) + val daemon = verifyPackage( + bundled.getValue(DAEMON_ARCHIVE), + releases.getValue(DAEMON_NAME), + ) + val jinni = verifyPackage( + bundled.getValue(JINNI_ARCHIVE), + releases.getValue(JINNI_NAME), + ) + return Bespok3dBootstrapSet(daemon, jinni) + } + + /** + * Applies the same pinned-publisher signature, identity, and payload-hash checks to a + * store package before the daemon is allowed to see it. + */ + fun verifyOfficialInstallPackage(bytes: ByteArray, name: String, version: String) { + require(bytes.size <= MAX_STORE_PACKAGE_BYTES) { "Bespok3d package exceeds its size limit" } + require(PLUGIN_NAME.matches(name)) { "Bespok3d package name is invalid" } + require(VERSION.matches(version)) { "Bespok3d package version is invalid" } + verifyPackage(bytes, ListedRelease(name, version, ""), MAX_STORE_UNPACKED_BYTES) + } + + /** Verifies exact catalog bytes against Helix's pinned Bespok3d publisher key. */ + fun verifyOfficialSignature(content: ByteArray, armoredSignature: ByteArray) { + verifySignature(content, armoredSignature) + } + + internal fun validateRelativePath(path: String): String { + require(path.isNotEmpty() && path.length <= 512) { "Bespok3d package path is invalid" } + require(!path.startsWith('/') && !path.contains('\\')) { "Bespok3d package path is unsafe" } + require(path.split('/').none { it.isEmpty() || it == "." || it == ".." }) { + "Bespok3d package path is unsafe" + } + return path + } + + private data class ListedRelease(val name: String, val version: String, val archive: String) + + private fun parseIndex(bytes: ByteArray): Map { + val index = JSONObject(bytes.toString(Charsets.UTF_8)) + require(index.optInt("schema_version") == 1) { "Unsupported Bespok3d bootstrap index" } + require(index.optString("publisher").uppercase(Locale.US) == OFFICIAL_FINGERPRINT) { + "Bespok3d bootstrap index has the wrong publisher" + } + val plugins = index.getJSONArray("plugins") + val releases = buildMap { + for (position in 0 until plugins.length()) { + val plugin = plugins.getJSONObject(position) + val name = plugin.getString("name") + put(name, ListedRelease(name, plugin.getString("version"), plugin.getString("download_url"))) + } + } + require(releases.keys == setOf(DAEMON_NAME, JINNI_NAME)) { + "Bespok3d bootstrap index contains unexpected packages" + } + require(releases.getValue(DAEMON_NAME).archive == DAEMON_ARCHIVE) + require(releases.getValue(JINNI_NAME).archive == JINNI_ARCHIVE) + return releases + } + + private fun verifyPackage( + bytes: ByteArray, + listed: ListedRelease, + maxUnpackedBytes: Int = MAX_PACKAGE_UNPACKED_BYTES, + ): Bespok3dBootstrapPackage { + val archive = readZip(ByteArrayInputStream(bytes), maxUnpackedBytes, MAX_STORE_ENTRY_BYTES) + val manifestBytes = archive[MANIFEST_NAME] + ?: throw IllegalArgumentException("Bespok3d package has no manifest") + val signatureBytes = archive[MANIFEST_SIGNATURE_NAME] + ?: throw IllegalArgumentException("Bespok3d package has no signature") + verifySignature(manifestBytes, signatureBytes) + + val manifest = JSONObject(manifestBytes.toString(Charsets.UTF_8)) + require(manifest.getString("name") == listed.name) { "Bespok3d package identity mismatch" } + require(manifest.getString("version") == listed.version) { "Bespok3d package version mismatch" } + require(manifest.getString("publisher").uppercase(Locale.US) == OFFICIAL_FINGERPRINT) { + "Bespok3d package has the wrong publisher" + } + + val declared = manifest.getJSONArray("files") + require(declared.length() in 1..MAX_ENTRIES) { "Bespok3d package declares no usable payload" } + val payload = ArrayList(declared.length()) + val declaredArchivePaths = mutableSetOf() + for (position in 0 until declared.length()) { + val file = declared.getJSONObject(position) + val archivePath = validateRelativePath(file.getString("path")) + require(declaredArchivePaths.add(archivePath)) { "Bespok3d manifest repeats a file path" } + val fileBytes = archive[archivePath] + ?: throw IllegalArgumentException("Bespok3d package is missing $archivePath") + require(sha256(fileBytes) == file.getString("sha256").lowercase(Locale.US)) { + "Bespok3d file hash failed for $archivePath" + } + val modeText = file.optString("mode", "644") + require(modeText.matches(Regex("^[0-7]{3,4}$"))) { "Bespok3d file mode is invalid" } + if (archivePath.startsWith(PAYLOAD_PREFIX)) { + payload += Bespok3dBootstrapFile( + path = validateRelativePath(archivePath.removePrefix(PAYLOAD_PREFIX)), + mode = modeText.toInt(8), + bytes = fileBytes, + ) + } + } + require(payload.isNotEmpty()) { "Bespok3d package declares no installable payload" } + val actualSignedPaths = archive.keys + .filterTo(mutableSetOf()) { it != MANIFEST_NAME && it != MANIFEST_SIGNATURE_NAME } + require(actualSignedPaths == declaredArchivePaths) { + "Bespok3d package contains an undeclared file" + } + return Bespok3dBootstrapPackage(listed.name, listed.version, payload) + } + + private fun verifySignature(content: ByteArray, armoredSignature: ByteArray) { + val signatures = PGPUtil.getDecoderStream(ByteArrayInputStream(armoredSignature)).use { decoded -> + JcaPGPObjectFactory(decoded).nextObject() as? PGPSignatureList + } ?: throw IllegalArgumentException("Bespok3d signature is malformed") + require(signatures.size() == 1) { "Bespok3d signature count is invalid" } + val signature = signatures[0] + val keyRings = PGPUtil.getDecoderStream( + ByteArrayInputStream(OFFICIAL_PUBLIC_KEY.toByteArray(Charsets.US_ASCII)), + ).use { decoded -> PGPPublicKeyRingCollection(decoded, JcaKeyFingerprintCalculator()) } + val signingKey = keyRings.getPublicKey(signature.keyID) + ?: throw IllegalArgumentException("Bespok3d signature was not made by the official key") + val primaryFingerprint = keyRings.keyRings.asSequence() + .firstOrNull { it.getPublicKey(signature.keyID) != null } + ?.publicKey + ?.fingerprint + ?.joinToString("") { "%02X".format(it) } + require(primaryFingerprint == OFFICIAL_FINGERPRINT) { + "Bespok3d signature was not made by the pinned publisher" + } + signature.init(JcaPGPContentVerifierBuilderProvider().setProvider(BouncyCastleProvider()), signingKey) + signature.update(content) + require(signature.verify()) { "Bespok3d signature verification failed" } + } + + private fun readZip(input: InputStream, maxTotalBytes: Int, maxEntryBytes: Int): Map { + val files = linkedMapOf() + var totalBytes = 0 + ZipInputStream(input.buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + require(!entry.isDirectory) { "Bespok3d archive contains a directory entry" } + val name = validateRelativePath(entry.name) + require(files.size < MAX_ENTRIES && !files.containsKey(name)) { + "Bespok3d archive contains too many or duplicate entries" + } + val output = ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (true) { + val count = zip.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + totalBytes += count + require(output.size() <= maxEntryBytes && totalBytes <= maxTotalBytes) { + "Bespok3d archive exceeds its size limit" + } + } + files[name] = output.toByteArray() + zip.closeEntry() + } + } + return files + } + + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + + private const val INDEX_NAME = "index.json" + private const val INDEX_SIGNATURE_NAME = "index.json.sig" + private const val MANIFEST_NAME = "manifest.json" + private const val MANIFEST_SIGNATURE_NAME = "manifest.json.sig" + private const val PAYLOAD_PREFIX = "files/" + private const val DAEMON_NAME = "bespok3d-daemon" + private const val JINNI_NAME = "bespok3d-jinni-snapmaker-u1" + private const val DAEMON_ARCHIVE = "bespok3d-daemon-0.12.24.b3" + private const val JINNI_ARCHIVE = "bespok3d-jinni-snapmaker-u1-0.1.10.b3" + private const val OFFICIAL_FINGERPRINT = "679939555819FB5F6423DC68C4388E76BFA9B4E0" + private const val MAX_ENTRIES = 512 + private const val MAX_BUNDLE_BYTES = 12 * 1024 * 1024 + private const val MAX_PACKAGE_BYTES = 10 * 1024 * 1024 + private const val MAX_PACKAGE_UNPACKED_BYTES = 16 * 1024 * 1024 + private const val MAX_STORE_PACKAGE_BYTES = 64 * 1024 * 1024 + private const val MAX_STORE_UNPACKED_BYTES = 128 * 1024 * 1024 + private const val MAX_STORE_ENTRY_BYTES = 64 * 1024 * 1024 + private val PLUGIN_NAME = Regex("^[a-z0-9][a-z0-9-]{0,63}$") + private val VERSION = Regex("^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") + + private val EXPECTED_BUNDLE_MEMBERS = mapOf( + DAEMON_ARCHIVE to "f2721e686efffa352aacb6174a2a280afae446817f0c0c0643c3ac62d46c5980", + JINNI_ARCHIVE to "7b1d6d4eda4e5035853c21db3d1d4cdd4930bc5af7719c8883ecc3f643e4a765", + INDEX_NAME to "69282901be04796e9e8c2c39073d0948cd3ddd2affdde1237da46587672ffa8f", + INDEX_SIGNATURE_NAME to "c89c1ff9d4a3efb36d74578389795229385047d3f81fa201cc427c1dfd1ac530", + ) + + private const val OFFICIAL_PUBLIC_KEY = """-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGoe+wUBEADJjkI85zRmpx2XmaU2e7eb1OGR0Khw0z5dByvQ0odMovBhInK4 +mmWR1d+DL2yLt8QNh421LGuBd1iWXSx6jTKPi8PcxBSxfhfJydJWIji58HFN/sTd +dyk+I20Ln9k0B0A8BpLnSzVUTEKYrqYiRSAJcPVkrA1myp3X4kUt/DyqERHE/HF+ +bmwMsW0pgpdvs1umUOV7EdpADWorfWcWFOGKFJSGbd8K3hjFR9IPt6sPeKsUGU5U +01hdFp89a/DAX/Q2LGQP/v+WNUpNQtj6CMPRPc2sjNcyH16m9EsIugkWoimxsoSk +gKAoINq+gQtp/qckQiXoApXnB1ewQfWmz0C+zAoSL/qXd/QEpStZhgvlDX4eOeUl +LdOLleRnwqorNgz4Qr96C1uETJF2ew8iZm5v4nPOidP9eG0OOrYsiHjmiOubD3A9 +V6GLGiaVuRNJ1dIew615bOmOhQY/8Sa32QoUeDYVDEL4pZxyk+fuxObvBGfvRFdG +wuuVvEXX0L+Ne7KSHSVUXQGGobjfrektB8OSOFpAM9iGAhtH/lCXq8OjogjzoetE +47JflKHZLmAaspl16WrsRk+GPxGwAf8ckAs7GxgaxbTECkeauG2Iqcmme1k+3kmK +NQBrQq5NMz4A+OMN0g/4BO/S8RkLtxC1cjDCZ72MNgzh4lt+to91Vr8R6wARAQAB +tFFCZXNwb2szZCBSZWdpc3RyeSBTaWduaW5nIEtleSAob2ZmaWNpYWwgbGlzdCBz +aWduaW5nIGtleSkgPHJlZ2lzdHJ5QGJlc3BvazNkLm9yZz6JAm0EEwEIAFcWIQRn +mTlVWBn7X2Qj3GjEOI52v6m04AUCah77BRsUgAAAAAAEAA5tYW51MiwyLjUrMS4x +MiwwLDMCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQxDiOdr+ptOAq +7BAAlCoYtauXk8As3ajW2IJLUOYHxtal+h4UUaXiiNKwgtZBbnIZByfDZ68veDoP +SQ3PfKLKgypuJqGNRKCORiP/zw2Co7AqwHgsG9G5B48SsDIQlRX1nad5Acc5XyHN +GKqDu0mxQd9GVU96zhOknZoF4f2yrrHhrv1OYrbzHsp9ktyddfyO4izurs0zPh6B +6ln1AgbOwc+yMG3NjqpmjEgXn/5B+WCXU/9wwOC8TmOGdZHtdVgzExZEbEgRkqe+ +Wzq8Or8at+CLn2BCyYyKJcRQVDNYubjpE0BsYw4t/n01PwDKlgk4Kc4JPmjAXgqh +7ZJDegBIb14+rhwptKBpr/bGHJxJQBqAPmeqIPjNYNSkXlVbToS8RRsy5/7wWm7E +UKQChOBY4CZ9+d6H7IEIkj6Cay0NRDNRGBJ8H1ePsA9P8xCU567F0iEXwKKmWPiL +lB1lLI5KScW7kfx9iHQ8NKGxhmiDbB7J/Zd+et5WZIKONit+xifU4YVOpELbhRYA +6G7i1pFOQhXLZG832pKMqHCPCpBqT5imrJ2NKYqCHyZ2aVi3gK6mpYWnzSh5Xcpv +HGkr0kOBhL1zF6g4Cn/wU26QI4mQ2eEOqBRhUTFBbBZ7fQTbFgA4AV9Gwi8L6tNB +At5hzMkILtyaJ1gDVIBv/Qmet5QtOB22Sq54rRL4W+igroM= +=DgD7 +-----END PGP PUBLIC KEY BLOCK-----""" +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dClient.kt b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dClient.kt new file mode 100644 index 0000000..485bff3 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dClient.kt @@ -0,0 +1,774 @@ +package org.crabcore.u1control.bespok3d + +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Base64 +import java.util.Locale +import java.util.UUID +import java.util.zip.ZipInputStream +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +data class Bespok3dProbe( + val version: String, + val license: String, + val source: String, + val certificatePem: String, + val certificateSha256: String, +) + +data class Bespok3dAccessRequest( + val identity: String, + val token: String, + val certificatePem: String, + val certificateSha256: String, +) + +data class Bespok3dStatus( + val version: String, + val printerUuid: String, +) + +data class Bespok3dPluginConfigField( + val key: String, + val label: String, + val type: String, + val defaultValue: String?, + val required: Boolean, + val options: List, + val hint: String, + val onValue: String, + val offValue: String, +) + +data class Bespok3dPlugin( + val id: String, + val title: String, + val version: String, + val tagline: String, + val category: String, + val repository: String, + val dependencies: List, + val config: List, +) + +data class Bespok3dPluginCatalog( + val plugins: List, + val installed: Map, +) + +data class Bespok3dPluginInstallResult( + val ok: Boolean, + val installedIds: List, + val failures: Map, +) + +data class Bespok3dHelixScreenState( + val installed: Boolean, + val selected: String?, +) + +data class Bespok3dBundledPluginIdentity( + val id: String, + val version: String, +) + +class Bespok3dHttpException(val statusCode: Int, message: String) : Exception(message) + +/** Pure validation and JSON parsing for the public Bespok3d daemon contract. */ +object Bespok3dProtocol { + private val identityPattern = Regex("^[A-Za-z0-9:._-]{1,128}$") + private val tokenPattern = Regex("^[A-Za-z0-9]{16,128}$") + + fun validateAccessToken(token: String) { + require(tokenPattern.matches(token)) { "Invalid Bespok3d access token" } + } + + fun accessRequestBody( + label: String, + identity: String, + token: String, + publicKey: String = "", + ): String { + require(identityPattern.matches(identity)) { "Invalid Bespok3d client identity" } + validateAccessToken(token) + require(label.length <= 64 && label.all { !it.isISOControl() }) { + "Invalid Bespok3d client label" + } + require(publicKey.length <= 8192) { "Bespok3d public key is too large" } + return JSONObject() + .put("label", label) + .put("identity", identity) + .put("token", token) + .put("public_key", publicKey) + .toString() + } + + fun certificateSha256(encoded: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(encoded) + .joinToString(":") { "%02X".format(it) } + + fun parseProbe( + body: String, + certificatePem: String, + certificateSha256: String, + ): Bespok3dProbe { + val json = JSONObject(body) + val license = json.optString("license") + val source = json.optString("source") + val version = json.optString("version") + require(license == "AGPL-3.0-or-later") { "The service is not a Bespok3d daemon" } + require(source == "https://github.com/Bespok3d/daemon") { + "The service is not the official Bespok3d daemon" + } + require(version.isNotBlank()) { "Bespok3d returned no version" } + require(certificatePem.contains("BEGIN CERTIFICATE")) { "Bespok3d returned no certificate" } + require(Regex("^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$").matches(certificateSha256)) { + "Bespok3d returned no certificate fingerprint" + } + return Bespok3dProbe(version, license, source, certificatePem, certificateSha256) + } + + fun parseAccessResponse(body: String): String { + val json = JSONObject(body) + require(json.optBoolean("ok")) { "Bespok3d rejected the access request" } + return json.optString("cert").takeIf { it.contains("BEGIN CERTIFICATE") } + ?: throw IllegalArgumentException("Bespok3d returned no certificate") + } + + fun parseStatus(body: String): Bespok3dStatus { + val json = JSONObject(body) + require(json.optBoolean("ok")) { "Bespok3d status was not healthy" } + val version = json.optString("version").takeIf(String::isNotBlank) + ?: throw IllegalArgumentException("Bespok3d returned no version") + val printerUuid = json.optString("printer_uuid").takeIf(String::isNotBlank) + ?: throw IllegalArgumentException("Bespok3d returned no printer identity") + return Bespok3dStatus(version, printerUuid) + } + + fun parsePluginCatalog(indexBody: String, capabilitiesBody: String): Bespok3dPluginCatalog { + val index = JSONObject(indexBody) + require(index.optInt("schema_version") == 1) { "Unsupported Bespok3d plugin catalog" } + require(index.optString("publisher").uppercase(Locale.US) == OFFICIAL_PUBLISHER) { + "Bespok3d plugin catalog has the wrong publisher" + } + val installedJson = JSONObject(capabilitiesBody).optJSONObject("installed") ?: JSONObject() + val installed = installedJson.keys().asSequence().associateWith { installedJson.getString(it) } + val pluginsJson = index.optJSONArray("plugins") ?: JSONArray() + val plugins = buildList { + for (position in 0 until pluginsJson.length()) { + val plugin = pluginsJson.getJSONObject(position) + val id = plugin.requiredPluginId("name") + if (id.startsWith("bespok3d-daemon") || id.startsWith("bespok3d-jinni-")) continue + val version = plugin.requiredVersion("version") + val repository = repositoryFromDownloadUrl(plugin.getString("download_url")) + val dependencies = plugin.optJSONArray("deps").strings().onEach(::validatePluginId) + val configJson = plugin.optJSONArray("config") ?: JSONArray() + val config = buildList { + for (fieldIndex in 0 until configJson.length()) { + val field = configJson.getJSONObject(fieldIndex) + val key = field.getString("key") + require(CONFIG_KEY.matches(key)) { "Bespok3d plugin config key is invalid" } + add( + Bespok3dPluginConfigField( + key = key, + label = field.optString("label", key).take(120), + type = field.optString("type", "text").take(32), + defaultValue = if (field.has("default") && !field.isNull("default")) { + field.get("default").toString() + } else { + null + }, + required = field.optBoolean("required", false), + options = field.optJSONArray("options").strings().take(64), + hint = field.optString("hint").take(500), + onValue = field.optString("onValue", "true").take(MAX_CONFIG_FIELD_LENGTH), + offValue = field.optString("offValue", "false").take(MAX_CONFIG_FIELD_LENGTH), + ), + ) + } + } + add( + Bespok3dPlugin( + id = id, + title = plugin.optString("title", id).take(120), + version = version, + tagline = plugin.optString("tagline").take(500), + category = plugin.optString("category", "other").take(64), + repository = repository, + dependencies = dependencies, + config = config, + ), + ) + } + } + require(plugins.map { it.id }.distinct().size == plugins.size) { + "Bespok3d plugin catalog repeats a package" + } + return Bespok3dPluginCatalog(plugins, installed) + } + + fun dependencyOrder(catalog: Bespok3dPluginCatalog, selectedIds: List): List { + require(selectedIds.isNotEmpty()) { "Select at least one Bespok3d plugin" } + val byId = catalog.plugins.associateBy { it.id } + val visiting = mutableSetOf() + val visited = mutableSetOf() + val ordered = mutableListOf() + fun visit(id: String, explicitlySelected: Boolean) { + validatePluginId(id) + if (id in visited || (!explicitlySelected && catalog.installed.containsKey(id))) return + require(visiting.add(id)) { "Bespok3d plugin dependency cycle includes $id" } + val plugin = byId[id] ?: throw IllegalArgumentException("Bespok3d catalog has no plugin $id") + plugin.dependencies.forEach { dependencyId -> visit(dependencyId, false) } + visiting.remove(id) + visited.add(id) + ordered += plugin + } + selectedIds.distinct().forEach { pluginId -> visit(pluginId, true) } + return ordered + } + + fun parseReleaseAssetUrl(body: String, plugin: Bespok3dPlugin): String { + val expectedName = "${plugin.id}-${plugin.version}.b3" + val releases = JSONArray(body) + for (releaseIndex in 0 until releases.length()) { + val assets = releases.getJSONObject(releaseIndex).optJSONArray("assets") ?: continue + for (assetIndex in 0 until assets.length()) { + val asset = assets.getJSONObject(assetIndex) + if (asset.optString("name") != expectedName) continue + val url = asset.optString("browser_download_url") + require(url.startsWith("https://github.com/${plugin.repository}/releases/download/") && + url.endsWith("/$expectedName")) { "Bespok3d release returned an unsafe package URL" } + return url + } + } + throw IllegalArgumentException("Bespok3d release package is unavailable for ${plugin.id} ${plugin.version}") + } + + fun parseInstallResult(body: String): Bespok3dPluginInstallResult { + val result = JSONObject(body) + val rows = result.optJSONArray("results") ?: JSONArray() + val installed = mutableListOf() + val failures = linkedMapOf() + for (position in 0 until rows.length()) { + val row = rows.getJSONObject(position) + val rawId = row.getString("plugin_id") + val id = if (rawId == SERVICES_RESULT_ID) rawId else rawId.also(::validatePluginId) + if (row.optBoolean("ok")) { + if (id != SERVICES_RESULT_ID) installed += id + } else { + failures[id] = row.optString("reason", "Installation failed").take(500) + } + } + return Bespok3dPluginInstallResult(result.optBoolean("ok") && failures.isEmpty(), installed, failures) + } + + /** + * Verifies every byte before the locally built HelixScreen package can bypass + * the official Bespok3d catalog. The APK signature then protects these pinned bytes. + */ + fun verifyBundledHelixScreenPackage(bytes: ByteArray): Bespok3dBundledPluginIdentity { + require(bytes.size <= MAX_BUNDLED_PACKAGE_BYTES) { "Bundled HelixScreen package is too large" } + val sha256 = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + require(sha256 == BUNDLED_HELIXSCREEN_SHA256) { + "Bundled HelixScreen package failed integrity verification" + } + val manifest = ZipInputStream(ByteArrayInputStream(bytes)).use { archive -> + var parsed: JSONObject? = null + while (true) { + val entry = archive.nextEntry ?: break + if (entry.name != "manifest.json") continue + require(parsed == null) { "Bundled HelixScreen package repeats its manifest" } + val output = ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + while (true) { + val count = archive.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + require(output.size() <= MAX_BUNDLED_MANIFEST_BYTES) { + "Bundled HelixScreen manifest is too large" + } + } + parsed = JSONObject(output.toString(StandardCharsets.UTF_8.name())) + } + parsed ?: throw IllegalArgumentException("Bundled HelixScreen package has no manifest") + } + require(manifest.optString("name") == BUNDLED_HELIXSCREEN_ID) { + "Bundled HelixScreen package has the wrong id" + } + require(manifest.optString("version") == BUNDLED_HELIXSCREEN_VERSION) { + "Bundled HelixScreen package has the wrong version" + } + return Bespok3dBundledPluginIdentity(BUNDLED_HELIXSCREEN_ID, BUNDLED_HELIXSCREEN_VERSION) + } + + fun parseHelixScreenConfig(body: String): Bespok3dHelixScreenState { + val selected = JSONObject(body).optJSONObject("vars")?.optString(HELIX_SCREEN_CONFIG_KEY) + require(selected in HELIX_SCREEN_CHOICES) { "HelixScreen returned an invalid touchscreen selection" } + return Bespok3dHelixScreenState(installed = true, selected = selected) + } + + fun helixScreenReconfigureBody(selected: String): String { + require(selected in HELIX_SCREEN_CHOICES) { "Invalid touchscreen selection" } + return JSONObject().put(HELIX_SCREEN_CONFIG_KEY, selected).toString() + } + + fun parseHelixScreenReconfigure(body: String, selected: String): Bespok3dHelixScreenState { + require(selected in HELIX_SCREEN_CHOICES) { "Invalid touchscreen selection" } + val result = JSONObject(body) + require(result.optBoolean("ok")) { "Bespok3d did not switch the touchscreen" } + require(result.optString("plugin_id") == HELIX_SCREEN_PLUGIN_ID) { + "Bespok3d reconfigured the wrong plugin" + } + val log = result.optJSONArray("log") ?: JSONArray() + for (position in 0 until log.length()) { + require(log.getJSONObject(position).optBoolean("ok")) { + "Bespok3d could not restart the touchscreen services" + } + } + return Bespok3dHelixScreenState(installed = true, selected = selected) + } + + private fun JSONObject.requiredPluginId(key: String): String = + getString(key).also(::validatePluginId) + + private fun JSONObject.requiredVersion(key: String): String = + getString(key).also { require(VERSION.matches(it)) { "Bespok3d plugin version is invalid" } } + + private fun repositoryFromDownloadUrl(value: String): String { + val match = DOWNLOAD_API.matchEntire(value) + ?: throw IllegalArgumentException("Bespok3d plugin has an unsupported download source") + return "${match.groupValues[1]}/${match.groupValues[2]}" + } + + private fun validatePluginId(id: String) { + require(PLUGIN_ID.matches(id)) { "Bespok3d plugin id is invalid" } + } + + private fun JSONArray?.strings(): List { + if (this == null) return emptyList() + return buildList { for (position in 0 until length()) add(getString(position)) } + } + + private const val OFFICIAL_PUBLISHER = "679939555819FB5F6423DC68C4388E76BFA9B4E0" + private const val SERVICES_RESULT_ID = "(services)" + const val BUNDLED_HELIXSCREEN_ASSET = "bespok3d/helixscreen-ui-0.1.0.b3" + private const val BUNDLED_HELIXSCREEN_ID = "helixscreen-ui" + private const val BUNDLED_HELIXSCREEN_VERSION = "0.1.0" + private const val BUNDLED_HELIXSCREEN_SHA256 = + "ad888897f08f4129dc80b92f1f1d10f07f29c854169a85e5996e00d1f1f4bbb4" + private const val MAX_BUNDLED_PACKAGE_BYTES = 64 * 1024 * 1024 + private const val MAX_BUNDLED_MANIFEST_BYTES = 256 * 1024 + private const val HELIX_SCREEN_PLUGIN_ID = BUNDLED_HELIXSCREEN_ID + private const val HELIX_SCREEN_CONFIG_KEY = "SCREEN_UI" + private val HELIX_SCREEN_CHOICES = setOf("snapmaker", "helixscreen") + private const val MAX_CONFIG_FIELD_LENGTH = 2_048 + private val PLUGIN_ID = Regex("^[a-z0-9][a-z0-9-]{0,63}$") + private val CONFIG_KEY = Regex("^[A-Z][A-Z0-9_]{0,63}$") + private val VERSION = Regex("^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") + private val DOWNLOAD_API = Regex("^https://api\\.github\\.com/repos/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/releases/assets/[0-9]+$") +} + +/** HTTPS client for the certificate-pinned daemon and signature-pinned official plugin catalog. */ +class Bespok3dClient { + fun probe(host: String): Bespok3dProbe { + val response = request(host, "/license", "GET", null, null, null) + return Bespok3dProtocol.parseProbe( + response.body, + pem(response.leaf), + Bespok3dProtocol.certificateSha256(response.leaf.encoded), + ) + } + + fun requestAccess(host: String, label: String, publicKey: String = ""): Bespok3dAccessRequest { + val identity = "helix-${UUID.randomUUID()}" + val token = ByteArray(32).also(SecureRandom()::nextBytes).joinToString("") { "%02x".format(it) } + val body = Bespok3dProtocol.accessRequestBody(label.trim(), identity, token, publicKey) + val response = request(host, "/access/request", "POST", body, null, null) + val returnedPem = Bespok3dProtocol.parseAccessResponse(response.body) + val returnedCertificate = certificate(returnedPem) + if (!returnedCertificate.encoded.contentEquals(response.leaf.encoded)) { + throw CertificateException("Bespok3d pairing certificate did not match the connected printer") + } + return Bespok3dAccessRequest( + identity, + token, + returnedPem, + Bespok3dProtocol.certificateSha256(returnedCertificate.encoded), + ) + } + + fun status(host: String, token: String, certificatePem: String): Bespok3dStatus { + Bespok3dProtocol.validateAccessToken(token) + val response = request(host, "/status", "GET", null, token, certificatePem) + return Bespok3dProtocol.parseStatus(response.body) + } + + fun helixScreenState( + host: String, + token: String, + certificatePem: String, + ): Bespok3dHelixScreenState { + Bespok3dProtocol.validateAccessToken(token) + return try { + val response = request( + host, + "/plugins/helixscreen-ui/config", + "GET", + null, + token, + certificatePem, + ) + Bespok3dProtocol.parseHelixScreenConfig(response.body) + } catch (error: Bespok3dHttpException) { + if (error.statusCode != HttpURLConnection.HTTP_NOT_FOUND) throw error + Bespok3dHelixScreenState(installed = false, selected = null) + } + } + + fun configureHelixScreen( + host: String, + token: String, + certificatePem: String, + selected: String, + ): Bespok3dHelixScreenState { + Bespok3dProtocol.validateAccessToken(token) + val body = Bespok3dProtocol.helixScreenReconfigureBody(selected) + val response = requestBytes( + host = host, + path = "/plugins/helixscreen-ui/reconfigure", + method = "POST", + body = body.toByteArray(StandardCharsets.UTF_8), + contentType = "application/json", + token = token, + certificatePem = certificatePem, + timeoutMs = RECONFIGURE_TIMEOUT_MS, + ) + Bespok3dProtocol.parseHelixScreenReconfigure(response.body, selected) + return helixScreenState(host, token, certificatePem).also { state -> + require(state.installed && state.selected == selected) { + "Bespok3d did not persist the touchscreen selection" + } + } + } + + fun plugins(host: String, token: String, certificatePem: String): Bespok3dPluginCatalog { + Bespok3dProtocol.validateAccessToken(token) + val indexBytes = download(CATALOG_URL, MAX_CATALOG_BYTES, "application/json") + val signatureBytes = download(CATALOG_SIGNATURE_URL, MAX_SIGNATURE_BYTES, "application/pgp-signature") + Bespok3dBootstrapPackages.verifyOfficialSignature(indexBytes, signatureBytes) + val capabilities = request(host, "/capabilities", "GET", null, token, certificatePem).body + return Bespok3dProtocol.parsePluginCatalog(indexBytes.toString(Charsets.UTF_8), capabilities) + } + + fun installPlugins( + host: String, + token: String, + certificatePem: String, + selectedIds: List, + requestedVars: Map>, + ): Bespok3dPluginInstallResult { + val catalog = plugins(host, token, certificatePem) + val ordered = Bespok3dProtocol.dependencyOrder(catalog, selectedIds) + if (ordered.isEmpty()) return Bespok3dPluginInstallResult(true, emptyList(), emptyMap()) + require(requestedVars.keys.all { requestedId -> selectedIds.contains(requestedId) }) { + "Bespok3d config was supplied for an unselected plugin" + } + val packages = ordered.map { plugin -> + val releasesUrl = "https://api.github.com/repos/${plugin.repository}/releases?per_page=100" + val releases = download(releasesUrl, MAX_RELEASES_BYTES, "application/vnd.github+json") + val assetUrl = Bespok3dProtocol.parseReleaseAssetUrl(releases.toString(Charsets.UTF_8), plugin) + val bytes = download(assetUrl, MAX_PACKAGE_BYTES, "application/octet-stream") + Bespok3dBootstrapPackages.verifyOfficialInstallPackage(bytes, plugin.id, plugin.version) + DownloadedPlugin(plugin, bytes, resolvedVars(plugin, requestedVars[plugin.id].orEmpty())) + } + val boundary = "----HelixB3${UUID.randomUUID().toString().replace("-", "")}" + val body = buildBatchMultipart(boundary, packages) + val response = requestBytes( + host = host, + path = "/packages/install-batch", + method = "POST", + body = body, + contentType = "multipart/form-data; boundary=$boundary", + token = token, + certificatePem = certificatePem, + timeoutMs = INSTALL_TIMEOUT_MS, + ) + return Bespok3dProtocol.parseInstallResult(response.body) + } + + /** Uploads only the exact HelixScreen package shipped in this APK. */ + fun installBundledHelixScreen( + host: String, + token: String, + certificatePem: String, + bytes: ByteArray, + ): Bespok3dPluginInstallResult { + Bespok3dProtocol.validateAccessToken(token) + val identity = Bespok3dProtocol.verifyBundledHelixScreenPackage(bytes) + if (helixScreenState(host, token, certificatePem).installed) { + return Bespok3dPluginInstallResult(true, listOf(identity.id), emptyMap()) + } + val plugin = Bespok3dPlugin( + id = identity.id, + title = "HelixScreen Touchscreen", + version = identity.version, + tagline = "", + category = "screen", + repository = "", + dependencies = emptyList(), + config = emptyList(), + ) + val boundary = "----HelixB3${UUID.randomUUID().toString().replace("-", "")}" + val body = buildBatchMultipart( + boundary, + listOf(DownloadedPlugin(plugin, bytes, mapOf("SCREEN_UI" to "snapmaker"))), + ) + val response = requestBytes( + host = host, + path = "/packages/install-batch", + method = "POST", + body = body, + contentType = "multipart/form-data; boundary=$boundary", + token = token, + certificatePem = certificatePem, + timeoutMs = INSTALL_TIMEOUT_MS, + ) + val result = Bespok3dProtocol.parseInstallResult(response.body) + require(result.installedIds.all { it == identity.id }) { + "Bespok3d reported the wrong installed plugin" + } + if (result.ok) { + require(result.installedIds == listOf(identity.id)) { + "Bespok3d did not confirm the HelixScreen installation" + } + helixScreenState(host, token, certificatePem).also { state -> + require(state.installed && state.selected == "snapmaker") { + "HelixScreen was not installed with the stock touchscreen selected" + } + } + } + return result + } + + private data class DownloadedPlugin( + val plugin: Bespok3dPlugin, + val bytes: ByteArray, + val vars: Map, + ) + + private fun resolvedVars( + plugin: Bespok3dPlugin, + requested: Map, + ): Map { + val declared = plugin.config.associateBy { it.key } + require(requested.keys.all(declared::containsKey)) { + "Bespok3d config contains an unknown field for ${plugin.id}" + } + require(requested.values.all { it.length <= MAX_CONFIG_VALUE_LENGTH && it.all { char -> !char.isISOControl() } }) { + "Bespok3d config value is invalid" + } + return buildMap { + plugin.config.forEach { field -> + val value = requested[field.key] ?: field.defaultValue + require(!field.required || !value.isNullOrBlank()) { + "${field.label} is required for ${plugin.title}" + } + if (value != null) put(field.key, value) + } + } + } + + private fun buildBatchMultipart(boundary: String, packages: List): ByteArray { + val newline = "\r\n" + val output = ByteArrayOutputStream() + packages.forEach { item -> + output.write( + ("--$boundary$newline" + + "Content-Disposition: form-data; name=\"files\"; filename=\"${item.plugin.id}.b3\"$newline" + + "Content-Type: application/octet-stream$newline$newline").toByteArray(StandardCharsets.US_ASCII), + ) + output.write(item.bytes) + output.write(newline.toByteArray(StandardCharsets.US_ASCII)) + } + val vars = JSONObject() + packages.filter { it.vars.isNotEmpty() }.forEach { item -> vars.put(item.plugin.id, JSONObject(item.vars)) } + output.write( + ("--$boundary$newline" + + "Content-Disposition: form-data; name=\"vars_json\"$newline$newline" + + vars.toString() + newline + + "--$boundary--$newline").toByteArray(StandardCharsets.UTF_8), + ) + require(output.size() <= MAX_BATCH_BYTES) { "Selected Bespok3d plugins exceed the batch size limit" } + return output.toByteArray() + } + + private data class Response(val body: String, val leaf: X509Certificate) + + private fun request( + host: String, + path: String, + method: String, + body: String?, + token: String?, + certificatePem: String?, + ): Response = requestBytes( + host = host, + path = path, + method = method, + body = body?.toByteArray(StandardCharsets.UTF_8), + contentType = if (body == null) null else "application/json", + token = token, + certificatePem = certificatePem, + timeoutMs = TIMEOUT_MS, + ) + + private fun requestBytes( + host: String, + path: String, + method: String, + body: ByteArray?, + contentType: String?, + token: String?, + certificatePem: String?, + timeoutMs: Int, + ): Response { + val cleanHost = validatedHost(host) + val urlHost = if (cleanHost.contains(':') && !cleanHost.startsWith('[')) "[$cleanHost]" else cleanHost + val connection = URL("https://$urlHost:$PORT$path").openConnection() as HttpsURLConnection + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = timeoutMs + connection.requestMethod = method + connection.hostnameVerifier = HostnameVerifier { _, _ -> true } + connection.sslSocketFactory = socketFactory(certificatePem) + connection.setRequestProperty("Accept", "application/json") + if (token != null) connection.setRequestProperty("Authorization", "Bearer $token") + if (body != null) { + connection.doOutput = true + connection.setRequestProperty("Content-Type", contentType ?: "application/octet-stream") + connection.setFixedLengthStreamingMode(body.size) + connection.outputStream.use { it.write(body) } + } + + try { + val status = connection.responseCode + val leaf = connection.serverCertificates.firstOrNull() as? X509Certificate + ?: throw CertificateException("Bespok3d presented no certificate") + val stream = if (status >= HttpURLConnection.HTTP_BAD_REQUEST) { + connection.errorStream + } else { + connection.inputStream + } + val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + if (status >= HttpURLConnection.HTTP_BAD_REQUEST) { + throw Bespok3dHttpException(status, "Bespok3d returned HTTP $status") + } + return Response(text, leaf) + } finally { + connection.disconnect() + } + } + + private fun download(url: String, maxBytes: Int, accept: String): ByteArray { + require(url.startsWith("https://")) { "Bespok3d downloads require HTTPS" } + val connection = URL(url).openConnection() as HttpsURLConnection + connection.instanceFollowRedirects = true + connection.connectTimeout = DOWNLOAD_TIMEOUT_MS + connection.readTimeout = DOWNLOAD_TIMEOUT_MS + connection.setRequestProperty("Accept", accept) + connection.setRequestProperty("User-Agent", "Helix-Android/3.0") + try { + val status = connection.responseCode + if (status >= HttpURLConnection.HTTP_BAD_REQUEST) { + throw Bespok3dHttpException(status, "Bespok3d download returned HTTP $status") + } + val length = connection.contentLengthLong + require(length < 0 || length <= maxBytes) { "Bespok3d download exceeds its size limit" } + val output = ByteArrayOutputStream(if (length in 1..Int.MAX_VALUE) length.toInt() else 16 * 1024) + connection.inputStream.use { input -> + val buffer = ByteArray(16 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + require(output.size() <= maxBytes) { "Bespok3d download exceeds its size limit" } + } + } + return output.toByteArray() + } finally { + connection.disconnect() + } + } + + private fun validatedHost(raw: String): String { + val host = raw.trim().removePrefix("[").removeSuffix("]") + require(host.isNotEmpty() && host.length <= 253) { "Bespok3d host is required" } + require(!host.contains('/') && !host.contains("//") && host.none(Char::isWhitespace)) { + "Bespok3d host must not include a URL or path" + } + return host + } + + private fun socketFactory(pinnedPem: String?) = SSLContext.getInstance("TLS").apply { + val manager = if (pinnedPem == null) TrustAnyCertificate else ExactCertificateTrust(certificate(pinnedPem)) + init(null, arrayOf(manager), null) + }.socketFactory + + private fun certificate(pem: String): X509Certificate = + CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(pem.toByteArray(StandardCharsets.US_ASCII))) as X509Certificate + + private fun pem(certificate: X509Certificate): String { + val base64 = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(certificate.encoded) + return "-----BEGIN CERTIFICATE-----\n$base64\n-----END CERTIFICATE-----\n" + } + + private object TrustAnyCertificate : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + override fun getAcceptedIssuers(): Array = emptyArray() + } + + private class ExactCertificateTrust(private val pinned: X509Certificate) : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() ?: throw CertificateException("Bespok3d presented no certificate") + if (!leaf.encoded.contentEquals(pinned.encoded)) { + throw CertificateException("Bespok3d certificate does not match the paired printer") + } + } + override fun getAcceptedIssuers(): Array = arrayOf(pinned) + } + + private companion object { + const val PORT = 4269 + const val TIMEOUT_MS = 8_000 + const val DOWNLOAD_TIMEOUT_MS = 30_000 + const val INSTALL_TIMEOUT_MS = 300_000 + const val RECONFIGURE_TIMEOUT_MS = 60_000 + const val MAX_CONFIG_VALUE_LENGTH = 2_048 + const val MAX_SIGNATURE_BYTES = 16 * 1024 + const val MAX_CATALOG_BYTES = 2 * 1024 * 1024 + const val MAX_RELEASES_BYTES = 4 * 1024 * 1024 + const val MAX_PACKAGE_BYTES = 64 * 1024 * 1024 + const val MAX_BATCH_BYTES = 96 * 1024 * 1024 + const val CATALOG_URL = "https://raw.githubusercontent.com/Bespok3d/main-index/main/index.json" + const val CATALOG_SIGNATURE_URL = "$CATALOG_URL.sig" + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dModule.kt b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dModule.kt new file mode 100644 index 0000000..1dbf66b --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dModule.kt @@ -0,0 +1,323 @@ +package org.crabcore.u1control.bespok3d + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.ViewManager +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.UUID + +class Bespok3dModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + private val client = Bespok3dClient() + private val u1Preflight = Bespok3dU1Preflight() + private val u1Enrollment = Bespok3dU1Enrollment() + + override fun getName(): String = NAME + + /** Read-only daemon detection. This does not create a client or change the printer. */ + @ReactMethod + fun probe(config: ReadableMap, promise: Promise) = background(promise) { + val result = client.probe(config.requiredString("host")) + Arguments.createMap().apply { + putString("version", result.version) + putString("license", result.license) + putString("source", result.source) + putString("certificatePem", result.certificatePem) + putString("certificateSha256", result.certificateSha256) + } + } + + /** Adds a pending phone request; an already-authorized Bespok3d client must approve it. */ + @ReactMethod + fun requestAccess(config: ReadableMap, promise: Promise) = background(promise) { + val result = client.requestAccess( + host = config.requiredString("host"), + label = config.requiredString("label"), + publicKey = config.optionalString("publicKey"), + ) + Arguments.createMap().apply { + putString("identity", result.identity) + putString("token", result.token) + putString("certificatePem", result.certificatePem) + putString("certificateSha256", result.certificateSha256) + } + } + + /** Returns granted=false only for the daemon's explicit 401 pending response. */ + @ReactMethod + fun checkAccess(config: ReadableMap, promise: Promise) = background(promise) { + try { + val result = client.status( + host = config.requiredString("host"), + token = config.requiredString("token"), + certificatePem = config.requiredString("certificatePem"), + ) + Arguments.createMap().apply { + putBoolean("granted", true) + putString("version", result.version) + putString("printerUuid", result.printerUuid) + } + } catch (error: Bespok3dHttpException) { + if (error.statusCode != 401) throw error + Arguments.createMap().apply { putBoolean("granted", false) } + } + } + + /** Reads which touchscreen UI the locally installed HelixScreen plugin selected. */ + @ReactMethod + fun helixScreenState(config: ReadableMap, promise: Promise) = + background(promise, "helixscreen-status-failed") { + client.helixScreenState( + host = config.requiredString("host"), + token = config.requiredSecret("token"), + certificatePem = config.requiredString("certificatePem"), + ).toWritableMap() + } + + /** Reconfigures only helixscreen-ui and accepts only stock or HelixScreen as the target. */ + @ReactMethod + fun configureHelixScreen(config: ReadableMap, promise: Promise) = + background(promise, "helixscreen-switch-failed") { + client.configureHelixScreen( + host = config.requiredString("host"), + token = config.requiredSecret("token"), + certificatePem = config.requiredString("certificatePem"), + selected = config.requiredString("selected"), + ).toWritableMap() + } + + /** Returns the signed official catalog alongside the printer's live installed versions. */ + @ReactMethod + fun plugins(config: ReadableMap, promise: Promise) = background(promise, "plugin-catalog-failed") { + val result = client.plugins( + host = config.requiredString("host"), + token = config.requiredSecret("token"), + certificatePem = config.requiredString("certificatePem"), + ) + Arguments.createMap().apply { + putArray("plugins", Arguments.createArray().apply { + result.plugins.forEach { plugin -> + pushMap(Arguments.createMap().apply { + putString("id", plugin.id) + putString("title", plugin.title) + putString("version", plugin.version) + putString("tagline", plugin.tagline) + putString("category", plugin.category) + putArray("dependencies", Arguments.fromList(plugin.dependencies)) + putArray("config", Arguments.createArray().apply { + plugin.config.forEach { field -> + pushMap(Arguments.createMap().apply { + putString("key", field.key) + putString("label", field.label) + putString("type", field.type) + if (field.defaultValue == null) putNull("defaultValue") + else putString("defaultValue", field.defaultValue) + putBoolean("required", field.required) + putArray("options", Arguments.fromList(field.options)) + putString("hint", field.hint) + putString("onValue", field.onValue) + putString("offValue", field.offValue) + }) + } + }) + }) + } + }) + putMap("installed", Arguments.createMap().apply { + result.installed.forEach(::putString) + }) + } + } + + /** Downloads, verifies, and batch-installs only the package ids explicitly selected by the user. */ + @ReactMethod + fun installPlugins(config: ReadableMap, promise: Promise) = background(promise, "plugin-install-failed") { + val selectedIds = config.requiredArray("pluginIds").strings() + val requestedVars = parsePluginVars(config.optionalString("varsJson")) + val result = client.installPlugins( + host = config.requiredString("host"), + token = config.requiredSecret("token"), + certificatePem = config.requiredString("certificatePem"), + selectedIds = selectedIds, + requestedVars = requestedVars, + ) + Arguments.createMap().apply { + putBoolean("ok", result.ok) + putArray("installedIds", Arguments.fromList(result.installedIds)) + putMap("failures", Arguments.createMap().apply { + result.failures.forEach(::putString) + }) + } + } + + /** Installs the exact hash-pinned HelixScreen package embedded in the APK. */ + @ReactMethod + fun installBundledHelixScreen(config: ReadableMap, promise: Promise) = + background(promise, "helixscreen-install-failed") { + val bytes = reactApplicationContext.assets + .open(Bespok3dProtocol.BUNDLED_HELIXSCREEN_ASSET) + .use { it.readBytes() } + val result = client.installBundledHelixScreen( + host = config.requiredString("host"), + token = config.requiredSecret("token"), + certificatePem = config.requiredString("certificatePem"), + bytes = bytes, + ) + Arguments.createMap().apply { + putBoolean("ok", result.ok) + putArray("installedIds", Arguments.fromList(result.installedIds)) + putMap("failures", Arguments.createMap().apply { + result.failures.forEach(::putString) + }) + } + } + + /** Verifies a stock, idle U1 using read-only SSH and Moonraker requests. */ + @ReactMethod + fun preflightU1(config: ReadableMap, promise: Promise) = + background(promise, "u1-preflight-failed") { + val result = u1Preflight.run( + host = config.requiredString("host"), + password = config.requiredSecret("password"), + ) + Arguments.createMap().apply { + putString("firmware", result.firmware) + putString("model", result.model) + putBoolean("overlayActive", result.overlayActive) + putBoolean("workspacePresent", result.workspacePresent) + putBoolean("daemonRunning", result.daemonRunning) + putString("printState", result.printState) + putBoolean("eligible", result.eligible) + if (result.reason == null) putNull("reason") else putString("reason", result.reason) + putString("sshHostKeySha256", result.sshHostKeySha256) + } + } + + /** Creates credentials before mutation so the UI can persist them securely for retry. */ + @ReactMethod + fun prepareU1Enrollment(promise: Promise) = background(promise, "u1-credentials-failed") { + val credentials = newEnrollmentCredentials() + Arguments.createMap().apply { + putString("identity", credentials.identity) + putString("token", credentials.token) + } + } + + /** Runs the official signed, idempotent stock-U1 enrollment recipe. */ + @ReactMethod + fun enrollU1(config: ReadableMap, promise: Promise) = background(promise, "u1-enroll-failed") { + // Signature verification intentionally finishes before the first enrollment SSH write. + val bootstrap = Bespok3dBootstrapPackages.load(reactApplicationContext) + val credentials = Bespok3dU1EnrollmentCredentials( + identity = config.requiredString("identity"), + token = config.requiredSecret("token"), + ) + val result = u1Enrollment.run( + Bespok3dU1EnrollmentConfig( + host = config.requiredString("host"), + password = config.requiredSecret("password"), + sshHostKeySha256 = config.requiredString("sshHostKeySha256"), + label = config.requiredString("label"), + credentials = credentials, + ), + bootstrap, + ) + val certificate = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(result.certificatePem.toByteArray(Charsets.US_ASCII))) as X509Certificate + Arguments.createMap().apply { + putString("identity", credentials.identity) + putString("token", credentials.token) + putString("certificatePem", result.certificatePem) + putString("certificateSha256", Bespok3dProtocol.certificateSha256(certificate.encoded)) + putString("daemonVersion", result.daemonVersion) + putString("jinniVersion", result.jinniVersion) + putArray("completedSteps", Arguments.fromList(result.completedSteps)) + } + } + + private fun background( + promise: Promise, + fallbackCode: String = "daemon-unreachable", + operation: () -> Any?, + ) { + Thread({ + try { + promise.resolve(operation()) + } catch (error: Throwable) { + val code = when (error) { + is Bespok3dEnrollmentException -> "u1-enroll-${error.stepId}" + is IllegalArgumentException -> "bad-config" + is CertificateException -> "certificate-mismatch" + is Bespok3dHttpException -> "daemon-http-${error.statusCode}" + else -> fallbackCode + } + promise.reject(code, error.message ?: "Bespok3d request failed", error) + } + }, "helix-bespok3d").start() + } + + private fun ReadableMap.requiredString(key: String): String = + getString(key)?.trim()?.takeIf(String::isNotEmpty) + ?: throw IllegalArgumentException("$key is required") + + private fun ReadableMap.requiredSecret(key: String): String = + getString(key)?.takeIf(String::isNotEmpty) + ?: throw IllegalArgumentException("$key is required") + + private fun ReadableMap.optionalString(key: String): String = + if (hasKey(key) && !isNull(key)) getString(key)?.trim().orEmpty() else "" + + private fun ReadableMap.requiredArray(key: String): ReadableArray = + getArray(key) ?: throw IllegalArgumentException("$key is required") + + private fun ReadableArray.strings(): List = buildList { + for (position in 0 until size()) { + add(getString(position) ?: throw IllegalArgumentException("pluginIds must contain strings")) + } + } + + private fun parsePluginVars(json: String): Map> { + if (json.isBlank()) return emptyMap() + val root = JSONObject(json) + return root.keys().asSequence().associateWith { pluginId -> + val fields = root.getJSONObject(pluginId) + fields.keys().asSequence().associateWith(fields::getString) + } + } + + private fun Bespok3dHelixScreenState.toWritableMap() = Arguments.createMap().apply { + putBoolean("installed", installed) + if (selected == null) putNull("selected") else putString("selected", selected) + } + + private fun newEnrollmentCredentials(): Bespok3dU1EnrollmentCredentials = + Bespok3dU1EnrollmentCredentials( + identity = "helix-${UUID.randomUUID()}", + token = ByteArray(32).also(SecureRandom()::nextBytes).joinToString("") { "%02x".format(it) }, + ) + + companion object { + const val NAME = "HelixBespok3d" + } +} + +class Bespok3dPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(Bespok3dModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> = emptyList() +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dSsh.kt b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dSsh.kt new file mode 100644 index 0000000..4364367 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dSsh.kt @@ -0,0 +1,213 @@ +package org.crabcore.u1control.bespok3d + +import com.jcraft.jsch.ChannelExec +import com.jcraft.jsch.ChannelSftp +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.security.MessageDigest +import java.util.Base64 +import java.util.UUID + +internal interface Bespok3dSsh : Closeable { + fun exec(command: String): String + fun read(path: String): ByteArray + fun write(path: String, bytes: ByteArray, mode: Int = 0b110100100) + fun reconnect() +} + +internal fun interface Bespok3dSshFactory { + fun open(host: String, password: String, expectedHostKeySha256: String): Bespok3dSsh +} + +/** SSH transport used only after the user has confirmed the fingerprint returned by preflight. */ +internal class JschBespok3dSsh( + host: String, + password: String, + expectedHostKeySha256: String, +) : Bespok3dSsh { + private val cleanHost = validateHost(host) + private val passwordBytes = password.toByteArray(Charsets.UTF_8) + private val expectedFingerprint = validateFingerprint(expectedHostKeySha256) + private var session: Session? = null + private var sftpSession: Session? = null + private var sftpChannel: ChannelSftp? = null + + init { + require(passwordBytes.isNotEmpty()) { "SSH password is required" } + connect() + } + + override fun exec(command: String): String { + require(command.isNotBlank()) { "SSH command is required" } + val active = connectedSession() + val errors = ByteArrayOutputStream() + val channel = active.openChannel("exec") as ChannelExec + try { + channel.setCommand(command) + channel.setInputStream(null) + channel.setErrStream(errors) + val output = channel.inputStream + channel.connect(TIMEOUT_MS) + val text = output.readLimited(MAX_COMMAND_OUTPUT_BYTES).toString(Charsets.UTF_8) + if (channel.awaitExitStatus(TIMEOUT_MS) != 0) { + val detail = errors.toByteArray().take(MAX_ERROR_BYTES).toByteArray() + .toString(Charsets.UTF_8).trim() + throw IllegalStateException(detail.ifEmpty { "Bespok3d SSH command failed" }) + } + return text + } finally { + channel.disconnect() + } + } + + override fun read(path: String): ByteArray { + validateRemotePath(path) + try { + return connectedSftpChannel().get(path).use { it.readLimited(MAX_REMOTE_FILE_BYTES) } + } catch (error: Throwable) { + disconnectSftp() + throw error + } + } + + override fun write(path: String, bytes: ByteArray, mode: Int) { + validateRemotePath(path) + require(mode in 0..0b111111111) { "Remote file mode is invalid" } + require(bytes.size <= MAX_REMOTE_FILE_BYTES) { "Remote file exceeds upload limit" } + val temporary = "$path.helix-${UUID.randomUUID()}" + val channel = connectedSftpChannel() + try { + channel.put(bytes.inputStream(), temporary) + channel.chmod(mode, temporary) + channel.rename(temporary, path) + } catch (error: Throwable) { + runCatching { channel.rm(temporary) } + disconnectSftp() + throw error + } + } + + override fun reconnect() { + disconnectSftp() + session?.disconnect() + session = null + connect() + } + + override fun close() { + disconnectSftp() + session?.disconnect() + session = null + passwordBytes.fill(0) + } + + private fun connect() { + session = createVerifiedSession() + } + + private fun createVerifiedSession(): Session { + val next = JSch().getSession(SSH_USER, cleanHost, SSH_PORT) + try { + next.setPassword(passwordBytes) + next.setConfig("StrictHostKeyChecking", "no") + next.setConfig("PreferredAuthentications", "password,keyboard-interactive") + next.timeout = TIMEOUT_MS + next.connect(TIMEOUT_MS) + val actual = hostKeyFingerprint(next) + require(MessageDigest.isEqual(actual.toByteArray(), expectedFingerprint.toByteArray())) { + "U1 SSH host key changed; run preflight again before enrollment" + } + return next + } catch (error: Throwable) { + next.disconnect() + throw error + } + } + + /** + * Stock U1 closes the subsystem pipe after Helix disconnects an SFTP channel, + * so a second channel on that SSH session fails with "Pipe closed". Keep one + * separately authenticated channel for the enrollment's ordered transfers. + */ + private fun connectedSftpChannel(): ChannelSftp { + sftpChannel?.takeIf(ChannelSftp::isConnected)?.let { return it } + disconnectSftp() + val transferSession = createVerifiedSession() + var channel: ChannelSftp? = null + try { + channel = transferSession.openChannel("sftp") as ChannelSftp + channel.connect(TIMEOUT_MS) + sftpSession = transferSession + sftpChannel = channel + return channel + } catch (error: Throwable) { + channel?.disconnect() + transferSession.disconnect() + throw error + } + } + + private fun disconnectSftp() { + sftpChannel?.disconnect() + sftpChannel = null + sftpSession?.disconnect() + sftpSession = null + } + + private fun connectedSession(): Session = session?.takeIf(Session::isConnected) + ?: throw IllegalStateException("Bespok3d SSH session is disconnected") + + private fun hostKeyFingerprint(active: Session): String { + val hostKey = Base64.getDecoder().decode(active.hostKey.key) + return "SHA256:" + Base64.getEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(hostKey), + ) + } + + private fun java.io.InputStream.readLimited(limit: Int): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (true) { + val count = read(buffer) + if (count < 0) break + require(output.size() + count <= limit) { "Bespok3d SSH response exceeds its size limit" } + output.write(buffer, 0, count) + } + return output.toByteArray() + } + + private companion object { + const val SSH_USER = "root" + const val SSH_PORT = 22 + const val TIMEOUT_MS = 8_000 + const val MAX_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024 + const val MAX_ERROR_BYTES = 16 * 1024 + const val MAX_REMOTE_FILE_BYTES = 12 * 1024 * 1024 + + fun validateHost(raw: String): String { + val host = raw.trim().removePrefix("[").removeSuffix("]") + require(host.isNotEmpty() && host.length <= 253) { "U1 host is required" } + require(!host.contains('/') && !host.contains("//") && host.none(Char::isWhitespace)) { + "U1 host must not include a URL or path" + } + return host + } + + fun validateFingerprint(raw: String): String { + val value = raw.trim() + require(value.matches(Regex("^SHA256:[A-Za-z0-9+/]{43}$"))) { + "Confirmed SSH host-key fingerprint is invalid" + } + return value + } + + fun validateRemotePath(path: String) { + require(path.startsWith('/') && path.length <= 512 && !path.contains('\u0000')) { + "Remote path is invalid" + } + require(path.split('/').none { it == "." || it == ".." }) { "Remote path is unsafe" } + } + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dSshExitStatus.kt b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dSshExitStatus.kt new file mode 100644 index 0000000..5e59254 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dSshExitStatus.kt @@ -0,0 +1,38 @@ +package org.crabcore.u1control.bespok3d + +import com.jcraft.jsch.ChannelExec +import java.net.SocketTimeoutException +import java.util.concurrent.TimeUnit + +/** + * JSch may deliver command EOF before the SSH exit-status packet. Reading + * Channel.exitStatus immediately can therefore return -1 for a successful + * command. Wait for the packet with the same finite bound as the channel. + */ +internal fun ChannelExec.awaitExitStatus(timeoutMs: Int): Int = awaitSshExitStatus( + readStatus = { exitStatus }, + timeoutMs = timeoutMs.toLong(), +) + +internal fun awaitSshExitStatus( + readStatus: () -> Int, + timeoutMs: Long, + nanoTime: () -> Long = System::nanoTime, + pause: (Long) -> Unit = Thread::sleep, +): Int { + require(timeoutMs > 0) { "SSH exit-status timeout must be positive" } + val startedAt = nanoTime() + val timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMs) + while (true) { + val status = readStatus() + if (status >= 0) return status + val elapsed = nanoTime() - startedAt + if (elapsed >= timeoutNanos) { + throw SocketTimeoutException("Timed out waiting for SSH command exit status") + } + val remainingMs = TimeUnit.NANOSECONDS.toMillis(timeoutNanos - elapsed) + pause(remainingMs.coerceIn(1, EXIT_STATUS_POLL_MS)) + } +} + +private const val EXIT_STATUS_POLL_MS = 10L diff --git a/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dU1Enrollment.kt b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dU1Enrollment.kt new file mode 100644 index 0000000..78b124b --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dU1Enrollment.kt @@ -0,0 +1,359 @@ +package org.crabcore.u1control.bespok3d + +import org.json.JSONArray +import org.json.JSONObject + +data class Bespok3dU1EnrollmentCredentials( + val identity: String, + val token: String, +) + +data class Bespok3dU1EnrollmentConfig( + val host: String, + val password: String, + val sshHostKeySha256: String, + val label: String, + val credentials: Bespok3dU1EnrollmentCredentials, +) + +data class Bespok3dU1EnrollmentResult( + val certificatePem: String, + val daemonVersion: String, + val jinniVersion: String, + val completedSteps: List, +) + +class Bespok3dEnrollmentException( + val stepId: String, + cause: Throwable, +) : IllegalStateException("Bespok3d enrollment failed during $stepId: ${cause.message}", cause) + +/** Pure, byte-for-byte-compatible patchers from the official Snapmaker U1 adapter. */ +object Bespok3dU1EnrollmentProtocol { + fun patchS90lmd(content: String): String { + if (content.contains("S99bespok3d")) return content + val lines = content.split('\n').toMutableList() + require(lines.firstOrNull()?.startsWith("#!") == true) { + "Unexpected S90lmd format: file does not start with a shebang" + } + lines.add(1, "[ -x /etc/init.d/S99bespok3d ] && exec /etc/init.d/S99bespok3d \"\$@\"") + return lines.joinToString("\n") + } + + fun patchNginx(content: String): String { + if (content.contains("bespok3d/etc/nginx/locations")) return content + val stripped = content.trimEnd() + require(stripped.endsWith('}')) { "Unexpected nginx config format: file does not end with \"}\"" } + return stripped.dropLast(1) + + " include /userdata/bespok3d/etc/nginx/locations/*.conf;\n}\n" + } + + fun mergeAcl(existingText: String?, identity: String, token: String, label: String): String { + val existing = runCatching { existingText?.let(::JSONObject) }.getOrNull() ?: JSONObject() + val keys = existing.stringList("keys") + val tokens = existing.stringList("tokens") + val role = if (keys.isEmpty()) "admin" else existing.optJSONObject("roles") + ?.optString(identity)?.takeIf(String::isNotEmpty) ?: "user" + if (!keys.contains(identity)) keys += identity + if (!tokens.contains(token)) tokens += token + return JSONObject().apply { + put("keys", JSONArray(keys)) + put("roles", copyObject(existing.optJSONObject("roles")).apply { put(identity, role) }) + put("labels", copyObject(existing.optJSONObject("labels")).apply { put(identity, label) }) + put("tokens", JSONArray(tokens)) + put( + "token_identity", + copyObject(existing.optJSONObject("token_identity")).apply { put(token, identity) }, + ) + }.toString(2) + } + + private fun JSONObject.stringList(key: String): MutableList { + val array = optJSONArray(key) ?: return mutableListOf() + return buildList { + for (index in 0 until array.length()) { + array.optString(index).takeIf(String::isNotEmpty)?.let(::add) + } + }.toMutableList() + } + + private fun copyObject(source: JSONObject?): JSONObject = + if (source == null) JSONObject() else JSONObject(source.toString()) +} + +/** + * Installs only signature-verified bundled bytes, following the official U1 adapter's ordered, + * idempotent enrollment recipe. Re-running after a disconnect resumes safely from preflight. + */ +internal class Bespok3dU1Enrollment( + private val preflight: (String, String) -> Bespok3dU1PreflightResult = + { host, password -> Bespok3dU1Preflight().run(host, password) }, + private val sshFactory: Bespok3dSshFactory = + Bespok3dSshFactory { host, password, fingerprint -> + JschBespok3dSsh(host, password, fingerprint) + }, + private val pause: (Long) -> Unit = Thread::sleep, +) { + fun run( + config: Bespok3dU1EnrollmentConfig, + bootstrap: Bespok3dBootstrapSet, + ): Bespok3dU1EnrollmentResult { + validate(config) + val checked = step("preflight") { preflight(config.host, config.password) } + require(checked.sshHostKeySha256 == config.sshHostKeySha256) { + "U1 SSH host key changed; run preflight again before enrollment" + } + require(checked.eligible) { checked.reason ?: "U1 is not eligible for Bespok3d enrollment" } + + val completed = mutableListOf("preflight") + var certificatePem = "" + sshFactory.open(config.host, config.password, config.sshHostKeySha256).use { ssh -> + var overlayWasActive = false + runStep(completed, "unlock-overlay") { + overlayWasActive = ssh.exec("test -e /oem/.debug && echo yes || echo no").trim() == "yes" + ssh.exec( + "cp /oem/printer_data/gui/wpa_supplicant.conf /etc/wpa_supplicant.conf " + + "2>/dev/null || true && touch /oem/.debug", + ) + } + runStep(completed, "fix-wifi-persistence") { ssh.exec(FIX_WIFI_COMMAND) } + runStep(completed, "reboot-and-reconnect") { + if (!overlayWasActive) { + runCatching { ssh.exec("reboot") } + reconnectAfterReboot(ssh) + } + } + runStep(completed, "create-workspace") { ssh.exec(CREATE_WORKSPACE_COMMAND) } + runStep(completed, "deploy-s99") { + val script = bootstrap.daemon.files.requiredFile("S99bespok3d") + ssh.write("/etc/init.d/S99bespok3d", script.bytes, 0b111101101) + } + runStep(completed, "patch-s90lmd") { + patchRemote(ssh, "/etc/init.d/S90lmd", 0b111101101, Bespok3dU1EnrollmentProtocol::patchS90lmd) + } + runStep(completed, "stable-network") { + ssh.exec("rm -rf /oem/dhcpcd") + ssh.exec(STABLE_DHCP_COMMAND) + ssh.exec(STABLE_MAC_COMMAND) + } + runStep(completed, "patch-nginx") { + patchRemote( + ssh, + "/etc/nginx/sites-enabled/fluidd", + 0b110100100, + Bespok3dU1EnrollmentProtocol::patchNginx, + ) + } + runStep(completed, "klipper-includes") { + ssh.exec(CREATE_KLIPPER_DIRS_COMMAND) + ssh.exec(includeCommand("/oem/printer_data/config/printer.cfg", "bespok3d/klipper", "[include bespok3d/klipper/*.cfg]")) + ssh.exec(includeCommand("/oem/printer_data/config/moonraker.conf", "bespok3d/moonraker", "[include bespok3d/moonraker/*.cfg]")) + } + runStep(completed, "deploy-daemon") { deployDaemon(ssh, bootstrap) } + runStep(completed, "generate-daemon-cert") { + val exists = ssh.exec("test -f $CERT_PATH && echo yes || echo no").trim() == "yes" + if (!exists) ssh.exec(GENERATE_CERT_COMMAND) + certificatePem = ssh.read(CERT_PATH).toString(Charsets.UTF_8) + require(certificatePem.contains("-----BEGIN CERTIFICATE-----")) { + "Bespok3d daemon certificate is malformed" + } + } + runStep(completed, "enroll-daemon-key") { + ssh.exec("mkdir -p $AUTH_ROOT") + val exists = ssh.exec("test -f $ACL_PATH && echo yes || echo no").trim() == "yes" + val current = if (exists) ssh.read(ACL_PATH).toString(Charsets.UTF_8) else null + val acl = Bespok3dU1EnrollmentProtocol.mergeAcl( + current, + config.credentials.identity, + config.credentials.token, + config.label, + ) + ssh.write(ACL_PATH, acl.toByteArray(Charsets.UTF_8), 0b110000000) + } + runStep(completed, "start-daemon") { + ssh.exec("[ -f $AUTOSTART_PATH ] && $AUTOSTART_PATH stop 2>/dev/null || true") + ssh.exec("$AUTOSTART_PATH start") + ssh.exec(VERIFY_DAEMON_COMMAND) + } + runStep(completed, "verify") { ssh.exec(VERIFY_INSTALL_COMMAND) } + } + return Bespok3dU1EnrollmentResult( + certificatePem = certificatePem, + daemonVersion = bootstrap.daemon.version, + jinniVersion = bootstrap.jinni.version, + completedSteps = completed, + ) + } + + private fun deployDaemon(ssh: Bespok3dSsh, bootstrap: Bespok3dBootstrapSet) { + val daemonFiles = bootstrap.daemon.files.filterNot { it.path in INIT_SCRIPTS } + ssh.exec("rm -rf /userdata/bespok3d/var/lib/demon") + replaceOwnedPayload(ssh, daemonFiles) + replaceOwnedPayload(ssh, bootstrap.jinni.files) + val autostart = bootstrap.daemon.files.requiredFile("s10bespok3d-daemon") + ssh.write(AUTOSTART_PATH, autostart.bytes, 0b111101101) + ssh.exec(CLEAN_SYSTEM_PYTHON_COMMAND) + val venv = ssh.exec("test -x $BESPOK3D/venv/bin/python3 && echo yes || echo no").trim() + if (venv != "yes") ssh.exec("python3 -m venv $BESPOK3D/venv") + val wheels = daemonFiles.filter { it.path.startsWith("wheels/") } + require(wheels.isNotEmpty()) { "Signed Bespok3d daemon package contains no wheels" } + ssh.exec( + "$BESPOK3D/venv/bin/pip install --no-index --no-deps " + + wheels.joinToString(" ") { shellQuote("$DAEMON_ROOT/${it.path}") }, + ) + } + + private fun replaceOwnedPayload(ssh: Bespok3dSsh, files: List) { + require(files.isNotEmpty()) { "Signed Bespok3d package has no payload" } + val owned = files.map { it.path.substringBefore('/') }.distinct() + ssh.exec("rm -rf " + owned.joinToString(" ") { shellQuote("$DAEMON_ROOT/$it") }) + val directories = files.mapNotNull { file -> + file.path.substringBeforeLast('/', "").takeIf(String::isNotEmpty)?.let { "$DAEMON_ROOT/$it" } + }.distinct() + ssh.exec("mkdir -p " + (listOf(DAEMON_ROOT) + directories).joinToString(" ", transform = ::shellQuote)) + files.forEach { file -> ssh.write("$DAEMON_ROOT/${file.path}", file.bytes, file.mode and 0b111111111) } + } + + private fun patchRemote( + ssh: Bespok3dSsh, + path: String, + mode: Int, + patcher: (String) -> String, + ) { + val current = ssh.read(path).toString(Charsets.UTF_8) + val patched = patcher(current) + if (patched != current) ssh.write(path, patched.toByteArray(Charsets.UTF_8), mode) + } + + private fun reconnectAfterReboot(ssh: Bespok3dSsh) { + var lastError: Throwable? = null + repeat(RECONNECT_ATTEMPTS) { attempt -> + if (attempt > 0) pause(RECONNECT_DELAY_MS) + try { + ssh.reconnect() + return + } catch (error: Throwable) { + lastError = error + } + } + throw IllegalStateException("U1 did not reconnect after reboot", lastError) + } + + private fun validate(config: Bespok3dU1EnrollmentConfig) { + require(config.password.isNotEmpty()) { "SSH password is required" } + require(config.label.isNotBlank() && config.label.length <= 64 && config.label.none(Char::isISOControl)) { + "Enrollment label is invalid" + } + require(config.credentials.identity.matches(Regex("^helix-[a-f0-9-]{36}$"))) { + "Enrollment identity is invalid" + } + require(config.credentials.token.matches(Regex("^[a-f0-9]{64}$"))) { + "Enrollment token is invalid" + } + } + + private fun step(id: String, operation: () -> T): T = try { + operation() + } catch (error: Throwable) { + throw Bespok3dEnrollmentException(id, error) + } + + private fun runStep(completed: MutableList, id: String, operation: () -> Unit) { + step(id, operation) + completed += id + } + + private fun includeCommand(path: String, pattern: String, line: String): String = + """grep -q '$pattern' $path 2>/dev/null || python3 -c " +content = open('$path').read() +marker = '#*# <---------------------- SAVE_CONFIG' +at = content.find(marker) +head = (content if at < 0 else content[:at]).rstrip('\\n') +tail = '' if at < 0 else '\\n' + content[at:] +open('$path', 'w').write(head + '\\n\\n' + '$line' + '\\n' + tail) +"""".trimIndent() + + private fun shellQuote(value: String): String = "'${value.replace("'", "'\\''")}'" + + private fun List.requiredFile(path: String): Bespok3dBootstrapFile = + singleOrNull { it.path == path } + ?: throw IllegalArgumentException("Signed Bespok3d package is missing $path") + + private companion object { + const val BESPOK3D = "/userdata/bespok3d" + const val DAEMON_ROOT = "$BESPOK3D/var/lib/daemon" + const val AUTH_ROOT = "$BESPOK3D/auth" + const val ACL_PATH = "$AUTH_ROOT/acl.json" + const val CERT_PATH = "$BESPOK3D/etc/daemon/server.crt" + const val AUTOSTART_PATH = "$BESPOK3D/etc/init.d/autostart/s10bespok3d-daemon" + const val RECONNECT_ATTEMPTS = 100 + const val RECONNECT_DELAY_MS = 3_000L + val INIT_SCRIPTS = setOf("s10bespok3d-daemon", "S99bespok3d") + + val FIX_WIFI_COMMAND = "mkdir -p /userdata/cfg" + + " && { { grep -q 'network=' /tmp/wpa_supplicant.conf 2>/dev/null" + + " && cp -p /tmp/wpa_supplicant.conf /userdata/cfg/wpa_config.conf; }" + + " || { grep -q 'network=' /etc/wpa_supplicant.conf 2>/dev/null" + + " && cp -p /etc/wpa_supplicant.conf /userdata/cfg/wpa_config.conf; } || true; }" + + " && grep -q 'network=' /userdata/cfg/wpa_config.conf 2>/dev/null" + + " && ln -sf /userdata/cfg/wpa_config.conf /etc/wpa_supplicant.conf || true" + + val CREATE_WORKSPACE_COMMAND = """ + mkdir -p $BESPOK3D/bin $BESPOK3D/sbin $BESPOK3D/etc/daemon \ + $BESPOK3D/etc/init.d/autostart $BESPOK3D/etc/nginx/locations $BESPOK3D/home \ + $BESPOK3D/root $BESPOK3D/run $BESPOK3D/usr/local/plugins $BESPOK3D/var/db \ + $BESPOK3D/var/lib $BESPOK3D/var/log && + ([ -f $BESPOK3D/etc/version ] || printf '%s\n' '0.0.1' > $BESPOK3D/etc/version) && + chmod +t $BESPOK3D/run && chown -R lava:lava $BESPOK3D && chmod -R 755 $BESPOK3D + """.trimIndent() + + val STABLE_DHCP_COMMAND = """ + if [ ! -L /var/db/dhcpcd ]; then + mkdir -p $BESPOK3D/var/db/dhcpcd + [ -d /var/db/dhcpcd ] && cp -a /var/db/dhcpcd/. $BESPOK3D/var/db/dhcpcd/ 2>/dev/null || true + rm -rf /var/db/dhcpcd + ln -sf $BESPOK3D/var/db/dhcpcd /var/db/dhcpcd + fi + """.trimIndent() + + val STABLE_MAC_COMMAND = """ + MAC=${'$'}(cat /sys/class/net/wlan0/address 2>/dev/null) + if [ -n "${'$'}MAC" ]; then + mkdir -p /etc/udev/rules.d + printf 'SUBSYSTEM=="net", ACTION=="add", KERNEL=="wlan0", RUN+="/sbin/ip link set wlan0 address %s"\n' "${'$'}MAC" > /etc/udev/rules.d/70-wlan0-mac.rules + fi + """.trimIndent() + + val CREATE_KLIPPER_DIRS_COMMAND = """ + mkdir -p /oem/printer_data/config/bespok3d/klipper \ + /oem/printer_data/config/bespok3d/moonraker /oem/printer_data/config/bespok3d/data && + touch /oem/printer_data/config/bespok3d/klipper/main.cfg \ + /oem/printer_data/config/bespok3d/moonraker/main.cfg && + chown -R lava:lava /oem/printer_data/config/bespok3d && + chmod -R 755 /oem/printer_data/config/bespok3d + """.trimIndent() + + const val GENERATE_CERT_COMMAND = + "openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 " + + "-keyout $BESPOK3D/etc/daemon/server.key -out $CERT_PATH -sha256 -days 3650 " + + "-nodes -subj '/CN=bespok3d-daemon' 2>&1" + + const val CLEAN_SYSTEM_PYTHON_COMMAND = + "cd /oem/overlay/upper/usr/lib/python3.11/site-packages 2>/dev/null && " + + "for p in fastapi uvicorn pydantic pydantic_core uvloop httptools websockets watchfiles " + + "multipart dotenv pgpy annotated_types starlette click typing_inspection annotated_doc; " + + "do rm -rf \"\$p\" \"\$p\"-*.dist-info 2>/dev/null; done; " + + "rm -f typing_extensions.py typing_extensions.pyc; rm -rf __pycache__ 2>/dev/null; true" + + const val VERIFY_DAEMON_COMMAND = + "sleep 5 && pid=\$(cat $BESPOK3D/run/bespok3d-daemon.pid 2>/dev/null) && " + + "[ -n \"\$pid\" ] && kill -0 \"\$pid\" 2>/dev/null || " + + "{ cat $BESPOK3D/var/log/daemon.log >&2; exit 1; }" + + const val VERIFY_INSTALL_COMMAND = + "test -d $BESPOK3D && test -f /etc/init.d/S99bespok3d && " + + "pid=\$(cat $BESPOK3D/run/bespok3d-daemon.pid 2>/dev/null) && " + + "[ -n \"\$pid\" ] && kill -0 \"\$pid\" 2>/dev/null || " + + "{ cat $BESPOK3D/var/log/daemon.log >&2; exit 1; }" + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dU1Preflight.kt b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dU1Preflight.kt new file mode 100644 index 0000000..043e5a3 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/bespok3d/Bespok3dU1Preflight.kt @@ -0,0 +1,189 @@ +package org.crabcore.u1control.bespok3d + +import com.jcraft.jsch.ChannelExec +import com.jcraft.jsch.JSch +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import java.util.Base64 + +data class Bespok3dU1PreflightResult( + val firmware: String, + val model: String, + val overlayActive: Boolean, + val workspacePresent: Boolean, + val daemonRunning: Boolean, + val printState: String, + val eligible: Boolean, + val reason: String?, + val sshHostKeySha256: String, +) + +data class Bespok3dU1SystemState( + val firmware: String, + val model: String, + val overlayActive: Boolean, + val workspacePresent: Boolean, + val daemonRunning: Boolean, +) + +/** Pure parsing and eligibility rules for the stock Snapmaker U1 preflight. */ +object Bespok3dU1PreflightProtocol { + fun parseSystemState(output: String): Bespok3dU1SystemState { + val values = output.lineSequence() + .mapNotNull { line -> + val separator = line.indexOf('=') + if (separator <= 0) null else line.substring(0, separator) to line.substring(separator + 1).trim() + } + .toMap() + val firmware = values.required("firmware") + require(firmware == "stock" || firmware == "extended") { "Unknown U1 firmware state" } + val model = values.required("model") + return Bespok3dU1SystemState( + firmware = firmware, + model = model, + overlayActive = values.requiredFlag("overlay"), + workspacePresent = values.requiredFlag("workspace"), + daemonRunning = values.requiredFlag("daemon"), + ) + } + + fun parsePrintState(body: String): String = + JSONObject(body) + .optJSONObject("result") + ?.optJSONObject("status") + ?.optJSONObject("print_stats") + ?.optString("state") + ?.trim() + ?.lowercase() + ?.takeIf(String::isNotEmpty) + ?: throw IllegalArgumentException("Moonraker returned no print state") + + fun result( + system: Bespok3dU1SystemState, + printState: String, + sshHostKeySha256: String, + ): Bespok3dU1PreflightResult { + val reason = when { + !system.model.contains("RK3562", ignoreCase = true) -> + "The SSH host is not a supported Snapmaker U1" + system.firmware == "extended" -> + "Bespok3d enrollment requires stock Snapmaker U1 firmware" + printState == "printing" || printState == "paused" -> + "The printer must be idle before Bespok3d enrollment" + else -> null + } + return Bespok3dU1PreflightResult( + firmware = system.firmware, + model = system.model, + overlayActive = system.overlayActive, + workspacePresent = system.workspacePresent, + daemonRunning = system.daemonRunning, + printState = printState, + eligible = reason == null, + reason = reason, + sshHostKeySha256 = sshHostKeySha256, + ) + } + + private fun Map.required(key: String): String = + get(key)?.takeIf(String::isNotEmpty) + ?: throw IllegalArgumentException("U1 preflight returned no $key") + + private fun Map.requiredFlag(key: String): Boolean = when (required(key)) { + "yes" -> true + "no" -> false + else -> throw IllegalArgumentException("U1 preflight returned an invalid $key value") + } +} + +/** Performs only read operations over SSH and Moonraker; it never changes the printer. */ +class Bespok3dU1Preflight { + fun run(host: String, password: String): Bespok3dU1PreflightResult { + val cleanHost = validatedHost(host) + require(password.isNotEmpty()) { "SSH password is required" } + val passwordBytes = password.toByteArray(Charsets.UTF_8) + val session = JSch().getSession(SSH_USER, cleanHost, SSH_PORT) + try { + session.setPassword(passwordBytes) + session.setConfig("StrictHostKeyChecking", "no") + session.setConfig("PreferredAuthentications", "password,keyboard-interactive") + session.timeout = TIMEOUT_MS + session.connect(TIMEOUT_MS) + val hostKey = Base64.getDecoder().decode(session.hostKey.key) + val fingerprint = "SHA256:" + Base64.getEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(hostKey), + ) + val system = Bespok3dU1PreflightProtocol.parseSystemState(exec(session, PREFLIGHT_COMMAND)) + val printState = readPrintState(cleanHost) + return Bespok3dU1PreflightProtocol.result(system, printState, fingerprint) + } finally { + passwordBytes.fill(0) + session.disconnect() + } + } + + private fun exec(session: com.jcraft.jsch.Session, command: String): String { + val errors = ByteArrayOutputStream() + val channel = session.openChannel("exec") as ChannelExec + try { + channel.setCommand(command) + channel.setInputStream(null) + channel.setErrStream(errors) + val output = channel.inputStream + channel.connect(TIMEOUT_MS) + val text = output.bufferedReader().use { it.readText() } + if (channel.awaitExitStatus(TIMEOUT_MS) != 0) { + val detail = errors.toString(Charsets.UTF_8.name()).trim() + throw IllegalStateException(detail.ifEmpty { "U1 SSH preflight failed" }) + } + return text + } finally { + channel.disconnect() + } + } + + private fun readPrintState(host: String): String { + val urlHost = if (host.contains(':') && !host.startsWith('[')) "[$host]" else host + val connection = URL("http://$urlHost:$MOONRAKER_PORT/printer/objects/query?print_stats") + .openConnection() as HttpURLConnection + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + connection.requestMethod = "GET" + connection.setRequestProperty("Accept", "application/json") + try { + val status = connection.responseCode + if (status !in 200..299) throw IllegalStateException("Moonraker returned HTTP $status") + return Bespok3dU1PreflightProtocol.parsePrintState( + connection.inputStream.bufferedReader().use { it.readText() }, + ) + } finally { + connection.disconnect() + } + } + + private fun validatedHost(raw: String): String { + val host = raw.trim().removePrefix("[").removeSuffix("]") + require(host.isNotEmpty() && host.length <= 253) { "U1 host is required" } + require(!host.contains('/') && !host.contains("//") && host.none(Char::isWhitespace)) { + "U1 host must not include a URL or path" + } + return host + } + + private companion object { + const val SSH_USER = "root" + const val SSH_PORT = 22 + const val MOONRAKER_PORT = 7125 + const val TIMEOUT_MS = 8_000 + val PREFLIGHT_COMMAND = """ + printf 'firmware='; if [ -e /usr/local/bin/extended-config.py ]; then echo extended; else echo stock; fi + printf 'model='; tr -d '\000' /dev/null || uname -m; echo + printf 'overlay='; if [ -e /oem/.debug ]; then echo yes; else echo no; fi + printf 'workspace='; if [ -d /userdata/bespok3d ]; then echo yes; else echo no; fi + printf 'daemon='; if [ -r /userdata/bespok3d/run/bespok3d-daemon.pid ] && kill -0 "${'$'}(cat /userdata/bespok3d/run/bespok3d-daemon.pid)" 2>/dev/null; then echo yes; else echo no; fi + """.trimIndent() + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/GcodeToolMapper.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/GcodeToolMapper.kt index 7ab3b2d..d9b4dfb 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/GcodeToolMapper.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/GcodeToolMapper.kt @@ -26,11 +26,12 @@ object GcodeToolMapper { RegexOption.IGNORE_CASE, ) - // Keep adaptive probing inside the U1's safe bed-mesh area. Model bounds + // Keep adaptive probing inside the machine's safe bed-mesh area. Model bounds // can extend past X=0 even though the printer's motion range starts there. - private const val U1_BED_MESH_MIN = 3.0 - private const val U1_BED_MESH_MAX = 267.0 - private const val U1_BED_MESH_MIN_SPAN = 1.0 + // The inset was written for the U1 as a literal 3..267 on a 270mm bed; it is + // now derived so a 220mm machine does not get told to probe out to 267. + private const val BED_MESH_INSET = 3.0 + private const val BED_MESH_MIN_SPAN = 1.0 fun applyInitialTool(path: String, initialTool: Int): Result { val file = File(path) @@ -103,7 +104,12 @@ object GcodeToolMapper { return Result(changed, if (mask == 0) 1 shl tool else mask) } - fun clampU1BedMeshBounds(path: String): BedMeshResult { + /** + * Constrains BED_MESH_CALIBRATE probe bounds to the machine's bed, inset by a + * few mm. A gcode with no such command is left untouched and reported as a + * success — most machines never emit one. + */ + fun clampBedMeshBounds(path: String, bedSizeX: Float, bedSizeY: Float): BedMeshResult { val file = File(path) if (!file.exists() || !file.isFile) return BedMeshResult(false, false) @@ -111,7 +117,7 @@ object GcodeToolMapper { val safeToRewrite = runCatching { file.bufferedReader().useLines { lines -> lines.forEach { line -> - val result = rewriteBedMeshLine(line) + val result = rewriteBedMeshLine(line, bedSizeX, bedSizeY) if (!result.valid) return@runCatching false if (result.line != line) needsRewrite = true } @@ -127,7 +133,7 @@ object GcodeToolMapper { tmp.bufferedWriter().use { out -> file.bufferedReader().useLines { lines -> lines.forEach { line -> - val result = rewriteBedMeshLine(line) + val result = rewriteBedMeshLine(line, bedSizeX, bedSizeY) check(result.valid) out.write(result.line) out.newLine() @@ -148,7 +154,7 @@ object GcodeToolMapper { return BedMeshResult(true, true) } - private fun rewriteBedMeshLine(line: String): BedMeshLineResult { + private fun rewriteBedMeshLine(line: String, bedSizeX: Float, bedSizeY: Float): BedMeshLineResult { val trimmed = line.trimStart() if (!trimmed.startsWith("BED_MESH_CALIBRATE", ignoreCase = true)) { return BedMeshLineResult(line, true) @@ -163,8 +169,8 @@ object GcodeToolMapper { return BedMeshLineResult(line, false) } - val safeX = safeAxisBounds(minX, maxX) ?: return BedMeshLineResult(line, false) - val safeY = safeAxisBounds(minY, maxY) ?: return BedMeshLineResult(line, false) + val safeX = safeAxisBounds(minX, maxX, bedSizeX) ?: return BedMeshLineResult(line, false) + val safeY = safeAxisBounds(minY, maxY, bedSizeY) ?: return BedMeshLineResult(line, false) if (safeX.first == minX && safeX.second == maxX && safeY.first == minY && safeY.second == maxY ) { @@ -176,12 +182,16 @@ object GcodeToolMapper { return BedMeshLineResult(line.replaceRange(match.range, replacement), true) } - private fun safeAxisBounds(rawMin: Double, rawMax: Double): Pair? { + private fun safeAxisBounds(rawMin: Double, rawMax: Double, bedSize: Float): Pair? { if (!rawMin.isFinite() || !rawMax.isFinite() || rawMax <= rawMin) return null - val safeRange = U1_BED_MESH_MAX - U1_BED_MESH_MIN - val span = (rawMax - rawMin).coerceIn(U1_BED_MESH_MIN_SPAN, safeRange) - val min = rawMin.coerceIn(U1_BED_MESH_MIN, U1_BED_MESH_MAX - span) + val axisMin = BED_MESH_INSET + val axisMax = bedSize.toDouble() - BED_MESH_INSET + if (axisMax <= axisMin) return null + + val safeRange = axisMax - axisMin + val span = (rawMax - rawMin).coerceIn(BED_MESH_MIN_SPAN.coerceAtMost(safeRange), safeRange) + val min = rawMin.coerceIn(axisMin, axisMax - span) return min to (min + span) } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixGcodePreviewActivity.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixGcodePreviewActivity.kt index 5a713a9..cc9c919 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixGcodePreviewActivity.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixGcodePreviewActivity.kt @@ -20,6 +20,8 @@ import org.json.JSONObject import org.crabcore.u1control.MainActivity import com.u1.slicer.gcode.GcodeParser import com.u1.slicer.gcode.ParsedGcode +import com.u1.slicer.viewer.BedProfile +import com.u1.slicer.viewer.MachineProfile import com.u1.slicer.viewer.GcodeViewerView import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -35,6 +37,10 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +/** Bambu upload/start is owned by the RN send flow, not this Moonraker preview. */ +internal fun usesHelixBambuSend(machineProfile: MachineProfile): Boolean = + machineProfile.sliceProfileAsset?.lowercase() in setOf("bambu_p1s.json", "bambu_a1.json") + /** * Native 3D G-code toolpath preview — plain-views port of the reference app's * Compose GcodeViewer3DScreen. Shows sliced toolpaths as GPU-instanced ribbons @@ -66,9 +72,16 @@ class HelixGcodePreviewActivity : Activity() { private var initialTool: Int = 0 private var loadedToolMask: Int = -1 private var usedToolMask: Int = -1 + /** Build volume of the printer this gcode is headed for; see the model preview. */ + private var bedProfile: BedProfile = BedProfile.U1 + /** Full machine profile — also gates the PAXX-only print preferences below. */ + private var machineProfile: MachineProfile = MachineProfile.U1 private var prefFlowCal = false private var prefTimelapse = false private var prefAutoLevel = false + private var prefIfs = false + private var prefAiMonitoring = true + private var prefAiSensitivity = AiDetectionSensitivity.LOW // Print-dialog tool→slot mapping: index = the tool the slicer used, value = // the physical U1 slot the user picked for it. Identity until changed. @@ -106,6 +119,12 @@ class HelixGcodePreviewActivity : Activity() { initialTool = intent.getIntExtra(EXTRA_INITIAL_TOOL, 0).coerceIn(0, 3) loadedToolMask = intent.getIntExtra(EXTRA_LOADED_TOOL_MASK, -1) usedToolMask = intent.getIntExtra(EXTRA_USED_TOOL_MASK, -1) + // The extra carries a MachineProfile wrapper, not a bare bed. Parsing it as + // a bed reads no size keys and yields a plateless 270 grid. + machineProfile = MachineProfile.fromJson(intent.getStringExtra(EXTRA_BED_PROFILE)) + bedProfile = machineProfile.bed + prefAiMonitoring = PreprocessPreferenceStore.aiMonitoringEnabled(this) + prefAiSensitivity = PreprocessPreferenceStore.aiDetectionSensitivity(this) val title = intent.getStringExtra(EXTRA_TITLE) ?.takeIf { it.isNotBlank() } ?: "3D G-code View" @@ -281,6 +300,7 @@ class HelixGcodePreviewActivity : Activity() { // Send-to-printer bar shown under the toolpath view. Uploads the sliced G-code // straight to the connected Moonraker (passed in from the RN app). private fun buildSendBar(): View { + val bambuSend = usesHelixBambuSend(machineProfile) val bar = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setBackgroundColor(Color.rgb(18, 21, 24)) @@ -289,11 +309,15 @@ class HelixGcodePreviewActivity : Activity() { // Hidden until a send actually starts — no idle chatter above the buttons. sendStatus = TextView(this).apply { - text = if (moonrakerUrl.isBlank()) "No printer connected in Helix." else "" + text = when { + bambuSend -> "Bambu print ready for LAN upload." + moonrakerUrl.isBlank() -> "No printer connected in Helix." + else -> "" + } textSize = 12f setTextColor(Color.rgb(160, 170, 180)) setPadding(0, 0, 0, dp(8)) - visibility = if (moonrakerUrl.isBlank()) View.VISIBLE else View.GONE + visibility = if (text.isBlank()) View.GONE else View.VISIBLE } bar.addView(sendStatus) @@ -319,11 +343,21 @@ class HelixGcodePreviewActivity : Activity() { val enabled = moonrakerUrl.isNotBlank() || HelixPrinterStore.read(this).isNotEmpty() // Save works with no printer at all — it's the manual-upload escape hatch. row.addView(pill("Save", false) { saveGcode() }, - LinearLayout.LayoutParams(0, dp(48), 0.8f).apply { setMargins(0, 0, dp(5), 0) }) - row.addView(pill("Upload", false) { if (enabled) sendToPrinter(false) }.apply { alpha = if (enabled) 1f else 0.4f }, - LinearLayout.LayoutParams(0, dp(48), 1f).apply { setMargins(dp(5), 0, dp(5), 0) }) - row.addView(pill("Upload & Print", true) { if (enabled) showPrintPreprocessDialog() }.apply { alpha = if (enabled) 1f else 0.4f }, - LinearLayout.LayoutParams(0, dp(48), 1.6f).apply { setMargins(dp(5), 0, 0, 0) }) + LinearLayout.LayoutParams(0, dp(48), 0.8f).apply { + setMargins(0, 0, dp(5), 0) + }) + // These actions speak Moonraker HTTP. Bambu's verified FTPS + MQTT path is + // in the Helix Slice screen, so showing these here would either claim the + // live printer is offline or send the wrong protocol to it. + if (bambuSend) { + row.addView(pill("Upload & Print", true) { returnToHelixForBambuSend() }, + LinearLayout.LayoutParams(0, dp(48), 1.6f).apply { setMargins(dp(5), 0, 0, 0) }) + } else { + row.addView(pill("Upload", false) { if (enabled) sendToPrinter(false) }.apply { alpha = if (enabled) 1f else 0.4f }, + LinearLayout.LayoutParams(0, dp(48), 1f).apply { setMargins(dp(5), 0, dp(5), 0) }) + row.addView(pill("Upload & Print", true) { if (enabled) showPrintPreprocessDialog() }.apply { alpha = if (enabled) 1f else 0.4f }, + LinearLayout.LayoutParams(0, dp(48), 1.6f).apply { setMargins(dp(5), 0, 0, 0) }) + } bar.addView(row) return bar } @@ -357,9 +391,17 @@ class HelixGcodePreviewActivity : Activity() { .ifEmpty { listOf(initialTool.coerceIn(0, 3)) } val initialPrefs = mutableSetOf().apply { + if (prefAiMonitoring && machineProfile.supportsPrintPreferences) { + add(PreprocessRouting.Pref.AI_MONITORING) + } if (prefAutoLevel) add(PreprocessRouting.Pref.AUTO_LEVEL) if (prefFlowCal) add(PreprocessRouting.Pref.FLOW_CAL) if (prefTimelapse) add(PreprocessRouting.Pref.TIMELAPSE) + // RN defaults IFS on (slicer.tsx printPrefs): a material-station machine + // feeds from the station unless the operator opts out of it. + if (prefIfs || machineProfile.printPrefs.contains(PreprocessRouting.Pref.IFS.key)) { + add(PreprocessRouting.Pref.IFS) + } } val config = HelixPreprocessSheet.Config( @@ -368,6 +410,14 @@ class HelixGcodePreviewActivity : Activity() { estGrams = LastSliceStore.estimatedFilamentGrams.toFloat(), layers = LastSliceStore.totalLayers, thumbnail = runCatching { GcodeThumbnailReader.readBitmap(gcodePath) }.getOrNull(), + // The RN layer decides which toggles this machine can honour, so the + // sheet and the JS dialog cannot drift apart again. + offeredPrefs = PreprocessRouting.offeredPrefs( + machineProfile.printPrefs, + supportsAiMonitoring = machineProfile.supportsPrintPreferences, + ), + // Same for what the machine calls its feeds — Lane 1–4 vs T0–T3. + laneNaming = machineProfile.laneNaming, lanes = lanes, required = required, perToolGrams = parsePerToolGrams(), @@ -402,9 +452,50 @@ class HelixGcodePreviewActivity : Activity() { prefAutoLevel = prefs.contains(PreprocessRouting.Pref.AUTO_LEVEL) prefFlowCal = prefs.contains(PreprocessRouting.Pref.FLOW_CAL) prefTimelapse = prefs.contains(PreprocessRouting.Pref.TIMELAPSE) + prefIfs = prefs.contains(PreprocessRouting.Pref.IFS) + prefAiMonitoring = prefs.contains(PreprocessRouting.Pref.AI_MONITORING) + if (machineProfile.supportsPrintPreferences) { + PreprocessPreferenceStore.setAiMonitoringEnabled(this, prefAiMonitoring) + } + sendToPrinter(true) } + /** + * Waits briefly for the printer's material prompt and answers it with the + * lanes chosen on the sheet. Silent when none appears — most machines never + * raise one, and a print that started cleanly needs no confirmation. + */ + private fun answerMaterialPrompt(client: OkHttpClient, base: String) { + val toolToSlot = toolSlotMap.withIndex().associate { (tool, lane) -> tool to lane } + val deadline = System.currentTimeMillis() + PROMPT_WAIT_MS + while (System.currentTimeMillis() < deadline) { + val body = runCatching { + client.newCall( + Request.Builder().url("$base/server/gcode_store?count=60").get().build(), + ).execute().use { resp -> if (resp.isSuccessful) resp.body?.string().orEmpty() else "" } + }.getOrDefault("") + + val answer = ZmodPrintPrompt.answerFor( + ZmodPrintPrompt.messagesFromStore(body), + uploadName, + toolToSlot, + prefAutoLevel, + ) + if (answer != null) { + val enc = URLEncoder.encode(answer, "UTF-8") + runCatching { + client.newCall( + Request.Builder().url("$base/printer/gcode/script?script=$enc") + .post("".toRequestBody(null)).build(), + ).execute().close() + } + return + } + Thread.sleep(PROMPT_POLL_MS) + } + } + private fun parseHex(hex: String): Int = try { Color.parseColor(if (hex.startsWith("#")) hex else "#$hex") } catch (_: Throwable) { @@ -548,6 +639,8 @@ class HelixGcodePreviewActivity : Activity() { val requestedTimelapse = alsoPrint && prefTimelapse val requestedAutoLevel = alsoPrint && prefAutoLevel val requestedFlowCalibration = alsoPrint && prefFlowCal + val requestedAiMonitoring = alsoPrint && prefAiMonitoring + val requestedAiSensitivity = prefAiSensitivity val requestedPhysicalExtruders = physicalUsedExtruders() sending = true reportProgress(if (requestedTimelapse) "Preparing timelapse..." else "Uploading $uploadName...", 0.02f) @@ -602,22 +695,49 @@ class HelixGcodePreviewActivity : Activity() { if (!resp.isSuccessful) throw IllegalStateException("Upload HTTP ${resp.code}") } if (alsoPrint) { - reportProgress("Applying print preferences...", 0.9f) - applyPrintPreferences( - client, - base, - requestedAutoLevel, - requestedTimelapse, - requestedFlowCalibration, - requestedPhysicalExtruders, - ) - reportProgress("Starting $uploadName...", 0.96f) - val enc = URLEncoder.encode(uploadName, "UTF-8") - client.newCall( - Request.Builder().url("$base/printer/print/start?filename=$enc") - .post("".toRequestBody(null)).build(), - ).execute().use { resp -> - if (!resp.isSuccessful) throw IllegalStateException("Print start HTTP ${resp.code}") + // IFS off on a material-station machine: zmod's per-print external-spool + // path. SET_ZCOLOR SILENT=2 raises no material prompt at all, so there + // is nothing to answer — applyPrintPreferences already no-ops here + // (supportsPrintPreferences=false), and /printer/print/start is what + // would raise the prompt, so it is skipped too. + val ifsOff = machineProfile.printPrefs.contains(PreprocessRouting.Pref.IFS.key) && !prefIfs + if (ifsOff) { + reportProgress("Starting $uploadName (external spool)...", 0.96f) + val script = URLEncoder.encode( + ZmodPrintPrompt.ifsOffPrintGcode(uploadName, prefAutoLevel), + "UTF-8", + ) + moonrakerJson( + client, + Request.Builder().url("$base/printer/gcode/script?script=$script") + .post("".toRequestBody(null)).build(), + "Print start", + ) + } else { + reportProgress("Applying AI monitoring and print preferences...", 0.9f) + applyPrintPreferences( + client, + base, + requestedAutoLevel, + requestedTimelapse, + requestedFlowCalibration, + requestedAiMonitoring, + requestedAiSensitivity, + requestedPhysicalExtruders, + ) + reportProgress("Starting $uploadName...", 0.96f) + val enc = URLEncoder.encode(uploadName, "UTF-8") + client.newCall( + Request.Builder().url("$base/printer/print/start?filename=$enc") + .post("".toRequestBody(null)).build(), + ).execute().use { resp -> + if (!resp.isSuccessful) throw IllegalStateException("Print start HTTP ${resp.code}") + } + // zmod answers a print start by asking which lane feeds each tool — + // a question this sheet just asked. Answer it here rather than waiting + // for the RN layer, which is not even in front yet. + reportProgress("Confirming materials...", 0.98f) + answerMaterialPrompt(client, base) } } reportProgress(if (alsoPrint) "Sent — printing $uploadName" else "Uploaded $uploadName", 1f) @@ -640,6 +760,8 @@ class HelixGcodePreviewActivity : Activity() { autoLevel: Boolean, timelapse: Boolean, flowCalibration: Boolean, + aiMonitoring: Boolean, + aiSensitivity: AiDetectionSensitivity, usedExtruders: List, ) { val state = moonrakerJson( @@ -654,7 +776,13 @@ class HelixGcodePreviewActivity : Activity() { throw IllegalStateException("Printer is already $state") } - val script = "SET_MAIN_STATE MAIN_STATE=IDLE\n" + + // PAXX/U1 firmware only. Other machines have neither SET_PRINT_PREFERENCES + // nor the print_task_config object, so this errored and then failed its own + // read-back with "Printer rejected the selected print preferences". + if (!machineProfile.supportsPrintPreferences) return + + val script = PreprocessRouting.aiMonitoringCommand(aiMonitoring, aiSensitivity) + "\n" + + "SET_MAIN_STATE MAIN_STATE=IDLE\n" + "SET_PRINT_USED_EXTRUDERS EXTRUDERS=${usedExtruders.joinToString(",")}\n" + "SET_PRINT_PREFERENCES BED_LEVEL=${if (autoLevel) 1 else 0} " + "TIME_LAPSE_CAMERA=${if (timelapse) 1 else 0} " + @@ -728,6 +856,16 @@ class HelixGcodePreviewActivity : Activity() { } } + /** Returns to the still-mounted Slice tab, which owns Bambu FTPS + MQTT. */ + private fun returnToHelixForBambuSend() { + LastSliceStore.requestBambuSend() + val helixIntent = Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + startActivity(helixIntent) + finish() + } + // Dialog remap: rewrite tool changes onto the slots the user picked. private fun remappedGcodeFile(): File { val identity = toolSlotMap.withIndex().all { (i, v) -> i == v } @@ -828,6 +966,8 @@ class HelixGcodePreviewActivity : Activity() { FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT, ) + // Set before attach so the first frame already has the right bed. + it.renderer.bedProfile = bedProfile } viewer = view view.setExtruderColors(resolveSlotColors()) @@ -1024,6 +1164,10 @@ class HelixGcodePreviewActivity : Activity() { const val EXTRA_INITIAL_TOOL = "initialTool" const val EXTRA_LOADED_TOOL_MASK = "loadedToolMask" const val EXTRA_USED_TOOL_MASK = "usedToolMask" + const val EXTRA_BED_PROFILE = "bedProfile" + /** How long to wait for a material prompt after starting a print. */ + private const val PROMPT_WAIT_MS = 12_000L + private const val PROMPT_POLL_MS = 600L const val EXTRA_MODEL_PATH = "modelPath" const val EXTRA_SLOT_COLORS = "slotColors" private const val REQ_SAVE_GCODE = 4471 diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixIcons.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixIcons.kt index 5daff02..7fd593a 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixIcons.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixIcons.kt @@ -33,6 +33,7 @@ object HelixIcons { const val PALETTE_SWATCH = 0xF08B5 const val GRID = 0xF02C1 const val VIDEO = 0xF0BDC + const val ROBOT_OUTLINE = 0xF167A const val HELP_CIRCLE = 0xF0625 const val ACCESS_POINT = 0xF0002 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 2e7584c..2b13729 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 @@ -25,6 +25,8 @@ import com.u1.slicer.data.WipeTowerDepthEstimator import com.u1.slicer.model.CopyArrangeCalculator import com.u1.slicer.viewer.MeshData import com.u1.slicer.viewer.ModelRenderer +import com.u1.slicer.viewer.BedProfile +import com.u1.slicer.viewer.MachineProfile import com.u1.slicer.viewer.ModelViewerView import com.u1.slicer.viewer.StlParser import org.json.JSONObject @@ -54,6 +56,27 @@ class HelixModelPreviewActivity : Activity() { private var native: NativeLibrary? = null private var modelPath: String = "" private var interactive = false + /** + * Build volume of the printer this model is headed for, resolved by the RN + * layer from the active printer. Read in onCreate so it is set on the renderer + * before the GL surface exists. + */ + private var bedProfile: BedProfile = BedProfile.U1 + /** Bed + slice profile of the target machine, from the same intent extra. */ + private var machineProfile: MachineProfile = MachineProfile.U1 + /** + * The raw payload, kept so it can be handed on verbatim to the G-code preview + * this screen opens after slicing. Without it that screen re-defaults to the + * U1 and the plate visibly changes machine mid-flow. + */ + private var machineProfileJson: String? = null + + // Placement bounds. These were a single BED_SIZE=270 constant, which let you + // drag a model straight off a 220mm AD5X plate. CopyArrangeCalculator's + // auto-arrange still takes one dimension, which is fine while every bed we + // support is square — revisit if a rectangular bed (H2D) lands. + private val bedSizeX: Float get() = bedProfile.sizeX + private val bedSizeY: Float get() = bedProfile.sizeY // Theme accent from the RN app (defaults to Helix blue) — used for the Slice // button + tile icon tint so the native screen follows the user's theme. private var accentColor: Int = 0xFF2196F3.toInt() @@ -115,6 +138,16 @@ class HelixModelPreviewActivity : Activity() { private var towerX = 0f private var towerY = 0f private var towerDepth = 20f + // Footprint width must match what the slice actually uses, or the tower the + // user positions here is not the tower that gets printed. + private var towerWidth = Project3mfSettings.DEFAULT_PRIME_TOWER_WIDTH_MM + private var towerWidthSourcePath: String? = null + + // The opened project's own settings, and the printer profile to read them + // against. Both are absent for an STL, and the defaults are absent for any + // printer whose bundled profile carries no print settings. + private var projectSettings = Project3mfSettings.NONE + private var profileDefaults = Project3mfSettings.NONE override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -127,11 +160,20 @@ class HelixModelPreviewActivity : Activity() { loadedToolMask = intent.getIntExtra(EXTRA_LOADED_TOOL_MASK, -1) autoArrangeOnLoad = intent.getBooleanExtra(EXTRA_AUTO_ARRANGE, false) materialProfilesJson = intent.getStringExtra(EXTRA_MATERIAL_PROFILES) + machineProfileJson = intent.getStringExtra(EXTRA_BED_PROFILE) + machineProfile = MachineProfile.fromJson(machineProfileJson) + bedProfile = machineProfile.bed val title = intent.getStringExtra(EXTRA_TITLE) ?.takeIf { it.isNotBlank() } ?: File(modelPath).name.ifBlank { "3D Preview" } displayTitle = title + // What the project itself asks for, read before the toolbar is built so the + // settings tiles open showing the file's values rather than Helix's. + projectSettings = Project3mfSettings.read(modelPath) + profileDefaults = Project3mfSettings.readProfileAsset(this, machineProfile.sliceProfileAsset) + sliceSettings = HelixSliceSettings.seededFrom(projectSettings) + val rootView = buildLayout(title) setContentView(rootView) EdgeInsets.apply(rootView) @@ -309,6 +351,8 @@ class HelixModelPreviewActivity : Activity() { addTool(buildInfillTile()) addTool(buildIroningTile()) addTool(buildBrimTile()) + // Only a project file has settings to report on. + if (projectSettings.isPresent) addTool(buildProjectSettingsTile()) refreshDeleteTile() refreshUndoRedoTiles() @@ -427,7 +471,7 @@ class HelixModelPreviewActivity : Activity() { val liveBoxes = lib.getObjectBoundingBoxes() val incoming = lib.nativeGetObjectWorldAABBMins() if (liveBoxes.isNotEmpty()) { - val result = CopyArrangeCalculator.autoArrange(liveBoxes, null, incoming, BED_SIZE) + val result = CopyArrangeCalculator.autoArrange(liveBoxes, null, incoming, bedSizeX) lib.setObjectPositions(result.positions) PrepareSession.setPositions(result.positions) } @@ -527,7 +571,7 @@ class HelixModelPreviewActivity : Activity() { // the grid follows the object's current footprint). if (count == 1 && copyCount > 1 && newBoxes.size >= 2) { positions = CopyArrangeCalculator.calculate( - newBoxes[0], newBoxes[1], copyCount, BED_SIZE, BED_SIZE, + newBoxes[0], newBoxes[1], copyCount, bedSizeX, bedSizeY, ) PrepareSession.setCopies(copyCount, positions) } @@ -560,14 +604,15 @@ class HelixModelPreviewActivity : Activity() { renderer.wipeTower = null return } + refreshTowerWidth() towerDepth = WipeTowerDepthEstimator.estimateDepth(m.sizeZ) val dragged = PrepareSession.towerPosition if (dragged != null) { - towerX = dragged.first.coerceIn(0f, BED_SIZE - TOWER_WIDTH) - towerY = dragged.second.coerceIn(0f, BED_SIZE - towerDepth) + towerX = dragged.first.coerceIn(0f, bedSizeX - towerWidth) + towerY = dragged.second.coerceIn(0f, bedSizeY - towerDepth) } else { val (tx, ty) = CopyArrangeCalculator.computeWipeTowerPositionForObjects( - positions, boxes, TOWER_WIDTH, towerDepth, BED_SIZE, BED_SIZE, + positions, boxes, towerWidth, towerDepth, bedSizeX, bedSizeY, ) towerX = tx towerY = ty @@ -578,10 +623,18 @@ class HelixModelPreviewActivity : Activity() { val bandColors = towerBandColors(m) val towerHeight = (m.sizeZ * 0.35f).coerceIn(12f, 40f) renderer.wipeTower = ModelRenderer.WipeTowerInfo( - towerX, towerY, TOWER_WIDTH, towerDepth, bandColors, towerHeight, + towerX, towerY, towerWidth, towerDepth, bandColors, towerHeight, ) } + /** The project's own `prime_tower_width`, re-read whenever the model file changes. */ + private fun refreshTowerWidth() { + if (towerWidthSourcePath == modelPath) return + towerWidthSourcePath = modelPath + towerWidth = Project3mfSettings.read(modelPath).primeTowerWidth + ?: Project3mfSettings.DEFAULT_PRIME_TOWER_WIDTH_MM + } + private fun needsPrimeTower(mesh: MeshData): Boolean { val used = mesh.usedExtruderSlots() if (used.size >= 2) return true @@ -614,7 +667,7 @@ class HelixModelPreviewActivity : Activity() { row.visibility = View.GONE return } - val maxCopies = CopyArrangeCalculator.maxCopies(boxes[0], boxes[1], BED_SIZE, BED_SIZE) + val maxCopies = CopyArrangeCalculator.maxCopies(boxes[0], boxes[1], bedSizeX, bedSizeY) copiesSeek?.max = (maxCopies - 1).coerceAtLeast(0) copiesSeek?.progress = copyCount - 1 // Dead slider with no explanation reads as broken — say why it won't move. @@ -636,7 +689,7 @@ class HelixModelPreviewActivity : Activity() { copiesLabel?.text = "Copies: $copyCount" val view = viewer ?: return positions = if (copyCount > 1) { - CopyArrangeCalculator.calculate(boxes[0], boxes[1], copyCount, BED_SIZE, BED_SIZE) + CopyArrangeCalculator.calculate(boxes[0], boxes[1], copyCount, bedSizeX, bedSizeY) } else { basePositions.copyOf() } @@ -655,6 +708,10 @@ class HelixModelPreviewActivity : Activity() { FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT, ) + // Before the view is attached, so the bed mesh, grid and camera are built + // for the right machine on the very first frame — ModelRenderer reads this + // in onSurfaceCreated and does not rebuild afterwards. + it.renderer.bedProfile = bedProfile } viewer = view @@ -679,16 +736,16 @@ class HelixModelPreviewActivity : Activity() { val bi = if (objectCount == 1) 0 else index val sizeX = boxes.getOrElse(bi * 3) { 0f } val sizeY = boxes.getOrElse(bi * 3 + 1) { 0f } - positions[index * 2] = (positions[index * 2] + dx).coerceIn(0f, BED_SIZE - sizeX) - positions[index * 2 + 1] = (positions[index * 2 + 1] + dy).coerceIn(0f, BED_SIZE - sizeY) + positions[index * 2] = (positions[index * 2] + dx).coerceIn(0f, bedSizeX - sizeX) + positions[index * 2 + 1] = (positions[index * 2 + 1] + dy).coerceIn(0f, bedSizeY - sizeY) view.renderer.instancePositions = positions.copyOf() } else if (index == instanceCount && towerShown) { // Wipe tower drag (hitTest reports index == instance count for the tower). - towerX = (towerX + dx).coerceIn(0f, BED_SIZE - TOWER_WIDTH) - towerY = (towerY + dy).coerceIn(0f, BED_SIZE - towerDepth) + towerX = (towerX + dx).coerceIn(0f, bedSizeX - towerWidth) + towerY = (towerY + dy).coerceIn(0f, bedSizeY - towerDepth) view.renderer.wipeTower = ModelRenderer.WipeTowerInfo( - towerX, towerY, TOWER_WIDTH, towerDepth, + towerX, towerY, towerWidth, towerDepth, mesh?.let { towerBandColors(it) } ?: emptyList(), ((mesh?.sizeZ ?: 30f) * 0.35f).coerceIn(12f, 40f), ) @@ -896,10 +953,97 @@ class HelixModelPreviewActivity : Activity() { HelixInfillSettingsUi.show(this, accentColor, sliceSettings) { updated -> sliceSettings.infillDensity = updated.infillDensity sliceSettings.infillPattern = updated.infillPattern + sliceSettings.chosen = updated.chosen refreshSettingsTiles() } } + /** + * Lists what the opened project specifies, marking the values that differ + * from the printer profile's own. + * + * Without this the prepare screen showed only the four settings it can edit, + * so a file that changed its layer height, wall count or speeds looked + * identical to one that had not. + */ + private fun buildProjectSettingsTile(): View = buildSettingsTile( + R.drawable.ic_tool_settings, + "Project", + isOn = { projectSettings.summarize(profileDefaults).any { it.differs } }, + ) { + showProjectSettingsSheet() + } + + private fun showProjectSettingsSheet() { + val rows = projectSettings.summarize(profileDefaults) + val content = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + + if (rows.isEmpty()) { + content.addView( + TextView(this).apply { + text = "This file carries no project settings, so the printer profile's own " + + "values are used throughout." + textSize = 13f + setTextColor(HelixAppTheme.SUBTEXT) + }, + ) + } else { + val changed = rows.count { it.differs } + content.addView( + TextView(this).apply { + text = when { + profileDefaults.isPresent && changed > 0 -> + "$changed setting${if (changed == 1) "" else "s"} differ from the printer profile." + profileDefaults.isPresent -> + "Everything here matches the printer profile." + // No bundled defaults to compare against; say so rather than + // implying the project changed nothing. + else -> "What this project asks for. Helix has no stored defaults " + + "for this printer to compare against." + } + textSize = 12f + setTextColor(HelixAppTheme.SUBTEXT) + setPadding(0, 0, 0, dp(10)) + }, + ) + rows.forEach { row -> content.addView(projectSettingRow(row)) } + } + + HelixThemedDialog.showFloatingCenter( + activity = this, + accent = accentColor, + title = "Project settings", + iconRes = R.drawable.ic_tool_settings, + content = content, + secondaryLabel = "", + primaryLabel = "Close", + onPrimary = {}, + ) + } + + private fun projectSettingRow(row: ProjectSettingRow): View { + val label = TextView(this).apply { + text = row.label + textSize = 13f + setTextColor(HelixAppTheme.SUBTEXT) + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + } + val value = TextView(this).apply { + // The profile's value is worth showing only where it disagrees. + text = if (row.differs) "${row.value} (was ${row.default})" else row.value + textSize = 13f + gravity = Gravity.END + setTextColor(if (row.differs) accentColor else Color.rgb(232, 237, 243)) + } + return LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, dp(5), 0, dp(5)) + addView(label) + addView(value) + } + } + private fun buildIroningTile(): View = buildSettingsTile( R.drawable.ic_tool_iron, "Ironing", @@ -911,6 +1055,7 @@ class HelixModelPreviewActivity : Activity() { sliceSettings.ironingFlow = updated.ironingFlow sliceSettings.ironingSpacing = updated.ironingSpacing sliceSettings.ironingSpeed = updated.ironingSpeed + sliceSettings.chosen = updated.chosen refreshSettingsTiles() } } @@ -970,6 +1115,7 @@ class HelixModelPreviewActivity : Activity() { private fun openBrimSettings() { HelixBrimSettingsUi.show(this, accentColor, sliceSettings.brimWidthMm) { width -> + sliceSettings = sliceSettings.choosing(SliceSettingGroup.BRIM) sliceSettings.brimWidthMm = width refreshBrimTile() } @@ -1140,7 +1286,7 @@ class HelixModelPreviewActivity : Activity() { val liveBoxes = lib.getObjectBoundingBoxes() val incoming = lib.nativeGetObjectWorldAABBMins() if (liveBoxes.isNotEmpty()) { - val result = CopyArrangeCalculator.autoArrange(liveBoxes, null, incoming, BED_SIZE) + val result = CopyArrangeCalculator.autoArrange(liveBoxes, null, incoming, bedSizeX) lib.setObjectPositions(result.positions) PrepareSession.setPositions(result.positions) } @@ -1212,7 +1358,7 @@ class HelixModelPreviewActivity : Activity() { val existing = if (positions.isNotEmpty()) positions else runCatching { lib.nativeGetObjectWorldAABBMins() }.getOrDefault(FloatArray(0)) if (newBoxes.size >= 3 && existing.isNotEmpty()) { - val placed = CopyArrangeCalculator.placeAdditionalObject(existing, newBoxes, BED_SIZE) + val placed = CopyArrangeCalculator.placeAdditionalObject(existing, newBoxes, bedSizeX) lib.setObjectPositions(placed) PrepareSession.setPositions(placed) } @@ -1343,6 +1489,8 @@ class HelixModelPreviewActivity : Activity() { initialTool = initialTool, sliceSettings = sliceSettings, materialProfilesJson = materialProfilesJson, + usedExtrudersHint = mesh?.usedExtruderSlots(), + machine = machineProfile, ) val result = outcome.result runOnUiThread { @@ -1368,6 +1516,9 @@ class HelixModelPreviewActivity : Activity() { putExtra(HelixGcodePreviewActivity.EXTRA_LOADED_TOOL_MASK, loadedToolMask) putExtra(HelixGcodePreviewActivity.EXTRA_USED_TOOL_MASK, outcome.usedToolMask) putExtra(HelixGcodePreviewActivity.EXTRA_MODEL_PATH, modelPath) + machineProfileJson?.let { + putExtra(HelixGcodePreviewActivity.EXTRA_BED_PROFILE, it) + } }, ) } @@ -1438,14 +1589,14 @@ class HelixModelPreviewActivity : Activity() { // an object under the printed tower. val reserved = if (towerShown) floatArrayOf( towerX - 5f, towerY - 5f, - towerX + TOWER_WIDTH + 5f, towerY + towerDepth + 5f, + towerX + towerWidth + 5f, towerY + towerDepth + 5f, ) else null runEdit { lib -> val liveBoxes = runCatching { lib.getObjectBoundingBoxes() }.getOrDefault(FloatArray(0)) val incoming = if (positions.isNotEmpty()) positions else runCatching { lib.nativeGetObjectWorldAABBMins() }.getOrDefault(FloatArray(0)) if (liveBoxes.isEmpty()) return@runEdit false - val result = CopyArrangeCalculator.autoArrange(liveBoxes, reserved, incoming, BED_SIZE) + val result = CopyArrangeCalculator.autoArrange(liveBoxes, reserved, incoming, bedSizeX) lib.setObjectPositions(result.positions) PrepareSession.setPositions(result.positions) if (result.overflowCount > 0) { @@ -1573,10 +1724,6 @@ class HelixModelPreviewActivity : Activity() { const val EXTRA_LOADED_TOOL_MASK = "loadedToolMask" const val EXTRA_AUTO_ARRANGE = "autoArrange" const val EXTRA_MATERIAL_PROFILES = "materialProfiles" - private const val BED_SIZE = 270f - - // Orca default prime_tower_width (the lab SliceConfig's 60mm default is the - // engine-side fallback; the preview mirrors what profiles actually use). - private const val TOWER_WIDTH = 35f + const val EXTRA_BED_PROFILE = "bedProfile" } } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPreprocessSheet.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPreprocessSheet.kt index 41e8412..dabda43 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPreprocessSheet.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixPreprocessSheet.kt @@ -63,6 +63,13 @@ class HelixPreprocessSheet( val printers: List, val activePrinterUrl: String, val initialPrefs: Set, + /** Toggles this machine can honour; see PreprocessRouting.offeredPrefs. */ + val offeredPrefs: List = Pref.values().toList(), + /** + * How the machine names its feeds — "lane" for AD5X/Bambu, "tool" for the + * U1. From MachineProfile.laneNaming; see PreprocessRouting.laneWord. + */ + val laneNaming: String = "tool", val sendLabel: String = "Hold to start", /** Reports reachability, busy state and bed mesh for a printer, on the UI thread. */ val probePrinter: (String, (PrinterState) -> Unit) -> Unit, @@ -205,6 +212,9 @@ class HelixPreprocessSheet( private val required: List = config.required.distinct().sorted().ifEmpty { listOf(0) } + /** The machine's word for a feed — "lane" on the U1, "Lane" on AD5X/Bambu. */ + private val word = PreprocessRouting.laneWord(config.laneNaming) + private fun tools(): List = PreprocessRouting.buildTools(required, displayLanes, manual, config.perToolGrams) @@ -214,6 +224,7 @@ class HelixPreprocessSheet( printerName = printerName(printerUrl), tools = tools, lanes = displayLanes, + laneNaming = config.laneNaming, ) /** Saved printer matching [url] — exact first, then by host, since LAN and @@ -539,8 +550,10 @@ class HelixPreprocessSheet( private fun cleanRow(rerouted: List): View { val message = if (rerouted.isNotEmpty()) { - "Routed around empty lanes — " + - rerouted.joinToString(", ") { "T${it.fileTool} on lane ${it.assigned + 1}" } + "Routed around empty ${word}s — " + + rerouted.joinToString(", ") { + "${PreprocessRouting.toolLabel(it.fileTool, config.laneNaming)} on $word ${it.assigned + 1}" + } } else { "Lanes loaded, printer idle" } @@ -566,7 +579,7 @@ class HelixPreprocessSheet( tools.forEach { chips.addSpaced(laneChip(it, 26), 4) } head.addSpaced(chips, 0) head.addSpaced( - text("${tools.size} ${if (tools.size == 1) "lane" else "lanes"}", 12.5f, P.TEXT, bold = true), + text("${tools.size} ${if (tools.size == 1) word else "${word}s"}", 12.5f, P.TEXT, bold = true), 10, ) head.addSpaced(weightBar(tools), 10, LinearLayout.LayoutParams(0, dp(6), 1f)) @@ -618,14 +631,14 @@ class HelixPreprocessSheet( line.addSpaced(laneChip(tool, 34), 0) val heading = PreprocessRouting.laneLabel(tool.lane) + when (tool.source) { - RouteSource.MANUAL -> " → lane ${tool.assigned + 1}" - RouteSource.AUTO -> " auto → lane ${tool.assigned + 1}" + RouteSource.MANUAL -> " → $word ${tool.assigned + 1}" + RouteSource.AUTO -> " auto → $word ${tool.assigned + 1}" RouteSource.IDENTITY -> "" } val labels = column() labels.addSpaced(text(heading, 14f, P.TEXT, bold = true), 0) labels.addSpaced( - text("lane ${tool.assigned + 1} · ${PreprocessRouting.laneDetail(tool.lane)}", 11.5f, P.DIM), + text("$word ${tool.assigned + 1} · ${PreprocessRouting.laneDetail(tool.lane)}", 11.5f, P.DIM), 2, ) line.addSpaced(labels, 12, weighted()) @@ -639,7 +652,7 @@ class HelixPreprocessSheet( } private fun optionsHead(): View { - val summary = Pref.values().filter { prefs.contains(it) } + val summary = config.offeredPrefs.filter { prefs.contains(it) } .joinToString(", ") { it.label } .ifBlank { "Print preferences" } val open = openFold == Fold.OPTIONS @@ -660,7 +673,7 @@ class HelixPreprocessSheet( private fun optionsBody(): View { val list = column().apply { setPadding(0, dp(4), 0, 0) } - Pref.values().forEach { pref -> + config.offeredPrefs.forEach { pref -> val on = prefs.contains(pref) val line = row().apply { setPadding(dp(4), dp(9), dp(4), dp(9)) @@ -709,7 +722,12 @@ class HelixPreprocessSheet( layoutParams = LinearLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) } chip.addView( - text("T${tool.fileTool}", sizeDp * 0.34f, P.TEXT, bold = true).apply { + text( + PreprocessRouting.toolChipLabel(tool.fileTool, config.laneNaming), + sizeDp * 0.34f, + P.TEXT, + bold = true, + ).apply { gravity = Gravity.CENTER }, FrameLayout.LayoutParams( @@ -1004,7 +1022,10 @@ class HelixPreprocessSheet( private fun openLaneLayer(fileTool: Int) { val assigned = tools().firstOrNull { it.fileTool == fileTool }?.assigned ?: fileTool - openLayer("Lane for T$fileTool", "Choose the physical spool that feeds this tool.") { content -> + // "Lane 1" would not fit the picker in a tool-wording sentence, so the two + // namings phrase the title differently — mirror PrintPreprocessDialog.tsx. + val title = if (config.laneNaming == "lane") "Spool for Lane ${fileTool + 1}" else "Lane for T$fileTool" + openLayer(title, "Choose the physical spool that feeds this tool.") { content -> displayLanes.forEach { lane -> val badge = FrameLayout(activity).apply { background = oval(P.BG, if (lane.isEmpty) P.alpha(P.DANGER, 0.6f) else lane.color, 2f) 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 88bf52c..9957110 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 @@ -3,6 +3,7 @@ package org.crabcore.u1control.slicing import android.content.Context import com.u1.slicer.NativeLibrary import com.u1.slicer.data.SliceConfig +import com.u1.slicer.viewer.MachineProfile import com.u1.slicer.data.SliceResult import org.json.JSONArray import org.json.JSONObject @@ -39,6 +40,13 @@ object HelixSliceRunner { val pressureAdvance: Float, ) + private data class MachineGcodeProfile( + val startGcode: String, + val endGcode: String, + val translateMarlinMachineLimits: Boolean = false, + val stripM486: Boolean = false, + ) + fun parseMaterialProfiles(json: String?): List { if (json.isNullOrBlank()) return emptyList() return runCatching { @@ -73,6 +81,8 @@ object HelixSliceRunner { sliceSettings: HelixSliceSettings = HelixSliceSettings(), materialProfilesJson: String? = null, forceExtruderCount: Int? = null, + usedExtrudersHint: List? = null, + machine: MachineProfile = MachineProfile.U1, configure: SliceConfig.() -> Unit = {}, ): Outcome { if (!slicing.compareAndSet(false, true)) throw BusyError() @@ -86,7 +96,28 @@ object HelixSliceRunner { val effectivePath = PrepareSession.paintedFileFor(path) ?.takeIf { File(it).exists() } ?: path - val loadPath = SliceSettings3mfPatcher.resolvePath(context, effectivePath, sliceSettings) + val usedExtruders = usedExtrudersHint.orEmpty().filter { it in 0..3 }.distinct() + val verifiedSingleMaterial = isSingleMaterialBambu(machine.sliceProfileAsset, usedExtruders) + val normalizedPath = if ( + verifiedSingleMaterial && + effectivePath.endsWith(".3mf", ignoreCase = true) && + usedExtruders.single() != 0 + ) { + // A Bambu project may list four installed presets while every triangle + // on this plate uses only one of them. Keep the project settings, but + // remap that lone source extruder to logical tool 0: the physical AMS + // lane is selected later by the project_file command. + val output = File(context.cacheDir, "helix_bambu_single_${File(effectivePath).name}") + PlateExtractor.remapExtruders( + File(effectivePath), + mapOf(usedExtruders.single() + 1 to 0), + output, + ).absolutePath + } else { + effectivePath + } + val loadPath = SliceSettings3mfPatcher.resolvePath(context, normalizedPath, sliceSettings) + val projectSettings = Project3mfSettings.read(loadPath) if (!lib.loadModel(loadPath)) { throw LoadError("Engine could not load model: $loadPath") } @@ -106,19 +137,49 @@ object HelixSliceRunner { // when the file declares fewer filaments than the user routed to. val filamentCount = readFilamentCount(lib) val effectiveCount = maxOf(filamentCount, forceExtruderCount ?: 1).coerceIn(1, 4) - val multicolor = effectiveCount > 1 + val multicolor = !verifiedSingleMaterial && effectiveCount > 1 - val (startGcode, endGcode) = readMachineGcode(context) + val machineGcode = readMachineProfile(context, machine.sliceProfileAsset) val selectedTool = initialTool.coerceIn(0, 3) val materialProfiles = parseMaterialProfiles(materialProfilesJson) val config = SliceConfig( - machineStartGcode = startGcode, - machineEndGcode = endGcode, + machineStartGcode = machineGcode.startGcode, + machineEndGcode = machineGcode.endGcode, + // The engine reads these over JNI to size the printable area. Left at + // the U1's 270 default they let a 220mm machine slice parts that do + // not fit its bed. + bedSizeX = machine.bed.sizeX, + bedSizeY = machine.bed.sizeY, + maxPrintHeight = machine.bed.height, ).apply { + // Default wipe-tower corner, in bed coordinates. The old 170/140 + // literals were placed for a 270mm bed and sit off a smaller one. + wipeTowerX = machine.bed.sizeX * (170f / 270f) + wipeTowerY = machine.bed.sizeY * (140f / 270f) + wipeTowerWidth = + projectSettings.primeTowerWidth ?: Project3mfSettings.DEFAULT_PRIME_TOWER_WIDTH_MM + // The project's own settings first. The engine reads them when it + // opens the 3MF, but the JNI wrapper then applies every SliceConfig + // field over the top, so anything left at a Helix default here + // silently replaces what the file asked for. Seeding them makes that + // second pass a no-op instead of a stomp. + projectSettings.applyTo(this) + // Then the user's deliberate overrides, which outrank the file. sliceSettings.applyTo(this) configure() applyMaterialProfiles(materialProfiles, selectedTool) if (multicolor) configureMultiTool(effectiveCount) else forceSingleTool(selectedTool) + // The engine applies SliceConfig over the project settings it just read + // from the 3MF, so leaving this at its false default deleted the tower + // every multicolour project asked for — while the prepare screen still + // drew one. + // + // Only a multi-tool slice can have a tower at all. Within that, the + // project's own answer decides: a file that says nothing (an STL, or a + // 3MF with no project settings) keeps the previous behaviour of no + // tower rather than being newly given one, because a U1 purges through + // its own firmware mechanism and does not want one uninvited. + wipeTowerEnabled = multicolor && projectSettings.primeTowerEnabled == true // Wipe tower position shown/dragged on the prepare screen. PrepareSession.towerPositionFor(path)?.let { (tx, ty) -> wipeTowerX = tx @@ -137,7 +198,19 @@ object HelixSliceRunner { } if (result != null && result.success && result.gcodePath.isNotBlank()) { - val bedMesh = GcodeToolMapper.clampU1BedMeshBounds(result.gcodePath) + if (machineGcode.translateMarlinMachineLimits || machineGcode.stripM486) { + val compatibility = KlipperGcodeCompatibility.apply( + result.gcodePath, + machineGcode.translateMarlinMachineLimits, + machineGcode.stripM486, + ) + check(compatibility.success) { + "Could not translate unsupported machine G-code" + } + } + val bedMesh = GcodeToolMapper.clampBedMeshBounds( + result.gcodePath, machine.bed.sizeX, machine.bed.sizeY, + ) check(bedMesh.success) { "Could not safely constrain the adaptive bed-mesh bounds" } @@ -179,22 +252,58 @@ object HelixSliceRunner { } } - // Snapmaker U1 machine start/end G-code from the bundled printer profile - // (same asset + shape as the reference app's readPrinterMachineGcode). - private fun readMachineGcode(context: Context): Pair { + // Machine start/end G-code from the bundled printer profile for [asset] + // (same shape as the reference app's readPrinterMachineGcode). + // + // A null asset means we have no verified profile for the machine, so we hand + // the engine empty strings and let it emit its own bare defaults. That is the + // right answer for an unknown printer: generic G-code may be suboptimal, but + // another machine's start G-code homes and primes in the wrong places. + private fun readMachineProfile(context: Context, asset: String?): MachineGcodeProfile { + if (asset.isNullOrBlank()) return MachineGcodeProfile("", "") return try { val json = context.assets - .open("orca_profiles/printer/snapmaker_u1.json") + .open("orca_profiles/printer/$asset") .bufferedReader().use { JSONObject(it.readText()) } - Pair( - json.optString("machine_start_gcode", ""), - json.optString("machine_end_gcode", ""), + MachineGcodeProfile( + startGcode = compatibleMachineStartGcode( + asset, + json.optString("machine_start_gcode", ""), + ), + endGcode = json.optString("machine_end_gcode", ""), + translateMarlinMachineLimits = json.optBoolean( + "helix_translate_marlin_machine_limits", + false, + ), + stripM486 = json.optBoolean("helix_strip_m486", false), ) - } catch (error: Throwable) { - "" to "" + } catch (_: Throwable) { + MachineGcodeProfile("", "") } } + /** + * Bambu changed its purge expressions in August 2025 to independent purge + * speed/temperature variables and explicitly marked the profile change as + * requiring the latest slicer. Helix's prebuilt engine predates those two + * placeholders, but supports the immediately preceding official Bambu + * expressions. The emitted commands and physical motion remain unchanged; + * only their value sources use names this engine can resolve. + */ + internal fun compatibleMachineStartGcode(asset: String, startGcode: String): String { + return when (asset) { + BAMBU_P1S_PROFILE_ASSET -> + startGcode.replace(BAMBU_NEW_PURGE_EXPRESSION, BAMBU_P1S_COMPAT_PURGE_EXPRESSION) + BAMBU_A1_PROFILE_ASSET -> startGcode + .replace(BAMBU_NEW_PURGE_EXPRESSION, BAMBU_A1_COMPAT_PURGE_EXPRESSION) + .replace(BAMBU_A1_NEW_FLUSH_TEMPERATURE, BAMBU_A1_COMPAT_FLUSH_TEMPERATURE) + else -> startGcode + } + } + + internal fun isSingleMaterialBambu(asset: String?, usedExtruders: List): Boolean = + asset in BAMBU_PROFILE_ASSETS && usedExtruders.filter { it in 0..3 }.distinct().size == 1 + /** Number of filaments declared in the loaded model's project config (>=1). */ private fun readFilamentCount(lib: NativeLibrary): Int = readFilamentColours(lib).size.coerceAtLeast(1) @@ -217,6 +326,23 @@ object HelixSliceRunner { } } + private const val BAMBU_P1S_PROFILE_ASSET = "bambu_p1s.json" + private const val BAMBU_A1_PROFILE_ASSET = "bambu_a1.json" + private val BAMBU_PROFILE_ASSETS = setOf(BAMBU_P1S_PROFILE_ASSET, BAMBU_A1_PROFILE_ASSET) + private const val BAMBU_NEW_PURGE_EXPRESSION = + "M620.1 E F{flush_volumetric_speeds[initial_no_support_extruder]/2.4053*60} " + + "T{flush_temperatures[initial_no_support_extruder]}" + private const val BAMBU_P1S_COMPAT_PURGE_EXPRESSION = + "M620.1 E F{filament_max_volumetric_speed[initial_extruder]/2.4053*60} " + + "T{nozzle_temperature_range_high[initial_extruder]}" + private const val BAMBU_A1_COMPAT_PURGE_EXPRESSION = + "M620.1 E F{filament_max_volumetric_speed[initial_no_support_extruder]/2.4053*60} " + + "T{nozzle_temperature_range_high[initial_no_support_extruder]}" + private const val BAMBU_A1_NEW_FLUSH_TEMPERATURE = + "M109 S{flush_temperatures[initial_no_support_extruder]} H300" + private const val BAMBU_A1_COMPAT_FLUSH_TEMPERATURE = + "M109 S{nozzle_temperature_range_high[initial_no_support_extruder]} H300" + private fun canonicalColour(raw: String): String { val hex = raw.removePrefix("#").uppercase() return if (hex.matches(Regex("[0-9A-F]{8}"))) "#${hex.take(6)}" 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 4d2502c..0f19b68 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,8 +13,20 @@ import android.widget.TextView import android.widget.ArrayAdapter import com.u1.slicer.data.SliceConfig import org.crabcore.u1control.R +import org.json.JSONArray import org.json.JSONObject +/** + * The groups of settings the prepare screen can override. + * + * A group counts as chosen only once the user has opened its dialog and applied + * it. That distinction matters because these overrides are written into a 3MF's + * own project settings: emitting a group the user never touched would replace + * whatever the file asked for with this class's constructor defaults, which is + * how a downloaded model that specifies supports or a brim silently lost them. + */ +enum class SliceSettingGroup { SUPPORTS, BRIM, INFILL, IRONING } + /** User-facing slice overrides on the prepare screen (Phase 1 — SliceConfig fields only). */ data class HelixSliceSettings( var supportsEnabled: Boolean = false, @@ -41,7 +53,14 @@ data class HelixSliceSettings( var ironingSpacing: Float = 0.15f, /** Ironing speed mm/s (Orca default 30). */ var ironingSpeed: Int = 30, + /** Which groups the user actually applied; see [SliceSettingGroup]. */ + var chosen: Set = emptySet(), ) { + fun chose(group: SliceSettingGroup): Boolean = group in chosen + + /** This value plus [group] marked as a deliberate user choice. */ + fun choosing(group: SliceSettingGroup): HelixSliceSettings = copy(chosen = chosen + group) + fun hasSupportsEnabled(): Boolean = supportsEnabled fun hasBrimEnabled(): Boolean = brimWidthMm > 0f @@ -68,64 +87,133 @@ data class HelixSliceSettings( put("ironingFlow", ironingFlow) put("ironingSpacing", ironingSpacing.toDouble()) put("ironingSpeed", ironingSpeed) + put("chosen", JSONArray(chosen.map { it.name })) }.toString() + /** + * Writes the user's choices onto [config], leaving untouched groups alone. + * + * Runs after [Project3mfSettings.applyTo], so anything not chosen here keeps + * the project's own value rather than this class's constructor default. + */ fun applyTo(config: SliceConfig) { - config.supportEnabled = supportsEnabled - if (supportsEnabled) { - config.supportType = supportType - config.supportAngle = supportAngle.toFloat() - config.supportBuildPlateOnly = supportBuildPlateOnly - config.supportPattern = supportPattern - if (supportFilament > 0) config.supportFilament = supportFilament - val iface = when { - supportInterfaceFilament == -1 -> supportFilament - supportInterfaceFilament > 0 -> supportInterfaceFilament - else -> 0 + if (chose(SliceSettingGroup.SUPPORTS)) { + config.supportEnabled = supportsEnabled + if (supportsEnabled) { + config.supportType = supportType + config.supportAngle = supportAngle.toFloat() + config.supportBuildPlateOnly = supportBuildPlateOnly + config.supportPattern = supportPattern + if (supportFilament > 0) config.supportFilament = supportFilament + val iface = when { + supportInterfaceFilament == -1 -> supportFilament + supportInterfaceFilament > 0 -> supportInterfaceFilament + else -> 0 + } + if (iface > 0) config.supportInterfaceFilament = iface } - if (iface > 0) config.supportInterfaceFilament = iface } - config.brimWidth = brimWidthMm.coerceAtLeast(0f) - if (infillDensity in 0f..1f) config.fillDensity = infillDensity - if (infillPattern != "default") config.fillPattern = infillPattern + if (chose(SliceSettingGroup.BRIM)) config.brimWidth = brimWidthMm.coerceAtLeast(0f) + if (chose(SliceSettingGroup.INFILL)) { + if (infillDensity in 0f..1f) config.fillDensity = infillDensity + if (infillPattern != "default") config.fillPattern = infillPattern + } // Ironing has no SliceConfig fields — it rides only via the 3MF profile // overrides in [toProfileKeyOverrides] (so STL slices skip it). } - /** Keys for [SliceSettings3mfPatcher] (Orca project_settings.config JSON). */ + /** + * Keys for [SliceSettings3mfPatcher] (Orca project_settings.config JSON). + * + * Only groups the user applied appear. Writing a key for an untouched group + * would overwrite the project's own answer with a default nobody picked — + * which is exactly how a 3MF authored with supports arrived at the engine + * with `enable_support: 0`. + */ fun toProfileKeyOverrides(): Map { val out = linkedMapOf() - out["enable_support"] = if (supportsEnabled) "1" else "0" - if (supportsEnabled) { - out["support_type"] = supportType - out["support_threshold_angle"] = supportAngle.toString() - out["support_on_build_plate_only"] = if (supportBuildPlateOnly) "1" else "0" - out["support_base_pattern"] = supportPattern - if (supportFilament > 0) out["support_filament"] = supportFilament.toString() - val iface = when { - supportInterfaceFilament == -1 -> supportFilament - supportInterfaceFilament > 0 -> supportInterfaceFilament - else -> 0 + if (chose(SliceSettingGroup.SUPPORTS)) { + out["enable_support"] = if (supportsEnabled) "1" else "0" + if (supportsEnabled) { + out["support_type"] = supportType + out["support_threshold_angle"] = supportAngle.toString() + out["support_on_build_plate_only"] = if (supportBuildPlateOnly) "1" else "0" + out["support_base_pattern"] = supportPattern + if (supportFilament > 0) out["support_filament"] = supportFilament.toString() + val iface = when { + supportInterfaceFilament == -1 -> supportFilament + supportInterfaceFilament > 0 -> supportInterfaceFilament + else -> 0 + } + if (iface > 0) out["support_interface_filament"] = iface.toString() } - if (iface > 0) out["support_interface_filament"] = iface.toString() } - out["brim_width"] = brimWidthMm.coerceAtLeast(0f).toString() - out["brim_type"] = if (brimWidthMm > 0f) "outer_only" else "no_brim" - if (infillDensity in 0f..1f) { - out["sparse_infill_density"] = "${(infillDensity * 100).toInt()}%" + if (chose(SliceSettingGroup.BRIM)) { + out["brim_width"] = brimWidthMm.coerceAtLeast(0f).toString() + out["brim_type"] = if (brimWidthMm > 0f) "outer_only" else "no_brim" + } + if (chose(SliceSettingGroup.INFILL)) { + if (infillDensity in 0f..1f) { + out["sparse_infill_density"] = "${(infillDensity * 100).toInt()}%" + } + if (infillPattern != "default") out["sparse_infill_pattern"] = infillPattern } - if (infillPattern != "default") out["sparse_infill_pattern"] = infillPattern - if (hasIroningEnabled()) { + if (chose(SliceSettingGroup.IRONING)) { + // An applied ironing dialog with ironing switched off must still say so, + // or turning it back off would leave the project's own value standing. out["ironing_type"] = ironingType - out["ironing_pattern"] = ironingPattern - out["ironing_flow"] = "$ironingFlow%" - out["ironing_spacing"] = ironingSpacing.toString() - out["ironing_speed"] = ironingSpeed.toString() + if (hasIroningEnabled()) { + out["ironing_pattern"] = ironingPattern + out["ironing_flow"] = "$ironingFlow%" + out["ironing_spacing"] = ironingSpacing.toString() + out["ironing_speed"] = ironingSpeed.toString() + } } return out } companion object { + /** + * Prepare-screen state showing what [project] actually asks for. + * + * Nothing here counts as a user choice — [chosen] stays empty — so these + * values light the toolbar tiles and pre-fill the dialogs without being + * written back over the project as overrides. Open a dialog and apply it + * and the group becomes a real choice from that point on. + */ + fun seededFrom(project: Project3mfSettings): HelixSliceSettings { + val settings = HelixSliceSettings() + if (!project.isPresent) return settings + + project.boolean("enable_support")?.let { settings.supportsEnabled = it } + project.string("support_type")?.let { settings.supportType = it } + project.int("support_threshold_angle")?.let { + settings.supportAngle = it.coerceIn(0, 90) + } + project.boolean("support_on_build_plate_only")?.let { settings.supportBuildPlateOnly = it } + project.string("support_base_pattern")?.let { settings.supportPattern = it } + project.int("support_filament")?.let { settings.supportFilament = it } + project.int("support_interface_filament")?.let { settings.supportInterfaceFilament = it } + + // A brim the project switched off has no width to show, whatever number + // is parked in brim_width. + val brimOff = project.string("brim_type") == "no_brim" + project.float("brim_width")?.let { settings.brimWidthMm = if (brimOff) 0f else it } + + project.fraction("sparse_infill_density")?.let { + settings.infillDensity = it.coerceIn(0f, 1f) + } + project.string("sparse_infill_pattern")?.let { settings.infillPattern = it } + + project.string("ironing_type")?.let { settings.ironingType = it } + project.string("ironing_pattern")?.let { settings.ironingPattern = it } + project.fraction("ironing_flow")?.let { settings.ironingFlow = (it * 100).toInt() } + project.float("ironing_spacing")?.let { settings.ironingSpacing = it } + project.int("ironing_speed")?.let { settings.ironingSpeed = it } + + return settings + } + /** Build settings from the RN sliceFile options map (all keys optional). */ fun fromBridgeOptions( supportEnabled: Boolean?, @@ -138,17 +226,48 @@ data class HelixSliceSettings( brimWidth: Double?, ): HelixSliceSettings { val settings = HelixSliceSettings() - supportEnabled?.let { settings.supportsEnabled = it } - supportType?.let { settings.supportType = it } - supportAngle?.let { settings.supportAngle = it.toInt().coerceIn(0, 90) } - supportFilament?.let { settings.supportFilament = it } - supportInterfaceFilament?.let { settings.supportInterfaceFilament = it } - supportBuildPlateOnly?.let { settings.supportBuildPlateOnly = it } - supportPattern?.let { settings.supportPattern = it } - brimWidth?.let { settings.brimWidthMm = it.toFloat() } + // A key the caller sent is a deliberate choice; one it omitted leaves the + // project's own value alone. + val chosen = mutableSetOf() + supportEnabled?.let { settings.supportsEnabled = it; chosen += SliceSettingGroup.SUPPORTS } + supportType?.let { settings.supportType = it; chosen += SliceSettingGroup.SUPPORTS } + supportAngle?.let { + settings.supportAngle = it.toInt().coerceIn(0, 90) + chosen += SliceSettingGroup.SUPPORTS + } + supportFilament?.let { settings.supportFilament = it; chosen += SliceSettingGroup.SUPPORTS } + supportInterfaceFilament?.let { + settings.supportInterfaceFilament = it + chosen += SliceSettingGroup.SUPPORTS + } + supportBuildPlateOnly?.let { + settings.supportBuildPlateOnly = it + chosen += SliceSettingGroup.SUPPORTS + } + supportPattern?.let { settings.supportPattern = it; chosen += SliceSettingGroup.SUPPORTS } + brimWidth?.let { settings.brimWidthMm = it.toFloat(); chosen += SliceSettingGroup.BRIM } + settings.chosen = chosen return settings } + /** + * The chosen-group set from a [toJson] payload. + * + * A record written before this field existed has no "chosen" array. Those + * are replays of a slice the user had already configured, so treating them + * as "everything the prepare screen can set was chosen" reproduces that + * slice exactly rather than quietly re-slicing it with different settings. + */ + private fun chosenFromJson(o: JSONObject): Set { + val raw = o.optJSONArray("chosen") ?: return SliceSettingGroup.entries.toSet() + return (0 until raw.length()) + .mapNotNull { index -> + val name = raw.optString(index) + SliceSettingGroup.entries.firstOrNull { it.name == name } + } + .toSet() + } + /** Reconstruct from [toJson] output; null/blank/garbage → defaults. */ fun fromJson(json: String?): HelixSliceSettings { if (json.isNullOrBlank()) return HelixSliceSettings() @@ -170,6 +289,7 @@ data class HelixSliceSettings( ironingFlow = o.optInt("ironingFlow", 10), ironingSpacing = o.optDouble("ironingSpacing", 0.15).toFloat(), ironingSpeed = o.optInt("ironingSpeed", 30), + chosen = chosenFromJson(o), ) }.getOrDefault(HelixSliceSettings()) } @@ -415,7 +535,7 @@ object HelixSupportSettingsUi { title = "Supports", iconRes = R.drawable.ic_tool_support, content = content, - onPrimary = { onApply(draft) }, + onPrimary = { onApply(draft.choosing(SliceSettingGroup.SUPPORTS)) }, ) } } @@ -499,7 +619,7 @@ object HelixInfillSettingsUi { title = "Infill", iconRes = R.drawable.ic_tool_arrange, content = content, - onPrimary = { onApply(draft) }, + onPrimary = { onApply(draft.choosing(SliceSettingGroup.INFILL)) }, ) } } @@ -616,7 +736,7 @@ object HelixIroningSettingsUi { title = "Ironing", iconRes = R.drawable.ic_tool_iron, content = content, - onPrimary = { onApply(draft) }, + onPrimary = { onApply(draft.choosing(SliceSettingGroup.IRONING)) }, ) } } 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 3288b09..09f3ac4 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 @@ -21,6 +21,7 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.modules.core.DeviceEventManagerModule import com.google.firebase.messaging.FirebaseMessaging import com.u1.slicer.NativeLibrary +import com.u1.slicer.viewer.MachineProfile import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -181,6 +182,24 @@ class HelixSlicerModule( promise.resolve(filename) } + /** Consumes the native preview's one-shot request to open Bambu's send sheet. */ + @ReactMethod + fun takeBambuSendRequest(promise: Promise) { + promise.resolve(LastSliceStore.takeBambuSendRequest()) + } + + /** Mirrors the saved RN setting into native prefs for the Android print path. */ + @ReactMethod + fun setAiDetectionSensitivity(value: String, promise: Promise) { + try { + val sensitivity = AiDetectionSensitivity.fromWireValue(value) + PreprocessPreferenceStore.setAiDetectionSensitivity(reactApplicationContext, sensitivity) + promise.resolve(true) + } catch (error: IllegalArgumentException) { + promise.reject("E_AI_SENSITIVITY", error.message, error) + } + } + /** Opens the system file picker for .3mf / .stl and imports into app storage. */ @ReactMethod fun pickModelFile(promise: Promise) { @@ -502,6 +521,7 @@ class HelixSlicerModule( loadedToolMask: Int, autoArrange: Boolean, materialProfilesJson: String?, + bedProfileJson: String?, promise: Promise, ) { val activity = getCurrentActivity() @@ -532,6 +552,9 @@ class HelixSlicerModule( if (!materialProfilesJson.isNullOrBlank()) { putExtra(HelixModelPreviewActivity.EXTRA_MATERIAL_PROFILES, materialProfilesJson) } + if (!bedProfileJson.isNullOrBlank()) { + putExtra(HelixModelPreviewActivity.EXTRA_BED_PROFILE, bedProfileJson) + } // User-declared filament colours (Slice Lab "Your filaments" row). if (slotColors != null && slotColors.size() > 0) { val list = ArrayList(slotColors.size()) @@ -781,6 +804,7 @@ class HelixSlicerModule( initialTool: Int, loadedToolMask: Int, usedToolMask: Int, + bedProfileJson: String?, promise: Promise, ) { val activity = getCurrentActivity() @@ -804,6 +828,9 @@ class HelixSlicerModule( putExtra(HelixGcodePreviewActivity.EXTRA_INITIAL_TOOL, initialTool.coerceIn(0, 3)) putExtra(HelixGcodePreviewActivity.EXTRA_LOADED_TOOL_MASK, loadedToolMask) putExtra(HelixGcodePreviewActivity.EXTRA_USED_TOOL_MASK, usedToolMask) + if (!bedProfileJson.isNullOrBlank()) { + putExtra(HelixGcodePreviewActivity.EXTRA_BED_PROFILE, bedProfileJson) + } LastSliceStore.modelPath?.let { putExtra(HelixGcodePreviewActivity.EXTRA_MODEL_PATH, it) } } activity.startActivity(intent) @@ -866,6 +893,11 @@ class HelixSlicerModule( } val materialProfilesJson = o?.takeIf { it.hasKey("materialProfiles") }?.getString("materialProfiles") val forceExtruderCount = o?.takeIf { it.hasKey("forceExtruderCount") }?.getInt("forceExtruderCount") + // Background re-slice: without this the runner defaults to the U1 and a + // re-slice would silently differ from the prepare-screen slice. + val machine = MachineProfile.fromJson( + o?.takeIf { it.hasKey("machineProfile") }?.getString("machineProfile") + ) val outcome = HelixSliceRunner.run( reactApplicationContext, lib, @@ -875,6 +907,7 @@ class HelixSlicerModule( sliceSettings = sliceSettings, materialProfilesJson = materialProfilesJson, forceExtruderCount = forceExtruderCount, + machine = machine, ) { o?.let { opts -> if (opts.hasKey("layerHeight")) layerHeight = opts.getDouble("layerHeight").toFloat() diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixThemedDialog.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixThemedDialog.kt index 98e47b5..e955709 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixThemedDialog.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixThemedDialog.kt @@ -146,22 +146,27 @@ object HelixThemedDialog { LinearLayout(activity).apply { orientation = LinearLayout.HORIZONTAL setPadding(0, dp(16), 0, 0) - addView( - actionButton(secondaryLabel, HelixAppTheme.CARD_ALT, HelixAppTheme.BORDER, HelixAppTheme.TEXT) { - onSecondary() - dismiss() - }, - LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { - marginEnd = dp(8) - }, - ) + // A read-only sheet has nothing to cancel; a blank label drops the + // button rather than rendering an empty one. + val hasSecondary = secondaryLabel.isNotBlank() + if (hasSecondary) { + addView( + actionButton(secondaryLabel, HelixAppTheme.CARD_ALT, HelixAppTheme.BORDER, HelixAppTheme.TEXT) { + onSecondary() + dismiss() + }, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { + marginEnd = dp(8) + }, + ) + } addView( actionButton(primaryLabel, accent, null, Color.WHITE) { onPrimary() dismiss() }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { - marginStart = dp(8) + if (hasSecondary) marginStart = dp(8) }, ) }, diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/KlipperGcodeCompatibility.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/KlipperGcodeCompatibility.kt new file mode 100644 index 0000000..26374c8 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/KlipperGcodeCompatibility.kt @@ -0,0 +1,111 @@ +package org.crabcore.u1control.slicing + +import java.io.File +import java.util.Locale +import kotlin.math.sqrt + +/** + * Repairs Marlin-only commands emitted by the prebuilt slicer for a machine + * profile that explicitly opts in. The native engine knows Orca's Klipper + * keys, but its JNI SliceConfig bridge does not expose them to Kotlin. + */ +object KlipperGcodeCompatibility { + data class Result( + val success: Boolean, + val rewritten: Boolean, + val translatedLimits: Int, + val strippedCommands: Int, + ) + + private data class Rewrite(val line: String?, val translated: Boolean) + + private val targetedCommand = Regex( + """^(\s*)(M201|M203|M205|M486)\b(.*)$""", + RegexOption.IGNORE_CASE, + ) + private val xyParameter = Regex( + """(?:^|\s)([XY])\s*(-?(?:\d+(?:\.\d*)?|\.\d+))(?=\s|;|$)""", + RegexOption.IGNORE_CASE, + ) + + fun apply( + path: String, + translateMarlinMachineLimits: Boolean, + stripM486: Boolean, + ): Result { + val file = File(path) + if (!file.exists() || !file.isFile) return Result(false, false, 0, 0) + if (!translateMarlinMachineLimits && !stripM486) return Result(true, false, 0, 0) + + val tmp = File(file.parentFile, file.name + ".klipper.tmp") + var changed = false + var translated = 0 + var stripped = 0 + val completed = runCatching { + tmp.bufferedWriter().use { out -> + file.bufferedReader().useLines { lines -> + lines.forEach { line -> + val match = targetedCommand.matchEntire(line) + val command = match?.groupValues?.get(2)?.uppercase(Locale.US) + val rewrite = when { + command == "M486" && stripM486 -> Rewrite(null, false) + command in MACHINE_LIMIT_COMMANDS && translateMarlinMachineLimits -> + translateLimit(command!!, match!!.groupValues[3]) + else -> Rewrite(line, false) + } + + if (rewrite.line != line) changed = true + if (rewrite.translated) translated++ + if (rewrite.line == null) stripped++ else { + out.write(rewrite.line) + out.newLine() + } + } + } + } + true + }.getOrDefault(false) + + if (!completed) { + tmp.delete() + return Result(false, false, 0, 0) + } + if (!changed) { + tmp.delete() + return Result(true, false, 0, 0) + } + if (!tmp.renameTo(file)) { + tmp.delete() + return Result(false, false, 0, 0) + } + return Result(true, true, translated, stripped) + } + + private fun translateLimit(command: String, arguments: String): Rewrite { + val xyValues = xyParameter.findAll(arguments.substringBefore(';')) + .mapNotNull { match -> + match.groupValues[2].toDoubleOrNull() + ?.takeIf { it.isFinite() && it >= 0.0 } + } + .toList() + val xyLimit = xyValues.minOrNull() ?: return Rewrite(null, false) + + val (parameter, value) = when (command) { + "M201" -> "ACCEL" to xyLimit + "M203" -> "VELOCITY" to xyLimit + // Marlin jerk is an instantaneous velocity-vector delta. At a 90-degree + // corner that delta is sqrt(2) times Klipper's square-corner velocity. + "M205" -> "SQUARE_CORNER_VELOCITY" to (xyLimit / sqrt(2.0)) + else -> return Rewrite(null, false) + } + return Rewrite( + "SET_VELOCITY_LIMIT $parameter=${formatValue(value)} ; Helix translated $command", + true, + ) + } + + private fun formatValue(value: Double): String = + String.format(Locale.US, "%.5f", value).trimEnd('0').trimEnd('.') + + private val MACHINE_LIMIT_COMMANDS = setOf("M201", "M203", "M205") +} 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 481d4cf..801b88e 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 @@ -18,6 +18,20 @@ object LastSliceStore { @Volatile var usedToolMask: Int = 1 @Volatile var sliceSettingsJson: String? = null @Volatile var materialProfilesJson: String? = null + @Volatile private var bambuSendRequested: Boolean = false + + /** One-shot handoff from the native preview to the RN Bambu send dialog. */ + @Synchronized + fun requestBambuSend() { + bambuSendRequested = true + } + + @Synchronized + fun takeBambuSendRequest(): Boolean { + val requested = bambuSendRequested + bambuSendRequested = false + return requested + } fun record( model: String, @@ -49,5 +63,6 @@ object LastSliceStore { usedToolMask = 1 sliceSettingsJson = null materialProfilesJson = null + bambuSendRequested = false } } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessPreferenceStore.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessPreferenceStore.kt new file mode 100644 index 0000000..1fe1c10 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessPreferenceStore.kt @@ -0,0 +1,65 @@ +package org.crabcore.u1control.slicing + +import android.content.Context + +enum class AiDetectionSensitivity(val wireValue: String) { + LOW("low"), + HIGH("high"); + + companion object { + fun fromStoredValue(value: String?): AiDetectionSensitivity = + values().firstOrNull { it.wireValue == value?.trim()?.lowercase() } ?: LOW + + fun fromWireValue(value: String): AiDetectionSensitivity = + values().firstOrNull { it.wireValue == value.trim().lowercase() } + ?: throw IllegalArgumentException("Unsupported AI detection sensitivity: $value") + } +} + +/** + * User defaults for the native pre-print sheet. + * + * These are printer behaviours, not model/slice geometry, so they deliberately + * live outside [HelixSliceSettings]. A 3MF may own its brim and supports, but it + * must never decide whether the printer's failure monitoring is armed. + */ +object PreprocessPreferenceStore { + private const val PREFS_NAME = "helix_preprocess_preferences" + private const val KEY_AI_MONITORING = "ai_monitoring" + private const val KEY_AI_SENSITIVITY = "ai_detection_sensitivity" + + internal fun aiMonitoringValue(saved: Boolean?): Boolean = saved ?: true + + fun aiMonitoringEnabled(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val saved = if (prefs.contains(KEY_AI_MONITORING)) { + prefs.getBoolean(KEY_AI_MONITORING, true) + } else { + null + } + return aiMonitoringValue(saved) + } + + fun setAiMonitoringEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_AI_MONITORING, enabled) + .apply() + } + + internal fun aiDetectionSensitivityValue(saved: String?): AiDetectionSensitivity = + AiDetectionSensitivity.fromStoredValue(saved) + + fun aiDetectionSensitivity(context: Context): AiDetectionSensitivity { + val saved = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_AI_SENSITIVITY, null) + return aiDetectionSensitivityValue(saved) + } + + fun setAiDetectionSensitivity(context: Context, sensitivity: AiDetectionSensitivity) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_AI_SENSITIVITY, sensitivity.wireValue) + .apply() + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessRouting.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessRouting.kt index ae816ed..e59b185 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessRouting.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/PreprocessRouting.kt @@ -50,12 +50,67 @@ object PreprocessRouting { val blocking: Boolean, ) - enum class Pref(val label: String, val hint: String, val icon: Int) { - AUTO_LEVEL("Auto leveling", "Probe the bed before the first layer", HelixIcons.GRID), - FLOW_CAL("Flow calibration", "Calibrate extrusion on the way in", HelixIcons.TUNE), - TIMELAPSE("Time-lapse", "Capture a frame every layer", HelixIcons.VIDEO), + /** + * [key] matches services/printerProfiles.ts, which decides which of these a + * given machine actually offers — an AD5X has no flow-calibration macro and + * no TIMELAPSE_* macros, and printing with either injected aborts the job. + */ + enum class Pref(val key: String, val label: String, val hint: String, val icon: Int) { + AI_MONITORING( + "aiMonitoring", + "AI Monitoring", + "Detects spaghetti failures and build-plate obstructions, then automatically pauses the print", + HelixIcons.ROBOT_OUTLINE, + ), + AUTO_LEVEL("autoLevel", "Auto leveling", "Probe the bed before the first layer", HelixIcons.GRID), + FLOW_CAL("flowCal", "Flow calibration", "Calibrate extrusion on the way in", HelixIcons.TUNE), + TIMELAPSE("timelapse", "Time-lapse", "Capture a frame every layer", HelixIcons.VIDEO), + IFS("ifs", "Enable IFS", "Feed colors from the material station", HelixIcons.TUNE), + } + + /** + * The toggles [printPrefs] permits, in enum order. AI monitoring is a U1 + * firmware capability rather than a slicer preference, so its availability + * travels separately from the machine-profile key list. + */ + @JvmStatic + fun offeredPrefs(printPrefs: List, supportsAiMonitoring: Boolean = false): List { + val allowed = printPrefs.toSet() + return Pref.values().filter { pref -> + if (pref == Pref.AI_MONITORING) supportsAiMonitoring + else printPrefs.isEmpty() || allowed.contains(pref.key) + } } + /** U1/PAXX command applied immediately before a print starts. */ + @JvmStatic + fun aiMonitoringCommand( + enabled: Boolean, + sensitivity: AiDetectionSensitivity = AiDetectionSensitivity.LOW, + ): String = if (enabled) { + "DEFECT_DETECTION_CONFIG MAIN_ENABLE=1 CLEAN_BED_ENABLE=1 " + + "NOODLE_ENABLE=1 SENSITIVITY=${sensitivity.wireValue}" + } else { + "DEFECT_DETECTION_CONFIG MAIN_ENABLE=0" + } + + /** + * How a machine names its material feeds. "tool" — U1/generic: tools are + * "T0"–"T3", the physical feeds are plain "lanes". "lane" — AD5X/Bambu: the + * feeds themselves are the unit, shown as "Lane 1"–"Lane 4". Mirrors + * `LaneNaming` in services/printPreprocess.ts. + */ + /** The noun for a physical feed: "lane" on the U1, "Lane" where the machine names them. */ + fun laneWord(laneNaming: String): String = if (laneNaming == "lane") "Lane" else "lane" + + /** Chip label: "T2" on the U1, the bare number "3" where the feeds are named lanes. */ + fun toolChipLabel(fileTool: Int, laneNaming: String): String = + if (laneNaming == "lane") "${fileTool + 1}" else "T$fileTool" + + /** Full reference: "T0" on the U1, "Lane 1" where the feeds are named lanes. */ + fun toolLabel(fileTool: Int, laneNaming: String): String = + if (laneNaming == "lane") "Lane ${fileTool + 1}" else "T$fileTool" + /** A lane that can feed a print. `unknown` counts — refusing on no evidence is worse. */ private fun usable(lane: Lane?): Boolean = lane != null && !lane.isEmpty @@ -132,7 +187,9 @@ object PreprocessRouting { printerName: String, tools: List, lanes: List, + laneNaming: String = "tool", ): List { + val word = laneWord(laneNaming) val starved = tools.filter { it.lane.isEmpty } val loadedCount = lanes.count { it.status == "loaded" } val rerouted = tools.filter { it.source == RouteSource.AUTO } @@ -141,20 +198,21 @@ object PreprocessRouting { starved.isNotEmpty() -> Check( key = "filament", detail = "This file needs ${tools.size} materials and only $loadedCount " + - (if (loadedCount == 1) "lane has" else "lanes have") + " filament", + (if (loadedCount == 1) "$word has" else "${word}s have") + " filament", tone = Tone.FAIL, blocking = true, ) rerouted.isNotEmpty() -> Check( key = "filament", - detail = rerouted.joinToString(", ") { "T${it.fileTool} to lane ${it.assigned + 1}" } + - " — your own lanes were empty", + detail = rerouted.joinToString(", ") { + "${toolLabel(it.fileTool, laneNaming)} to $word ${it.assigned + 1}" + } + " — your own ${word}s were empty", tone = Tone.PASS, blocking = true, ) else -> Check( key = "filament", - detail = "${tools.size} of ${lanes.size} lanes feed this print", + detail = "${tools.size} of ${lanes.size} ${word}s feed this print", tone = Tone.PASS, blocking = true, ) diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/Project3mfSettings.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/Project3mfSettings.kt new file mode 100644 index 0000000..6a9f88a --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/Project3mfSettings.kt @@ -0,0 +1,275 @@ +package org.crabcore.u1control.slicing + +import android.content.Context +import com.u1.slicer.data.SliceConfig +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.zip.ZipFile + +/** + * One line of the project-settings sheet. + * + * [default] is null when the printer's bundled profile says nothing about this + * setting, which is the case for every non-Bambu profile Helix ships. + */ +data class ProjectSettingRow( + val label: String, + val value: String, + val default: String?, +) { + /** True when the project overrides a value the printer profile does specify. */ + val differs: Boolean get() = default != null && default != value +} + +/** + * A 3MF's embedded [Metadata/project_settings.config], and the bridge from it + * into [SliceConfig]. + * + * The native engine reads that entry itself when it opens the model — and then + * the prebuilt JNI wrapper applies every [SliceConfig] field over the result. + * The wrapper is a binary we do not build, so it cannot be taught to skip + * fields; the only way to stop it overwriting the project's own settings with + * Helix's defaults is to put the project's values into SliceConfig first. That + * is what [applyTo] is for. User overrides are applied afterwards and still win. + * + * Every accessor is nullable and means "the project did not say", which is not + * the same as "the project said zero". Callers decide the fallback. + * + * Bambu writes values as strings, and per-extruder or per-filament keys as + * arrays of strings, so both shapes are accepted and arrays read their first + * entry — the profile's own primary variant. + * crabcore + */ +class Project3mfSettings private constructor(private val config: JSONObject?) { + + /** False when the file carried no project settings at all (a plain STL). */ + val isPresent: Boolean get() = config != null + + // ---- Typed readers ---- + + /** A key's value, unwrapping the array form Bambu uses for per-variant keys. */ + fun string(key: String): String? { + val raw = config?.opt(key) ?: return null + val value = if (raw is JSONArray) raw.opt(0) else raw + return (value as? String ?: value?.toString())?.trim()?.takeIf { it.isNotEmpty() } + } + + fun boolean(key: String): Boolean? = when (string(key)?.lowercase()) { + "1", "true" -> true + "0", "false" -> false + else -> null + } + + fun float(key: String): Float? = string(key)?.toFloatOrNull() + + fun int(key: String): Int? = string(key)?.toFloatOrNull()?.toInt() + + /** `"15%"` reads as 0.15; a bare `"0.15"` is already a fraction. */ + fun fraction(key: String): Float? { + val raw = string(key) ?: return null + val percent = raw.endsWith("%") + val value = raw.removeSuffix("%").toFloatOrNull() ?: return null + return if (percent) value / 100f else value + } + + // ---- Named settings ---- + + val primeTowerEnabled: Boolean? get() = boolean("enable_prime_tower") + + val primeTowerWidth: Float? get() = float("prime_tower_width")?.takeIf { it > 0f } + + /** + * Bed temperature for the plate the project is actually set up for. + * + * Bambu stores a temperature per plate type and names the chosen one in + * `curr_bed_type`; reading `hot_plate_temp` regardless would give a Supertack + * project the High Temp figure, which for PLA is 20C out. + */ + val bedTemperature: Int? get() { + val key = BED_TEMP_KEYS[string("curr_bed_type")] ?: "hot_plate_temp" + return int(key)?.takeIf { it > 0 } + } + + /** + * Copies the project's settings onto [config]. + * + * Only keys the project actually carries are written, so a file that says + * nothing about a setting leaves Helix's default in place. Nothing here is a + * user choice — [HelixSliceSettings.applyTo] runs afterwards for those. + */ + fun applyTo(target: SliceConfig) { + if (!isPresent) return + + // Layers and shells. + float("layer_height")?.let { target.layerHeight = it } + float("initial_layer_print_height")?.let { target.firstLayerHeight = it } + int("wall_loops")?.let { target.perimeters = it } + int("top_shell_layers")?.let { target.topSolidLayers = it } + int("bottom_shell_layers")?.let { target.bottomSolidLayers = it } + + // Infill. + fraction("sparse_infill_density")?.let { target.fillDensity = it.coerceIn(0f, 1f) } + string("sparse_infill_pattern")?.let { target.fillPattern = it } + + // Speeds. These are per-extruder-variant arrays; the first entry is the + // profile's primary variant, which is the one Helix slices for. + float("outer_wall_speed")?.let { target.printSpeed = it } + float("travel_speed")?.let { target.travelSpeed = it } + float("initial_layer_speed")?.let { target.firstLayerSpeed = it } + + // Adhesion. + int("skirt_loops")?.let { target.skirtLoops = it } + float("skirt_distance")?.let { target.skirtDistance = it } + float("brim_width")?.let { target.brimWidth = it } + + // Supports. + boolean("enable_support")?.let { target.supportEnabled = it } + string("support_type")?.let { target.supportType = it } + float("support_threshold_angle")?.let { target.supportAngle = it } + boolean("support_on_build_plate_only")?.let { target.supportBuildPlateOnly = it } + string("support_base_pattern")?.let { target.supportPattern = it } + int("support_filament")?.let { target.supportFilament = it } + int("support_interface_filament")?.let { target.supportInterfaceFilament = it } + + // Hardware and material. + float("nozzle_diameter")?.let { target.nozzleDiameter = it } + float("filament_diameter")?.let { target.filamentDiameter = it } + string("filament_type")?.let { target.filamentType = it } + // Left for resolveNativeMaterialProfiles to override when the user's own + // filament library has something to say; this is only the file's guess. + int("nozzle_temperature")?.let { target.nozzleTemp = it } + bedTemperature?.let { target.bedTemp = it } + + // Retraction. + float("retraction_length")?.let { target.retractLength = it } + float("retraction_speed")?.let { target.retractSpeed = it } + } + + /** + * The settings this project specifies, in prepare-screen order, each next to + * the printer profile's own value where one is known. + * + * [baseline] is the bundled machine profile. Only the Bambu profiles carry + * print-scoped keys, so for a U1 or an AD5X the defaults come back null and + * the rows read as "this is what the project asks for" rather than "this is + * what the project changed" — which is the honest answer when there is + * nothing to compare against. + */ + fun summarize(baseline: Project3mfSettings = NONE): List { + if (!isPresent) return emptyList() + return SUMMARY_KEYS.mapNotNull { entry -> + val value = entry.read(this) ?: return@mapNotNull null + ProjectSettingRow(entry.label, value, entry.read(baseline)) + } + } + + private class SummaryKey(val label: String, val read: (Project3mfSettings) -> String?) + + companion object { + private const val PROJECT_SETTINGS = "Metadata/project_settings.config" + + private fun onOff(value: Boolean?): String? = when (value) { + true -> "On" + false -> "Off" + null -> null + } + + private fun unit(raw: String?, suffix: String): String? = + raw?.let { "$it$suffix" } + + /** + * What the project-settings sheet lists, in the order it lists them. + * + * Deliberately a curated set: a Bambu project and Helix's bundled profile + * differ in hundreds of bookkeeping keys, and listing those would bury the + * handful a user actually recognises. + */ + private val SUMMARY_KEYS = listOf( + SummaryKey("Layer height") { unit(it.string("layer_height"), " mm") }, + SummaryKey("First layer") { unit(it.string("initial_layer_print_height"), " mm") }, + SummaryKey("Walls") { it.string("wall_loops") }, + SummaryKey("Top layers") { it.string("top_shell_layers") }, + SummaryKey("Bottom layers") { it.string("bottom_shell_layers") }, + SummaryKey("Infill") { it.string("sparse_infill_density") }, + SummaryKey("Infill pattern") { it.string("sparse_infill_pattern") }, + SummaryKey("Supports") { onOff(it.boolean("enable_support")) }, + SummaryKey("Support type") { it.string("support_type") }, + SummaryKey("Overhang angle") { unit(it.string("support_threshold_angle"), "°") }, + SummaryKey("Brim") { it.string("brim_type") }, + SummaryKey("Brim width") { unit(it.string("brim_width"), " mm") }, + SummaryKey("Ironing") { it.string("ironing_type") }, + SummaryKey("Prime tower") { onOff(it.boolean("enable_prime_tower")) }, + SummaryKey("Outer wall speed") { unit(it.string("outer_wall_speed"), " mm/s") }, + SummaryKey("Travel speed") { unit(it.string("travel_speed"), " mm/s") }, + SummaryKey("First layer speed") { unit(it.string("initial_layer_speed"), " mm/s") }, + SummaryKey("Skirt loops") { it.string("skirt_loops") }, + SummaryKey("Build plate") { it.string("curr_bed_type") }, + SummaryKey("Bed temp") { it.bedTemperature?.let { temp -> "$temp°C" } }, + ) + + /** + * `prime_tower_width` when the project does not name one. This is what the + * stock Bambu and Orca profiles ship, and what the prepare screen draws — + * SliceConfig's own 60mm default matched neither. + */ + const val DEFAULT_PRIME_TOWER_WIDTH_MM = 35f + + /** + * Bambu's plate labels and the key each one's temperature lives under. + * Labels are the engine's own, read out of libprusaslicer-jni.so. + */ + private val BED_TEMP_KEYS = mapOf( + "Cool Plate" to "cool_plate_temp", + "Smooth Cool Plate" to "cool_plate_temp", + "Textured Cool Plate" to "textured_cool_plate_temp", + "Cool Plate (SuperTack)" to "supertack_plate_temp", + "Supertack Plate" to "supertack_plate_temp", + "Engineering Plate" to "eng_plate_temp", + "High Temp Plate" to "hot_plate_temp", + "Smooth High Temp Plate" to "hot_plate_temp", + "Smooth PEI Plate" to "hot_plate_temp", + "Textured PEI Plate" to "textured_plate_temp", + ) + + /** Nothing known — a plain STL, a missing file, or a damaged zip. */ + val NONE = Project3mfSettings(null) + + /** + * Reads [path]'s embedded project settings. Anything that is not a readable + * 3MF carrying that entry yields [NONE] rather than throwing: a slice must + * still run on a file whose metadata we cannot parse. + */ + fun read(path: String?): Project3mfSettings { + if (path.isNullOrBlank() || !path.endsWith(".3mf", ignoreCase = true)) return NONE + val file = File(path) + if (!file.exists()) return NONE + return runCatching { + ZipFile(file).use { zip -> + val entry = zip.getEntry(PROJECT_SETTINGS) ?: return NONE + parse(zip.getInputStream(entry).bufferedReader().use { it.readText() }) + } + }.getOrDefault(NONE) + } + + /** + * The bundled machine profile for [asset], to compare a project against. + * + * Only the Bambu profiles carry print-scoped keys; the U1 and AD5X ones do + * not, so those legitimately come back with nothing to compare. + */ + fun readProfileAsset(context: Context, asset: String?): Project3mfSettings { + if (asset.isNullOrBlank()) return NONE + return runCatching { + parse( + context.assets.open("orca_profiles/printer/$asset") + .bufferedReader().use { it.readText() }, + ) + }.getOrDefault(NONE) + } + + /** Parses the config JSON body. Exposed for tests; never throws. */ + internal fun parse(json: String): Project3mfSettings = + runCatching { Project3mfSettings(JSONObject(json)) }.getOrDefault(NONE) + } +} diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/ZmodPrintPrompt.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/ZmodPrintPrompt.kt new file mode 100644 index 0000000..0b015d1 --- /dev/null +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/ZmodPrintPrompt.kt @@ -0,0 +1,133 @@ +package org.crabcore.u1control.slicing + +import org.json.JSONObject + +/** + * Answers zmod's "Select print materials" prompt from the preprocess sheet that + * already asked the same question. + * + * A FlashForge AD5X running zmod raises a Klipper prompt on every print start, + * asking which IFS lane feeds each tool. The operator has just answered exactly + * that on Helix's own sheet, so asking again is pure friction — and until this + * existed the print simply sat there unanswered. + * + * This lives natively, beside the screen that starts the print, rather than + * leaning on the RN layer: the prompt arrives within a second or two of the + * start, while Android is still handing control back, and a handoff that races + * the printer is a handoff that sometimes loses. + * + * Indexing: Helix holds slots 0-based, the macro takes `T=` with a + * 1-based lane. Slot s becomes lane s + 1. + */ +object ZmodPrintPrompt { + + private val TOOL_ARG = Regex("""(^|\s)T(\d+)=(-?\d+)""") + private val LEVELING_ARG = Regex("""(^|\s)LEVELING=(-?\d+)""", RegexOption.IGNORE_CASE) + private val FILENAME_ARG = Regex("""FILENAME\s*=\s*(?:"([^"]*)"|(\S+))""", RegexOption.IGNORE_CASE) + private val PROMPT_DIRECTIVE = Regex("""^\s*(?://)?\s*action:\s*(prompt_[a-z_]+)\s*(.*)$""", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + + /** The commit macro of an open, shown material prompt, or null. */ + fun pendingCommitMacro(messages: List): String? { + var open = false + var shown = false + var commit: String? = null + + for (line in messages) { + val match = PROMPT_DIRECTIVE.find(line) ?: continue + val directive = match.groupValues[1].lowercase() + val rest = match.groupValues[2].trim() + when (directive) { + // A fresh prompt discards whatever the last one was mid-composing. + "prompt_begin" -> { open = true; shown = false; commit = null } + "prompt_end" -> { open = false; shown = false; commit = null } + "prompt_show" -> if (open) shown = true + "prompt_footer_button", "prompt_button" -> { + if (!open) continue + val gcode = rest.split('|').getOrNull(1)?.trim().orEmpty() + if (gcode.startsWith("PRINT_ZCOLOR", ignoreCase = true)) commit = gcode + } + } + } + return if (open && shown) commit else null + } + + /** The file a macro names, unquoted. */ + fun macroFilename(gcode: String): String? { + val match = FILENAME_ARG.find(gcode) ?: return null + val value = match.groupValues[1].ifBlank { match.groupValues[2] } + return value.trim().ifBlank { null } + } + + /** Basename, case-insensitive — the printer's path and ours can differ. */ + private fun fileKey(name: String): String = + name.substringAfterLast('/').substringAfterLast('\\').trim().lowercase() + + fun macroTargetsFile(gcode: String, filename: String): Boolean { + val target = macroFilename(gcode) ?: return false + if (filename.isBlank()) return false + return fileKey(target) == fileKey(filename) + } + + /** + * Rewrites lane arguments from [toolToSlot] (0-based tool → 0-based slot). + * + * Only arguments the macro already carries are touched, and only tools that + * were actually mapped — inventing a lane for a tool we know nothing about + * would point the printer at a spool the operator never chose. + */ + fun applyToolSlots(gcode: String, toolToSlot: Map): String = + TOOL_ARG.replace(gcode) { match -> + val lead = match.groupValues[1] + val tool = match.groupValues[2] + val slot = toolToSlot[tool.toIntOrNull() ?: -1] + if (slot == null || slot < 0) match.value else "${lead}T$tool=${slot + 1}" + } + + /** Sets LEVELING=, which is the only way bed levelling reaches this machine. */ + fun applyLeveling(gcode: String, autoLevel: Boolean?): String { + if (autoLevel == null) return gcode + return LEVELING_ARG.replace(gcode) { match -> + "${match.groupValues[1]}LEVELING=${if (autoLevel) 1 else 0}" + } + } + + /** + * The macro that starts [filename] with the operator's own choices, or null + * when there is no matching prompt waiting. + */ + fun answerFor( + messages: List, + filename: String, + toolToSlot: Map, + autoLevel: Boolean?, + ): String? { + val commit = pendingCommitMacro(messages) ?: return null + if (!macroTargetsFile(commit, filename)) return null + return applyLeveling(applyToolSlots(commit, toolToSlot), autoLevel) + } + + /** Pulls the response strings out of a Moonraker `server/gcode_store` reply. */ + fun messagesFromStore(json: String): List = try { + val arr = JSONObject(json).optJSONObject("result")?.optJSONArray("gcode_store") + if (arr == null) emptyList() else (0 until arr.length()).mapNotNull { + arr.optJSONObject(it)?.optString("message")?.takeIf(String::isNotBlank) + } + } catch (_: Throwable) { + emptyList() + } + + /** + * Starts [filename] without the material station — zmod's per-print IFS-off + * path. Its own UI button "Hide color selection, print without IFS" sends + * exactly this: SILENT=2 skips the material-selection prompt entirely, and + * the print runs from the external side spool with every T-command in the + * G-code ignored. Nothing is persisted; the next print asks again. + * + * There is no per-tool variant of this: PRINT_ZCOLOR rejects slot 0, so an + * external spool can never be mapped in as one lane among several — IFS-off + * is all-or-nothing. + */ + fun ifsOffPrintGcode(filename: String, autoLevel: Boolean): String = + """SET_ZCOLOR FILENAME="$filename" SILENT=2 LEVELING=${if (autoLevel) 1 else 0}""" +} diff --git a/android/app/src/main/res/drawable/ic_printer.xml b/android/app/src/main/res/drawable/ic_printer.xml index 486542c..740994b 100644 --- a/android/app/src/main/res/drawable/ic_printer.xml +++ b/android/app/src/main/res/drawable/ic_printer.xml @@ -7,7 +7,7 @@ @@ -17,16 +17,16 @@ android:startY="180" android:endX="878" android:endY="870"> - - - + + + @@ -36,16 +36,16 @@ android:startY="180" android:endX="878" android:endY="870"> - - - + + + @@ -55,32 +55,32 @@ android:startY="334" android:endX="808" android:endY="392"> - - + + @@ -90,15 +90,15 @@ android:startY="424" android:endX="795" android:endY="766"> - - + + @@ -108,23 +108,23 @@ android:startY="459" android:endX="750" android:endY="487"> - - - + + + @@ -134,8 +134,8 @@ android:startY="647" android:endX="777" android:endY="730"> - - + + diff --git a/android/app/src/test/java/com/u1/slicer/gcode/GcodeParserTest.kt b/android/app/src/test/java/com/u1/slicer/gcode/GcodeParserTest.kt new file mode 100644 index 0000000..3cfe7f8 --- /dev/null +++ b/android/app/src/test/java/com/u1/slicer/gcode/GcodeParserTest.kt @@ -0,0 +1,55 @@ +package com.u1.slicer.gcode + +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Test + +class GcodeParserTest { + @Test + fun bambuMachineToolCodesDoNotReplaceThePrintableFilament() { + withGcode( + """ + M83 + T0 + T1000 + G1 X10 Y0 E1 + T255 + G1 X20 Y0 E1 + """.trimIndent(), + ) { file -> + val extruders = GcodeParser.parse(file).layers + .flatMap { it.moves } + .filter { it.type == MoveType.EXTRUDE } + .map { it.extruder } + + assertEquals(listOf(0, 0), extruders) + } + } + + @Test + fun legitimateMultiDigitFilamentIndicesStillWork() { + withGcode( + """ + M83 + T10 + G1 X10 Y0 E1 + """.trimIndent(), + ) { file -> + val extrusion = GcodeParser.parse(file).layers + .flatMap { it.moves } + .single { it.type == MoveType.EXTRUDE } + + assertEquals(10, extrusion.extruder) + } + } + + private fun withGcode(contents: String, block: (java.io.File) -> Unit) { + val path = Files.createTempFile("helix-gcode-parser-", ".gcode") + try { + Files.write(path, contents.toByteArray()) + block(path.toFile()) + } finally { + Files.deleteIfExists(path) + } + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bambu/BambuFtpsClientTest.kt b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuFtpsClientTest.kt new file mode 100644 index 0000000..88ea25d --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuFtpsClientTest.kt @@ -0,0 +1,125 @@ +package org.crabcore.u1control.bambu + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.InputStream + +class BambuFtpsClientTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `upload uses implicit FTPS root contract and verifies remote size`() { + val artifact = temporaryFolder.newFile("safe_job.gcode.3mf").apply { + writeBytes(byteArrayOf(1, 2, 3, 4)) + } + val session = RecordingSession(remoteSize = artifact.length()) + val client = BambuFtpsClient { session } + + val result = client.upload(CONFIG, artifact) + + assertEquals("safe_job.gcode.3mf", result.remoteName) + assertEquals(4L, result.verifiedBytes) + assertEquals( + listOf( + "connect:printer.local:990:15000:120000", + "login:bblp", + "binary", + "pbsz0-protp", + "passive", + "store:safe_job.gcode.3mf:4", + "size:safe_job.gcode.3mf", + "logout", + "close", + ), + session.calls, + ) + } + + @Test + fun `upload rejects paths and raw gcode before opening a connection`() { + val artifact = temporaryFolder.newFile("safe_job.gcode.3mf").apply { writeText("archive") } + var opened = false + val client = BambuFtpsClient { + opened = true + RecordingSession(artifact.length()) + } + + val pathError = runCatching { client.upload(CONFIG, artifact, "cache/safe_job.gcode.3mf") } + .exceptionOrNull() + val typeError = runCatching { client.upload(CONFIG, artifact, "safe_job.gcode") } + .exceptionOrNull() + + assertTrue(pathError?.message.orEmpty().contains("printer root")) + assertTrue(typeError?.message.orEmpty().contains("filename")) + assertTrue(!opened) + } + + @Test + fun `upload fails closed when SIZE does not match and always disconnects`() { + val artifact = temporaryFolder.newFile("safe_job.gcode.3mf").apply { writeText("archive") } + val session = RecordingSession(remoteSize = artifact.length() - 1) + val client = BambuFtpsClient { session } + + val error = runCatching { client.upload(CONFIG, artifact) }.exceptionOrNull() + + assertTrue(error?.message.orEmpty().contains("size verification failed")) + assertEquals("close", session.calls.last()) + assertTrue("logout" !in session.calls) + } + + private class RecordingSession(private val remoteSize: Long?) : BambuFtpsSession { + val calls = mutableListOf() + + override fun connect(host: String, port: Int, connectTimeoutMs: Int, dataTimeoutMs: Int) { + calls += "connect:$host:$port:$connectTimeoutMs:$dataTimeoutMs" + } + + override fun login(username: String, password: String): Boolean { + calls += "login:$username" + return true + } + + override fun useBinaryTransfers(): Boolean { + calls += "binary" + return true + } + + override fun protectDataChannel() { + calls += "pbsz0-protp" + } + + override fun usePassiveMode() { + calls += "passive" + } + + override fun store(remoteName: String, input: InputStream): Boolean { + calls += "store:$remoteName:${input.readBytes().size}" + return true + } + + override fun size(remoteName: String): Long? { + calls += "size:$remoteName" + return remoteSize + } + + override fun logout() { + calls += "logout" + } + + override fun close() { + calls += "close" + } + } + + private companion object { + val CONFIG = BambuFtpsConfig( + host = "printer.local", + serial = "01P00C000000000", + accessCode = "stored-code", + ) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bambu/BambuLiveCameraTest.kt b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuLiveCameraTest.kt new file mode 100644 index 0000000..7fecfae --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuLiveCameraTest.kt @@ -0,0 +1,175 @@ +// Live chamber-camera test. Skips without a printer, like the MQTT ones: +// +// BAMBU_HOST=192.168.1.x BAMBU_SERIAL=01P... BAMBU_ACCESS_CODE=12345678 \ +// ./gradlew :app:testDebugUnitTest --tests '*BambuLiveCameraTest*' +// +// crabcore + +package org.crabcore.u1control.bambu + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.lang.reflect.InvocationTargetException +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicReference + +class BambuLiveCameraTest { + + private val host = System.getenv("BAMBU_HOST").orEmpty() + private val serial = System.getenv("BAMBU_SERIAL").orEmpty() + private val accessCode = System.getenv("BAMBU_ACCESS_CODE").orEmpty() + + private fun requirePrinter() { + assumeTrue( + "Set BAMBU_HOST, BAMBU_SERIAL and BAMBU_ACCESS_CODE to run the live camera test", + host.isNotEmpty() && serial.isNotEmpty() && accessCode.isNotEmpty() + ) + } + + @Test + fun servesChamberFramesAsMjpeg() { + requirePrinter() + val camera = BambuChamberCamera() + + try { + val url = camera.start(host, serial, accessCode) + println("[bambu] camera serving at $url") + assertTrue("Must serve on loopback only", url.startsWith("http://127.0.0.1:")) + + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 5000 + readTimeout = 20000 + } + assertEquals(200, connection.responseCode) + assertTrue( + "Expected an MJPEG content type, got ${connection.contentType}", + connection.contentType.startsWith("multipart/x-mixed-replace") + ) + + val frames = readFrames(BufferedInputStream(connection.inputStream), wanted = 2) + connection.disconnect() + + assertEquals("Expected two frames", 2, frames.size) + frames.forEachIndexed { index, frame -> + assertTrue("Frame $index is too small at ${frame.size} bytes", frame.size > 1000) + // JPEG start- and end-of-image markers. + assertEquals("Frame $index has no JPEG SOI", 0xFFD8, marker(frame, 0)) + assertEquals("Frame $index has no JPEG EOI", 0xFFD9, marker(frame, frame.size - 2)) + } + + val saved = dumpDirectory().resolve("bambu-chamber-frame.jpg") + saved.writeBytes(frames.last()) + println("[bambu] ${frames.size} frames, last ${frames.last().size} bytes -> ${saved.absolutePath}") + } finally { + camera.stop() + } + } + + @Test + fun refusesAPrinterWithADifferentSerial() { + requirePrinter() + val camera = BambuChamberCamera() + + try { + camera.start(host, "01PDEADBEEF000000", accessCode) + fail("Expected the camera handshake to be refused for a mismatched serial") + } catch (e: BambuConnectException) { + assertEquals("wrong-serial", e.code) + println("[bambu] camera serial mismatch refused: ${e.message}") + } finally { + camera.stop() + } + } + + @Test + fun interruptingIdleViewerIsNormalShutdown() { + val camera = BambuChamberCamera() + val running = BambuChamberCamera::class.java.getDeclaredField("running").apply { + isAccessible = true + setBoolean(camera, true) + } + val streamTo = BambuChamberCamera::class.java + .getDeclaredMethod("streamTo", java.io.OutputStream::class.java) + .apply { isAccessible = true } + val failure = AtomicReference(null) + + val viewer = Thread { + try { + streamTo.invoke(camera, ByteArrayOutputStream()) + } catch (error: InvocationTargetException) { + failure.set(error.targetException) + } catch (error: Throwable) { + failure.set(error) + } + } + viewer.start() + + val deadline = System.currentTimeMillis() + 2000 + while (viewer.state != Thread.State.TIMED_WAITING && System.currentTimeMillis() < deadline) { + Thread.yield() + } + viewer.interrupt() + viewer.join(2000) + running.setBoolean(camera, false) + + assertFalse("Interrupted camera viewer did not stop", viewer.isAlive) + assertNull("Normal camera shutdown escaped as an exception", failure.get()) + } + + private fun marker(frame: ByteArray, offset: Int): Int = + ((frame[offset].toInt() and 0xFF) shl 8) or (frame[offset + 1].toInt() and 0xFF) + + /** Minimal multipart reader: enough to prove the stream is well-formed. */ + private fun readFrames(input: BufferedInputStream, wanted: Int): List { + val frames = mutableListOf() + + while (frames.size < wanted) { + var length = -1 + // Headers run until a blank line; Content-Length tells us the frame size. + while (true) { + val line = readLine(input) ?: return frames + if (line.isEmpty() && length >= 0) break + if (line.startsWith("Content-Length:", ignoreCase = true)) { + length = line.substringAfter(':').trim().toInt() + } + } + + val frame = ByteArray(length) + var read = 0 + while (read < length) { + val count = input.read(frame, read, length - read) + if (count < 0) return frames + read += count + } + frames.add(frame) + } + + return frames + } + + private fun readLine(input: BufferedInputStream): String? { + val builder = StringBuilder() + while (true) { + val byte = input.read() + if (byte < 0) return null + if (byte == '\n'.code) return builder.toString().trimEnd('\r') + builder.append(byte.toChar()) + } + } + + private fun dumpDirectory(): File { + val configured = System.getenv("BAMBU_DUMP_DIR").orEmpty() + val dir = if (configured.isNotEmpty()) File(configured) else File("build/reports/bambu") + dir.mkdirs() + return dir + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bambu/BambuLiveConnectionTest.kt b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuLiveConnectionTest.kt new file mode 100644 index 0000000..6a067a3 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuLiveConnectionTest.kt @@ -0,0 +1,194 @@ +// Live tests against a real Bambu printer in LAN Only Mode. These are the only +// way to prove the TLS and auth path actually works — the rest of the suite can +// only prove it compiles. +// +// They skip unless the printer's details are in the environment, so CI and +// anyone without the hardware stay green: +// +// BAMBU_HOST=192.168.1.x BAMBU_SERIAL=01P... BAMBU_ACCESS_CODE=12345678 \ +// ./gradlew :app:testDebugUnitTest --tests '*BambuLiveConnectionTest*' +// +// Nothing machine-specific belongs in this file. +// crabcore + +package org.crabcore.u1control.bambu + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit + +class BambuLiveConnectionTest { + + @Test + fun replacedSessionCannotOwnNewConnectionCallbacks() { + val sessions = BambuConnectionSessionGate() + val first = sessions.begin() + assertTrue(sessions.owns(first)) + + val replacement = sessions.begin() + assertFalse(sessions.owns(first)) + assertTrue(sessions.owns(replacement)) + + sessions.invalidate() + assertFalse(sessions.owns(replacement)) + } + + private val host = System.getenv("BAMBU_HOST").orEmpty() + private val serial = System.getenv("BAMBU_SERIAL").orEmpty() + private val accessCode = System.getenv("BAMBU_ACCESS_CODE").orEmpty() + + private fun requirePrinter() { + assumeTrue( + "Set BAMBU_HOST, BAMBU_SERIAL and BAMBU_ACCESS_CODE to run the live Bambu tests", + host.isNotEmpty() && serial.isNotEmpty() && accessCode.isNotEmpty() + ) + } + + @Test + fun connectsAndReceivesFullState() { + requirePrinter() + + val reports = CopyOnWriteArrayList() + val firstReport = CountDownLatch(1) + val connection = BambuMqttConnection(object : BambuMqttConnection.Listener { + override fun onReport(payload: String) { + reports.add(payload) + firstReport.countDown() + } + + override fun onStateChange(state: String, message: String?) { + println("[bambu] state=$state ${message.orEmpty()}") + } + }) + + try { + connection.connect(host, BambuMqttConnection.DEFAULT_PORT, serial, accessCode) + .get(20, TimeUnit.SECONDS) + println("[bambu] connected and subscribed") + + // Bambu only pushes deltas after the first report, so without pushall a + // freshly connected client can sit there seeing almost nothing. + connection.publish("""{"pushing":{"sequence_id":"1","command":"pushall"}}""") + .get(10, TimeUnit.SECONDS) + + assertTrue( + "No report arrived within 30s of pushall", + firstReport.await(30, TimeUnit.SECONDS) + ) + + // Give the printer a moment to finish a multi-message dump before saving. + Thread.sleep(3000) + + val dump = dumpDirectory().resolve("bambu-report-dump.json") + dump.writeText(reports.joinToString(separator = ",\n", prefix = "[\n", postfix = "\n]")) + println("[bambu] captured ${reports.size} report(s) -> ${dump.absolutePath}") + } finally { + connection.close() + } + } + + /** + * The whole point of pinning the certificate CN: a printer that is not the one + * we configured must be refused, rather than silently dropping the connection + * the way a bad serial does at the MQTT layer. + */ + @Test + fun rejectsMismatchedSerial() { + requirePrinter() + + val connection = BambuMqttConnection(object : BambuMqttConnection.Listener { + override fun onReport(payload: String) = Unit + override fun onStateChange(state: String, message: String?) = Unit + }) + + try { + connection.connect(host, BambuMqttConnection.DEFAULT_PORT, "01PDEADBEEF000000", accessCode) + .get(20, TimeUnit.SECONDS) + fail("Expected the handshake to be refused for a serial the printer does not have") + } catch (e: ExecutionException) { + val cause = e.cause + assertTrue( + "Expected a wrong-serial failure, got: $cause", + cause is BambuConnectException && cause.code == "wrong-serial" + ) + println("[bambu] serial mismatch correctly refused: ${cause?.message}") + } finally { + connection.close() + } + } + + /** + * Pins down what a bad access code actually looks like on the wire, so the + * settings screen can say "wrong access code" instead of "connection failed". + */ + @Test + fun reportsBadAccessCodeDistinctly() { + requirePrinter() + + val connection = BambuMqttConnection(object : BambuMqttConnection.Listener { + override fun onReport(payload: String) = Unit + override fun onStateChange(state: String, message: String?) = Unit + }) + + try { + connection.connect(host, BambuMqttConnection.DEFAULT_PORT, serial, "00000000") + .get(20, TimeUnit.SECONDS) + fail("Expected the printer to refuse a bad access code") + } catch (e: ExecutionException) { + val cause = e.cause + assertTrue( + "Expected a wrong-access-code failure, got: $cause", + cause is BambuConnectException && cause.code == "wrong-access-code" + ) + println("[bambu] bad access code correctly refused: ${cause?.message}") + } finally { + connection.close() + } + } + + /** + * Proves a command actually reaches the printer. The chamber light is the + * only thing safe to drive from a test: it is instantly visible, and it moves + * no filament and starts no print. Restores itself either way. + */ + @Test + fun togglesChamberLight() { + requirePrinter() + + val connection = BambuMqttConnection(object : BambuMqttConnection.Listener { + override fun onReport(payload: String) = Unit + override fun onStateChange(state: String, message: String?) = Unit + }) + + fun light(on: String) = + """{"system":{"sequence_id":"1","command":"ledctrl","led_node":"chamber_light",""" + + """"led_mode":"$on","led_on_time":500,"led_off_time":500,"loop_times":0,"interval_time":0}}""" + + try { + connection.connect(host, BambuMqttConnection.DEFAULT_PORT, serial, accessCode) + .get(20, TimeUnit.SECONDS) + + connection.publish(light("off")).get(10, TimeUnit.SECONDS) + println("[bambu] chamber light OFF") + Thread.sleep(3000) + connection.publish(light("on")).get(10, TimeUnit.SECONDS) + println("[bambu] chamber light ON") + } finally { + connection.close() + } + } + + private fun dumpDirectory(): File { + val configured = System.getenv("BAMBU_DUMP_DIR").orEmpty() + val dir = if (configured.isNotEmpty()) File(configured) else File("build/reports/bambu") + dir.mkdirs() + return dir + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintArtifactTest.kt b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintArtifactTest.kt new file mode 100644 index 0000000..d14ac04 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintArtifactTest.kt @@ -0,0 +1,148 @@ +package org.crabcore.u1control.bambu + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.security.MessageDigest +import java.util.Base64 +import java.util.zip.ZipFile + +class BambuPrintArtifactTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `build writes firmware-ready project with matching uppercase gcode md5`() { + val gcode = temporaryFolder.newFile("plate.gcode").apply { writeText(P1S_GCODE) } + val output = temporaryFolder.root.resolve("safe_job.gcode.3mf") + + val result = BambuPrintArtifact.build( + gcodeFile = gcode, + outputFile = output, + projectSettingsJson = p1sProjectSettings().toString(), + thumbnailPng = THUMBNAIL, + metadata = BambuPrintArtifact.Metadata(710, 1.21, "PLA", "#e4bd68"), + ) + + assertEquals(md5(output.readBytes()), result.archiveMd5) + assertEquals(md5(P1S_GCODE.toByteArray()), result.gcodeMd5) + assertEquals( + listOf( + BambuPrintArtifact.PrintableObject(42, "Bracket & Cap (copy 1)"), + BambuPrintArtifact.PrintableObject(77, "Bracket & Cap (copy 2)"), + ), + result.objects, + ) + ZipFile(output).use { zip -> + val names = zip.entries().asSequence().map { it.name }.toSet() + assertTrue(REQUIRED_ENTRIES.all(names::contains)) + assertEquals(result.gcodeMd5, zip.readText("Metadata/plate_1.gcode.md5")) + assertEquals(P1S_GCODE, zip.readText("Metadata/plate_1.gcode")) + assertTrue(zip.readText("Metadata/slice_info.config").contains("printer_model_id\" value=\"C12")) + assertTrue(zip.readText("Metadata/slice_info.config").contains("color=\"#E4BD68\"")) + assertTrue(zip.readText("Metadata/slice_info.config").contains("identify_id=\"42\" name=\"Bracket & Cap (copy 1)\"")) + assertTrue(zip.readText("Metadata/slice_info.config").contains("identify_id=\"77\" name=\"Bracket & Cap (copy 2)\"")) + assertEquals("Bambu Lab P1S", JSONObject(zip.readText("Metadata/project_settings.config")).getString("printer_model")) + } + } + + @Test + fun `build refuses generic or U1 gcode even when archive settings say P1S`() { + val gcode = temporaryFolder.newFile("u1.gcode").apply { + writeText(P1S_GCODE.replace("machine: P1S-0.4", "machine: Snapmaker U1")) + } + + val error = runCatching { + BambuPrintArtifact.build( + gcode, + temporaryFolder.root.resolve("unsafe.gcode.3mf"), + p1sProjectSettings().toString(), + THUMBNAIL, + BambuPrintArtifact.Metadata(1, 1.0, "PLA", "#FFFFFF"), + ) + }.exceptionOrNull() + + assertTrue(error is IllegalArgumentException) + assertTrue(error?.message.orEmpty().contains("P1S profile")) + assertFalse(temporaryFolder.root.resolve("unsafe.gcode.3mf").exists()) + } + + @Test + fun `build refuses incomplete project settings and one pixel thumbnails`() { + val gcode = temporaryFolder.newFile("plate.gcode").apply { writeText(P1S_GCODE) } + val incomplete = JSONObject() + .put("printer_model", "Bambu Lab P1S") + .put("printer_settings_id", "Bambu Lab P1S 0.4 nozzle") + .put("machine_start_gcode", "; machine: P1S-0.4") + + val settingsError = runCatching { + BambuPrintArtifact.build( + gcode, + temporaryFolder.root.resolve("incomplete.gcode.3mf"), + incomplete.toString(), + THUMBNAIL, + BambuPrintArtifact.Metadata(1, 1.0, "PLA", "#FFFFFF"), + ) + }.exceptionOrNull() + assertTrue(settingsError?.message.orEmpty().contains("incomplete")) + + val onePixel = THUMBNAIL.copyOf().apply { + this[19] = 1 + this[23] = 1 + } + val thumbnailError = runCatching { + BambuPrintArtifact.build( + gcode, + temporaryFolder.root.resolve("tiny.gcode.3mf"), + p1sProjectSettings().toString(), + onePixel, + BambuPrintArtifact.Metadata(1, 1.0, "PLA", "#FFFFFF"), + ) + }.exceptionOrNull() + assertTrue(thumbnailError?.message.orEmpty().contains("larger than 1x1")) + } + + private fun p1sProjectSettings(): JSONObject = JSONObject().apply { + repeat(500) { index -> put("verified_setting_$index", index.toString()) } + put("printer_model", "Bambu Lab P1S") + put("printer_settings_id", "Bambu Lab P1S 0.4 nozzle") + put("machine_start_gcode", ";===== machine: P1S-0.4 ========================") + } + + private fun ZipFile.readText(name: String): String = + getInputStream(getEntry(name)).bufferedReader().use { it.readText() } + + private fun md5(bytes: ByteArray): String = MessageDigest.getInstance("MD5") + .digest(bytes) + .joinToString("") { "%02X".format(it) } + + private companion object { + const val P1S_GCODE = "; HEADER_BLOCK_START\n; total layer number: 1\n; model label id: 42,77\n; HEADER_BLOCK_END\n; CONFIG_BLOCK_START\n; machine_start_gcode = ;===== machine: P1S-0.4 ========================\n; CONFIG_BLOCK_END\n; EXECUTABLE_BLOCK_START\n; printing object Bracket & Cap id:1 copy 0\n; start printing object, unique label id: 42\nM624 AQ==\nM625\n; printing object Bracket & Cap id:1 copy 1\n; start printing object, unique label id: 77\nM624 Ag==\nM625\n; printing object Bracket & Cap id:1 copy 0\n; start printing object, unique label id: 42\nG28\n" + + val THUMBNAIL: ByteArray = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAAqADAAQAAAABAAAAAgAAAADtGLyqAAAAGUlEQVQIHWNc1RfKyQAETOz8P1l+sXxhBQAmRQSkZjmXUAAAAABJRU5ErkJggg==" + ) + + val REQUIRED_ENTRIES = setOf( + "[Content_Types].xml", + "_rels/.rels", + "3D/3dmodel.model", + "Metadata/project_settings.config", + "Metadata/plate_1.json", + "Metadata/plate_1.gcode.md5", + "Metadata/plate_1.gcode", + "Metadata/_rels/model_settings.config.rels", + "Metadata/model_settings.config", + "Metadata/slice_info.config", + "Metadata/plate_1.png", + "Metadata/plate_1_small.png", + "Metadata/plate_no_light_1.png", + "Metadata/top_1.png", + "Metadata/pick_1.png", + ) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintProtocolTest.kt b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintProtocolTest.kt new file mode 100644 index 0000000..eb397a0 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintProtocolTest.kt @@ -0,0 +1,117 @@ +package org.crabcore.u1control.bambu + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class BambuPrintProtocolTest { + + @Test + fun matchesThePayloadAcceptedByTheRealP1s() { + val payload = BambuPrintProtocol.buildProjectFilePayload( + BambuPrintProtocol.ProjectFileCommand( + sequenceId = "20000", + fileName = "Small_Tiny_Mini_Micro_Hen.gcode.3mf", + subtaskName = "Small_Tiny_Mini_Micro_Hen", + md5 = "ead10b6e54929a62386c9639a39f344a", + toolToLane = mapOf(0 to 2), + bedType = "supertack_plate", + useAms = true, + bedLeveling = true, + flowCalibration = false, + timelapse = false, + ) + ) + + val print = JSONObject(payload).getJSONObject("print") + assertEquals("project_file", print.getString("command")) + assertEquals("ftp://Small_Tiny_Mini_Micro_Hen.gcode.3mf", print.getString("url")) + assertEquals("Metadata/plate_1.gcode", print.getString("param")) + assertEquals("EAD10B6E54929A62386C9639A39F344A", print.getString("md5")) + assertEquals(listOf(2, -1, -1, -1), print.getJSONArray("ams_mapping").intList()) + assertEquals( + listOf(0 to 2, 255 to 255, 255 to 255, 255 to 255), + print.getJSONArray("ams_mapping2").mappingList(), + ) + assertEquals(1, print.getInt("auto_bed_leveling")) + assertEquals(2, print.getInt("extrude_cali_flag")) + assertEquals(0, print.getInt("extrude_cali_manual_mode")) + assertEquals(2, print.getInt("nozzle_offset_cali")) + assertEquals(true, print.getBoolean("bed_leveling")) + assertEquals(false, print.getBoolean("flow_cali")) + assertEquals(true, print.getBoolean("layer_inspect")) + assertEquals(false, print.getBoolean("timelapse")) + assertEquals(true, print.getBoolean("use_ams")) + assertEquals(false, print.getBoolean("vibration_cali")) + } + + @Test + fun mapsGlobalLanesAcrossMultipleAmsUnits() { + val print = JSONObject( + BambuPrintProtocol.buildProjectFilePayload(command(mapOf(0 to 0, 1 to 5, 2 to -1))) + ).getJSONObject("print") + + assertEquals( + listOf(0 to 0, 1 to 1, 255 to 255, 255 to 255), + print.getJSONArray("ams_mapping2").mappingList(), + ) + } + + @Test + fun disablingAmsRoutesEveryToolToTheExternalSpool() { + val print = JSONObject( + BambuPrintProtocol.buildProjectFilePayload(command(mapOf(0 to 3), useAms = false)) + ).getJSONObject("print") + + assertEquals(listOf(-1, -1, -1, -1), print.getJSONArray("ams_mapping").intList()) + } + + @Test + fun rejectsPathsAndNonPrintArchives() { + assertThrows(IllegalArgumentException::class.java) { + BambuPrintProtocol.buildProjectFilePayload(command(emptyMap()).copy(fileName = "cache/job.gcode.3mf")) + } + assertThrows(IllegalArgumentException::class.java) { + BambuPrintProtocol.buildProjectFilePayload(command(emptyMap()).copy(fileName = "job.gcode")) + } + } + + @Test + fun acceptsOnlyTheMatchingSuccessfulPrinterResponse() { + val success = """{"print":{"command":"project_file","sequence_id":"42","result":"success"}}""" + val rejected = """{"print":{"command":"project_file","sequence_id":"42","result":"failed"}}""" + val otherSequence = """{"print":{"command":"project_file","sequence_id":"41","result":"success"}}""" + val telemetry = """{"print":{"command":"push_status","sequence_id":"42"}}""" + + assertEquals(BambuPrintProtocol.Acknowledgement.SUCCESS, BambuPrintProtocol.acknowledgement(success, "42")) + assertEquals(BambuPrintProtocol.Acknowledgement.REJECTED, BambuPrintProtocol.acknowledgement(rejected, "42")) + assertEquals(BambuPrintProtocol.Acknowledgement.NOT_MATCHING, BambuPrintProtocol.acknowledgement(otherSequence, "42")) + assertEquals(BambuPrintProtocol.Acknowledgement.NOT_MATCHING, BambuPrintProtocol.acknowledgement(telemetry, "42")) + assertEquals(BambuPrintProtocol.Acknowledgement.NOT_MATCHING, BambuPrintProtocol.acknowledgement("not json", "42")) + } + + private fun command( + mapping: Map, + useAms: Boolean = true, + ) = BambuPrintProtocol.ProjectFileCommand( + sequenceId = "42", + fileName = "job.gcode.3mf", + subtaskName = "job", + md5 = "0123456789ABCDEF0123456789ABCDEF", + toolToLane = mapping, + bedType = "supertack_plate", + useAms = useAms, + bedLeveling = true, + flowCalibration = false, + timelapse = false, + ) + + private fun org.json.JSONArray.intList(): List = + (0 until length()).map(::getInt) + + private fun org.json.JSONArray.mappingList(): List> = + (0 until length()).map { index -> + getJSONObject(index).let { it.getInt("ams_id") to it.getInt("slot_id") } + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintUploadTest.kt b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintUploadTest.kt new file mode 100644 index 0000000..5a5402a --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuPrintUploadTest.kt @@ -0,0 +1,162 @@ +package org.crabcore.u1control.bambu + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.Base64 +import java.util.zip.ZipFile + +class BambuPrintUploadTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `upload packages verified single-tool gcode before FTPS transfer`() { + val gcode = gcodeWithThumbnail() + var transferCalled = false + val subject = BambuPrintUpload(temporaryFolder.root, projectSettings()) { config, artifact, remote -> + transferCalled = true + assertEquals("192.0.2.1", config.host) + assertEquals("safe_job.gcode.3mf", remote) + ZipFile(artifact).use { zip -> + assertTrue(zip.getEntry("Metadata/plate_1.gcode") != null) + assertEquals(THUMBNAIL.toList(), zip.getInputStream(zip.getEntry("Metadata/plate_1.png")).readBytes().toList()) + } + BambuFtpsUploadResult(remote, artifact.length()) + } + + val result = subject.upload(request(gcode)) + + assertTrue(transferCalled) + assertEquals("safe_job.gcode.3mf", result.remoteName) + assertTrue(result.archiveMd5.matches(Regex("[0-9A-F]{32}"))) + assertFalse(temporaryFolder.root.listFiles().orEmpty().any { it.name.endsWith(".gcode.3mf") }) + } + + @Test + fun `A1 serial selects A1 settings and N2S archive metadata`() { + val gcode = gcodeWithThumbnail(A1_PREFIX, "a1.gcode") + var selectedSerial = "" + val subject = BambuPrintUpload( + temporaryFolder.root, + { serial -> + selectedSerial = serial + a1ProjectSettings() + }, + { config, artifact, remote -> + assertEquals("03900A000000001", config.serial) + ZipFile(artifact).use { zip -> + val project = JSONObject(zip.readText("Metadata/project_settings.config")) + assertEquals("Bambu Lab A1", project.getString("printer_model")) + assertTrue(zip.readText("Metadata/slice_info.config").contains("value=\"N2S\"")) + } + BambuFtpsUploadResult(remote, artifact.length()) + }, + ) + + subject.upload(request(gcode).copy(serial = "03900A000000001")) + + assertEquals("03900A000000001", selectedSerial) + } + + @Test + fun `A1 settings reject P1S gcode before transfer`() { + val gcode = gcodeWithThumbnail(P1S_PREFIX, "wrong-model.gcode") + var transferCalled = false + val subject = BambuPrintUpload(temporaryFolder.root, { a1ProjectSettings() }) { _, _, _ -> + transferCalled = true + error("must not upload") + } + + val error = runCatching { + subject.upload(request(gcode).copy(serial = "03900A000000001")) + }.exceptionOrNull() + + assertTrue(error?.message.orEmpty().contains("Bambu Lab A1 profile")) + assertFalse(transferCalled) + } + + @Test + fun `upload refuses remapped or multicolor tool masks before transfer`() { + val gcode = gcodeWithThumbnail() + var transferCalled = false + val subject = BambuPrintUpload(temporaryFolder.root, projectSettings()) { _, _, _ -> + transferCalled = true + error("must not upload") + } + + for (mask in listOf(0, 2, 3, 15)) { + val error = runCatching { subject.upload(request(gcode).copy(usedToolMask = mask)) }.exceptionOrNull() + assertTrue(error?.message.orEmpty().contains("tool 0")) + } + assertFalse(transferCalled) + } + + @Test + fun `upload refuses missing thumbnails before transfer`() { + val gcode = temporaryFolder.newFile("no-preview.gcode").apply { writeText(P1S_PREFIX + "G28\n") } + var transferCalled = false + val subject = BambuPrintUpload(temporaryFolder.root, projectSettings()) { _, _, _ -> + transferCalled = true + error("must not upload") + } + + val error = runCatching { subject.upload(request(gcode)) }.exceptionOrNull() + + assertTrue(error?.message.orEmpty().contains("preview")) + assertFalse(transferCalled) + } + + private fun gcodeWithThumbnail( + prefix: String = P1S_PREFIX, + name: String = "plate.gcode", + ): File { + val encoded = Base64.getEncoder().encodeToString(THUMBNAIL) + return temporaryFolder.newFile(name).apply { + writeText("$prefix; thumbnail begin 2x2 ${encoded.length}\n; $encoded\n; thumbnail end\nG28\n") + } + } + + private fun request(gcode: File) = BambuPrintUpload.Request( + host = "192.0.2.1", + serial = "01P00A000000001", + accessCode = "12345678", + gcodeFile = gcode, + remoteName = "safe_job.gcode.3mf", + usedToolMask = 1, + predictionSeconds = 710, + weightGrams = 1.21, + filamentType = "PLA", + filamentColor = "#E4BD68", + ) + + private fun projectSettings(): String = JSONObject().apply { + repeat(500) { put("verified_setting_$it", it.toString()) } + put("printer_model", "Bambu Lab P1S") + put("printer_settings_id", "Bambu Lab P1S 0.4 nozzle") + put("machine_start_gcode", ";===== machine: P1S-0.4 ========================") + }.toString() + + private fun a1ProjectSettings(): String = JSONObject().apply { + repeat(500) { put("verified_setting_$it", it.toString()) } + put("printer_model", "Bambu Lab A1") + put("printer_settings_id", "Bambu Lab A1 0.4 nozzle") + put("machine_start_gcode", ";===== machine: A1 =========================") + }.toString() + + private fun ZipFile.readText(name: String): String = + getInputStream(getEntry(name)).bufferedReader().use { it.readText() } + + private companion object { + const val P1S_PREFIX = "; HEADER_BLOCK_START\n; HEADER_BLOCK_END\n; CONFIG_BLOCK_START\n; machine_start_gcode = ;===== machine: P1S-0.4 ========================\n; CONFIG_BLOCK_END\n; EXECUTABLE_BLOCK_START\n" + const val A1_PREFIX = "; HEADER_BLOCK_START\n; HEADER_BLOCK_END\n; CONFIG_BLOCK_START\n; machine_start_gcode = ;===== machine: A1 =========================\n; CONFIG_BLOCK_END\n; EXECUTABLE_BLOCK_START\n" + val THUMBNAIL: ByteArray = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAAqADAAQAAAABAAAAAgAAAADtGLyqAAAAGUlEQVQIHWNc1RfKyQAETOz8P1l+sXxhBQAmRQSkZjmXUAAAAABJRU5ErkJggg==" + ) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bambu/BambuStatusProbeTest.kt b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuStatusProbeTest.kt new file mode 100644 index 0000000..27b1aa0 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bambu/BambuStatusProbeTest.kt @@ -0,0 +1,83 @@ +package org.crabcore.u1control.bambu + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit + +class BambuStatusProbeTest { + @Test + fun `probe requests full state ignores deltas and closes after full report`() { + lateinit var fake: FakeConnection + val subject = BambuStatusProbe(1_000) { listener -> + FakeConnection(listener).also { fake = it } + } + + val result = subject.probe(CONFIG) + + assertEquals(PUSH_ALL, fake.published) + fake.listener.onReport("{\"print\":{\"mc_percent\":12}}") + assertFalse(result.isDone) + val full = "{\"print\":{\"gcode_state\":\"IDLE\",\"mc_percent\":0}}" + fake.listener.onReport(full) + assertEquals(full, result.get(1, TimeUnit.SECONDS)) + assertTrue(fake.closed) + } + + @Test + fun `probe fails and closes when connection fails`() { + lateinit var fake: FakeConnection + val subject = BambuStatusProbe(1_000) { listener -> + FakeConnection(listener, BambuConnectException("connect-failed", "offline")).also { fake = it } + } + + val error = runCatching { subject.probe(CONFIG).get(1, TimeUnit.SECONDS) }.exceptionOrNull() + + assertTrue(error is ExecutionException) + assertTrue(error?.cause is BambuConnectException) + assertTrue(fake.closed) + } + + @Test + fun `probe times out when printer never returns a full state`() { + lateinit var fake: FakeConnection + val subject = BambuStatusProbe(25) { listener -> + FakeConnection(listener).also { fake = it } + } + + val error = runCatching { subject.probe(CONFIG).get(1, TimeUnit.SECONDS) }.exceptionOrNull() + + assertTrue(error is ExecutionException) + assertEquals("probe-timeout", (error?.cause as BambuConnectException).code) + assertTrue(fake.closed) + } + + private class FakeConnection( + val listener: BambuMqttConnection.Listener, + private val connectError: Throwable? = null, + ) : BambuProbeConnection { + var published: String? = null + var closed = false + + override fun connect(config: BambuStatusProbeConfig): CompletableFuture = + if (connectError == null) CompletableFuture.completedFuture(null) + else CompletableFuture().apply { completeExceptionally(connectError) } + + override fun publish(payload: String): CompletableFuture { + published = payload + return CompletableFuture.completedFuture(null) + } + + override fun close() { + closed = true + } + } + + private companion object { + val CONFIG = BambuStatusProbeConfig("192.0.2.1", "01P00A000000001", "12345678") + const val PUSH_ALL = "{\"pushing\":{\"sequence_id\":\"0\",\"command\":\"pushall\"}}" + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dBootstrapPackagesTest.kt b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dBootstrapPackagesTest.kt new file mode 100644 index 0000000..400d14f --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dBootstrapPackagesTest.kt @@ -0,0 +1,63 @@ +package org.crabcore.u1control.bespok3d + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.File + +class Bespok3dBootstrapPackagesTest { + private fun bundleBytes(): ByteArray { + val appProject = System.getProperty("helix.appProjectDir") + ?: throw IllegalStateException("helix.appProjectDir test property is missing") + return File(appProject, "src/main/assets/${Bespok3dBootstrapPackages.ASSET_PATH}").readBytes() + } + + @Test + fun verifiesTheExactOfficialBootstrapReleaseAndEveryPayloadHash() { + val packages = Bespok3dBootstrapPackages.load(ByteArrayInputStream(bundleBytes())) + + assertEquals("bespok3d-daemon", packages.daemon.name) + assertEquals("0.12.24", packages.daemon.version) + assertEquals(127, packages.daemon.files.size) + assertTrue(packages.daemon.files.any { it.path == "S99bespok3d" }) + assertTrue(packages.daemon.files.any { it.path == "s10bespok3d-daemon" }) + assertTrue(packages.daemon.files.any { it.path.startsWith("wheels/") }) + + assertEquals("bespok3d-jinni-snapmaker-u1", packages.jinni.name) + assertEquals("0.1.10", packages.jinni.version) + assertEquals(43, packages.jinni.files.size) + assertTrue(packages.jinni.files.any { it.path == "bespok3d_jinni.py" }) + assertTrue(packages.jinni.files.any { it.path == "paths.json" }) + + (packages.daemon.files + packages.jinni.files).forEach { file -> + assertTrue(file.path.isNotBlank()) + assertTrue(file.bytes.isNotEmpty()) + assertTrue(file.mode in 0..0b111_111_111) + } + } + + @Test + fun rejectsAnyChangeToThePinnedReleaseBytes() { + val corrupted = bundleBytes() + corrupted[corrupted.size / 2] = (corrupted[corrupted.size / 2].toInt() xor 0x01).toByte() + + assertThrows(Exception::class.java) { + Bespok3dBootstrapPackages.load(ByteArrayInputStream(corrupted)) + } + } + + @Test + fun rejectsTraversalAbsoluteWindowsAndAmbiguousPaths() { + for (path in listOf("../escape", "dir/../escape", "/absolute", "C:\\escape", "dir//file", "./file")) { + assertThrows(IllegalArgumentException::class.java) { + Bespok3dBootstrapPackages.validateRelativePath(path) + } + } + assertEquals( + "wheels/package.whl", + Bespok3dBootstrapPackages.validateRelativePath("wheels/package.whl"), + ) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dProtocolTest.kt b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dProtocolTest.kt new file mode 100644 index 0000000..4d54839 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dProtocolTest.kt @@ -0,0 +1,259 @@ +package org.crabcore.u1control.bespok3d + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class Bespok3dProtocolTest { + private fun bundledHelixScreenBytes(): ByteArray { + val appProject = System.getProperty("helix.appProjectDir") + ?: throw IllegalStateException("helix.appProjectDir test property is missing") + return File( + appProject, + "src/main/assets/${Bespok3dProtocol.BUNDLED_HELIXSCREEN_ASSET}", + ).readBytes() + } + + @Test + fun buildsThePublishedAccessRequestContractExactly() { + val json = JSONObject( + Bespok3dProtocol.accessRequestBody( + label = "Crabman's Pixel", + identity = "helix-1234_abcd", + token = "0123456789abcdef0123456789abcdef", + publicKey = "PUBLIC KEY", + ) + ) + + assertEquals( + setOf("label", "identity", "token", "public_key"), + json.keys().asSequence().toSet(), + ) + assertEquals("Crabman's Pixel", json.getString("label")) + assertEquals("helix-1234_abcd", json.getString("identity")) + assertEquals("0123456789abcdef0123456789abcdef", json.getString("token")) + assertEquals("PUBLIC KEY", json.getString("public_key")) + } + + @Test + fun rejectsCredentialsTheDaemonWouldReject() { + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.accessRequestBody("Phone", "identity with spaces", "0123456789abcdef") + } + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.accessRequestBody("Phone", "helix-phone", "too-short") + } + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.accessRequestBody("Phone\nInjected", "helix-phone", "0123456789abcdef") + } + } + + @Test + fun recognizesOnlyTheOfficialDaemonSourceOffer() { + val cert = "-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----" + val fingerprint = Bespok3dProtocol.certificateSha256("fixture".toByteArray()) + val probe = Bespok3dProtocol.parseProbe( + """{"version":"0.12.24","license":"AGPL-3.0-or-later","source":"https://github.com/Bespok3d/daemon"}""", + cert, + fingerprint, + ) + assertEquals("0.12.24", probe.version) + assertEquals(cert, probe.certificatePem) + assertEquals(fingerprint, probe.certificateSha256) + assertEquals(32, fingerprint.split(':').size) + + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.parseProbe( + """{"version":"0.12.24","license":"AGPL-3.0-or-later","source":"https://example.com/lookalike"}""", + cert, + fingerprint, + ) + } + } + + @Test + fun parsesPendingAccessAndHealthyStatusWithoutExposingExtraFields() { + val cert = "-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----" + assertEquals(cert, Bespok3dProtocol.parseAccessResponse(JSONObject() + .put("ok", true) + .put("cert", cert) + .toString())) + + assertEquals( + Bespok3dStatus("0.12.24", "printer-uuid"), + Bespok3dProtocol.parseStatus( + """{"ok":true,"version":"0.12.24","printer_uuid":"printer-uuid"}""" + ), + ) + } + + @Test + fun parsesOnlyOfficialCatalogPackagesAndLiveInstalledVersions() { + val catalog = Bespok3dProtocol.parsePluginCatalog( + """{ + "schema_version":1, + "publisher":"679939555819fb5f6423dc68c4388e76bfa9b4e0", + "plugins":[ + { + "name":"bespok3d-daemon","version":"0.12.24", + "download_url":"https://api.github.com/repos/Bespok3d/daemon/releases/assets/1" + }, + { + "name":"camera-hw-accel","title":"Camera HW Accel","version":"0.1.10", + "tagline":"Fast camera","category":"camera", + "download_url":"https://api.github.com/repos/Bespok3d/u1-hw-camera/releases/assets/42", + "deps":["moonraker-auth"], + "config":[{ + "key":"WEBRTC_ENABLED","label":"WebRTC stream","type":"toggle", + "default":"1","required":true,"options":["0","1"],"hint":"Uses less CPU", + "onValue":"1","offValue":"0" + }] + }, + { + "name":"moonraker-auth","title":"Moonraker Login","version":"0.1.1", + "download_url":"https://api.github.com/repos/Bespok3d/moonraker-auth/releases/assets/43", + "deps":[] + } + ] + }""".trimIndent(), + """{"installed":{"moonraker-auth":"0.1.1"}}""", + ) + + assertEquals(listOf("camera-hw-accel", "moonraker-auth"), catalog.plugins.map { it.id }) + assertEquals(mapOf("moonraker-auth" to "0.1.1"), catalog.installed) + val camera = catalog.plugins.first() + assertEquals("Bespok3d/u1-hw-camera", camera.repository) + assertEquals(listOf("moonraker-auth"), camera.dependencies) + assertEquals("1", camera.config.single().defaultValue) + assertEquals("1", camera.config.single().onValue) + assertEquals("0", camera.config.single().offValue) + assertTrue(camera.config.single().required) + } + + @Test + fun ordersMissingDependenciesBeforeExplicitSelections() { + val dependency = Bespok3dPlugin("dep", "Dependency", "1.0.0", "", "system", "Bespok3d/dep", emptyList(), emptyList()) + val app = Bespok3dPlugin("app", "App", "1.0.0", "", "ui", "Bespok3d/app", listOf("dep"), emptyList()) + val catalog = Bespok3dPluginCatalog(listOf(app, dependency), emptyMap()) + assertEquals(listOf("dep", "app"), Bespok3dProtocol.dependencyOrder(catalog, listOf("app")).map { it.id }) + + val alreadyInstalled = catalog.copy(installed = mapOf("dep" to "1.0.0")) + assertEquals(listOf("app"), Bespok3dProtocol.dependencyOrder(alreadyInstalled, listOf("app")).map { it.id }) + assertEquals(listOf("dep"), Bespok3dProtocol.dependencyOrder(alreadyInstalled, listOf("dep")).map { it.id }) + } + + @Test + fun acceptsOnlyTheExactReleaseAssetForTheCatalogIdentity() { + val plugin = Bespok3dPlugin( + "camera-hw-accel", "Camera", "0.1.10", "", "camera", + "Bespok3d/u1-hw-camera", emptyList(), emptyList(), + ) + val expected = "https://github.com/Bespok3d/u1-hw-camera/releases/download/camera-hw-accel-v0.1.10/camera-hw-accel-0.1.10.b3" + assertEquals( + expected, + Bespok3dProtocol.parseReleaseAssetUrl( + """[{"assets":[{"name":"camera-hw-accel-0.1.10.b3","browser_download_url":"$expected"}]}]""", + plugin, + ), + ) + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.parseReleaseAssetUrl( + """[{"assets":[{"name":"camera-hw-accel-0.1.10.b3","browser_download_url":"https://evil.example/package.b3"}]}]""", + plugin, + ) + } + } + + @Test + fun preservesPerPluginBatchFailures() { + val result = Bespok3dProtocol.parseInstallResult( + """{"ok":false,"results":[ + {"plugin_id":"fluidd","ok":true}, + {"plugin_id":"camera-hw-accel","ok":false,"reason":"camera service did not start"}, + {"plugin_id":"(services)","ok":true} + ]}""", + ) + assertFalse(result.ok) + assertEquals(listOf("fluidd"), result.installedIds) + assertEquals(mapOf("camera-hw-accel" to "camera service did not start"), result.failures) + } + + @Test + fun preservesTheDaemonServiceRestartFailureWithoutTreatingItAsAPluginId() { + val result = Bespok3dProtocol.parseInstallResult( + """{"ok":false,"results":[ + {"plugin_id":"camera-hw-accel","ok":true}, + {"plugin_id":"(services)","ok":false,"reason":"moonraker did not restart"} + ]}""", + ) + + assertFalse(result.ok) + assertEquals(listOf("camera-hw-accel"), result.installedIds) + assertEquals(mapOf("(services)" to "moonraker did not restart"), result.failures) + } + + @Test + fun validatesHelixScreenConfigAndBuildsTheExactReconfigureContract() { + assertEquals( + Bespok3dHelixScreenState(installed = true, selected = "helixscreen"), + Bespok3dProtocol.parseHelixScreenConfig( + """{"vars":{"SCREEN_UI":"helixscreen"}}""", + ), + ) + assertEquals( + """{"SCREEN_UI":"snapmaker"}""", + Bespok3dProtocol.helixScreenReconfigureBody("snapmaker"), + ) + assertEquals( + Bespok3dHelixScreenState(installed = true, selected = "snapmaker"), + Bespok3dProtocol.parseHelixScreenReconfigure( + """{"plugin_id":"helixscreen-ui","ok":true,"log":[]}""", + "snapmaker", + ), + ) + } + + @Test + fun rejectsUnknownTouchscreenValuesAndWrongPluginResponses() { + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.parseHelixScreenConfig("""{"vars":{"SCREEN_UI":"other"}}""") + } + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.helixScreenReconfigureBody("other") + } + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.parseHelixScreenReconfigure( + """{"plugin_id":"camera-hw-accel","ok":true,"log":[]}""", + "helixscreen", + ) + } + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.parseHelixScreenReconfigure( + """{"plugin_id":"helixscreen-ui","ok":true,"log":[{"id":"restart","ok":false}]}""", + "helixscreen", + ) + } + } + + @Test + fun verifiesTheExactBundledHelixScreenIdentityAndBytes() { + assertEquals( + Bespok3dBundledPluginIdentity(id = "helixscreen-ui", version = "0.1.0"), + Bespok3dProtocol.verifyBundledHelixScreenPackage(bundledHelixScreenBytes()), + ) + } + + @Test + fun rejectsAnyChangeToTheBundledHelixScreenPackage() { + val corrupted = bundledHelixScreenBytes() + corrupted[corrupted.size / 2] = (corrupted[corrupted.size / 2].toInt() xor 0x01).toByte() + + assertThrows(IllegalArgumentException::class.java) { + Bespok3dProtocol.verifyBundledHelixScreenPackage(corrupted) + } + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dSshExitStatusTest.kt b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dSshExitStatusTest.kt new file mode 100644 index 0000000..d2855ba --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dSshExitStatusTest.kt @@ -0,0 +1,66 @@ +package org.crabcore.u1control.bespok3d + +import java.net.SocketTimeoutException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +class Bespok3dSshExitStatusTest { + @Test + fun waitsForExitStatusPacketAfterCommandEof() { + val statuses = ArrayDeque(listOf(-1, -1, 0)) + var now = 0L + + val result = awaitSshExitStatus( + readStatus = { statuses.removeFirst() }, + timeoutMs = 100, + nanoTime = { now }, + pause = { milliseconds -> now += milliseconds * 1_000_000 }, + ) + + assertEquals(0, result) + assertEquals(20_000_000L, now) + } + + @Test + fun preservesRealNonzeroExitStatus() { + assertEquals(127, awaitSshExitStatus({ 127 }, timeoutMs = 100)) + } + + @Test + fun timesOutWhenServerNeverSendsExitStatus() { + var now = 0L + assertThrows(SocketTimeoutException::class.java) { + awaitSshExitStatus( + readStatus = { -1 }, + timeoutMs = 20, + nanoTime = { now }, + pause = { milliseconds -> now += milliseconds * 1_000_000 }, + ) + } + } + + @Test + fun liveStockU1SupportsSequentialSftpChannels() { + val host = System.getenv("BESPOK3D_U1_HOST") + val password = System.getenv("BESPOK3D_U1_PASSWORD") + assumeTrue( + "Set BESPOK3D_U1_HOST and BESPOK3D_U1_PASSWORD for the live test", + host != null && password != null, + ) + val fingerprint = Bespok3dU1Preflight().run(host!!, password!!).sshHostKeySha256 + + JschBespok3dSsh(host, password, fingerprint).use { ssh -> + repeat(3) { attempt -> + val init = try { + ssh.read("/etc/init.d/S90lmd").toString(Charsets.UTF_8) + } catch (error: Throwable) { + throw IllegalStateException("Sequential SFTP read ${attempt + 1} failed", error) + } + assertTrue(init.startsWith("#!")) + } + } + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dU1EnrollmentTest.kt b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dU1EnrollmentTest.kt new file mode 100644 index 0000000..2f00fae --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dU1EnrollmentTest.kt @@ -0,0 +1,256 @@ +package org.crabcore.u1control.bespok3d + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class Bespok3dU1EnrollmentTest { + @Test + fun patchersAreExactAndIdempotent() { + val init = "#!/bin/sh\necho stock\n" + val patchedInit = Bespok3dU1EnrollmentProtocol.patchS90lmd(init) + assertEquals( + "#!/bin/sh\n[ -x /etc/init.d/S99bespok3d ] && exec /etc/init.d/S99bespok3d \"\$@\"\necho stock\n", + patchedInit, + ) + assertEquals(patchedInit, Bespok3dU1EnrollmentProtocol.patchS90lmd(patchedInit)) + + val nginx = "server {\n listen 80;\n}\n\n" + val patchedNginx = Bespok3dU1EnrollmentProtocol.patchNginx(nginx) + assertEquals( + "server {\n listen 80;\n include /userdata/bespok3d/etc/nginx/locations/*.conf;\n}\n", + patchedNginx, + ) + assertEquals(patchedNginx, Bespok3dU1EnrollmentProtocol.patchNginx(patchedNginx)) + + assertThrows(IllegalArgumentException::class.java) { + Bespok3dU1EnrollmentProtocol.patchS90lmd("echo no-shebang") + } + assertThrows(IllegalArgumentException::class.java) { + Bespok3dU1EnrollmentProtocol.patchNginx("server {") + } + } + + @Test + fun aclMergePreservesOtherClientsAndDoesNotDemoteAnExistingAdmin() { + val original = """ + { + "keys": ["helix-11111111-1111-1111-1111-111111111111"], + "roles": {"helix-11111111-1111-1111-1111-111111111111": "admin"}, + "labels": {"helix-11111111-1111-1111-1111-111111111111": "Old phone"}, + "tokens": ["${"1".repeat(64)}"], + "token_identity": {"${"1".repeat(64)}": "helix-11111111-1111-1111-1111-111111111111"} + } + """.trimIndent() + val newIdentity = "helix-22222222-2222-2222-2222-222222222222" + val merged = JSONObject( + Bespok3dU1EnrollmentProtocol.mergeAcl(original, newIdentity, "2".repeat(64), "New phone"), + ) + + assertEquals(2, merged.getJSONArray("keys").length()) + assertEquals("admin", merged.getJSONObject("roles").getString("helix-11111111-1111-1111-1111-111111111111")) + assertEquals("user", merged.getJSONObject("roles").getString(newIdentity)) + assertEquals("New phone", merged.getJSONObject("labels").getString(newIdentity)) + assertEquals(newIdentity, merged.getJSONObject("token_identity").getString("2".repeat(64))) + + val rerun = JSONObject( + Bespok3dU1EnrollmentProtocol.mergeAcl(merged.toString(), newIdentity, "2".repeat(64), "Renamed"), + ) + assertEquals(2, rerun.getJSONArray("keys").length()) + assertEquals(2, rerun.getJSONArray("tokens").length()) + assertEquals("user", rerun.getJSONObject("roles").getString(newIdentity)) + assertEquals("Renamed", rerun.getJSONObject("labels").getString(newIdentity)) + } + + @Test + fun runsTheOfficialOrderedRecipeAndUploadsOnlyVerifiedPayload() { + val remote = FakeSsh(overlayActive = false) + val enrollment = enrollment(remote) + + val result = enrollment.run(config(), bootstrap()) + + assertEquals(STEPS, result.completedSteps) + assertEquals("0.12.24", result.daemonVersion) + assertEquals("0.1.10", result.jinniVersion) + assertTrue(result.certificatePem.contains("BEGIN CERTIFICATE")) + assertEquals(1, remote.reconnects) + assertTrue(remote.operations.indexOfFirst { it == "exec:reboot" } < remote.operations.indexOfFirst { it == "reconnect" }) + assertEquals("daemon", remote.files["/userdata/bespok3d/var/lib/daemon/daemon.py"]?.toString(Charsets.UTF_8)) + assertEquals("jinni", remote.files["/userdata/bespok3d/var/lib/daemon/bespok3d_jinni.py"]?.toString(Charsets.UTF_8)) + assertEquals("autostart", remote.files["/userdata/bespok3d/etc/init.d/autostart/s10bespok3d-daemon"]?.toString(Charsets.UTF_8)) + assertFalse(remote.files.containsKey("/userdata/bespok3d/var/lib/daemon/S99bespok3d")) + assertTrue(remote.operations.any { it.startsWith("exec:/userdata/bespok3d/venv/bin/pip install --no-index --no-deps") }) + val acl = JSONObject(remote.files.getValue("/userdata/bespok3d/auth/acl.json").toString(Charsets.UTF_8)) + assertEquals("admin", acl.getJSONObject("roles").getString(IDENTITY)) + assertEquals(IDENTITY, acl.getJSONObject("token_identity").getString(TOKEN)) + } + + @Test + fun anActiveOverlaySkipsTheRebootAndARepeatLeavesPatchesSingle() { + val remote = FakeSsh(overlayActive = true) + val enrollment = enrollment(remote) + + enrollment.run(config(), bootstrap()) + enrollment.run(config(), bootstrap()) + + assertEquals(0, remote.reconnects) + assertFalse(remote.operations.contains("exec:reboot")) + val init = remote.files.getValue("/etc/init.d/S90lmd").toString(Charsets.UTF_8) + val nginx = remote.files.getValue("/etc/nginx/sites-enabled/fluidd").toString(Charsets.UTF_8) + assertEquals( + 1, + init.lineSequence().count { + it == "[ -x /etc/init.d/S99bespok3d ] && exec /etc/init.d/S99bespok3d \"\$@\"" + }, + ) + assertEquals(1, Regex("bespok3d/etc/nginx/locations").findAll(nginx).count()) + } + + @Test + fun reportsTheExactFailedStepAndNeverContinuesPastIt() { + val remote = FakeSsh(overlayActive = true, failCommand = "rm -rf /oem/dhcpcd") + val error = assertThrows(Bespok3dEnrollmentException::class.java) { + enrollment(remote).run(config(), bootstrap()) + } + + assertEquals("stable-network", error.stepId) + assertFalse(remote.operations.any { it.contains("sites-enabled/fluidd") }) + assertFalse(remote.files.containsKey("/userdata/bespok3d/auth/acl.json")) + } + + @Test + fun refusesAChangedHostKeyBeforeOpeningTheMutationTransport() { + var opened = false + val enrollment = Bespok3dU1Enrollment( + preflight = { _, _ -> eligiblePreflight("SHA256:${"B".repeat(43)}") }, + sshFactory = Bespok3dSshFactory { _, _, _ -> opened = true; FakeSsh(true) }, + pause = {}, + ) + + assertThrows(IllegalArgumentException::class.java) { + enrollment.run(config(), bootstrap()) + } + assertFalse(opened) + } + + private fun enrollment(remote: FakeSsh): Bespok3dU1Enrollment = Bespok3dU1Enrollment( + preflight = { _, _ -> eligiblePreflight(FINGERPRINT) }, + sshFactory = Bespok3dSshFactory { host, password, fingerprint -> + assertEquals("192.168.1.17", host) + assertEquals("snapmaker", password) + assertEquals(FINGERPRINT, fingerprint) + remote + }, + pause = {}, + ) + + private fun eligiblePreflight(fingerprint: String) = Bespok3dU1PreflightResult( + firmware = "stock", + model = "Rockchip RK3562 EVB2 DDR4 V10 Board", + overlayActive = false, + workspacePresent = false, + daemonRunning = false, + printState = "standby", + eligible = true, + reason = null, + sshHostKeySha256 = fingerprint, + ) + + private fun config() = Bespok3dU1EnrollmentConfig( + host = "192.168.1.17", + password = "snapmaker", + sshHostKeySha256 = FINGERPRINT, + label = "Helix test phone", + credentials = Bespok3dU1EnrollmentCredentials(IDENTITY, TOKEN), + ) + + private fun bootstrap() = Bespok3dBootstrapSet( + daemon = Bespok3dBootstrapPackage( + "bespok3d-daemon", + "0.12.24", + listOf( + Bespok3dBootstrapFile("daemon.py", 0b110100100, "daemon".toByteArray()), + Bespok3dBootstrapFile("wheels/dependency.whl", 0b110100100, "wheel".toByteArray()), + Bespok3dBootstrapFile("S99bespok3d", 0b111101101, "dispatcher".toByteArray()), + Bespok3dBootstrapFile("s10bespok3d-daemon", 0b111101101, "autostart".toByteArray()), + ), + ), + jinni = Bespok3dBootstrapPackage( + "bespok3d-jinni-snapmaker-u1", + "0.1.10", + listOf(Bespok3dBootstrapFile("bespok3d_jinni.py", 0b110100100, "jinni".toByteArray())), + ), + ) + + private class FakeSsh( + private val overlayActive: Boolean, + private val failCommand: String? = null, + ) : Bespok3dSsh { + val operations = mutableListOf() + val files = mutableMapOf( + "/etc/init.d/S90lmd" to "#!/bin/sh\necho stock\n".toByteArray(), + "/etc/nginx/sites-enabled/fluidd" to "server {\n}\n".toByteArray(), + "/userdata/bespok3d/etc/daemon/server.crt" to + "-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----\n".toByteArray(), + ) + var reconnects = 0 + + override fun exec(command: String): String { + operations += "exec:$command" + if (failCommand != null && command.contains(failCommand)) error("fixture failure") + return when { + command == "test -e /oem/.debug && echo yes || echo no" -> if (overlayActive) "yes\n" else "no\n" + command.startsWith("test -f /userdata/bespok3d/etc/daemon/server.crt") -> "yes\n" + command.startsWith("test -f /userdata/bespok3d/auth/acl.json") -> + if (files.containsKey("/userdata/bespok3d/auth/acl.json")) "yes\n" else "no\n" + command.startsWith("test -x /userdata/bespok3d/venv/bin/python3") -> "yes\n" + else -> "" + } + } + + override fun read(path: String): ByteArray { + operations += "read:$path" + return files.getValue(path) + } + + override fun write(path: String, bytes: ByteArray, mode: Int) { + operations += "write:$path:$mode" + files[path] = bytes.copyOf() + } + + override fun reconnect() { + operations += "reconnect" + reconnects += 1 + } + + override fun close() { + operations += "close" + } + } + + private companion object { + const val FINGERPRINT = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + const val IDENTITY = "helix-12345678-1234-1234-1234-123456789abc" + val TOKEN = "a".repeat(64) + val STEPS = listOf( + "preflight", + "unlock-overlay", + "fix-wifi-persistence", + "reboot-and-reconnect", + "create-workspace", + "deploy-s99", + "patch-s90lmd", + "stable-network", + "patch-nginx", + "klipper-includes", + "deploy-daemon", + "generate-daemon-cert", + "enroll-daemon-key", + "start-daemon", + "verify", + ) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dU1PreflightTest.kt b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dU1PreflightTest.kt new file mode 100644 index 0000000..592be19 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/bespok3d/Bespok3dU1PreflightTest.kt @@ -0,0 +1,95 @@ +package org.crabcore.u1control.bespok3d + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +class Bespok3dU1PreflightTest { + @Test + fun parsesStockU1AndAllowsIdleEnrollment() { + val system = Bespok3dU1PreflightProtocol.parseSystemState( + """ + firmware=stock + model=Rockchip RK3562 EVB2 DDR4 V10 Board + overlay=no + workspace=no + daemon=no + """.trimIndent(), + ) + val printState = Bespok3dU1PreflightProtocol.parsePrintState( + """{"result":{"status":{"print_stats":{"state":"standby"}}}}""", + ) + val result = Bespok3dU1PreflightProtocol.result(system, printState, "SHA256:fixture") + + assertEquals("stock", result.firmware) + assertEquals("Rockchip RK3562 EVB2 DDR4 V10 Board", result.model) + assertFalse(result.overlayActive) + assertFalse(result.workspacePresent) + assertFalse(result.daemonRunning) + assertEquals("standby", result.printState) + assertTrue(result.eligible) + assertNull(result.reason) + } + + @Test + fun blocksExtendedFirmwareAndAnActivePrint() { + val extended = Bespok3dU1SystemState( + "extended", + "Rockchip RK3562 EVB2 DDR4 V10 Board", + true, + false, + false, + ) + val extendedResult = Bespok3dU1PreflightProtocol.result(extended, "standby", "SHA256:key") + assertFalse(extendedResult.eligible) + assertEquals( + "Bespok3d enrollment requires stock Snapmaker U1 firmware", + extendedResult.reason, + ) + + val stock = extended.copy(firmware = "stock", overlayActive = false) + for (state in listOf("printing", "paused")) { + val activeResult = Bespok3dU1PreflightProtocol.result(stock, state, "SHA256:key") + assertFalse(activeResult.eligible) + assertEquals("The printer must be idle before Bespok3d enrollment", activeResult.reason) + } + + val unrelatedHost = stock.copy(model = "Generic Linux Server") + val unrelatedResult = Bespok3dU1PreflightProtocol.result(unrelatedHost, "standby", "SHA256:key") + assertFalse(unrelatedResult.eligible) + assertEquals("The SSH host is not a supported Snapmaker U1", unrelatedResult.reason) + } + + @Test + fun rejectsIncompleteSshOrMoonrakerResponses() { + assertThrows(IllegalArgumentException::class.java) { + Bespok3dU1PreflightProtocol.parseSystemState("firmware=stock\nmodel=Snapmaker U1") + } + assertThrows(IllegalArgumentException::class.java) { + Bespok3dU1PreflightProtocol.parseSystemState( + "firmware=unknown\nmodel=U1\noverlay=no\nworkspace=no\ndaemon=no", + ) + } + assertThrows(IllegalArgumentException::class.java) { + Bespok3dU1PreflightProtocol.parsePrintState("""{"result":{"status":{}}}""") + } + } + + @Test + fun liveStockU1Preflight() { + val host = System.getenv("BESPOK3D_U1_HOST") + val password = System.getenv("BESPOK3D_U1_PASSWORD") + assumeTrue("Set BESPOK3D_U1_HOST and BESPOK3D_U1_PASSWORD for the live test", host != null && password != null) + + val result = Bespok3dU1Preflight().run(host!!, password!!) + assertEquals("stock", result.firmware) + assertTrue(result.model.contains("RK3562", ignoreCase = true)) + assertTrue(result.printState != "printing" && result.printState != "paused") + assertTrue(result.eligible) + assertTrue(Regex("^SHA256:[A-Za-z0-9+/]{43}$").matches(result.sshHostKeySha256)) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/slicing/GcodeToolMapperTest.kt b/android/app/src/test/java/org/crabcore/u1control/slicing/GcodeToolMapperTest.kt index 3e57af9..48388e9 100644 --- a/android/app/src/test/java/org/crabcore/u1control/slicing/GcodeToolMapperTest.kt +++ b/android/app/src/test/java/org/crabcore/u1control/slicing/GcodeToolMapperTest.kt @@ -7,10 +7,13 @@ import org.junit.Assert.assertTrue import org.junit.Test class GcodeToolMapperTest { + /** The Snapmaker U1's 270mm bed, which these cases were written against. */ + private val U1_BED = 270f + @Test fun shiftsOutOfBoundsMeshWithoutCollapsingItsSpan() { withGcode("BED_MESH_CALIBRATE mesh_min=-2,-2 mesh_max=1,1 ADAPTIVE=1") { file -> - val result = GcodeToolMapper.clampU1BedMeshBounds(file.toString()) + val result = GcodeToolMapper.clampBedMeshBounds(file.toString(), U1_BED, U1_BED) assertTrue(result.success) assertTrue(result.rewritten) @@ -25,7 +28,7 @@ class GcodeToolMapperTest { fun leavesSafeBoundsUntouched() { val gcode = "BED_MESH_CALIBRATE mesh_min=3,4 mesh_max=260,267 ADAPTIVE=1" withGcode(gcode) { file -> - val result = GcodeToolMapper.clampU1BedMeshBounds(file.toString()) + val result = GcodeToolMapper.clampBedMeshBounds(file.toString(), U1_BED, U1_BED) assertTrue(result.success) assertFalse(result.rewritten) @@ -37,7 +40,7 @@ class GcodeToolMapperTest { fun failsClosedForCollapsedOrReversedBounds() { val gcode = "BED_MESH_CALIBRATE mesh_min=20,20 mesh_max=20,10 ADAPTIVE=1" withGcode(gcode) { file -> - val result = GcodeToolMapper.clampU1BedMeshBounds(file.toString()) + val result = GcodeToolMapper.clampBedMeshBounds(file.toString(), U1_BED, U1_BED) assertFalse(result.success) assertFalse(result.rewritten) @@ -49,7 +52,7 @@ class GcodeToolMapperTest { fun acceptsDefaultMeshCommandWithoutAdaptiveBounds() { val gcode = "BED_MESH_CALIBRATE" withGcode(gcode) { file -> - val result = GcodeToolMapper.clampU1BedMeshBounds(file.toString()) + val result = GcodeToolMapper.clampBedMeshBounds(file.toString(), U1_BED, U1_BED) assertTrue(result.success) assertFalse(result.rewritten) @@ -57,6 +60,34 @@ class GcodeToolMapperTest { } } + @Test + fun clampsToASmallerBedRatherThanTheU1() { + // 3..217 on a 220mm AD5X. Under the old hardcoded 3..267 this line was + // considered safe and the printer would probe well past its own bed. + withGcode("BED_MESH_CALIBRATE mesh_min=3,3 mesh_max=260,260 ADAPTIVE=1") { file -> + val result = GcodeToolMapper.clampBedMeshBounds(file.toString(), 220f, 220f) + + assertTrue(result.success) + assertTrue(result.rewritten) + assertEquals( + "BED_MESH_CALIBRATE mesh_min=3.00000,3.00000 mesh_max=217.00000,217.00000 ADAPTIVE=1\n", + readText(file), + ) + } + } + + @Test + fun boundsSafeOnTheU1CanStillBeUnsafeOnASmallerBed() { + // Same input, two machines: untouched at 270, clamped at 220. + val gcode = "BED_MESH_CALIBRATE mesh_min=3,4 mesh_max=260,267 ADAPTIVE=1" + withGcode(gcode) { file -> + assertFalse(GcodeToolMapper.clampBedMeshBounds(file.toString(), U1_BED, U1_BED).rewritten) + } + withGcode(gcode) { file -> + assertTrue(GcodeToolMapper.clampBedMeshBounds(file.toString(), 220f, 220f).rewritten) + } + } + private fun withGcode(contents: String, block: (java.nio.file.Path) -> Unit) { val file = Files.createTempFile("helix-bed-mesh-", ".gcode") try { diff --git a/android/app/src/test/java/org/crabcore/u1control/slicing/HelixSliceRunnerProfileTest.kt b/android/app/src/test/java/org/crabcore/u1control/slicing/HelixSliceRunnerProfileTest.kt new file mode 100644 index 0000000..60c5cca --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/slicing/HelixSliceRunnerProfileTest.kt @@ -0,0 +1,87 @@ +package org.crabcore.u1control.slicing + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HelixSliceRunnerProfileTest { + @Test + fun `P1S startup uses the official pre independent-purge variables`() { + val current = """ + G28 + M620.1 E F{flush_volumetric_speeds[initial_no_support_extruder]/2.4053*60} T{flush_temperatures[initial_no_support_extruder]} + G90 + """.trimIndent() + + val compatible = HelixSliceRunner.compatibleMachineStartGcode("bambu_p1s.json", current) + + assertFalse(compatible.contains("flush_volumetric_speeds")) + assertFalse(compatible.contains("flush_temperatures")) + assertTrue( + compatible.contains( + "M620.1 E F{filament_max_volumetric_speed[initial_extruder]/2.4053*60} " + + "T{nozzle_temperature_range_high[initial_extruder]}", + ), + ) + assertEquals(3, compatible.lineSequence().count()) + } + + @Test + fun `A1 startup uses the official pre independent-purge variables`() { + val current = """ + G28 X + M620.1 E F{flush_volumetric_speeds[initial_no_support_extruder]/2.4053*60} T{flush_temperatures[initial_no_support_extruder]} + M109 S{flush_temperatures[initial_no_support_extruder]} H300 + G90 + """.trimIndent() + + val compatible = HelixSliceRunner.compatibleMachineStartGcode("bambu_a1.json", current) + + assertFalse(compatible.contains("flush_volumetric_speeds")) + assertFalse(compatible.contains("flush_temperatures")) + assertTrue( + compatible.contains( + "M620.1 E F{filament_max_volumetric_speed[initial_no_support_extruder]/2.4053*60} " + + "T{nozzle_temperature_range_high[initial_no_support_extruder]}", + ), + ) + assertTrue( + compatible.contains( + "M109 S{nozzle_temperature_range_high[initial_no_support_extruder]} H300", + ), + ) + assertEquals(4, compatible.lineSequence().count()) + } + + @Test + fun `compatibility substitution is Bambu-only`() { + val current = + "M620.1 E F{flush_volumetric_speeds[initial_no_support_extruder]/2.4053*60} " + + "T{flush_temperatures[initial_no_support_extruder]}" + + assertEquals( + current, + HelixSliceRunner.compatibleMachineStartGcode("snapmaker_u1.json", current), + ) + assertEquals( + current, + HelixSliceRunner.compatibleMachineStartGcode("flashforge_ad5x.json", current), + ) + } + + @Test + fun `Bambu trusts actual mesh use over the installed preset list`() { + assertTrue(HelixSliceRunner.isSingleMaterialBambu("bambu_p1s.json", listOf(3))) + assertTrue(HelixSliceRunner.isSingleMaterialBambu("bambu_a1.json", listOf(3, 3))) + assertFalse(HelixSliceRunner.isSingleMaterialBambu("bambu_p1s.json", listOf(0, 3))) + assertFalse(HelixSliceRunner.isSingleMaterialBambu("bambu_a1.json", emptyList())) + } + + @Test + fun `single-material inference cannot change other printers`() { + assertFalse(HelixSliceRunner.isSingleMaterialBambu("snapmaker_u1.json", listOf(3))) + assertFalse(HelixSliceRunner.isSingleMaterialBambu("flashforge_ad5x.json", listOf(3))) + assertFalse(HelixSliceRunner.isSingleMaterialBambu(null, listOf(3))) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/slicing/HelixSliceSettingsOverrideTest.kt b/android/app/src/test/java/org/crabcore/u1control/slicing/HelixSliceSettingsOverrideTest.kt new file mode 100644 index 0000000..cef0220 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/slicing/HelixSliceSettingsOverrideTest.kt @@ -0,0 +1,180 @@ +package org.crabcore.u1control.slicing + +import com.u1.slicer.data.SliceConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A 3MF's own settings survive unless the user actually overrode them. + * + * These overrides are written into the project's `project_settings.config`, so + * emitting a key for a group nobody touched replaced the file's answer with a + * constructor default — which is how downloaded models arrived at the engine + * with their supports and brim switched off. + */ +class HelixSliceSettingsOverrideTest { + + @Test + fun `an untouched prepare screen writes no project overrides at all`() { + assertTrue(HelixSliceSettings().toProfileKeyOverrides().isEmpty()) + } + + @Test + fun `supports a project asked for are not switched off by an untouched dialog`() { + val overrides = HelixSliceSettings().toProfileKeyOverrides() + + assertFalse("enable_support" in overrides) + assertFalse("brim_width" in overrides) + assertFalse("brim_type" in overrides) + } + + @Test + fun `applying the supports dialog writes supports and nothing else`() { + val settings = HelixSliceSettings(supportsEnabled = true, supportType = "tree(auto)") + .choosing(SliceSettingGroup.SUPPORTS) + + val overrides = settings.toProfileKeyOverrides() + + assertEquals("1", overrides["enable_support"]) + assertEquals("tree(auto)", overrides["support_type"]) + assertFalse("brim_width" in overrides) + assertFalse("sparse_infill_density" in overrides) + assertFalse("ironing_type" in overrides) + } + + @Test + fun `switching supports off is still recorded once the dialog was applied`() { + val overrides = HelixSliceSettings(supportsEnabled = false) + .choosing(SliceSettingGroup.SUPPORTS) + .toProfileKeyOverrides() + + assertEquals("0", overrides["enable_support"]) + } + + @Test + fun `an applied brim of zero still turns the project's brim off`() { + val overrides = HelixSliceSettings(brimWidthMm = 0f) + .choosing(SliceSettingGroup.BRIM) + .toProfileKeyOverrides() + + assertEquals("no_brim", overrides["brim_type"]) + assertEquals("0.0", overrides["brim_width"]) + } + + @Test + fun `an applied ironing dialog switched off still overrides the project`() { + val overrides = HelixSliceSettings(ironingType = "no ironing") + .choosing(SliceSettingGroup.IRONING) + .toProfileKeyOverrides() + + assertEquals("no ironing", overrides["ironing_type"]) + assertFalse("ironing_speed" in overrides) + } + + @Test + fun `untouched groups leave SliceConfig alone for the project to fill`() { + val defaults = SliceConfig() + val config = SliceConfig(supportEnabled = true, brimWidth = 5f, fillDensity = 0.25f) + + HelixSliceSettings().applyTo(config) + + assertTrue("an untouched dialog must not disable supports", config.supportEnabled) + assertEquals(5f, config.brimWidth, 0.001f) + assertEquals(0.25f, config.fillDensity, 0.0001f) + assertEquals(defaults.supportPattern, config.supportPattern) + } + + @Test + fun `a chosen group does reach SliceConfig`() { + val config = SliceConfig(supportEnabled = true, brimWidth = 5f) + + HelixSliceSettings(supportsEnabled = false, brimWidthMm = 0f) + .choosing(SliceSettingGroup.SUPPORTS) + .choosing(SliceSettingGroup.BRIM) + .applyTo(config) + + assertFalse(config.supportEnabled) + assertEquals(0f, config.brimWidth, 0.001f) + } + + @Test + fun `the user's choice outranks the project`() { + val config = SliceConfig() + val project = Project3mfSettings.parse( + """{"enable_support": "1", "sparse_infill_density": "15%"}""", + ) + + project.applyTo(config) + HelixSliceSettings(supportsEnabled = false, infillDensity = 0.4f) + .choosing(SliceSettingGroup.SUPPORTS) + .choosing(SliceSettingGroup.INFILL) + .applyTo(config) + + assertFalse(config.supportEnabled) + assertEquals(0.4f, config.fillDensity, 0.0001f) + } + + // ---- Round-tripping ---- + + @Test + fun `chosen groups survive the re-slice round trip`() { + val settings = HelixSliceSettings(supportsEnabled = true) + .choosing(SliceSettingGroup.SUPPORTS) + .choosing(SliceSettingGroup.IRONING) + + val restored = HelixSliceSettings.fromJson(settings.toJson()) + + assertEquals( + setOf(SliceSettingGroup.SUPPORTS, SliceSettingGroup.IRONING), + restored.chosen, + ) + assertEquals(settings.toProfileKeyOverrides(), restored.toProfileKeyOverrides()) + } + + @Test + fun `an untouched screen round-trips as untouched`() { + val restored = HelixSliceSettings.fromJson(HelixSliceSettings().toJson()) + + assertTrue(restored.chosen.isEmpty()) + assertTrue(restored.toProfileKeyOverrides().isEmpty()) + } + + @Test + fun `a record written before chosen existed replays as fully chosen`() { + // Those records are re-slices of a print the user had already set up, so + // reproducing them means honouring every value they carry. + val legacy = """{"supportsEnabled": true, "brimWidthMm": 5.0, "ironingType": "top"}""" + + val restored = HelixSliceSettings.fromJson(legacy) + + assertEquals(SliceSettingGroup.entries.toSet(), restored.chosen) + assertEquals("1", restored.toProfileKeyOverrides()["enable_support"]) + assertEquals("5.0", restored.toProfileKeyOverrides()["brim_width"]) + } + + @Test + fun `bridge options mark only the groups the caller sent`() { + val supportsOnly = HelixSliceSettings.fromBridgeOptions( + supportEnabled = true, supportType = null, supportAngle = null, + supportFilament = null, supportInterfaceFilament = null, + supportBuildPlateOnly = null, supportPattern = null, brimWidth = null, + ) + + assertEquals(setOf(SliceSettingGroup.SUPPORTS), supportsOnly.chosen) + assertFalse("brim_width" in supportsOnly.toProfileKeyOverrides()) + } + + @Test + fun `bridge options that say nothing choose nothing`() { + val nothing = HelixSliceSettings.fromBridgeOptions( + supportEnabled = null, supportType = null, supportAngle = null, + supportFilament = null, supportInterfaceFilament = null, + supportBuildPlateOnly = null, supportPattern = null, brimWidth = null, + ) + + assertTrue(nothing.chosen.isEmpty()) + assertTrue(nothing.toProfileKeyOverrides().isEmpty()) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/slicing/KlipperGcodeCompatibilityTest.kt b/android/app/src/test/java/org/crabcore/u1control/slicing/KlipperGcodeCompatibilityTest.kt new file mode 100644 index 0000000..095d802 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/slicing/KlipperGcodeCompatibilityTest.kt @@ -0,0 +1,122 @@ +package org.crabcore.u1control.slicing + +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class KlipperGcodeCompatibilityTest { + @Test + fun translatesMarlinLimitsAndStripsM486ForOptedInKlipperProfile() { + val gcode = """ + ; generated machine limits + M201 X12000 Y10000 Z500 E5000 + M203 X500 Y450 Z30 E30 + M205 X10 Y8 Z2 E2 + M486 S0 + G1 X10 Y10 F12000 + M486 S-1 + """.trimIndent() + + withGcode(gcode) { file -> + val result = KlipperGcodeCompatibility.apply( + file.toString(), + translateMarlinMachineLimits = true, + stripM486 = true, + ) + + assertTrue(result.success) + assertTrue(result.rewritten) + assertEquals(3, result.translatedLimits) + assertEquals(2, result.strippedCommands) + assertEquals( + """ + ; generated machine limits + SET_VELOCITY_LIMIT ACCEL=10000 ; Helix translated M201 + SET_VELOCITY_LIMIT VELOCITY=450 ; Helix translated M203 + SET_VELOCITY_LIMIT SQUARE_CORNER_VELOCITY=5.65685 ; Helix translated M205 + G1 X10 Y10 F12000 + """.trimIndent() + "\n", + readText(file), + ) + } + } + + @Test + fun leavesU1BytesUntouchedWhenProfileDoesNotOptIn() { + val bytes = "; U1\r\nM201 X10000 Y10000\r\nM486 S0\r\n".toByteArray(Charsets.UTF_8) + val file = Files.createTempFile("helix-u1-gcode-", ".gcode") + try { + Files.write(file, bytes) + + val result = KlipperGcodeCompatibility.apply( + file.toString(), + translateMarlinMachineLimits = false, + stripM486 = false, + ) + + assertTrue(result.success) + assertFalse(result.rewritten) + assertTrue(bytes.contentEquals(Files.readAllBytes(file))) + } finally { + Files.deleteIfExists(file) + } + } + + @Test + fun optionsAreIndependentAndCommentsAreNeverCommands() { + val gcode = """ + ; M486 S0 + M201 X9000 Y8000 + M486 S1 + M204 S5000 + """.trimIndent() + + withGcode(gcode) { file -> + val result = KlipperGcodeCompatibility.apply( + file.toString(), + translateMarlinMachineLimits = false, + stripM486 = true, + ) + + assertTrue(result.success) + assertEquals(0, result.translatedLimits) + assertEquals(1, result.strippedCommands) + assertEquals( + "; M486 S0\nM201 X9000 Y8000\nM204 S5000\n", + readText(file), + ) + } + } + + @Test + fun stripsMalformedTargetLimitInsteadOfSendingAnUnknownCommand() { + withGcode("M201 Z500 E1000") { file -> + val result = KlipperGcodeCompatibility.apply( + file.toString(), + translateMarlinMachineLimits = true, + stripM486 = false, + ) + + assertTrue(result.success) + assertTrue(result.rewritten) + assertEquals(0, result.translatedLimits) + assertEquals(1, result.strippedCommands) + assertEquals("", readText(file)) + } + } + + private fun withGcode(contents: String, block: (java.nio.file.Path) -> Unit) { + val file = Files.createTempFile("helix-klipper-compat-", ".gcode") + try { + Files.write(file, "$contents\n".toByteArray(Charsets.UTF_8)) + block(file) + } finally { + Files.deleteIfExists(file) + } + } + + private fun readText(file: java.nio.file.Path): String = + String(Files.readAllBytes(file), Charsets.UTF_8) +} diff --git a/android/app/src/test/java/org/crabcore/u1control/slicing/PreprocessPrefsTest.kt b/android/app/src/test/java/org/crabcore/u1control/slicing/PreprocessPrefsTest.kt new file mode 100644 index 0000000..42f2373 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/slicing/PreprocessPrefsTest.kt @@ -0,0 +1,132 @@ +package org.crabcore.u1control.slicing + +import com.u1.slicer.viewer.MachineProfile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The preprocess sheet used to carry its own hardcoded toggle list, which drifted + * from the RN one: an AD5X was offered a time-lapse despite having no TIMELAPSE_* + * macros, and injecting those aborts the print on "Unknown command". + */ +class PreprocessPrefsTest { + @Test + fun ad5xIsOfferedOnlyLevellingAndItsMaterialStation() { + val offered = PreprocessRouting.offeredPrefs(listOf("autoLevel", "ifs")) + assertEquals( + listOf(PreprocessRouting.Pref.AUTO_LEVEL, PreprocessRouting.Pref.IFS), + offered, + ) + } + + @Test + fun theU1KeepsItsPaxxToggles() { + val offered = PreprocessRouting.offeredPrefs( + listOf("autoLevel", "flowCal", "timelapse"), + supportsAiMonitoring = true, + ) + assertEquals(4, offered.size) + assertEquals(PreprocessRouting.Pref.AI_MONITORING, offered.first()) + assertTrue(offered.contains(PreprocessRouting.Pref.FLOW_CAL)) + assertTrue(offered.contains(PreprocessRouting.Pref.TIMELAPSE)) + assertTrue(!offered.contains(PreprocessRouting.Pref.IFS)) + } + + @Test + fun anEmptyListFallsBackToEveryToggle() { + // An older JS bundle sends no list. Showing an empty options fold would look + // broken, so the sheet keeps its full set rather than nothing. + assertEquals( + PreprocessRouting.Pref.values().filterNot { it == PreprocessRouting.Pref.AI_MONITORING }, + PreprocessRouting.offeredPrefs(emptyList()), + ) + } + + @Test + fun aiMonitoringUsesTheU1DefectDetectionCommand() { + assertEquals( + "DEFECT_DETECTION_CONFIG MAIN_ENABLE=1 CLEAN_BED_ENABLE=1 " + + "NOODLE_ENABLE=1 SENSITIVITY=low", + PreprocessRouting.aiMonitoringCommand(true), + ) + assertEquals( + "DEFECT_DETECTION_CONFIG MAIN_ENABLE=1 CLEAN_BED_ENABLE=1 " + + "NOODLE_ENABLE=1 SENSITIVITY=high", + PreprocessRouting.aiMonitoringCommand(true, AiDetectionSensitivity.HIGH), + ) + assertEquals( + "DEFECT_DETECTION_CONFIG MAIN_ENABLE=0", + PreprocessRouting.aiMonitoringCommand(false), + ) + } + + @Test + fun aiMonitoringDefaultsOnAndPreservesAnExplicitChoice() { + assertTrue(PreprocessPreferenceStore.aiMonitoringValue(null)) + assertTrue(PreprocessPreferenceStore.aiMonitoringValue(true)) + assertFalse(PreprocessPreferenceStore.aiMonitoringValue(false)) + } + + @Test + fun aiSensitivityDefaultsLowAndRejectsUnsupportedBridgeValues() { + assertEquals( + AiDetectionSensitivity.LOW, + PreprocessPreferenceStore.aiDetectionSensitivityValue(null), + ) + assertEquals( + AiDetectionSensitivity.HIGH, + PreprocessPreferenceStore.aiDetectionSensitivityValue(" HIGH "), + ) + assertEquals( + AiDetectionSensitivity.LOW, + PreprocessPreferenceStore.aiDetectionSensitivityValue("medium"), + ) + + var rejected = false + try { + AiDetectionSensitivity.fromWireValue("medium") + } catch (_: IllegalArgumentException) { + rejected = true + } + assertTrue(rejected) + } + + @Test + fun unknownKeysAreIgnoredRatherThanCrashing() { + val offered = PreprocessRouting.offeredPrefs(listOf("autoLevel", "somethingNew")) + assertEquals(listOf(PreprocessRouting.Pref.AUTO_LEVEL), offered) + } + + @Test + fun machineProfileParsesTheOfferedPrefs() { + val parsed = MachineProfile.fromJson( + """{"bed":{"sizeX":220,"sizeY":220,"height":220,"modelAsset":"ad5x_bed.stl"}, + "sliceProfileAsset":"flashforge_ad5x.json","supportsPrintPreferences":false, + "printPrefs":["autoLevel","ifs"]}""" + ) + assertEquals(listOf("autoLevel", "ifs"), parsed.printPrefs) + assertEquals(220f, parsed.bed.sizeX, 0.001f) + + // A payload without the field must not wipe the sheet's toggles. + val legacy = MachineProfile.fromJson("""{"bed":{"sizeX":270,"sizeY":270,"height":270}}""") + assertTrue(legacy.printPrefs.isEmpty()) + } + + @Test + fun onlyTheVerifiedBambuProfileReturnsToHelixForSending() { + val p1s = MachineProfile.fromJson( + """{"bed":{"sizeX":256,"sizeY":256,"height":250}, + "sliceProfileAsset":"bambu_p1s.json","supportsPrintPreferences":false}""" + ) + assertTrue(usesHelixBambuSend(p1s)) + assertTrue(!usesHelixBambuSend(MachineProfile.U1)) + + val ad5x = MachineProfile.fromJson( + """{"bed":{"sizeX":220,"sizeY":220,"height":220}, + "sliceProfileAsset":"flashforge_ad5x.json","supportsPrintPreferences":false}""" + ) + assertTrue(!usesHelixBambuSend(ad5x)) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/slicing/Project3mfSettingsTest.kt b/android/app/src/test/java/org/crabcore/u1control/slicing/Project3mfSettingsTest.kt new file mode 100644 index 0000000..516e6c7 --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/slicing/Project3mfSettingsTest.kt @@ -0,0 +1,368 @@ +package org.crabcore.u1control.slicing + +import com.u1.slicer.data.SliceConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class Project3mfSettingsTest { + @get:Rule + val temp = TemporaryFolder() + + private fun project3mf(name: String, settings: String?): File { + val file = temp.newFile(name) + ZipOutputStream(FileOutputStream(file)).use { zip -> + zip.putNextEntry(ZipEntry("3D/3dmodel.model")) + zip.write("".toByteArray()) + zip.closeEntry() + if (settings != null) { + zip.putNextEntry(ZipEntry("Metadata/project_settings.config")) + zip.write(settings.toByteArray()) + zip.closeEntry() + } + } + return file + } + + // ---- Value reading ---- + + @Test + fun `reads Bambu's string-valued keys`() { + val parsed = Project3mfSettings.parse( + """{"enable_prime_tower": "1", "prime_tower_width": "35"}""", + ) + + assertEquals(true, parsed.primeTowerEnabled) + assertEquals(35f, parsed.primeTowerWidth!!, 0.001f) + } + + @Test + fun `an explicit off is not the same as unset`() { + assertEquals(false, Project3mfSettings.parse("""{"enable_prime_tower": "0"}""").primeTowerEnabled) + assertNull(Project3mfSettings.parse("""{"layer_height": "0.2"}""").primeTowerEnabled) + } + + @Test + fun `per-variant arrays read their primary entry`() { + // Bambu stores speeds once per extruder variant; Helix slices the first. + val parsed = Project3mfSettings.parse("""{"outer_wall_speed": ["200", "350"]}""") + + assertEquals(200f, parsed.float("outer_wall_speed")!!, 0.001f) + } + + @Test + fun `percentages read as fractions and bare numbers pass through`() { + assertEquals(0.15f, Project3mfSettings.parse("""{"d": "15%"}""").fraction("d")!!, 0.0001f) + assertEquals(0.15f, Project3mfSettings.parse("""{"d": "0.15"}""").fraction("d")!!, 0.0001f) + assertNull(Project3mfSettings.parse("""{"d": "wide"}""").fraction("d")) + } + + @Test + fun `nonsense values read as unset rather than as a decision`() { + val parsed = Project3mfSettings.parse( + """{"enable_prime_tower": "maybe", "prime_tower_width": "wide"}""", + ) + + assertNull(parsed.primeTowerEnabled) + assertNull(parsed.primeTowerWidth) + assertFalse(Project3mfSettings.parse("{").isPresent) + } + + @Test + fun `a non-positive prime tower width is refused`() { + assertNull(Project3mfSettings.parse("""{"prime_tower_width": "0"}""").primeTowerWidth) + assertNull(Project3mfSettings.parse("""{"prime_tower_width": "-5"}""").primeTowerWidth) + } + + // ---- Bed temperature ---- + + @Test + fun `bed temperature follows the plate the project is set up for`() { + val settings = """ + {"curr_bed_type": "Supertack Plate", "supertack_plate_temp": ["45"], + "hot_plate_temp": ["65"], "textured_plate_temp": ["65"], "cool_plate_temp": ["35"]} + """.trimIndent() + + assertEquals(45, Project3mfSettings.parse(settings).bedTemperature) + } + + @Test + fun `every plate label the engine knows maps to a temperature`() { + val labels = mapOf( + "Cool Plate" to "cool_plate_temp", + "Textured Cool Plate" to "textured_cool_plate_temp", + "Cool Plate (SuperTack)" to "supertack_plate_temp", + "Engineering Plate" to "eng_plate_temp", + "High Temp Plate" to "hot_plate_temp", + "Textured PEI Plate" to "textured_plate_temp", + ) + labels.entries.forEachIndexed { index, (label, key) -> + val temp = 40 + index + // A decoy under a different plate's key, so a mapping that ignored + // curr_bed_type would read the wrong number rather than none at all. + val decoy = if (key == "hot_plate_temp") "cool_plate_temp" else "hot_plate_temp" + val parsed = Project3mfSettings.parse( + """{"curr_bed_type": "$label", "$key": ["$temp"], "$decoy": ["99"]}""", + ) + assertEquals("$label should read $key", temp, parsed.bedTemperature) + } + } + + @Test + fun `an unknown plate falls back to the high temp figure`() { + val parsed = Project3mfSettings.parse( + """{"curr_bed_type": "Mystery Plate", "hot_plate_temp": ["70"]}""", + ) + + assertEquals(70, parsed.bedTemperature) + } + + @Test + fun `a zeroed plate temperature is not a reading`() { + assertNull( + Project3mfSettings.parse("""{"curr_bed_type": "Cool Plate", "cool_plate_temp": ["0"]}""") + .bedTemperature, + ) + } + + // ---- SliceConfig seeding ---- + + @Test + fun `the project's settings reach SliceConfig instead of Helix defaults`() { + val config = SliceConfig() + val settings = """ + { + "layer_height": "0.16", "initial_layer_print_height": "0.2", + "wall_loops": "3", "top_shell_layers": "4", "bottom_shell_layers": "3", + "sparse_infill_density": "25%", "sparse_infill_pattern": "grid", + "outer_wall_speed": ["200", "350"], "travel_speed": ["500", "500"], + "initial_layer_speed": ["50", "50"], + "skirt_loops": "1", "skirt_distance": "2", "brim_width": "5", + "enable_support": "1", "support_type": "tree(auto)", + "support_threshold_angle": "30", "support_on_build_plate_only": "1", + "support_base_pattern": "rectilinear", "support_filament": "2", + "support_interface_filament": "3", + "nozzle_diameter": ["0.4"], "filament_diameter": ["1.75"], + "filament_type": ["PETG"], "nozzle_temperature": ["245"], + "curr_bed_type": "Textured PEI Plate", "textured_plate_temp": ["70"], + "retraction_length": ["0.8"], "retraction_speed": ["30"] + } + """.trimIndent() + + Project3mfSettings.parse(settings).applyTo(config) + + assertEquals(0.16f, config.layerHeight, 0.0001f) + assertEquals(0.2f, config.firstLayerHeight, 0.0001f) + assertEquals(3, config.perimeters) + assertEquals(4, config.topSolidLayers) + assertEquals(3, config.bottomSolidLayers) + assertEquals(0.25f, config.fillDensity, 0.0001f) + assertEquals("grid", config.fillPattern) + assertEquals(200f, config.printSpeed, 0.001f) + assertEquals(500f, config.travelSpeed, 0.001f) + assertEquals(50f, config.firstLayerSpeed, 0.001f) + assertEquals(1, config.skirtLoops) + assertEquals(2f, config.skirtDistance, 0.001f) + assertEquals(5f, config.brimWidth, 0.001f) + assertTrue(config.supportEnabled) + assertEquals("tree(auto)", config.supportType) + assertEquals(30f, config.supportAngle, 0.001f) + assertTrue(config.supportBuildPlateOnly) + assertEquals("rectilinear", config.supportPattern) + assertEquals(2, config.supportFilament) + assertEquals(3, config.supportInterfaceFilament) + assertEquals(0.4f, config.nozzleDiameter, 0.0001f) + assertEquals(1.75f, config.filamentDiameter, 0.0001f) + assertEquals("PETG", config.filamentType) + assertEquals(245, config.nozzleTemp) + assertEquals(70, config.bedTemp) + assertEquals(0.8f, config.retractLength, 0.0001f) + assertEquals(30f, config.retractSpeed, 0.001f) + } + + @Test + fun `a key the project omits leaves the Helix default standing`() { + val defaults = SliceConfig() + val config = SliceConfig() + + Project3mfSettings.parse("""{"layer_height": "0.16"}""").applyTo(config) + + assertEquals(0.16f, config.layerHeight, 0.0001f) + assertEquals(defaults.perimeters, config.perimeters) + assertEquals(defaults.fillPattern, config.fillPattern) + assertEquals(defaults.bedTemp, config.bedTemp) + assertEquals(defaults.supportEnabled, config.supportEnabled) + } + + @Test + fun `a file with no project settings changes nothing`() { + val defaults = SliceConfig() + val config = SliceConfig() + + Project3mfSettings.NONE.applyTo(config) + + assertEquals(defaults.layerHeight, config.layerHeight, 0.0001f) + assertEquals(defaults.bedTemp, config.bedTemp) + assertEquals(defaults.fillDensity, config.fillDensity, 0.0001f) + } + + // ---- Reading from disk ---- + + @Test + fun `reads the config out of a real 3MF`() { + val file = project3mf("project.3mf", """{"enable_prime_tower": "1", "prime_tower_width": "42"}""") + + val parsed = Project3mfSettings.read(file.absolutePath) + + assertTrue(parsed.isPresent) + assertEquals(true, parsed.primeTowerEnabled) + assertEquals(42f, parsed.primeTowerWidth!!, 0.001f) + } + + @Test + fun `anything unreadable yields NONE instead of throwing`() { + val noSettings = project3mf("bare.3mf", null) + val notAZip = temp.newFile("broken.3mf").apply { writeText("not a zip") } + val stl = temp.newFile("model.stl") + + listOf( + noSettings.absolutePath, + notAZip.absolutePath, + stl.absolutePath, + File(temp.root, "gone.3mf").absolutePath, + null, + " ", + ).forEach { path -> + assertFalse("$path should read as absent", Project3mfSettings.read(path).isPresent) + } + } + + @Test + fun `NONE decides nothing`() { + assertFalse(Project3mfSettings.NONE.isPresent) + assertNull(Project3mfSettings.NONE.primeTowerEnabled) + assertNull(Project3mfSettings.NONE.primeTowerWidth) + assertNull(Project3mfSettings.NONE.bedTemperature) + assertTrue(Project3mfSettings.DEFAULT_PRIME_TOWER_WIDTH_MM > 0f) + } + + // ---- Project settings sheet ---- + + @Test + fun `the sheet reports the project's values against the profile's`() { + val project = Project3mfSettings.parse( + """{"layer_height": "0.16", "wall_loops": "3", "sparse_infill_density": "25%"}""", + ) + val profile = Project3mfSettings.parse( + """{"layer_height": "0.2", "wall_loops": "3", "sparse_infill_density": "15%"}""", + ) + + val rows = project.summarize(profile).associateBy { it.label } + + assertEquals("0.16 mm", rows["Layer height"]!!.value) + assertEquals("0.2 mm", rows["Layer height"]!!.default) + assertTrue(rows["Layer height"]!!.differs) + // Same value on both sides is not a change. + assertFalse(rows["Walls"]!!.differs) + assertTrue(rows["Infill"]!!.differs) + } + + @Test + fun `a setting the project omits is left off the sheet`() { + val rows = Project3mfSettings.parse("""{"layer_height": "0.2"}""").summarize() + + assertEquals(listOf("Layer height"), rows.map { it.label }) + } + + @Test + fun `with no profile to compare against nothing reads as changed`() { + val project = Project3mfSettings.parse("""{"layer_height": "0.16", "wall_loops": "3"}""") + + val rows = project.summarize(Project3mfSettings.NONE) + + assertEquals(2, rows.size) + assertTrue(rows.none { it.differs }) + assertTrue(rows.all { it.default == null }) + } + + @Test + fun `an STL has no sheet`() { + assertTrue(Project3mfSettings.NONE.summarize().isEmpty()) + } + + @Test + fun `booleans read as words rather than digits`() { + val rows = Project3mfSettings.parse( + """{"enable_support": "1", "enable_prime_tower": "0"}""", + ).associateRows() + + assertEquals("On", rows["Supports"]!!.value) + assertEquals("Off", rows["Prime tower"]!!.value) + } + + private fun Project3mfSettings.associateRows() = summarize().associateBy { it.label } + + // ---- Seeding the prepare screen ---- + + @Test + fun `the prepare screen opens showing what the project asks for`() { + val project = Project3mfSettings.parse( + """ + {"enable_support": "1", "support_type": "tree(auto)", "support_threshold_angle": "30", + "support_on_build_plate_only": "1", "support_base_pattern": "rectilinear", + "brim_type": "outer_only", "brim_width": "5", + "sparse_infill_density": "25%", "sparse_infill_pattern": "grid", + "ironing_type": "top", "ironing_flow": "20%", "ironing_speed": "25"} + """.trimIndent(), + ) + + val seeded = HelixSliceSettings.seededFrom(project) + + assertTrue(seeded.supportsEnabled) + assertEquals("tree(auto)", seeded.supportType) + assertEquals(30, seeded.supportAngle) + assertTrue(seeded.supportBuildPlateOnly) + assertEquals("rectilinear", seeded.supportPattern) + assertEquals(5f, seeded.brimWidthMm, 0.001f) + assertEquals(0.25f, seeded.infillDensity, 0.0001f) + assertEquals("grid", seeded.infillPattern) + assertEquals("top", seeded.ironingType) + assertEquals(20, seeded.ironingFlow) + assertEquals(25, seeded.ironingSpeed) + } + + @Test + fun `seeded values are not treated as user overrides`() { + val project = Project3mfSettings.parse("""{"enable_support": "1", "brim_width": "5"}""") + + val seeded = HelixSliceSettings.seededFrom(project) + + // Showing the file's supports must not write them back as a Helix override, + // or every project would be re-stamped with its own values on every slice. + assertTrue(seeded.chosen.isEmpty()) + assertTrue(seeded.toProfileKeyOverrides().isEmpty()) + } + + @Test + fun `a project with its brim switched off shows no brim width`() { + val seeded = HelixSliceSettings.seededFrom( + Project3mfSettings.parse("""{"brim_type": "no_brim", "brim_width": "5"}"""), + ) + + assertEquals(0f, seeded.brimWidthMm, 0.001f) + assertFalse(seeded.hasBrimEnabled()) + } + + @Test + fun `an STL seeds the prepare screen with plain defaults`() { + assertEquals(HelixSliceSettings(), HelixSliceSettings.seededFrom(Project3mfSettings.NONE)) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/slicing/ProjectSettingsSheetAssetTest.kt b/android/app/src/test/java/org/crabcore/u1control/slicing/ProjectSettingsSheetAssetTest.kt new file mode 100644 index 0000000..c3fb91f --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/slicing/ProjectSettingsSheetAssetTest.kt @@ -0,0 +1,64 @@ +package org.crabcore.u1control.slicing + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File + +/** + * The project-settings sheet against real bundled profile data. + * + * The curated key list is only useful if those keys exist in the files users + * actually open; a typo in one would silently drop its row and nothing else + * would notice. + */ +class ProjectSettingsSheetAssetTest { + + private fun asset(name: String): File? = listOf( + File("src/main/assets/orca_profiles/printer/$name"), + File("app/src/main/assets/orca_profiles/printer/$name"), + File("android/app/src/main/assets/orca_profiles/printer/$name"), + ).firstOrNull { it.exists() } + + private fun settings(name: String): Project3mfSettings? = + asset(name)?.let { Project3mfSettings.parse(it.readText()) } + + @Test + fun `every curated setting is present in a real Bambu profile`() { + val profile = settings("bambu_a1.json") + assumeTrue("bundled A1 profile not reachable from the test working directory", profile != null) + + val rows = profile!!.summarize() + + // Every row the sheet can show should resolve against real data. + assertEquals(20, rows.size) + assertTrue(rows.all { it.value.isNotBlank() }) + assertEquals("0.2 mm", rows.first { it.label == "Layer height" }.value) + assertEquals("Supertack Plate", rows.first { it.label == "Build plate" }.value) + // The A1's Supertack PLA figure, not the High Temp plate's. + assertEquals("45°C", rows.first { it.label == "Bed temp" }.value) + } + + @Test + fun `a profile compared against itself reports no changes`() { + val profile = settings("bambu_a1.json") + assumeTrue("bundled A1 profile not reachable from the test working directory", profile != null) + + assertTrue(profile!!.summarize(profile).none { it.differs }) + } + + @Test + fun `a project that changed something is reported as changed`() { + val profile = settings("bambu_a1.json") + assumeTrue("bundled A1 profile not reachable from the test working directory", profile != null) + + val project = Project3mfSettings.parse("""{"layer_height": "0.28", "wall_loops": "2"}""") + val rows = project.summarize(profile!!).associateBy { it.label } + + assertEquals("0.28 mm", rows["Layer height"]!!.value) + assertEquals("0.2 mm", rows["Layer height"]!!.default) + assertTrue(rows["Layer height"]!!.differs) + assertEquals(false, rows["Walls"]!!.differs) + } +} diff --git a/android/app/src/test/java/org/crabcore/u1control/slicing/ZmodPrintPromptTest.kt b/android/app/src/test/java/org/crabcore/u1control/slicing/ZmodPrintPromptTest.kt new file mode 100644 index 0000000..b4eb5ce --- /dev/null +++ b/android/app/src/test/java/org/crabcore/u1control/slicing/ZmodPrintPromptTest.kt @@ -0,0 +1,116 @@ +package org.crabcore.u1control.slicing + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Captured verbatim from a FlashForge AD5X (zmod) accepting a print. This is the + * run that left Helix reporting the job as sent while the printer sat waiting. + */ +class ZmodPrintPromptTest { + private val commit = + """PRINT_ZCOLOR LEVELING=1 FILENAME="1+Mini+Turtle.gcode" ALLOWED_TOOL_COUNT=4 T0=2 T1=2 T2=2 T3=2""" + + private val promptLines = listOf( + "// action:prompt_end", + "// action:prompt_begin Select print materials", + "// action:prompt_text 1+Mini+Turtle.gcode | Extruder: None (2)", + """// action:prompt_button Leveling Off|SET_ZCOLOR SILENT=0 FILENAME="1+Mini+Turtle.gcode" LEVELING=1| |808080""", + "// action:prompt_footer_button Start print|$commit|red", + "// action:prompt_footer_button Cancel|RESPOND TYPE=command MSG=action:prompt_end", + "// action:prompt_show", + ) + + @Test + fun findsTheCommitMacroOfAnOpenPrompt() { + assertEquals(commit, ZmodPrintPrompt.pendingCommitMacro(promptLines)) + } + + @Test + fun aPromptNotYetShownIsNotAnswered() { + // Everything except prompt_show — the printer is still composing it. + assertNull(ZmodPrintPrompt.pendingCommitMacro(promptLines.dropLast(1))) + } + + @Test + fun anAlreadyClosedPromptIsNotAnswered() { + // The dangerous one: a stale prompt in the buffer must not make Helix fire + // a print macro at a printer that has moved on. + assertNull(ZmodPrintPrompt.pendingCommitMacro(promptLines + "// action:prompt_end")) + } + + @Test + fun ordinaryConsoleOutputIsInert() { + val noise = listOf("ok", "// H1 > command H1 ok. 7913595", """!! Unknown command:"T0"""") + assertNull(ZmodPrintPrompt.pendingCommitMacro(noise)) + assertNotNull(ZmodPrintPrompt.pendingCommitMacro(noise + promptLines)) + } + + @Test + fun lanesAreRewrittenFromZeroBasedSlotsToOneBasedLanes() { + val out = ZmodPrintPrompt.applyToolSlots(commit, mapOf(0 to 0, 1 to 1, 2 to 2, 3 to 3)) + assertTrue(out, out.contains("T0=1 T1=2 T2=3 T3=4")) + // The tool count is not a lane argument. + assertTrue(out, out.contains("ALLOWED_TOOL_COUNT=4")) + } + + @Test + fun unmappedToolsKeepThePrinterOwnProposal() { + val out = ZmodPrintPrompt.applyToolSlots(commit, mapOf(0 to 3)) + assertTrue(out, out.contains("T0=4 T1=2 T2=2 T3=2")) + } + + @Test + fun levellingIsAppliedAndOnlyToItsOwnArgument() { + assertTrue(ZmodPrintPrompt.applyLeveling(commit, false).contains("LEVELING=0")) + assertTrue(ZmodPrintPrompt.applyLeveling(commit, true).contains("LEVELING=1")) + // Null leaves the printer's own value alone. + assertEquals(commit, ZmodPrintPrompt.applyLeveling(commit, null)) + val similar = "PRINT_ZCOLOR AUTO_LEVELING_MODE=3 LEVELING=0" + assertTrue(ZmodPrintPrompt.applyLeveling(similar, true).contains("AUTO_LEVELING_MODE=3")) + } + + @Test + fun answersOnlyForOurOwnFile() { + val mine = ZmodPrintPrompt.answerFor(promptLines, "1+Mini+Turtle.gcode", mapOf(0 to 1), false) + assertNotNull(mine) + assertTrue(mine!!, mine.contains("T0=2")) + assertTrue(mine, mine.contains("LEVELING=0")) + + // A job started at the printer is the operator's to answer. + assertNull(ZmodPrintPrompt.answerFor(promptLines, "someone-elses.gcode", mapOf(0 to 1), false)) + } + + @Test + fun matchesOnBasenameSoAPathStillCounts() { + assertTrue(ZmodPrintPrompt.macroTargetsFile(commit, "gcodes/1+Mini+Turtle.gcode")) + assertTrue(!ZmodPrintPrompt.macroTargetsFile(commit, "other.gcode")) + } + + @Test + fun readsMessagesOutOfAMoonrakerStoreReply() { + // Built rather than hand-written: the macro contains quotes, and splicing it + // into a JSON literal produces a payload the parser rightly rejects. + val store = JSONArray() + for (line in promptLines) { + store.put(JSONObject().put("message", line).put("type", "response")) + } + val json = JSONObject().put("result", JSONObject().put("gcode_store", store)).toString() + + val messages = ZmodPrintPrompt.messagesFromStore(json) + assertEquals(promptLines.size, messages.size) + assertEquals(commit, ZmodPrintPrompt.pendingCommitMacro(messages)) + } + + @Test + fun malformedStoreRepliesYieldNothingRatherThanThrowing() { + for (bad in listOf("", "not json", "{}", """{"result":{}}""")) { + assertEquals(bad, emptyList(), ZmodPrintPrompt.messagesFromStore(bad)) + } + } +} diff --git a/app.json b/app.json index a00ac7f..105c59d 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Helix", "slug": "u1-control", - "version": "3.0.0", + "version": "3.0.1", "orientation": "portrait", "scheme": "u1control", "userInterfaceStyle": "dark", @@ -24,7 +24,7 @@ }, "android": { "package": "org.crabcore.u1control", - "versionCode": 19, + "versionCode": 20, "softwareKeyboardLayoutMode": "resize", "permissions": [ "android.permission.POST_NOTIFICATIONS" diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 73ce58c..77bb7b9 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -9,6 +9,7 @@ import { t } from '../../services/i18n'; import { getSharedModelFile, takeNativePrintSentNotice } from '../../services/nativeSlicer'; import { setPendingModel } from '../../services/pendingModel'; import { COCKPIT } from '../../components/dashboard/shared'; +import { setPrintIntent } from '../../services/printIntent'; type IconName = React.ComponentProps['name']; @@ -37,6 +38,16 @@ export default function TabLayout() { const filename = await takeNativePrintSentNotice(); if (!alive) return; if (filename) { + // The native preview screen uploads and starts the print itself, so + // nothing staged an intent for the material prompt a zmod printer + // raises — and without one that prompt takes over the screen with a + // slot picker the user already filled in on the native sheet. + // + // Empty mapping on purpose: that sheet has already rewritten the + // G-code so each tool is its physical slot, so the printer's own + // proposal is correct and answering it verbatim is exactly what + // tapping its "Start print" button would do. + setPrintIntent({ filename, toolToSlot: {} }); setNativePrintFilename(filename); router.navigate('/'); return; diff --git a/app/(tabs)/ace.tsx b/app/(tabs)/ace.tsx index 0366500..329d26c 100644 --- a/app/(tabs)/ace.tsx +++ b/app/(tabs)/ace.tsx @@ -24,6 +24,9 @@ import type { TemperatureUnit } from '../../services/temperature'; import { colors, spacing } from '../../constants/theme'; import { useThemedAlert } from '../../hooks/useThemedAlert'; +/** No fallbacks: lanes fall back to their own reported colour or the empty outline. */ +const EMPTY_SLOT_COLORS: string[] = []; + function resolveAceSlotColors(status: Record, fallback: string[]): string[] { const raw = status.print_task_config?.filament_color_rgba; return fallback.map((fallbackColor, index) => { @@ -50,12 +53,17 @@ function multiAceOverrideUrl(activeUrl: string): string { } export default function ACEScreen() { - const { units, aceMacros, hardwareDetected, sendGcode, activeAceIndex } = useACE(); + const { units, aceMacros, hardwareDetected, sendGcode, activeAceIndex, mode } = useACE(); const { connection, status, activeUrl } = useMoonraker(); const { settings } = useSettings(); const { showAlert, alertDialog } = useThemedAlert(); const disabled = connection !== 'connected'; - const slotColors = resolveAceSlotColors(status, settings.filamentSlotColors); + // A material station reports its own colours, and print_task_config is a + // Snapmaker-only object — falling back to the U1's manually configured slot + // colours here painted the AD5X's lanes with another printer's filament. + const slotColors = mode === 'material-station' + ? EMPTY_SLOT_COLORS + : resolveAceSlotColors(status, settings.filamentSlotColors); const [editingSlot, setEditingSlot] = useState(null); const [savingSlot, setSavingSlot] = useState(false); diff --git a/app/(tabs)/files.tsx b/app/(tabs)/files.tsx index fc671ab..6035a45 100644 --- a/app/(tabs)/files.tsx +++ b/app/(tabs)/files.tsx @@ -17,6 +17,10 @@ import { usePrintHistory } from '../../hooks/usePrintHistory'; import { t } from '../../services/i18n'; import { useThemedAlert } from '../../hooks/useThemedAlert'; import { useSettings } from '../../hooks/useSettings'; +import { printerProfile } from '../../services/printerProfiles'; +import { setPrintIntent } from '../../services/printIntent'; +import { ifsOffPrintGcode } from '../../services/zmodPrintPrompt'; +import { applicablePrefs } from '../../services/printPreprocess'; import PrintPreprocessDialog, { type PrintPref } from '../../components/PrintPreprocessDialog'; import type { FilamentSlotDisplay } from '../../components/FilamentSlotsEditor'; import { normalizeFilamentSlotColors } from '../../constants/filamentColors'; @@ -27,6 +31,27 @@ import { routeTools } from '../../services/printPreprocess'; type Mode = 'files' | 'history' | 'timelapse'; +/** Map a Klipper print_stats state onto the picker's label and selectability. */ +function printerStatusEntry(state: string | undefined): { + label: string; + busy: boolean; + selectable: boolean; +} { + const value = state ?? 'unknown'; + const busy = value === 'printing' || value === 'paused'; + const label = + value === 'printing' ? 'Printing' + : value === 'paused' ? 'Paused' + : value === 'error' ? 'Error' + : 'Ready'; + return { label, busy, selectable: !busy }; +} + +/** Short enough that a sleeping printer doesn't hold the picker on "Checking…". */ +const PRINTER_PROBE_TIMEOUT_MS = 3500; +/** Re-check while the sheet is open so a printer waking up turns Ready on its own. */ +const PRINTER_PROBE_INTERVAL_MS = 6000; + const MODES: { key: Mode; label: string }[] = [ { key: 'files', label: 'Files' }, { key: 'history', label: 'History' }, @@ -50,6 +75,7 @@ export default function FilesScreen() { flowCal: false, timelapse: false, autoLevel: false, + ifs: true, }); const [assignments, setAssignments] = useState>({}); const [selectedPrinterId, setSelectedPrinterId] = useState(settings.activePrinterId); @@ -86,32 +112,69 @@ export default function FilesScreen() { id: printer.id, name: printer.name, url: printerConnectionUrl(printer), + kind: printer.kind, + // Files can print to a printer other than the active one, so the PAXX + // capability has to travel with the option, not come from global state. + supportsPrintPreferences: printerProfile(printer.kind).supportsPrintPreferences, })), [settings.printers]); useEffect(() => { if (!selectedFile || printerOptions.length === 0) return; let live = true; - setPrinterStatuses(Object.fromEntries(printerOptions.map((printer) => [printer.id, { - label: 'Checking…', - busy: false, - selectable: Boolean(printer.url), - }]))); - Promise.all(printerOptions.map(async (printer) => { - if (!printer.url) return [printer.id, { label: 'No URL', busy: false, selectable: false }] as const; + + // Paint something true immediately. The active printer already has a live + // WebSocket, so its state is known without any HTTP round trip, and any + // printer probed earlier keeps its last-known label rather than flashing + // "Checking…" — a connected machine reading "Offline" for a few seconds + // looks like a fault when nothing is actually wrong. + setPrinterStatuses((prev) => { + const next = { ...prev }; + for (const printer of printerOptions) { + if (printer.id === settings.activePrinterId && connected) { + next[printer.id] = printerStatusEntry(status.print_stats?.state); + } else if (!next[printer.id]) { + next[printer.id] = { + label: 'Checking…', + busy: false, + selectable: Boolean(printer.url), + }; + } + } + return next; + }); + // Each printer settles on its own. Waiting for Promise.all meant one slow + // or sleeping machine held every other printer on "Checking…", and a single + // failed probe used to pin a live printer at "Offline" until the sheet was + // reopened — so keep re-probing while it is open. + const probe = async (printer: (typeof printerOptions)[number]) => { + if (!printer.url) return { label: 'No URL', busy: false, selectable: false }; try { - const result = await api.queryObjects<{ print_stats?: { state?: string } }>(printer.url, ['print_stats']); - const state = result?.status?.print_stats?.state ?? 'unknown'; - const busy = state === 'printing' || state === 'paused'; - const label = state === 'printing' ? 'Printing' : state === 'paused' ? 'Paused' : state === 'error' ? 'Error' : 'Ready'; - return [printer.id, { label, busy, selectable: !busy }] as const; + const result = await api.queryObjects<{ print_stats?: { state?: string } }>( + printer.url, + ['print_stats'], + PRINTER_PROBE_TIMEOUT_MS + ); + return printerStatusEntry(result?.status?.print_stats?.state); } catch { - return [printer.id, { label: 'Offline', busy: false, selectable: false }] as const; + return { label: 'Offline', busy: false, selectable: false }; } - })).then((entries) => { - if (live) setPrinterStatuses(Object.fromEntries(entries)); - }); - return () => { live = false; }; - }, [printerOptions, selectedFile]); + }; + + const probeAll = () => { + printerOptions.forEach(async (printer) => { + const entry = await probe(printer); + if (!live) return; + setPrinterStatuses((prev) => ({ ...prev, [printer.id]: entry })); + }); + }; + + probeAll(); + const timer = setInterval(probeAll, PRINTER_PROBE_INTERVAL_MS); + return () => { + live = false; + clearInterval(timer); + }; + }, [printerOptions, selectedFile, connected, settings.activePrinterId, status.print_stats?.state]); const selectedPrinter = printerOptions.find((printer) => printer.id === selectedPrinterId) ?? printerOptions[0]; @@ -182,8 +245,14 @@ export default function FilesScreen() { }); }, []); - const reprint = useCallback(async (prefs: Readonly>) => { + const reprint = useCallback(async (rawPrefs: Readonly>) => { if (!selectedFile || !activeUrl || !selectedPrinter?.url) return; + // Files can send to a printer other than the active one, and the toggles + // survive a swap — honour only what the target machine offers. + const prefs = applicablePrefs(rawPrefs, { + printerKind: selectedPrinter.kind, + multicolor: fileSlots.length > 1, + }); const targetUrl = selectedPrinter.url; setSending(true); setModalError(null); @@ -204,6 +273,10 @@ export default function FilesScreen() { ); const usedExtruders = [...new Set(Object.values(effectiveAssignments) as number[])].sort((a, b) => a - b); + // One toolhead: colors come from the material station, or — with IFS off — + // from the external side spool via zmod's per-print SET_ZCOLOR. See slicer.tsx. + const ifsOff = printerProfile(selectedPrinter?.kind).printPrefs.includes('ifs') && !prefs.ifs; + // Busy check — refuse if the target printer is already printing/paused. const before = await api.queryObjects<{ print_stats?: { state?: string } }>(targetUrl, ['print_stats']); const currentState = before?.status?.print_stats?.state; @@ -238,36 +311,54 @@ export default function FilesScreen() { // Apply preferences (always explicit — firmware caches prior job state). setSendProgress(0.7); - await api.runGcode( - targetUrl, - `SET_MAIN_STATE MAIN_STATE=IDLE\nSET_PRINT_USED_EXTRUDERS EXTRUDERS=${usedExtruders.join(',')}\nSET_PRINT_PREFERENCES BED_LEVEL=${prefs.autoLevel ? 1 : 0} TIME_LAPSE_CAMERA=${prefs.timelapse ? 1 : 0} FLOW_CALIBRATE=${prefs.flowCal ? 1 : 0} FLOW_CALIBRATE_EXTRUDERS=0,1,2,3`, - ); + // PAXX/U1 firmware only. Other machines have neither the macros nor the + // print_task_config object, so this block errored and then failed its own + // read-back — the "Printer rejected the selected print preferences" report. + if (selectedPrinter.supportsPrintPreferences) { + await api.runGcode( + targetUrl, + `SET_MAIN_STATE MAIN_STATE=IDLE\nSET_PRINT_USED_EXTRUDERS EXTRUDERS=${usedExtruders.join(',')}\nSET_PRINT_PREFERENCES BED_LEVEL=${prefs.autoLevel ? 1 : 0} TIME_LAPSE_CAMERA=${prefs.timelapse ? 1 : 0} FLOW_CALIBRATE=${prefs.flowCal ? 1 : 0} FLOW_CALIBRATE_EXTRUDERS=0,1,2,3`, + ); - // Verify the firmware accepted the preferences; abort if not. - const applied = await api.queryObjects<{ - print_task_config?: { - auto_bed_leveling?: boolean; - time_lapse_camera?: boolean; - flow_calibrate?: boolean; - flow_calib_extruders?: boolean[]; - extruders_used?: boolean[]; - }; - }>(targetUrl, ['print_task_config']); - const taskConfig = applied?.status?.print_task_config; - if ( - taskConfig?.auto_bed_leveling !== prefs.autoLevel || - taskConfig?.time_lapse_camera !== prefs.timelapse || - taskConfig?.flow_calibrate !== prefs.flowCal || - taskConfig?.flow_calib_extruders?.length !== 4 || - !taskConfig?.flow_calib_extruders?.every(Boolean) || - taskConfig?.extruders_used?.length !== 4 || - !taskConfig?.extruders_used?.every((used, tool) => used === usedExtruders.includes(tool)) - ) { - throw new Error('Printer rejected the selected print preferences.'); + // Verify the firmware accepted the preferences; abort if not. + const applied = await api.queryObjects<{ + print_task_config?: { + auto_bed_leveling?: boolean; + time_lapse_camera?: boolean; + flow_calibrate?: boolean; + flow_calib_extruders?: boolean[]; + extruders_used?: boolean[]; + }; + }>(targetUrl, ['print_task_config']); + const taskConfig = applied?.status?.print_task_config; + if ( + taskConfig?.auto_bed_leveling !== prefs.autoLevel || + taskConfig?.time_lapse_camera !== prefs.timelapse || + taskConfig?.flow_calibrate !== prefs.flowCal || + taskConfig?.flow_calib_extruders?.length !== 4 || + !taskConfig?.flow_calib_extruders?.every(Boolean) || + taskConfig?.extruders_used?.length !== 4 || + !taskConfig?.extruders_used?.every((used, tool) => used === usedExtruders.includes(tool)) + ) { + throw new Error('Printer rejected the selected print preferences.'); + } } setSendProgress(0.92); - await api.startPrint(targetUrl, printPath); + if (ifsOff) { + // IFS off: SET_ZCOLOR SILENT=2 starts with no material prompt, so no + // intent is staged — the external side spool feeds the whole print. + await api.runGcode(targetUrl, ifsOffPrintGcode(printPath, prefs.autoLevel)); + } else { + // Reprints keep the file's own tool numbering, so the routed assignments + // are the mapping a zmod material prompt needs — see printIntent. + setPrintIntent({ + filename: printPath, + toolToSlot: effectiveAssignments, + autoLevel: prefs.autoLevel, + }); + await api.startPrint(targetUrl, printPath); + } setSendProgress(1); closePrintModal(); showAlert({ title: t('Print started'), message: printPath, icon: 'check-circle' }); @@ -341,7 +432,8 @@ export default function FilesScreen() { progress={sendProgress} errorMessage={modalError} onSend={reprint} - sendLabel="Print Again" + sendLabel="Hold to Print Again" + printerKind={selectedPrinter?.kind ?? null} /> ); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 5a2f669..7a74257 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,6 +1,9 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { + Animated, + Easing, KeyboardAvoidingView, + PanResponder, Platform, Pressable, ScrollView, @@ -25,9 +28,11 @@ import { useMoonraker } from '../../hooks/useMoonraker'; import AboutCard from '../../components/settings/AboutCard'; import BackupCard from '../../components/settings/BackupCard'; import MacroDisplayCard from '../../components/settings/MacroDisplayCard'; +import PrinterDiscoveryDialog from '../../components/settings/PrinterDiscoveryDialog'; import PrinterEditorModal from '../../components/settings/PrinterEditorModal'; import ThemedDialog from '../../components/ThemedDialog'; import { buildSettingsSavePatch, hasDraftChanges } from '../../services/settingsDraft'; +import { deleteBespok3dCredentialRecord } from '../../services/bespok3dSecureStore'; import { clearStoredFcmDeviceToken, configureFcmForPrinter, @@ -51,12 +56,21 @@ import { import { api, applyConfigIfChanged, + type AiDetectionSensitivity, isTailscaleUrl, normalizeBaseUrl, normalizeMoonrakerUrl, printerConnectionUrl, } from '../../services/moonraker'; import { getMakerWorldCookies } from '../../services/nativeSlicer'; +import type { DiscoveredPrinter } from '../../services/printerDiscovery'; +import { printerEntryFromDiscovery } from '../../services/printerSetup'; +import { MANUAL_PRINTER_KIND } from '../../services/printerProfiles'; +import { + dashboardSectionAvailable, + getDashboardSections, + setDashboardForPrinter, +} from '../../services/dashboardSections'; const ACCENTS = [ { name: 'Fluidd Blue', hex: '#2196f3' }, @@ -121,6 +135,7 @@ export default function SettingsScreen() { // Non-null while the Add printer modal is open: a blank entry the modal // fills in, so adding looks and behaves exactly like editing. const [newPrinter, setNewPrinter] = useState(null); + const [printerDiscoveryOpen, setPrinterDiscoveryOpen] = useState(false); const [editingPrinterId, setEditingPrinterId] = useState(null); const [saveDialogOpen, setSaveDialogOpen] = useState(false); // null = the index. Nova's whole bet: one screen at a time, so a Save button @@ -218,8 +233,19 @@ export default function SettingsScreen() { setPendingRemoval(p); }; - const confirmRemovePrinter = (p: PrinterEntry) => { + const confirmRemovePrinter = async (p: PrinterEntry) => { setPendingRemoval(null); + // A deleted printer must not leave its bearer token or certificate behind. + try { + await deleteBespok3dCredentialRecord(p.id); + } catch (error) { + showAlert({ + title: 'Could not remove printer securely', + message: error instanceof Error ? error.message : String(error), + icon: 'shield-alert-outline', + }); + return; + } const printers = settings.printers.filter((x) => x.id !== p.id); const macroDisplayByPrinter = { ...settings.macroDisplayByPrinter }; delete macroDisplayByPrinter[p.id]; @@ -278,6 +304,17 @@ export default function SettingsScreen() { return true; }; + const reviewDiscoveredPrinter = (printer: DiscoveredPrinter) => { + setPrinterDiscoveryOpen(false); + setNewPrinter( + printerEntryFromDiscovery( + printer, + `p${Date.now()}`, + `Printer ${settings.printers.length + 1}` + ) + ); + }; + const setNotificationMode = (mode: Settings['notificationMode']) => { const patch: Partial = { notificationMode: mode }; if (mode === 'ntfy') { @@ -291,12 +328,20 @@ export default function SettingsScreen() { const randomizeNtfyTopic = () => set({ ntfyTopic: generateNtfyTopic() }); + const activeSections = getDashboardSections(settings); + + // Writes against the active printer only. `dashboard` stays the template new + // printers inherit, so it tracks the last edit rather than going stale. const updateDashboardSection = (key: keyof DashboardSections, value: boolean) => { + const next = { ...activeSections, [key]: value }; update({ - dashboard: { - ...settings.dashboard, - [key]: value, - }, + dashboard: next, + dashboardByPrinter: setDashboardForPrinter( + settings.dashboardByPrinter, + settings.activePrinterId, + next, + settings.dashboard + ), }); }; @@ -359,12 +404,19 @@ export default function SettingsScreen() { const online = connection === 'connected'; const activePrinter = draft.printers.find((p) => p.id === draft.activePrinterId); - const sectionsOn = SECTION_LABELS.filter(({ key }) => settings.dashboard[key]).length; + // The draft can stay mounted while another tab switches printers. Capability + // gates must follow the live active printer or U1-only controls leak onto an + // AD5X until Settings is remounted. + const liveActivePrinter = settings.printers.find((p) => p.id === settings.activePrinterId); + const availableSectionLabels = SECTION_LABELS.filter(({ key }) => + dashboardSectionAvailable(activePrinter?.kind, key) + ); + const sectionsOn = availableSectionLabels.filter(({ key }) => activeSections[key]).length; const notifyOn = NOTIFY_KEYS.filter((key) => draft[key]).length; const accentName = ACCENTS.find((a) => a.hex === draft.accentColor)?.name ?? 'Custom'; const languageName = LANGUAGES.find((l) => l.code === draft.language)?.label ?? draft.language; - const SECTIONS: { + const allSections: { key: string; title: string; icon: IconName; @@ -398,7 +450,14 @@ export default function SettingsScreen() { title: t('Dashboard sections'), icon: 'view-dashboard-outline', commit: 'instant', - summary: `${sectionsOn} ${t('of')} ${SECTION_LABELS.length} ${t('sections shown')}`, + summary: `${sectionsOn} ${t('of')} ${availableSectionLabels.length} ${t('sections shown')}`, + }, + { + key: 'ai-monitoring', + title: 'AI monitoring', + icon: 'robot-outline', + commit: 'live', + summary: `${draft.aiDetectionSensitivity === 'high' ? 'High' : 'Low'} sensitivity · Spaghetti & bed`, }, { key: 'appearance', @@ -441,6 +500,9 @@ export default function SettingsScreen() { summary: t('Export, import, updates'), }, ]; + const SECTIONS = allSections.filter( + ({ key }) => key !== 'ai-monitoring' || liveActivePrinter?.kind === 'snapmaker-u1' + ); const current = SECTIONS.find((s) => s.key === section) ?? null; @@ -559,6 +621,15 @@ export default function SettingsScreen() { )} ))} + setPrinterDiscoveryOpen(true)} + > + + + Discover printers on Wi-Fi + + @@ -569,22 +640,29 @@ export default function SettingsScreen() { tailscaleUrl: '', cameraUrl: '/webcam/webrtc', connectionMode: 'lan', + kind: MANUAL_PRINTER_KIND, }) } > - + {t('Add printer')} + + {t('Add printer manually')} ) : null} {section === 'dashboard' ? ( + <> + {/* These toggles are per printer now, so note it — otherwise + there is no way to tell which machine a change applies to. */} + + {t('These sections apply to this printer only.')} + - {SECTION_LABELS.map(({ key, label }) => ( + {availableSectionLabels.map(({ key, label }) => ( updateDashboardSection(key, v)} /> ))} @@ -595,6 +673,22 @@ export default function SettingsScreen() { /> + + ) : null} + + {section === 'ai-monitoring' ? ( + + Detection sensitivity + + Controls how readily AI pauses for spaghetti failures and build-plate + obstructions. High catches more potential failures, but may cause more false + pauses. + + setLive({ aiDetectionSensitivity })} + /> + ) : null} {section === 'appearance' ? ( @@ -820,6 +914,12 @@ export default function SettingsScreen() { + setPrinterDiscoveryOpen(false)} + onSelect={reviewDiscoveredPrinter} + /> setEditingPrinterId(null)} @@ -831,7 +931,6 @@ export default function SettingsScreen() { onClose={() => setNewPrinter(null)} onSave={saveNewPrinter} /> - void; +}) { + const [trackWidth, setTrackWidth] = useState(0); + const [dragging, setDragging] = useState(false); + const position = useRef(new Animated.Value(value === 'high' ? 1 : 0)).current; + const dragPosition = useRef(value === 'high' ? 1 : 0); + const trackRef = useRef(null); + const trackWidthRef = useRef(0); + const trackPageXRef = useRef(0); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + dragPosition.current = value === 'high' ? 1 : 0; + Animated.timing(position, { + toValue: dragPosition.current, + duration: 180, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + }, [position, value]); + + const updatePosition = (pageX: number) => { + const usableWidth = Math.max(1, trackWidthRef.current - 24); + const next = Math.max( + 0, + Math.min(1, (pageX - trackPageXRef.current - 12) / usableWidth) + ); + dragPosition.current = next; + position.setValue(next); + }; + + const commitPosition = () => { + const next: AiDetectionSensitivity = dragPosition.current >= 0.5 ? 'high' : 'low'; + onChangeRef.current(next); + setDragging(false); + }; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: (event) => { + setDragging(true); + const pageX = event.nativeEvent.pageX; + trackRef.current?.measureInWindow((x, _y, width) => { + trackPageXRef.current = x; + trackWidthRef.current = width; + setTrackWidth(width); + updatePosition(pageX); + }); + }, + onPanResponderMove: (event) => updatePosition(event.nativeEvent.pageX), + onPanResponderRelease: commitPosition, + onPanResponderTerminate: commitPosition, + }) + ).current; + + const usableWidth = Math.max(0, trackWidth - 24); + const thumbX = position.interpolate({ inputRange: [0, 1], outputRange: [0, usableWidth] }); + const fillWidth = position.interpolate({ inputRange: [0, 1], outputRange: [0, usableWidth] }); + + return ( + + + {(['low', 'high'] as const).map((option) => { + const selected = value === option; + return ( + onChange(option)} hitSlop={10}> + + {option === 'low' ? 'Low' : 'High'} + + + ); + })} + + { + const width = event.nativeEvent.layout.width; + trackWidthRef.current = width; + setTrackWidth(width); + trackRef.current?.measureInWindow((x) => { + trackPageXRef.current = x; + }); + }} + accessible + accessibilityRole="adjustable" + accessibilityLabel="AI detection sensitivity" + accessibilityValue={{ min: 0, max: 1, now: value === 'high' ? 1 : 0, text: value }} + accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]} + onAccessibilityAction={(event) => + onChange(event.nativeEvent.actionName === 'increment' ? 'high' : 'low') + } + {...panResponder.panHandlers} + > + + + + + + ); +} + const styles = StyleSheet.create({ screen: { flex: 1, @@ -1153,6 +1367,62 @@ const styles = StyleSheet.create({ fontSize: 12, marginBottom: spacing.sm, }, + aiDescription: { + color: colors.subtext, + fontSize: 12, + lineHeight: 18, + }, + sensitivityControl: { + marginTop: spacing.lg, + }, + sensitivityLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + marginHorizontal: 2, + marginBottom: 2, + }, + sensitivityLabel: { + color: colors.subtext, + fontSize: 13, + fontWeight: '700', + }, + sensitivityLabelActive: { + color: colors.primary, + }, + sensitivityTouchTrack: { + height: 40, + justifyContent: 'center', + }, + sensitivityTrack: { + position: 'absolute', + left: 12, + right: 12, + height: 6, + borderRadius: 3, + backgroundColor: colors.cardAlt, + }, + sensitivityFill: { + position: 'absolute', + left: 12, + height: 6, + borderRadius: 3, + backgroundColor: colors.primary, + }, + sensitivityThumb: { + position: 'absolute', + left: 0, + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 3, + borderColor: colors.primary, + backgroundColor: colors.text, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3, + elevation: 4, + }, field: { gap: 4, }, @@ -1223,6 +1493,12 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: '600', }, + smallBtnContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + }, connectionBtn: { minHeight: 44, marginTop: spacing.sm, diff --git a/app/(tabs)/slicer.tsx b/app/(tabs)/slicer.tsx index 0500570..b11ee3e 100644 --- a/app/(tabs)/slicer.tsx +++ b/app/(tabs)/slicer.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, + AppState, Pressable, RefreshControl, ScrollView, @@ -37,6 +38,7 @@ import { getModelPlates, getNativeSlicerStatus, getSharedMakerWorldLink, + takeBambuSendRequest, ModelPlate, NativeMakerWorldDownload, NativeSliceResult, @@ -61,17 +63,38 @@ import { import { useMoonraker } from '../../hooks/useMoonraker'; import { fetchMakerWorldPlateStats } from '../../services/makerWorld'; import { useACE } from '../../hooks/useACE'; +import { useMaterialStation } from '../../hooks/useMaterialStation'; +import { + canUseReportedFilamentSlots, + materialStationSlots, + unavailableMaterialStationSlots, +} from '../../services/filamentSlots'; import type { AceUnit } from '../../hooks/useACE'; import { useSettings } from '../../hooks/useSettings'; +import { + printerProfile, + resolveMachineProfile, + type PrinterKind, +} from '../../services/printerProfiles'; import { type FilamentSlotDisplay } from '../../components/FilamentSlotsEditor'; import { normalizeFilamentSlotColors } from '../../constants/filamentColors'; import { takeMwDownload } from '../../services/mwBus'; import { subscribePendingModel, takePendingModel } from '../../services/pendingModel'; import { setPrintSentNotice } from '../../services/printSentBus'; +import { setPrintIntent } from '../../services/printIntent'; +import { ifsOffPrintGcode } from '../../services/zmodPrintPrompt'; import PrintPreprocessDialog, { type PrintPref } from '../../components/PrintPreprocessDialog'; -import { api, normalizeMoonrakerUrl, printerConnectionUrl, thumbnailUrl } from '../../services/moonraker'; +import { applicablePrefs, routeTools } from '../../services/printPreprocess'; +import { + api, + buildAiMonitoringCommand, + normalizeMoonrakerUrl, + printerConnectionUrl, + thumbnailUrl, +} from '../../services/moonraker'; import { resolveNativeMaterialProfiles } from '../../services/filamentProfiles'; import { useThemedAlert } from '../../hooks/useThemedAlert'; +import { startBambuProjectFile, uploadBambuPrintArtifact } from '../../services/bambuMqtt'; const MW_DESIGN_RE = /(?:https?:\/\/)?(?:www\.)?makerworld\.com\/(?:\w+\/)?models\/(\d+)/i; // The specific print profile/instance the user is viewing, e.g. @@ -192,25 +215,62 @@ export default function SliceLabScreen() { flowCal: false, timelapse: false, autoLevel: false, + ifs: true, }); + const [aiMonitoring, setAiMonitoring] = useState(true); const handledUrlRef = useRef(null); const awaitingInteractive = useRef(false); const { activeUrl, connection, status, objectList } = useMoonraker(); const ace = useACE(); const { settings, update: updateSettings, loaded: settingsLoaded } = useSettings(); - const toolLoad = useMemo( - () => resolveToolLoad(status, objectList, ace.units, ace.hardwareDetected, connection), - [status, objectList, ace.units, ace.hardwareDetected, connection], + // Bed of the printer this model is headed for, so the native preview draws the + // machine you are actually on instead of always the U1's 270mm plate. Bambu + // needs the serial too — one kind, two different plates. + const activeMachine = useMemo(() => { + const active = settings.printers.find((printer) => printer.id === settings.activePrinterId); + return resolveMachineProfile(active); + }, [settings.printers, settings.activePrinterId]); + const activePrinterKind = useMemo( + () => + settings.printers.find((printer) => printer.id === settings.activePrinterId)?.kind ?? null, + [settings.printers, settings.activePrinterId], ); - const filamentSlots = useMemo( - () => resolveFilamentSlots( + const bambuExternalSpool = activePrinterKind === 'bambu-lan' + && status.print_task_config?.bambu_filament_source === 'external'; + const toolLoad = useMemo( + () => resolveToolLoad( status, - settings.filamentSlotColors, - settings.filamentSlotBrands, - settings.filamentSlotMaterials, - toolLoad, + objectList, + ace.units, + ace.hardwareDetected, + connection, + activePrinterKind, ), - [status, settings.filamentSlotColors, settings.filamentSlotBrands, settings.filamentSlotMaterials, toolLoad], + [status, objectList, ace.units, ace.hardwareDetected, connection, activePrinterKind], + ); + // The AD5X reports its lanes over FlashForge's REST API, not Moonraker: it + // publishes no print_task_config at all, so resolveFilamentSlots below finds + // nothing and every lane silently falls back to the saved manual settings — + // which is how the slicer ended up offering spools from a different printer. + const materialStation = useMaterialStation(); + const filamentSlots = useMemo( + () => { + if (activePrinterKind === 'flashforge-ad5x') { + const fromStation = materialStationSlots(materialStation.units); + // Never fall through to the global manual U1 slots. If FlashForge + // credentials are missing or its REST query has not answered yet, + // unknown neutral lanes are more honest than cached filament. + return fromStation ?? unavailableMaterialStationSlots(); + } + return resolveFilamentSlots( + status, + settings.filamentSlotColors, + settings.filamentSlotBrands, + settings.filamentSlotMaterials, + toolLoad, + ); + }, + [activePrinterKind, materialStation.units, status, settings.filamentSlotColors, settings.filamentSlotBrands, settings.filamentSlotMaterials, toolLoad], ); const effectiveFilamentSlotColors = useMemo( () => filamentSlots.map((slot) => slot.color), @@ -298,6 +358,12 @@ export default function SliceLabScreen() { }, []); const pickLocalModel = useCallback(async () => { + // The tab can be reached before persisted printer settings and the native + // slicer status finish their cold-start reads. Opening Android's picker in + // that window races the activity result against initialization and also + // renders the model against DEFAULT_SETTINGS (the U1), regardless of the + // actual active printer. + if (!settingsLoaded || result.state === 'loading') return; try { const file = await pickModelFile(); applyOpenedFile(file); @@ -306,7 +372,7 @@ export default function SliceLabScreen() { if (/cancel/i.test(message)) return; showAlert({ title: 'Upload', message, icon: 'alert-circle-outline' }); } - }, [applyOpenedFile, showAlert]); + }, [applyOpenedFile, result.state, settingsLoaded, showAlert]); const clearModel = useCallback(() => { handledUrlRef.current = null; @@ -328,6 +394,37 @@ export default function SliceLabScreen() { // so we still show the model when the native handoff lands late. useEffect(() => subscribePendingModel(applyOpenedFile), [applyOpenedFile]); + // The native Bambu preview cannot perform its own send: that verified path + // needs the live RN MQTT session plus saved credentials. Its Upload & Print + // button returns here and raises this one-shot request instead. + useEffect(() => { + let active = true; + const openRequestedSend = async () => { + if (!(await takeBambuSendRequest()) || !active) return; + const last = await getLastSliceResult(); + if (!last || !active) { + showAlert({ + title: 'Bambu print', + message: 'The completed slice is no longer available. Slice the model again.', + icon: 'alert-circle-outline', + }); + return; + } + setSlice({ state: 'success', result: last }); + setPrintStart({ state: 'idle' }); + setSendProgress(0); + setPreprocessOpen(true); + }; + void openRequestedSend(); + const sub = AppState.addEventListener('change', (state) => { + if (state === 'active') void openRequestedSend(); + }); + return () => { + active = false; + sub.remove(); + }; + }, [showAlert]); + // Re-check MakerWorld login + pick up interactive downloads / native slice results. useFocusEffect( useCallback(() => { @@ -636,16 +733,30 @@ export default function SliceLabScreen() { connection === 'connected' ? activeUrl : null, filamentSlots, ); + // Bambu's file stays on logical tool 0. The selected AMS lane travels in + // the project_file payload later; baking the physical lane into T-codes + // would make the old U1-oriented engine emit the wrong tool semantics. + // The loaded mask remains physical AMS truth, though: the native print + // sheet uses it to decide which lanes can actually feed that logical tool. + const bambuLane = toolLoad.firstLoaded ?? toolLoad.selectedTool; + const bambu = activePrinterKind === 'bambu-lan'; + const previewColors = bambu + ? [effectiveFilamentSlotColors[bambuLane] ?? effectiveFilamentSlotColors[0], ...effectiveFilamentSlotColors.slice(1)] + : effectiveFilamentSlotColors; + const sliceMaterials = bambu && materialProfiles[bambuLane] + ? [materialProfiles[bambuLane], ...materialProfiles.slice(1)] + : materialProfiles; await openNativeModelPreview( path, title, - effectiveFilamentSlotColors, + previewColors, colors.primary, connection === 'connected' ? activeUrl : null, - toolLoad.selectedTool, + bambu ? 0 : toolLoad.selectedTool, toolLoad.nativeLoadedToolMask, Boolean(selectedPlate), - materialProfiles, + sliceMaterials, + activeMachine, ); } catch (error) { showAlert({ @@ -658,7 +769,7 @@ export default function SliceLabScreen() { setExtractProgress(null); setExtracting(false); } - }, [activeUrl, connection, download, effectiveFilamentSlotColors, filamentSlots, showAlert, toolLoad, plates, selectedPlate]); + }, [activeMachine, activePrinterKind, activeUrl, connection, download, effectiveFilamentSlotColors, filamentSlots, showAlert, toolLoad, plates, selectedPlate]); const openToolpathPreview = useCallback(async () => { if (slice.state !== 'success') return; @@ -673,6 +784,7 @@ export default function SliceLabScreen() { initialTool, toolLoad.nativeLoadedToolMask, slice.result.usedToolMask ?? (1 << initialTool), + activeMachine, ); } catch (error) { showAlert({ @@ -681,7 +793,7 @@ export default function SliceLabScreen() { icon: 'alert-circle-outline', }); } - }, [activeUrl, connection, download, showAlert, slice, toolLoad]); + }, [activeMachine, activeUrl, connection, download, showAlert, slice, toolLoad]); const openPreprocess = useCallback(() => { if (slice.state !== 'success') return; @@ -716,9 +828,18 @@ export default function SliceLabScreen() { // The dialog's Print button: upload the sliced gcode, verify it, then start — // all in one go, driving the dialog's progress bar. const uploadAndPrint = useCallback(async ( - requestedPrefs: Readonly>, + rawPrefs: Readonly>, ) => { if (slice.state !== 'success') return; + // The dialog can swap printers, and the toggles are one state object — so a + // time-lapse enabled for the U1 is still set after switching to an AD5X, + // where the toggle is not even shown and the macros do not exist. Honour + // only what the selected machine actually offers. + const requestedPrefs = applicablePrefs(rawPrefs, { + printerKind: activePrinterKind, + multicolor: (slice.result.usedToolMask ?? 0) !== 0 + && [0, 1, 2, 3].filter((t) => ((slice.result.usedToolMask ?? 0) & (1 << t)) !== 0).length > 1, + }); if (!activeUrl) { setPrintStart({ state: 'error', message: 'Printer URL is blank.' }); return; @@ -727,16 +848,36 @@ export default function SliceLabScreen() { 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 isBambu = activePrinterKind === 'bambu-lan'; + // Bambu keeps file tools logical and sends their physical AMS routing in + // project_file. Every existing printer keeps the old identity/remap path. const remap = toolRemap ?? {}; const fullTarget: Record = {}; - for (const ft of fileTools) fullTarget[ft] = remap[ft] ?? ft; + if (isBambu) { + const routes = routeTools( + fileTools, + filamentSlots.map((slot) => ({ + index: slot.index, + color: slot.color, + brand: slot.brand, + material: slot.material, + mainType: slot.mainType, + subType: slot.subType, + status: slot.status, + })), + remap, + ); + for (const ft of fileTools) fullTarget[ft] = routes[ft].lane; + } else { + 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 = + !isBambu && fileTools.some((ft) => fullTarget[ft] !== ft) && Boolean(slice.result.modelPath) && Boolean(slice.result.sliceSettings) && @@ -746,15 +887,94 @@ export default function SliceLabScreen() { ? 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); + const missingTools = isBambu + ? targets.some((lane) => filamentSlots.find((slot) => slot.index === lane)?.status === 'empty') + ? bambuExternalSpool + ? 'External Spool' + : targets.map((lane) => `Lane ${lane + 1}`).join(', ') + : null + : missingLoadedTools(toolLoad, requiredToolMask); if (missingTools) { setPrintStart({ state: 'error', message: `Load filament in ${missingTools} before printing.` }); return; } + // One toolhead, so colors can only come from the material station — or, with + // IFS off, from the external side spool. zmod's per-print IFS-off path is a + // SET_ZCOLOR sent in place of the normal print start; see ifsOffPrintGcode. + const ifsOff = + printerProfile(activePrinterKind).printPrefs.includes('ifs') && !requestedPrefs.ifs; const sourceName = download.state === 'success' ? download.result.fileName : null; try { let gcodePath = slice.result.gcodePath; + if (isBambu) { + if (fileRequiredMask !== 1 || fileTools.length !== 1 || fileTools[0] !== 0) { + throw new Error('Bambu LAN printing currently supports one logical filament only.'); + } + const printer = settings.printers.find((entry) => entry.id === settings.activePrinterId); + const host = bambuHostFromUrl(printer?.url ?? ''); + const serial = printer?.serialNumber?.trim() ?? ''; + const accessCode = printer?.checkCode?.trim() ?? ''; + if (!host || !serial || !accessCode) { + throw new Error('The Bambu printer needs its LAN address, serial number, and access code.'); + } + const serialPrefix = serial.toUpperCase().slice(0, 3); + const p1s = serialPrefix === '01P'; + const fullSizeA1 = serialPrefix === '039'; + if (!p1s && !fullSizeA1) { + throw new Error('Bambu LAN printing currently supports the P1S and full-size A1 only.'); + } + const currentState = status?.print_stats?.state; + if (currentState === 'printing' || currentState === 'paused') { + throw new Error(`Printer is already ${currentState}.`); + } + const targetLane = fullTarget[0]; + const filament = filamentSlots.find((slot) => slot.index === targetLane); + if (!filament || filament.status === 'empty') { + throw new Error(bambuExternalSpool + ? 'Load filament on the External Spool before printing.' + : `Load filament in Lane ${targetLane + 1} before printing.`); + } + + const remoteName = buildBambuProjectFilename(sourceName, gcodePath); + setPrintStart({ state: 'starting', message: 'Uploading project over FTPS…' }); + setSendProgress(0.15); + const uploaded = await uploadBambuPrintArtifact({ + host, + serial, + accessCode, + gcodePath, + remoteName, + usedToolMask: fileRequiredMask, + predictionSeconds: slice.result.estimatedTimeSeconds, + weightGrams: slice.result.estimatedFilamentGrams, + filamentType: bambuFilamentType(filament.mainType || filament.material), + filamentColor: normalizeFilamentSlotColors([filament.color])[0], + }); + setPrintStart({ state: 'starting', message: 'Waiting for printer confirmation…' }); + setSendProgress(0.75); + await startBambuProjectFile({ + remoteName: uploaded.remoteName, + subtaskName: uploaded.remoteName.replace(/\.gcode\.3mf$/i, ''), + archiveMd5: uploaded.archiveMd5, + toolToLane: { 0: bambuExternalSpool ? -1 : targetLane }, + // Preserve the payload accepted by the real P1S. The A1 is beta and + // may have any supported plate installed, so let its firmware detect + // the plate instead of falsely claiming it is SuperTack. + bedType: p1s ? 'supertack_plate' : 'auto', + useAms: !bambuExternalSpool, + bedLeveling: requestedPrefs.autoLevel, + flowCalibration: requestedPrefs.flowCal, + timelapse: requestedPrefs.timelapse, + vibrationCalibration: requestedPrefs.autoLevel, + }); + setSendProgress(1); + setPrintStart({ state: 'done', message: `Print started: ${uploaded.remoteName}` }); + setPreprocessOpen(false); + setPrintSentNotice({ filename: uploaded.remoteName }); + router.push('/'); + return; + } if (wantsReslice) { const onResliceProgress = (percentage: number) => setSendProgress(0.05 + (Math.max(0, Math.min(100, percentage)) / 100) * 0.06); @@ -771,6 +991,7 @@ export default function SliceLabScreen() { initialTool: collapseSlot, sliceSettings: slice.result.sliceSettings, materialProfiles: slice.result.materialProfiles, + machineProfile: JSON.stringify(activeMachine), }, onResliceProgress, ); @@ -793,6 +1014,7 @@ export default function SliceLabScreen() { sliceSettings: slice.result.sliceSettings, materialProfiles: slice.result.materialProfiles, forceExtruderCount, + machineProfile: JSON.stringify(activeMachine), }, onResliceProgress, ); @@ -831,33 +1053,55 @@ export default function SliceLabScreen() { if (currentState === 'printing' || currentState === 'paused') { throw new Error(`Printer is already ${currentState}.`); } - await api.runGcode( - activeUrl, - `SET_MAIN_STATE MAIN_STATE=IDLE\nSET_PRINT_USED_EXTRUDERS EXTRUDERS=${usedExtruders.join(',')}\nSET_PRINT_PREFERENCES BED_LEVEL=${requestedPrefs.autoLevel ? 1 : 0} TIME_LAPSE_CAMERA=${requestedPrefs.timelapse ? 1 : 0} FLOW_CALIBRATE=${requestedPrefs.flowCal ? 1 : 0} FLOW_CALIBRATE_EXTRUDERS=0,1,2,3`, - ); - const applied = await api.queryObjects<{ - print_task_config?: { - auto_bed_leveling?: boolean; - time_lapse_camera?: boolean; - flow_calibrate?: boolean; - flow_calib_extruders?: boolean[]; - extruders_used?: boolean[]; - }; - }>(activeUrl, ['print_task_config']); - const taskConfig = applied.status?.print_task_config; - if ( - taskConfig?.auto_bed_leveling !== requestedPrefs.autoLevel || - taskConfig?.time_lapse_camera !== requestedPrefs.timelapse || - taskConfig?.flow_calibrate !== requestedPrefs.flowCal || - taskConfig?.flow_calib_extruders?.length !== 4 || - !taskConfig?.flow_calib_extruders?.every(Boolean) || - taskConfig?.extruders_used?.length !== 4 || - !taskConfig?.extruders_used?.every((used, tool) => used === usedExtruders.includes(tool)) - ) { - throw new Error('Printer rejected the selected print preferences.'); + // PAXX/U1 firmware only — see supportsPrintPreferences. Other machines + // have neither the macros nor print_task_config, so this both errored and + // failed its own verification. + if (activeMachine.supportsPrintPreferences) { + await api.runGcode( + activeUrl, + `${buildAiMonitoringCommand(aiMonitoring, settings.aiDetectionSensitivity)}\nSET_MAIN_STATE MAIN_STATE=IDLE\nSET_PRINT_USED_EXTRUDERS EXTRUDERS=${usedExtruders.join(',')}\nSET_PRINT_PREFERENCES BED_LEVEL=${requestedPrefs.autoLevel ? 1 : 0} TIME_LAPSE_CAMERA=${requestedPrefs.timelapse ? 1 : 0} FLOW_CALIBRATE=${requestedPrefs.flowCal ? 1 : 0} FLOW_CALIBRATE_EXTRUDERS=0,1,2,3`, + ); + const applied = await api.queryObjects<{ + print_task_config?: { + auto_bed_leveling?: boolean; + time_lapse_camera?: boolean; + flow_calibrate?: boolean; + flow_calib_extruders?: boolean[]; + extruders_used?: boolean[]; + }; + }>(activeUrl, ['print_task_config']); + const taskConfig = applied.status?.print_task_config; + if ( + taskConfig?.auto_bed_leveling !== requestedPrefs.autoLevel || + taskConfig?.time_lapse_camera !== requestedPrefs.timelapse || + taskConfig?.flow_calibrate !== requestedPrefs.flowCal || + taskConfig?.flow_calib_extruders?.length !== 4 || + !taskConfig?.flow_calib_extruders?.every(Boolean) || + taskConfig?.extruders_used?.length !== 4 || + !taskConfig?.extruders_used?.every((used, tool) => used === usedExtruders.includes(tool)) + ) { + throw new Error('Printer rejected the selected print preferences.'); + } } setPrintStart({ state: 'starting', message: 'Starting print…' }); - await api.startPrint(activeUrl, verifiedPath); + if (ifsOff) { + // IFS off: SET_ZCOLOR SILENT=2 starts the print with no material + // prompt at all, so there is nothing to stage an intent for — every + // T-command is ignored and the external side spool feeds the print. + await api.runGcode(activeUrl, ifsOffPrintGcode(verifiedPath, requestedPrefs.autoLevel)); + } else { + // A zmod printer answers this with a material-selection prompt. Stage the + // mapping so it can be answered with the user's slots instead of the + // printer's guess. Helix slices so the G-code's tool index already is the + // physical slot, hence the identity map over the tools actually used. + setPrintIntent({ + filename: verifiedPath, + toolToSlot: Object.fromEntries(usedExtruders.map((tool) => [tool, tool])), + // The AD5X takes levelling on its print macro, not as a preference. + autoLevel: requestedPrefs.autoLevel, + }); + await api.startPrint(activeUrl, verifiedPath); + } setSendProgress(1); setPrintStart({ state: 'done', message: `Print started: ${verifiedPath}` }); setPreprocessOpen(false); @@ -873,7 +1117,7 @@ export default function SliceLabScreen() { message: `Send failed: ${error instanceof Error ? error.message : String(error)}`, }); } - }, [activeUrl, slice, toolLoad, download, router, toolRemap, filamentSlots]); + }, [activeMachine, activePrinterKind, activeUrl, aiMonitoring, bambuExternalSpool, download, filamentSlots, router, settings.activePrinterId, settings.aiDetectionSensitivity, settings.printers, slice, status, toolLoad, toolRemap]); const refresh = async () => { setRefreshing(true); @@ -882,6 +1126,7 @@ export default function SliceLabScreen() { }; const ready = result.state === 'ready' ? result.status.loaded && !result.status.coreError : false; + const startupReady = settingsLoaded && result.state !== 'loading'; const printerReady = connection === 'connected' && Boolean(activeUrl); const hasModel = download.state === 'success'; const sliced = slice.state === 'success'; @@ -903,18 +1148,24 @@ export default function SliceLabScreen() { const slicedRequiredToolMask = slice.state === 'success' ? slice.result.usedToolMask ?? (1 << slicedInitialTool) : 1 << slicedInitialTool; - const missingPrintTools = sliced ? missingLoadedTools(toolLoad, slicedRequiredToolMask) : null; + // A Bambu slice deliberately asks for logical T0 even when AMS Lane 1 is + // empty; the dialog routes that logical tool to a loaded physical lane. + const missingPrintTools = sliced && activePrinterKind !== 'bambu-lan' + ? missingLoadedTools(toolLoad, slicedRequiredToolMask) + : null; const printDialogSlots = useMemo( () => 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. + // Existing printers preserve their identity defaults exactly. Bambu passes + // manual choices only so the dialog's routeTools can auto-pick a loaded AMS + // lane when logical T0's same-numbered lane is empty. const printDialogAssignments = useMemo(() => { + if (activePrinterKind === 'bambu-lan') return toolRemap ?? {}; const m: Record = {}; for (const slot of printDialogSlots) m[slot.index] = toolRemap?.[slot.index] ?? slot.index; return m; - }, [printDialogSlots, toolRemap]); + }, [activePrinterKind, 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). @@ -958,6 +1209,9 @@ export default function SliceLabScreen() { // The pinned action says why it can't run rather than just going grey. The // order matters: the reason nearest the user's next tap wins. const primaryAction = (() => { + if (!startupReady) { + return { icon: 'progress-wrench' as const, label: 'Loading printer…', enabled: false, onPress: noop }; + } if (!hasModel) { const importing = download.state === 'downloading'; return { @@ -1005,7 +1259,10 @@ export default function SliceLabScreen() { } > {!hasModel ? ( - + - + {sliced ? : null} @@ -1200,11 +1461,15 @@ export default function SliceLabScreen() { perToolGrams={perToolGrams} prefs={printPrefs} onTogglePref={(pref) => setPrintPrefs((prev) => ({ ...prev, [pref]: !prev[pref] }))} + aiMonitoring={aiMonitoring} + onToggleAiMonitoring={() => setAiMonitoring((enabled) => !enabled)} sending={printStart.state === 'starting'} progress={sendProgress} statusMessage={printStart.state === 'starting' ? printStart.message : null} errorMessage={printStart.state === 'error' ? printStart.message : null} onSend={uploadAndPrint} + printerKind={activePrinterKind} + externalSpool={bambuExternalSpool} /> {alertDialog} @@ -1224,6 +1489,22 @@ function buildPrinterUploadFilename(sourceName: string | null | undefined, gcode return `${clean}_${Date.now()}.gcode`; } +function buildBambuProjectFilename(sourceName: string | null | undefined, gcodePath: string): string { + return buildPrinterUploadFilename(sourceName, gcodePath).replace(/\.gcode$/i, '.gcode.3mf'); +} + +function bambuHostFromUrl(url: string): string { + return url.trim().replace(/^\w+:\/\//, '').replace(/[/:].*$/, ''); +} + +function bambuFilamentType(value: string): string { + return (value || 'PLA') + .trim() + .replace(/[^A-Za-z0-9+_.-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32) || 'PLA'; +} + function uploadedPathFromResponse(uploaded: UploadResult, fallback: string): string { if (!uploaded) return fallback; @@ -1301,14 +1582,24 @@ function resolveToolLoad( aceUnits: AceUnit[], aceHardwareDetected: boolean, connection: string, + printerKind: PrinterKind | null, ): ToolLoadInfo { - const slots: ToolLoadSlot[] = [0, 1, 2, 3].map((index) => ({ index, status: 'unknown' })); + const slotCount = status.print_task_config?.bambu_filament_source === 'external' ? 1 : 4; + const slots: ToolLoadSlot[] = Array.from( + { length: slotCount }, + (_, index) => ({ index, status: 'unknown' }), + ); let source: ToolLoadInfo['source'] = 'unknown'; let hasData = false; - if (connection === 'connected' && Array.isArray(status.print_task_config?.filament_exist)) { + // A Bambu reconnect retains the last complete MQTT report while the new + // socket comes up. Keep that printer-authored AMS occupancy visible instead + // of flashing old manual U1 slots, but retain the connected-only rule for + // Moonraker printers whose stale sensor state must not authorize a print. + const mayUseReportedSlots = canUseReportedFilamentSlots(connection, printerKind); + if (mayUseReportedSlots && Array.isArray(status.print_task_config?.filament_exist)) { source = 'printer'; - for (let index = 0; index < 4; index++) { + for (let index = 0; index < slotCount; index++) { const exists = status.print_task_config.filament_exist[index]; if (typeof exists === 'boolean') { hasData = true; @@ -1321,7 +1612,7 @@ function resolveToolLoad( source = 'ace'; for (const unit of aceUnits) { for (const lane of unit.lanes) { - if (lane.index < 0 || lane.index > 3) continue; + if (lane.index < 0 || lane.index >= slots.length) continue; const next = lane.status === 'loaded' || lane.status === 'drying' ? 'loaded' : lane.status === 'busy' @@ -1349,7 +1640,7 @@ function resolveToolLoad( for (const key of booleanKeys) { const detected = Boolean(status[key]?.filament_detected); const index = toolIndexFromSensorKey(key) ?? (booleanKeys.length === 1 ? 0 : null); - if (index == null || index < 0 || index > 3) continue; + if (index == null || index < 0 || index >= slots.length) continue; hasData = true; slots[index].status = detected ? 'loaded' : 'empty'; } @@ -1364,7 +1655,7 @@ function resolveToolLoad( const known = hasData && slots.some((slot) => slot.status !== 'unknown'); const selectedTool = firstLoaded ?? 0; const blockReason = known && firstLoaded == null - ? 'No loaded filament detected. Load a U1 head before slicing or printing.' + ? 'No loaded filament detected. Load filament before slicing or printing.' : null; return { @@ -1387,8 +1678,9 @@ function resolveFilamentSlots( toolLoad: ToolLoadInfo, ): FilamentSlotDisplay[] { const ptc = status.print_task_config ?? {}; + const slotCount = ptc.bambu_filament_source === 'external' ? 1 : 4; - return Array.from({ length: 4 }, (_, index) => { + return Array.from({ length: slotCount }, (_, index) => { const loadStatus = toolLoad.slots[index]?.status ?? 'unknown'; const printerColor = loadStatus !== 'empty' ? rgbaStringToHex(Array.isArray(ptc.filament_color_rgba) ? ptc.filament_color_rgba[index] : null) diff --git a/app/(tabs)/tools.tsx b/app/(tabs)/tools.tsx index 5a8cac0..17dd54c 100644 --- a/app/(tabs)/tools.tsx +++ b/app/(tabs)/tools.tsx @@ -13,13 +13,31 @@ // read once. bed_mesh and print_task_config are both subscribed, so those are // real. Spoolman and Console aren't probed anywhere — a green "Connected" for // them would be invented, so they keep a static descriptor and no dot. -import React, { useMemo } from 'react'; -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import React, { useMemo, useState } from 'react'; +import { + Alert, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { alpha, COCKPIT as P, Dot, SectionLabel, type IconName } from '../../components/dashboard/shared'; +import { + alpha, + COCKPIT as P, + Dot, + SectionLabel, + type IconName, +} from '../../components/dashboard/shared'; +import Bespok3dEnrollmentDialog from '../../components/settings/Bespok3dEnrollmentDialog'; +import ThemedDialog from '../../components/ThemedDialog'; import { useMoonraker } from '../../hooks/useMoonraker'; +import { useSettings } from '../../hooks/useSettings'; +import { type BambuCalibrationOptions } from '../../services/bambuControls'; +import { startBambuCalibration } from '../../services/bambuMqtt'; import { t } from '../../services/i18n'; interface Tool { @@ -28,7 +46,7 @@ interface Tool { /** Shown when there's no live status for this tool. */ short: string; icon: IconName; - route: string; + route?: string; } interface Category { @@ -40,7 +58,19 @@ const CATEGORIES: Category[] = [ { title: 'Printer', tools: [ - { key: 'mesh', title: 'Bed Mesh', short: 'Surface shape', icon: 'grid', route: '/mesh' }, + { + key: 'bambuCalibration', + title: 'Calibration', + short: 'Bed, vibration and motor-noise calibration', + icon: 'tune-variant', + }, + { + key: 'mesh', + title: 'Bed Mesh', + short: 'Surface shape', + icon: 'grid', + route: '/mesh', + }, { key: 'ace', title: 'multiACE', @@ -62,6 +92,17 @@ const CATEGORIES: Category[] = [ }, ], }, + { + title: 'Plugins', + tools: [ + { + key: 'bespok3d', + title: 'Bespok3d', + short: 'Secure remote access', + icon: 'cube-outline', + }, + ], + }, { title: 'Diagnostics', tools: [ @@ -91,34 +132,100 @@ function toneColor(tone: Tone): string { export default function ToolsScreen() { const router = useRouter(); + const { settings } = useSettings(); const { connection, status } = useMoonraker(); + const [bespokOpen, setBespokOpen] = useState(false); + const [calibrationOpen, setCalibrationOpen] = useState(false); const connected = connection === 'connected'; + const activePrinter = settings.printers.find( + (printer) => printer.id === settings.activePrinterId, + ); + const activeU1 = + activePrinter?.kind === 'snapmaker-u1' ? activePrinter : null; + const activeBambu = + activePrinter?.kind === 'bambu-lan' ? activePrinter : null; + const bambuPrintState = String(status.print_stats?.state ?? '').toLowerCase(); + const bambuIdle = + bambuPrintState === 'standby' || bambuPrintState === 'complete'; const statuses = useMemo>(() => { + const bespok3d: Status = activeU1 + ? { label: `Manage on ${activeU1.name}`, tone: 'ok' } + : { label: 'Select a Snapmaker U1', tone: 'warn' }; + if (!connected) { const off: Status = { label: t('Offline'), tone: 'off' }; - return { mesh: off, ace: off, spoolman: off, console: off }; + return { + bambuCalibration: off, + mesh: off, + ace: off, + spoolman: off, + bespok3d, + console: off, + }; } const profile: unknown = status.bed_mesh?.profile_name; - const exists: unknown[] = Array.isArray(status.print_task_config?.filament_exist) + const exists: unknown[] = Array.isArray( + status.print_task_config?.filament_exist, + ) ? status.print_task_config.filament_exist : []; const loaded = exists.filter(Boolean).length; return { + bambuCalibration: activeBambu + ? bambuIdle + ? { label: t('Ready — printer is idle'), tone: 'ok' } + : { label: t('Available when the printer is idle'), tone: 'warn' } + : null, mesh: typeof profile === 'string' && profile ? { label: profile, tone: 'ok' } : { label: t('No mesh loaded'), tone: 'warn' }, ace: loaded > 0 - ? { label: `${loaded} ${t('of')} ${exists.length || 4} ${t('lanes')}`, tone: 'ok' } + ? { + label: `${loaded} ${t('of')} ${exists.length || 4} ${t('lanes')}`, + tone: 'ok', + } : { label: t('No filament'), tone: 'warn' }, spoolman: null, + bespok3d, console: null, }; - }, [connected, status]); + }, [activeBambu, activeU1, bambuIdle, connected, status]); + + const confirmBambuCalibration = ( + label: string, + options: BambuCalibrationOptions, + ) => { + setCalibrationOpen(false); + Alert.alert( + t(`Start ${label}?`), + t( + 'The printer must be empty and unobstructed. It will move and make calibration sounds.', + ), + [ + { text: t('Cancel'), style: 'cancel' }, + { + text: t('Start calibration'), + onPress: () => { + void startBambuCalibration(options) + .then(() => Alert.alert(t('Calibration command sent.'))) + .catch((error: unknown) => { + Alert.alert( + t('Calibration failed'), + error instanceof Error + ? error.message + : t('The printer rejected the command.'), + ); + }); + }, + }, + ], + ); + }; return ( @@ -131,39 +238,121 @@ export default function ToolsScreen() { {t(category.title)} - {category.tools.map((tool, index) => { - const state = statuses[tool.key]; - return ( - router.push(tool.route as never)} - style={({ pressed }) => [ - styles.row, - index > 0 && styles.rowDivided, - pressed && { backgroundColor: alpha(P.accent, 0.07) }, - ]} - > - - - - {t(tool.title)} - - {state ? : null} - - {state ? state.label : t(tool.short)} - + {category.tools + .filter( + (tool) => tool.key !== 'bambuCalibration' || activeBambu, + ) + .map((tool, index) => { + const state = statuses[tool.key]; + const disabled = + (tool.key === 'bespok3d' && activeU1 === null) || + (tool.key === 'bambuCalibration' && + (!connected || !bambuIdle)); + const openTool = () => { + if (tool.key === 'bambuCalibration') { + if (!disabled) setCalibrationOpen(true); + return; + } + if (tool.key === 'bespok3d') { + if (activeU1) setBespokOpen(true); + return; + } + if (tool.route) router.push(tool.route as never); + }; + return ( + [ + styles.row, + index > 0 && styles.rowDivided, + disabled && styles.rowDisabled, + pressed && { backgroundColor: alpha(P.accent, 0.07) }, + ]} + > + + + + {t(tool.title)} + + {state ? ( + + ) : null} + + {state ? state.label : t(tool.short)} + + - - - - ); - })} + + + ); + })} ))} + setBespokOpen(false)} + /> + setCalibrationOpen(false)} + actions={[ + { + text: t('Full calibration'), + icon: 'tune-variant', + variant: 'primary', + onPress: () => + confirmBambuCalibration('full calibration', { + bedLeveling: true, + vibration: true, + motorNoise: true, + }), + }, + { + text: t('Bed leveling'), + icon: 'grid', + onPress: () => + confirmBambuCalibration('bed leveling', { bedLeveling: true }), + }, + { + text: t('Vibration compensation'), + icon: 'waveform', + onPress: () => + confirmBambuCalibration('vibration compensation', { + vibration: true, + }), + }, + { + text: t('Motor-noise calibration'), + icon: 'volume-high', + onPress: () => + confirmBambuCalibration('motor-noise calibration', { + motorNoise: true, + }), + }, + { text: t('Close'), onPress: () => setCalibrationOpen(false) }, + ]} + /> ); } @@ -173,7 +362,13 @@ const styles = StyleSheet.create({ flex: { flex: 1 }, content: { padding: 16, paddingBottom: 40, gap: 18 }, - title: { color: P.text, fontSize: 26, fontWeight: '800', letterSpacing: -0.7, marginBottom: -4 }, + title: { + color: P.text, + fontSize: 26, + fontWeight: '800', + letterSpacing: -0.7, + marginBottom: -4, + }, category: { gap: 9 }, card: { @@ -190,11 +385,20 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, height: 78, }, + rowDisabled: { opacity: 0.58 }, // Hairline rather than a full border: the rows are one card, and a heavy // rule between them would break that. - rowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: P.border }, + rowDivided: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: P.border, + }, rowText: { flex: 1, gap: 4 }, - rowTitle: { color: P.text, fontSize: 16, fontWeight: '800', letterSpacing: -0.2 }, + rowTitle: { + color: P.text, + fontSize: 16, + fontWeight: '800', + letterSpacing: -0.2, + }, statusLine: { flexDirection: 'row', alignItems: 'center', gap: 6 }, rowSub: { flex: 1, color: P.dim, fontSize: 12, fontWeight: '600' }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index 66b45a5..3532530 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,7 +4,8 @@ import { StatusBar } from 'expo-status-bar'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { DarkTheme, ThemeProvider } from '@react-navigation/native'; import { SettingsProvider, useSettings } from '../hooks/useSettings'; -import { MoonrakerProvider } from '../hooks/useMoonraker'; +import { PrinterProvider } from '../hooks/usePrinterTransport'; +import KlipperPromptDialog from '../components/KlipperPromptDialog'; import FirstRunSetup from '../components/FirstRunSetup'; import { configureFcmForPrinter, @@ -97,7 +98,7 @@ function AppShell() { return ( <> - + @@ -105,8 +106,12 @@ function AppShell() { + {/* A printer can raise a prompt at any moment, including from a job + started elsewhere, so this lives above the whole navigator rather + than on any one screen. */} + - + ); diff --git a/assets/icons/printer.svg b/assets/icons/printer.svg index 5b5064c..1f9cebe 100644 --- a/assets/icons/printer.svg +++ b/assets/icons/printer.svg @@ -1,31 +1,31 @@ - - - + + + - - + + - - - + + + - - + + - - + + - + @@ -33,24 +33,24 @@ - + - - - - + + + + - + - + - + @@ -58,5 +58,5 @@ - + diff --git a/components/CameraFeed.tsx b/components/CameraFeed.tsx index 30b93d5..2ba6594 100644 --- a/components/CameraFeed.tsx +++ b/components/CameraFeed.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Image, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { AppState, Image, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useFocusEffect } from 'expo-router'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -29,8 +30,83 @@ interface Props { radius?: number; } +const SNAPSHOT_POLL_MS = 250; + +// Bambu (and any feed with a snapshot endpoint) renders natively: the app +// polls the snapshot URL with plain RN Image instead of decoding MJPEG or a +// WebRTC page inside a WebView — the WebView renders blank on some devices. +function NativeSnapshotFeed({ + url, + paused, + resetKey, +}: { + url: string; + paused: boolean; + resetKey: string; +}) { + const [frameNonce, setFrameNonce] = useState(() => Date.now()); + const [failed, setFailed] = useState(false); + // The visible Image only ever points at an already-decoded frame: each poll + // loads into a hidden preloader first, and the visible URI hard-swaps once + // that load finishes (Bambu Handy style — frame by frame, no fade). + // Swapping mid-decode is what made the feed flash. + const [displayedUri, setDisplayedUri] = useState(null); + const frameUri = cacheBustUrl(url, frameNonce); + + const advanceFrame = useCallback(() => { + // Never restart at ?n=1 after a refresh/remount. React Native's image cache + // retains those URLs, which made one fresh frame flash before an old frame + // from the first camera session replaced it. + setFrameNonce((previous) => Math.max(Date.now(), previous + 1)); + }, []); + + useEffect(() => { + advanceFrame(); + setFailed(false); + setDisplayedUri(null); + }, [advanceFrame, resetKey]); + + useEffect(() => { + if (paused) return; + const timer = setInterval(advanceFrame, SNAPSHOT_POLL_MS); + return () => clearInterval(timer); + }, [advanceFrame, paused, resetKey]); + + return ( + + {displayedUri && ( + + )} + { + setFailed(false); + setDisplayedUri(frameUri); + }} + onError={() => setFailed(true)} + /> + {failed && reconnecting…} + + ); +} + const WEBRTC_FIRST_FRAME_MESSAGE = 'helix:webrtc:first-frame'; -const WEBRTC_PREVIEW_TIMEOUT_MS = 10_000; +const LIVE_PREVIEW_TIMEOUT_MS = 4_000; + +function resolveMjpegBridgeUrl(url: string): string | undefined { + try { + const parsed = new URL(url); + if (!/\/webcam\/webrtc\/?$/i.test(parsed.pathname)) return undefined; + parsed.pathname = '/webcam/stream.mjpg'; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return undefined; + } +} const WEBRTC_FIRST_FRAME_SCRIPT = ` (function () { @@ -112,6 +188,10 @@ var statusEl = document.getElementById('s'); var lastFrame = 0; var controller = null; var prevUrl = null; +var stopped = false; +var readySent = false; +var snapshotTimer = null; +var watchdogTimer = null; function setStatus(t) { statusEl.textContent = t; @@ -125,6 +205,10 @@ function bust(u) { function showFrame(bytes) { lastFrame = Date.now(); setStatus(''); + if (!readySent && window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { + readySent = true; + window.ReactNativeWebView.postMessage(${JSON.stringify(WEBRTC_FIRST_FRAME_MESSAGE)}); + } var blob = new Blob([bytes], { type: 'image/jpeg' }); var u = URL.createObjectURL(blob); img.onload = function () { @@ -142,7 +226,11 @@ function findMarker(b, second, from) { } async function streamLoop() { - for (;;) { + while (!stopped) { + if (document.hidden) { + await new Promise(function (r) { setTimeout(r, 500); }); + continue; + } controller = new AbortController(); try { var res = await fetch(bust(SRC), { cache: 'no-store', signal: controller.signal }); @@ -175,7 +263,8 @@ async function streamLoop() { if (latest) showFrame(latest); } } catch (e) {} - setStatus('reconnecting\\u2026'); + if (stopped) return; + setStatus(document.hidden ? '' : 'reconnecting\\u2026'); await new Promise(function (r) { setTimeout(r, 800); }); } } @@ -183,23 +272,37 @@ async function streamLoop() { function snapshotLoop() { img.onload = function () { lastFrame = Date.now(); setStatus(''); }; img.onerror = function () { setStatus('reconnecting\\u2026'); }; - setInterval(function () { img.src = bust(SRC); }, 700); + snapshotTimer = setInterval(function () { img.src = bust(SRC); }, 700); img.src = bust(SRC); } // Watchdog: no frame for 6s -> kill the fetch, loop reconnects. -setInterval(function () { +watchdogTimer = setInterval(function () { if (!SNAPSHOT && lastFrame && Date.now() - lastFrame > 6000 && controller) { try { controller.abort(); } catch (e) {} } }, 2000); -// Coming back from background: force a fresh connection immediately. +function stopPlayer() { + stopped = true; + if (snapshotTimer) clearInterval(snapshotTimer); + if (watchdogTimer) clearInterval(watchdogTimer); + if (controller) { + try { controller.abort(); } catch (e) {} + } + if (prevUrl) URL.revokeObjectURL(prevUrl); +} + +// Hidden WebViews must not leave a costly MJPEG client running on the printer. document.addEventListener('visibilitychange', function () { - if (!document.hidden && !SNAPSHOT && Date.now() - lastFrame > 3000 && controller) { + if (document.hidden && controller) { + try { controller.abort(); } catch (e) {} + } else if (!SNAPSHOT && Date.now() - lastFrame > 3000 && controller) { try { controller.abort(); } catch (e) {} } }); +window.addEventListener('pagehide', stopPlayer); +window.addEventListener('beforeunload', stopPlayer); if (SNAPSHOT) snapshotLoop(); else streamLoop(); @@ -378,9 +481,26 @@ export default function CameraFeed({ const [showStats, setShowStats] = useState(false); const [savingSnapshot, setSavingSnapshot] = useState(false); const [readyPlayerKey, setReadyPlayerKey] = useState(null); - const [dismissedPreviewKey, setDismissedPreviewKey] = useState(null); + const [snapshotFallbackKey, setSnapshotFallbackKey] = useState(null); + const [screenFocused, setScreenFocused] = useState(true); + const [appActive, setAppActive] = useState(AppState.currentState === 'active'); const webViewRef = useRef(null); const { showAlert, alertDialog } = useThemedAlert(); + const streamPaused = paused || !screenFocused || !appActive; + + useFocusEffect( + useCallback(() => { + setScreenFocused(true); + return () => setScreenFocused(false); + }, []), + ); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + setAppActive(state === 'active'); + }); + return () => subscription.remove(); + }, []); // /webcam/webrtc and /screen/ serve their own player pages. const isRemoteScreen = /\/screen\/?($|\?)/i.test(url); @@ -393,18 +513,29 @@ export default function CameraFeed({ return m ? m[0] : undefined; }, [url]); - const html = useMemo(() => buildPlayerHtml(url, isSnapshot), [url, isSnapshot]); + // Android WebView cannot reliably establish the U1's WebRTC page because + // its sandbox may deny network-interface enumeration during ICE. The U1 + // serves the same camera as continuous MJPEG, which our bounded parser can + // display smoothly without WebRTC permissions. + const mjpegBridgeUrl = useMemo( + () => (isWebrtcPage ? resolveMjpegBridgeUrl(url) : undefined), + [isWebrtcPage, url], + ); + const html = useMemo( + () => buildPlayerHtml(mjpegBridgeUrl ?? url, isSnapshot), + [isSnapshot, mjpegBridgeUrl, url], + ); const screenHtml = useMemo(() => buildScreenPlayerHtml(url), [url]); const webViewSource = useMemo( () => - isWebrtcPage + isWebrtcPage && !mjpegBridgeUrl ? { uri: url } : { html: isRemoteScreen ? screenHtml : html, baseUrl: origin }, - [html, isRemoteScreen, isWebrtcPage, origin, screenHtml, url], + [html, isRemoteScreen, isWebrtcPage, mjpegBridgeUrl, origin, screenHtml, url], ); const screenPauseBootstrap = useMemo( - () => `window.__helixScreenPaused = ${paused ? 'true' : 'false'}; true;`, - [paused], + () => `window.__helixScreenPaused = ${streamPaused ? 'true' : 'false'}; true;`, + [streamPaused], ); const playerKey = `${url}-${nonce}-${fullscreen ? 'fs' : 'card'}`; const previewUri = useMemo( @@ -412,23 +543,25 @@ export default function CameraFeed({ [snapshotUrl, playerKey], ); const liveFrameReady = readyPlayerKey === playerKey; + const useSnapshotFallback = + isWebrtcPage && !!snapshotUrl && !liveFrameReady && snapshotFallbackKey === playerKey; const showSnapshotPreview = isWebrtcPage && !!previewUri && !liveFrameReady && - dismissedPreviewKey !== playerKey; + !useSnapshotFallback; useEffect(() => { if (!showSnapshotPreview) return; const timeout = setTimeout(() => { - setDismissedPreviewKey(playerKey); - }, WEBRTC_PREVIEW_TIMEOUT_MS); + setSnapshotFallbackKey(playerKey); + }, LIVE_PREVIEW_TIMEOUT_MS); return () => clearTimeout(timeout); }, [playerKey, showSnapshotPreview]); const syncRemoteScreenPause = useCallback(() => { if (!isRemoteScreen) return; - const nextPaused = paused ? 'true' : 'false'; + const nextPaused = streamPaused ? 'true' : 'false'; webViewRef.current?.injectJavaScript(` window.__helixScreenPaused = ${nextPaused}; if (typeof window.helixSetScreenPaused === 'function') { @@ -436,7 +569,7 @@ export default function CameraFeed({ } true; `); - }, [isRemoteScreen, paused]); + }, [isRemoteScreen, streamPaused]); useEffect(() => { syncRemoteScreenPause(); @@ -520,41 +653,59 @@ export default function CameraFeed({ ); } + // Snapshot-only feeds skip WebView entirely. U1 WebRTC URLs are bridged to + // continuous MJPEG; the advertised snapshot remains the bounded last resort + // if neither that bridge nor a direct WebRTC page produces a frame. const feed = ( - - {showSnapshotPreview && previewUri && ( + {snapshotUrl && + ((!isRemoteScreen && !isWebrtcPage && isSnapshot) || useSnapshotFallback) ? ( + + ) : ( + <> + {isRemoteScreen || !streamPaused ? ( + + ) : ( + + Camera paused + + )} + {!streamPaused && showSnapshotPreview && previewUri && ( setDismissedPreviewKey(playerKey)} + onError={() => setSnapshotFallbackKey(playerKey)} /> Starting live camera… )} + + )} ); @@ -668,6 +819,29 @@ const styles = StyleSheet.create({ feedContainer: { flex: 1, }, + snapshotImage: { + width: '100%', + height: '100%', + }, + snapshotImageOverlay: { + ...StyleSheet.absoluteFillObject, + width: '100%', + height: '100%', + }, + // Hidden but mounted so the frame decodes before it is shown. + preloaderImage: { + position: 'absolute', + width: 1, + height: 1, + opacity: 0, + }, + reconnecting: { + position: 'absolute', + alignSelf: 'center', + top: '45%', + color: colors.subtext, + fontSize: 13, + }, snapshotPreview: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000', diff --git a/components/FirstRunSetup.tsx b/components/FirstRunSetup.tsx index 0cc71ac..fb71a66 100644 --- a/components/FirstRunSetup.tsx +++ b/components/FirstRunSetup.tsx @@ -1,243 +1,143 @@ import React, { useState } from 'react'; -import { - Image, - KeyboardAvoidingView, - Modal, - Platform, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; +import { Image, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; + import { colors, spacing } from '../constants/theme'; -import { ConnectionMode, PrinterEntry, useSettings } from '../hooks/useSettings'; -import { normalizeMoonrakerUrl, validatePrinterConnectionTarget } from '../services/moonraker'; -import type { PrinterConnectionValidationError } from '../services/moonraker'; +import { useSettings, type PrinterEntry } from '../hooks/useSettings'; import { t } from '../services/i18n'; -import TailscaleIcon from './TailscaleIcon'; +import type { DiscoveredPrinter } from '../services/printerDiscovery'; +import { printerEntryFromDiscovery } from '../services/printerSetup'; +import { MANUAL_PRINTER_KIND } from '../services/printerProfiles'; import PrinterIcon from './PrinterIcon'; +import PrinterDiscoveryDialog from './settings/PrinterDiscoveryDialog'; +import PrinterEditorModal from './settings/PrinterEditorModal'; -const CONNECTION_MODES: { - value: ConnectionMode; - label: string; - icon: React.ComponentProps['name']; -}[] = [ - { value: 'lan', label: 'LAN only', icon: 'wifi' }, - { value: 'auto', label: 'Auto', icon: 'swap-horizontal' }, - { value: 'tailscale', label: 'Tailscale only', icon: 'vpn' }, -]; - -function connectionErrorMessage(error: PrinterConnectionValidationError | null): string { - if (error === 'missing-tailscale-url') return 'Tailscale-only mode needs a Tailscale URL.'; - if (error === 'missing-printer-url') return 'Enter the printer IP or Moonraker URL.'; - return ''; +function blankPrinter(): PrinterEntry { + return { + id: `p${Date.now()}`, + name: '', + url: '', + tailscaleUrl: '', + cameraUrl: '/webcam/webrtc', + connectionMode: 'lan', + kind: MANUAL_PRINTER_KIND, + }; } export default function FirstRunSetup({ visible }: { visible: boolean }) { const insets = useSafeAreaInsets(); - const { update } = useSettings(); - const [name, setName] = useState('Snapmaker U1'); - const [lanUrl, setLanUrl] = useState(''); - const [tailscaleUrl, setTailscaleUrl] = useState(''); - const [connectionMode, setConnectionMode] = useState('lan'); - const [error, setError] = useState(''); - const [saving, setSaving] = useState(false); + const { settings, update } = useSettings(); + const [discoveryOpen, setDiscoveryOpen] = useState(false); + const [printerDraft, setPrinterDraft] = useState(null); - const save = async () => { - const normalizedLan = normalizeMoonrakerUrl(lanUrl); - const normalizedTailscale = normalizeMoonrakerUrl(tailscaleUrl); - const validationError = validatePrinterConnectionTarget( - connectionMode, - normalizedLan, - normalizedTailscale - ); - if (validationError) { - setError(connectionErrorMessage(validationError)); - return; - } - - setSaving(true); - try { - const entry: PrinterEntry = { - id: `p${Date.now()}`, - name: name.trim() || 'Snapmaker U1', - url: normalizedLan, - tailscaleUrl: normalizedTailscale, - cameraUrl: '/webcam/webrtc', - connectionMode, - }; + const reviewDiscoveredPrinter = (printer: DiscoveredPrinter) => { + setDiscoveryOpen(false); + setPrinterDraft(printerEntryFromDiscovery(printer, `p${Date.now()}`)); + }; - await update({ - printers: [entry], - activePrinterId: entry.id, - primaryUrl: entry.url, - tailscaleUrl: entry.tailscaleUrl, - cameraUrl: entry.cameraUrl, - connectionMode: entry.connectionMode, - }); - setError(''); - } finally { - setSaving(false); - } + const savePrinter = async (printer: PrinterEntry): Promise => { + await update({ + printers: [printer], + activePrinterId: printer.id, + primaryUrl: printer.url, + tailscaleUrl: printer.tailscaleUrl, + cameraUrl: printer.cameraUrl, + connectionMode: printer.connectionMode, + }); + return true; }; return ( - - - - - LAYER 1 - {t('Welcome to Helix')} - - - - - {t( - 'A mobile Fluidd-style controller for PAXX/Moonraker so you do not have to open the printer web UI every time.' - )} - + + + + LAYER 1 + {t('Welcome to Helix')} + - - - - - - {t('Optional. Only add Tailscale if you already use it for remote access.')} + + + + Add your printer now. You can add more printers later from Settings. - {t('Connection mode')} - - {CONNECTION_MODES.map((mode) => { - const active = mode.value === connectionMode; - return ( - setConnectionMode(mode.value)} - > - {mode.value === 'tailscale' ? ( - - ) : ( - - )} - - {t(mode.label)} - - - ); - })} - - - {t('Tailscale only never falls back to Wi-Fi. Auto tries LAN first.')} - + setDiscoveryOpen(true)} + /> + setPrinterDraft(blankPrinter())} + /> - {error ? {error} : null} - - - - - - {saving ? t('Saving...') : t('Start using Helix')} - + + + + Bambu printers are added manually because LAN mode does not advertise them through + Moonraker discovery. You will need the printer IP, serial number, and LAN access code. + - + + + setDiscoveryOpen(false)} + onSelect={reviewDiscoveredPrinter} + /> + setPrinterDraft(null)} + onSave={savePrinter} + /> ); } -function Field({ - label, - value, - onChangeText, - placeholder, - keyboardType = 'default', +function SetupChoice({ + icon, + title, + detail, + onPress, }: { - label: string; - value: string; - onChangeText: (value: string) => void; - placeholder: string; - keyboardType?: 'default' | 'url'; + icon: React.ComponentProps['name']; + title: string; + detail: string; + onPress: () => void; }) { return ( - - {label} - - + + + {icon === 'printer-3d' ? ( + + ) : ( + + )} + + + {title} + {detail} + + + ); } const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - scrim: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.55)', - }, - keyboard: { - flex: 1, - justifyContent: 'flex-end', - }, + screen: { flex: 1, justifyContent: 'flex-end' }, + scrim: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.55)' }, deck: { - maxHeight: '82%', backgroundColor: colors.bg, borderTopLeftRadius: 28, borderTopRightRadius: 28, @@ -253,114 +153,46 @@ const styles = StyleSheet.create({ marginTop: 10, backgroundColor: colors.border, }, - deckHead: { - paddingHorizontal: 18, - paddingTop: 12, - gap: 3, - }, + deckHead: { paddingHorizontal: 18, paddingTop: 12, gap: 3 }, depth: { color: colors.subtext, fontSize: 10, fontWeight: '800', letterSpacing: 1.3, }, - content: { - padding: 18, - gap: spacing.md, - }, - logo: { - width: 80, - height: 80, - borderRadius: 20, - alignSelf: 'center', - }, - title: { - color: colors.text, - fontSize: 19, - fontWeight: '800', - letterSpacing: -0.4, - }, - copy: { - color: colors.subtext, - fontSize: 14, - lineHeight: 20, - textAlign: 'center', - }, - card: { - backgroundColor: colors.card, - borderRadius: 12, - borderWidth: 1, - borderColor: colors.border, - padding: spacing.lg, - gap: spacing.md, - }, - field: { - gap: 5, - }, - fieldLabel: { - color: colors.subtext, - fontSize: 12, - fontWeight: '700', - }, - input: { - backgroundColor: colors.bg, - borderRadius: 8, + title: { color: colors.text, fontSize: 19, fontWeight: '800', letterSpacing: -0.4 }, + content: { padding: 18, gap: spacing.md }, + logo: { width: 74, height: 74, borderRadius: 19, alignSelf: 'center' }, + copy: { color: colors.subtext, fontSize: 14, lineHeight: 20, textAlign: 'center' }, + choice: { + minHeight: 88, + padding: spacing.md, + borderRadius: 14, borderWidth: 1, borderColor: colors.border, - color: colors.text, - fontSize: 14, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm + 2, - }, - hint: { - color: colors.subtext, - fontSize: 11, - lineHeight: 16, - }, - modeRow: { + backgroundColor: colors.card, flexDirection: 'row', - gap: spacing.sm, + alignItems: 'center', + gap: spacing.md, }, - modeBtn: { - flex: 1, - minHeight: 42, - borderRadius: 8, + choiceIcon: { + width: 46, + height: 46, + borderRadius: 13, backgroundColor: colors.cardAlt, alignItems: 'center', justifyContent: 'center', - flexDirection: 'row', - gap: 6, - paddingHorizontal: 6, - paddingVertical: spacing.sm, - }, - modeText: { - flexShrink: 1, - color: colors.text, - fontSize: 12, - fontWeight: '700', - textAlign: 'center', }, - error: { - color: colors.danger, - fontSize: 12, - fontWeight: '700', - }, - saveBtn: { - height: 52, - borderRadius: 999, - backgroundColor: colors.primary, + choiceCopy: { flex: 1, gap: 4 }, + choiceTitle: { color: colors.text, fontSize: 15, fontWeight: '800' }, + choiceDetail: { color: colors.subtext, fontSize: 12, lineHeight: 17 }, + bambuNote: { + padding: 12, + borderRadius: 11, + backgroundColor: colors.cardAlt, flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.sm, - }, - saveText: { - color: '#fff', - fontSize: 15, - fontWeight: '800', - }, - actions: { - paddingHorizontal: 18, - paddingTop: 6, + alignItems: 'flex-start', + gap: 9, }, + noteText: { flex: 1, color: colors.subtext, fontSize: 11, lineHeight: 16 }, }); diff --git a/components/KlipperPromptDialog.tsx b/components/KlipperPromptDialog.tsx new file mode 100644 index 0000000..77b0a62 --- /dev/null +++ b/components/KlipperPromptDialog.tsx @@ -0,0 +1,174 @@ +// Renders the dialog a printer is blocking on. +// +// Klipper's action:prompt protocol lets firmware ask the operator a question +// mid-command — a FlashForge AD5X (zmod) raises "Select print materials" on +// every print start. The command that raised it does not proceed until a client +// answers, so before Helix rendered these a print would upload, report success, +// and then quietly never begin. +// +// Mounted once, app-wide (see app/_layout.tsx): a prompt can be raised by the +// printer at any moment, including from a job someone started elsewhere, so it +// cannot belong to one screen. +// crabcore + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import ThemedDialog, { type DialogAction } from './ThemedDialog'; +import { COCKPIT as P } from './dashboard/shared'; +import { useMoonraker } from '../hooks/useMoonraker'; +import type { KlipperPrompt, KlipperPromptButton } from '../services/klipperPrompt'; +import { clearPrintIntent, peekPrintIntent, subscribePrintIntent } from '../services/printIntent'; +import { autoAnswerGcode } from '../services/zmodPrintPrompt'; + +/** + * Maps Klipper's style hint onto the dialog's button variants. Klipper sends + * primary/secondary/info/warning/error; zmod also sends bare colours, and the + * AD5X marks its commit button `red` — which means "this is the one that acts", + * not "this is destructive". + */ +function variantFor(button: KlipperPromptButton): DialogAction['variant'] { + const style = (button.style ?? '').toLowerCase(); + if (style === 'error') return 'danger'; + if (style === 'primary' || style === 'red') return 'primary'; + // A footer button with no usable hint is still the commit action. + return button.footer ? 'primary' : 'secondary'; +} + +export default function KlipperPromptDialog() { + const { prompt, answerPrompt, dismissPrompt } = useMoonraker(); + // Guards a double-tap: answering twice runs the macro twice, and on a print + // prompt that means two print starts. + const [answering, setAnswering] = useState(false); + + // Re-runs the decision when an intent is staged after the prompt has already + // arrived, which is the normal order for a print sent from the native preview. + const [intentTick, setIntentTick] = useState(0); + useEffect(() => subscribePrintIntent(() => setIntentTick((n) => n + 1)), []); + + // A zmod material prompt for the print Helix just sent is answered from the + // slot the user already chose, rather than asked again. Anything else — a + // runout, or a prompt for a job started at the printer — falls through to the + // dialog for the operator. + // + // Held in a ref rather than useMemo: the answer is consumed (it clears the + // intent), so a recomputation would return null and flash the dialog back up + // mid-dismiss. React may drop a useMemo at any time; a ref it may not. + const decisionRef = useRef<{ prompt: KlipperPrompt | null; tick: number; gcode: string | null }>({ + prompt: null, + tick: -1, + gcode: null, + }); + if (decisionRef.current.prompt !== prompt || decisionRef.current.tick !== intentTick) { + const intent = prompt ? peekPrintIntent() : null; + decisionRef.current = { + prompt, + tick: intentTick, + gcode: + prompt && intent + ? autoAnswerGcode(prompt, intent.filename, intent.toolToSlot, intent.autoLevel) + : null, + }; + } + const autoAnswer = decisionRef.current.gcode; + + // Fires once per prompt: re-running would start the print twice. + const firedRef = useRef(null); + useEffect(() => { + if (!prompt) { + firedRef.current = null; + return; + } + if (!autoAnswer || firedRef.current === autoAnswer) return; + firedRef.current = autoAnswer; + clearPrintIntent(); + void answerPrompt(autoAnswer); + }, [autoAnswer, prompt, answerPrompt]); + + const onAnswer = useCallback( + async (gcode: string) => { + if (answering) return; + setAnswering(true); + try { + await answerPrompt(gcode); + } finally { + setAnswering(false); + } + }, + [answering, answerPrompt] + ); + + const onClose = useCallback(async () => { + if (answering) return; + setAnswering(true); + try { + await dismissPrompt(); + } finally { + setAnswering(false); + } + }, [answering, dismissPrompt]); + + // Rendering an auto-answered prompt would flash the dialog for a frame before + // the effect above dismisses it. + if (!prompt || autoAnswer) return null; + + // Footer buttons are the commit row and belong in the dialog's action bar. + // Inline choices are rendered as body rows so a long list of colour or tool + // options cannot push the commit button off screen. + const footer = prompt.buttons.filter((button) => button.footer); + const inline = prompt.buttons.filter((button) => !button.footer); + + const actions: DialogAction[] = footer.length + ? footer.map((button) => ({ + text: button.label, + variant: variantFor(button), + disabled: answering, + onPress: () => { void onAnswer(button.gcode); }, + })) + : // A prompt with no footer is informational; give it a way out. + [{ text: 'Dismiss', variant: 'secondary', disabled: answering, onPress: () => { void onClose(); } }]; + + return ( + { void onClose(); }} + actions={actions} + > + + {prompt.text.map((paragraph, index) => ( + + {paragraph} + + ))} + + {inline.map((button, index) => ( + { if (!answering) void onAnswer(button.gcode); }} + > + {button.label} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + body: { gap: 10 }, + text: { color: P.dim, fontSize: 14, lineHeight: 20 }, + choice: { + color: P.text, + fontSize: 15, + fontWeight: '600', + backgroundColor: P.surfaceAlt, + borderRadius: 12, + paddingVertical: 14, + paddingHorizontal: 16, + overflow: 'hidden', + }, + choiceDisabled: { opacity: 0.5 }, +}); diff --git a/components/PrintPreprocessDialog.tsx b/components/PrintPreprocessDialog.tsx index f3aee4d..202cb4c 100644 --- a/components/PrintPreprocessDialog.tsx +++ b/components/PrintPreprocessDialog.tsx @@ -9,7 +9,7 @@ // loaded ones does not block a printable job. Blocking is reserved for genuinely // unsatisfiable cases — fewer usable lanes than the file needs, printer busy, or // offline. -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Animated, @@ -31,16 +31,20 @@ import { COCKPIT as P, alpha, ThumbMock, type IconName } from './dashboard/share import { PrinterIcon } from './PrinterIcon'; import { Chevron, Collapsible } from './ui'; import { - PREF_COPY, buildPreprocessChecks, buildPreprocessTools, - finishClock, laneDetail, laneLabel, + laneWord, + prefCopyFor, + toolChipLabel, + toolLabel, + type LaneNaming, type PrintPref, type PreprocessLane, type PreprocessTool, } from '../services/printPreprocess'; +import { finishClock } from '../services/printEta'; export type { PrintPref }; @@ -73,6 +77,9 @@ type Props = { perToolGrams: number[]; prefs: Record; onTogglePref: (pref: PrintPref) => void; + /** U1 firmware AI detector; absent for printers that do not expose it. */ + aiMonitoring?: boolean; + onToggleAiMonitoring?: () => void; sending: boolean; progress: number; statusMessage?: string | null; @@ -84,6 +91,10 @@ type Props = { /** Active bed_mesh profile name, or null. */ meshProfile?: string | null; layers?: number; + /** Drives pref filtering (no flow calibration on AD5X) and lane naming. */ + printerKind?: string | null; + /** Bambu's virtual tray when the printer has no physical AMS attached. */ + externalSpool?: boolean; }; const HOLD_MS = 700; @@ -118,6 +129,8 @@ export default function PrintPreprocessDialog({ perToolGrams, prefs, onTogglePref, + aiMonitoring, + onToggleAiMonitoring, sending, progress, statusMessage, @@ -127,16 +140,32 @@ export default function PrintPreprocessDialog({ connected = true, meshProfile = null, layers = 0, + printerKind = null, + externalSpool = false, }: Props) { + // AD5X and Bambu owners know the feeds as numbered lanes; the U1 names its + // tools T0–T3 and the feeds stay plain "lanes". + const naming: LaneNaming = + printerKind === 'flashforge-ad5x' || printerKind === 'bambu-lan' ? 'lane' : 'tool'; + const word = laneWord(naming); const insets = useSafeAreaInsets(); const [openFold, setOpenFold] = useState<'routing' | 'options' | null>(null); const [picking, setPicking] = useState(null); const [printerOpen, setPrinterOpen] = useState(false); const hold = useRef(new Animated.Value(0)).current; const [holding, setHolding] = useState(false); + // Mirrors the hold animation into a number so the label can count up with the + // fill. The bar itself is driven by the Animated value, not by this state. + const [holdPct, setHoldPct] = useState(0); + + useEffect(() => { + const id = hold.addListener(({ value }) => setHoldPct(Math.round(value * 100))); + return () => hold.removeListener(id); + }, [hold]); const lanes = useMemo(() => { const pool = availableSlots.length > 0 ? availableSlots : slots; + if (externalSpool) return pool.slice(0, 1).map(asLane); // Pad to four lanes so routeTools can address T0–T3 by index. const byIndex = new Map(pool.map((slot) => [slot.index, asLane(slot)])); return Array.from({ length: 4 }, (_, index) => { @@ -149,7 +178,7 @@ export default function PrintPreprocessDialog({ status: 'empty' as const, }; }); - }, [availableSlots, slots, requiredColors]); + }, [availableSlots, externalSpool, slots, requiredColors]); const required = useMemo( () => slots.map((slot) => slot.index).sort((a, b) => a - b), @@ -172,16 +201,32 @@ export default function PrintPreprocessDialog({ printerName: selectedPrinter?.name ?? 'Printer', tools, lanes, + naming, }), - [connected, printerBusy, selectedPrinter?.name, tools, lanes], + [connected, printerBusy, selectedPrinter?.name, tools, lanes, naming], + ); + + // Prefs the active printer can actually honor (no flow calibration on the + // AD5X) plus IFS only when the file is multi-color. + const prefCopy = useMemo( + () => prefCopyFor({ printerKind, multicolor: tools.length > 1 }), + [printerKind, tools.length], ); + const showAiMonitoring = + printerKind === 'snapmaker-u1' && + typeof aiMonitoring === 'boolean' && + Boolean(onToggleAiMonitoring); + const preferenceSummary = [ + ...(showAiMonitoring && aiMonitoring ? ['AI Monitoring'] : []), + ...prefCopy.filter(({ key }) => prefs[key]).map(({ label }) => label), + ].join(', '); const failing = checks.filter((check) => check.tone === 'fail' && check.blocking); const blocked = failing.length > 0; const blockReason = failing[0]?.detail ?? null; const notes = checks.filter((check) => check.tone === 'warn' || check.tone === 'fail'); const rerouted = tools.filter((tool) => tool.source === 'auto'); - const canRemap = Boolean(onAssignSlot) && lanes.length > 0; + const canRemap = !externalSpool && Boolean(onAssignSlot) && lanes.length > 0; const routingOpen = openFold === 'routing' || blocked; const pct = Math.round(Math.max(0, Math.min(1, progress)) * 100); const displayGrams = @@ -206,10 +251,15 @@ export default function PrintPreprocessDialog({ dismissMain.current = requestClose; const dismissTop = useRef(closeTopLayer); dismissTop.current = closeTopLayer; + // Hold-to-send lives inside the sheet, and a finger almost always drifts more + // than 12px across a 700ms hold. Without this guard the sheet claimed the + // gesture mid-hold, cancelling the press, so the print could never be sent. + const holdingRef = useRef(false); const dismissOnDrag = (onDismiss: () => void) => PanResponder.create({ onStartShouldSetPanResponder: () => false, - onMoveShouldSetPanResponder: (_e, g) => g.dy > 12 && g.dy > Math.abs(g.dx), + onMoveShouldSetPanResponder: (_e, g) => + !holdingRef.current && g.dy > 12 && g.dy > Math.abs(g.dx), onPanResponderRelease: (_e, g) => { if (g.dy > 70 || g.vy > 0.6) onDismiss(); }, @@ -219,9 +269,11 @@ export default function PrintPreprocessDialog({ const beginHold = () => { if (blocked || sending) return; + holdingRef.current = true; setHolding(true); Animated.timing(hold, { toValue: 1, duration: HOLD_MS, useNativeDriver: false }).start( ({ finished }) => { + holdingRef.current = false; setHolding(false); hold.setValue(0); if (finished) { @@ -237,6 +289,7 @@ export default function PrintPreprocessDialog({ }; const cancelHold = () => { + holdingRef.current = false; hold.stopAnimation(() => { Animated.timing(hold, { toValue: 0, duration: 150, useNativeDriver: false }).start(); }); @@ -327,9 +380,11 @@ export default function PrintPreprocessDialog({ - {rerouted.length > 0 - ? `Routed around empty lanes — ${rerouted - .map((tool) => `T${tool.fileTool} on lane ${tool.assigned + 1}`) + {externalSpool + ? 'External Spool ready, printer idle' + : rerouted.length > 0 + ? `Routed around empty ${word}s — ${rerouted + .map((tool) => `${toolLabel(tool.fileTool, naming)} on ${word} ${tool.assigned + 1}`) .join(', ')}` : 'Lanes loaded, printer idle'} @@ -345,11 +400,19 @@ export default function PrintPreprocessDialog({ > {tools.map((tool) => ( - + ))} - {tools.length} {tools.length === 1 ? 'lane' : 'lanes'} + {externalSpool + ? 'External Spool' + : `${tools.length} ${tools.length === 1 ? word : `${word}s`}`} @@ -364,19 +427,25 @@ export default function PrintPreprocessDialog({ disabled={!canRemap} style={styles.laneRow} > - + {laneLabel(tool.lane)} {tool.source !== 'identity' ? ( {' '} - {tool.source === 'manual' ? '→' : 'auto →'} lane {tool.assigned + 1} + {tool.source === 'manual' ? '→' : 'auto →'} {word} {tool.assigned + 1} ) : null} - lane {tool.assigned + 1} · {laneDetail(tool.lane)} + {externalSpool ? 'External Spool' : `${word} ${tool.assigned + 1}`} ·{' '} + {laneDetail(tool.lane)} {tool.grams > 0 ? ( @@ -396,16 +465,38 @@ export default function PrintPreprocessDialog({ > - {PREF_COPY.filter(({ key }) => prefs[key]) - .map(({ label }) => label) - .join(', ') || 'Print preferences'} + {preferenceSummary || 'Print preferences'} - {PREF_COPY.map(({ key, label, hint, icon }) => { + {showAiMonitoring ? ( + + + + AI Monitoring + + Detects spaghetti failures and build-plate obstructions, then automatically + pauses the print + + + + + + + ) : null} + {prefCopy.map(({ key, label, hint, icon }) => { const on = prefs[key]; return ( onTogglePref(key)} style={styles.prefRow}> @@ -448,12 +539,19 @@ export default function PrintPreprocessDialog({ onPressIn={beginHold} onPressOut={cancelHold} disabled={sending} - style={[styles.holdBtn, sending && styles.holdOff]} + // Colours are applied inline rather than left to the StyleSheet: + // setAccent() mutates the palette after this module is evaluated, + // so anything captured by StyleSheet.create can be a stale accent. + style={[styles.holdBtn, { backgroundColor: P.accentFill }, sending && styles.holdOff]} > - + + + - - {holding ? 'Keep holding…' : sendLabel} + + {holding ? `Keep holding… ${holdPct}%` : sendLabel || 'Hold to start'} )} @@ -479,7 +577,9 @@ export default function PrintPreprocessDialog({ {picking != null ? ( <> - Lane for T{picking} + + {naming === 'lane' ? `Spool for Lane ${picking + 1}` : `Lane for T${picking}`} + Choose the physical spool that feeds this tool. @@ -632,7 +732,17 @@ function WeightBar({ tools }: { tools: PreprocessTool[] }) { ); } -function LaneChip({ tool, size = 38 }: { tool: PreprocessTool; size?: number }) { +function LaneChip({ + tool, + size = 38, + naming = 'tool', + externalSpool = false, +}: { + tool: PreprocessTool; + size?: number; + naming?: LaneNaming; + externalSpool?: boolean; +}) { const empty = tool.lane.status === 'empty'; const ring = empty ? alpha(P.danger, 0.8) @@ -646,7 +756,9 @@ function LaneChip({ tool, size = 38 }: { tool: PreprocessTool; size?: number }) { width: size, height: size, borderRadius: size / 2, borderColor: ring }, ]} > - T{tool.fileTool} + + {externalSpool ? 'EXT' : toolChipLabel(tool.fileTool, naming)} + {empty ? ( @@ -799,6 +911,10 @@ const styles = StyleSheet.create({ }, knobOn: { alignSelf: 'flex-end', backgroundColor: P.onAccent }, + // No overflow:'hidden' here. Paired with borderRadius:999 on Android it + // clipped the icon and label out of existence, leaving a blank pill — the + // identical fixBar below renders fine and differs only by that one property. + // The progress fill gets its own clipping wrapper instead. holdBtn: { height: 56, borderRadius: 999, @@ -807,6 +923,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', gap: 9, + }, + holdFillClip: { + ...StyleSheet.absoluteFillObject, + borderRadius: 999, overflow: 'hidden', }, holdOff: { opacity: 0.5 }, diff --git a/components/PrinterIcon.tsx b/components/PrinterIcon.tsx index 545cf77..1f811b3 100644 --- a/components/PrinterIcon.tsx +++ b/components/PrinterIcon.tsx @@ -27,9 +27,9 @@ export function PrinterIcon({ y2="870" gradientUnits="userSpaceOnUse" > - - - + + + - - + + - - - + + + - - + + - - + + {/* Bowden tube / filament guide stubs */} - + @@ -90,24 +90,24 @@ export function PrinterIcon({ {/* Control screen */} - - - - + + + + {/* Door / viewing window */} - + {/* X gantry rail */} - + {/* Four toolheads */} - + @@ -118,7 +118,7 @@ export function PrinterIcon({ diff --git a/components/ScreenMirror.tsx b/components/ScreenMirror.tsx new file mode 100644 index 0000000..75e1da5 --- /dev/null +++ b/components/ScreenMirror.tsx @@ -0,0 +1,253 @@ +// ScreenMirror — live view of the AD5X touchscreen with remote tap, embedded +// in the Home "Printer screen" section. +// +// Pure Image + fetch (no MJPEG: RN's Image can't consume a multipart stream). +// Frames are double-buffered (see useScreenMirror) so the displayed image is +// never blank between frames — no flicker. Taps map directly to raw screen +// pixels (0..799 / 0..479, origin top-left): helixd applies the tslib inverse +// transform itself, so the client does NO calibration, scaling or Y-flip. +// +// This renders chrome-less and fills its parent (the dashboard screenCard owns +// the border/radius). It gates its own polling on tab focus + foreground so a +// backgrounded Home tab doesn't keep loading the printer. +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Animated, AppState, Image, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useFocusEffect } from 'expo-router'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { COCKPIT as P, alpha, type IconName } from './dashboard/shared'; +import { useScreenMirror } from '../hooks/useScreenMirror'; + +const RIPPLE_MS = 600; +const RIPPLE_SIZE = 46; + +interface Banner { + text: string; + tone: 'warn' | 'danger'; + icon: IconName; +} + +function stateLabel(state: string): string { + const s = state.toLowerCase(); + if (s === 'printing') return 'Printing'; + if (s === 'paused') return 'Paused'; + if (s === 'standby' || s === 'idle' || s === 'complete' || s === '') return 'Standby'; + return state.charAt(0).toUpperCase() + state.slice(1); +} + +function stateDotColor(state: string, throttled: boolean): string { + if (throttled) return P.warn; + const s = state.toLowerCase(); + if (s === 'standby' || s === 'idle' || s === 'complete' || s === '') return P.success; + if (s === 'printing' || s === 'paused') return P.warn; + return P.dim; +} + +function bannerFor(s: ReturnType): Banner | null { + if (s.touchError) { + return { text: `Touch unavailable: ${s.touchError}`, tone: 'danger', icon: 'alert-circle' }; + } + // 409 ("printer busy") is surfaced as a silent no-op, not a lock banner — + // taps stay enabled during prints. Other failures (network/4xx) still show. + if (s.tapError && s.tapError.status !== 409) { + return { text: s.tapError.message, tone: 'danger', icon: 'alert-circle' }; + } + return null; +} + +export default function ScreenMirror() { + // Self-gate polling: only run while the Home tab is focused and the app is + // in the foreground. + const [focused, setFocused] = useState(true); + const [foreground, setForeground] = useState(true); + useFocusEffect( + useCallback(() => { + setFocused(true); + return () => setFocused(false); + }, []) + ); + useEffect(() => { + const sub = AppState.addEventListener('change', (state) => setForeground(state === 'active')); + return () => sub.remove(); + }, []); + + const s = useScreenMirror(focused && foreground); + const [measured, setMeasured] = useState({ w: 0, h: 0 }); + const [ripple, setRipple] = useState<{ x: number; y: number; key: number } | null>(null); + const rippleAnim = useRef(new Animated.Value(0)).current; + const rippleKey = useRef(0); + + useEffect(() => { + if (!ripple) return; + rippleAnim.setValue(0); + const anim = Animated.timing(rippleAnim, { + toValue: 1, + duration: RIPPLE_MS, + useNativeDriver: true, + }); + anim.start(); + const t = setTimeout(() => setRipple(null), RIPPLE_MS); + return () => { + anim.stop(); + clearTimeout(t); + }; + }, [ripple, rippleAnim]); + + const handlePressIn = useCallback( + (x: number, y: number) => { + if (measured.w === 0 || measured.h === 0) return; + const sx = Math.max(0, Math.min(s.width - 1, Math.round((x / measured.w) * s.width))); + const sy = Math.max(0, Math.min(s.height - 1, Math.round((y / measured.h) * s.height))); + rippleKey.current += 1; + setRipple({ x, y, key: rippleKey.current }); + // force=true: helixd refuses taps while the printer reports busy, so + // the mirror would be unclickable mid-print without forcing through. + void s.tap(sx, sy, true).then((r) => { + // Pull a fresh frame right after a tap so the result is visible + // immediately instead of on the next poll (matters most while + // printing, where capture is throttled to ~1 fps). + if (r.ok) s.refresh(); + }); + }, + [measured.w, measured.h, s] + ); + + const rippleScale = rippleAnim.interpolate({ inputRange: [0, 1], outputRange: [0.35, 1.7] }); + const rippleOpacity = rippleAnim.interpolate({ inputRange: [0, 0.25, 1], outputRange: [0.85, 0.5, 0] }); + + const banner = bannerFor(s); + const dotColor = stateDotColor(s.printState, s.throttled); + + return ( + { + const { width: w, height: h } = e.nativeEvent.layout; + setMeasured((prev) => (prev.w === w && prev.h === h ? prev : { w, h })); + }} + > + {/* Committed (displayed) frame — stays put while the next one loads. */} + {s.committedUri ? ( + + ) : null} + + {/* Pending frame — loads on top, promoted to committed once decoded so + the surface is never blank between frames (no flicker). */} + {s.pendingUri ? ( + + ) : null} + + {!s.hasFrame ? ( + + + + {!s.baseUrl ? 'No printer' : s.loading ? 'Connecting…' : 'Loading screen…'} + + + ) : null} + + {/* Tap surface — fills the exact panel rect (aspect-matched, so no + letterboxing to undo) and forwards touches as single taps. */} + handlePressIn(e.nativeEvent.locationX, e.nativeEvent.locationY)} + > + {ripple ? ( + + ) : null} + + + + + {stateLabel(s.printState)} + · {s.throttled ? 'throttled ~1 fps' : 'live'} + + + {banner ? ( + + + {banner.text} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + surface: { + width: '100%', + backgroundColor: '#050608', + }, + placeholder: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + placeholderText: { color: P.dim, fontSize: 12, fontWeight: '600' }, + ripple: { + position: 'absolute', + width: RIPPLE_SIZE, + height: RIPPLE_SIZE, + borderRadius: RIPPLE_SIZE / 2, + borderWidth: 2, + borderColor: alpha('#FFFFFF', 0.9), + backgroundColor: alpha(P.accent, 0.18), + }, + badge: { + position: 'absolute', + top: 10, + left: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 9, + paddingVertical: 5, + borderRadius: 999, + backgroundColor: alpha('#000000', 0.6), + }, + badgeDot: { width: 7, height: 7, borderRadius: 4 }, + badgeText: { color: P.text, fontSize: 11, fontWeight: '800' }, + badgeNote: { color: P.dim, fontSize: 11, fontWeight: '700' }, + banner: { + position: 'absolute', + bottom: 10, + left: 10, + right: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 7, + paddingHorizontal: 11, + paddingVertical: 8, + borderRadius: 10, + }, + bannerWarn: { backgroundColor: alpha(P.warn, 0.9) }, + bannerDanger: { backgroundColor: alpha(P.danger, 0.9) }, + bannerText: { color: '#FFFFFF', fontSize: 12, fontWeight: '800', flexShrink: 1 }, +}); diff --git a/components/ThemedDialog.tsx b/components/ThemedDialog.tsx index 51f9a15..c44ef2b 100644 --- a/components/ThemedDialog.tsx +++ b/components/ThemedDialog.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { KeyboardAvoidingView, Modal, + Platform, Pressable, ScrollView, StyleSheet, @@ -103,10 +104,24 @@ export default function ThemedDialog({ if (focus) { return ( - - + + {danger ? : null} - + {!form ? ( ))} - + ); } diff --git a/components/dashboard/Dashboard.tsx b/components/dashboard/Dashboard.tsx index 37c9780..163e024 100644 --- a/components/dashboard/Dashboard.tsx +++ b/components/dashboard/Dashboard.tsx @@ -8,7 +8,7 @@ // Camera and job are separate cards, not one overlaid hero: an unobstructed // feed matters more than the vertical space a scrim would save. // -// Every section maps to a settings.dashboard.* toggle and renders independently, +// Every section maps to a sections.* toggle and renders independently, // so hiding any one of them leaves the rest intact. import React, { useCallback, useEffect, useRef, useState } from 'react'; import { @@ -37,18 +37,39 @@ import { alpha, COCKPIT as P, Dot } from './shared'; import PrinterIcon from '../PrinterIcon'; import { useSettings, type PrinterEntry } from '../../hooks/useSettings'; import { api, printerConnectionUrl } from '../../services/moonraker'; +import { MANUAL_PRINTER_KIND } from '../../services/printerProfiles'; +import { getDashboardSections } from '../../services/dashboardSections'; +import { probeBambuStatus } from '../../services/bambuMqtt'; +import { applyBambuReport } from '../../services/bambuReport'; +import { bambuStatus } from '../../services/bambuAdapter'; +import { useMoonraker } from '../../hooks/useMoonraker'; const PAGE = 16; const PICKER_GAP = 8; +// Picker probes use a short timeout: an offline printer should flip to its +// offline dot fast instead of hanging the row for the default 8s. +const PICKER_PROBE_TIMEOUT_MS = 2500; +const BAMBU_PICKER_PROBE_INTERVAL_MS = 15000; type PickerAnchor = { x: number; y: number; width: number; height: number }; -type PrinterRuntime = { state: 'idle' | 'busy' | 'offline'; progress: number }; +type PrinterRuntime = { state: 'idle' | 'busy' | 'offline' | 'unknown'; progress: number }; +// The picker is a Modal that unmounts whenever it closes. Keep its last +// confirmed rows outside that short lifecycle so printer swaps do not flash +// "Not checked" while the same background probes reconnect. +const pickerRuntimeCache: Record = {}; type PrinterStatusQuery = { print_stats?: { state?: string }; virtual_sdcard?: { progress?: number }; display_status?: { progress?: number }; }; +function bambuHost(url: string): string { + return (url || '') + .trim() + .replace(/^\w+:\/\//, '') + .replace(/[/:].*$/, ''); +} + function activePrinterRuntime(data: CockpitData): PrinterRuntime { if (!data.online) return { state: 'offline', progress: 0 }; if (data.state === 'printing') { @@ -61,14 +82,25 @@ function runtimeLabel(runtime: PrinterRuntime): string { if (runtime.state === 'busy') { return `${t('Busy')} · ${Math.round(runtime.progress * 100)}%`; } - return runtime.state === 'offline' ? t('Offline') : t('Idle'); + if (runtime.state === 'offline') return t('Offline'); + return runtime.state === 'unknown' ? t('Not checked') : t('Idle'); +} + +function runtimeColor(runtime: PrinterRuntime): string { + if (runtime.state === 'busy') return P.accent; + if (runtime.state === 'offline') return P.danger; + return runtime.state === 'unknown' ? P.dim : P.success; } export default function Cockpit() { const { width } = useWindowDimensions(); const contentWidth = width - PAGE * 2; const data = useCockpitData(); + const { connection, reconnect } = useMoonraker(); const { settings, update } = useSettings(); + // Layout follows the active printer, so hiding a card on one machine leaves + // the others alone. + const sections = getDashboardSections(settings); // The slicer hands off a one-shot "print sent" notice for Home to surface. // Nothing else consumes it, so dropping this silently loses the confirmation. @@ -84,6 +116,8 @@ export default function Cockpit() { const [editingSlot, setEditingSlot] = useState(null); const [pickerAnchor, setPickerAnchor] = useState(null); const [addingPrinter, setAddingPrinter] = useState(null); + const [configuringMaterialStation, setConfiguringMaterialStation] = + useState(null); const [notificationsOpen, setNotificationsOpen] = useState(false); const [estopConfirm, setEstopConfirm] = useState(false); const notifications = useNotifications(); @@ -96,6 +130,8 @@ export default function Cockpit() { { setNotificationsOpen(true); @@ -103,17 +139,26 @@ export default function Cockpit() { }} /> {/* Every section honors its Settings → Dashboard toggle. */} - {settings.dashboard.camera ? : null} - {settings.dashboard.progress ? : null} - {settings.dashboard.filaments ? ( - + {sections.camera ? : null} + {sections.progress ? : null} + {sections.filaments ? ( + { + const activePrinter = settings.printers.find( + (printer) => printer.id === settings.activePrinterId + ); + if (activePrinter) setConfiguringMaterialStation(activePrinter); + }} + /> ) : null} - {settings.dashboard.temps ? ( + {sections.temps ? ( ) : null} - {settings.dashboard.pandaBreath ? : null} - {settings.dashboard.macros ? : null} - {settings.dashboard.gui ? ( + {sections.pandaBreath ? : null} + {sections.macros ? : null} + {sections.gui ? ( setScrollEnabled(true)} /> ) : null} - {settings.dashboard.estop ? setEstopConfirm(true)} /> : null} + {sections.estop ? setEstopConfirm(true)} /> : null} {editingSlot != null ? ( @@ -142,6 +187,7 @@ export default function Cockpit() { tailscaleUrl: '', cameraUrl: '/webcam/webrtc', connectionMode: 'lan', + kind: MANUAL_PRINTER_KIND, }); }} /> @@ -165,6 +211,24 @@ export default function Cockpit() { }} /> + setConfiguringMaterialStation(null)} + onSave={async (printer) => { + await update({ + printers: settings.printers.map((entry) => + entry.id === printer.id ? printer : entry + ), + primaryUrl: printer.url, + tailscaleUrl: printer.tailscaleUrl, + cameraUrl: printer.cameraUrl, + connectionMode: printer.connectionMode, + }); + return true; + }} + /> + {notificationsOpen ? ( void; onOpenPicker: (anchor: PickerAnchor) => void; onOpenNotifications: () => void; }) { @@ -255,6 +323,18 @@ function TopBar({ + {showReconnect ? ( + [styles.reconnectButton, pressed && { opacity: 0.7 }]} + onPress={onReconnect} + accessibilityRole="button" + accessibilityLabel={t('Reconnect now')} + hitSlop={6} + > + + + ) : null} + ); @@ -276,7 +356,10 @@ function PrinterPicker({ const { height } = useWindowDimensions(); const printers = settings.printers ?? []; const activeRuntime = activePrinterRuntime(data); - const [runtimeByPrinter, setRuntimeByPrinter] = useState>({}); + const [runtimeByPrinter, setRuntimeByPrinter] = useState>( + () => ({ ...pickerRuntimeCache }) + ); + const lastBambuProbeAt = useRef>({}); // Android's Modal window includes the translucent status bar while // measureInWindow reports coordinates below it. const anchorY = anchor.y + insets.top; @@ -284,40 +367,84 @@ function PrinterPicker({ anchorY + anchor.height + PICKER_GAP + Math.min(360, height * 0.5) > height - insets.bottom; + useEffect(() => { + const activePrinterId = settings.activePrinterId; + if (activePrinterId && data.online) { + pickerRuntimeCache[activePrinterId] = activeRuntime; + } + }, [settings.activePrinterId, data.online, activeRuntime.state, activeRuntime.progress]); + useEffect(() => { let mounted = true; const poll = async () => { - const entries = await Promise.all( + // Update each printer as its own probe resolves. A single Promise.all + // here would let one offline printer (which hangs until the fetch + // timeout) hold back the online status of every other row. + await Promise.all( printers .filter((printer) => printer.id !== settings.activePrinterId) - .map(async (printer): Promise<[string, PrinterRuntime]> => { + .map(async (printer) => { + const setRuntime = (runtime: PrinterRuntime) => { + pickerRuntimeCache[printer.id] = runtime; + if (mounted) { + setRuntimeByPrinter((current) => ({ ...current, [printer.id]: runtime })); + } + }; + if (printer.kind === 'bambu-lan') { + const host = bambuHost(printer.url); + const serial = (printer.serialNumber ?? '').trim(); + const accessCode = (printer.checkCode ?? '').trim(); + if (!host || !serial || !accessCode) { + setRuntime({ state: 'unknown', progress: 0 }); + return; + } + + const now = Date.now(); + if (now - (lastBambuProbeAt.current[printer.id] ?? 0) < BAMBU_PICKER_PROBE_INTERVAL_MS) { + return; + } + lastBambuProbeAt.current[printer.id] = now; + try { + const report = await probeBambuStatus({ host, serial, accessCode }); + const mapped = bambuStatus(applyBambuReport({}, report).state); + const rawState = mapped.print_stats?.state?.toLowerCase(); + const rawProgress = mapped.virtual_sdcard?.progress ?? 0; + const progress = Math.max(0, Math.min(1, Number(rawProgress) || 0)); + setRuntime({ + state: rawState === 'printing' || rawState === 'paused' ? 'busy' : 'idle', + progress, + }); + } catch { + setRuntime({ state: 'offline', progress: 0 }); + } + return; + } const url = printerConnectionUrl(printer); - if (!url) return [printer.id, { state: 'offline', progress: 0 }]; + if (!url) { + setRuntime({ state: 'offline', progress: 0 }); + return; + } try { - const result = await api.queryObjects(url, [ - 'print_stats', - 'virtual_sdcard', - 'display_status', - ]); + const result = await api.queryObjects( + url, + ['print_stats', 'virtual_sdcard', 'display_status'], + PICKER_PROBE_TIMEOUT_MS + ); const rawState = result.status?.print_stats?.state?.toLowerCase(); const rawProgress = result.status?.virtual_sdcard?.progress ?? result.status?.display_status?.progress ?? 0; const progress = Math.max(0, Math.min(1, Number(rawProgress) || 0)); - return [ - printer.id, - { - state: rawState === 'printing' || rawState === 'paused' ? 'busy' : 'idle', - progress, - }, - ]; + setRuntime({ + state: rawState === 'printing' || rawState === 'paused' ? 'busy' : 'idle', + progress, + }); } catch { - return [printer.id, { state: 'offline', progress: 0 }]; + setRuntime({ state: 'offline', progress: 0 }); } }) ); - if (mounted) setRuntimeByPrinter(Object.fromEntries(entries)); }; void poll(); const timer = setInterval(() => void poll(), 5000); @@ -371,13 +498,8 @@ function PrinterPicker({ const active = printer.id === settings.activePrinterId; const runtime = active ? activeRuntime - : runtimeByPrinter[printer.id] ?? { state: 'offline', progress: 0 }; - const runtimeColor = - runtime.state === 'busy' - ? P.accent - : runtime.state === 'offline' - ? P.danger - : P.success; + : runtimeByPrinter[printer.id] ?? { state: 'unknown', progress: 0 }; + const color = runtimeColor(runtime); return ( - + {printer.name?.trim() || t('Printer')} - + {runtimeLabel(runtime)} {active ? ( @@ -453,6 +575,16 @@ const styles = StyleSheet.create({ printerName: { color: P.text, fontSize: 15, fontWeight: '800' }, statusRow: { flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 2 }, statusText: { color: P.dim, fontSize: 11, fontWeight: '700' }, + reconnectButton: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: alpha(P.danger, 0.1), + borderWidth: 1, + borderColor: alpha(P.danger, 0.35), + }, pickerLayer: { ...StyleSheet.absoluteFillObject, diff --git a/components/dashboard/parts/FilamentEditor.tsx b/components/dashboard/parts/FilamentEditor.tsx index 41ba340..b99bcda 100644 --- a/components/dashboard/parts/FilamentEditor.tsx +++ b/components/dashboard/parts/FilamentEditor.tsx @@ -3,7 +3,7 @@ // Replaces the reuse of the app's FilamentSlotsEditor, which meant a pointless // "now pick which slot" step after you'd already told us which slot. // -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Keyboard, Modal, @@ -42,6 +42,14 @@ export default function FilamentEditor({ slot, onClose }: { slot: number; onClos const insets = useSafeAreaInsets(); const { settings } = useSettings(); const { status } = useMoonraker(); + const activePrinter = settings.printers.find((printer) => printer.id === settings.activePrinterId); + const externalSpool = activePrinter?.kind === 'bambu-lan' + && status.print_task_config?.bambu_filament_source === 'external'; + const filamentPositionLabel = externalSpool + ? t('External Spool') + : activePrinter?.kind === 'snapmaker-u1' + ? t('Toolhead') + : t('Slot'); const [error, setError] = useState(null); const { updateSlot } = useFilamentSlotWrites( (message) => setError(message), @@ -51,7 +59,15 @@ export default function FilamentEditor({ slot, onClose }: { slot: number; onClos const brands = settings.filamentSlotBrands ?? []; const materials = settings.filamentSlotMaterials ?? []; const subtypes = settings.filamentSlotSubtypes ?? []; - const initialBrand = brands[slot] || 'Generic'; + // The card is printer-first, so the editor must open with the same live + // colour/material instead of jumping back to a stale manual setting. + const resolved = resolveFilamentSlots(status, { + slotColors: colors, + slotBrands: brands, + slotMaterials: materials, + slotSubtypes: subtypes, + })[slot]; + const initialBrand = resolved?.brand || brands[slot] || 'Generic'; const [picker, setPicker] = useState(null); const [pickerAnchor, setPickerAnchor] = useState({ @@ -60,11 +76,13 @@ export default function FilamentEditor({ slot, onClose }: { slot: number; onClos width: 320, height: 56, }); - const [colorDraft, setColorDraft] = useState(colors[slot] || '#FFFFFF'); + const [colorDraft, setColorDraft] = useState(resolved?.color || colors[slot] || '#FFFFFF'); const [hexDraft, setHexDraft] = useState(''); - const [materialDraft, setMaterialDraft] = useState(materials[slot] || 'PLA'); + const [materialDraft, setMaterialDraft] = useState( + resolved?.mainType || materials[slot] || 'PLA' + ); const [subtypeDraft, setSubtypeDraft] = useState( - subtypes[slot] || DEFAULT_FILAMENT_SUBTYPE + resolved?.subType || subtypes[slot] || DEFAULT_FILAMENT_SUBTYPE ); const [brandDraft, setBrandDraft] = useState( BRAND_PRESETS.includes(initialBrand) ? initialBrand : 'Other' @@ -101,17 +119,6 @@ export default function FilamentEditor({ slot, onClose }: { slot: number; onClos const sheetMaxHeight = Math.max(240, height - keyboardHeight - insets.top - 12); const swatchSize = Math.min(64, Math.floor((width - 52 - 30) / 4)); - const resolved = useMemo( - () => - resolveFilamentSlots(status, { - slotColors: colors, - slotBrands: brands, - slotMaterials: materials, - slotSubtypes: subtypes, - })[slot], - [brands, colors, materials, status, subtypes, slot] - ); - const applyColor = (hex: string) => { const normalized = normalizeFilamentHex(hex); if (!normalized) return; @@ -192,7 +199,9 @@ export default function FilamentEditor({ slot, onClose }: { slot: number; onClos ]} > - {t('Toolhead')} {slot + 1} + + {externalSpool ? filamentPositionLabel : `${filamentPositionLabel} ${slot + 1}`} + {[brandDraft === 'Other' ? customBrandDraft || t('Custom brand') : brandDraft, materialDraft] .filter(Boolean) diff --git a/components/dashboard/parts/Hero.tsx b/components/dashboard/parts/Hero.tsx index 9f94150..05e6fa6 100644 --- a/components/dashboard/parts/Hero.tsx +++ b/components/dashboard/parts/Hero.tsx @@ -5,7 +5,7 @@ // brings its actual controls (stats / light / snapshot / refresh / fullscreen) // rather than my mock-ups of them. import React, { useState } from 'react'; -import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Alert, Image, Linking, Pressable, StyleSheet, Text, View } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import CameraFeed from '../../CameraFeed'; import ThemedDialog from '../../ThemedDialog'; @@ -13,6 +13,14 @@ import { ProgressBar } from '../../ui/progress'; import { alpha, CameraMock, COCKPIT as P, Dot, ThumbMock, type IconName } from '../shared'; import type { CockpitData } from './data'; import { t } from '../../../services/i18n'; +import type { BambuSpeedPreset } from '../../../services/bambuMqtt'; + +const BAMBU_SPEEDS: { preset: BambuSpeedPreset; label: string }[] = [ + { preset: 1, label: 'Silent (50%)' }, + { preset: 2, label: 'Standard (100%)' }, + { preset: 3, label: 'Sport (124%)' }, + { preset: 4, label: 'Ludicrous (166%)' }, +]; export function CameraCard({ data, width }: { data: CockpitData; width: number }) { const tone = toneFor(data); @@ -55,16 +63,73 @@ export function JobCard({ data }: { data: CockpitData }) { // Cancel used to fire printer.print.cancel straight from the button, so a // mis-tap threw away a print with no way back. const [confirmCancel, setConfirmCancel] = useState(false); + const [showHms, setShowHms] = useState(false); + const [showSkipObjects, setShowSkipObjects] = useState(false); + const [selectedSkipIds, setSelectedSkipIds] = useState([]); + const [skipBusy, setSkipBusy] = useState(false); + const hmsFaults = data.bambu?.hmsFaults ?? []; + const hasHmsFaults = hmsFaults.length > 0; + const printObjects = data.bambu?.printObjects ?? []; + const activePrintObjects = printObjects.filter((object) => !object.skipped); + const canSkipObjects = state === 'printing' && activePrintObjects.length > 1; + const openSkipObjects = () => { + setSelectedSkipIds([]); + setShowSkipObjects(true); + }; + const toggleSkipObject = (identifyId: number) => { + setSelectedSkipIds((current) => + current.includes(identifyId) + ? current.filter((id) => id !== identifyId) + : [...current, identifyId] + ); + }; + const skipSelectionInvalid = + selectedSkipIds.length === 0 || selectedSkipIds.length >= activePrintObjects.length; + const confirmSkipObjects = async () => { + if (skipSelectionInvalid || skipBusy) return; + setSkipBusy(true); + try { + await data.actions.skipBambuObjects(selectedSkipIds); + setShowSkipObjects(false); + setSelectedSkipIds([]); + } catch (error: unknown) { + Alert.alert( + t('Could not skip objects'), + error instanceof Error ? error.message : t('The printer rejected the command.') + ); + } finally { + setSkipBusy(false); + } + }; + const chooseBambuSpeed = () => { + Alert.alert(t('Print speed'), t('Choose a Bambu speed preset.'), [ + { text: t('Cancel'), style: 'cancel' }, + ...BAMBU_SPEEDS.map(({ preset, label }) => ({ + text: data.bambu?.speedPreset === preset ? `✓ ${label}` : label, + onPress: () => void data.actions.setBambuSpeed(preset).catch((error: unknown) => { + Alert.alert( + t('Speed change failed'), + error instanceof Error ? error.message : t('The printer did not accept the speed change.') + ); + }), + })), + ]); + }; // Nothing to report when idle: the camera pill already says READY, so a card // holding a placeholder thumbnail and two navigation buttons was just noise // between the feed and the toolheads. Files and Slice live in the tab bar. - if (state === 'idle') return null; + if (state === 'idle' && (!hasHmsFaults || data.offline)) return null; return ( - + - {state === 'error' ? ( + {state === 'error' || (state === 'idle' && hasHmsFaults) ? ( @@ -89,7 +154,46 @@ export function JobCard({ data }: { data: CockpitData }) { ) : null} - {renderActions(data, () => setConfirmCancel(true))} + {hasHmsFaults ? ( + setShowHms(true)} + style={({ pressed }) => [styles.hmsBanner, pressed && { opacity: 0.72 }]} + > + + + + {hmsFaults.length === 1 ? t('Printer alert') : `${hmsFaults.length} ${t('printer alerts')}`} + + + {hmsFaults[0]?.code} + + + + + ) : null} + + {canSkipObjects ? ( + [styles.skipBanner, pressed && { opacity: 0.72 }]} + > + + + {t('Skip objects')} + + {activePrintObjects.length} {t('objects still printing')} + + + + + ) : null} + + + {renderActions(data, () => setConfirmCancel(true), chooseBambuSpeed)} + {/* Layer rather than Focus: losing a print is bad but recoverable — you can re-slice and print again — so it doesn't earn the whole screen @@ -118,6 +222,126 @@ export function JobCard({ data }: { data: CockpitData }) { { text: t('Keep printing'), onPress: () => setConfirmCancel(false) }, ]} /> + + `${fault.code}\n${fault.summary}`) + .join('\n\n')} + icon="alert-outline" + onClose={() => setShowHms(false)} + actions={[ + ...(data.paused + ? [{ + text: t('Resume print'), + icon: 'play' as const, + variant: 'primary' as const, + onPress: () => { + setShowHms(false); + data.actions.resume(); + }, + }] + : []), + ...(state === 'printing' + ? [{ + text: t('Stop print'), + icon: 'close-octagon-outline' as const, + variant: 'danger' as const, + onPress: () => { + setShowHms(false); + setConfirmCancel(true); + }, + }] + : []), + { + text: t('Clear alert'), + icon: 'check' as const, + onPress: () => { + setShowHms(false); + void data.actions.clearBambuErrors().catch((error: unknown) => { + Alert.alert( + t('Could not clear alert'), + error instanceof Error ? error.message : t('The printer rejected the command.') + ); + }); + }, + }, + { + text: t('Official help'), + icon: 'open-in-new' as const, + onPress: () => { + const helpUrl = hmsFaults[0]?.helpUrl; + if (helpUrl) void Linking.openURL(helpUrl).catch(() => { + Alert.alert(t('Could not open Bambu Lab help.')); + }); + }, + }, + { text: t('Close'), onPress: () => setShowHms(false) }, + ]} + /> + + = activePrintObjects.length + ? t('Keep at least one object printing.') + : t('Selected objects will stop printing permanently. This cannot be undone.') + } + icon="selection-remove" + onClose={() => !skipBusy && setShowSkipObjects(false)} + actions={[ + { + text: skipBusy + ? t('Sending…') + : `${t('Skip selected')} (${selectedSkipIds.length})`, + icon: 'selection-remove', + variant: 'danger', + disabled: skipSelectionInvalid || skipBusy, + onPress: () => void confirmSkipObjects(), + }, + { + text: t('Keep printing'), + disabled: skipBusy, + onPress: () => setShowSkipObjects(false), + }, + ]} + > + + {printObjects.map((object) => { + const selected = selectedSkipIds.includes(object.identifyId); + return ( + toggleSkipObject(object.identifyId)} + style={({ pressed }) => [ + styles.skipObjectRow, + selected && styles.skipObjectRowSelected, + object.skipped && { opacity: 0.5 }, + pressed && { opacity: 0.75 }, + ]} + > + + + {object.name} + + {object.skipped ? t('Already skipped') : `ID ${object.identifyId}`} + + + + ); + })} + + ); } @@ -138,6 +362,16 @@ function toneFor(data: CockpitData): { label: string; color: string } { function renderInfo(data: CockpitData) { const { state, job } = data; + const firstHmsFault = data.bambu?.hmsFaults[0]; + + if (state === 'idle' && firstHmsFault) { + return ( + <> + {t('Printer needs attention')} + {firstHmsFault.summary} + + ); + } if (state === 'printing' && job) { return ( @@ -188,12 +422,23 @@ function renderInfo(data: CockpitData) { ); } -function renderActions(data: CockpitData, onCancel: () => void) { +function renderActions(data: CockpitData, onCancel: () => void, onSpeed: () => void) { const { actions, state, paused } = data; if (state === 'printing') { return ( <> + {data.bambu ? ( + item.preset === data.bambu?.speedPreset) + ?.label.split(' ')[0] ?? t('Speed') + } + tone="ghost" + onPress={onSpeed} + /> + ) : null} {paused ? ( ) : ( @@ -301,6 +546,56 @@ const styles = StyleSheet.create({ progressBar: { flex: 1 }, progressPct: { color: P.text, fontSize: 13, fontWeight: '800', minWidth: 38, textAlign: 'right' }, + hmsBanner: { + minHeight: 58, + borderRadius: 14, + borderWidth: 1, + borderColor: alpha(P.danger, 0.32), + backgroundColor: alpha(P.danger, 0.09), + paddingHorizontal: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + hmsBannerText: { flex: 1, gap: 2 }, + hmsBannerTitle: { color: P.text, fontSize: 13, fontWeight: '800' }, + hmsBannerCode: { color: P.dim, fontSize: 11, fontWeight: '700' }, + + skipBanner: { + minHeight: 58, + borderRadius: 14, + borderWidth: 1, + borderColor: alpha(P.warn, 0.3), + backgroundColor: alpha(P.warn, 0.08), + paddingHorizontal: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + skipBannerText: { flex: 1, gap: 2 }, + skipBannerTitle: { color: P.text, fontSize: 13, fontWeight: '800' }, + skipBannerDetail: { color: P.dim, fontSize: 11, fontWeight: '700' }, + skipObjectList: { alignSelf: 'stretch', width: '100%', maxWidth: 460, gap: 8 }, + skipObjectRow: { + minHeight: 60, + borderRadius: 14, + borderWidth: 1, + borderColor: P.border, + backgroundColor: P.surface, + paddingHorizontal: 14, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + skipObjectRowSelected: { + borderColor: alpha(P.danger, 0.65), + backgroundColor: alpha(P.danger, 0.1), + }, + skipObjectText: { flex: 1, gap: 2 }, + skipObjectName: { color: P.text, fontSize: 14, fontWeight: '800' }, + skipObjectId: { color: P.dim, fontSize: 11, fontWeight: '700' }, + actions: { flexDirection: 'row', gap: 8 }, action: { flex: 1, diff --git a/components/dashboard/parts/Modules.tsx b/components/dashboard/parts/Modules.tsx index 7f16cda..44b6068 100644 --- a/components/dashboard/parts/Modules.tsx +++ b/components/dashboard/parts/Modules.tsx @@ -6,6 +6,7 @@ import React, { useState } from 'react'; import { Alert, Pressable, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import CameraFeed from '../../CameraFeed'; +import ScreenMirror from '../../ScreenMirror'; import { alpha, CameraMock, @@ -14,7 +15,9 @@ import { Sparkline, } from '../shared'; import type { CockpitData, CockpitTemp, CockpitTool } from './data'; +import { useSettings } from '../../../hooks/useSettings'; import { t } from '../../../services/i18n'; +import type { BambuFan, BambuHeater } from '../../../services/bambuControls'; // ~16s of trace at the model's 2s sampling interval. const MIN_TRACE_POINTS = 8; @@ -26,6 +29,24 @@ const PANDA_DRY_PRESETS = [ { label: 'PETG 60°C 12h', temp: 60, hours: 12 }, ]; +const BAMBU_TEMPERATURE_PRESETS: Record = { + nozzle: [0, 180, 200, 220, 240, 260], + bed: [0, 40, 55, 65, 80, 100], +}; +const BAMBU_FAN_PRESETS = [0, 25, 50, 75, 100]; +const BAMBU_FANS: { key: BambuFan; label: string }[] = [ + { key: 'part', label: 'Part' }, + { key: 'aux', label: 'Aux' }, + { key: 'chamber', label: 'Chamber' }, +]; + +function reportBambuControlError(title: string, error: unknown) { + Alert.alert( + title, + error instanceof Error ? error.message : t('The printer did not accept the command.') + ); +} + /** * Four across, colour in a thick left edge. Carried over from the Rail * direction: the edges line up into one striped band, so you find the spool you @@ -35,35 +56,145 @@ const PANDA_DRY_PRESETS = [ export function ToolheadRail({ data, onEditSlot, + onConfigureMaterialStation, }: { data: CockpitData; onEditSlot: (index: number) => void; + onConfigureMaterialStation: () => void; }) { + const { settings } = useSettings(); + const activePrinter = settings.printers.find((printer) => printer.id === settings.activePrinterId); + const bambu = activePrinter?.kind === 'bambu-lan'; + const ad5x = activePrinter?.kind === 'flashforge-ad5x'; + const externalSpool = bambu && data.tools.length === 1; + const sectionTitle = externalSpool ? t('External Spool') : bambu ? 'AMS Slots' : ad5x ? 'IFS Slots' : t('Toolheads'); + const materialStationNeedsSetup = + ad5x && + (data.materialStationError === 'missing-credentials' || + data.materialStationError === 'auth-failed'); + + const openBambuSlot = (tool: CockpitTool) => { + const busy = data.state === 'printing'; + const actions = [ + !busy && !tool.empty + ? { + text: tool.active ? t('Unload') : t('Load'), + onPress: () => void data.actions.changeBambuFilament( + tool.active ? null : tool.bambuTrayIndex, + tool.bambuChangeTemp + ).catch((error: unknown) => { + Alert.alert( + t('Filament change failed'), + error instanceof Error + ? error.message + : t('The printer did not accept the filament change.') + ); + }), + } + : null, + { text: t('Edit filament'), onPress: () => onEditSlot(tool.id) }, + { text: t('Cancel'), style: 'cancel' as const }, + ].filter(Boolean) as { text: string; style?: 'cancel'; onPress?: () => void }[]; + + Alert.alert( + externalSpool ? t('External Spool') : `${t('AMS Slot')} ${tool.id + 1}`, + busy + ? t('Filament cannot be changed while the printer is busy.') + : tool.active + ? t('This slot is currently feeding the nozzle.') + : tool.empty + ? t('This slot is empty.') + : `${tool.brand} ${tool.material}`.trim(), + actions + ); + }; + return ( - {t('Toolheads')} + {sectionTitle} + {materialStationNeedsSetup ? ( + + + + + {data.materialStationError === 'auth-failed' + ? t('IFS access was rejected') + : t('Connect the AD5X IFS')} + + + {t('Add the printer serial number and Printer ID to show which rolls are actually loaded.')} + + + [styles.ifsSetupButton, pressed && { opacity: 0.72 }]} + onPress={onConfigureMaterialStation} + accessibilityRole="button" + accessibilityLabel={t('Set up IFS access')} + > + {t('Set up')} + + + ) : null} + {bambu && data.bambu?.amsHealth.length ? ( + + {data.bambu.amsHealth.map((health) => ( + + + + {data.bambu!.amsHealth.length > 1 ? `AMS ${health.unit + 1} · ` : ''} + {health.humidity == null ? t('Humidity unavailable') : `${t('Humidity')} ${health.humidity}/5`} + {health.temperature == null ? '' : ` · ${Math.round(health.temperature)}°C`} + + + ))} + {t('1 is driest')} + + ) : null} {data.tools.map((tool) => ( - onEditSlot(tool.id)} /> + openBambuSlot(tool) : () => onEditSlot(tool.id)} + /> ))} ); } -function ToolCard({ tool, onPress }: { tool: CockpitTool; onPress: () => void }) { +function ToolCard({ + tool, + positionLabel, + onPress, +}: { + tool: CockpitTool; + positionLabel: string; + onPress?: () => void; +}) { + // Empty Bambu trays still need to open so their saved filament can be edited. + const disabled = !onPress; return ( [ styles.toolCard, tool.active && { borderColor: alpha(P.accent, 0.5), backgroundColor: alpha(P.accent, 0.06), }, - pressed && { opacity: 0.7 }, + pressed && !disabled && { opacity: 0.7 }, ]} > {/* An empty slot still has a saved colour; painting the edge solid would @@ -75,7 +206,7 @@ function ToolCard({ tool, onPress }: { tool: CockpitTool; onPress: () => void }) ]} /> - T{tool.id + 1} + {positionLabel} {tool.empty ? ( {t('Empty')} ) : ( @@ -103,22 +234,92 @@ function ToolCard({ tool, onPress }: { tool: CockpitTool; onPress: () => void }) } export function TempRow({ cardWidth, data }: { cardWidth: number; data: CockpitData }) { + const chooseTemperature = (temp: CockpitTemp) => { + const heater: BambuHeater | null = temp.key === 'nozzle' + ? 'nozzle' + : temp.key === 'bed' + ? 'bed' + : null; + if (!heater) return; + Alert.alert(t(`Set ${temp.label} temperature`), t('Choose a target temperature.'), [ + { text: t('Cancel'), style: 'cancel' }, + ...BAMBU_TEMPERATURE_PRESETS[heater].map((target) => ({ + text: `${target === temp.target ? '✓ ' : ''}${target === 0 ? t('Off') : `${target}°C`}`, + onPress: () => void data.actions.setBambuTemperature(heater, target) + .catch((error: unknown) => reportBambuControlError(t('Temperature change failed'), error)), + })), + ]); + }; + + const chooseFanSpeed = (fan: BambuFan, label: string, current: number) => { + Alert.alert(`${label} ${t('fan')}`, t('Choose a fan speed.'), [ + { text: t('Cancel'), style: 'cancel' }, + ...BAMBU_FAN_PRESETS.map((percent) => ({ + text: `${percent === current ? '✓ ' : ''}${percent === 0 ? t('Off') : `${percent}%`}`, + onPress: () => void data.actions.setBambuFan(fan, percent) + .catch((error: unknown) => reportBambuControlError(t('Fan change failed'), error)), + })), + ]); + }; + + const visibleFans = BAMBU_FANS.flatMap((fan) => { + const speed = data.bambu?.fans[fan.key]; + return speed == null ? [] : [{ ...fan, speed }]; + }); + return ( - {t('Temperatures')} + + {t('Temperatures')} + {data.temps.map((temp) => ( - + chooseTemperature(temp) + : undefined} + /> ))} + {visibleFans.length > 0 ? ( + + {visibleFans.map((fan) => ( + chooseFanSpeed(fan.key, fan.label, fan.speed)} + style={({ pressed }) => [ + styles.bambuFanChip, + data.offline && styles.pandaChipDisabled, + pressed && { opacity: 0.68 }, + ]} + > + + {fan.label} + {fan.speed}% + + ))} + + ) : null} ); } -function TempCard({ temp, width }: { temp: CockpitTemp; width: number }) { +function TempCard({ temp, width, onPress }: { temp: CockpitTemp; width: number; onPress?: () => void }) { const heating = temp.target > 0; return ( - + [styles.tempCard, { width }, pressed && { opacity: 0.72 }]} + > {temp.label} @@ -144,7 +345,7 @@ function TempCard({ temp, width }: { temp: CockpitTemp; width: number }) { )} - + ); } @@ -285,10 +486,9 @@ function Chip({ } /** - * The printer's own touchscreen, mirrored. In the real build this is the `gui` - * webcam (name === 'gui' or a /screen/ URL) rendered in a WebView that must - * stay MOUNTED when hidden — remounting /screen/ makes the printer report - * "No Signal". Hide it with height 0 + pointerEvents none instead. + * The printer's own touchscreen, mirrored. On the AD5X this is helixd's live + * /api/screen mirror with remote taps (ScreenMirror); other machines fall back + * to the `gui` webcam in a WebView. */ export function PrinterScreen({ data, @@ -301,35 +501,43 @@ export function PrinterScreen({ onInteractStart: () => void; onInteractEnd: () => void; }) { - // The U1 panel is 4:3, unlike the 16:9 print camera — a shared ratio would - // letterbox one of them. - const height = Math.round(width * (3 / 4)); + const { settings } = useSettings(); + const activePrinter = settings.printers.find((p) => p.id === settings.activePrinterId); + // helixd (screen + tap daemon) only exists on the AD5X — U1/generic keep the + // legacy gui webcam mirror. + const useLiveScreen = activePrinter?.kind === 'flashforge-ad5x'; + + // The legacy U1 panel is 4:3; the live AD5X panel sizes itself (5:3) via the + // mirror's aspectRatio, so no fixed height is needed there. + const legacyHeight = Math.round(width * (3 / 4)); return ( {t('Printer screen')} - {/* The embedded panel forwards touches (including drags) to the printer. - The parent ScrollView would otherwise claim any vertical movement as a - scroll, so it's disabled for the duration of a touch here — without - this the screen only ever sees taps, and its sliders/menus are dead. */} + {/* The embedded panel forwards touches to the printer. The parent + ScrollView would otherwise claim vertical movement as a scroll, so + it's disabled for the duration of a touch here — without this the + screen only ever sees taps, and its sliders/menus are dead. */} - {data.guiScreen ? ( + {useLiveScreen ? ( + + ) : data.guiScreen ? ( ) : ( void; pandaBreath: DashboardPandaBreath; + bambu: DashboardBambu | null; } const TEMP_ICONS: Record = { @@ -127,7 +132,10 @@ export function useCockpitData(): CockpitData { target: t.target, active: t.active, empty: t.loaded === 'empty', + bambuTrayIndex: t.bambuTrayIndex, + bambuChangeTemp: t.bambuChangeTemp, })), + materialStationError: model.materialStationError, temps: model.temps.map((t) => ({ key: t.key, label: t.label, @@ -146,5 +154,6 @@ export function useCockpitData(): CockpitData { lightOn: model.lightOn, toggleLight: model.toggleLight, pandaBreath: model.pandaBreath, + bambu: model.bambu, }; } diff --git a/components/settings/Bespok3dEnrollmentDialog.tsx b/components/settings/Bespok3dEnrollmentDialog.tsx new file mode 100644 index 0000000..3c96580 --- /dev/null +++ b/components/settings/Bespok3dEnrollmentDialog.tsx @@ -0,0 +1,853 @@ +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Switch, + Text, + TextInput, + View, +} from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import type { PrinterEntry } from '../../hooks/useSettings'; +import { + configureBespok3dHelixScreen, + enrollBespok3dU1, + getBespok3dHelixScreenState, + installBundledBespok3dHelixScreen, + installBespok3dPlugins, + listBespok3dPlugins, + preflightBespok3dU1, + prepareBespok3dU1Enrollment, + type Bespok3dPlugin, + type Bespok3dPluginCatalog, + type Bespok3dPluginConfigField, + type Bespok3dPluginInstallResult, + type Bespok3dPluginVars, + type Bespok3dHelixScreenState, + type Bespok3dScreenUi, + type Bespok3dU1Preflight, +} from '../../services/bespok3d'; +import { + createEnrolledBespok3dCredentialRecord, + createPreparedBespok3dCredentialRecord, + type Bespok3dEnrolledCredentialRecord, +} from '../../services/bespok3dCredentials'; +import { + readBespok3dCredentialRecord, + writeBespok3dCredentialRecord, +} from '../../services/bespok3dSecureStore'; +import { printerConnectionUrl } from '../../services/moonraker'; +import ThemedDialog, { type DialogAction } from '../ThemedDialog'; +import { alpha, COCKPIT } from '../dashboard/shared'; + +type Stage = + | 'password' + | 'checking' + | 'review' + | 'installing' + | 'complete' + | 'helixscreen-confirm' + | 'helixscreen-installing' + | 'plugin-loading' + | 'plugins' + | 'plugin-installing' + | 'plugin-result' + | 'error'; + +interface Props { + visible: boolean; + printer: PrinterEntry | null; + onClose: () => void; +} + +export default function Bespok3dEnrollmentDialog({ visible, printer, onClose }: Props) { + const [stage, setStage] = useState('password'); + const [password, setPassword] = useState(''); + const [preflight, setPreflight] = useState(null); + const [enrolled, setEnrolled] = useState(null); + const [catalog, setCatalog] = useState(null); + const [selectedPlugins, setSelectedPlugins] = useState([]); + const [pluginVars, setPluginVars] = useState({}); + const [pluginResult, setPluginResult] = useState(null); + const [helixScreen, setHelixScreen] = useState(null); + const [screenBusy, setScreenBusy] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + let live = true; + if (!visible || !printer) return () => { live = false; }; + setPassword(''); + setPreflight(null); + setEnrolled(null); + setCatalog(null); + setSelectedPlugins([]); + setPluginVars({}); + setPluginResult(null); + setHelixScreen(null); + setScreenBusy(false); + setError(''); + setStage('password'); + readBespok3dCredentialRecord(printer.id) + .then((record) => { + if (!live || record?.status !== 'enrolled') return; + setEnrolled(record); + setStage('complete'); + }) + .catch(() => {}); + return () => { live = false; }; + }, [printer, visible]); + + useEffect(() => { + let live = true; + if (!visible || !printer || !enrolled || enrolled.printerId !== printer.id) { + return () => { live = false; }; + } + getBespok3dHelixScreenState(printerConnectionUrl(printer), enrolled) + .then((state) => { if (live) setHelixScreen(state); }) + .catch((reason) => { + if (live) setError(reason instanceof Error ? reason.message : String(reason)); + }); + return () => { live = false; }; + }, [enrolled, printer, visible]); + + const busy = stage === 'checking' + || stage === 'installing' + || stage === 'helixscreen-installing' + || stage === 'plugin-loading' + || stage === 'plugin-installing' + || screenBusy; + const close = () => { + if (busy) return; + setPassword(''); + onClose(); + }; + + const checkPrinter = async () => { + if (!printer || !password) return; + setStage('checking'); + setError(''); + try { + const result = await preflightBespok3dU1(printerConnectionUrl(printer), password); + if (!result.eligible) throw new Error(result.reason || 'This printer cannot be enrolled.'); + setPreflight(result); + setStage('review'); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + setStage('error'); + } + }; + + const install = async () => { + if (!printer || !preflight || !password) return; + setStage('installing'); + setError(''); + try { + const existing = await readBespok3dCredentialRecord(printer.id); + const prepared = existing?.status === 'prepared' + && existing.sshHostKeySha256 === preflight.sshHostKeySha256 + ? existing + : createPreparedBespok3dCredentialRecord( + printer.id, + preflight.sshHostKeySha256, + 'Helix Android', + await prepareBespok3dU1Enrollment(), + ); + // Persist before mutation so the exact same identity survives an app or Wi-Fi interruption. + await writeBespok3dCredentialRecord(prepared); + const result = await enrollBespok3dU1( + printerConnectionUrl(printer), + password, + preflight.sshHostKeySha256, + prepared.label, + prepared, + ); + const completed = createEnrolledBespok3dCredentialRecord(prepared, result); + await writeBespok3dCredentialRecord(completed); + setEnrolled(completed); + setPassword(''); + setStage('complete'); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + setStage('error'); + } + }; + + const loadPlugins = async () => { + if (!printer || !enrolled) return; + setStage('plugin-loading'); + setError(''); + setPluginResult(null); + try { + const nextCatalog = await listBespok3dPlugins(printerConnectionUrl(printer), enrolled); + const defaults: Bespok3dPluginVars = {}; + nextCatalog.plugins.forEach((plugin) => { + const values: Record = {}; + plugin.config.forEach((field) => { + if (field.defaultValue !== null) values[field.key] = field.defaultValue; + }); + defaults[plugin.id] = values; + }); + setCatalog(nextCatalog); + setSelectedPlugins([]); + setPluginVars(defaults); + setStage('plugins'); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + setStage('complete'); + } + }; + + const installSelectedPlugins = async () => { + if (!printer || !enrolled || selectedPlugins.length === 0) return; + setStage('plugin-installing'); + setError(''); + try { + const selectedVars = Object.fromEntries( + selectedPlugins.map((pluginId) => [pluginId, pluginVars[pluginId] ?? {}]), + ); + const result = await installBespok3dPlugins( + printerConnectionUrl(printer), + enrolled, + selectedPlugins, + selectedVars, + ); + setPluginResult(result); + setStage('plugin-result'); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + setStage('plugins'); + } + }; + + const togglePlugin = (pluginId: string) => { + setSelectedPlugins((current) => current.includes(pluginId) + ? current.filter((id) => id !== pluginId) + : [...current, pluginId]); + }; + + const setPluginVar = (pluginId: string, key: string, value: string) => { + setPluginVars((current) => ({ + ...current, + [pluginId]: { ...(current[pluginId] ?? {}), [key]: value }, + })); + }; + + const switchScreen = async (selected: Bespok3dScreenUi) => { + if ( + !printer + || !enrolled + || enrolled.printerId !== printer.id + || screenBusy + || helixScreen?.selected === selected + ) return; + setScreenBusy(true); + setError(''); + try { + const state = await configureBespok3dHelixScreen( + printerConnectionUrl(printer), + enrolled, + selected, + ); + setHelixScreen(state); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setScreenBusy(false); + } + }; + + const installHelixScreen = async () => { + if ( + !printer + || !enrolled + || enrolled.printerId !== printer.id + || helixScreen?.installed !== false + ) return; + setStage('helixscreen-installing'); + setError(''); + try { + const result = await installBundledBespok3dHelixScreen( + printerConnectionUrl(printer), + enrolled, + ); + if (!result.ok) { + const details = Object.entries(result.failures) + .map(([pluginId, reason]) => `${pluginId}: ${reason}`) + .join('\n'); + throw new Error(details || 'Bespok3d could not install HelixScreen.'); + } + const state = await getBespok3dHelixScreenState(printerConnectionUrl(printer), enrolled); + if (!state.installed) throw new Error('Bespok3d did not report HelixScreen as installed.'); + setHelixScreen(state); + setStage('complete'); + } catch (reason) { + try { + setHelixScreen( + await getBespok3dHelixScreenState(printerConnectionUrl(printer), enrolled), + ); + } catch { + // Best-effort refresh; preserve the original installation failure below. + } + setError(reason instanceof Error ? reason.message : String(reason)); + setStage('complete'); + } + }; + + const actions: DialogAction[] = (() => { + if (stage === 'password') return [ + { + text: 'Check printer', + icon: 'shield-check-outline', + variant: 'primary', + disabled: !password, + onPress: checkPrinter, + }, + { text: 'Cancel', onPress: close }, + ]; + if (stage === 'review') return [ + { + text: 'Install & reboot U1', + icon: 'download-lock-outline', + variant: 'danger', + onPress: install, + }, + { text: 'Cancel', onPress: close }, + ]; + if (stage === 'complete') { + if (helixScreen?.installed === false) return [ + { + text: 'Install HelixScreen', + icon: 'monitor-arrow-down-variant', + variant: 'primary', + onPress: () => { setError(''); setStage('helixscreen-confirm'); }, + }, + { text: 'Choose plugins', icon: 'puzzle-outline', onPress: loadPlugins }, + { text: 'Done', icon: 'check', onPress: close }, + ]; + return [ + { text: 'Choose plugins', icon: 'puzzle-outline', variant: 'primary', onPress: loadPlugins }, + { text: 'Done', icon: 'check', onPress: close }, + { + text: 'Check or reinstall', + onPress: () => { + setEnrolled(null); + setStage('password'); + }, + }, + ]; + } + if (stage === 'helixscreen-confirm') return [ + { + text: 'Install HelixScreen', + icon: 'download-lock-outline', + variant: 'danger', + onPress: installHelixScreen, + }, + { text: 'Cancel', onPress: () => setStage('complete') }, + ]; + if (stage === 'plugins') return [ + { + text: selectedPlugins.length > 0 + ? `Install selected (${selectedPlugins.length})` + : 'Select plugins to install', + icon: 'download-outline', + variant: 'primary', + disabled: selectedPlugins.length === 0, + onPress: installSelectedPlugins, + }, + { text: 'Back', onPress: () => { setError(''); setStage('complete'); } }, + ]; + if (stage === 'plugin-result') return [ + { text: 'Back to plugins', icon: 'refresh', variant: 'primary', onPress: loadPlugins }, + { text: 'Done', onPress: close }, + ]; + if (stage === 'error') return [ + { text: 'Try again', icon: 'refresh', variant: 'primary', onPress: () => setStage('password') }, + { text: 'Close', onPress: close }, + ]; + const busyText = stage === 'checking' + ? 'Checking…' + : stage === 'helixscreen-installing' + ? 'Installing HelixScreen…' + : stage === 'plugin-loading' + ? 'Loading plugins…' + : 'Installing…'; + return [{ text: busyText, disabled: true, onPress: () => {} }]; + })(); + + const title = stage === 'plugin-result' + ? (pluginResult?.ok ? 'Plugins installed' : 'Plugin install results') + : ({ + password: 'Set up Bespok3d', + checking: 'Checking your U1', + review: 'Confirm U1 enrollment', + installing: 'Installing Bespok3d', + complete: 'Bespok3d is ready', + 'helixscreen-confirm': 'Install HelixScreen?', + 'helixscreen-installing': 'Installing HelixScreen', + 'plugin-loading': 'Loading plugin catalog', + plugins: 'Choose Bespok3d plugins', + 'plugin-installing': 'Installing selected plugins', + error: 'Enrollment stopped safely', + } satisfies Record, string>)[stage]; + + return ( + + + {stage === 'password' ? ( + <> + {enrolled ? Checking again will not remove your saved access. : null} + + Helix will first perform a read-only safety check. Enter the stock U1 SSH password. + + + The password is used for this run only and is never saved. + + ) : null} + + {stage === 'checking' + || stage === 'installing' + || stage === 'helixscreen-installing' + || stage === 'plugin-loading' + || stage === 'plugin-installing' ? ( + + + + {stage === 'checking' + ? 'Confirming stock firmware, printer identity, idle state, and SSH host key…' + : stage === 'installing' + ? 'Verifying the signed package, rebooting if needed, reconnecting, and installing. This can take several minutes. Keep Helix open and stay on this Wi-Fi.' + : stage === 'helixscreen-installing' + ? 'Verifying the HelixScreen package and uploading it to your U1. Keep Helix open, stay on this Wi-Fi, and do not start a print.' + : stage === 'plugin-loading' + ? 'Checking the signed official catalog and reading what is installed on your U1…' + : 'Downloading and verifying each selected package, then applying them together. Keep Helix open and do not start a print.'} + + + ) : null} + + {stage === 'review' && preflight ? ( + <> + + + + + This changes the printer + + The U1 may reboot. Helix will install the official signed Bespok3d daemon and startup hooks. Do not power off the printer or leave this network. + + + + ) : null} + + {stage === 'helixscreen-confirm' ? ( + <> + + Helix will upload the bundled 42 MB touchscreen package to this U1 through its paired Bespok3d connection. + + + This changes the printer touchscreen + + Installation restarts the display service, but keeps the stock Snapmaker screen selected. After it finishes, you can deliberately switch to HelixScreen or return to stock at any time. Do not install while printing. + + + + ) : null} + + {stage === 'complete' && enrolled ? ( + <> + + + + + {helixScreen?.installed ? ( + + + + Printer touchscreen + + Switches only the U1 display. Klipper and printer settings stay unchanged. + + + {screenBusy ? : null} + + + switchScreen('snapmaker')} + /> + switchScreen('helixscreen')} + /> + + + ) : helixScreen?.installed === false ? ( + + + + + HelixScreen is available + + Install it from Helix, then choose between HelixScreen and the stock Snapmaker interface. + + + + + ) : ( + + + + Checking touchscreen options… + + + )} + Access credentials are stored in Android secure storage. + {error ? {error} : null} + + ) : null} + + {stage === 'plugins' && catalog ? ( + <> + + Nothing is installed until you check it and press Install selected. Packages come from the signed official catalog. + + {error ? {error} : null} + + {catalog.plugins.map((plugin) => ( + togglePlugin(plugin.id)} + onChange={(key, value) => setPluginVar(plugin.id, key, value)} + /> + ))} + + + ) : null} + + {stage === 'plugin-result' && pluginResult ? ( + + {pluginResult.installedIds.map((pluginId) => ( + + ))} + {Object.entries(pluginResult.failures).map(([pluginId, reason]) => ( + + ))} + {pluginResult.installedIds.length === 0 && Object.keys(pluginResult.failures).length === 0 ? ( + The selected plugins were already current. + ) : null} + + ) : null} + + {stage === 'error' ? ( + <> + {error || 'Enrollment did not finish.'} + + The installer is safe to run again. It rechecks the printer and reuses the securely stored identity. + + + ) : null} + + + ); +} + +function PluginChoice({ + plugin, + installedVersion, + selected, + values, + onToggle, + onChange, +}: { + plugin: Bespok3dPlugin; + installedVersion?: string; + selected: boolean; + values: Record; + onToggle: () => void; + onChange: (key: string, value: string) => void; +}) { + const isInstalled = installedVersion === plugin.version; + const versionLabel = isInstalled + ? `Installed · v${installedVersion}` + : installedVersion + ? `Update v${installedVersion} → v${plugin.version}` + : `Available · v${plugin.version}`; + return ( + + + + + + {plugin.title} + {plugin.category} + + {versionLabel} + {plugin.tagline ? {plugin.tagline} : null} + {plugin.dependencies.length > 0 ? ( + Also needs: {plugin.dependencies.join(', ')} + ) : null} + + + {selected && plugin.config.length > 0 ? ( + + {plugin.config.map((field) => ( + onChange(field.key, value)} + /> + ))} + + ) : null} + + ); +} + +function PluginConfigField({ + field, + value, + onChange, +}: { + field: Bespok3dPluginConfigField; + value: string; + onChange: (value: string) => void; +}) { + if (field.type === 'toggle') { + return ( + + + {field.label} + {field.hint ? {field.hint} : null} + + onChange(enabled ? field.onValue : field.offValue)} + trackColor={{ false: COCKPIT.border, true: alpha(COCKPIT.accent, 0.5) }} + thumbColor={value === field.onValue ? COCKPIT.accent : COCKPIT.dim} + /> + + ); + } + if (field.type === 'select' && field.options.length > 0) { + return ( + + {field.label} + + {field.options.map((option) => ( + onChange(option)} + style={[styles.option, value === option && styles.optionSelected]} + > + {option} + + ))} + + {field.hint ? {field.hint} : null} + + ); + } + return ( + + {field.label}{field.required ? ' *' : ''} + + {field.hint ? {field.hint} : null} + + ); +} + +function ResultRow({ ok, text }: { ok: boolean; text: string }) { + return ( + + + {text} + + ); +} + +function ScreenChoice({ + label, + selected, + disabled, + onPress, +}: { + label: string; + selected: boolean; + disabled: boolean; + onPress: () => void; +}) { + return ( + + {label} + + ); +} + +function Fact({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + body: { alignSelf: 'stretch', gap: 12 }, + copy: { color: COCKPIT.text, fontSize: 14, lineHeight: 21, textAlign: 'center' }, + note: { color: COCKPIT.dim, fontSize: 12, lineHeight: 18, textAlign: 'center' }, + input: { + minHeight: 52, + borderRadius: 12, + borderWidth: 1, + borderColor: COCKPIT.border, + backgroundColor: COCKPIT.surfaceAlt, + color: COCKPIT.text, + paddingHorizontal: 16, + fontSize: 16, + }, + progress: { alignItems: 'center', gap: 16, paddingVertical: 16 }, + fact: { borderRadius: 12, backgroundColor: COCKPIT.surfaceAlt, padding: 12, gap: 4 }, + factLabel: { color: COCKPIT.dim, fontSize: 11, fontWeight: '800', textTransform: 'uppercase' }, + factValue: { color: COCKPIT.text, fontSize: 13, lineHeight: 18, fontWeight: '700' }, + mono: { fontFamily: 'monospace', fontSize: 10 }, + warning: { + borderRadius: 14, + borderWidth: 1, + borderColor: alpha(COCKPIT.danger, 0.52), + backgroundColor: alpha(COCKPIT.danger, 0.12), + padding: 14, + gap: 6, + }, + warningTitle: { color: COCKPIT.danger, fontSize: 14, fontWeight: '900' }, + warningText: { color: COCKPIT.text, fontSize: 12, lineHeight: 18 }, + error: { color: COCKPIT.danger, fontSize: 14, lineHeight: 20, textAlign: 'center' }, + screenCard: { + borderRadius: 14, + borderWidth: 1, + borderColor: COCKPIT.border, + backgroundColor: COCKPIT.surfaceAlt, + padding: 14, + gap: 12, + }, + screenHeading: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + screenCopy: { flex: 1, gap: 4 }, + screenTitle: { color: COCKPIT.text, fontSize: 14, fontWeight: '900' }, + screenHint: { color: COCKPIT.dim, fontSize: 11, lineHeight: 16 }, + pluginList: { alignSelf: 'stretch', gap: 10 }, + plugin: { + borderRadius: 14, + borderWidth: 1, + borderColor: COCKPIT.border, + backgroundColor: COCKPIT.surfaceAlt, + overflow: 'hidden', + }, + pluginSelected: { borderColor: alpha(COCKPIT.accent, 0.75) }, + pluginHeader: { flexDirection: 'row', alignItems: 'flex-start', gap: 11, padding: 14 }, + pluginText: { flex: 1, gap: 4 }, + pluginTitleLine: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + pluginTitle: { flex: 1, color: COCKPIT.text, fontSize: 15, fontWeight: '800' }, + pluginCategory: { + color: COCKPIT.dim, + fontSize: 9, + fontWeight: '900', + textTransform: 'uppercase', + }, + pluginVersion: { color: COCKPIT.accent, fontSize: 11, fontWeight: '800' }, + pluginTagline: { color: COCKPIT.dim, fontSize: 12, lineHeight: 17 }, + pluginDependency: { color: COCKPIT.warn, fontSize: 11, lineHeight: 16, fontWeight: '700' }, + configList: { borderTopWidth: 1, borderTopColor: COCKPIT.border, padding: 14, gap: 14 }, + configRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + configBlock: { gap: 7 }, + configText: { flex: 1, gap: 3 }, + configLabel: { color: COCKPIT.text, fontSize: 13, fontWeight: '800' }, + configHint: { color: COCKPIT.dim, fontSize: 10, lineHeight: 15 }, + configInput: { + minHeight: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: COCKPIT.border, + backgroundColor: COCKPIT.bg, + color: COCKPIT.text, + paddingHorizontal: 12, + }, + optionList: { flexDirection: 'row', flexWrap: 'wrap', gap: 7 }, + option: { + borderRadius: 999, + borderWidth: 1, + borderColor: COCKPIT.border, + backgroundColor: COCKPIT.bg, + paddingHorizontal: 12, + paddingVertical: 8, + }, + optionSelected: { borderColor: COCKPIT.accent, backgroundColor: alpha(COCKPIT.accent, 0.16) }, + optionDisabled: { opacity: 0.6 }, + optionText: { color: COCKPIT.dim, fontSize: 11, fontWeight: '800' }, + optionTextSelected: { color: COCKPIT.accent }, + resultList: { alignSelf: 'stretch', gap: 10 }, + resultRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 9, + borderRadius: 12, + backgroundColor: COCKPIT.surfaceAlt, + padding: 12, + }, + resultText: { flex: 1, color: COCKPIT.text, fontSize: 12, lineHeight: 18 }, +}); diff --git a/components/settings/MacroDisplayCard.tsx b/components/settings/MacroDisplayCard.tsx index d757c48..d937174 100644 --- a/components/settings/MacroDisplayCard.tsx +++ b/components/settings/MacroDisplayCard.tsx @@ -3,6 +3,7 @@ import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-nativ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useMoonraker } from '../../hooks/useMoonraker'; import { useSettings } from '../../hooks/useSettings'; +import { getDashboardSections, setDashboardForPrinter } from '../../services/dashboardSections'; import { MacroDisplaySettings, countSelectedMacros, @@ -34,7 +35,13 @@ export default function MacroDisplayCard() { settings.activePrinterId, next ), - dashboard: { ...settings.dashboard, macros: true }, + // Turning the macro card on applies to this printer, not every printer. + dashboardByPrinter: setDashboardForPrinter( + settings.dashboardByPrinter, + settings.activePrinterId, + { ...getDashboardSections(settings), macros: true }, + settings.dashboard + ), }); }; diff --git a/components/settings/PrinterDiscoveryDialog.tsx b/components/settings/PrinterDiscoveryDialog.tsx new file mode 100644 index 0000000..a730625 --- /dev/null +++ b/components/settings/PrinterDiscoveryDialog.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; + +import ThemedDialog from '../ThemedDialog'; +import { alpha, COCKPIT as P } from '../dashboard/shared'; +import { + discoverPrinters, + getSuggestedDiscoverySubnet, + type DiscoveredPrinter, + type PrinterDiscoveryProgress, +} from '../../services/printerDiscovery'; +import { printerProfile } from '../../services/printerProfiles'; +import type { PrinterEntry } from '../../services/settingsMigration'; + +interface Props { + visible: boolean; + existingPrinters: readonly PrinterEntry[]; + onClose: () => void; + onSelect: (printer: DiscoveredPrinter) => void; +} + +function hostFromUrl(url: string): string { + try { + return new URL(url).hostname; + } catch { + return ''; + } +} + +export default function PrinterDiscoveryDialog({ + visible, + existingPrinters, + onClose, + onSelect, +}: Props) { + const [subnet, setSubnet] = useState(''); + const [loadingSubnet, setLoadingSubnet] = useState(false); + const [scanning, setScanning] = useState(false); + const [progress, setProgress] = useState(null); + const [results, setResults] = useState([]); + const [error, setError] = useState(''); + const scanController = useRef(null); + const subnetEditedByUser = useRef(false); + const existingPrinterUrls = useMemo( + () => existingPrinters.map((printer) => printer.url), + [existingPrinters] + ); + const existingHosts = useMemo( + () => new Set(existingPrinterUrls.map(hostFromUrl).filter(Boolean)), + [existingPrinterUrls] + ); + + useEffect(() => { + if (!visible) { + scanController.current?.abort(); + scanController.current = null; + return; + } + + subnetEditedByUser.current = false; + setLoadingSubnet(true); + setProgress(null); + setResults([]); + setError(''); + let disposed = false; + getSuggestedDiscoverySubnet(existingPrinterUrls) + .then((suggestedSubnet) => { + if (!disposed && !subnetEditedByUser.current) setSubnet(suggestedSubnet); + }) + .catch((reason: unknown) => { + if (disposed) return; + if (!subnetEditedByUser.current) setSubnet(''); + setError(reason instanceof Error ? reason.message : String(reason)); + }) + .finally(() => { + if (!disposed) setLoadingSubnet(false); + }); + + return () => { + disposed = true; + scanController.current?.abort(); + }; + }, [existingPrinterUrls, visible]); + + const stopScan = () => { + scanController.current?.abort(); + scanController.current = null; + setScanning(false); + }; + + const close = () => { + stopScan(); + onClose(); + }; + + const startScan = async () => { + scanController.current?.abort(); + const controller = new AbortController(); + scanController.current = controller; + setScanning(true); + setResults([]); + setProgress(null); + setError(''); + + try { + const discovered = await discoverPrinters(subnet, setProgress, controller.signal); + if (!controller.signal.aborted) setResults(discovered); + } catch (reason) { + if (!controller.signal.aborted) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + } finally { + if (scanController.current === controller) { + scanController.current = null; + setScanning(false); + } + } + }; + + const progressRatio = progress && progress.total > 0 ? progress.scanned / progress.total : 0; + const scanLabel = results.length > 0 || progress ? 'Scan again' : 'Scan network'; + + return ( + void startScan(), + }, + { text: 'Close', onPress: close }, + ] + } + > + + Wi-Fi subnet + { + subnetEditedByUser.current = true; + setSubnet(value); + }} + editable={!scanning} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + returnKeyType="done" + showSoftInputOnFocus + selectionColor={P.accent} + cursorColor={P.accent} + accessibilityLabel="Wi-Fi subnet" + placeholder={loadingSubnet ? 'Detecting network…' : '192.168.1.0/24'} + placeholderTextColor={P.dim} + /> + + Helix scans only this private /24 network. Tailscale and internet addresses are not scanned. + + + {progress ? ( + + + + + + {scanning ? 'Scanning' : 'Scanned'} {progress.scanned} of {progress.total} addresses + {progress.found > 0 ? ` • ${progress.found} found` : ''} + + + ) : null} + + {error ? ( + + + {error} + + ) : null} + + {!scanning && progress && !error && results.length === 0 ? ( + + + No printers found + + Make sure the phone and printer are on the same Wi-Fi, then check the subnet and scan again. + + + ) : null} + + {results.map((printer) => { + const alreadyAdded = existingHosts.has(printer.ip); + return ( + + + + + + + + {printer.name} + + {printer.moonrakerUrl} + + + + + {printer.machineType || printerProfile(printer.kind).label} + {printer.serial ? ` • ${printer.serial}` : ''} + + + + + {printer.cameraUrl + ? `Print camera: ${printer.cameraName || 'detected'}` + : 'Print camera was not reported'} + + + + [ + styles.addButton, + { backgroundColor: alreadyAdded ? P.surfaceAlt : P.accentFill }, + pressed && !alreadyAdded && { opacity: 0.78 }, + ]} + disabled={alreadyAdded} + onPress={() => onSelect(printer)} + > + + + {alreadyAdded ? 'Already added' : 'Review & add'} + + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + content: { width: '100%', marginTop: 22, gap: 10 }, + label: { color: P.text, fontSize: 13, fontWeight: '700' }, + input: { + height: 48, + borderRadius: 10, + borderWidth: 1, + borderColor: P.border, + backgroundColor: P.surface, + color: P.text, + paddingHorizontal: 14, + fontSize: 16, + }, + hint: { color: P.dim, fontSize: 12, lineHeight: 18 }, + progressBlock: { gap: 7, marginTop: 4 }, + progressTrack: { height: 6, borderRadius: 3, backgroundColor: P.surfaceAlt, overflow: 'hidden' }, + progressFill: { height: '100%', borderRadius: 3 }, + progressText: { color: P.dim, fontSize: 12 }, + errorRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + padding: 12, + borderRadius: 10, + backgroundColor: alpha(P.danger, 0.12), + }, + errorText: { flex: 1, color: P.danger, fontSize: 13, lineHeight: 18 }, + emptyState: { alignItems: 'center', gap: 7, paddingVertical: 18 }, + emptyTitle: { color: P.text, fontSize: 15, fontWeight: '700' }, + emptyText: { color: P.dim, fontSize: 12, lineHeight: 18, textAlign: 'center' }, + resultCard: { + marginTop: 4, + padding: 14, + borderRadius: 12, + borderWidth: 1, + borderColor: P.border, + backgroundColor: P.surface, + gap: 12, + }, + resultHeading: { flexDirection: 'row', alignItems: 'center', gap: 11 }, + printerIcon: { + width: 40, + height: 40, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + resultIdentity: { flex: 1, gap: 2 }, + resultName: { color: P.text, fontSize: 15, fontWeight: '700' }, + resultAddress: { color: P.dim, fontSize: 12 }, + details: { gap: 5 }, + detailText: { color: P.dim, fontSize: 12, lineHeight: 17 }, + cameraRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + addButton: { + height: 44, + borderRadius: 9, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 7, + }, + addButtonText: { fontSize: 14, fontWeight: '800' }, +}); diff --git a/components/settings/PrinterEditorModal.tsx b/components/settings/PrinterEditorModal.tsx index 880d880..e87effc 100644 --- a/components/settings/PrinterEditorModal.tsx +++ b/components/settings/PrinterEditorModal.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { + ActivityIndicator, KeyboardAvoidingView, Modal, Platform, @@ -13,11 +14,15 @@ import { } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import type { ConnectionMode, PrinterEntry } from '../../hooks/useSettings'; +import type { PrinterKind } from '../../services/printerProfiles'; import { + normalizeBaseUrl, normalizeMoonrakerUrl, validatePrinterConnectionTarget, } from '../../services/moonraker'; import { t } from '../../services/i18n'; +import { bambuConnectionFailureMessage } from '../../services/bambuConnection'; +import { BambuError, probeBambuStatus } from '../../services/bambuMqtt'; import { alpha } from '../dashboard/shared'; import PrinterIcon from '../PrinterIcon'; import TailscaleIcon from '../TailscaleIcon'; @@ -33,6 +38,43 @@ const CONNECTION_MODES: { { value: 'tailscale', label: 'Tailscale only', icon: 'vpn' }, ]; +// Discovery sets this automatically, but a printer typed in by hand has nothing +// to detect from — and the AD5X's Wi-Fi drops often enough that discovery +// regularly misses it, so picking the model manually has to be possible. +const PRINTER_KINDS: { + value: PrinterKind; + label: string; + hint: string; + icon: React.ComponentProps['name']; +}[] = [ + { + value: 'snapmaker-u1', + label: 'Snapmaker U1', + hint: '4 toolheads, ACE filament system', + icon: 'printer-3d-nozzle', + }, + { + value: 'flashforge-ad5x', + label: 'FlashForge AD5X', + hint: 'Klipper mod, 4-slot material station', + icon: 'printer-3d', + }, + { + value: 'bambu-lan', + label: 'Bambu Lab', + // The LAN protocol is the same across the range — A1, P1, X1, H2D — so + // there is no reason to list models and get the list wrong. + hint: 'Any Bambu in LAN Only Mode', + icon: 'printer-3d', + }, + { + value: 'generic-klipper', + label: 'Other Klipper printer', + hint: 'Anything running Moonraker', + icon: 'cog-outline', + }, +]; + export default function PrinterEditorModal({ printer, mode = 'edit', @@ -49,7 +91,16 @@ export default function PrinterEditorModal({ const [tailscaleUrl, setTailscaleUrl] = useState(''); const [cameraUrl, setCameraUrl] = useState(''); const [connectionMode, setConnectionMode] = useState('lan'); + const [kind, setKind] = useState('snapmaker-u1'); + const [kindOpen, setKindOpen] = useState(false); + const [serialNumber, setSerialNumber] = useState(''); + const [checkCode, setCheckCode] = useState(''); const [saving, setSaving] = useState(false); + const [testingConnection, setTestingConnection] = useState(false); + const [connectionTest, setConnectionTest] = useState<{ + tone: 'pass' | 'fail'; + message: string; + } | null>(null); const [validationMessage, setValidationMessage] = useState(null); useEffect(() => { @@ -59,16 +110,77 @@ export default function PrinterEditorModal({ setTailscaleUrl(printer.tailscaleUrl); setCameraUrl(printer.cameraUrl); setConnectionMode(printer.connectionMode); + setKind(printer.kind); + setSerialNumber(printer.serialNumber ?? ''); + setCheckCode(printer.checkCode ?? ''); + setConnectionTest(null); setValidationMessage(null); + setKindOpen(false); }, [printer]); + useEffect(() => { + setConnectionTest(null); + }, [url, serialNumber, checkCode, kind]); + if (!printer) return null; + const selectedKind = PRINTER_KINDS.find((option) => option.value === kind) ?? PRINTER_KINDS[0]; + const isAd5x = kind === 'flashforge-ad5x'; + // Bambu needs the same two fields, but as credentials rather than extras: + // without them there is no connection at all, not just no filament data. + const isBambu = kind === 'bambu-lan'; + const wantsCredentials = isAd5x || isBambu; + + const testBambuConnection = async () => { + const normalizedUrl = normalizeBaseUrl(url); + let host = ''; + try { + host = new URL(normalizedUrl).hostname; + } catch { + // Validation below presents the same actionable message as a blank IP. + } + if (!host || !serialNumber.trim() || !checkCode.trim()) { + setConnectionTest({ + tone: 'fail', + message: t('Enter the printer IP, serial number, and LAN access code.'), + }); + return; + } + + setTestingConnection(true); + setConnectionTest(null); + setValidationMessage(null); + try { + await probeBambuStatus({ + host, + serial: serialNumber.trim(), + accessCode: checkCode.trim(), + }); + setConnectionTest({ + tone: 'pass', + message: t('Connected — the printer returned its live status.'), + }); + } catch (error: unknown) { + const reason = error instanceof BambuError ? error.reason : 'unknown'; + const fallback = error instanceof Error ? error.message : String(error); + setConnectionTest({ + tone: 'fail', + message: t(bambuConnectionFailureMessage(reason, fallback)), + }); + } finally { + setTestingConnection(false); + } + }; + const save = async () => { - const normalizedUrl = normalizeMoonrakerUrl(url); - const normalizedTailscaleUrl = normalizeMoonrakerUrl(tailscaleUrl); + // Bambu speaks MQTT on 8883, so tacking Moonraker's 7125 onto its address + // would be a lie the connection screen then shows back to the user. + const normalizedUrl = isBambu ? normalizeBaseUrl(url) : normalizeMoonrakerUrl(url); + const normalizedTailscaleUrl = isBambu ? '' : normalizeMoonrakerUrl(tailscaleUrl); + + const effectiveConnectionMode: ConnectionMode = isBambu ? 'lan' : connectionMode; const connectionError = validatePrinterConnectionTarget( - connectionMode, + effectiveConnectionMode, normalizedUrl, normalizedTailscaleUrl ); @@ -81,16 +193,36 @@ export default function PrinterEditorModal({ return; } + // Unlike the AD5X, a Bambu cannot connect at all without these: the serial + // is the MQTT topic and the certificate's identity, and the access code is + // the password. Saving without them would produce a printer that silently + // never connects. + if (isBambu && (!serialNumber.trim() || !checkCode.trim())) { + setValidationMessage( + t('Bambu printers need both the serial number and the LAN access code.') + ); + return; + } + + const defaultName = isAd5x ? 'FlashForge AD5X' : isBambu ? 'Bambu Lab' : 'Snapmaker U1'; + setSaving(true); setValidationMessage(null); try { const saved = await onSave({ ...printer, - name: name.trim() || 'Snapmaker U1', + name: name.trim() || defaultName, url: normalizedUrl, tailscaleUrl: normalizedTailscaleUrl, - cameraUrl: cameraUrl.trim() || '/webcam/webrtc', - connectionMode, + // Bambu's camera is a per-session loopback URL the transport creates, + // so there is no path to store here. + cameraUrl: isBambu + ? '' + : cameraUrl.trim() || (isAd5x ? '/webcam/?action=stream' : '/webcam/webrtc'), + connectionMode: effectiveConnectionMode, + kind, + serialNumber: wantsCredentials ? serialNumber.trim() : '', + checkCode: wantsCredentials ? checkCode.trim() : '', }); if (saved) onClose(); } catch (error) { @@ -132,53 +264,199 @@ export default function PrinterEditorModal({ contentContainerStyle={styles.modalContent} keyboardShouldPersistTaps="handled" > + + {t('Printer model')} + setKindOpen((value) => !value)} + accessibilityRole="button" + accessibilityState={{ expanded: kindOpen }} + > + + + {t(selectedKind.label)} + + + + {kindOpen ? ( + + {PRINTER_KINDS.map((option) => { + const active = option.value === kind; + return ( + { + setKind(option.value); + setKindOpen(false); + }} + accessibilityRole="radio" + accessibilityState={{ selected: active }} + accessibilityLabel={option.label} + > + + + + {t(option.label)} + + {t(option.hint)} + + + + ); + })} + + ) : null} + - - - - {t('Connection mode')} - - - - {t( - 'LAN only never uses Tailscale. Tailscale only never falls back to Wi-Fi. Auto tries LAN, then Tailscale.' - )} - + {/* Bambu's LAN MQTT protocol has no Helix Tailscale transport. + Every Moonraker printer, including the AD5X, supports all + three connection modes. */} + {!isBambu ? ( + + ) : null} + {/* AD5X camera routing follows its LAN/Tailscale host automatically. */} + {!wantsCredentials ? ( + + ) : null} + {wantsCredentials ? ( + <> + + + + {isBambu + ? t( + 'Both are on the printer under Settings → Network, which must be in LAN Only Mode. The access code grants full control of the printer.' + ) + : t( + 'Needed only to read the filament slots. Both are on the printer under Settings → Network, which must have Network mode switched on.' + )} + + {isBambu ? ( + <> + void testBambuConnection()} + disabled={testingConnection || saving} + accessibilityRole="button" + accessibilityLabel={t('Test connection')} + > + {testingConnection ? ( + + ) : ( + + )} + + {testingConnection ? t('Testing connection...') : t('Test connection')} + + + {connectionTest ? ( + + + + {connectionTest.message} + + + ) : null} + + ) : null} + + ) : null} + {!isBambu ? ( + <> + + {t('Connection mode')} + + + + {t( + 'LAN only never uses Tailscale. Tailscale only never falls back to Wi-Fi. Auto tries LAN, then Tailscale.' + )} + + + ) : null} @@ -210,6 +488,7 @@ export default function PrinterEditorModal({ ); } +// Compact chips: connection mode is a short, fixed set that fits one row. function ConnectionModeSelector({ value, onChange, @@ -332,6 +611,40 @@ const styles = StyleSheet.create({ paddingVertical: 10, fontSize: 14, }, + // Inline expanding picker, not a Modal popover: PrinterEditorModal is already + // a Modal, and nesting a second Modal flickers/jumps on Android. + kindTrigger: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: colors.card, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 14, + paddingVertical: 12, + }, + kindTriggerText: { flex: 1, color: colors.text, fontSize: 14, fontWeight: '600' }, + kindList: { gap: 6, marginTop: 6 }, + kindRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 10, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: alpha(colors.text, 0.03), + }, + kindRowOn: { + borderColor: colors.primary, + backgroundColor: alpha(colors.primary, 0.1), + }, + kindLabels: { flex: 1, gap: 2 }, + kindName: { color: colors.text, fontSize: 13, fontWeight: '700' }, + kindNameOn: { color: colors.primary }, + kindHint: { color: colors.subtext, fontSize: 11 }, modeRow: { flexDirection: 'row', gap: 8 }, modeBtn: { flex: 1, @@ -348,6 +661,20 @@ const styles = StyleSheet.create({ modeText: { color: colors.text, fontSize: 12, fontWeight: '700' }, modeTextOn: { color: '#fff' }, note: { color: colors.subtext, fontSize: 11, fontStyle: 'italic' }, + testConnectionAction: { + minHeight: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: alpha(colors.primary, 0.45), + backgroundColor: alpha(colors.primary, 0.08), + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 7, + }, + testConnectionText: { color: colors.primary, fontSize: 13, fontWeight: '800' }, + connectionTestResult: { flexDirection: 'row', alignItems: 'flex-start', gap: 7 }, + connectionTestText: { flex: 1, fontSize: 12, fontWeight: '700', lineHeight: 17 }, modalActions: { width: '100%', flexDirection: 'row', gap: 8 }, secondaryAction: { flex: 1, diff --git a/components/slicer/parts.tsx b/components/slicer/parts.tsx index b6f3aeb..265c320 100644 --- a/components/slicer/parts.tsx +++ b/components/slicer/parts.tsx @@ -15,6 +15,7 @@ import { alpha, CameraMock, COCKPIT as P, Dot, type IconName } from '../dashboar import { t } from '../../services/i18n'; import type { FilamentSlotDisplay } from '../FilamentSlotsEditor'; import PrinterIcon from '../PrinterIcon'; +import { useSettings } from '../../hooks/useSettings'; export type Tone = 'good' | 'bad' | 'muted' | 'warn'; @@ -191,10 +192,16 @@ export function StatRow({ export function ToolRail({ slots, onEdit, + externalSpool = false, }: { slots: FilamentSlotDisplay[]; onEdit: (index: number) => void; + externalSpool?: boolean; }) { + const { settings } = useSettings(); + const activePrinter = settings.printers.find((printer) => printer.id === settings.activePrinterId); + const bambu = activePrinter?.kind === 'bambu-lan'; + return ( {t('FILAMENT')} @@ -216,7 +223,13 @@ export function ToolRail({ ]} /> - T{slot.index + 1} + + {externalSpool + ? t('External Spool') + : bambu + ? `${t('Lane')} ${slot.index + 1}` + : `T${slot.index + 1}`} + {empty ? ( {t('Empty')} ) : ( diff --git a/goldie/README-android.md b/goldie/README-android.md new file mode 100644 index 0000000..4ae73a5 --- /dev/null +++ b/goldie/README-android.md @@ -0,0 +1,152 @@ +# Play Store screenshots (Android + goldie framing) + +goldie is an iOS tool: its `capture` stage drives an iOS simulator through +argent, its only device spec is `iphone-6.9`, its only bezels are iPhone 17 +Pro, and `verify` checks Apple's spec table. None of that applies to Android. + +What *is* reusable is goldie's back half. `frame` only reads raw PNGs listed in +`out/raw//manifest.json` and composites them onto a captioned tile, so +Android captures can be fed straight into it. + +**Only run these three stages.** `capture`, `preview`, `doctor` and `verify` +assume an iOS build and will fail or mislead. + +## 1. Boot the emulator and install a release build + +A debug build paints LogBox banners into the captures and needs Metro running, +so it makes unusable marketing assets. Use the release APK. + +```bash +export PATH=/opt/homebrew/share/android-commandlinetools/platform-tools:$PATH +ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools \ + /opt/homebrew/share/android-commandlinetools/emulator/emulator \ + -avd FoldFast-Pixel-8 -no-snapshot-load -no-boot-anim & +adb wait-for-device +adb install -r -d android/app/build/outputs/apk/release/app-release.apk +``` + +## 2. Give the emulator a route to the printer + +An empty, offline app is not a screenshot worth shipping — the dashboard shows +a white error card and zeroed temperatures. But the emulator has **no route to +the LAN** (`nc: connect: No route to host`), so it cannot reach the printer +directly. + +Bridge it with a host-side TCP proxy plus `adb reverse`: + +- `scripts/printer-proxy.py` listens on `127.0.0.1:7125` → printer `:7125` + (Moonraker) and `127.0.0.1:8081` → printer `:80` (web UI, camera, screen). +- **Run it with `/usr/bin/python3`.** macOS Local Network Privacy blocks + Homebrew `node` from the LAN (`EHOSTUNREACH`) while Apple-signed binaries are + allowed. This is not the Claude sandbox; disabling it does not help. + +```bash +/usr/bin/python3 goldie/scripts/printer-proxy.py & # edit TARGET first +adb root # needed to bind :80 inside the emulator +adb reverse tcp:7125 tcp:7125 +adb reverse tcp:8081 tcp:8081 +adb reverse tcp:80 tcp:8081 # the camera and mirrored screen live on :80 +``` + +Then point the app at the tunnel: printer URL `http://127.0.0.1:7125`, +connection mode **LAN only**. + +### The camera needs to be a snapshot, not WebRTC + +The printer advertises `/webcam/webrtc`, and WebRTC needs UDP that an +`adb reverse` tunnel cannot carry — the card renders a dead player with +"Connection lost". `CameraFeed` treats any URL matching `/snapshot/i` as a +polled still image over plain HTTP, which the tunnel *does* carry. + +The printer editor did not persist this field reliably, so write it directly +(requires `adb root`): + +```bash +DB=/data/data/org.crabcore.u1control/databases/RKStorage +adb shell "sqlite3 $DB \"select value from catalystLocalStorage\"" > settings.json +# set .cameraUrl and .printers[active].cameraUrl to +# http://127.0.0.1:8081/webcam/snapshot.jpg +adb push settings.new.json /data/local/tmp/settings.new.json +adb shell am force-stop org.crabcore.u1control +adb shell "sqlite3 $DB \"update catalystLocalStorage \ + set value = CAST(readfile('/data/local/tmp/settings.new.json') AS TEXT) \ + where key='u1control.settings.v1'\"" +``` + +`CAST(... AS TEXT)` matters: a bare `readfile()` stores a BLOB and the app +falls back to first-run setup with every printer gone. + +## 3. Clean the status bar + +```bash +adb shell settings put global sysui_demo_allowed 1 +for c in "command enter" "command clock -e hhmm 0930" \ + "command battery -e level 100 -e plugged false" \ + "command network -e wifi show -e level 4" \ + "command network -e mobile hide" \ + "command notifications -e visible false"; do + adb shell am broadcast -a com.android.systemui.demo -e $c +done +``` + +## 4. Capture the scenes + +```bash +adb exec-out screencap -p > goldie/captures/.png +``` + +One file per scene id in `goldie.config.ts`. Keep the untouched captures in +`goldie/captures/`; `scripts/prep-raw.sh` crops each one to 1080×2265 (dropping +the light 3-button nav bar, which clashes badly with Helix's dark UI) and +stages it in `out/raw/iphone-6.9/`. That directory is named `iphone-6.9` +because it is goldie's only device key — it does not mean the captures came +from an iPhone. + +The Slice tab needs a model loaded or it screenshots as "NO MODEL". Generate +one with `scripts/gen-gear.py`, push it to `/sdcard/Download/`, and open it +through the app's own picker. Model coordinates map to bed coordinates, so +centre the mesh on the bed (the U1 plate is 220 mm) or it lands in a corner. + +## 5. Prep, frame, resize + +`frame` refuses to run without `out/raw//manifest.json`, which normally +comes from `capture`. `prep-raw.sh` crops the captures and writes that manifest +(it only needs sceneId/file per scene). + +```bash +goldie/scripts/prep-raw.sh # crop + manifest +GOLDIE_CONFIG=$PWD/goldie/goldie.config.ts npx -y goldie@0 frame --screen-only +GOLDIE_CONFIG=$PWD/goldie/goldie.config.ts npx -y goldie@0 manifest # for the studio +goldie/scripts/play-resize.sh # -> Play sizes +GOLDIE_CONFIG=$PWD/goldie/goldie.config.ts npx -y goldie@0 studio --no-open +``` + +`frame` needs `ffmpeg` on the PATH for its final flatten (`brew install +ffmpeg`). `--screen-only` suppresses the iPhone bezel; the config sets +`theme.screenOnly` too, so the flag is belt-and-braces. + +`goldie frame` renders at 1320×2868 — Apple's 6.9" size, a **2.17:1** ratio. +Google Play caps phone screenshots at 2:1, so those tiles would be rejected as +they come out of goldie. `play-resize.sh` pads each one to exactly 9:16 +(1620×2880) using `fillborders=mode=smear`, which replicates the edge pixels +so the background gradient extends instead of leaving solid bars. + +**Upload from `goldie/out/play/phone/`, not from `out/screenshots/`.** The +`6.9`/`en-US` path segments are goldie's iOS naming and mean nothing to Play. + +### The studio silently overrides the config + +Running `goldie studio` writes `goldie/goldie.design.json`, and that file +**wins over `goldie.config.ts`** on every later `frame`. It appeared here with +a grey gradient and `template: "magazine"` — whose `minimal` layout draws no +copy at all — so the tiles came out grey and captionless with the config +untouched and looking correct. + +If a re-frame comes out not matching the config, delete `goldie.design.json` +and frame again. Keep it only if you deliberately restyled in the studio and +want that look; the honest fix is to copy the values you liked back into +`theme.*` in the config and delete the file. + +Play Store allows a maximum of **8** phone screenshots, which is what the +config produces. `captures/timelapse.png` is a spare (the Timelapse tab, with +real clip thumbnails) if you want to swap one out. diff --git a/goldie/captures/filament.png b/goldie/captures/filament.png new file mode 100644 index 0000000..559d01d Binary files /dev/null and b/goldie/captures/filament.png differ diff --git a/goldie/captures/files.png b/goldie/captures/files.png new file mode 100644 index 0000000..235a7d5 Binary files /dev/null and b/goldie/captures/files.png differ diff --git a/goldie/captures/gcode.png b/goldie/captures/gcode.png new file mode 100644 index 0000000..c1bb9ec Binary files /dev/null and b/goldie/captures/gcode.png differ diff --git a/goldie/captures/history.png b/goldie/captures/history.png new file mode 100644 index 0000000..6865cdb Binary files /dev/null and b/goldie/captures/history.png differ diff --git a/goldie/captures/home.png b/goldie/captures/home.png new file mode 100644 index 0000000..2001882 Binary files /dev/null and b/goldie/captures/home.png differ diff --git a/goldie/captures/mesh.png b/goldie/captures/mesh.png new file mode 100644 index 0000000..bf394a3 Binary files /dev/null and b/goldie/captures/mesh.png differ diff --git a/goldie/captures/model.png b/goldie/captures/model.png new file mode 100644 index 0000000..0948394 Binary files /dev/null and b/goldie/captures/model.png differ diff --git a/goldie/captures/printsheet.png b/goldie/captures/printsheet.png new file mode 100644 index 0000000..50502c9 Binary files /dev/null and b/goldie/captures/printsheet.png differ diff --git a/goldie/captures/timelapse.png b/goldie/captures/timelapse.png new file mode 100644 index 0000000..2f37a68 Binary files /dev/null and b/goldie/captures/timelapse.png differ diff --git a/goldie/goldie.config.ts b/goldie/goldie.config.ts new file mode 100644 index 0000000..5b9535c --- /dev/null +++ b/goldie/goldie.config.ts @@ -0,0 +1,136 @@ +/** + * Play Store assets for Helix. + * + * goldie's capture stage is iOS-only (it drives an iOS simulator through + * argent), so it is NOT used here. The raw screenshots in out/raw/iphone-6.9/ + * are captured from an Android emulator with `adb exec-out screencap` — see + * goldie/README-android.md — and only goldie's framing stage runs: + * + * GOLDIE_CONFIG=/goldie/goldie.config.ts npx -y goldie@0 frame --screen-only + * GOLDIE_CONFIG=... npx -y goldie@0 manifest + * GOLDIE_CONFIG=... npx -y goldie@0 studio --no-open + * + * Do NOT run `capture`, `preview`, `doctor` or `verify`: they assume an iOS + * build and Apple's spec table. `appPath` below is a placeholder that only + * those stages read. + * + * The bezel is off (`screenOnly`) because the only bundled frames are iPhone + * 17 Pro and these are Pixel captures. Play Store accepts bare screenshots. + */ + +const APP_ROOT = "/Users/crabman/Documents/Projects/Helix-play-store"; + +const config = { + appRoot: APP_ROOT, + appPath: "unused-on-android.app", + bundleId: "org.crabcore.u1control", + + devices: ["iphone-6.9"], + locales: ["en-US"], + appearance: "dark", + + // Required by the config schema even though `theme.screenOnly` means no + // bezel is drawn — the bundled frames are all iPhone, these are Pixel shots. + frame: { variant: "17-pro-silver" }, + + theme: { + // Helix is a dark app; a deep cool gradient lets the screens sit on the + // tile without a bright halo around them. + background: "linear-gradient(165deg, #0B1220 0%, #111C2E 55%, #16233A 100%)", + headlineColor: "#F2F6FF", + subheadColor: "#93A4BF", + fontFamily: '"DM Sans", -apple-system, system-ui, sans-serif', + copyHeightRatio: 0.24, + deviceWidthRatio: 0.86, + // "uniform", not "editorial": editorial opens with a panorama that spans + // two tiles, which reads as one wide image only in Apple's screenshot + // strip. Play Store shows tiles individually, so every scene gets its own. + template: "uniform", + layout: "classic", + screenOnly: true, + }, + + store: { + name: "Helix", + subtitle: { "en-US": "Control and slice on your phone" }, + developer: "crabcore", + category: "Tools", + rating: 4.8, + ratingCount: "—", + ageRating: "3+", + price: "Free", + description: { + "en-US": + "Helix drives your 3D printer from your phone. Live camera, temperatures, toolheads and the printer's own screen, mirrored the moment you open the app.\n\nIt also slices. A full slicing engine runs on the device, so a model goes from file to toolpaths to printing without a laptop or a cloud round-trip.\n\nWorks with Snapmaker U1, Klipper/Moonraker printers, Bambu Lab in LAN mode, and FlashForge AD5X.", + }, + }, + + scenes: [ + { + kind: "screenshot", + id: "home", + headline: { "en-US": "Your printer, live" }, + subhead: { + "en-US": "Camera, temperatures and every toolhead the second you open the app.", + }, + }, + { + kind: "screenshot", + id: "gcode", + headline: { "en-US": "The slicer is on your phone" }, + subhead: { + "en-US": "A real slicing engine runs on the device. No laptop, no cloud.", + }, + }, + { + kind: "screenshot", + id: "files", + headline: { "en-US": "Every print, one library" }, + subhead: { + "en-US": "Real thumbnails, search and history for everything on the printer.", + }, + }, + { + kind: "screenshot", + id: "printsheet", + headline: { "en-US": "Reprint in one press" }, + subhead: { + "en-US": "Time, filament and which lane feeds it — checked before it starts.", + }, + }, + { + kind: "screenshot", + id: "history", + headline: { "en-US": "Every gram, every hour" }, + subhead: { + "en-US": "Filament use, job history and lifetime totals for the machine.", + }, + }, + { + kind: "screenshot", + id: "model", + headline: { "en-US": "Set it up on the bed" }, + subhead: { + "en-US": "Move, scale, rotate and orient before a single layer is sliced.", + }, + }, + { + kind: "screenshot", + id: "mesh", + headline: { "en-US": "See the bed, not the guesswork" }, + subhead: { + "en-US": "Your live bed mesh in 3D, with the numbers that actually matter.", + }, + }, + { + kind: "screenshot", + id: "filament", + headline: { "en-US": "Tell it what's loaded" }, + subhead: { + "en-US": "Colour, material and brand per toolhead, so previews match reality.", + }, + }, + ], +}; + +export default config; diff --git a/goldie/scripts/gen-gear.py b/goldie/scripts/gen-gear.py new file mode 100755 index 0000000..3182716 --- /dev/null +++ b/goldie/scripts/gen-gear.py @@ -0,0 +1,61 @@ +import math, struct + +TEETH, MOD, THICK = 22, 2.0, 10.0 +R_PITCH = MOD * TEETH / 2.0 +R_OUT, R_ROOT = R_PITCH + MOD, R_PITCH - 1.25 * MOD +R_BORE = 6.0 +CX = CY = 110.0 +SEG = 6 # points per tooth flank group + +def gear_profile(): + pts = [] + step = 2 * math.pi / TEETH + for i in range(TEETH): + a0 = i * step + # root -> flank up -> tip -> flank down, as fractions of one tooth pitch + keys = [(0.00, R_ROOT), (0.12, R_ROOT), (0.26, R_OUT), + (0.50, R_OUT), (0.64, R_ROOT), (0.88, R_ROOT)] + for f, r in keys: + a = a0 + f * step + pts.append((r * math.cos(a), r * math.sin(a))) + return pts + +def tris(): + prof = gear_profile() + n = len(prof) + bore = [(R_BORE * math.cos(2 * math.pi * i / 48), R_BORE * math.sin(2 * math.pi * i / 48)) + for i in range(48)] + m = len(bore) + out = [] + z0, z1 = 0.0, THICK + # outer wall + for i in range(n): + a, b = prof[i], prof[(i + 1) % n] + out += [((a[0],a[1],z0),(b[0],b[1],z0),(b[0],b[1],z1)), + ((a[0],a[1],z0),(b[0],b[1],z1),(a[0],a[1],z1))] + # bore wall (reversed winding) + for i in range(m): + a, b = bore[i], bore[(i + 1) % m] + out += [((a[0],a[1],z0),(b[0],b[1],z1),(b[0],b[1],z0)), + ((a[0],a[1],z0),(a[0],a[1],z1),(b[0],b[1],z1))] + # top and bottom faces: fan between bore ring and outer profile + for i in range(n): + a, b = prof[i], prof[(i + 1) % n] + c, d = bore[i * m // n], bore[((i + 1) * m // n) % m] + out += [((a[0],a[1],z1),(b[0],b[1],z1),(d[0],d[1],z1)), + ((a[0],a[1],z1),(d[0],d[1],z1),(c[0],c[1],z1)), + ((a[0],a[1],z0),(d[0],d[1],z0),(b[0],b[1],z0)), + ((a[0],a[1],z0),(c[0],c[1],z0),(d[0],d[1],z0))] + return out + +t = tris() +path = 'Reduction Gear 22T.stl' # push to /sdcard/Download/ on the emulator +with open(path, 'wb') as f: + f.write(b'\0' * 80) + f.write(struct.pack(' $DST" diff --git a/goldie/scripts/prep-raw.sh b/goldie/scripts/prep-raw.sh new file mode 100755 index 0000000..7d5066f --- /dev/null +++ b/goldie/scripts/prep-raw.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Crop the Android system nav bar off each raw capture and stage it where +# `goldie frame` expects it. +# +# The Pixel 8 emulator is 1080x2400; the light 3-button nav bar occupies the +# bottom ~135 px and clashes badly with Helix's dark UI in a marketing tile. +# Cropping it here (rather than capturing in immersive mode) keeps the original +# captures in goldie/captures/ intact. +set -e +DIR="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$DIR/captures" +DST="$DIR/out/raw/iphone-6.9" +mkdir -p "$DST" +for f in "$SRC"/*.png; do + n=$(basename "$f") + ffmpeg -y -loglevel error -i "$f" -vf "crop=1080:2265:0:0" "$DST/$n" + echo " prep $n" +done +/usr/bin/python3 "$DIR/scripts/write-manifest.py" diff --git a/goldie/scripts/printer-proxy.py b/goldie/scripts/printer-proxy.py new file mode 100755 index 0000000..b3e1eb3 --- /dev/null +++ b/goldie/scripts/printer-proxy.py @@ -0,0 +1,39 @@ +import socket, threading +TARGET = '192.168.1.17' +MAP = [(7125, 7125), (8081, 80)] # (local_port, printer_port) + +def pump(a, b): + try: + while True: + data = a.recv(65536) + if not data: break + b.sendall(data) + except Exception: pass + finally: + for s in (a, b): + try: s.shutdown(socket.SHUT_RDWR) + except Exception: pass + try: s.close() + except Exception: pass + +def handle(client, remote): + try: + up = socket.create_connection((TARGET, remote), timeout=10) + up.settimeout(None); client.settimeout(None) + threading.Thread(target=pump, args=(client, up), daemon=True).start() + threading.Thread(target=pump, args=(up, client), daemon=True).start() + except Exception as e: + print(f'up:{remote} err {e}', flush=True) + client.close() + +def serve(local, remote): + srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(('127.0.0.1', local)); srv.listen(64) + print(f'proxy 127.0.0.1:{local} -> {TARGET}:{remote}', flush=True) + while True: + c, _ = srv.accept() + threading.Thread(target=handle, args=(c, remote), daemon=True).start() + +for l, r in MAP: + threading.Thread(target=serve, args=(l, r), daemon=True).start() +threading.Event().wait() diff --git a/goldie/scripts/write-manifest.py b/goldie/scripts/write-manifest.py new file mode 100755 index 0000000..38ba3e6 --- /dev/null +++ b/goldie/scripts/write-manifest.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Write the capture manifest `goldie frame` needs. + +goldie normally produces this in its `capture` stage, which is iOS-only. The +Android captures are dropped into out/raw/iphone-6.9/ by hand, so the manifest +is written by hand too. `frame` only reads sceneId and file from it. +""" +import json, os, sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RAW = os.path.join(ROOT, 'out', 'raw', 'iphone-6.9') +SCENES = ['home', 'gcode', 'files', 'printsheet', 'history', + 'model', 'mesh', 'filament'] # keep in config order; Play caps at 8 + +missing = [s for s in SCENES if not os.path.exists(os.path.join(RAW, s + '.png'))] +if missing: + sys.exit('missing captures: ' + ', '.join(missing)) + +manifest = {'screenshots': [{'sceneId': s, 'file': os.path.join(RAW, s + '.png')} + for s in SCENES]} +with open(os.path.join(RAW, 'manifest.json'), 'w') as f: + json.dump(manifest, f, indent=2) +print('wrote', os.path.join(RAW, 'manifest.json'), 'with', len(SCENES), 'scenes') diff --git a/hooks/useACE.ts b/hooks/useACE.ts index 548b7ff..f7c7b80 100644 --- a/hooks/useACE.ts +++ b/hooks/useACE.ts @@ -15,41 +15,15 @@ export const ACE_MACROS = { switchAce: (ace: number) => `ACE_SWITCH TARGET=${ace} AUTOLOAD=1`, }; -export type LaneStatus = 'loaded' | 'empty' | 'busy' | 'drying' | 'unknown'; - -export interface AceLane { - index: number; - status: LaneStatus; - brand?: string; - material?: string; - sku?: string; - colorHex?: string; -} +// The lane/unit model moved to services/aceModel.ts so the FlashForge mapping +// can be tested without React. Re-exported here so existing importers are +// unaffected. +export type { AceLane, AceUnit, HeadSource, LaneStatus } from '../services/aceModel'; +import type { AceLane, AceUnit, HeadSource, LaneStatus } from '../services/aceModel'; +import { useMaterialStation } from './useMaterialStation'; -export interface AceUnit { - index: number; // 1-based - aceIndex: number; // 0-based multiACE device index (macro argument) - connected: boolean; - active: boolean; - protocol?: string; - temp?: number; - humidity?: number; - dryer: { - active: boolean; - targetTemp?: number; - remainingMin?: number; - }; - lanes: AceLane[]; -} - -// head_source entry from multiACE: which ACE/slot currently feeds a head. -export interface HeadSource { - aceIndex: number; - slot: number; - material?: string; - colorHex?: string; - brand?: string; -} +/** The AD5X is single-head, so there is nothing to attribute to a toolhead. */ +const NO_HEAD_SOURCES: (HeadSource | null)[] = [null, null, null, null]; function parseColor(c: any): string | undefined { if (Array.isArray(c) && c.length >= 3) { @@ -183,6 +157,26 @@ export function useACE() { const aceMacros = useMemo(() => macros.filter((m) => /ace/i.test(m)), [macros]); + // A FlashForge material station fills the same lane UI, but its state comes + // from the printer's own REST API rather than Klipper. The two never coexist: + // a machine has multiACE or an IFS, so whichever reports hardware wins. + const materialStation = useMaterialStation(); + if (materialStation.detected) { + return { + units: materialStation.units, + aceMacros, + sendGcode, + hardwareDetected: true, + deviceCount: materialStation.units.length, + mode: 'material-station', + activeAceIndex: 0, + swapInProgress: false, + headSources: NO_HEAD_SOURCES, + materialStationError: materialStation.error, + refreshMaterialStation: materialStation.refresh, + }; + } + return { units, aceMacros, @@ -193,5 +187,7 @@ export function useACE() { activeAceIndex, swapInProgress, headSources, + materialStationError: materialStation.error, + refreshMaterialStation: materialStation.refresh, }; } diff --git a/hooks/useBambu.tsx b/hooks/useBambu.tsx new file mode 100644 index 0000000..d68b8f3 --- /dev/null +++ b/hooks/useBambu.tsx @@ -0,0 +1,271 @@ +// Bambu as a drop-in for MoonrakerProvider. +// +// It fills the same MoonrakerContext, so every consumer — the dashboard, the +// temperature cards, the filament slots, the console — works against a Bambu +// printer without knowing one exists. What arrives as MQTT JSON is reshaped +// into Klipper's vocabulary by services/bambuAdapter.ts. +// +// What Bambu genuinely cannot do is left honestly absent rather than faked: +// there are no macros, no printer objects to enumerate, and `rpc` rejects, +// because Moonraker's JSON-RPC has no counterpart here. File browsing needs +// FTPS and is not wired up yet. +// crabcore + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AppState } from 'react-native'; + +import { bambuStatus } from '../services/bambuAdapter'; +import { + connectBambu, + disconnectBambu, + onBambuCameraStopped, + onBambuMessage, + onBambuState, + pauseBambuPrint, + requestBambuFullState, + resumeBambuPrint, + sendBambuGcode, + setBambuChamberLight, + startBambuCamera, + stopBambuCamera, + stopBambuPrint, + BambuError, +} from '../services/bambuMqtt'; +import { applyBambuReport, type BambuState } from '../services/bambuReport'; +import { + MoonrakerContext, + type ConnectionState, + type ConsoleLine, + type MoonrakerContextValue, +} from './useMoonraker'; +import { useSettings } from './useSettings'; +import type { WebcamInfo } from '../services/moonraker'; + +/** Bambu deltas land about once a second, so no extra batching is needed. */ +const CAMERA_RETRY_MS = 5000; +let lineIdCounter = 0; + +/** Host without scheme or port — the transport dials an IP, not a URL. */ +function bambuHost(url: string): string { + return (url || '') + .trim() + .replace(/^\w+:\/\//, '') + .replace(/[/:].*$/, ''); +} + +export function BambuProvider({ children }: { children: React.ReactNode }) { + const { settings } = useSettings(); + const printer = settings.printers.find((p) => p.id === settings.activePrinterId); + + const host = bambuHost(printer?.url ?? ''); + const serial = (printer?.serialNumber ?? '').trim(); + const accessCode = (printer?.checkCode ?? '').trim(); + + const [connection, setConnection] = useState('disconnected'); + const [status, setStatus] = useState>({}); + const [consoleLines, setConsoleLines] = useState([]); + const [cameraUrl, setCameraUrl] = useState(''); + const [generation, setGeneration] = useState(0); + const [appActive, setAppActive] = useState(AppState.currentState === 'active'); + + const stateRef = useRef({}); + const appStateRef = useRef(AppState.currentState); + + const addLine = useCallback((type: ConsoleLine['type'], text: string) => { + setConsoleLines((prev) => [...prev.slice(-499), { id: ++lineIdCounter, time: Date.now(), type, text }]); + }, []); + + // The MQTT connection and its report stream. + useEffect(() => { + if (!host || !serial || !accessCode) { + setConnection('disconnected'); + return; + } + + let cancelled = false; + setConnection('connecting'); + stateRef.current = {}; + + const messages = onBambuMessage((report) => { + const next = applyBambuReport(stateRef.current, report); + stateRef.current = next.state; + setStatus(bambuStatus(next.state)); + }); + + const states = onBambuState((event) => { + if (cancelled) return; + setConnection(event.state === 'connected' ? 'connected' : 'disconnected'); + // A recovered link only carries deltas, so the held picture has to be + // rebuilt from scratch or it silently keeps showing stale values. + if (event.state === 'connected') void requestBambuFullState().catch(() => {}); + }); + + connectBambu({ host, serial, accessCode }) + .then(() => { + if (cancelled) return; + setConnection('connected'); + return requestBambuFullState(); + }) + .catch((e: unknown) => { + if (cancelled) return; + setConnection('disconnected'); + addLine('error', e instanceof BambuError ? e.message : String(e)); + }); + + return () => { + cancelled = true; + messages.remove(); + states.remove(); + void disconnectBambu(); + }; + }, [host, serial, accessCode, generation, addLine]); + + // The chamber camera is a separate socket with its own failure modes, so it + // gets its own lifecycle rather than riding on the MQTT connection. + useEffect(() => { + if (!host || !serial || !accessCode || !appActive) { + setCameraUrl(''); + return; + } + + let cancelled = false; + let retry: ReturnType | null = null; + + let reported = false; + // A stream that dies leaves the URL pointing at nothing, so treat it like + // a failed open and go round again. + const stopped = onBambuCameraStopped(() => { + if (cancelled) return; + setCameraUrl(''); + if (!retry) retry = setTimeout(open, CAMERA_RETRY_MS); + }); + + const open = () => { + retry = null; + startBambuCamera({ host, serial, accessCode }) + .then((url) => { + if (!cancelled) setCameraUrl(url); + }) + .catch((e: unknown) => { + if (cancelled) return; + // The printer refuses a second viewer while Bambu Studio is watching, + // so this retries rather than giving up — but staying silent about it + // leaves a blank camera pane with nothing to diagnose, so say it once. + if (!reported) { + reported = true; + addLine('error', `Camera: ${e instanceof BambuError ? e.message : String(e)}`); + } + retry = setTimeout(open, CAMERA_RETRY_MS); + }); + }; + open(); + + return () => { + cancelled = true; + stopped.remove(); + if (retry) clearTimeout(retry); + void stopBambuCamera(); + }; + }, [host, serial, accessCode, appActive, addLine]); + + const reconnect = useCallback(() => setGeneration((n) => n + 1), []); + + // Android can freeze both sockets while backgrounded. Always replace the + // MQTT session after a real background -> active transition; asking a + // half-open connection for pushall can succeed locally while no report ever + // comes back. appActive independently tears down/reopens the camera. + useEffect(() => { + const sub = AppState.addEventListener('change', (next) => { + const wasActive = appStateRef.current === 'active'; + appStateRef.current = next; + const isActive = next === 'active'; + setAppActive(isActive); + if (isActive && !wasActive) reconnect(); + }); + return () => sub.remove(); + }, [reconnect]); + + const webcams = useMemo( + () => + cameraUrl + ? [ + { + name: 'Chamber', + enabled: true, + service: 'mjpegstreamer', + stream_url: cameraUrl, + // Stills come from the loopback server's /frame endpoint, which + // serves the newest buffered frame — no second printer connection. + snapshot_url: cameraUrl.replace('/stream/', '/frame/'), + }, + ] + : [], + [cameraUrl] + ); + + const sendGcode = useCallback( + async (script: string): Promise => { + const cmd = script.trim(); + if (!cmd) return false; + addLine('command', cmd); + try { + // The dashboard drives the chamber light with Klipper's SET_LED, which + // Bambu does not implement — it has a dedicated ledctrl command. The + // adapter presents the light as a Klipper LED, so this is where that + // pretence has to be undone. + const led = cmd.match(/^SET_LED\b.*?\b(?:WHITE|RED)=(\d+)/i); + if (led) { + await setBambuChamberLight(Number(led[1]) > 0); + return true; + } + await sendBambuGcode(cmd); + return true; + } catch (e: any) { + addLine('error', `!! ${e?.message ?? e}`); + return false; + } + }, + [addLine] + ); + + /** + * Bambu has no JSON-RPC. The print controls are mapped because the dashboard + * drives them through Moonraker method names; everything else rejects, which + * the callers already treat as "unsupported on this printer". + */ + const rpc = useCallback(async (method: string): Promise => { + if (method === 'printer.print.pause') return pauseBambuPrint(); + if (method === 'printer.print.resume') return resumeBambuPrint(); + if (method === 'printer.print.cancel') return stopBambuPrint(); + throw new Error(`${method} is not available on Bambu printers`); + }, []); + + const clearConsole = useCallback(() => setConsoleLines([]), []); + + const value = useMemo( + () => ({ + connection, + klippyState: connection === 'connected' ? 'ready' : 'disconnected', + activeUrl: host ? `http://${host}` : '', + status, + consoleLines, + // Bambu speaks MQTT, not Klipper, so there is no action:prompt protocol + // to render and nothing to answer. + prompt: null, + answerPrompt: async () => {}, + dismissPrompt: async () => {}, + // Bambu exposes no macros and no object list to enumerate. + macros: [], + objectList: Object.keys(status), + gcodeHelp: {}, + webcams, + sendGcode, + rpc, + reconnect, + clearConsole, + }), + [connection, host, status, consoleLines, webcams, sendGcode, rpc, reconnect, clearConsole] + ); + + return {children}; +} diff --git a/hooks/useDashboardModel.ts b/hooks/useDashboardModel.ts index a5d4cfb..38c0ca1 100644 --- a/hooks/useDashboardModel.ts +++ b/hooks/useDashboardModel.ts @@ -8,6 +8,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useMoonraker } from './useMoonraker'; import { useSettings } from './useSettings'; +import { useMaterialStation } from './useMaterialStation'; +import type { FlashForgeError } from '../services/flashforgeApi'; import { findMachineChamberTemperatureSource } from '../services/chamberTemperature'; import { api, @@ -18,14 +20,37 @@ import { resolveSnapshotUrl, thumbnailUrl, } from '../services/moonraker'; -import { calculatePrintEtas } from '../services/printEta'; +import { + calculatePrintEtas, + fetchLatestM73, + finishClock, + type CapturedM73Estimate, +} from '../services/printEta'; import { formatDuration } from '../components/PrintProgress'; import { filterMacrosForDisplay, getMacroDisplay } from '../services/macroDisplay'; import { + materialStationSlots, resolveFilamentSlots, type FilamentSlotStatus, + unavailableMaterialStationSlots, } from '../services/filamentSlots'; import { t } from '../services/i18n'; +import { + changeBambuFilament, + bambuObjectsForJob, + clearBambuErrors, + setBambuSpeed, + skipBambuObjects as sendBambuSkipObjects, + type BambuPrintableObject, + type BambuSpeedPreset, +} from '../services/bambuMqtt'; +import type { BambuHmsFault } from '../services/bambuAdapter'; +import { + buildBambuFanCommand, + buildBambuTemperatureCommand, + type BambuFan, + type BambuHeater, +} from '../services/bambuControls'; export type DashboardState = 'offline' | 'idle' | 'printing' | 'finished' | 'error'; @@ -43,6 +68,18 @@ export interface DashboardTool { loaded: FilamentSlotStatus; /** Whether the values came from the printer or fell back to saved settings. */ source: 'printer' | 'manual'; + /** Bambu's global AMS tray id (254 for the external spool). */ + bambuTrayIndex: number | null; + /** Safe load/unload temperature derived from the tray's filament profile. */ + bambuChangeTemp: number; +} + +export interface DashboardBambu { + speedPreset: BambuSpeedPreset | null; + amsHealth: { unit: number; humidity?: number; temperature?: number }[]; + fans: Partial>; + hmsFaults: BambuHmsFault[]; + printObjects: (BambuPrintableObject & { skipped: boolean })[]; } export interface DashboardTemp { @@ -77,6 +114,12 @@ export interface DashboardActions { emergencyStop: () => void; /** Runs a Klipper macro by name. */ runMacro: (name: string) => void; + setBambuSpeed: (preset: BambuSpeedPreset) => Promise; + changeBambuFilament: (trayIndex: number | null, nozzleTemperature: number) => Promise; + setBambuTemperature: (heater: BambuHeater, target: number) => Promise; + setBambuFan: (fan: BambuFan, percent: number) => Promise; + clearBambuErrors: () => Promise; + skipBambuObjects: (objectIds: number[]) => Promise; /** Panda Breath chamber heater / dryer. The model owns command construction * because the combined `stop` and the START-vs-RUN dry dispatch depend on * feature-detected state (`gcodeHelp`) the component can't see. */ @@ -113,6 +156,8 @@ export interface DashboardModel { errorMessage: string; job: DashboardJob | null; tools: DashboardTool[]; + /** Why an AD5X IFS could not be read; null after a successful poll. */ + materialStationError: FlashForgeError | null; temps: DashboardTemp[]; macros: string[]; camera: { url: string; snapshotUrl?: string } | null; @@ -121,6 +166,7 @@ export interface DashboardModel { lightOn: boolean; toggleLight: (() => void) | undefined; pandaBreath: DashboardPandaBreath; + bambu: DashboardBambu | null; actions: DashboardActions; } @@ -129,17 +175,25 @@ const EXTRUDERS = ['extruder', 'extruder1', 'extruder2', 'extruder3']; // the sparkline degenerating into a flat line at steady state. const HISTORY_LENGTH = 40; const SAMPLE_MS = 2000; +// Scan at coarse file-position boundaries so status frames do not turn into +// several HTTP range requests per second. Each scan already reads 128 KiB. +const M73_SCAN_STRIDE_BYTES = 64 * 1024; function num(value: unknown): number { const n = Number(value); return Number.isFinite(n) ? n : 0; } -/** Clock time when the print is expected to finish. */ +/** + * Clock time when the print is expected to finish. + * + * Shares [finishClock] with the pre-print dialog so a job that ends tomorrow + * says so in both places — this used to print a bare time, which on a two-day + * print read as if it finished this afternoon. + */ function etaClock(remainingSeconds: number | null): string { if (remainingSeconds == null) return '--'; - const done = new Date(Date.now() + remainingSeconds * 1000); - return done.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + return finishClock(remainingSeconds); } // ---------------------------------------------------------------- panda breath @@ -207,8 +261,18 @@ function pandaModeLabel( } export function useDashboardModel(): DashboardModel { - const { status, connection, klippyState, activeUrl, macros, webcams, sendGcode, rpc, gcodeHelp } = - useMoonraker(); + const { + status, + connection, + klippyState, + activeUrl, + proxyUrl, + macros, + webcams, + sendGcode, + rpc, + gcodeHelp, + } = useMoonraker(); const { settings } = useSettings(); const ps = status.print_stats ?? {}; @@ -291,7 +355,36 @@ export function useDashboardModel(): DashboardModel { }, [chamberSource, history, status]); // ---------------------------------------------------------------- tools + const materialStation = useMaterialStation(); + const tools = useMemo(() => { + const activePrinterKind = settings.printers.find( + (printer) => printer.id === settings.activePrinterId + )?.kind; + // A material station owns its own slot data. print_task_config below is a + // Snapmaker object the FlashForge does not have, so without this the AD5X's + // toolhead cards showed the U1's manually configured filament colours. + if (activePrinterKind === 'flashforge-ad5x') { + const heater = status.extruder ?? {}; + const slots = materialStationSlots(materialStation.units) + ?? unavailableMaterialStationSlots(); + return slots.map((slot) => ({ + id: slot.index, + color: slot.color, + brand: slot.brand ?? t('Generic'), + material: slot.material, + mainType: slot.mainType, + // Every slot feeds the one hotend, so they share its temperature. + temp: Math.round(num(heater.temperature)), + target: Math.round(num(heater.target)), + active: slot.status === 'loaded', + loaded: slot.status, + source: 'printer' as const, + bambuTrayIndex: null, + bambuChangeTemp: 220, + })); + } + // Same resolution the existing Filaments card uses: printer-reported values // from print_task_config, falling back to the slot config the user picked. const slots = resolveFilamentSlots(status, { @@ -300,11 +393,26 @@ export function useDashboardModel(): DashboardModel { slotMaterials: settings.filamentSlotMaterials ?? [], slotSubtypes: settings.filamentSlotSubtypes ?? [], }); + const visibleSlots = status.print_task_config?.bambu_filament_source === 'external' + ? slots.slice(0, 1) + : slots; + const isBambuSlots = status.print_task_config?.bambu_filament_source != null; const activeExtruder: string = status.toolhead?.extruder ?? 'extruder'; - - return EXTRUDERS.map((name, i) => { + const rawBambuActiveTray = status.bambu?.active_tray; + const bambuActiveTray = rawBambuActiveTray == null ? null : Number(rawBambuActiveTray); + const bambuTrayIndexes = status.print_task_config?.bambu_tray_index ?? []; + const bambuTempMin = status.print_task_config?.nozzle_temp_min ?? []; + const bambuTempMax = status.print_task_config?.nozzle_temp_max ?? []; + + return visibleSlots.map((slot, i) => { + const name = EXTRUDERS[i] ?? 'extruder'; const heater = status[name] ?? {}; - const slot = slots[i]; + const trayIndex = Number(bambuTrayIndexes[i]); + const minTemp = Number(bambuTempMin[i]); + const maxTemp = Number(bambuTempMax[i]); + const profileTemp = minTemp > 0 && maxTemp >= minTemp + ? Math.round((minTemp + maxTemp) / 2) + : 220; return { id: i, color: slot.color, @@ -313,9 +421,13 @@ export function useDashboardModel(): DashboardModel { mainType: slot.mainType, temp: Math.round(num(heater.temperature)), target: Math.round(num(heater.target)), - active: name === activeExtruder, + active: isBambuSlots + ? bambuActiveTray != null && Number.isFinite(bambuActiveTray) && trayIndex === bambuActiveTray + : name === activeExtruder, loaded: slot.status, source: slot.source ?? 'manual', + bambuTrayIndex: Number.isFinite(trayIndex) ? trayIndex : null, + bambuChangeTemp: profileTemp, }; }); }, [ @@ -323,12 +435,17 @@ export function useDashboardModel(): DashboardModel { settings.filamentSlotColors, settings.filamentSlotMaterials, settings.filamentSlotSubtypes, + settings.activePrinterId, + settings.printers, status, + materialStation, ]); // ---------------------------------------------------------------- job const [slicerEstimate, setSlicerEstimate] = useState(null); + const [m73Estimate, setM73Estimate] = useState(null); const [thumbUri, setThumbUri] = useState(null); + const printDurationRef = useRef(0); useEffect(() => { let live = true; @@ -362,16 +479,60 @@ export function useDashboardModel(): DashboardModel { const printing = rawState === 'printing'; const paused = rawState === 'paused'; const activeJob = printing || paused; + const printDuration = num(ps.print_duration); + printDurationRef.current = printDuration; + const reportedRemaining = Number(ps.info?.remaining_time); + const printerRemainingSeconds = + ps.info?.remaining_time !== '' && + ps.info?.remaining_time != null && + Number.isFinite(reportedRemaining) && + reportedRemaining >= 0 + ? reportedRemaining + : null; + const filePosition = Math.max(0, num(vsd.file_position)); + const m73ScanPosition = + Math.floor(filePosition / M73_SCAN_STRIDE_BYTES) * M73_SCAN_STRIDE_BYTES; + + useEffect(() => { + setM73Estimate(null); + }, [filename]); + + useEffect(() => { + // Bambu already supplied a better onboard countdown and exposes no + // Moonraker file endpoint. U1 and AD5X both serve their active G-code. + if ( + !activeJob || + printerRemainingSeconds != null || + !activeUrl || + !filename || + m73ScanPosition <= 0 + ) { + return; + } + + const controller = new AbortController(); + fetchLatestM73(activeUrl, filename, m73ScanPosition, controller.signal) + .then((estimate) => { + if (!estimate) return; + setM73Estimate({ + ...estimate, + printDurationAtCapture: printDurationRef.current, + }); + }) + .catch(() => {}); + return () => controller.abort(); + }, [activeJob, activeUrl, filename, m73ScanPosition, printerRemainingSeconds]); const etas = useMemo( () => calculatePrintEtas({ - printDuration: num(ps.print_duration), + printDuration, slicerTotalSeconds: slicerEstimate, - m73: null, + m73: m73Estimate, fallbackProgress: progress, + printerRemainingSeconds, }), - [progress, ps.print_duration, slicerEstimate] + [m73Estimate, printDuration, printerRemainingSeconds, progress, slicerEstimate] ); // A finished job should only take over the dashboard if THIS session watched @@ -448,6 +609,19 @@ export function useDashboardModel(): DashboardModel { if (tailscale && tailscale !== primary) api.emergencyStop(tailscale).catch(() => {}); }, runMacro: (name: string) => sendGcode(name), + setBambuSpeed, + changeBambuFilament: (trayIndex, nozzleTemperature) => + changeBambuFilament(trayIndex, nozzleTemperature), + setBambuTemperature: async (heater, target) => { + const accepted = await sendGcode(buildBambuTemperatureCommand(heater, target)); + if (!accepted) throw new Error('The printer rejected the temperature command.'); + }, + setBambuFan: async (fan, percent) => { + const accepted = await sendGcode(buildBambuFanCommand(fan, percent)); + if (!accepted) throw new Error('The printer rejected the fan command.'); + }, + clearBambuErrors, + skipBambuObjects: sendBambuSkipObjects, panda: { setTarget: (temp: number) => sendGcode(`M141 S${clampPandaTemp(temp)}`), setAuto: (temp: number, enabled: boolean) => @@ -490,11 +664,14 @@ export function useDashboardModel(): DashboardModel { ); const job = useMemo(() => { - if (!filename) return null; + // Local Bambu LAN jobs can leave both subtask_name and gcode_file empty. + // The live layer/progress/countdown are still valid and must not disappear + // merely because the printer supplied no human-readable job name. + if (!filename && !activeJob) return null; const layer = ps.info?.current_layer ?? null; const layers = ps.info?.total_layer ?? null; return { - name: filename.split('/').pop() ?? filename, + name: filename ? (filename.split('/').pop() ?? filename) : t('Print in progress'), progress, layer: typeof layer === 'number' ? layer : null, layers: typeof layers === 'number' && layers > 0 ? layers : null, @@ -503,20 +680,35 @@ export function useDashboardModel(): DashboardModel { eta: etaClock(etas.liveRemainingSeconds), thumbUri, }; - }, [etas.liveRemainingSeconds, filename, progress, ps.info, thumbUri]); + }, [activeJob, etas.liveRemainingSeconds, filename, progress, ps.info, thumbUri]); // ---------------------------------------------------------------- camera const activePrinter = settings.printers.find((p) => p.id === settings.activePrinterId); - const baseUrl = activeUrl || (activePrinter ? printerConnectionUrl(activePrinter) : ''); + const baseUrl = proxyUrl || activeUrl || (activePrinter ? printerConnectionUrl(activePrinter) : ''); const camera = useMemo(() => { - const url = resolveCameraUrl(settings.cameraUrl, baseUrl); + const advertised = webcams.find((w) => !isGuiWebcam(w)); + // Bambu's camera URL is created per session. The AD5X advertises its live + // ustreamer endpoints through Moonraker and may change them independently + // of app settings. Both transports therefore take the advertised record as + // authoritative; saved paths remain a fallback for U1/generic Klipper. + const preferAdvertised = + activePrinter?.kind === 'bambu-lan' || activePrinter?.kind === 'flashforge-ad5x'; + const configured = preferAdvertised ? '' : resolveCameraUrl(settings.cameraUrl, baseUrl); + const url = + (advertised && preferAdvertised + ? resolveCameraUrl(advertised.stream_url, baseUrl) + : configured) || + (advertised ? resolveCameraUrl(advertised.stream_url, baseUrl) : ''); if (!url) return null; - const match = webcams.find((w) => resolveCameraUrl(w.stream_url, baseUrl) === url); + + const match = configured + ? webcams.find((w) => resolveCameraUrl(w.stream_url, baseUrl) === url) + : advertised; return { url, - snapshotUrl: resolveSnapshotUrl(match?.snapshot_url, settings.cameraUrl, baseUrl), + snapshotUrl: resolveSnapshotUrl(match?.snapshot_url, configured || url, baseUrl), }; - }, [baseUrl, settings.cameraUrl, webcams]); + }, [activePrinter?.kind, baseUrl, settings.cameraUrl, webcams]); const guiScreen = useMemo(() => { const match = webcams.find(isGuiWebcam); @@ -530,20 +722,33 @@ export function useDashboardModel(): DashboardModel { }, [baseUrl, webcams]); // ---------------------------------------------------------------- light - // Mirrors the LED handling in app/(tabs)/index.tsx: find the first led-like - // object, treat any non-zero channel as "on". + // The chamber LED frequently does not push a notify_status_update for + // color_data after SET_LED, so driving `lightOn` purely off reported state + // leaves it stuck at the connect-time value — the second press then re-sends + // the same value and nothing happens. Flip an optimistic override on press so + // the next toggle always sends the opposite value, and drop the override once + // (if) the printer ever reports the matching state. const ledKey = useMemo( () => Object.keys(status).find((k) => /^(led|neopixel|dotstar)\s/.test(k)), [status] ); const ledColors: number[][] = status[ledKey ?? '']?.color_data ?? []; - const lightOn = ledColors.some((c) => Array.isArray(c) && c.some((v) => num(v) > 0)); + const reportedLedOn = ledColors.some((c) => Array.isArray(c) && c.some((v) => num(v) > 0)); + const [ledOverride, setLedOverride] = useState<{ key: string; on: boolean } | null>(null); + const activeLedOverride = ledOverride && ledOverride.key === ledKey ? ledOverride : null; + const lightOn = activeLedOverride ? activeLedOverride.on : reportedLedOn; + useEffect(() => { + const pending = ledOverride; + if (!pending || pending.key !== ledKey || !ledColors.length) return; + if (reportedLedOn === pending.on) setLedOverride(null); + }, [ledColors.length, ledKey, ledOverride, reportedLedOn]); const toggleLight = useMemo(() => { if (!ledKey) return undefined; return () => { const name = ledKey.replace(/^(led|neopixel|dotstar)\s+/, ''); const hasWhite = ledColors.some((c) => Array.isArray(c) && c.length > 3); const v = lightOn ? 0 : 1; + setLedOverride({ key: ledKey, on: !lightOn }); sendGcode( hasWhite ? `SET_LED LED=${name} RED=0 GREEN=0 BLUE=0 WHITE=${v} SYNC=0` @@ -565,6 +770,52 @@ export function useDashboardModel(): DashboardModel { [macros, settings.activePrinterId, settings.macroDisplayByPrinter] ); + const bambu = useMemo(() => { + if (activePrinter?.kind !== 'bambu-lan') return null; + const rawPreset = Number(status.bambu?.speed_preset); + const speedPreset = [1, 2, 3, 4].includes(rawPreset) + ? rawPreset as BambuSpeedPreset + : null; + const rawHealth = Array.isArray(status.bambu?.ams_health) ? status.bambu.ams_health : []; + const rawFans = status.bambu?.fans ?? {}; + const rawFaults = Array.isArray(status.bambu?.hms_faults) ? status.bambu.hms_faults : []; + const rawSkippedIds = Array.isArray(status.bambu?.skipped_object_ids) + ? status.bambu.skipped_object_ids + : []; + const skippedIds = new Set( + rawSkippedIds + .map((value: unknown) => Number(value)) + .filter((value: number) => Number.isSafeInteger(value) && value >= 0) + ); + const fanValue = (value: unknown): number | undefined => { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, Math.min(100, Math.round(parsed))) : undefined; + }; + return { + speedPreset, + amsHealth: rawHealth.map((entry: any, position: number) => ({ + unit: Number.isFinite(Number(entry?.unit)) ? Number(entry.unit) : position, + humidity: Number.isFinite(Number(entry?.humidity)) ? Number(entry.humidity) : undefined, + temperature: Number.isFinite(Number(entry?.temperature)) ? Number(entry.temperature) : undefined, + })), + fans: { + part: fanValue(rawFans.part), + aux: fanValue(rawFans.aux), + chamber: fanValue(rawFans.chamber), + }, + hmsFaults: rawFaults.filter( + (fault: any): fault is BambuHmsFault => + typeof fault?.code === 'string' && + typeof fault?.summary === 'string' && + typeof fault?.helpUrl === 'string' + ), + printObjects: bambuObjectsForJob(filename).map((object) => ({ + ...object, + skipped: skippedIds.has(object.identifyId), + })), + }; + }, [activePrinter?.kind, filename, status.bambu]); + return { printerName: activePrinter?.name || t('Printer'), online: connected, @@ -584,6 +835,7 @@ export function useDashboardModel(): DashboardModel { '', job: activeJob || observedFinished ? job : null, tools, + materialStationError: materialStation.error, temps, macros: visibleMacros, camera, @@ -591,6 +843,7 @@ export function useDashboardModel(): DashboardModel { lightOn, toggleLight, pandaBreath, + bambu, actions, }; } diff --git a/hooks/useFilamentSlotWrites.ts b/hooks/useFilamentSlotWrites.ts index 2dda172..a31a3c0 100644 --- a/hooks/useFilamentSlotWrites.ts +++ b/hooks/useFilamentSlotWrites.ts @@ -10,6 +10,11 @@ import { useCallback, useMemo } from 'react'; import { useMoonraker } from './useMoonraker'; import { useSettings } from './useSettings'; import { api, printerConnectionUrl } from '../services/moonraker'; +import { setBambuFilament } from '../services/bambuMqtt'; +import { + bambuFilamentWriteLocation, + resolveBambuFilamentEditIdentity, +} from '../services/bambuReport'; import { setFilamentSlotColors } from '../services/nativeSlicer'; import { normalizeFilamentSlotColors } from '../constants/filamentColors'; @@ -38,6 +43,56 @@ export function useFilamentSlotWrites(onError?: (message: string) => void): Fila const activePrinter = settings.printers.find((p) => p.id === settings.activePrinterId); const baseUrl = activeUrl || (activePrinter ? printerConnectionUrl(activePrinter) : ''); + const isBambu = activePrinter?.kind === 'bambu-lan'; + + // Bambu has no Moonraker REST — the same edit goes out as an + // ams_filament_setting publish. Occupancy and preset identity are separate: + // manually configured generic spools can be present with no reported ID. + // The external holder has no idle occupancy sensor, but Bambu explicitly + // permits editing its virtual tray at 255/254/0. + const pushBambu = useCallback( + async (effective: EffectiveSlots, changedIndex?: number) => { + const cfg = (status.print_task_config ?? {}) as Record; + const source = cfg.bambu_filament_source === 'external' ? 'external' : 'ams'; + const channels = source === 'external' + ? [changedIndex ?? 0] + : changedIndex == null + ? effective.colors.map((_, index) => index) + : [changedIndex]; + try { + await Promise.all( + channels.map((channel) => { + const material = effective.materials[channel] || 'PLA'; + const identity = resolveBambuFilamentEditIdentity( + source === 'external' || cfg.filament_exist?.[channel] === true, + cfg.filament_info_idx?.[channel], + material + ); + if (!identity.ok && identity.reason === 'empty') { + throw new Error('That AMS slot is empty — load a spool before editing it.'); + } + if (!identity.ok) { + throw new Error(`Bambu Studio has no generic ${material} preset for this filament edit.`); + } + const location = bambuFilamentWriteLocation(source, channel); + return setBambuFilament({ + ...location, + filamentId: identity.filamentId, + type: material, + colorRgba: `${effective.colors[channel].replace('#', '').slice(0, 6).toUpperCase()}FF`, + nozzleTempMin: cfg.nozzle_temp_min?.[channel] || 190, + nozzleTempMax: cfg.nozzle_temp_max?.[channel] || 240, + }); + }) + ); + return true; + } catch (error) { + onError?.(error instanceof Error ? error.message : 'Helix saved the value locally.'); + return false; + } + }, + [onError, status] + ); const current = useMemo( () => ({ @@ -56,6 +111,7 @@ export function useFilamentSlotWrites(onError?: (message: string) => void): Fila const push = useCallback( async (effective: EffectiveSlots, changedIndex?: number) => { + if (isBambu) return pushBambu(effective, changedIndex); if (!baseUrl) return true; // A single changed slot re-broadcasts only that channel; broadcasting all // four would churn the other slots' RFID caches for no reason. @@ -82,7 +138,7 @@ export function useFilamentSlotWrites(onError?: (message: string) => void): Fila return false; } }, - [baseUrl, onError, status] + [baseUrl, isBambu, onError, pushBambu, status] ); const updateColors = useCallback( diff --git a/hooks/useMaterialStation.ts b/hooks/useMaterialStation.ts new file mode 100644 index 0000000..7340748 --- /dev/null +++ b/hooks/useMaterialStation.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { materialStationToAceUnits, type AceUnit } from '../services/aceModel'; +import { + fetchMaterialStation, + hasFlashForgeCredentials, + type FlashForgeError, + type MaterialStation, +} from '../services/flashforgeApi'; +import { useMoonraker } from './useMoonraker'; +import { useSettings } from './useSettings'; + +// The FlashForge material station has no push channel — unlike Moonraker's +// WebSocket it has to be polled. Slots only change when someone loads filament, +// so this is deliberately slow. +const POLL_INTERVAL_MS = 10_000; + +interface MaterialStationState { + /** ACE-shaped units, so the existing lane UI renders these unchanged. */ + units: AceUnit[]; + /** True once the printer has confirmed it has a material station. */ + detected: boolean; + /** Last failure, cleared on the next success. */ + error: FlashForgeError | null; +} + +const IDLE: MaterialStationState = { units: [], detected: false, error: null }; + +// Home and the ACE tab both want the station, and the AD5X's Wi-Fi is fragile +// enough that polling it twice as often is worth avoiding. Callers within this +// window share one request. +const SHARED_WINDOW_MS = 4000; +let cachedKey = ''; +let cachedAt = 0; +let cachedResult: Awaited> | null = null; +let inFlight: Promise>> | null = null; + +async function sharedFetchMaterialStation( + baseUrl: string, + credentials: { serialNumber: string; checkCode: string }, + signal?: AbortSignal +) { + const key = `${baseUrl}|${credentials.serialNumber}`; + if (cachedResult && cachedKey === key && Date.now() - cachedAt < SHARED_WINDOW_MS) { + return cachedResult; + } + if (inFlight && cachedKey === key) return inFlight; + + cachedKey = key; + inFlight = fetchMaterialStation(baseUrl, credentials, signal).then((result) => { + cachedResult = result; + cachedAt = Date.now(); + inFlight = null; + return result; + }); + return inFlight; +} + +/** + * Reads the AD5X material station over FlashForge's REST API. + * + * Only the FlashForge profile polls; every other machine returns idle so this + * costs nothing. A single failed poll keeps the last known slots on screen + * rather than blanking the UI — the AD5X's Wi-Fi drops often enough that + * flickering would otherwise be constant. + */ +export function useMaterialStation(): MaterialStationState & { refresh: () => void } { + const { settings } = useSettings(); + const { activeUrl, connection } = useMoonraker(); + const [state, setState] = useState(IDLE); + const [nonce, setNonce] = useState(0); + + const activePrinter = settings.printers.find( + (printer) => printer.id === settings.activePrinterId + ); + const isFlashForge = activePrinter?.kind === 'flashforge-ad5x'; + // Follow the route Moonraker actually connected through. In Auto mode the + // saved LAN URL may be dead while Tailscale is healthy; independently + // choosing the LAN URL here made printer control work remotely while the IFS + // still appeared unavailable. + const baseUrl = isFlashForge && connection === 'connected' ? activeUrl : ''; + const serialNumber = activePrinter?.serialNumber ?? ''; + const checkCode = activePrinter?.checkCode ?? ''; + const credentialed = hasFlashForgeCredentials({ serialNumber, checkCode }); + + // Keeps the last good reading across failed polls without making it a + // dependency of the polling effect. + const lastStation = useRef(null); + + const refresh = useCallback(() => setNonce((value) => value + 1), []); + + useEffect(() => { + if (!isFlashForge || !baseUrl) { + lastStation.current = null; + setState(IDLE); + return; + } + + if (!credentialed) { + lastStation.current = null; + setState({ units: [], detected: false, error: 'missing-credentials' }); + return; + } + + let cancelled = false; + const controller = new AbortController(); + + const poll = async () => { + const result = await sharedFetchMaterialStation( + baseUrl, + { serialNumber, checkCode }, + controller.signal + ); + if (cancelled) return; + + if (result.ok) { + lastStation.current = result.value; + setState({ + units: materialStationToAceUnits(result.value), + detected: true, + error: null, + }); + return; + } + + setState({ + // Hold the previous slots so a dropped packet doesn't clear the screen. + units: materialStationToAceUnits(lastStation.current), + detected: lastStation.current != null, + error: result.error, + }); + }; + + void poll(); + const timer = setInterval(() => void poll(), POLL_INTERVAL_MS); + + return () => { + cancelled = true; + controller.abort(); + clearInterval(timer); + }; + }, [isFlashForge, baseUrl, credentialed, serialNumber, checkCode, nonce]); + + return { ...state, refresh }; +} diff --git a/hooks/useMoonraker.tsx b/hooks/useMoonraker.tsx index 1e7312a..affb63b 100644 --- a/hooks/useMoonraker.tsx +++ b/hooks/useMoonraker.tsx @@ -8,7 +8,23 @@ import React, { useState, } from 'react'; import { AppState } from 'react-native'; -import { isTailscaleUrl, normalizeMoonrakerUrl, WebcamInfo, wsUrl } from '../services/moonraker'; +import { + discoverHelixdRemoteBase, + helixdLanBaseUrl, + helixdRemoteMoonrakerUrl, + isTailscaleUrl, + normalizeBaseUrl, + normalizeMoonrakerUrl, + WebcamInfo, + wsUrl, +} from '../services/moonraker'; +import { + PROMPT_DISMISS_GCODE, + reducePromptLine, + visiblePrompt, + type KlipperPrompt, + type PromptBuildState, +} from '../services/klipperPrompt'; import { notifyEvent } from '../services/notifications'; import { historyFailureMessage, @@ -26,23 +42,41 @@ export interface ConsoleLine { text: string; } -interface MoonrakerContextValue { +/** + * The shape every transport must present. Moonraker is one implementation; + * hooks/useBambu.tsx is another, for printers that do not speak Moonraker at + * all. Consumers only ever see this interface, which is why adding Bambu needed + * no changes to the dashboard. + */ +export interface MoonrakerContextValue { connection: ConnectionState; klippyState: string; activeUrl: string; + /** Same-route HTTP origin for camera/screen traffic when it differs from Moonraker. */ + proxyUrl?: string; status: Record; consoleLines: ConsoleLine[]; macros: string[]; objectList: string[]; gcodeHelp: Record; webcams: WebcamInfo[]; + /** + * Dialog the printer is waiting on, or null. Klipper prompts block the + * command that raised them until a client answers, so an unrendered prompt + * looks exactly like a print that silently never started. + */ + prompt: KlipperPrompt | null; + /** Runs a prompt button's G-code and closes the dialog. */ + answerPrompt: (gcode: string) => Promise; + /** Closes the dialog and tells the printer the prompt was dismissed. */ + dismissPrompt: () => Promise; sendGcode: (script: string) => Promise; rpc: (method: string, params?: Record) => Promise; reconnect: () => void; clearConsole: () => void; } -const MoonrakerContext = createContext(null); +export const MoonrakerContext = createContext(null); // Base objects used for dashboard state. extruder1-3 cover the U1 tool heads, // and gcode_move supplies position data used by Fluidd-style controls. @@ -62,6 +96,8 @@ const BASE_OBJECTS = [ 'fan', ]; const MAX_CONSOLE_LINES = 500; +/** Filament swaps are a human-speed event; this only needs to feel prompt. */ +const PRINT_TASK_CONFIG_POLL_MS = 8000; const WS_OPEN = 1; const TEMP_WARNING_DELTA_C = 15; const TEMP_WARNING_RESET_DELTA_C = 5; @@ -70,6 +106,12 @@ const EXTRUDER_TARGET_DROP_MIN_DELTA_C = 5; const EXTRUDER_TARGET_DROP_SUPPRESS_MS = 5 * 60 * 1000; const HEATER_KEY_RE = /^(heater_bed|extruder\d*|heater_generic\s+.+)$/; const EXTRUDER_KEY_RE = /^extruder\d*$/; +// Android Doze can silently kill the TCP socket while the app is backgrounded: +// ws.readyState stays OPEN and onclose never fires, so the UI shows "connected" +// but no data flows. A periodic probe detects these zombie sockets and forces a +// reconnect when the printer stops answering. +const HEARTBEAT_INTERVAL_MS = 10000; +const HEARTBEAT_TIMEOUT_MS = 6000; let lineIdCounter = 0; function heaterLabel(key: string): string { @@ -84,13 +126,15 @@ function isExtruderKey(key: string): boolean { } export function MoonrakerProvider({ children }: { children: React.ReactNode }) { - const { settings, loaded } = useSettings(); + const { settings, loaded, update } = useSettings(); const [connection, setConnection] = useState('disconnected'); const [klippyState, setKlippyState] = useState('unknown'); const [activeUrl, setActiveUrl] = useState(''); + const [proxyUrl, setProxyUrl] = useState(''); const [status, setStatus] = useState>({}); const [consoleLines, setConsoleLines] = useState([]); + const [promptState, setPromptState] = useState(null); const [objectList, setObjectList] = useState([]); const [gcodeHelp, setGcodeHelp] = useState>({}); const [webcams, setWebcams] = useState([]); @@ -121,6 +165,8 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { const disconnectNoticeTimerRef = useRef | null>(null); const appStateRef = useRef(AppState.currentState); const backgroundedAtRef = useRef(null); + const heartbeatTimerRef = useRef | null>(null); + const freshAd5xRemoteRef = useRef<{ printerId: string; url: string } | null>(null); settingsRef.current = settings; @@ -292,6 +338,9 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { const handleGcodeResponse = useCallback( (msg: string) => { addLine(msg.startsWith('!!') ? 'error' : 'response', msg); + // reducePromptLine returns the same reference for non-prompt lines, so + // ordinary console chatter does not re-render anything. + setPromptState((previous) => reducePromptLine(previous, msg)); if (msg.startsWith('!!')) lastGcodeErrorRef.current = msg.replace(/^!!\s*/, '').trim(); // multiACE does not emit a dedicated swap-complete event, so this uses // broad console response matching. @@ -346,6 +395,24 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { }) .catch(() => {}); + // Recover a prompt the printer raised before this client connected. + // Klipper prompts are ordinary G-code responses, so a client that joins + // afterwards never sees them and the dialog would stay invisible while + // the printer waits. Moonraker keeps a rolling buffer; folding it + // replays the whole conversation, and any prompt already answered or + // ended reduces back to null on its own. + rpc('server.gcode_store', { count: 100 }) + .then((store: { gcode_store?: { message?: string }[] }) => { + if (gen !== generationRef.current) return; + const lines = store?.gcode_store ?? []; + let recovered: PromptBuildState | null = null; + for (const entry of lines) { + recovered = reducePromptLine(recovered, String(entry?.message ?? '')); + } + if (recovered) setPromptState(recovered); + }) + .catch(() => {}); + const subs: Record = {}; for (const name of BASE_OBJECTS) if (objects.includes(name)) subs[name] = null; for (const name of objects) { @@ -389,11 +456,61 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { [rpc, addLine] ); + const refreshAd5xRemoteBase = useCallback(async () => { + const current = settingsRef.current; + const printer = current.printers.find((entry) => entry.id === current.activePrinterId); + if (printer?.kind !== 'flashforge-ad5x') return; + + const discovered = await discoverHelixdRemoteBase(printer.url || current.primaryUrl); + if (discovered === null) return; + + // The active printer may have changed while the LAN request was in flight. + const latest = settingsRef.current; + const latestPrinter = latest.printers.find((entry) => entry.id === printer.id); + if (latest.activePrinterId !== printer.id || latestPrinter?.kind !== 'flashforge-ad5x') return; + + freshAd5xRemoteRef.current = { printerId: printer.id, url: discovered }; + const savedPrinterUrl = normalizeBaseUrl(latestPrinter.tailscaleUrl || ''); + const savedActiveUrl = normalizeBaseUrl(latest.tailscaleUrl || ''); + if (savedPrinterUrl === discovered && savedActiveUrl === discovered) return; + + await update({ + tailscaleUrl: discovered, + printers: latest.printers.map((entry) => + entry.id === printer.id ? { ...entry, tailscaleUrl: discovered } : entry + ), + }); + }, [update]); + const getUrls = useCallback((): string[] => { const urls: string[] = []; - const primary = normalizeMoonrakerUrl(settingsRef.current.primaryUrl); - const tailscale = normalizeMoonrakerUrl(settingsRef.current.tailscaleUrl); - const mode = settingsRef.current.connectionMode; + const current = settingsRef.current; + const activePrinter = current.printers.find((entry) => entry.id === current.activePrinterId); + const mode = current.connectionMode; + + if (activePrinter?.kind === 'flashforge-ad5x') { + const primary = normalizeMoonrakerUrl(activePrinter.url || current.primaryUrl); + const fresh = freshAd5xRemoteRef.current; + const tailscaleProxy = fresh?.printerId === activePrinter.id + ? fresh.url + : normalizeBaseUrl(activePrinter.tailscaleUrl || current.tailscaleUrl); + const tailscale = helixdRemoteMoonrakerUrl(tailscaleProxy); + + if (mode === 'lan') { + if (primary) urls.push(primary); + return urls; + } + if (mode === 'tailscale') { + if (tailscale) urls.push(tailscale); + return urls; + } + if (primary) urls.push(primary); + if (tailscale && tailscale !== primary) urls.push(tailscale); + return urls; + } + + const primary = normalizeMoonrakerUrl(current.primaryUrl); + const tailscale = normalizeMoonrakerUrl(current.tailscaleUrl); // tailscale-only means tailscale-only!!!!!!!!!!! // crabcore @@ -414,17 +531,60 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { const connectRef = useRef<() => void>(() => {}); + const stopHeartbeat = useCallback(() => { + if (heartbeatTimerRef.current) { + clearInterval(heartbeatTimerRef.current); + heartbeatTimerRef.current = null; + } + }, []); + + const startHeartbeat = useCallback(() => { + stopHeartbeat(); + heartbeatTimerRef.current = setInterval(() => { + if (!connectedRef.current) return; + const ws = wsRef.current; + if (!ws || ws.readyState !== WS_OPEN) return; + let answered = false; + const probeTimeout = setTimeout(() => { + if (!answered && connectedRef.current) { + addLine('error', '// Printer connection stalled — reconnecting'); + connectRef.current(); + } + }, HEARTBEAT_TIMEOUT_MS); + rpc('server.info') + .then(() => { + answered = true; + clearTimeout(probeTimeout); + }) + .catch(() => { + answered = true; + clearTimeout(probeTimeout); + }); + }, HEARTBEAT_INTERVAL_MS); + }, [rpc, addLine, stopHeartbeat]); + const scheduleReconnect = useCallback(() => { failCountRef.current += 1; + void refreshAd5xRemoteBase(); const urls = getUrls(); - // Alternate LAN and Tailscale after each failed connection attempt. - if (urls.length > 1) { - urlIndexRef.current = (urlIndexRef.current + 1) % urls.length; - } - const delay = Math.min(1000 * Math.pow(2, Math.min(failCountRef.current, 3)), 8000); + const hasAlternate = urls.length > 1; + const nextIndex = hasAlternate + ? (urlIndexRef.current + 1) % urls.length + : urlIndexRef.current; + urlIndexRef.current = nextIndex; + + // Try every configured route once before backing off. A dead LAN address + // in Auto mode must hand directly to Tailscale instead of adding a retry + // delay between candidates. Backoff starts only after the full set failed. + const completedCycle = !hasAlternate || nextIndex === 0; + const failedCycles = Math.ceil(failCountRef.current / Math.max(1, urls.length)); + const delay = completedCycle + ? Math.min(1000 * Math.pow(2, Math.min(failedCycles, 3)), 8000) + : 0; + if (!completedCycle) setConnection('connecting'); if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = setTimeout(() => connectRef.current(), delay); - }, [getUrls]); + }, [getUrls, refreshAd5xRemoteBase]); const connect = useCallback(() => { const gen = ++generationRef.current; @@ -449,11 +609,28 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { const urls = getUrls(); if (!urls.length) { connectedRef.current = false; + setProxyUrl(''); setConnection('disconnected'); return; } const url = urls[urlIndexRef.current % urls.length]; setActiveUrl(url); + const current = settingsRef.current; + const activePrinter = current.printers.find((entry) => entry.id === current.activePrinterId); + if (activePrinter?.kind === 'flashforge-ad5x') { + const fresh = freshAd5xRemoteRef.current; + const remoteProxy = fresh?.printerId === activePrinter.id + ? fresh.url + : normalizeBaseUrl(activePrinter.tailscaleUrl || current.tailscaleUrl); + const remoteMoonraker = helixdRemoteMoonrakerUrl(remoteProxy); + setProxyUrl( + remoteMoonraker && url === remoteMoonraker + ? remoteProxy + : helixdLanBaseUrl(activePrinter.url || current.primaryUrl) + ); + } else { + setProxyUrl(url); + } setConnection('connecting'); let ws: WebSocket; @@ -467,13 +644,23 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { // React Native WebSocket has no connection timeout, so enforce one to keep // LAN/Tailscale failover moving when a network path hangs. - const connectTimeoutMs = isTailscaleUrl(url) ? 15000 : 7000; + // Auto gets a short LAN probe because another viable route is waiting. + // LAN-only keeps the longer window for printers on weak Wi-Fi. + const autoFailover = current.connectionMode === 'auto' && urls.length > 1; + const connectTimeoutMs = isTailscaleUrl(url) ? 15000 : autoFailover ? 3000 : 7000; if (connectTimeoutRef.current) clearTimeout(connectTimeoutRef.current); connectTimeoutRef.current = setTimeout(() => { if (gen === generationRef.current && ws.readyState !== WS_OPEN) { + // Closing a CONNECTING React Native socket can take Android several + // more seconds to emit onclose. Invalidate this generation first so + // its eventual callback cannot rotate the URL a second time, then + // schedule the alternate route ourselves immediately. + generationRef.current += 1; + connectTimeoutRef.current = null; try { ws.close(); } catch {} + scheduleReconnect(); } }, connectTimeoutMs); @@ -489,6 +676,7 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { setConnection('connected'); addLine('response', `// Connected to ${url}`); initPrinter(gen); + startHeartbeat(); }; ws.onmessage = (ev) => { @@ -563,6 +751,7 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { ws.onclose = () => { if (gen !== generationRef.current) return; + stopHeartbeat(); if (connectTimeoutRef.current) clearTimeout(connectTimeoutRef.current); const wasConnected = connectedRef.current; connectedRef.current = false; @@ -590,7 +779,7 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { } scheduleReconnect(); }; - }, [getUrls, scheduleReconnect, addLine, initPrinter, checkTransitions, emitTerminalNotification, flushStatus, handleGcodeResponse]); + }, [getUrls, scheduleReconnect, addLine, initPrinter, checkTransitions, emitTerminalNotification, flushStatus, handleGcodeResponse, startHeartbeat, stopHeartbeat]); connectRef.current = connect; @@ -598,6 +787,7 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { if (!loaded) return; urlIndexRef.current = 0; failCountRef.current = 0; + void refreshAd5xRemoteBase(); connect(); const sub = AppState.addEventListener('change', (state) => { @@ -635,12 +825,53 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { if (connectTimeoutRef.current) clearTimeout(connectTimeoutRef.current); if (disconnectNoticeTimerRef.current) clearTimeout(disconnectNoticeTimerRef.current); if (flushTimerRef.current) clearTimeout(flushTimerRef.current); + if (heartbeatTimerRef.current) clearInterval(heartbeatTimerRef.current); try { wsRef.current?.close(); } catch {} wsRef.current = null; }; - }, [loaded, settings.primaryUrl, settings.tailscaleUrl, settings.connectionMode, connect]); + }, [ + loaded, + settings.activePrinterId, + settings.primaryUrl, + settings.tailscaleUrl, + settings.connectionMode, + connect, + refreshAd5xRemoteBase, + ]); + + // print_task_config carries the loaded filament (colour, vendor, type) but the + // U1 never emits notify_status_update for it — subscribing only ever yields + // the value read at connect time. Loading a new spool therefore left the app + // showing the previous filament until it was restarted, so poll it instead. + useEffect(() => { + if (connection !== 'connected') return; + + let cancelled = false; + const poll = async () => { + try { + const res = await rpc('printer.objects.query', { + objects: { print_task_config: null }, + }); + const next = res?.status?.print_task_config; + if (cancelled || !next) return; + statusRef.current.print_task_config = { + ...statusRef.current.print_task_config, + ...next, + }; + flushStatus(); + } catch { + // A dropped poll is harmless — the next one picks the change up. + } + }; + + const timer = setInterval(() => void poll(), PRINT_TASK_CONFIG_POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [connection, rpc, flushStatus]); const sendGcode = useCallback( async (script: string): Promise => { @@ -676,23 +907,44 @@ export function MoonrakerProvider({ children }: { children: React.ReactNode }) { [objectList] ); + const prompt = useMemo(() => visiblePrompt(promptState), [promptState]); + + // Close locally first: the dialog must never outlive the tap, even if the + // printer is slow or the socket has dropped. + const answerPrompt = useCallback( + async (gcode: string) => { + setPromptState(null); + await sendGcode(gcode).catch(() => false); + }, + [sendGcode] + ); + + const dismissPrompt = useCallback(async () => { + setPromptState(null); + await sendGcode(PROMPT_DISMISS_GCODE).catch(() => false); + }, [sendGcode]); + const value = useMemo( () => ({ connection, klippyState, activeUrl, + proxyUrl, status, consoleLines, macros, objectList, gcodeHelp, webcams, + prompt, + answerPrompt, + dismissPrompt, sendGcode, rpc, reconnect, clearConsole, }), - [connection, klippyState, activeUrl, status, consoleLines, macros, objectList, gcodeHelp, webcams, sendGcode, rpc, reconnect, clearConsole] + [connection, klippyState, activeUrl, proxyUrl, status, consoleLines, macros, objectList, gcodeHelp, webcams, prompt, answerPrompt, dismissPrompt, sendGcode, rpc, reconnect, clearConsole] ); return {children}; diff --git a/hooks/usePrinterTransport.tsx b/hooks/usePrinterTransport.tsx new file mode 100644 index 0000000..6cb6096 --- /dev/null +++ b/hooks/usePrinterTransport.tsx @@ -0,0 +1,31 @@ +// Picks the transport for the active printer. +// +// Helix spoke Moonraker to everything until Bambu arrived; Bambu speaks MQTT +// and nothing else. Both providers fill the same MoonrakerContext, so this is +// the only place in the app that knows there is more than one protocol. +// +// Remounting on a change of transport is deliberate: swapping printers must +// tear the old socket down and start the new one with empty state, and keying +// the subtree is the least error-prone way to guarantee that. +// crabcore + +import React from 'react'; + +import { BambuProvider } from './useBambu'; +import { MoonrakerProvider } from './useMoonraker'; +import { useSettings } from './useSettings'; + +export function PrinterProvider({ children }: { children: React.ReactNode }) { + const { settings } = useSettings(); + const printer = settings.printers.find((p) => p.id === settings.activePrinterId); + + if (printer?.kind === 'bambu-lan') { + return {children}; + } + + return ( + + {children} + + ); +} diff --git a/hooks/useScreenMirror.ts b/hooks/useScreenMirror.ts new file mode 100644 index 0000000..2f6c6bc --- /dev/null +++ b/hooks/useScreenMirror.ts @@ -0,0 +1,272 @@ +// useScreenMirror — polls helixd's /api/screen/* endpoints for the AD5X +// touchscreen mirror, and forwards single taps. +// +// helixd runs on port 80 of the same host Moonraker uses. Capture is CPU-heavy +// on the printer (~125 ms/frame; JPEG quality barely changes that), so frames +// are chained from the previous frame's onLoad/onError — never a fixed timer, +// which would pile requests up on a slow device. During a print the cadence +// drops to ~1 fps to leave Klipper alone; the server separately refuses taps. +// +// To avoid the per-frame flicker of a single reloading, frames are +// double-buffered: `pendingUri` loads hidden on top of the committed frame, +// and is only promoted to `committedUri` once decoded — so the displayed image +// is never blank between frames. +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSettings } from './useSettings'; +import { printerConnectionUrl, resolveScreenApiUrl } from '../services/moonraker'; + +export interface ScreenInfo { + width: number; + height: number; + touch_ok: boolean; + print_state: string; + touch_error?: string; +} + +export interface TapError { + /** HTTP status, or 0 for a network failure. */ + status: number; + message: string; + /** print_state echoed back by a 409 ("printing" / "paused"). */ + state?: string; +} + +export interface TapResult { + ok: boolean; + status: number; + x?: number; + y?: number; +} + +// Pure chaining: arm the next frame the instant the current one finishes +// (gap 0). Each frame costs the printer ~125 ms of CPU — that capture time is +// the rate limiter (~8 fps ceiling), not this gap. Adding a gap here would +// only stack on top of it and slow the mirror below the ceiling. inFlightRef +// still prevents overlap, so there's no pile-up on a slow device. +const IDLE_GAP_MS = 0; +// Klipper needs the CPU while printing — drop to ~1 fps. +const PRINTING_GAP_MS = 1000; +// After a failed fetch (printer offline / rebooting) back off so a dead host +// isn't hammered at 7 fps. +const ERROR_GAP_MS = 1500; +const INFO_POLL_MS = 5000; +const SNAPSHOT_QUALITY = 60; +const PRINT_STATES = new Set(['printing', 'paused']); +const DEFAULT_WIDTH = 800; +const DEFAULT_HEIGHT = 480; + +function isThrottled(state: string | undefined): boolean { + return !!state && PRINT_STATES.has(state); +} + +export function useScreenMirror(active: boolean) { + const { settings } = useSettings(); + + const baseUrl = useMemo(() => { + const activePrinter = settings.printers.find((p) => p.id === settings.activePrinterId); + const moonrakerBase = activePrinter + ? printerConnectionUrl(activePrinter) + : settings.primaryUrl; + return resolveScreenApiUrl(moonrakerBase); + }, [settings.printers, settings.activePrinterId, settings.primaryUrl]); + + // committed = the frame currently shown; pending = the next frame loading on + // top of it. Promotion happens only after the pending frame decodes. + const [committedUri, setCommittedUri] = useState(null); + const [pendingUri, setPendingUri] = useState(null); + const [hasFrame, setHasFrame] = useState(false); + const [loading, setLoading] = useState(false); + const [info, setInfo] = useState(null); + const [tapError, setTapError] = useState(null); + + // Refs mirror state for use inside the polling loop and the async tap, so + // the loop doesn't tear down/restart whenever a value it only reads changes. + const activeRef = useRef(active); + const baseUrlRef = useRef(baseUrl); + const pendingRef = useRef(null); + const inFlightRef = useRef(false); + const erroredRef = useRef(false); + const boundsRef = useRef({ w: DEFAULT_WIDTH, h: DEFAULT_HEIGHT }); + const timerRef = useRef | null>(null); + const seqRef = useRef(0); + + activeRef.current = active; + baseUrlRef.current = baseUrl; + + const printState = info?.print_state; + const throttled = isThrottled(printState); + const width = info?.width ?? DEFAULT_WIDTH; + const height = info?.height ?? DEFAULT_HEIGHT; + boundsRef.current = { w: width, h: height }; + const touchOk = info ? info.touch_ok : true; + const touchError = info?.touch_error ?? null; + + // Throttle is read via ref so scheduleNext stays referentially stable and + // the frame loop never closes over a stale cadence. + const throttledRef = useRef(throttled); + throttledRef.current = throttled; + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const armFrame = useCallback(() => { + const base = baseUrlRef.current; + if (!activeRef.current || !base || inFlightRef.current) return; + inFlightRef.current = true; + setLoading(true); + seqRef.current += 1; + // Cache-bust with a unique token: RN's Image dedupes by URI, so a fresh + // query string is what actually forces a reload on every frame. The same + // token is then reused as the committed URI, so promotion is a cache hit. + const uri = `${base}/api/screen/snapshot?q=${SNAPSHOT_QUALITY}&t=${Date.now()}_${seqRef.current}`; + pendingRef.current = uri; + setPendingUri(uri); + }, []); + + const scheduleNext = useCallback(() => { + clearTimer(); + const gap = erroredRef.current + ? ERROR_GAP_MS + : throttledRef.current + ? PRINTING_GAP_MS + : IDLE_GAP_MS; + timerRef.current = setTimeout(armFrame, gap); + }, [armFrame, clearTimer]); + + // Wired to the pending 's onLoad. Promotes it to the committed slot + // (cache hit, so the swap is instantaneous and flicker-free), then arms the + // next frame. There is always at most one frame in flight. + const onPendingLoaded = useCallback(() => { + const uri = pendingRef.current; + pendingRef.current = null; + inFlightRef.current = false; + erroredRef.current = false; + setLoading(false); + if (uri) { + setCommittedUri(uri); + setHasFrame(true); + } + setPendingUri(null); + if (activeRef.current && baseUrlRef.current) scheduleNext(); + }, [scheduleNext]); + + const onPendingError = useCallback(() => { + pendingRef.current = null; + inFlightRef.current = false; + erroredRef.current = true; + setLoading(false); + setPendingUri(null); + if (activeRef.current && baseUrlRef.current) scheduleNext(); + }, [scheduleNext]); + + // Force the next frame immediately — used right after a tap so the mirror + // reflects the result without waiting out the idle gap. + const refresh = useCallback(() => { + clearTimer(); + armFrame(); + }, [armFrame, clearTimer]); + + const fetchInfo = useCallback(async () => { + const base = baseUrlRef.current; + if (!base) return; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 4000); + const res = await fetch(`${base}/api/screen/info`, { signal: controller.signal }); + clearTimeout(timer); + if (!res.ok) return; + const data = (await res.json()) as ScreenInfo; + if (data && typeof data.width === 'number' && typeof data.height === 'number') { + setInfo(data); + } + } catch { + // Info is best-effort; the next poll retries. + } + }, []); + + // Lifecycle: (re)start when activated or when the printer host changes; + // tear everything down when inactive so a backgrounded section isn't + // loading the printer. + useEffect(() => { + if (!active || !baseUrl) { + clearTimer(); + inFlightRef.current = false; + setLoading(false); + return; + } + setHasFrame(false); + erroredRef.current = false; + inFlightRef.current = false; + void fetchInfo(); + armFrame(); + const infoTimer = setInterval(fetchInfo, INFO_POLL_MS); + return () => { + clearTimer(); + clearInterval(infoTimer); + inFlightRef.current = false; + setLoading(false); + }; + }, [active, baseUrl, armFrame, clearTimer, fetchInfo]); + + const tap = useCallback(async (x: number, y: number, force = false): Promise => { + const base = baseUrlRef.current; + if (!base) return { ok: false, status: 0 }; + const { w, h } = boundsRef.current; + const cx = Math.max(0, Math.min(w - 1, Math.round(x))); + const cy = Math.max(0, Math.min(h - 1, Math.round(y))); + try { + const res = await fetch(`${base}/api/screen/tap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ x: cx, y: cy, force }), + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + x?: number; + y?: number; + error?: string; + state?: string; + }; + if (res.ok) { + setTapError(null); + return { ok: true, status: 200, x: data.x ?? cx, y: data.y ?? cy }; + } + if (res.status === 409) { + setTapError({ status: 409, message: 'Taps are blocked while printing', state: data.state }); + } else { + setTapError({ status: res.status, message: data.error || `Tap failed (HTTP ${res.status})` }); + } + return { ok: false, status: res.status }; + } catch { + setTapError({ status: 0, message: 'Network error — is the printer reachable?' }); + return { ok: false, status: 0 }; + } + }, []); + + const clearTapError = useCallback(() => setTapError(null), []); + + return { + baseUrl, + committedUri, + pendingUri, + hasFrame, + loading, + info, + printState: printState ?? 'standby', + throttled, + touchOk, + touchError, + tapError, + clearTapError, + tap, + refresh, + onPendingLoaded, + onPendingError, + width, + height, + }; +} diff --git a/hooks/useSettings.tsx b/hooks/useSettings.tsx index 9c2b04a..9dca2cf 100644 --- a/hooks/useSettings.tsx +++ b/hooks/useSettings.tsx @@ -4,6 +4,7 @@ import { setLanguage } from '../services/i18n'; import { colors } from '../constants/theme'; import { colors as cockpitColors } from '../components/settings/cockpitTheme'; import { setAccent } from '../components/dashboard/shared'; +import { setNativeAiDetectionSensitivity } from '../services/nativeSlicer'; import { DEFAULT_SETTINGS, migrateSettings } from '../services/settingsMigration'; import type { Settings } from '../services/settingsMigration'; @@ -61,6 +62,10 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)).catch(() => {}); latestRef.current = migrated; setSettings(migrated); + // Printer transport must not wait on an unrelated native preference + // mirror. On a cold Android launch the bridge can still be warming up, + // which used to delay `loaded` and therefore the initial connection. + void setNativeAiDetectionSensitivity(migrated.aiDetectionSensitivity).catch(() => {}); } catch { // corrupt/missing settings — fall back to defaults } finally { @@ -74,7 +79,10 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { latestRef.current = next; applyAppearance(next); setSettings(next); - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + await Promise.all([ + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)), + setNativeAiDetectionSensitivity(next.aiDetectionSensitivity), + ]); }, []); const value = useMemo(() => ({ settings, loaded, update }), [settings, loaded, update]); diff --git a/package-lock.json b/package-lock.json index 186e5de..0f21a16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "u1-control", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "u1-control", - "version": "3.0.0", + "version": "3.0.1", "license": "AGPL-3.0-or-later", "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -22,6 +22,7 @@ "expo-intent-launcher": "~13.0.8", "expo-linking": "~8.0.12", "expo-media-library": "~18.2.1", + "expo-network": "~8.0.8", "expo-notifications": "~0.32.17", "expo-router": "~6.0.24", "expo-screen-orientation": "~9.0.9", @@ -6543,6 +6544,16 @@ "react-native": "*" } }, + "node_modules/expo-network": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-8.0.8.tgz", + "integrity": "sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-notifications": { "version": "0.32.17", "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz", diff --git a/package.json b/package.json index 7ee3c76..e2516e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "u1-control", - "version": "3.0.0", + "version": "3.0.1", "license": "AGPL-3.0-or-later", "main": "expo-router/entry", "private": true, @@ -27,6 +27,7 @@ "expo-intent-launcher": "~13.0.8", "expo-linking": "~8.0.12", "expo-media-library": "~18.2.1", + "expo-network": "~8.0.8", "expo-notifications": "~0.32.17", "expo-router": "~6.0.24", "expo-screen-orientation": "~9.0.9", diff --git a/scripts/check-regressions.js b/scripts/check-regressions.js index 9c79557..46f050d 100644 --- a/scripts/check-regressions.js +++ b/scripts/check-regressions.js @@ -3,6 +3,10 @@ const fs = require('node:fs'); const path = require('node:path'); const ts = require('typescript'); +// Repo root, resolved off this script's own location rather than the working +// directory, so the asset checks below pass wherever the suite is run from. +const REPO_ROOT = path.dirname(require.resolve('../package.json')); + require.extensions['.ts'] = function loadTypeScript(module, filename) { const source = fs.readFileSync(filename, 'utf8'); const { outputText } = ts.transpileModule(source, { @@ -29,6 +33,12 @@ const { buildSettingsSavePatch, hasDraftChanges, } = require(path.join('..', 'services', 'settingsDraft.ts')); +const { + bespok3dCredentialStorageKey, + createEnrolledBespok3dCredentialRecord, + createPreparedBespok3dCredentialRecord, + normalizeBespok3dCredentialRecord, +} = require(path.join('..', 'services', 'bespok3dCredentials.ts')); const { DEFAULT_SETTINGS, STORAGE_VERSION, @@ -71,13 +81,18 @@ const { findPandaBreathTemperatureSource, } = require(path.join('..', 'services', 'chamberTemperature.ts')); const { + buildAiMonitoringCommand, buildManualFilamentSlotCommand, fileUrl, + helixdLanBaseUrl, + helixdRemoteBaseUrl, + helixdRemoteMoonrakerUrl, isTailscaleUrl, normalizeBaseUrl, normalizeMoonrakerUrl, printerConnectionUrl, resolveCameraUrl, + resolveScreenApiUrl, resolveSnapshotUrl, thumbnailUrl, validatePrinterConnectionTarget, @@ -313,6 +328,7 @@ test('settings migration starts first launch without a prefilled printer', () => assert.equal(migrated.connectionMode, 'lan'); assert.equal(migrated.dashboard.pandaBreath, false); assert.equal(migrated.temperatureUnit, 'c'); + assert.equal(migrated.aiDetectionSensitivity, 'low'); assert.deepEqual(migrated.printers, []); }); @@ -333,6 +349,7 @@ test('settings migration normalizes legacy single-printer settings', () => { tailscaleUrl: 'http://100.64.0.50:7125', cameraUrl: '/webcam/snapshot', connectionMode: 'lan', + kind: 'snapmaker-u1', }); }); @@ -365,6 +382,11 @@ test('settings migration normalizes temperature unit', () => { assert.equal(migrateSettings({ temperatureUnit: 'wat' }).temperatureUnit, 'c'); }); +test('settings migration accepts only firmware-supported AI sensitivity values', () => { + assert.equal(migrateSettings({ aiDetectionSensitivity: 'high' }).aiDetectionSensitivity, 'high'); + assert.equal(migrateSettings({ aiDetectionSensitivity: 'medium' }).aiDetectionSensitivity, 'low'); +}); + test('temperature unit helpers convert display and input values', () => { assert.equal(normalizeTemperatureUnit('f'), 'f'); assert.equal(normalizeTemperatureUnit('bad'), 'c'); @@ -534,6 +556,29 @@ test('normalizes Moonraker URLs with default HTTP port 7125', () => { assert.equal(normalizeMoonrakerUrl('http://100.115.155.101:80'), 'http://100.115.155.101:7125'); }); +test('derives the AD5X helixd origin without hardcoding a remote host', () => { + assert.equal(helixdLanBaseUrl('http://192.168.1.83:7125'), 'http://192.168.1.83'); + assert.equal(helixdLanBaseUrl('http://192.168.1.83/fluidd/'), 'http://192.168.1.83'); + assert.equal(helixdLanBaseUrl('not a valid host'), ''); +}); + +test('accepts helixd serve_url only while its backend is running', () => { + const live = { + backend_state: 'Running', + dns_name: 'renamed-printer.example.ts.net', + serve_url: 'https://renamed-printer.example.ts.net/', + }; + assert.equal(helixdRemoteBaseUrl(live), 'https://renamed-printer.example.ts.net'); + assert.equal(helixdRemoteBaseUrl({ ...live, backend_state: 'Stopped' }), ''); + assert.equal(helixdRemoteBaseUrl({ ...live, serve_url: '' }), ''); + assert.equal(helixdRemoteBaseUrl({ ...live, serve_url: 'javascript:alert(1)' }), ''); + assert.equal( + helixdRemoteMoonrakerUrl(helixdRemoteBaseUrl(live)), + 'http://renamed-printer.example.ts.net:7125' + ); + assert.equal(helixdRemoteMoonrakerUrl('https://ordinary.example.com'), ''); +}); + test('detects Tailscale hosts and IPs', () => { assert.equal(isTailscaleUrl('http://100.115.155.101:7125'), true); assert.equal(isTailscaleUrl('printer.tailnet.ts.net'), true); @@ -622,6 +667,14 @@ test('resolves camera paths against active printer host on port 80', () => { resolveCameraUrl('http://camera.local/stream', 'http://192.168.1.17:7125'), 'http://camera.local/stream' ); + assert.equal( + resolveCameraUrl('/webcam/?action=stream', 'https://renamed-printer.example.ts.net'), + 'https://renamed-printer.example.ts.net/webcam/?action=stream' + ); + assert.equal( + resolveScreenApiUrl('https://renamed-printer.example.ts.net'), + 'https://renamed-printer.example.ts.net' + ); }); test('resolves snapshot URL from explicit value or webcam stream', () => { @@ -647,6 +700,57 @@ test('builds encoded file and thumbnail URLs', () => { ); }); +test('buildAiMonitoringCommand explicitly arms U1 spaghetti and bed checks', () => { + assert.equal( + buildAiMonitoringCommand(true), + 'DEFECT_DETECTION_CONFIG MAIN_ENABLE=1 CLEAN_BED_ENABLE=1 NOODLE_ENABLE=1 SENSITIVITY=low' + ); + assert.equal( + buildAiMonitoringCommand(true, 'high'), + 'DEFECT_DETECTION_CONFIG MAIN_ENABLE=1 CLEAN_BED_ENABLE=1 NOODLE_ENABLE=1 SENSITIVITY=high' + ); + assert.equal( + buildAiMonitoringCommand(false), + 'DEFECT_DETECTION_CONFIG MAIN_ENABLE=0' + ); +}); + +test('slicer offers AI Monitoring with the promised behavior and applies it before print', () => { + const dialog = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'PrintPreprocessDialog.tsx'), + 'utf8' + ); + const slicer = fs.readFileSync( + path.join(REPO_ROOT, 'app', '(tabs)', 'slicer.tsx'), + 'utf8' + ); + const settingsScreen = fs.readFileSync( + path.join(REPO_ROOT, 'app', '(tabs)', 'settings.tsx'), + 'utf8' + ); + const settingsHook = fs.readFileSync( + path.join(REPO_ROOT, 'hooks', 'useSettings.tsx'), + 'utf8' + ); + const nativeSlicer = fs.readFileSync( + path.join(REPO_ROOT, 'services', 'nativeSlicer.ts'), + 'utf8' + ); + + assert.match(dialog, />AI Monitoring { for (const bad of [-1, 4, 1.5, NaN, Infinity, '0', null]) { assert.throws( @@ -898,7 +1002,34 @@ test('uses M73 remaining time and adjusts it for observed printer pace', () => { ); }); -test('holds unstable live ETA early and retains byte-progress fallback', () => { +test('prefers a printer-reported countdown even before progress is stable', () => { + assert.deepEqual( + calculatePrintEtas({ + printDuration: 30, + slicerTotalSeconds: 3600, + m73: null, + fallbackProgress: 0, + printerRemainingSeconds: 44 * 60, + }), + { + slicerRemainingSeconds: 44 * 60, + liveRemainingSeconds: 44 * 60, + source: 'printer', + } + ); + assert.equal( + calculatePrintEtas({ + printDuration: 3600, + slicerTotalSeconds: 3600, + m73: null, + fallbackProgress: 1, + printerRemainingSeconds: 0, + }).liveRemainingSeconds, + 0 + ); +}); + +test('shows slicer timing early and retains byte-progress fallback', () => { assert.deepEqual( calculatePrintEtas({ printDuration: 60, @@ -912,10 +1043,23 @@ test('holds unstable live ETA early and retains byte-progress fallback', () => { }), { slicerRemainingSeconds: 3540, - liveRemainingSeconds: null, + liveRemainingSeconds: 3540, source: 'm73', } ); + assert.deepEqual( + calculatePrintEtas({ + printDuration: 60, + slicerTotalSeconds: 3600, + m73: null, + fallbackProgress: 0, + }), + { + slicerRemainingSeconds: 3540, + liveRemainingSeconds: 3540, + source: 'slicer', + } + ); assert.deepEqual( calculatePrintEtas({ printDuration: 600, @@ -1040,3 +1184,1935 @@ test('builds camera snapshot cache-bust URL and filename', () => { 'helix-camera-2026-07-03T12-34-56-789Z.jpg' ); }); + +test('camera lifecycle uses continuous U1 video with a bounded snapshot fallback', () => { + const source = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'CameraFeed.tsx'), + 'utf8' + ); + const transport = fs.readFileSync( + path.join(REPO_ROOT, 'hooks', 'usePrinterTransport.tsx'), + 'utf8' + ); + assert.match(source, /Math\.max\(Date\.now\(\), previous \+ 1\)/); + assert.doesNotMatch(source, /tick \|\| Date\.now\(\)/); + assert.match(source, /const streamPaused = paused \|\| !screenFocused \|\| !appActive/); + assert.match(source, /LIVE_PREVIEW_TIMEOUT_MS = 4_000/); + assert.match(source, /parsed\.pathname = '\/webcam\/stream\.mjpg'/); + assert.match(source, /buildPlayerHtml\(mjpegBridgeUrl \?\? url, isSnapshot\)/); + assert.match(source, /readySent = true;[\s\S]*ReactNativeWebView\.postMessage/); + assert.match(source, /setSnapshotFallbackKey\(playerKey\)/); + assert.match(source, /\|\| useSnapshotFallback/); + assert.match(source, /window\.addEventListener\('pagehide', stopPlayer\)/); + assert.match(transport, /key={`moonraker:\$\{printer\?\.id \?\? 'none'\}`}/); +}); + +test('AD5X dashboard prefers Moonraker advertised camera endpoints', () => { + const source = fs.readFileSync( + path.join(REPO_ROOT, 'hooks', 'useDashboardModel.ts'), + 'utf8' + ); + const transport = fs.readFileSync( + path.join(REPO_ROOT, 'hooks', 'useMoonraker.tsx'), + 'utf8' + ); + assert.match(source, /activePrinter\?\.kind === 'flashforge-ad5x'/); + assert.match(source, /advertised && preferAdvertised/); + assert.match(source, /proxyUrl \|\| activeUrl/); + assert.match(transport, /const primary = normalizeMoonrakerUrl\(activePrinter\.url/); + assert.match(transport, /const tailscale = helixdRemoteMoonrakerUrl\(tailscaleProxy\)/); +}); + +const { + DEFAULT_PRINTER_KIND, + MANUAL_PRINTER_KIND, + PRINTER_PROFILES, + bambuBedForSerial, + detectPrinterKind, + normalizePrinterKind, + printerProfile, + resolveBedProfile, + resolveMachineProfile, +} = require(path.join('..', 'services', 'printerProfiles.ts')); +const { + printerEntryFromDiscovery, +} = require(path.join('..', 'services', 'printerSetup.ts')); + +test('network discovery produces the same editable printer entry everywhere', () => { + const discovered = { + ip: '192.168.1.83', + moonrakerUrl: 'http://192.168.1.83:7125', + name: '', + serial: 'SN123', + machineType: 'AD5X', + kind: 'flashforge-ad5x', + cameraUrl: '', + cameraName: null, + }; + assert.deepEqual(printerEntryFromDiscovery(discovered, 'p1', 'Printer 1'), { + id: 'p1', + name: 'Printer 1', + url: 'http://192.168.1.83:7125', + tailscaleUrl: '', + cameraUrl: '/webcam/?action=stream', + connectionMode: 'lan', + kind: 'flashforge-ad5x', + serialNumber: 'SN123', + }); +}); + +test('detects the Snapmaker U1 from PAXX product_info', () => { + assert.equal(detectPrinterKind({ machineType: 'U1' }), 'snapmaker-u1'); + assert.equal(detectPrinterKind({ machineType: 'Snapmaker u1' }), 'snapmaker-u1'); + assert.equal(detectPrinterKind({ serial: '811234567' }), 'snapmaker-u1'); +}); + +test('detects the FlashForge Klipper mod, which exposes no product_info', () => { + assert.equal(detectPrinterKind({ hostname: 'flashforge' }), 'flashforge-ad5x'); + assert.equal(detectPrinterKind({ hostname: 'FlashForge-AD5X' }), 'flashforge-ad5x'); + assert.equal(detectPrinterKind({ machineType: 'AD5X' }), 'flashforge-ad5x'); + assert.equal(detectPrinterKind({ deviceName: 'Adventurer 5X' }), 'flashforge-ad5x'); +}); + +test('falls back to the generic Klipper profile for unknown machines', () => { + assert.equal(detectPrinterKind({}), 'generic-klipper'); + assert.equal(detectPrinterKind({ hostname: 'voron' }), 'generic-klipper'); + assert.equal(DEFAULT_PRINTER_KIND, 'generic-klipper'); +}); + +test('U1 identity wins over an unrelated hostname', () => { + assert.equal( + detectPrinterKind({ machineType: 'U1', hostname: 'flashforge' }), + 'snapmaker-u1' + ); +}); + +test('printer profiles describe toolheads and material slots', () => { + assert.equal(printerProfile('snapmaker-u1').toolheads, 4); + assert.equal(printerProfile('flashforge-ad5x').toolheads, 1); + assert.equal(printerProfile('flashforge-ad5x').materialSlots, 4); + assert.equal(printerProfile('generic-klipper').materialSlots, 0); + // Unknown kinds must resolve rather than throw. + assert.equal(printerProfile('nonsense').kind, DEFAULT_PRINTER_KIND); + assert.equal(normalizePrinterKind(undefined), DEFAULT_PRINTER_KIND); +}); + +test('camera fallbacks stay host-relative so the stream follows the printer', () => { + assert.equal(printerProfile('snapmaker-u1').defaultCameraPath, '/webcam/webrtc'); + // Matches the [webcam video] section zmod ships on the AD5X. + assert.equal(printerProfile('flashforge-ad5x').defaultCameraPath, '/webcam/?action=stream'); + assert.equal(printerProfile('generic-klipper').defaultCameraPath, ''); + + // A relative fallback must survive resolveCameraUrl unchanged apart from the host. + assert.equal( + resolveCameraUrl(printerProfile('flashforge-ad5x').defaultCameraPath, 'http://192.168.1.83:7125'), + 'http://192.168.1.83/webcam/?action=stream' + ); +}); + +test('settings migration assumes pre-v12 printers are U1s', () => { + const migrated = migrateSettings({ + activePrinterId: 'p1', + printers: [{ id: 'p1', name: 'Mine', url: 'http://192.168.1.17:7125' }], + }); + + assert.equal(migrated.printers[0].kind, 'snapmaker-u1'); + assert.equal(MANUAL_PRINTER_KIND, 'snapmaker-u1'); +}); + +test('settings migration keeps an explicitly saved printer kind', () => { + const migrated = migrateSettings({ + activePrinterId: 'p1', + printers: [ + { id: 'p1', name: 'AD5X', url: 'http://192.168.1.83:7125', kind: 'flashforge-ad5x' }, + ], + }); + + assert.equal(migrated.printers[0].kind, 'flashforge-ad5x'); +}); + +test('settings migration keeps a URL-less FlashForge AD5X printer', () => { + // The AD5X editor hides the URL/Tailscale/camera/connection fields, so a + // manually-added AD5X has no URL until discovery fills one in. Migration + // must not silently delete it (regression: "added but never appeared"). + const migrated = migrateSettings({ + activePrinterId: 'p1', + printers: [ + { + id: 'p1', + name: 'AD5X', + url: '', + tailscaleUrl: '', + kind: 'flashforge-ad5x', + serialNumber: 'SN123', + checkCode: 'ABCD1234', + }, + ], + }); + + assert.equal(migrated.printers.length, 1); + assert.equal(migrated.printers[0].id, 'p1'); + assert.equal(migrated.printers[0].kind, 'flashforge-ad5x'); + assert.equal(migrated.printers[0].serialNumber, 'SN123'); + assert.equal(migrated.printers[0].checkCode, 'ABCD1234'); +}); + +test('settings migration still drops a URL-less non-AD5X printer', () => { + // U1/generic entries can't be saved without a URL (editor validates it), so a + // URL-less one is corrupt/legacy junk and should still be filtered out. + const migrated = migrateSettings({ + activePrinterId: 'p1', + printers: [{ id: 'p1', name: 'Broken', url: '', tailscaleUrl: '', kind: 'snapmaker-u1' }], + }); + + assert.equal(migrated.printers.length, 0); +}); + +const { + FLASHFORGE_API_PORT, + flashforgeApiUrl, + hasFlashForgeCredentials, + normalizeSlotColor, + normalizeSlotMaterial, + parseMaterialStation, +} = require(path.join('..', 'services', 'flashforgeApi.ts')); + +// Captured verbatim from Parallel-7/FlashForgeEmulator running in AD5X mode. +const AD5X_DETAIL = { + hasMatlStation: true, + matlStationInfo: { + currentSlot: 1, + currentLoadSlot: 0, + slotCnt: 4, + slotInfos: [ + { slotId: 1, hasFilament: true, materialName: 'PLA', materialColor: '#FF0000' }, + { slotId: 2, hasFilament: true, materialName: 'PLA', materialColor: '#00FF00' }, + { slotId: 3, hasFilament: false, materialName: '', materialColor: '' }, + { slotId: 4, hasFilament: false, materialName: '', materialColor: '' }, + ], + stateAction: 0, + stateStep: 0, + }, +}; + +test('derives the FlashForge API URL from a Moonraker URL', () => { + assert.equal(FLASHFORGE_API_PORT, 8898); + assert.equal(flashforgeApiUrl('http://192.168.1.83:7125'), 'http://192.168.1.83:8898'); + assert.equal(flashforgeApiUrl('192.168.1.83'), 'http://192.168.1.83:8898'); + assert.equal(flashforgeApiUrl('http://192.168.1.83/some/path'), 'http://192.168.1.83:8898'); + assert.equal(flashforgeApiUrl(''), ''); +}); + +test('requires both FlashForge credentials', () => { + assert.equal(hasFlashForgeCredentials({ serialNumber: 'S', checkCode: 'C' }), true); + assert.equal(hasFlashForgeCredentials({ serialNumber: 'S', checkCode: ' ' }), false); + assert.equal(hasFlashForgeCredentials({ serialNumber: '', checkCode: 'C' }), false); + assert.equal(hasFlashForgeCredentials(undefined), false); +}); + +test('normalizes material slot colours and rejects junk', () => { + assert.equal(normalizeSlotColor('#FF0000'), '#ff0000'); + assert.equal(normalizeSlotColor('00FF00'), '#00ff00'); + assert.equal(normalizeSlotColor('#f0a'), '#ff00aa'); + assert.equal(normalizeSlotColor(''), ''); + assert.equal(normalizeSlotColor('red'), ''); + assert.equal(normalizeSlotColor(null), ''); +}); + +test('parses the AD5X material station into zero-based slots', () => { + const station = parseMaterialStation(AD5X_DETAIL); + + assert.equal(station.slots.length, 4); + // Wire slotIds are 1-based; Helix's filament arrays are 0-based. + assert.deepEqual(station.slots[0], { + index: 0, + loaded: true, + material: 'PLA', + colorHex: '#ff0000', + }); + assert.deepEqual(station.slots[2], { + index: 2, + loaded: false, + material: '', + colorHex: '', + }); + assert.equal(station.activeSlot, 0); + // currentLoadSlot 0 is the "nothing loading" sentinel, not slot zero. + assert.equal(station.loadingSlot, null); +}); + +test('treats a loading slot as one-based too', () => { + const station = parseMaterialStation({ + hasMatlStation: true, + matlStationInfo: { ...AD5X_DETAIL.matlStationInfo, currentSlot: 4, currentLoadSlot: 2 }, + }); + + assert.equal(station.activeSlot, 3); + assert.equal(station.loadingSlot, 1); +}); + +test('returns no material station for printers without one', () => { + assert.equal(parseMaterialStation({ hasMatlStation: false }), null); + assert.equal(parseMaterialStation({}), null); + assert.equal(parseMaterialStation(null), null); +}); + +test('tolerates slot entries missing their slotId', () => { + const station = parseMaterialStation({ + matlStationInfo: { slotInfos: [{ hasFilament: true, materialName: 'PETG' }] }, + }); + + assert.equal(station.slots[0].index, 0); + assert.equal(station.slots[0].material, 'PETG'); + assert.equal(station.activeSlot, null); +}); + +test('settings keep FlashForge credentials only when set', () => { + const migrated = migrateSettings({ + activePrinterId: 'p1', + printers: [ + { + id: 'p1', + name: 'AD5X', + url: 'http://192.168.1.83:7125', + kind: 'flashforge-ad5x', + serialNumber: 'SNMQRE9605619', + checkCode: '7c160a97', + }, + { id: 'p2', name: 'U1', url: 'http://192.168.1.17:7125', kind: 'snapmaker-u1' }, + ], + }); + + assert.equal(migrated.printers[0].serialNumber, 'SNMQRE9605619'); + assert.equal(migrated.printers[0].checkCode, '7c160a97'); + assert.equal('serialNumber' in migrated.printers[1], false); + assert.equal('checkCode' in migrated.printers[1], false); +}); + +// Captured verbatim from a real AD5X on firmware 3.1.5 (zmod 1.7.1-49). +// Differs from the emulator: empty slots carry the "?" sentinel, and an +// unloaded slot still remembers its last material/colour. +const AD5X_REAL_DETAIL = { + hasMatlStation: true, + matlStationInfo: { + currentLoadSlot: 0, + currentSlot: 0, + slotCnt: 4, + slotInfos: [ + { hasFilament: false, materialColor: '#FEF043', materialName: 'PLA', slotId: 1 }, + { hasFilament: true, materialColor: '#FEF043', materialName: 'PLA', slotId: 2 }, + { hasFilament: false, materialColor: '', materialName: '?', slotId: 3 }, + { hasFilament: false, materialColor: '', materialName: '?', slotId: 4 }, + ], + stateAction: 0, + stateStep: 0, + }, +}; + +test('treats the firmware "?" material sentinel as unknown', () => { + assert.equal(normalizeSlotMaterial('?'), ''); + assert.equal(normalizeSlotMaterial('PLA'), 'PLA'); + assert.equal(normalizeSlotMaterial(' PETG '), 'PETG'); + assert.equal(normalizeSlotMaterial(undefined), ''); +}); + +test('parses a real AD5X material station payload', () => { + const station = parseMaterialStation(AD5X_REAL_DETAIL); + + assert.equal(station.slots.length, 4); + // Slot 2 (index 1) is the only one actually loaded. + assert.deepEqual(station.slots[1], { + index: 1, + loaded: true, + material: 'PLA', + colorHex: '#fef043', + }); + // Unloaded but remembered: keep the metadata, flag it as not loaded. + assert.equal(station.slots[0].loaded, false); + assert.equal(station.slots[0].material, 'PLA'); + assert.equal(station.slots[0].colorHex, '#fef043'); + // "?" must never reach the UI. + assert.equal(station.slots[2].material, ''); + assert.equal(station.slots[3].material, ''); + // currentSlot 0 means nothing selected, not slot index 0. + assert.equal(station.activeSlot, null); + assert.equal(station.loadingSlot, null); +}); + +const { + materialStationToAceUnits, +} = require(path.join('..', 'services', 'aceModel.ts')); + +test('maps a FlashForge material station onto one ACE-style unit', () => { + const units = materialStationToAceUnits(parseMaterialStation(AD5X_REAL_DETAIL)); + + assert.equal(units.length, 1); + const unit = units[0]; + assert.equal(unit.connected, true); + assert.equal(unit.active, true); + // The AD5X has no dryer; don't invent one. + assert.equal(unit.dryer.active, false); + assert.equal(unit.temp, undefined); + assert.equal(unit.humidity, undefined); + assert.equal(unit.lanes.length, 4); + + assert.equal(unit.lanes[1].status, 'loaded'); + assert.equal(unit.lanes[1].material, 'PLA'); + assert.equal(unit.lanes[1].colorHex, '#fef043'); + + // Remembered-but-unloaded: keeps its metadata, reports EMPTY. + assert.equal(unit.lanes[0].status, 'empty'); + assert.equal(unit.lanes[0].material, 'PLA'); + + // Genuinely empty slots carry no material at all (the "?" was stripped). + assert.equal(unit.lanes[2].status, 'empty'); + assert.equal(unit.lanes[2].material, undefined); + assert.equal(unit.lanes[2].colorHex, undefined); +}); + +test('a slot being loaded shows as busy', () => { + const station = parseMaterialStation({ + hasMatlStation: true, + matlStationInfo: { ...AD5X_REAL_DETAIL.matlStationInfo, currentLoadSlot: 3 }, + }); + const lanes = materialStationToAceUnits(station)[0].lanes; + + assert.equal(lanes[2].status, 'busy'); + assert.equal(lanes[1].status, 'loaded'); +}); + +test('no material station yields no units', () => { + assert.deepEqual(materialStationToAceUnits(null), []); +}); + +test('always renders four lanes even when the printer reports fewer', () => { + const units = materialStationToAceUnits({ + slots: [{ index: 0, loaded: true, material: 'PLA', colorHex: '#ffffff' }], + activeSlot: 0, + loadingSlot: null, + }); + + assert.equal(units[0].lanes.length, 4); + assert.equal(units[0].lanes[0].status, 'loaded'); + assert.equal(units[0].lanes[3].status, 'empty'); +}); + +const { + dashboardSectionAvailable, + getDashboardSections, + normalizeDashboardByPrinter, + setDashboardForPrinter, +} = require(path.join('..', 'services', 'dashboardSections.ts')); + +test('dashboard sections fall back to the global set for uncustomised printers', () => { + const s = migrateSettings({ + activePrinterId: 'p1', + dashboard: { gui: false, macros: true }, + printers: [{ id: 'p1', name: 'U1', url: 'http://192.168.1.17:7125' }], + }); + + const sections = getDashboardSections(s); + assert.equal(sections.gui, false); + assert.equal(sections.macros, true); + // Unspecified keys still come from defaults, not undefined. + assert.equal(sections.camera, true); +}); + +test('bambu never offers or renders the unsupported printer screen', () => { + const s = migrateSettings({ + activePrinterId: 'p1s', + dashboard: { gui: true }, + printers: [ + { id: 'p1s', name: 'P1S', url: 'http://192.168.1.50', kind: 'bambu-lan' }, + { id: 'u1', name: 'U1', url: 'http://192.168.1.17:7125', kind: 'snapmaker-u1' }, + ], + }); + + assert.equal(getDashboardSections(s).gui, false); + assert.equal(dashboardSectionAvailable('bambu-lan', 'gui'), false); + assert.equal(dashboardSectionAvailable('bambu-lan', 'camera'), true); + assert.equal( + getDashboardSections({ ...s, activePrinterId: 'u1' }).gui, + true + ); +}); + +test('hiding a section on one printer leaves the others alone', () => { + const s = migrateSettings({ + activePrinterId: 'ad5x', + dashboard: { gui: true }, + printers: [ + { id: 'u1', name: 'U1', url: 'http://192.168.1.17:7125' }, + { id: 'ad5x', name: 'AD5X', url: 'http://192.168.1.83:7125', kind: 'flashforge-ad5x' }, + ], + }); + + const byPrinter = setDashboardForPrinter( + s.dashboardByPrinter, + 'ad5x', + { ...getDashboardSections(s), gui: false }, + s.dashboard + ); + + // AD5X hides the GUI card... + assert.equal(getDashboardSections({ ...s, dashboardByPrinter: byPrinter }).gui, false); + // ...while the U1 keeps it. + assert.equal( + getDashboardSections({ ...s, activePrinterId: 'u1', dashboardByPrinter: byPrinter }).gui, + true + ); +}); + +test('per-printer dashboard map drops malformed entries', () => { + const template = DEFAULT_SETTINGS.dashboard; + const map = normalizeDashboardByPrinter( + { p1: { camera: false }, p2: null, p3: [], '': { camera: false } }, + template + ); + + assert.deepEqual(Object.keys(map), ['p1']); + assert.equal(map.p1.camera, false); + assert.equal(map.p1.temps, template.temps); +}); + +test('settings migration seeds an empty per-printer dashboard map', () => { + const migrated = migrateSettings({ dashboard: { gui: false } }); + assert.deepEqual(migrated.dashboardByPrinter, {}); + // The global set still carries the user's choice as the template. + assert.equal(migrated.dashboard.gui, false); +}); + +const { + applyBambuReport, + bambuFilamentWriteLocation, + bambuTrays, + genericBambuFilamentId, + isBambuTrayLoaded, + mergeBambuState, + resolveBambuFilamentEditIdentity, +} = require(path.join('..', 'services', 'bambuReport.ts')); +const { + bambuActiveSlot, + bambuAmsHealth, + bambuHmsFaults, + bambuStatus, +} = require(path.join('..', 'services', 'bambuAdapter.ts')); +const { + bambuFanPercent, + buildBambuCalibrationOption, + buildBambuFanCommand, + buildBambuTemperatureCommand, +} = require(path.join('..', 'services', 'bambuControls.ts')); + +// A real pushall response from a P1S, with identifiers redacted. Captured by +// android/app/src/test/.../BambuLiveConnectionTest.kt — regenerate it with +// BAMBU_DUMP_DIR set rather than editing this by hand. +const BAMBU_P1S_REPORT = require('./fixtures/bambu-p1s-report.json'); + +function bambuStateFromFixture() { + return applyBambuReport({}, BAMBU_P1S_REPORT).state; +} + +test('bambu full dumps and deltas are told apart by msg, not by size', () => { + const full = applyBambuReport({}, BAMBU_P1S_REPORT); + assert.equal(full.isFullState, true); + + const delta = applyBambuReport(full.state, { + print: { bed_temper: 41.5, command: 'push_status', msg: 1, sequence_id: '10856' }, + }); + assert.equal(delta.isFullState, false); + // The delta carried four fields; everything else must survive it. + assert.equal(delta.state.bed_temper, 41.5); + assert.equal(delta.state.total_layer_num, BAMBU_P1S_REPORT.print.total_layer_num); + assert.ok(delta.state.ams, 'a bed_temper delta must not wipe out the AMS'); +}); + +test('bambu reports without a print section leave state untouched', () => { + const state = bambuStateFromFixture(); + const after = applyBambuReport(state, { info: { command: 'get_version', module: [] } }); + assert.equal(after.isFullState, false); + assert.equal(after.state, state); +}); + +test('bambu state merge replaces arrays instead of blending them', () => { + const merged = mergeBambuState( + { ams: { ams: [{ id: '0' }, { id: '1' }] }, keep: 1 }, + { ams: { ams: [{ id: '0' }] } } + ); + assert.equal(merged.ams.ams.length, 1); + assert.equal(merged.keep, 1); +}); + +test('bambu status maps onto the klipper shape the dashboard reads', () => { + const status = bambuStatus(bambuStateFromFixture()); + + // FINISH is a completed print, not an idle machine. + assert.equal(status.print_stats.state, 'complete'); + assert.equal(status.print_stats.filename, '14min44s, Bambu PLA Basic, A1'); + assert.equal(status.print_stats.info.current_layer, 192); + assert.equal(status.print_stats.info.total_layer, 192); + assert.equal(status.display_status.progress, 1); + assert.equal(status.virtual_sdcard.is_active, false); + + // Read from the fixture rather than hardcoded, so recapturing it on a warmer + // or cooler printer does not fail the suite. + assert.equal(status.extruder.temperature, BAMBU_P1S_REPORT.print.nozzle_temper); + assert.equal(status.extruder.target, BAMBU_P1S_REPORT.print.nozzle_target_temper); + assert.equal(status.heater_bed.temperature, BAMBU_P1S_REPORT.print.bed_temper); + assert.equal(status.toolhead.extruder, 'extruder'); + assert.equal(status.fan.speed, 0); + assert.deepEqual(status.bambu.fans, { part: 0, aux: 0, chamber: 0 }); + assert.deepEqual(status.bambu.hms_faults, []); +}); + +test('bambu skip-object state keeps only valid unique printer IDs', () => { + const state = bambuStateFromFixture(); + const status = bambuStatus({ ...state, s_obj: [42, '77', 42, -1, 'bad', 2.5] }); + assert.deepEqual(status.bambu.skipped_object_ids, [42, 77]); + + const mqttSource = fs.readFileSync(path.join(REPO_ROOT, 'services', 'bambuMqtt.ts'), 'utf8'); + assert.match(mqttSource, /command: 'skip_objects'/); + assert.match(mqttSource, /obj_list: ids/); + assert.match(mqttSource, /normalizedBambuJobName\(uploaded\.remoteName\) !== active/); + + const heroSource = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'dashboard', 'parts', 'Hero.tsx'), + 'utf8' + ); + assert.match(heroSource, /activePrintObjects\.length > 1/); + assert.match(heroSource, /selectedSkipIds\.length >= activePrintObjects\.length/); + assert.match(heroSource, /Selected objects will stop printing permanently/); +}); + +test('bambu HMS faults preserve all four code groups and use exact descriptions', () => { + const [fault] = bambuHmsFaults({ + hms: [{ attr: 0x03008003, code: 0x00010001 }], + print_error: 0, + }); + + assert.equal(fault.code, '0300-8003-0001-0001'); + assert.equal(fault.summary, 'Spaghetti was detected by AI print monitoring.'); + assert.equal(fault.severity, 'warning'); + assert.equal( + fault.helpUrl, + 'https://wiki.bambulab.com/en/hms/home' + ); +}); + +test('bambu fault parsing keeps unknown codes honest and filters cancel echoes', () => { + const faults = bambuHmsFaults({ + hms: [ + { attr: '0x07010000', code: '0x00018099' }, + { attr: 0x03000000, code: 0x0000400c }, + { attr: 'not-a-code', code: 1 }, + ], + print_error: 0x05004001, + }); + + assert.deepEqual(faults.map((fault) => fault.code), [ + '0500-4001', + '0701-0000-0001-8099', + ]); + assert.equal(faults[1].summary, 'AMS or filament system fault.'); + assert.equal(faults[0].source, 'print_error'); +}); + +test('bambu climate commands are bounded and use the documented fan channels', () => { + assert.equal(buildBambuTemperatureCommand('nozzle', 220), 'M104 T0 S220'); + assert.equal(buildBambuTemperatureCommand('bed', 65), 'M140 S65'); + assert.equal(buildBambuFanCommand('part', 100), 'M106 P1 S255'); + assert.equal(buildBambuFanCommand('aux', 50), 'M106 P2 S128'); + assert.equal(buildBambuFanCommand('chamber', 0), 'M106 P3 S0'); + assert.throws(() => buildBambuTemperatureCommand('nozzle', 301), RangeError); + assert.throws(() => buildBambuTemperatureCommand('bed', -1), RangeError); + assert.throws(() => buildBambuFanCommand('part', 101), RangeError); + assert.equal(bambuFanPercent('15'), 100); + assert.equal(bambuFanPercent(7.5), 50); + assert.equal(bambuFanPercent(undefined), undefined); +}); + +test('bambu calibration uses only the common BambuStudio option bits', () => { + assert.equal(buildBambuCalibrationOption({ bedLeveling: true }), 1 << 1); + assert.equal(buildBambuCalibrationOption({ vibration: true }), 1 << 2); + assert.equal(buildBambuCalibrationOption({ motorNoise: true }), 1 << 3); + assert.equal( + buildBambuCalibrationOption({ bedLeveling: true, vibration: true, motorNoise: true }), + 14 + ); + assert.throws(() => buildBambuCalibrationOption({}), /at least one/i); + + const mqttSource = fs.readFileSync(path.join(REPO_ROOT, 'services', 'bambuMqtt.ts'), 'utf8'); + assert.match(mqttSource, /command: 'calibration'/); + assert.match(mqttSource, /option: buildBambuCalibrationOption\(options\)/); + + const toolsSource = fs.readFileSync(path.join(REPO_ROOT, 'app', '(tabs)', 'tools.tsx'), 'utf8'); + assert.match(toolsSource, /tool\.key === ["']bambuCalibration["']/); + assert.match(toolsSource, /\(!connected \|\| !bambuIdle\)/); + assert.match(toolsSource, /The printer must be empty and unobstructed/); +}); + +test('bambu print states map to klipper equivalents', () => { + const state = bambuStateFromFixture(); + const stateFor = (gcodeState) => bambuStatus({ ...state, gcode_state: gcodeState }).print_stats.state; + + assert.equal(stateFor('IDLE'), 'standby'); + assert.equal(stateFor('RUNNING'), 'printing'); + assert.equal(stateFor('PAUSE'), 'paused'); + assert.equal(stateFor('FAILED'), 'error'); + // Heating and levelling read as busy, not asleep. + assert.equal(stateFor('PREPARE'), 'printing'); + // An unknown state must not crash or claim the printer is running. + assert.equal(stateFor('SOMETHING_NEW'), 'standby'); +}); + +test('bambu remaining time is exposed in seconds', () => { + const state = { ...bambuStateFromFixture(), mc_remaining_time: 44 }; + assert.equal(bambuStatus(state).print_stats.info.remaining_time, 44 * 60); +}); + +test('bambu chamber temperature only appears when the printer has a reading', () => { + const state = bambuStateFromFixture(); + assert.ok(bambuStatus({ ...state, chamber_temper: 32 })['temperature_sensor chamber']); + // The P-series reports 0 when it has no real sensor; that must not render. + assert.equal(bambuStatus({ ...state, chamber_temper: 0 })['temperature_sensor chamber'], undefined); +}); + +test('bambu enumerates one AMS unit as four trays', () => { + const state = bambuStateFromFixture(); + const trays = bambuTrays(state); + + assert.equal(trays.length, 4); + assert.deepEqual(trays.map((entry) => entry.index), [0, 1, 2, 3]); + // Filament sitting in a tray is not the same as filament in the hotend: + // this capture has a loaded slot 0 but tray_now 255. + assert.equal(isBambuTrayLoaded(state, 0), true); + assert.equal(isBambuTrayLoaded(state, 1), false); + assert.equal(bambuActiveSlot(state), null); +}); + +test('bambu without an AMS exposes one honest external spool', () => { + const state = JSON.parse(JSON.stringify(bambuStateFromFixture())); + state.ams = { ...state.ams, ams: [], ams_exist_bits: '0', tray_exist_bits: '0' }; + state.vt_tray = { + ...state.vt_tray, + tray_info_idx: 'GFL99', + tray_type: 'PLA', + tray_color: '3366CCFF', + nozzle_temp_min: '190', + nozzle_temp_max: '240', + }; + + const idle = bambuStatus(state).print_task_config; + assert.equal(idle.bambu_filament_source, 'external'); + assert.deepEqual(idle.filament_exist, [null], 'the idle side holder has no occupancy sensor'); + assert.deepEqual(idle.filament_type, ['PLA']); + assert.deepEqual(idle.filament_color_rgba, ['3366CCFF']); + assert.equal(idle.filament_exist.length, 1, 'no AMS must not grow four fake bays'); + + state.ams.tray_now = '254'; + assert.deepEqual(bambuStatus(state).print_task_config.filament_exist, [true]); +}); + +test('bambu filament write locations preserve AMS and use the captured external address', () => { + assert.deepEqual(bambuFilamentWriteLocation('ams', 0), { unit: 0, tray: 0 }); + assert.deepEqual(bambuFilamentWriteLocation('ams', 5), { unit: 1, tray: 1 }); + assert.deepEqual(bambuFilamentWriteLocation('external', 0), { + unit: 255, + tray: 254, + slot: 0, + }); + assert.throws(() => bambuFilamentWriteLocation('external', 1), /only has channel 0/); + + const mqttSource = fs.readFileSync( + path.join(REPO_ROOT, 'services', 'bambuMqtt.ts'), + 'utf8' + ); + assert.match(mqttSource, /slot_id: setting\.slot/); +}); + +test('bambu filament edits use occupancy rather than treating a blank preset id as empty', () => { + assert.deepEqual(resolveBambuFilamentEditIdentity(true, '', 'PLA'), { + ok: true, + filamentId: 'GFL99', + }); + assert.deepEqual(resolveBambuFilamentEditIdentity(true, ' P1234567 ', 'PLA'), { + ok: true, + filamentId: 'P1234567', + }); + assert.deepEqual(resolveBambuFilamentEditIdentity(false, 'GFL99', 'PLA'), { + ok: false, + reason: 'empty', + }); +}); + +test('bambu filament edits only fall back to official generic material ids', () => { + assert.equal(genericBambuFilamentId(' pla '), 'GFL99'); + assert.equal(genericBambuFilamentId('PETG'), 'GFG99'); + assert.equal(genericBambuFilamentId('ABS'), 'GFB99'); + assert.equal(genericBambuFilamentId('TPU'), 'GFU99'); + assert.deepEqual(resolveBambuFilamentEditIdentity(true, '', 'PEEK'), { + ok: false, + reason: 'unsupported-material', + }); +}); + +test('bambu AMS humidity and temperature are surfaced', () => { + const unit = BAMBU_P1S_REPORT.print.ams.ams[0]; + const health = bambuAmsHealth(bambuStateFromFixture()); + + assert.equal(health.length, 1); + assert.equal(health[0].unit, 0); + assert.equal(health[0].humidity, Number(unit.humidity)); + assert.equal(health[0].temperature, Number(unit.temp)); +}); + +test('bambu maps a real loaded AMS tray onto print_task_config', () => { + const tray = BAMBU_P1S_REPORT.print.ams.ams[0].tray[0]; + const slots = bambuStatus(bambuStateFromFixture()).print_task_config; + + assert.deepEqual(slots.filament_exist, [true, false, false, false]); + assert.equal(slots.filament_type[0], tray.tray_type); + // resolveFilamentSlots parses 8-digit RGBA, so it is passed through as-is. + assert.equal(slots.filament_color_rgba[0], tray.tray_color); + // This spool was configured by hand: tray_sub_brands is empty, and joining a + // blank subtype must not leave trailing whitespace in the slot label. + assert.equal(slots.filament_sub_type[0], ''); + // Empty slots must claim nothing at all. + assert.equal(slots.filament_type[1], ''); +}); + +test('bambu treats a hand-configured slot as generic, not Bambu', () => { + // tray_is_bbl_bits is "1" on this real capture even though the slot was set + // by hand to Generic PETG (tray_info_idx GFG99) and its RFID tag is zeroed. + // The tag is the only trustworthy signal for a genuine spool. + const report = BAMBU_P1S_REPORT.print; + assert.equal(report.ams.tray_is_bbl_bits, '1'); + assert.equal(report.ams.ams[0].tray[0].tag_uid, '0000000000000000'); + assert.equal(bambuStatus(bambuStateFromFixture()).print_task_config.filament_vendor[0], 'Generic'); +}); + +test('bambu recognises a genuine RFID-read spool as Bambu', () => { + const state = bambuStateFromFixture(); + state.ams.ams[0].tray[0].tag_uid = 'A1B2C3D4E5F60708'; + assert.equal(bambuStatus(state).print_task_config.filament_vendor[0], 'Bambu'); +}); + +test('bambu strips a repeated base type out of the sub-brand', () => { + const state = bambuStateFromFixture(); + state.ams.ams[0].tray[0].tray_type = 'PLA'; + state.ams.ams[0].tray[0].tray_sub_brands = 'PLA Basic'; + // "PLA Basic" against type "PLA" must not render as "PLA PLA Basic". + assert.equal(bambuStatus(state).print_task_config.filament_sub_type[0], 'Basic'); +}); + +test('bambu ignores stale filament details on an unloaded tray', () => { + // The printer leaves the last spool's values behind after an unload, so + // tray_exist_bits is the only trustworthy signal. + const state = bambuStateFromFixture(); + state.ams.tray_exist_bits = '0'; + const slots = bambuStatus(state).print_task_config; + + assert.deepEqual(slots.filament_exist, [false, false, false, false]); + assert.equal(slots.filament_type[0], ''); + assert.equal(slots.filament_color_rgba[0], ''); +}); + +test('bambu indexes trays globally across multiple AMS units', () => { + const ams = { + ams: [ + { id: '0', tray: [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }] }, + { id: '1', tray: [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }] }, + ], + tray_exist_bits: '10', // hex: tray 4, the first slot of the second unit + tray_is_bbl_bits: '0', + tray_now: '4', + }; + const state = { ...bambuStateFromFixture(), ams }; + + assert.deepEqual(bambuTrays(state).map((entry) => entry.index), [0, 1, 2, 3, 4, 5, 6, 7]); + assert.equal(isBambuTrayLoaded(state, 4), true); + assert.equal(bambuActiveSlot(state), 4); + assert.equal(bambuStatus(state).print_task_config.filament_exist.length, 8); +}); + +test('bambu native preview receives physical AMS occupancy, not a fake logical-tool mask', () => { + const source = fs.readFileSync(path.join(REPO_ROOT, 'app', '(tabs)', 'slicer.tsx'), 'utf8'); + + // Logical tool 0 belongs in initialTool only. Replacing the loaded mask with + // `1` makes an empty AMS lane 1 the sole candidate and hides real loaded + // lanes 2-4 from the native preprocessing sheet. + assert.doesNotMatch(source, /bambu\s*\?\s*1\s*:\s*toolLoad\.nativeLoadedToolMask/); + assert.match( + source, + /bambu\s*\?\s*0\s*:\s*toolLoad\.selectedTool,\s*toolLoad\.nativeLoadedToolMask,/s, + ); +}); + +test('bambu-lan is a known printer kind with one head and an AMS', () => { + const profile = printerProfile('bambu-lan'); + assert.equal(profile.toolheads, 1); + assert.equal(profile.materialSlots, 4); + // The camera URL is created per session by the transport, so there is no + // static path to fall back on. + assert.equal(profile.defaultCameraPath, ''); +}); + +test('an unknown kind still falls back rather than throwing', () => { + assert.equal(normalizePrinterKind('bambu-lan'), 'bambu-lan'); + assert.equal(normalizePrinterKind('nonsense'), 'generic-klipper'); +}); + +const { + PROMPT_DISMISS_GCODE, + isPromptLine, + reducePromptLine, + visiblePrompt, +} = require(path.join('..', 'services', 'klipperPrompt.ts')); + +// Captured verbatim from a real FlashForge AD5X (zmod) accepting a print. +// This is the exact run that left Helix reporting "sent" while the printer sat +// waiting for someone to answer it. +const AD5X_PROMPT_LINES = [ + '// action:prompt_end', + '// action:prompt_begin Select print materials', + '// action:prompt_text 1+Mini+Turtle.gcode | Extruder: None (2)', + '// action:prompt_button_group_start', + '// action:prompt_button Leveling Off|SET_ZCOLOR SILENT=0 FILENAME="1+Mini+Turtle.gcode" LEVELING=1| |808080', + '// action:prompt_button Auto select colors|SET_ZCOLOR SILENT=0 AUTO_ASSIGN=1 FILENAME="1+Mini+Turtle.gcode" LEVELING=0| |202020', + '// action:prompt_button_group_end', + '// action:prompt_footer_button Start print|PRINT_ZCOLOR LEVELING=0 FILENAME="1+Mini+Turtle.gcode" ALLOWED_TOOL_COUNT=4 T0=2 T1=2 T2=2 T3=2|red', + '// action:prompt_footer_button Cancel|RESPOND TYPE=command MSG=action:prompt_end', + '// action:prompt_show', +]; + +function foldPrompt(lines) { + return lines.reduce((state, line) => reducePromptLine(state, line), null); +} + +test('parses a real AD5X material prompt into a renderable dialog', () => { + const prompt = visiblePrompt(foldPrompt(AD5X_PROMPT_LINES)); + assert.ok(prompt, 'prompt should be visible after prompt_show'); + assert.equal(prompt.title, 'Select print materials'); + assert.deepEqual(prompt.text, ['1+Mini+Turtle.gcode | Extruder: None (2)']); + assert.equal(prompt.buttons.length, 4); + + // The button that actually starts the print — the whole point of rendering this. + const start = prompt.buttons.find((b) => b.label === 'Start print'); + assert.ok(start, 'the Start print button must survive parsing'); + assert.equal(start.footer, true); + assert.match(start.gcode, /^PRINT_ZCOLOR /); + assert.match(start.gcode, /FILENAME="1\+Mini\+Turtle\.gcode"/); + + // Pipe characters inside the text must not be read as field separators. + assert.equal(prompt.buttons.filter((b) => !b.footer).length, 2); +}); + +test('a prompt stays hidden until prompt_show arrives', () => { + // Everything except the final show — a half-composed prompt must not flash up. + const composing = foldPrompt(AD5X_PROMPT_LINES.slice(0, -1)); + assert.ok(composing, 'state should exist while composing'); + assert.equal(visiblePrompt(composing), null); +}); + +test('prompt_end closes an open prompt', () => { + const open = foldPrompt(AD5X_PROMPT_LINES); + assert.ok(visiblePrompt(open)); + assert.equal(reducePromptLine(open, '// action:prompt_end'), null); +}); + +test('a second prompt_begin replaces a half-built prompt rather than merging', () => { + const stale = foldPrompt(AD5X_PROMPT_LINES.slice(0, 6)); + const fresh = reducePromptLine(stale, '// action:prompt_begin Filament runout'); + assert.equal(fresh.prompt.title, 'Filament runout'); + assert.deepEqual(fresh.prompt.text, []); + assert.deepEqual(fresh.prompt.buttons, []); +}); + +test('ordinary console output leaves the prompt state untouched', () => { + const open = foldPrompt(AD5X_PROMPT_LINES); + for (const noise of ['// H1 > command H1 ok. 7913595', 'ok', '!! Unknown command:"FOO"', '']) { + assert.equal(reducePromptLine(open, noise), open, `\`${noise}\` should be inert`); + } + // ...and cannot conjure a prompt out of nothing. + assert.equal(reducePromptLine(null, '// H1 > command H1 ok.'), null); +}); + +test('directives arriving with no open prompt do not crash or half-open one', () => { + for (const orphan of [ + '// action:prompt_show', + '// action:prompt_text stray', + '// action:prompt_button A|G1', + '// action:prompt_end', + ]) { + assert.equal(reducePromptLine(null, orphan), null, orphan); + } +}); + +test('buttons with no gcode are dropped instead of rendering dead controls', () => { + const begun = reducePromptLine(null, '// action:prompt_begin T'); + const withDead = reducePromptLine(begun, '// action:prompt_button Broken|'); + assert.deepEqual(withDead.prompt.buttons, []); + const withGood = reducePromptLine(withDead, '// action:prompt_button Fine|G28'); + assert.equal(withGood.prompt.buttons.length, 1); +}); + +test('isPromptLine only matches prompt directives', () => { + assert.equal(isPromptLine('// action:prompt_begin X'), true); + assert.equal(isPromptLine('// action:prompt_show'), true); + // Klipper has other action: verbs that are not prompts. + assert.equal(isPromptLine('// action:pause'), false); + assert.equal(isPromptLine('ok'), false); +}); + +// On connect Helix folds Moonraker's gcode_store to recover a prompt raised +// before it joined — otherwise the printer waits on a dialog nobody can see. +test('replaying the response buffer recovers a still-open prompt', () => { + const buffer = [ + '// Z-Offset: 0.0000 _PRINT_FILE', + 'File opened:1+Mini+Turtle.gcode Size:659160', + ...AD5X_PROMPT_LINES, + ]; + const prompt = visiblePrompt(foldPrompt(buffer)); + assert.ok(prompt, 'an open prompt must survive a replay'); + assert.equal(prompt.title, 'Select print materials'); +}); + +test('replaying a buffer whose prompt already closed recovers nothing', () => { + // The dangerous case: a stale prompt in the buffer must not resurrect as a + // dialog over a printer that has moved on. + const buffer = [...AD5X_PROMPT_LINES, '// action:prompt_end', 'ok']; + assert.equal(foldPrompt(buffer), null); +}); + +const { + applyToolSlots, + autoAnswerGcode, + macroFilename, + macroTargetsFile, + zmodCommitButton, +} = require(path.join('..', 'services', 'zmodPrintPrompt.ts')); +const { + PRINT_INTENT_TTL_MS, + clearPrintIntent, + peekPrintIntent, + setPrintIntent, + subscribePrintIntent, +} = require(path.join('..', 'services', 'printIntent.ts')); + +// The commit macro exactly as the AD5X emitted it. +const AD5X_COMMIT = + 'PRINT_ZCOLOR LEVELING=0 FILENAME="1+Mini+Turtle.gcode" ALLOWED_TOOL_COUNT=4 T0=2 T1=2 T2=2 T3=2'; + +test('lane arguments are rewritten from Helix 0-based slots to 1-based lanes', () => { + // Helix slot 0 is lane 1, slot 3 is lane 4 — the T0-T3 / T1-T4 offset. + const out = applyToolSlots(AD5X_COMMIT, { 0: 0, 1: 1, 2: 2, 3: 3 }); + assert.match(out, /T0=1 T1=2 T2=3 T3=4/); + // Everything else must survive untouched. + assert.match(out, /^PRINT_ZCOLOR LEVELING=0 FILENAME="1\+Mini\+Turtle\.gcode" ALLOWED_TOOL_COUNT=4 /); +}); + +test('ALLOWED_TOOL_COUNT is not mistaken for a lane argument', () => { + const out = applyToolSlots(AD5X_COMMIT, { 0: 0 }); + assert.match(out, /ALLOWED_TOOL_COUNT=4/, 'the tool count must not be rewritten'); +}); + +test('unmapped tools keep the printer own proposal', () => { + // Mapping only tool 0 leaves T1..T3 exactly as the printer sent them, which + // is what tapping its button unchanged would have done. + const out = applyToolSlots(AD5X_COMMIT, { 0: 3 }); + assert.match(out, /T0=4 T1=2 T2=2 T3=2/); +}); + +test('a nonsense slot is ignored rather than sent as a bad lane', () => { + for (const bad of [{ 0: -1 }, { 0: NaN }, { 0: undefined }, {}]) { + assert.match(applyToolSlots(AD5X_COMMIT, bad), /T0=2/, JSON.stringify(bad)); + } +}); + +test('the commit button and its filename are recognised', () => { + const prompt = visiblePrompt(foldPrompt(AD5X_PROMPT_LINES)); + const commit = zmodCommitButton(prompt); + assert.ok(commit); + assert.equal(macroFilename(commit.gcode), '1+Mini+Turtle.gcode'); + assert.equal(macroTargetsFile(commit.gcode, 'gcodes/1+Mini+Turtle.gcode'), true); + assert.equal(macroTargetsFile(commit.gcode, 'something-else.gcode'), false); +}); + +test('auto-answer only fires for a zmod material prompt about our own file', () => { + const prompt = visiblePrompt(foldPrompt(AD5X_PROMPT_LINES)); + const answer = autoAnswerGcode(prompt, '1+Mini+Turtle.gcode', { 0: 1 }); + assert.match(answer, /^PRINT_ZCOLOR /); + assert.match(answer, /T0=2/); + + // A different job — the operator answers that one. + assert.equal(autoAnswerGcode(prompt, 'other.gcode', { 0: 1 }), null); +}); + +test('a prompt that is not a material selection is never auto-answered', () => { + // A runout prompt has no PRINT_ZCOLOR button and must reach the operator. + const runout = visiblePrompt( + foldPrompt([ + '// action:prompt_begin Filament runout', + '// action:prompt_text Load filament and resume', + '// action:prompt_footer_button Resume|RESUME', + '// action:prompt_show', + ]) + ); + assert.equal(zmodCommitButton(runout), null); + assert.equal(autoAnswerGcode(runout, 'anything.gcode', { 0: 0 }), null); +}); + +// The native preview screen uploads and starts the print itself, so its intent +// is only staged once Android hands control back to RN — often after the +// printer has already asked. Subscribers re-decide when that happens. +test('staging an intent notifies subscribers so a waiting prompt gets answered', () => { + clearPrintIntent(); + let fired = 0; + const unsubscribe = subscribePrintIntent(() => { fired += 1; }); + + setPrintIntent({ filename: 'late.gcode', toolToSlot: {} }); + assert.equal(fired, 1, 'a staged intent must notify'); + + unsubscribe(); + setPrintIntent({ filename: 'later.gcode', toolToSlot: {} }); + assert.equal(fired, 1, 'unsubscribing must actually detach'); + clearPrintIntent(); +}); + +// The native sheet already rewrote the G-code so each tool is its physical +// slot, so its intent carries no mapping and the printer's own proposal stands +// — identical to tapping the printer's own "Start print". +test('an empty mapping answers the prompt without altering any lane', () => { + const prompt = visiblePrompt(foldPrompt(AD5X_PROMPT_LINES)); + const answer = autoAnswerGcode(prompt, '1+Mini+Turtle.gcode', {}); + assert.ok(answer, 'an empty mapping should still auto-answer'); + assert.equal(answer, zmodCommitButton(prompt).gcode, 'lanes must be untouched'); +}); + +test('a staged print intent expires rather than answering a later job', () => { + clearPrintIntent(); + assert.equal(peekPrintIntent(), null); + + setPrintIntent({ filename: 'a.gcode', toolToSlot: { 0: 1 } }); + const staged = peekPrintIntent(); + assert.equal(staged.filename, 'a.gcode'); + + // Just past the window: a prompt this late belongs to a different print, + // most likely one started at the printer. + assert.equal(peekPrintIntent(staged.stagedAt + PRINT_INTENT_TTL_MS + 1), null); + assert.equal(peekPrintIntent(), null, 'an expired intent is dropped, not retried'); + clearPrintIntent(); +}); + +const { + applicablePrefs, + buildPreprocessChecks, + prefCopyFor, + toolChipLabel, + toolLabel, +} = require(path.join('..', 'services', 'printPreprocess.ts')); +const { applyLeveling, ifsOffPrintGcode } = require(path.join('..', 'services', 'zmodPrintPrompt.ts')); + +// SET_ZCOLOR SILENT=2 is what zmod's own "Hide color selection, print without +// IFS" button sends — the material prompt never appears, every T-command is +// ignored, and the external side spool feeds the print. Nothing is persisted. +test('the IFS-off start is zmod\'s own external-spool macro', () => { + assert.equal( + ifsOffPrintGcode('1+Mini+Turtle.gcode', true), + 'SET_ZCOLOR FILENAME="1+Mini+Turtle.gcode" SILENT=2 LEVELING=1' + ); + assert.match(ifsOffPrintGcode('a.gcode', false), /LEVELING=0$/); + // The filename is quoted, so a path with a space survives the trip. + assert.match( + ifsOffPrintGcode('gcodes/my print.gcode', false), + /FILENAME="gcodes\/my print\.gcode"/ + ); +}); + +test('tool labels follow the machine naming', () => { + // U1/generic: tools are T0–T3 feeding plain "lanes". + assert.equal(toolLabel(0, 'tool'), 'T0'); + assert.equal(toolChipLabel(0, 'tool'), 'T0'); + // AD5X/Bambu: the feeds themselves are named Lane 1–4. + assert.equal(toolLabel(1, 'lane'), 'Lane 2'); + // The chip is a 26–34px circle — "Lane 2" would never fit, so it shows the number. + assert.equal(toolChipLabel(1, 'lane'), '2'); +}); + +// A file tool re-routed onto another feed — the one check whose wording names +// both the tool and the lane. +const REROUTED_LANES = [0, 1, 2, 3].map((index) => ({ + index, + color: '#ffffff', + material: 'PLA', + status: index === 1 ? 'loaded' : 'empty', +})); +const REROUTED_TOOLS = [ + { fileTool: 0, assigned: 1, grams: 1, lane: REROUTED_LANES[1], source: 'auto' }, +]; + +test('check wording follows the machine naming', () => { + const base = { + connected: true, + printerBusy: false, + printerName: 'Printer', + tools: REROUTED_TOOLS, + lanes: REROUTED_LANES, + }; + const detail = (naming) => + buildPreprocessChecks({ ...base, ...(naming ? { naming } : {}) }) + .find((check) => check.key === 'filament').detail; + + // The default and the explicit U1 wording are the pre-naming strings. + assert.equal(detail('tool'), 'T0 to lane 2 — your own lanes were empty'); + assert.equal(detail(undefined), 'T0 to lane 2 — your own lanes were empty'); + assert.equal(detail('lane'), 'Lane 1 to Lane 2 — your own Lanes were empty'); +}); + +test('the AD5X and Bambu name their feeds lanes, everything else tools', () => { + assert.equal(resolveMachineProfile({ kind: 'flashforge-ad5x' }).laneNaming, 'lane'); + assert.equal(resolveMachineProfile({ kind: 'bambu-lan' }).laneNaming, 'lane'); + assert.equal(resolveMachineProfile({ kind: 'snapmaker-u1' }).laneNaming, 'tool'); + assert.equal(resolveMachineProfile({ kind: 'generic-klipper' }).laneNaming, 'tool'); + assert.equal(resolveMachineProfile(null).laneNaming, 'tool'); +}); + +test('the AD5X offers only bed levelling and its material station', () => { + const keys = prefCopyFor({ printerKind: 'flashforge-ad5x', multicolor: false }) + .map(({ key }) => key); + assert.deepEqual(keys, ['autoLevel', 'ifs']); + // Single vs multi colour must not change what is on offer. + assert.deepEqual( + prefCopyFor({ printerKind: 'flashforge-ad5x', multicolor: true }).map(({ key }) => key), + ['autoLevel', 'ifs'] + ); + // The U1 keeps its PAXX toggles and never shows IFS. + const u1 = prefCopyFor({ printerKind: 'snapmaker-u1', multicolor: true }).map(({ key }) => key); + assert.deepEqual(u1, ['autoLevel', 'flowCal', 'timelapse']); + const bambu = prefCopyFor({ printerKind: 'bambu-lan', multicolor: false }).map(({ key }) => key); + assert.deepEqual(bambu, ['autoLevel', 'timelapse']); + const bambuCalibration = prefCopyFor({ printerKind: 'bambu-lan', multicolor: false }) + .find(({ key }) => key === 'autoLevel'); + assert.equal(bambuCalibration.label, 'Bed & vibration calibration'); + assert.match(bambuCalibration.hint, /Probe the bed and tune vibration/); +}); + +// The dialog can swap printers with the toggles already set. A time-lapse left +// on from the U1 would inject TIMELAPSE_* macros the AD5X does not have, and +// the print dies on "Unknown command" — so anything not offered is dropped. +test('preferences the target printer does not offer are dropped at send', () => { + const all = { autoLevel: true, flowCal: true, timelapse: true, ifs: true }; + + const ad5x = applicablePrefs(all, { printerKind: 'flashforge-ad5x', multicolor: true }); + assert.equal(ad5x.autoLevel, true); + assert.equal(ad5x.ifs, true); + assert.equal(ad5x.timelapse, false, 'the AD5X has no TIMELAPSE macros'); + assert.equal(ad5x.flowCal, false, 'flow calibration is PAXX-only'); + + const u1 = applicablePrefs(all, { printerKind: 'snapmaker-u1', multicolor: true }); + assert.equal(u1.timelapse, true); + assert.equal(u1.flowCal, true); + assert.equal(u1.ifs, false, 'the U1 has no IFS'); + + const bambu = applicablePrefs(all, { printerKind: 'bambu-lan', multicolor: false }); + assert.equal(bambu.autoLevel, true); + assert.equal(bambu.flowCal, false, 'the P1S has no per-print flow calibration'); + assert.equal(bambu.timelapse, true); + assert.equal(bambu.ifs, false); + + const slicerSource = fs.readFileSync(path.join(REPO_ROOT, 'app', '(tabs)', 'slicer.tsx'), 'utf8'); + const mqttModuleSource = fs.readFileSync( + path.join( + REPO_ROOT, + 'android', + 'app', + 'src', + 'main', + 'java', + 'org', + 'crabcore', + 'u1control', + 'bambu', + 'BambuMqttModule.kt' + ), + 'utf8' + ); + assert.match(slicerSource, /vibrationCalibration: requestedPrefs\.autoLevel/); + assert.match( + mqttModuleSource, + /vibrationCalibration = config\.getBoolean\("vibrationCalibration"\)/ + ); + + // Every key is always present, so callers never read undefined. + for (const key of ['autoLevel', 'flowCal', 'timelapse', 'ifs']) { + assert.equal(typeof ad5x[key], 'boolean', key); + } +}); + +// The AD5X takes levelling as an argument of its print macro, not as a +// preferences command — its own prompt offers LEVELING=1 and LEVELING=0. +test('bed levelling is applied to the AD5X print macro', () => { + const prompt = visiblePrompt(foldPrompt(AD5X_PROMPT_LINES)); + const on = autoAnswerGcode(prompt, '1+Mini+Turtle.gcode', {}, true); + const off = autoAnswerGcode(prompt, '1+Mini+Turtle.gcode', {}, false); + assert.match(on, /LEVELING=1/); + assert.match(off, /LEVELING=0/); + + // Undefined leaves the printer's own value untouched. + assert.equal(applyLeveling('PRINT_ZCOLOR LEVELING=0 X=1', undefined), 'PRINT_ZCOLOR LEVELING=0 X=1'); + // And it must not maul a similarly-named argument. + assert.match(applyLeveling('PRINT_ZCOLOR AUTO_LEVELING_MODE=3 LEVELING=0', true), /AUTO_LEVELING_MODE=3/); +}); + +test('the dismiss command is the one the printer itself offers as Cancel', () => { + const prompt = visiblePrompt(foldPrompt(AD5X_PROMPT_LINES)); + const cancel = prompt.buttons.find((b) => b.label === 'Cancel'); + assert.equal(cancel.gcode, PROMPT_DISMISS_GCODE); +}); + +// Every bed the app can render, as [label, bed]. The A-series beds hang off the +// serial lookup, so iterating the profile table alone would miss their geometry. +function everyBed() { + const beds = Object.entries(PRINTER_PROFILES).map(([kind, p]) => [kind, p.bed]); + beds.push(['bambu-lan a1-mini', bambuBedForSerial('03000A421900555')]); + beds.push(['bambu-lan a1', bambuBedForSerial('03900A421900555')]); + return beds; +} + +test('an A1 mini gets its own 180mm plate, not the 256 one', () => { + // Real Bambu serials: 030 prefixes an A1 mini, 039 an A1, 01P a P1S. + const mini = bambuBedForSerial('03000A421900555'); + assert.equal(mini.sizeX, 180); + assert.equal(mini.sizeY, 180); + assert.equal(mini.modelAsset, 'bambu_a1_mini_bed.stl'); + + const p1s = bambuBedForSerial('01P00C611300996'); + assert.equal(p1s.sizeX, 256); + assert.equal(p1s.modelAsset, 'bambu_x1_bed.stl'); +}); + +test('a full-size A1 gets its official 256mm Z travel', () => { + const a1 = bambuBedForSerial('03900A421900555'); + assert.equal(a1.sizeX, 256); + assert.equal(a1.sizeY, 256); + assert.equal(a1.height, 256); + assert.equal(a1.modelAsset, 'bambu_x1_bed.stl'); +}); + +// A miss here has to be harmless: unknown Bambus get the common 256mm plate +// with the conservative P1/X1 250mm height. +test('an unknown or missing Bambu serial falls back to the 256mm plate', () => { + for (const serial of [null, undefined, '', ' ', 'nonsense', 'BL-P001', 42]) { + const bed = bambuBedForSerial(serial); + assert.equal(bed.sizeX, 256, `serial ${String(serial)} should fall back to 256`); + assert.equal(bed.height, 250, `serial ${String(serial)} should keep conservative Z`); + assert.equal(bed.modelAsset, 'bambu_x1_bed.stl'); + } +}); + +test('bambu serial matching ignores case and surrounding whitespace', () => { + assert.equal(bambuBedForSerial(' 03000a421900555 ').sizeX, 180); + assert.equal(bambuBedForSerial(' 03900a421900555 ').height, 256); +}); + +test('resolveBedProfile routes each kind to the right bed', () => { + assert.equal(resolveBedProfile({ kind: 'snapmaker-u1' }).sizeX, 270); + assert.equal(resolveBedProfile({ kind: 'flashforge-ad5x' }).sizeX, 220); + assert.equal(resolveBedProfile({ kind: 'generic-klipper' }).sizeX, 220); + // Only Bambu consults the serial; the others ignore it entirely. + assert.equal( + resolveBedProfile({ kind: 'snapmaker-u1', serialNumber: '03000A421900555' }).sizeX, + 270 + ); + assert.equal( + resolveBedProfile({ kind: 'bambu-lan', serialNumber: '03000A421900555' }).sizeX, + 180 + ); + assert.equal(resolveBedProfile({ kind: 'bambu-lan' }).sizeX, 256); + // A missing or unsaved printer must still produce a usable bed. + assert.ok(resolveBedProfile(null).sizeX > 0); + assert.ok(resolveBedProfile(undefined).sizeX > 0); + assert.ok(resolveBedProfile({}).sizeX > 0); +}); + +test('resolveMachineProfile pairs each bed with its own slice profile', () => { + const u1 = resolveMachineProfile({ kind: 'snapmaker-u1' }); + assert.equal(u1.bed.sizeX, 270); + assert.equal(u1.sliceProfileAsset, 'snapmaker_u1.json'); + + const ad5x = resolveMachineProfile({ kind: 'flashforge-ad5x' }); + assert.equal(ad5x.bed.sizeX, 220); + assert.equal(ad5x.sliceProfileAsset, 'flashforge_ad5x.json'); + + const p1s = resolveMachineProfile({ kind: 'bambu-lan', serialNumber: ' 01p00c611300996 ' }); + assert.equal(p1s.sliceProfileAsset, 'bambu_p1s.json'); + assert.deepEqual(p1s.printPrefs, ['autoLevel', 'timelapse']); + + const a1 = resolveMachineProfile({ kind: 'bambu-lan', serialNumber: ' 03900a421900555 ' }); + assert.equal(a1.bed.height, 256); + assert.equal(a1.sliceProfileAsset, 'bambu_a1.json'); + assert.deepEqual(a1.printPrefs, ['autoLevel', 'timelapse']); + + // Unknown Bambu models and generic Klipper keep the engine defaults and + // cannot upload. A1 mini remains blocked until it gets its own machine profile. + assert.equal(resolveMachineProfile({ kind: 'bambu-lan' }).sliceProfileAsset, null); + assert.equal( + resolveMachineProfile({ kind: 'bambu-lan', serialNumber: '03000A421900555' }).sliceProfileAsset, + null + ); + assert.equal(resolveMachineProfile({ kind: 'generic-klipper' }).sliceProfileAsset, null); +}); + +// The whole point of the slice profile is that no machine gets another's start +// G-code. Two printers sharing an asset would silently reintroduce that. +test('no two printer kinds share a slice profile asset', () => { + const assets = Object.values(PRINTER_PROFILES) + .map((profile) => profile.sliceProfileAsset) + .filter((asset) => asset !== null); + assert.equal(new Set(assets).size, assets.length, `duplicate slice profiles: ${assets}`); +}); + +test('every declared slice profile is bundled and carries machine G-code', () => { + const dir = path.join(REPO_ROOT, 'android', 'app', 'src', 'main', 'assets', 'orca_profiles', 'printer'); + for (const [kind, profile] of Object.entries(PRINTER_PROFILES)) { + const asset = profile.sliceProfileAsset; + if (asset === null) continue; + const file = path.join(dir, asset); + assert.ok(fs.existsSync(file), `${kind} declares slice profile ${asset}, which is not bundled`); + + // HelixSliceRunner.readMachineProfile requires both templates; a profile + // missing them would slice with empty start/end G-code and no warning. + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + for (const key of ['machine_start_gcode', 'machine_end_gcode']) { + assert.ok( + typeof parsed[key] === 'string' && parsed[key].trim().length > 0, + `${kind} slice profile ${asset} has no ${key}` + ); + } + } +}); + +test('only the AD5X slice profile opts into Klipper compatibility rewriting', () => { + const dir = path.join(REPO_ROOT, 'android', 'app', 'src', 'main', 'assets', 'orca_profiles', 'printer'); + const ad5x = JSON.parse(fs.readFileSync(path.join(dir, 'flashforge_ad5x.json'), 'utf8')); + const u1 = JSON.parse(fs.readFileSync(path.join(dir, 'snapmaker_u1.json'), 'utf8')); + + assert.equal(ad5x.gcode_flavor, 'klipper'); + assert.equal(ad5x.helix_translate_marlin_machine_limits, true); + assert.equal(ad5x.helix_strip_m486, true); + assert.notEqual(u1.helix_translate_marlin_machine_limits, true); + assert.notEqual(u1.helix_strip_m486, true); +}); + +// print_task_config and SET_PRINT_PREFERENCES are PAXX firmware. Claiming them +// on any other machine makes printing fail with "Printer rejected the selected +// print preferences", because the read-back can never match. +const { + canUseReportedFilamentSlots, + materialStationSlots, + unavailableMaterialStationSlots, +} = require(path.join('..', 'services', 'filamentSlots.ts')); + +test('Bambu keeps its last AMS report during reconnect without changing Moonraker safety', () => { + assert.equal(canUseReportedFilamentSlots('disconnected', 'bambu-lan'), true); + assert.equal(canUseReportedFilamentSlots('connecting', 'bambu-lan'), true); + assert.equal(canUseReportedFilamentSlots('disconnected', 'snapmaker-u1'), false); + assert.equal(canUseReportedFilamentSlots('connected', 'snapmaker-u1'), true); +}); + +// An AD5X publishes no print_task_config, so the slicer's Moonraker-derived +// lanes fell back to saved manual settings and offered spools from another +// printer. These come off FlashForge's REST API instead. +test('AD5X lanes are read from the material station, not saved settings', () => { + const units = [{ + lanes: [ + { index: 0, status: 'loaded', material: 'PLA', brand: 'Numaker', colorHex: '#11AA33' }, + { index: 1, status: 'empty' }, + { index: 2, status: 'drying', material: 'PETG', brand: 'Hobby Lobby', colorHex: '#2244FF' }, + { index: 3, status: 'loaded', material: 'TPU', brand: 'Hobby Lobby', colorHex: '#FF8800' }, + ], + }]; + const slots = materialStationSlots(units); + + assert.equal(slots.length, 4); + assert.equal(slots[0].material, 'PLA'); + assert.equal(slots[0].brand, 'Numaker'); + assert.equal(slots[0].status, 'loaded'); + assert.equal(slots[1].status, 'empty'); + // A drying lane cannot feed a print, and the slot UI has no drying state. + assert.equal(slots[2].status, 'busy'); + assert.equal(slots[3].material, 'TPU'); + // Every slot is the machine speaking, so nothing is attributed to settings. + assert.ok(slots.every((slot) => slot.source === 'printer')); +}); + +test('a silent material station falls back rather than blanking the lanes', () => { + // Null is the signal to keep the existing behaviour; four empty lanes would + // look like the printer had nothing loaded. + assert.equal(materialStationSlots([]), null); + assert.equal(materialStationSlots([{ lanes: [] }]), null); +}); + +test('lanes missing from the station still produce four usable slots', () => { + // The station can report fewer than four; the UI always renders four. + const slots = materialStationSlots([{ lanes: [{ index: 0, status: 'loaded', material: 'PLA' }] }]); + assert.equal(slots.length, 4); + assert.equal(slots[0].status, 'loaded'); + for (const index of [1, 2, 3]) { + assert.equal(slots[index].status, 'empty', `slot ${index}`); + assert.equal(typeof slots[index].color, 'string'); + } +}); + +test('an unavailable AD5X station never falls back to cached manual filament', () => { + const slots = unavailableMaterialStationSlots(); + assert.equal(slots.length, 4); + assert.ok(slots.every((slot) => slot.status === 'unknown')); + assert.ok(slots.every((slot) => slot.material === '' && slot.mainType === '')); + assert.ok(slots.every((slot) => slot.source === 'printer')); +}); + +test('only the Snapmaker U1 claims PAXX print preferences', () => { + for (const [kind, profile] of Object.entries(PRINTER_PROFILES)) { + assert.equal( + profile.supportsPrintPreferences, + kind === 'snapmaker-u1', + `${kind} should ${kind === 'snapmaker-u1' ? '' : 'not '}claim print preferences` + ); + } + assert.equal(resolveMachineProfile({ kind: 'snapmaker-u1' }).supportsPrintPreferences, true); + assert.equal(resolveMachineProfile({ kind: 'flashforge-ad5x' }).supportsPrintPreferences, false); + assert.equal(resolveMachineProfile({ kind: 'bambu-lan' }).supportsPrintPreferences, false); + assert.equal(resolveMachineProfile(null).supportsPrintPreferences, false); +}); + +test('every printer kind declares a usable build volume', () => { + for (const [kind, profile] of Object.entries(PRINTER_PROFILES)) { + const { sizeX, sizeY, height } = profile.bed; + for (const [axis, value] of Object.entries({ sizeX, sizeY, height })) { + assert.ok( + Number.isFinite(value) && value > 0, + `${kind} bed ${axis} must be a positive number, got ${value}` + ); + } + } +}); + +test('the AD5X slices and previews against its own 220mm bed, not the U1 270', () => { + const ad5x = printerProfile('flashforge-ad5x').bed; + assert.equal(ad5x.sizeX, 220); + assert.equal(ad5x.sizeY, 220); + assert.equal(ad5x.height, 220); + assert.notEqual(ad5x.sizeX, printerProfile('snapmaker-u1').bed.sizeX); +}); + +// A bed mesh that is named but not shipped fails silently at runtime — the +// renderer catches the missing asset and falls back to a bare rectangle, so +// nothing crashes and nobody notices the plate stopped being drawn. +test('every declared bed mesh is actually bundled in the APK assets', () => { + const bedDir = path.join(REPO_ROOT, 'android', 'app', 'src', 'main', 'assets', 'bed'); + for (const [kind, bed] of everyBed()) { + if (bed.modelAsset === null) continue; + assert.ok( + fs.existsSync(path.join(bedDir, bed.modelAsset)), + `${kind} declares bed mesh ${bed.modelAsset}, which is missing from assets/bed/` + ); + } +}); + +// The renderer shifts each mesh by (sizeX/2, sizeY/2) — Orca's convention that +// the plate is authored about the centre of the printable area. If a mesh were +// authored some other way it would land off the bed, so pin the assumption. +test('bed meshes are authored about the centre of the printable area', () => { + const bedDir = path.join(REPO_ROOT, 'android', 'app', 'src', 'main', 'assets', 'bed'); + for (const [kind, bed] of everyBed()) { + const asset = bed.modelAsset; + if (asset === null) continue; + + const buffer = fs.readFileSync(path.join(bedDir, asset)); + assert.ok(buffer.length >= 84, `${kind} bed mesh ${asset} is too short to be a binary STL`); + const triangles = buffer.readUInt32LE(80); + assert.equal( + buffer.length, + 84 + triangles * 50, + `${kind} bed mesh ${asset} is not binary STL — the native loader only parses binary` + ); + + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (let i = 0; i < triangles; i += 1) { + const base = 84 + i * 50 + 12; // skip the per-facet normal + for (let v = 0; v < 3; v += 1) { + const x = buffer.readFloatLE(base + v * 12); + const y = buffer.readFloatLE(base + v * 12 + 4); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + + // The plate is a little larger than the printable area and carries a grab + // handle off the front, so the fit is loose rather than exact: it must + // cover the bed without ballooning past it. + const { sizeX, sizeY } = bed; + assert.ok( + maxX - minX >= sizeX && maxX - minX <= sizeX * 1.25, + `${kind} bed mesh spans ${(maxX - minX).toFixed(1)}mm in X, which does not match a ${sizeX}mm bed` + ); + assert.ok( + maxY - minY >= sizeY && maxY - minY <= sizeY * 1.25, + `${kind} bed mesh spans ${(maxY - minY).toFixed(1)}mm in Y, which does not match a ${sizeY}mm bed` + ); + } +}); + +test('bambu chamber light is presented as a klipper LED', () => { + const state = bambuStateFromFixture(); + // The dashboard finds the light by this key shape and reads color_data. + const on = bambuStatus({ ...state, lights_report: [{ node: 'chamber_light', mode: 'on' }] }); + assert.deepEqual(on['neopixel chamber_light'].color_data, [[1, 1, 1]]); + + const off = bambuStatus({ ...state, lights_report: [{ node: 'chamber_light', mode: 'off' }] }); + assert.deepEqual(off['neopixel chamber_light'].color_data, [[0, 0, 0]]); + + // A printer that reports no chamber light must not grow a dead toggle. + assert.equal(bambuStatus({ ...state, lights_report: [] })['neopixel chamber_light'], undefined); +}); + +const { + bambuConnectionFailureMessage, + classifyBambuConnectionFailure, +} = require(path.join('..', 'services', 'bambuConnection.ts')); + +test('Bambu setup connection failures stay actionable and stable', () => { + assert.deepEqual(classifyBambuConnectionFailure({ code: 'wrong-serial', message: 'TLS failed' }), { + reason: 'wrong-serial', + message: 'TLS failed', + }); + assert.equal( + bambuConnectionFailureMessage('wrong-serial', 'TLS failed'), + 'The serial number does not match this printer.' + ); + assert.equal( + bambuConnectionFailureMessage('wrong-access-code', 'not authorized'), + 'The printer rejected the LAN access code.' + ); + assert.equal(classifyBambuConnectionFailure({ code: 'probe-timeout' }).reason, 'unreachable'); + assert.equal( + bambuConnectionFailureMessage('unknown', 'Printer returned an unusual reply'), + 'Printer returned an unusual reply' + ); +}); + +test('Bambu setup probes read-only status and Home exposes offline reconnect', () => { + const editor = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'settings', 'PrinterEditorModal.tsx'), + 'utf8' + ); + const dashboard = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'dashboard', 'Dashboard.tsx'), + 'utf8' + ); + assert.match(editor, /await probeBambuStatus\(/); + assert.match(editor, /Test connection/); + assert.doesNotMatch(editor, /startBambuProjectFile|uploadBambuPrintArtifact/); + assert.match(dashboard, /connection === 'disconnected'/); + assert.match(dashboard, /accessibilityLabel=\{t\('Reconnect now'\)\}/); +}); + +test('AD5X editor exposes every connection mode and Tailscale-only never falls back to LAN', () => { + const editor = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'settings', 'PrinterEditorModal.tsx'), + 'utf8' + ); + const moonrakerHook = fs.readFileSync( + path.join(REPO_ROOT, 'hooks', 'useMoonraker.tsx'), + 'utf8' + ); + const materialStationHook = fs.readFileSync( + path.join(REPO_ROOT, 'hooks', 'useMaterialStation.ts'), + 'utf8' + ); + + assert.match(editor, /effectiveConnectionMode: ConnectionMode = isBambu \? 'lan' : connectionMode/); + assert.match(editor, /!isBambu \? \([\s\S]*Printer URL \(Tailscale/); + assert.match(editor, /!isBambu \? \([\s\S]*Connection mode/); + assert.equal( + [...moonrakerHook.matchAll(/if \(mode === 'tailscale'\) \{\s*if \(tailscale\) urls\.push\(tailscale\);\s*return urls;\s*\}/g)].length, + 2 + ); + assert.match(moonrakerHook, /const delay = completedCycle[\s\S]*: 0;/); + assert.match(moonrakerHook, /if \(!completedCycle\) setConnection\('connecting'\)/); + assert.match( + moonrakerHook, + /const autoFailover = current\.connectionMode === 'auto' && urls\.length > 1;/ + ); + assert.match(moonrakerHook, /autoFailover \? 3000 : 7000/); + assert.match( + moonrakerHook, + /generationRef\.current \+= 1;[\s\S]{0,400}scheduleReconnect\(\);/ + ); + assert.match( + materialStationHook, + /connection === 'connected' \? activeUrl : ''/ + ); +}); + +test('Bambu without AMS stays one external spool through Home, Slice, and print start', () => { + const dashboardModel = fs.readFileSync( + path.join(REPO_ROOT, 'hooks', 'useDashboardModel.ts'), + 'utf8' + ); + const dashboardModules = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'dashboard', 'parts', 'Modules.tsx'), + 'utf8' + ); + const slicerParts = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'slicer', 'parts.tsx'), + 'utf8' + ); + const slicer = fs.readFileSync( + path.join(REPO_ROOT, 'app', '(tabs)', 'slicer.tsx'), + 'utf8' + ); + + assert.match(dashboardModel, /bambu_filament_source === 'external'[\s\S]*slots\.slice\(0, 1\)/); + assert.match(dashboardModules, /externalSpool \? t\('External Spool'\)/); + assert.match(slicerParts, /externalSpool[\s\S]*t\('External Spool'\)/); + assert.match(slicer, /const slotCount = [^\n]*bambu_filament_source === 'external' \? 1 : 4/); + assert.match(slicer, /toolToLane: \{ 0: bambuExternalSpool \? -1 : targetLane \}/); + assert.match(slicer, /useAms: !bambuExternalSpool/); +}); + +test('Bambu external spool does not turn back into fake AMS lanes in the final dialog', () => { + const dialog = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'PrintPreprocessDialog.tsx'), + 'utf8' + ); + const editor = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'dashboard', 'parts', 'FilamentEditor.tsx'), + 'utf8' + ); + const slicer = fs.readFileSync( + path.join(REPO_ROOT, 'app', '(tabs)', 'slicer.tsx'), + 'utf8' + ); + + assert.match(dialog, /if \(externalSpool\) return pool\.slice\(0, 1\)\.map\(asLane\)/); + assert.match(dialog, /const canRemap = !externalSpool/); + assert.match(dialog, /externalSpool \? 'EXT' : toolChipLabel/); + assert.match(slicer, /externalSpool=\{bambuExternalSpool\}/); + assert.match(editor, /resolved\?\.color \|\| colors\[slot\]/); + assert.match(editor, /resolved\?\.mainType \|\| materials\[slot\]/); + assert.match(editor, /externalSpool \? filamentPositionLabel/); +}); + +test('Bespok3d enrollment credentials fail closed and stay scoped to one printer', () => { + const credentials = { + identity: 'helix-12345678-1234-1234-1234-123456789abc', + token: 'a'.repeat(64), + }; + const prepared = createPreparedBespok3dCredentialRecord( + 'u1-living-room', + `SHA256:${'A'.repeat(43)}`, + 'Helix Android', + credentials + ); + assert.equal(bespok3dCredentialStorageKey(prepared.printerId), 'helix.bespok3d.u1-living-room'); + assert.deepEqual(normalizeBespok3dCredentialRecord({ ...prepared, ignored: 'not persisted' }), prepared); + assert.equal(normalizeBespok3dCredentialRecord({ ...prepared, token: 'short' }), null); + assert.equal(normalizeBespok3dCredentialRecord({ ...prepared, printerId: '../other-printer' }), null); + assert.throws(() => bespok3dCredentialStorageKey('../other-printer')); + + const enrolled = createEnrolledBespok3dCredentialRecord(prepared, { + ...credentials, + certificatePem: '-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----\n', + certificateSha256: Array(32).fill('AA').join(':'), + daemonVersion: '0.12.24', + jinniVersion: '0.1.10', + completedSteps: ['verify'], + }); + assert.equal(enrolled.status, 'enrolled'); + assert.equal(enrolled.daemonVersion, '0.12.24'); + assert.equal(enrolled.completedSteps, undefined); +}); + +test('Bespok3d enrollment lives under Tools, stays U1-only, and saves before mutation', () => { + const settingsSource = fs.readFileSync( + path.join(REPO_ROOT, 'app', '(tabs)', 'settings.tsx'), + 'utf8' + ); + const toolsSource = fs.readFileSync( + path.join(REPO_ROOT, 'app', '(tabs)', 'tools.tsx'), + 'utf8' + ); + const dialogSource = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'settings', 'Bespok3dEnrollmentDialog.tsx'), + 'utf8' + ); + assert.match(toolsSource, /title: 'Plugins'/); + assert.match(toolsSource, /activePrinter\?\.kind === 'snapmaker-u1'/); + assert.match(toolsSource, / { + const dialogSource = fs.readFileSync( + path.join(REPO_ROOT, 'components', 'settings', 'Bespok3dEnrollmentDialog.tsx'), + 'utf8' + ); + const nativeSource = fs.readFileSync( + path.join( + REPO_ROOT, + 'android', + 'app', + 'src', + 'main', + 'java', + 'org', + 'crabcore', + 'u1control', + 'bespok3d', + 'Bespok3dClient.kt' + ), + 'utf8' + ); + + assert.match(dialogSource, /if \(helixScreen\?\.installed === false\) return \[/); + assert.match(dialogSource, /setStage\('helixscreen-confirm'\)/); + assert.match( + dialogSource, + /await installBundledBespok3dHelixScreen\([\s\S]*await getBespok3dHelixScreenState\(/, + ); + assert.match(dialogSource, /keeps the stock Snapmaker screen selected/); + assert.match(nativeSource, /verifyBundledHelixScreenPackage\(bytes\)/); + assert.match(nativeSource, /mapOf\("SCREEN_UI" to "snapmaker"\)/); + assert.match( + nativeSource, + /helixScreenState\(host, token, certificatePem\)[\s\S]*state\.selected == "snapmaker"/, + ); +}); + +// The A1 profile was originally produced by copying the P1S one, and its +// filament block came along unchanged: P1S bed temps, P1S fan curve, the P1S +// high-flow extruder variant, and the P1S chamber-heater command on a machine +// with no chamber. BambuPrintUpload ships this file verbatim as the uploaded +// project's settings, so these are the values the printer is told about. +// Regenerated 2026-08-14 from the official BambuStudio A1 presets. +test('Bambu A1 profile carries A1 filament values, not the P1S ones it was copied from', () => { + const profileDir = path.join( + REPO_ROOT, + 'android', + 'app', + 'src', + 'main', + 'assets', + 'orca_profiles', + 'printer', + ); + const a1 = JSON.parse(fs.readFileSync(path.join(profileDir, 'bambu_a1.json'), 'utf8')); + const p1s = JSON.parse(fs.readFileSync(path.join(profileDir, 'bambu_p1s.json'), 'utf8')); + + // Lanes are [PLA Basic, PETG HF, PLA Basic, PETG HF]. + assert.deepEqual(a1.filament_type, ['PLA', 'PETG', 'PLA', 'PETG']); + + // Bed: the A1 runs PLA 10C hotter than the enclosed P1S does. + assert.deepEqual(a1.hot_plate_temp, ['65', '70', '65', '70']); + assert.deepEqual(a1.hot_plate_temp_initial_layer, ['65', '70', '65', '70']); + assert.deepEqual(a1.textured_plate_temp, ['65', '70', '65', '70']); + assert.deepEqual(p1s.hot_plate_temp, ['55', '70', '55', '70']); + + // Cooling: the open-frame A1 runs a gentler fan than the enclosed P1S. + assert.deepEqual(a1.fan_min_speed, ['60', '30', '60', '30']); + assert.deepEqual(a1.fan_max_speed, ['80', '50', '80', '50']); + assert.deepEqual(a1.pre_start_fan_time, ['2', '2', '2', '2']); + + // The A1 hotend has no high-flow variant. + assert.ok( + a1.filament_extruder_variant.every((variant) => variant === 'Direct Drive Standard'), + 'A1 filament_extruder_variant must not carry the P1S high-flow entries', + ); + + // M142 sets the P1S chamber/aux heater. The A1 has no chamber. + a1.filament_start_gcode.forEach((gcode, lane) => { + assert.ok(!gcode.includes('M142'), `A1 lane ${lane} must not drive a chamber heater`); + }); + assert.ok( + p1s.filament_start_gcode.some((gcode) => gcode.includes('M142')), + 'P1S startup is expected to keep its chamber heater command', + ); + + // Preset identity must name the A1, not the machine it was copied from. + assert.equal(a1.print_settings_id, '0.20mm Standard @BBL A1'); + assert.ok( + a1.filament_settings_id.every((id) => id.endsWith('@BBL A1')), + 'A1 filament preset names must not say P1S', + ); + + // P1S presets carry two entries per filament (standard + high flow); the A1's + // carry one, so its per-filament arrays are one-per-lane. + ['nozzle_temperature', 'filament_max_volumetric_speed', 'filament_flow_ratio'].forEach((key) => { + assert.equal(a1[key].length, 4, `${key} should hold one value per lane on the A1`); + assert.equal(p1s[key].length, 8, `${key} is expected to stay dual-variant on the P1S`); + }); + + // The artifact builder refuses a profile that lost its bulk or its identity. + assert.ok(Object.keys(a1).length >= 500); + assert.equal(a1.printer_model, 'Bambu Lab A1'); +}); + +// A multi-day print showing a bare "5:00 PM" reads as five hours away when it +// is really five days. useDashboardModel's live ETA used to format the clock +// itself and lost the day; it now shares finishClock with the pre-print dialog. +const { finishClock } = require(path.join('..', 'services', 'printEta.ts')); + +test('finish clock names the day for prints that end after today', () => { + const HOUR = 3600; + const now = new Date(); + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + // Seconds from now until 9am on the Nth day from today, so the assertions do + // not depend on what time the suite happens to run. + const secondsUntilDayAt9am = (dayOffset) => + (midnight + dayOffset * 86_400_000 + 9 * HOUR * 1000 - Date.now()) / 1000; + + const today = finishClock(60); + assert.doesNotMatch(today, /tomorrow|\d{1,2}\/|[A-Z][a-z]{2}\b/, 'today stays a bare time'); + + assert.match(finishClock(secondsUntilDayAt9am(1)), /tomorrow$/); + + // Two to six days out reads as a weekday name. + for (const offset of [2, 3, 6]) { + const label = finishClock(secondsUntilDayAt9am(offset)); + const expected = new Date(midnight + offset * 86_400_000).toLocaleDateString(undefined, { + weekday: 'short', + }); + assert.ok( + label.endsWith(expected), + `${offset} days out should end with ${expected}, got ${label}` + ); + assert.doesNotMatch(label, /tomorrow/); + } + + // A week or more stops being unambiguous as a weekday, so it becomes a date. + const farOff = finishClock(secondsUntilDayAt9am(9)); + const farDate = new Date(midnight + 9 * 86_400_000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); + assert.ok(farOff.endsWith(farDate), `nine days out should end with ${farDate}, got ${farOff}`); +}); + +test('finish clock keeps the time itself intact and refuses negative input', () => { + const label = finishClock(0); + const expected = new Date().toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }); + + assert.equal(label, expected); + // A countdown that has overshot must not read as finishing yesterday. + assert.equal(finishClock(-500), expected); +}); diff --git a/scripts/fixtures/bambu-p1s-report.json b/scripts/fixtures/bambu-p1s-report.json new file mode 100644 index 0000000..e214a39 --- /dev/null +++ b/scripts/fixtures/bambu-p1s-report.json @@ -0,0 +1,196 @@ +{ + "print": { + "upgrade_state": { + "sequence_id": 0, + "progress": "", + "status": "IDLE", + "consistency_request": false, + "dis_state": 0, + "err_code": 0, + "force_upgrade": false, + "message": "0%, 0B/s", + "module": "", + "new_version_state": 2, + "cur_state_code": 0, + "idx2": 3084670294, + "new_ver_list": [] + }, + "ipcam": { + "ipcam_dev": "1", + "ipcam_record": "enable", + "timelapse": "disable", + "resolution": "", + "tutk_server": "disable", + "mode_bits": 3 + }, + "upload": { + "status": "idle", + "progress": 0, + "message": "" + }, + "net": { + "conf": 16, + "info": [ + { + "ip": 0, + "mask": 0 + } + ] + }, + "nozzle_temper": 31.84375, + "nozzle_target_temper": 0, + "bed_temper": 30, + "bed_target_temper": 0, + "chamber_temper": 5, + "mc_print_stage": "1", + "heatbreak_fan_speed": "0", + "cooling_fan_speed": "0", + "big_fan1_speed": "0", + "big_fan2_speed": "0", + "mc_percent": 100, + "mc_remaining_time": 0, + "ams_status": 0, + "ams_rfid_status": 0, + "hw_switch_state": 0, + "spd_mag": 100, + "spd_lvl": 2, + "print_error": 0, + "lifecycle": "product", + "wifi_signal": "-40dBm", + "gcode_state": "FINISH", + "gcode_file_prepare_percent": "100", + "queue_number": 0, + "queue_total": 0, + "queue_est": 0, + "queue_sts": 0, + "project_id": "0", + "profile_id": "0", + "task_id": "0", + "subtask_id": "0", + "subtask_name": "14min44s, Bambu PLA Basic, A1", + "gcode_file": "", + "stg": [], + "stg_cur": 255, + "print_type": "idle", + "home_flag": 6505744, + "mc_print_line_number": "0", + "mc_print_sub_stage": 0, + "sdcard": true, + "force_upgrade": false, + "mess_production_state": "active", + "layer_num": 192, + "total_layer_num": 192, + "s_obj": [], + "filam_bak": [], + "fan_gear": 0, + "nozzle_diameter": "0.4", + "nozzle_type": "stainless_steel", + "cali_version": 0, + "k": "0.0200", + "flag3": 90783, + "hms": [], + "online": { + "ahb": false, + "rfid": false, + "version": 789826101 + }, + "ams": { + "ams": [ + { + "chip_id": "0000000000000000000000000000000", + "ams_id": "000000000000000", + "check": 1, + "id": "0", + "humidity": "2", + "humidity_raw": "30", + "temp": "34.9", + "dry_time": 0, + "info": "1001", + "tray": [ + { + "id": "0", + "state": 3, + "remain": -1, + "k": 0.03999999910593, + "n": 1, + "cali_idx": -1, + "total_len": 330000, + "tag_uid": "0000000000000000", + "tray_id_name": "", + "tray_info_idx": "GFG99", + "tray_type": "PETG", + "tray_sub_brands": "", + "tray_color": "0ACC38FF", + "tray_weight": "0", + "tray_diameter": "0.00", + "tray_temp": "0", + "tray_time": "0", + "bed_temp_type": "0", + "bed_temp": "0", + "nozzle_temp_max": "270", + "nozzle_temp_min": "220", + "xcam_info": "000000000000000000000000", + "tray_uuid": "00000000000000000000000000000000", + "ctype": 0, + "cols": [ + "0ACC38FF" + ] + }, + { + "id": "1" + }, + { + "id": "2" + }, + { + "id": "3" + } + ] + } + ], + "ams_exist_bits": "1", + "tray_exist_bits": "1", + "tray_is_bbl_bits": "1", + "tray_tar": "255", + "tray_now": "255", + "tray_pre": "255", + "tray_read_done_bits": "1", + "tray_reading_bits": "0", + "version": 84, + "insert_flag": true, + "power_on_flag": false + }, + "vt_tray": { + "id": "254", + "tag_uid": "0000000000000000", + "tray_id_name": "", + "tray_info_idx": "", + "tray_type": "", + "tray_sub_brands": "", + "tray_color": "00000000", + "tray_weight": "0", + "tray_diameter": "0.00", + "tray_temp": "0", + "tray_time": "0", + "bed_temp_type": "0", + "bed_temp": "0", + "nozzle_temp_max": "0", + "nozzle_temp_min": "0", + "xcam_info": "000000000000000000000000", + "tray_uuid": "00000000000000000000000000000000", + "remain": 0, + "k": 0.019999999552965164, + "n": 1, + "cali_idx": -1 + }, + "lights_report": [ + { + "node": "chamber_light", + "mode": "on" + } + ], + "command": "push_status", + "msg": 0, + "sequence_id": "11274" + } +} diff --git a/services/aceModel.ts b/services/aceModel.ts new file mode 100644 index 0000000..068d850 --- /dev/null +++ b/services/aceModel.ts @@ -0,0 +1,97 @@ +// The lane/unit model the multi-material UI renders. +// +// Named for the Snapmaker ACE because that was the first hardware to use it, +// but it is now the shared shape: the FlashForge AD5X's material station maps +// into it too, so components/ACELane.tsx and app/(tabs)/ace.tsx render either +// machine without knowing which one they are looking at. +// +// These types live here rather than in hooks/useACE.ts so the pure mapping +// below can be unit-tested without pulling React in. +// crabcore + +import type { MaterialStation } from './flashforgeApi'; + +export type LaneStatus = 'loaded' | 'empty' | 'busy' | 'drying' | 'unknown'; + +export interface AceLane { + index: number; + status: LaneStatus; + brand?: string; + material?: string; + sku?: string; + colorHex?: string; +} + +export interface AceUnit { + index: number; // 1-based + aceIndex: number; // 0-based multiACE device index (macro argument) + connected: boolean; + active: boolean; + protocol?: string; + temp?: number; + humidity?: number; + dryer: { + active: boolean; + targetTemp?: number; + remainingMin?: number; + }; + lanes: AceLane[]; +} + +// head_source entry from multiACE: which ACE/slot currently feeds a head. +export interface HeadSource { + aceIndex: number; + slot: number; + material?: string; + colorHex?: string; + brand?: string; +} + +/** The AD5X's IFS always has four slots, matching the ACE lane count. */ +const IFS_LANE_COUNT = 4; + +/** + * Present a FlashForge material station as a single ACE-style unit. + * + * The AD5X has no dryer and no per-slot temperature/humidity, so those stay + * empty rather than being faked. Slots the printer still remembers a filament + * for keep that material and colour — the EMPTY status chip carries the real + * state, which is how the ACE lanes already behave for unloaded spools. + */ +export function materialStationToAceUnits(station: MaterialStation | null): AceUnit[] { + if (!station) return []; + + const laneCount = Math.max( + IFS_LANE_COUNT, + ...station.slots.map((slot) => slot.index + 1) + ); + + const lanes: AceLane[] = Array.from({ length: laneCount }, (_, index) => { + const slot = station.slots.find((entry) => entry.index === index); + // FlashForge omits unused slot records from some /detail replies. The IFS + // still physically has four lanes, so an omitted lane is empty—not an + // invitation to resurrect a saved spool from another printer. + if (!slot) return { index, status: 'empty' as LaneStatus }; + + const status: LaneStatus = + station.loadingSlot === index ? 'busy' : slot.loaded ? 'loaded' : 'empty'; + + return { + index, + status, + material: slot.material || undefined, + colorHex: slot.colorHex || undefined, + }; + }); + + return [ + { + index: 1, + aceIndex: 0, + connected: true, + active: true, + dryer: { active: false }, + lanes, + }, + ]; +} diff --git a/services/bambuAdapter.ts b/services/bambuAdapter.ts new file mode 100644 index 0000000..32281e6 --- /dev/null +++ b/services/bambuAdapter.ts @@ -0,0 +1,384 @@ +// Bambu state, rendered in the shape the rest of Helix already reads. +// +// Every consumer in the app — useDashboardModel, the temperature cards, the +// filament slots, the notification transitions in useMoonraker — speaks +// Klipper's status vocabulary. Rather than teach each of them a second dialect, +// this maps Bambu's report onto those same keys. That is the seam +// services/printerProfiles.ts was written for. +// +// The translation is deliberately shallow: it renames and rescales, and does +// not invent. Anything Bambu has no answer for is left absent rather than +// defaulted, so the UI's existing "unknown" handling still applies. +// +// Field names verified against a real P1S. +// crabcore + +import { + activeBambuTray, + BAMBU_EXTERNAL_TRAY, + bambuNumber, + bambuTrays, + isBambuTrayLoaded, + type BambuState, + type BambuTray, +} from './bambuReport'; +import { bambuFanPercent } from './bambuControls'; + +/** + * gcode_state -> Klipper's print_stats.state. + * + * PREPARE covers heating and bed levelling, which Klipper reports as part of + * printing; treating it as idle would make the dashboard look asleep while the + * machine is visibly busy. + */ +const PRINT_STATE: Record = { + IDLE: 'standby', + PREPARE: 'printing', + SLICING: 'printing', + RUNNING: 'printing', + PAUSE: 'paused', + FINISH: 'complete', + FAILED: 'error', +}; + +/** Part-cooling fan speeds arrive on a 0-15 scale; Klipper's fan.speed is 0-1. */ +const FAN_SCALE = 15; + +/** Helix's slot UI is built around four slots, matching one AMS unit. */ +const MIN_SLOTS = 4; + +function temperature(current: unknown, target: unknown): { temperature?: number; target?: number } { + return { temperature: bambuNumber(current), target: bambuNumber(target) }; +} + +function fanSpeed(value: unknown): number | undefined { + const raw = bambuNumber(value); + if (raw == null) return undefined; + return Math.max(0, Math.min(1, raw / FAN_SCALE)); +} + +/** + * "PLA Basic" against type "PLA" yields "Basic". Bambu repeats the base type + * inside the sub-brand, and the slot cards are too narrow to show it twice. + */ +function subType(tray: BambuTray): string { + const brand = (tray.tray_sub_brands ?? '').trim(); + const type = (tray.tray_type ?? '').trim(); + if (!brand) return ''; + if (type && brand.toUpperCase().startsWith(type.toUpperCase())) { + return brand.slice(type.length).trim(); + } + return brand; +} + +/** + * Whether the slot holds a real Bambu spool the AMS read by RFID. + * + * The obvious candidate, `tray_is_bbl_bits`, does not mean this: a slot set by + * hand to Generic PETG still reports it. A non-zero RFID tag does — verified on + * a P1S whose hand-configured slot had that bit set and a zeroed tag. + */ +function isGenuineBambuSpool(tray: BambuTray): boolean { + const uid = (tray.tag_uid ?? '').trim(); + return uid !== '' && /[^0]/.test(uid); +} + +/** + * AMS trays as `print_task_config`, which is where resolveFilamentSlots already + * looks. Its colour parser accepts the 8-digit RGBA Bambu emits, so the slot + * cards need no Bambu-specific code at all. + */ +function filamentSlots(state: BambuState): Record { + const trays = bambuTrays(state); + const slotCount = Math.max(MIN_SLOTS, ...trays.map((entry) => entry.index + 1)); + + const exist: boolean[] = new Array(slotCount).fill(false); + const colors: string[] = new Array(slotCount).fill(''); + const vendors: string[] = new Array(slotCount).fill(''); + const types: string[] = new Array(slotCount).fill(''); + const subTypes: string[] = new Array(slotCount).fill(''); + // Write-back fields: ams_filament_setting needs the tray's filament id and + // nozzle temps, and the slot cards' vocabulary has no place for them. + const infoIdx: string[] = new Array(slotCount).fill(''); + const tempMin: number[] = new Array(slotCount).fill(0); + const tempMax: number[] = new Array(slotCount).fill(0); + const trayIndexes: number[] = new Array(slotCount).fill(0).map((_, index) => index); + + for (const { index, tray } of trays) { + if (index >= slotCount) continue; + const loaded = isBambuTrayLoaded(state, index); + exist[index] = loaded; + // A tray keeps its last filament's details after being emptied, so only a + // loaded tray is allowed to describe itself. + if (!loaded) continue; + + colors[index] = tray.tray_color ?? ''; + types[index] = tray.tray_type ?? ''; + subTypes[index] = subType(tray); + vendors[index] = isGenuineBambuSpool(tray) ? 'Bambu' : 'Generic'; + infoIdx[index] = tray.tray_info_idx ?? ''; + tempMin[index] = bambuNumber(tray.nozzle_temp_min) ?? 0; + tempMax[index] = bambuNumber(tray.nozzle_temp_max) ?? 0; + } + + return { + bambu_filament_source: 'ams', + filament_exist: exist, + filament_color_rgba: colors, + filament_vendor: vendors, + filament_type: types, + filament_sub_type: subTypes, + filament_info_idx: infoIdx, + nozzle_temp_min: tempMin, + nozzle_temp_max: tempMax, + bambu_tray_index: trayIndexes, + }; +} + +/** + * The single virtual tray used when no AMS is attached. + * + * A P1S/A1 has no sensor that proves a side spool is physically present while + * idle, so `null` deliberately means "usable, occupancy unknown". `true` is + * reserved for the printer explicitly reporting tray 254 as the active feed. + * The printer-authored material metadata is still safe to show and edit. + */ +function externalFilamentSlot(state: BambuState): Record | null { + const raw = state.vt_tray; + const tray = (Array.isArray(raw) ? raw[0] : raw) as BambuTray | undefined; + if (!tray || typeof tray !== 'object') return null; + const hasMetadata = Boolean( + tray.tray_type?.trim() || tray.tray_info_idx?.trim() || tray.tray_color?.replace(/0/g, '') + ); + + return { + bambu_filament_source: 'external', + filament_exist: [activeBambuTray(state) === BAMBU_EXTERNAL_TRAY ? true : null], + filament_color_rgba: [hasMetadata ? tray.tray_color ?? '' : ''], + filament_vendor: [hasMetadata ? (isGenuineBambuSpool(tray) ? 'Bambu' : 'Generic') : ''], + filament_type: [hasMetadata ? tray.tray_type ?? '' : ''], + filament_sub_type: [hasMetadata ? subType(tray) : ''], + filament_info_idx: [hasMetadata ? tray.tray_info_idx ?? '' : ''], + nozzle_temp_min: [hasMetadata ? bambuNumber(tray.nozzle_temp_min) ?? 0 : 0], + nozzle_temp_max: [hasMetadata ? bambuNumber(tray.nozzle_temp_max) ?? 0 : 0], + bambu_tray_index: [BAMBU_EXTERNAL_TRAY], + }; +} + +export type BambuHmsSeverity = 'fatal' | 'error' | 'warning' | 'info'; + +export interface BambuHmsFault { + /** Four-group printer notation, or the two-group print_error notation. */ + code: string; + summary: string; + severity: BambuHmsSeverity; + helpUrl: string; + source: 'hms' | 'print_error'; +} + +const HMS_HOME_URL = 'https://wiki.bambulab.com/en/hms/home'; + +// These are exact four-group identities documented by Bambu. Never match only +// the first and last group: those discard component/alert-level information +// and can assign a neighbouring fault's remedy to the wrong problem. +const HMS_DESCRIPTIONS: Record = { + '0300-0200-0001-0001': 'The nozzle heater may be short-circuited.', + '0300-0200-0001-0002': 'The nozzle heater may have an open circuit.', + '0300-0300-0001-0001': 'The hotend cooling fan speed is abnormal.', + '0300-0D00-0001-0003': 'The build plate may not be positioned correctly.', + '0300-8003-0001-0001': 'Spaghetti was detected by AI print monitoring.', + '0300-800A-0001-0001': 'A filament pile-up was detected by AI print monitoring.', + '0500-0300-0001-0002': 'The toolhead is malfunctioning.', + '0500-0300-0001-0003': 'The AMS module is malfunctioning.', + '0700-6000-0002-0001': 'The AMS slot is overloaded; check for a tangle or excess drag.', + '07FF-2000-0002-0001': 'Filament has run out.', +}; + +function hmsInteger(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value >>> 0 : undefined; + if (typeof value !== 'string' || !value.trim()) return undefined; + const raw = value.trim(); + const parsed = /^0x/i.test(raw) ? Number.parseInt(raw.slice(2), 16) : Number(raw); + return Number.isFinite(parsed) ? parsed >>> 0 : undefined; +} + +function uint32Hex(value: number): string { + return (value >>> 0).toString(16).toUpperCase().padStart(8, '0'); +} + +function severityFromAttr(attr: number): BambuHmsSeverity { + const level = (attr >>> 8) & 0xf; + if (level === 1) return 'fatal'; + if (level === 2) return 'error'; + if (level === 4) return 'info'; + return 'warning'; +} + +function unknownHmsSummary(firstGroup: string): string { + if (firstGroup === '0300') return 'Printer hardware or motion system fault.'; + if (firstGroup === '0500') return 'Printer system or communication fault.'; + if (/^07(?:[0-9A-F]{2})$/.test(firstGroup)) return 'AMS or filament system fault.'; + return 'The printer reported an HMS fault.'; +} + +/** Every active Bambu fault, retaining the full identity needed by its wiki. */ +export function bambuHmsFaults(state: BambuState): BambuHmsFault[] { + const faults: BambuHmsFault[] = []; + const hms = Array.isArray(state.hms) ? state.hms : []; + + for (const entry of hms) { + const attr = hmsInteger(entry?.attr); + const code = hmsInteger(entry?.code); + if (attr == null || code == null) continue; + const groups = `${uint32Hex(attr)}${uint32Hex(code)}`.match(/.{4}/g); + if (!groups) continue; + const displayCode = groups.join('-'); + // Firmware emits these during a normal user-cancel sequence; they are + // acknowledgements, not actionable faults. + const shortCode = `${groups[0]}_${groups[3]}`; + if (shortCode === '0300_400C' || shortCode === '0500_400E') continue; + faults.push({ + code: displayCode, + summary: HMS_DESCRIPTIONS[displayCode] ?? unknownHmsSummary(groups[0]), + severity: severityFromAttr(attr), + // Bambu's per-code routes are incomplete and model-specific; even valid + // codes can return 404. Their HMS home is the stable official lookup. + helpUrl: HMS_HOME_URL, + source: 'hms', + }); + } + + const printError = hmsInteger(state.print_error); + if (printError) { + const raw = uint32Hex(printError); + const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; + faults.unshift({ + code, + summary: 'The printer reported a print-system error.', + severity: 'error', + helpUrl: HMS_HOME_URL, + source: 'print_error', + }); + } + + return faults; +} + +/** Whatever the printer is complaining about, in one line, or empty. */ +function errorMessage(faults: BambuHmsFault[]): string { + const first = faults[0]; + if (!first) return ''; + return faults.length === 1 + ? `${first.summary} (${first.code})` + : `${first.summary} (+${faults.length - 1} more)`; +} + +/** + * Builds the Klipper-shaped status object from held Bambu state. + * + * Keys are chosen to match what the existing UI probes for, including + * `temperature_sensor chamber`, which is how chamberTemperature.ts discovers a + * chamber reading. + */ +export function bambuStatus(state: BambuState): Record { + const gcodeState = String(state.gcode_state ?? '').toUpperCase(); + const printState = PRINT_STATE[gcodeState] ?? 'standby'; + const percent = bambuNumber(state.mc_percent); + const progress = percent == null ? undefined : Math.max(0, Math.min(1, percent / 100)); + const remainingMinutes = bambuNumber(state.mc_remaining_time); + const hmsFaults = bambuHmsFaults(state); + const rawSkippedObjects = (state as any).s_obj; + const skippedObjectIds = Array.isArray(rawSkippedObjects) + ? [...new Set(rawSkippedObjects + .map((value: unknown) => Number(value)) + .filter((value: number) => Number.isSafeInteger(value) && value >= 0))].slice(0, 64) + : []; + + const status: Record = { + print_stats: { + state: printState, + // subtask_name is the print job's name; Bambu's gcode_file is usually + // blank on the P-series, so this is the only human-readable label. + filename: String(state.subtask_name ?? state.gcode_file ?? ''), + message: printState === 'error' ? errorMessage(hmsFaults) : '', + info: { + current_layer: bambuNumber(state.layer_num), + total_layer: bambuNumber(state.total_layer_num), + // Bambu counts down in whole minutes; the rest of Helix works in + // seconds, and this is a far better ETA source than extrapolating + // from progress. + remaining_time: remainingMinutes == null ? undefined : remainingMinutes * 60, + }, + }, + virtual_sdcard: { + progress, + is_active: printState === 'printing', + }, + display_status: { progress }, + extruder: temperature(state.nozzle_temper, state.nozzle_target_temper), + heater_bed: temperature(state.bed_temper, state.bed_target_temper), + // Single hotend, and the name the rest of the app expects to find here. + toolhead: { extruder: 'extruder' }, + fan: { speed: fanSpeed(state.cooling_fan_speed) }, + bambu: { + speed_preset: bambuNumber(state.spd_lvl), + active_tray: activeBambuTray(state), + ams_health: bambuAmsHealth(state), + hms_faults: hmsFaults, + skipped_object_ids: skippedObjectIds, + fans: { + part: bambuFanPercent(state.cooling_fan_speed), + aux: bambuFanPercent(state.big_fan1_speed), + chamber: bambuFanPercent(state.big_fan2_speed), + }, + }, + }; + + // Presented as a Klipper LED so the dashboard's existing light control finds + // it. BambuProvider translates the SET_LED that control emits back into + // Bambu's own ledctrl command. + const lights = Array.isArray(state.lights_report) ? state.lights_report : []; + const chamberLight = lights.find((entry: any) => entry?.node === 'chamber_light'); + if (chamberLight) { + const on = String(chamberLight.mode ?? '').toLowerCase() === 'on' ? 1 : 0; + status['neopixel chamber_light'] = { color_data: [[on, on, on]] }; + } + + // The P-series reports a chamber figure whether or not it has a real sensor, + // so only surface it when it looks like a plausible reading. + const chamber = bambuNumber(state.chamber_temper); + if (chamber != null && chamber > 0) { + status['temperature_sensor chamber'] = { temperature: chamber }; + } + + if (Array.isArray(state?.ams?.ams) && state.ams.ams.length > 0) { + status.print_task_config = filamentSlots(state); + } else { + const external = externalFilamentSlot(state); + if (external) status.print_task_config = external; + } + + return status; +} + +/** Zero-based slot the printer is currently printing from, or null. */ +export function bambuActiveSlot(state: BambuState): number | null { + return activeBambuTray(state); +} + +/** AMS drying info, which has no Klipper equivalent and is surfaced directly. */ +export interface BambuAmsHealth { + unit: number; + /** Bambu's 1-5 scale, 1 being driest. Not a percentage. */ + humidity?: number; + temperature?: number; +} + +export function bambuAmsHealth(state: BambuState): BambuAmsHealth[] { + const units = Array.isArray(state?.ams?.ams) ? state.ams.ams : []; + return units.map((unit: any, position: number) => ({ + unit: bambuNumber(unit?.id) ?? position, + humidity: bambuNumber(unit?.humidity), + temperature: bambuNumber(unit?.temp), + })); +} diff --git a/services/bambuConnection.ts b/services/bambuConnection.ts new file mode 100644 index 0000000..3e508dc --- /dev/null +++ b/services/bambuConnection.ts @@ -0,0 +1,54 @@ +export type BambuConnectFailureReason = + | 'unavailable' + | 'bad-config' + | 'unreachable' + | 'wrong-access-code' + | 'wrong-serial' + | 'unknown'; + +const NATIVE_CONNECT_REASONS: Record = { + 'bad-config': 'bad-config', + 'wrong-serial': 'wrong-serial', + 'wrong-access-code': 'wrong-access-code', + 'connect-failed': 'unreachable', + 'tls-setup-failed': 'unreachable', + 'probe-timeout': 'unreachable', + 'probe-disconnected': 'unreachable', + 'subscribe-failed': 'unknown', + 'camera-failed': 'unreachable', +}; + +/** Turns a native bridge rejection into stable UI-facing connection semantics. */ +export function classifyBambuConnectionFailure(error: unknown): { + reason: BambuConnectFailureReason; + message: string; +} { + const value = error && typeof error === 'object' ? (error as Record) : {}; + const code = typeof value.code === 'string' ? value.code : ''; + const message = typeof value.message === 'string' ? value.message : String(error); + return { + reason: NATIVE_CONNECT_REASONS[code] ?? 'unknown', + message, + }; +} + +/** Actionable copy shared by add/edit printer connection checks. */ +export function bambuConnectionFailureMessage( + reason: BambuConnectFailureReason, + fallback: string +): string { + switch (reason) { + case 'wrong-serial': + return 'The serial number does not match this printer.'; + case 'wrong-access-code': + return 'The printer rejected the LAN access code.'; + case 'bad-config': + return 'Enter the printer IP, serial number, and LAN access code.'; + case 'unavailable': + return 'Bambu connection testing requires the Android build of Helix.'; + case 'unreachable': + return 'Could not reach the printer. Check its IP, Wi-Fi, and LAN Only Mode.'; + default: + return fallback || 'The printer did not accept the connection.'; + } +} diff --git a/services/bambuControls.ts b/services/bambuControls.ts new file mode 100644 index 0000000..1a01f6d --- /dev/null +++ b/services/bambuControls.ts @@ -0,0 +1,54 @@ +// Pure validation and G-code construction for Bambu dashboard controls. +// Keeping this separate from the native MQTT bridge makes every safety bound +// testable without loading React Native in the regression runner. + +export type BambuHeater = 'nozzle' | 'bed'; +export type BambuFan = 'part' | 'aux' | 'chamber'; +export interface BambuCalibrationOptions { + bedLeveling?: boolean; + vibration?: boolean; + motorNoise?: boolean; +} + +const HEATER_LIMITS: Record = { nozzle: 300, bed: 120 }; +const FAN_IDS: Record = { part: 1, aux: 2, chamber: 3 }; + +function boundedInteger(value: number, minimum: number, maximum: number, label: string): number { + if (!Number.isFinite(value)) throw new RangeError(`${label} must be a finite number`); + const rounded = Math.round(value); + if (rounded < minimum || rounded > maximum) { + throw new RangeError(`${label} must be between ${minimum} and ${maximum}`); + } + return rounded; +} + +export function buildBambuTemperatureCommand(heater: BambuHeater, target: number): string { + const safeTarget = boundedInteger(target, 0, HEATER_LIMITS[heater], `${heater} temperature`); + return heater === 'nozzle' ? `M104 T0 S${safeTarget}` : `M140 S${safeTarget}`; +} + +export function buildBambuFanCommand(fan: BambuFan, percent: number): string { + const safePercent = boundedInteger(percent, 0, 100, `${fan} fan speed`); + const pwm = Math.round((safePercent / 100) * 255); + return `M106 P${FAN_IDS[fan]} S${pwm}`; +} + +/** + * BambuStudio's public DeviceManager maps these three common calibrations to + * bits 1-3. Model-specific camera/nozzle/heatbed options stay unavailable. + */ +export function buildBambuCalibrationOption(options: BambuCalibrationOptions): number { + const option = + (options.bedLeveling ? 1 << 1 : 0) | + (options.vibration ? 1 << 2 : 0) | + (options.motorNoise ? 1 << 3 : 0); + if (option === 0) throw new RangeError('Select at least one Bambu calibration.'); + return option; +} + +/** Converts Bambu's reported 0-15 fan scale into the percentage shown in UI. */ +export function bambuFanPercent(value: unknown): number | undefined { + const raw = typeof value === 'string' && value.trim() ? Number(value) : value; + if (typeof raw !== 'number' || !Number.isFinite(raw)) return undefined; + return Math.round((Math.max(0, Math.min(15, raw)) / 15) * 100); +} diff --git a/services/bambuMqtt.ts b/services/bambuMqtt.ts new file mode 100644 index 0000000..0a6fff6 --- /dev/null +++ b/services/bambuMqtt.ts @@ -0,0 +1,474 @@ +// Typed surface over the HelixBambuMqtt native module — Bambu Lab's LAN +// protocol, which is MQTT over TLS on 8883 rather than Moonraker. +// +// This layer owns the wire vocabulary (topics live in Kotlin, payloads live +// here) and nothing else. Mapping Bambu's report JSON onto the Klipper-shaped +// status the dashboard already consumes is a separate concern. +// +// Payload shapes verified against crysxd/OctoApp-Plugin (AGPL-3.0). +// crabcore + +import { DeviceEventEmitter, NativeModules, Platform, type EmitterSubscription } from 'react-native'; +import { + classifyBambuConnectionFailure, + type BambuConnectFailureReason, +} from './bambuConnection'; +import { + buildBambuCalibrationOption, + type BambuCalibrationOptions, +} from './bambuControls'; + +interface BambuMqttNativeModule { + connect(config: { host: string; port?: number; serial: string; accessCode: string }): Promise; + disconnect(): Promise; + probeStatus(config: BambuConnectionConfig): Promise; + publish(payload: string): Promise; + uploadPrintArtifact(config: BambuPrintArtifactRequest): Promise; + startProjectFile(config: BambuProjectFileRequest): Promise; + startCamera(config: { host: string; serial: string; accessCode: string }): Promise; + stopCamera(): Promise; +} + +const nativeModule = NativeModules.HelixBambuMqtt as BambuMqttNativeModule | undefined; + +/** Bambu printers always listen here; there is no user-configurable port. */ +export const BAMBU_MQTT_PORT = 8883; + +export interface BambuConnectionConfig { + /** LAN address. Bambu has no mDNS name Helix can rely on, so this is an IP. */ + host: string; + /** Printed on the machine and in Bambu Studio; also the MQTT topic segment. */ + serial: string; + /** LAN access code from the printer's network settings. Grants full control. */ + accessCode: string; +} + +export interface BambuPrintArtifactRequest extends BambuConnectionConfig { + gcodePath: string; + remoteName: string; + usedToolMask: number; + predictionSeconds: number; + weightGrams: number; + filamentType: string; + filamentColor: string; +} + +export interface BambuPrintArtifactResult { + remoteName: string; + verifiedBytes: number; + archiveMd5: string; + gcodeMd5: string; + objects: BambuPrintableObject[]; +} + +export interface BambuPrintableObject { + identifyId: number; + name: string; +} + +export interface BambuPrintObjectJob { + remoteName: string; + objects: BambuPrintableObject[]; +} + +let lastUploadedObjectJob: BambuPrintObjectJob | null = null; + +function normalizedBambuJobName(value: string): string { + const leaf = value.trim().replace(/\\/g, '/').split('/').pop() ?? ''; + return leaf + .replace(/\.gcode\.3mf$/i, '') + .replace(/\.3mf$/i, '') + .replace(/\.gcode$/i, '') + .trim() + .toLowerCase(); +} + +/** Object IDs are valid only for the exact artifact that supplied them. */ +export function bambuObjectsForJob(activeJobName: string): BambuPrintableObject[] { + const active = normalizedBambuJobName(activeJobName); + const uploaded = lastUploadedObjectJob; + if (!active || !uploaded || normalizedBambuJobName(uploaded.remoteName) !== active) return []; + return uploaded.objects; +} + +export interface BambuProjectFileRequest { + remoteName: string; + subtaskName: string; + archiveMd5: string; + /** Zero-based file tool -> zero-based global AMS lane. */ + toolToLane: Record; + bedType: string; + useAms: boolean; + bedLeveling: boolean; + flowCalibration: boolean; + timelapse: boolean; + vibrationCalibration: boolean; +} + +export type BambuConnectionState = 'connected' | 'disconnected'; + +export interface BambuStateEvent { + state: BambuConnectionState; + message: string | null; +} + +/** + * Why a connect attempt failed, in terms a settings screen can act on. The + * native layer decides these — all three failure modes were confirmed against a + * real P1S rather than inferred, so there is no message-sniffing here. + */ +export type BambuConnectError = BambuConnectFailureReason; + +export class BambuError extends Error { + readonly reason: BambuConnectError; + + constructor(reason: BambuConnectError, message: string) { + super(message); + this.name = 'BambuError'; + this.reason = reason; + } +} + +/** Android-only for now; the iOS build has no Bambu transport yet. */ +export function isBambuTransportAvailable(): boolean { + return Platform.OS === 'android' && !!nativeModule; +} + +function toBambuError(error: unknown): BambuError { + if (error instanceof BambuError) return error; + const failure = classifyBambuConnectionFailure(error); + return new BambuError(failure.reason, failure.message); +} + +export async function connectBambu(config: BambuConnectionConfig): Promise { + if (!nativeModule) { + throw new BambuError('unavailable', 'Bambu printers need the Android build of Helix.'); + } + + try { + await nativeModule.connect({ + host: config.host.trim(), + port: BAMBU_MQTT_PORT, + serial: config.serial.trim(), + accessCode: config.accessCode.trim(), + }); + } catch (error: unknown) { + throw toBambuError(error); + } +} + +export async function disconnectBambu(): Promise { + if (!nativeModule) return; + await nativeModule.disconnect().catch(() => {}); +} + +/** Performs one bounded, read-only status request without changing the active printer. */ +export async function probeBambuStatus( + config: BambuConnectionConfig +): Promise> { + if (!nativeModule) { + throw new BambuError('unavailable', 'Bambu printers need the Android build of Helix.'); + } + + let payload: string; + try { + payload = await nativeModule.probeStatus({ + host: config.host.trim(), + serial: config.serial.trim(), + accessCode: config.accessCode.trim(), + }); + } catch (error: unknown) { + throw toBambuError(error); + } + const parsed: unknown = JSON.parse(payload); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new BambuError('unknown', 'The Bambu printer returned an invalid status report.'); + } + return parsed as Record; +} + +/** Uploads a validated single-filament P1S archive; it does not start printing. */ +export async function uploadBambuPrintArtifact( + request: BambuPrintArtifactRequest +): Promise { + if (!nativeModule) { + throw new BambuError('unavailable', 'Bambu printers need the Android build of Helix.'); + } + const result = await nativeModule.uploadPrintArtifact({ + ...request, + host: request.host.trim(), + serial: request.serial.trim(), + accessCode: request.accessCode.trim(), + gcodePath: request.gcodePath.trim(), + remoteName: request.remoteName.trim(), + filamentType: request.filamentType.trim(), + filamentColor: request.filamentColor.trim(), + }); + const objects = result.objects + .filter( + (item) => + Number.isSafeInteger(item?.identifyId) && + item.identifyId >= 0 && + typeof item.name === 'string' && + item.name.trim().length > 0 + ) + .slice(0, 64) + .map((item) => ({ identifyId: item.identifyId, name: item.name.trim() })); + lastUploadedObjectJob = { remoteName: result.remoteName, objects }; + return { ...result, objects }; +} + +/** Starts an uploaded project and resolves only after the matching printer acknowledgement. */ +export async function startBambuProjectFile(request: BambuProjectFileRequest): Promise { + if (!nativeModule) { + throw new BambuError('unavailable', 'Bambu printers need the Android build of Helix.'); + } + await nativeModule.startProjectFile({ + ...request, + remoteName: request.remoteName.trim(), + subtaskName: request.subtaskName.trim(), + archiveMd5: request.archiveMd5.trim().toUpperCase(), + bedType: request.bedType.trim(), + }); +} + +// Bambu echoes sequence_id back on responses. Nothing in Helix correlates them +// yet, but reusing a constant makes the printer's own logs useless, so count. +let sequenceId = 0; + +function nextSequenceId(): string { + sequenceId += 1; + return String(sequenceId); +} + +async function publish(payload: Record): Promise { + if (!nativeModule) { + throw new BambuError('unavailable', 'Not connected to a Bambu printer.'); + } + await nativeModule.publish(JSON.stringify(payload)); +} + +/** + * Ask for a complete state dump. Bambu only pushes deltas after the first + * report, so without this a freshly connected client sees almost nothing. + */ +export function requestBambuFullState(): Promise { + return publish({ pushing: { sequence_id: nextSequenceId(), command: 'pushall' } }); +} + +/** Firmware and module versions, which the report feed never includes. */ +export function requestBambuVersion(): Promise { + return publish({ info: { sequence_id: nextSequenceId(), command: 'get_version' } }); +} + +/** + * Runs a raw G-code line. Bambu accepts these over MQTT, which is what lets the + * existing console and macro buttons work against a Bambu printer. The trailing + * newline is required — the firmware ignores a line without it. + */ +export function sendBambuGcode(line: string): Promise { + return publish({ + print: { sequence_id: nextSequenceId(), command: 'gcode_line', param: `${line}\n` }, + }); +} + +export function pauseBambuPrint(): Promise { + return publish({ print: { sequence_id: nextSequenceId(), command: 'pause' } }); +} + +export function resumeBambuPrint(): Promise { + return publish({ print: { sequence_id: nextSequenceId(), command: 'resume' } }); +} + +export function stopBambuPrint(): Promise { + return publish({ print: { sequence_id: nextSequenceId(), command: 'stop' } }); +} + +/** Permanently excludes the selected objects from the running Bambu job. */ +export function skipBambuObjects(objectIds: number[]): Promise { + const ids = [...new Set(objectIds)]; + if (ids.length === 0 || ids.length > 63 || ids.some((id) => !Number.isSafeInteger(id) || id < 0)) { + throw new RangeError('Choose between 1 and 63 valid Bambu object IDs.'); + } + return publish({ + print: { sequence_id: nextSequenceId(), command: 'skip_objects', obj_list: ids }, + }); +} + +/** Acknowledges the printer's active HMS/print-error dialog. */ +export function clearBambuErrors(): Promise { + return publish({ print: { sequence_id: nextSequenceId(), command: 'clean_print_error' } }); +} + +export function startBambuCalibration(options: BambuCalibrationOptions): Promise { + return publish({ + print: { + sequence_id: nextSequenceId(), + command: 'calibration', + option: buildBambuCalibrationOption(options), + }, + }); +} + +/** + * Loads a tray into the hotend, by the global index bambuTrays() produces + * (unit 0 owns 0-3, unit 1 owns 4-7). Pass null to unload. + * + * The temperatures matter: the printer uses them to soften the outgoing + * filament, and sending nothing makes it refuse the swap. Callers should take + * them from the tray's own nozzle_temp_min/max. + */ +export function changeBambuFilament( + trayIndex: number | null, + nozzleTemperature: number +): Promise { + const target = trayIndex ?? BAMBU_UNLOAD_TARGET; + return publish({ + print: { + sequence_id: nextSequenceId(), + command: 'ams_change_filament', + target, + curr_temp: nozzleTemperature, + tar_temp: nozzleTemperature, + }, + }); +} + +/** 255 tells the AMS to unload rather than load a slot. */ +const BAMBU_UNLOAD_TARGET = 255; + +/** + * Answers the "filament change" prompt the printer raises mid-swap. Without + * this the machine sits waiting for someone to press its own screen. + */ +export function controlBambuAms(action: 'resume' | 'pause' | 'reset'): Promise { + return publish({ + print: { sequence_id: nextSequenceId(), command: 'ams_control', param: action }, + }); +} + +export interface BambuFilamentSetting { + /** Zero-based AMS unit, or 255 for the single external holder. */ + unit: number; + /** Zero-based AMS tray, or the external holder's global id 254. */ + tray: number; + /** BambuStudio includes slot_id=0 for external-spool edits. */ + slot?: number; + /** Bambu's filament id, e.g. "GFA00" for Bambu PLA Basic. */ + filamentId: string; + type: string; + /** 8-digit RGBA hex, no leading '#'. */ + colorRgba: string; + nozzleTempMin: number; + nozzleTempMax: number; +} + +/** Tells the printer what is in an editable AMS tray or external holder. */ +export function setBambuFilament(setting: BambuFilamentSetting): Promise { + return publish({ + print: { + sequence_id: nextSequenceId(), + command: 'ams_filament_setting', + ams_id: setting.unit, + tray_id: setting.tray, + ...(setting.slot == null ? {} : { slot_id: setting.slot }), + tray_info_idx: setting.filamentId, + tray_type: setting.type, + tray_color: setting.colorRgba.replace(/^#/, '').toUpperCase(), + nozzle_temp_min: setting.nozzleTempMin, + nozzle_temp_max: setting.nozzleTempMax, + }, + }); +} + +export function setBambuChamberLight(on: boolean): Promise { + return publish({ + system: { + sequence_id: nextSequenceId(), + command: 'ledctrl', + led_node: 'chamber_light', + led_mode: on ? 'on' : 'off', + // Required by the firmware even for a plain on/off; they only take + // effect for the flashing modes. + led_on_time: 500, + led_off_time: 500, + loop_times: 0, + interval_time: 0, + }, + }); +} + +/** Bambu's four speed presets: silent, standard, sport, ludicrous. */ +export type BambuSpeedPreset = 1 | 2 | 3 | 4; + +export function setBambuSpeed(preset: BambuSpeedPreset): Promise { + return publish({ + print: { sequence_id: nextSequenceId(), command: 'print_speed', param: String(preset) }, + }); +} + +/** + * Opens the chamber camera and resolves a local MJPEG URL. + * + * The P-series serves neither RTSP nor anything a WebView can open — port 6000 + * speaks a private protocol — so the native layer republishes the frames on + * loopback. The URL drops straight into CameraFeed like a Moonraker webcam. + * + * Resolves only once a frame has genuinely arrived, so the caller never gets a + * URL that shows nothing. + */ +export async function startBambuCamera(config: BambuConnectionConfig): Promise { + if (!nativeModule) { + throw new BambuError('unavailable', 'Bambu printers need the Android build of Helix.'); + } + + try { + return await nativeModule.startCamera({ + host: config.host.trim(), + serial: config.serial.trim(), + accessCode: config.accessCode.trim(), + }); + } catch (error: unknown) { + throw toBambuError(error); + } +} + +export async function stopBambuCamera(): Promise { + if (!nativeModule) return; + await nativeModule.stopCamera().catch(() => {}); +} + +/** + * Raw report payloads, already JSON-parsed. Bambu sends both full dumps and + * partial deltas over the same topic with no flag distinguishing them, so + * consumers must merge rather than replace. + */ +export function onBambuMessage( + callback: (report: Record) => void +): EmitterSubscription { + return DeviceEventEmitter.addListener('HelixBambuMessage', (event: { payload: string }) => { + let parsed: Record; + try { + parsed = JSON.parse(event.payload); + } catch { + // A malformed report is not worth tearing the connection down over. + return; + } + callback(parsed); + }); +} + +/** + * Fires when the printer's video stream ends. The URL from startBambuCamera is + * dead at that point, so listeners should drop it and reopen rather than leave + * a pane pointing at a port nothing is serving. + */ +export function onBambuCameraStopped(callback: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('HelixBambuCameraStopped', callback); +} + +export function onBambuState( + callback: (event: BambuStateEvent) => void +): EmitterSubscription { + return DeviceEventEmitter.addListener('HelixBambuState', callback); +} diff --git a/services/bambuReport.ts b/services/bambuReport.ts new file mode 100644 index 0000000..e6c00b4 --- /dev/null +++ b/services/bambuReport.ts @@ -0,0 +1,237 @@ +// Bambu's report feed: what arrives, and how to keep a coherent picture of it. +// +// The printer publishes to `device/{serial}/report` in two flavours, told apart +// by `print.msg`: +// +// msg: 0 full state dump, ~63 fields, only sent in reply to `pushall` +// msg: 1 delta, often 3-4 fields, sent continuously +// +// pybambu and OctoApp guess at this with a "more than 40 keys means full sync" +// heuristic. The field is authoritative, so Helix uses it. +// +// Deltas must be merged onto held state, never swapped in — a delta carrying +// only `bed_temper` would otherwise wipe out the print, the AMS and the +// temperatures. Shapes verified against a real P1S. +// crabcore + +/** Held state: the accumulation of one full dump plus every delta since. */ +export type BambuState = Record; + +/** One AMS tray. Empty slots report little more than an `id`. */ +export interface BambuTray { + id?: string; + tray_type?: string; + tray_sub_brands?: string; + tray_id_name?: string; + tray_info_idx?: string; + /** 8-digit RGBA hex, e.g. "FF6A13FF". Zeroed when the slot is empty. */ + tray_color?: string; + tray_weight?: string; + tray_diameter?: string; + nozzle_temp_min?: string; + nozzle_temp_max?: string; + /** Percent left, by the printer's own reckoning. -1 when it cannot tell. */ + remain?: number; + tray_uuid?: string; + /** + * RFID tag of a genuine Bambu spool, all zeroes when the slot was configured + * by hand. This — not tray_is_bbl_bits — is what distinguishes a real Bambu + * spool: a hand-set Generic PETG slot still reports that bit as set. + */ + tag_uid?: string; + /** Every colour in the filament; more than one for gradient spools. */ + cols?: string[]; +} + +export interface BambuAmsUnit { + id?: string; + /** Bambu's own 0-5 scale, where 1 is driest. Not a percentage. */ + humidity?: string; + temp?: string; + tray?: BambuTray[]; +} + +/** Nothing is loaded. Bambu uses this in tray_now/tray_tar/tray_pre. */ +export const BAMBU_NO_TRAY = 255; + +/** Trays per AMS unit, fixed by the hardware. */ +export const TRAYS_PER_AMS = 4; + +/** The external spool holder, which reports as a tray with this id. */ +export const BAMBU_EXTERNAL_TRAY = 254; + +/** The virtual AMS id Bambu uses when editing the single external holder. */ +export const BAMBU_EXTERNAL_AMS = 255; + +export type BambuFilamentSource = 'ams' | 'external'; + +export interface BambuFilamentWriteLocation { + unit: number; + tray: number; + /** Required for the virtual external holder; omitted for physical AMS trays. */ + slot?: number; +} + +/** + * On-wire address for a filament edit. + * + * The external values are not inferred from tray numbering: BambuStudio's + * captured P1/X1 request and Bambu's Fleet Hub reference both use + * ams_id=255, tray_id=254, slot_id=0. Physical AMS edits retain the existing + * unit/local-tray encoding. + */ +export function bambuFilamentWriteLocation( + source: BambuFilamentSource, + channel: number +): BambuFilamentWriteLocation { + if (!Number.isInteger(channel) || channel < 0) { + throw new RangeError('Bambu filament channel must be a non-negative integer'); + } + if (source === 'external') { + if (channel !== 0) throw new RangeError('A single external spool only has channel 0'); + return { unit: BAMBU_EXTERNAL_AMS, tray: BAMBU_EXTERNAL_TRAY, slot: 0 }; + } + return { unit: Math.floor(channel / TRAYS_PER_AMS), tray: channel % TRAYS_PER_AMS }; +} + +// Bambu Studio's official Generic ... @base profiles. These IDs describe a +// material preset; they are not evidence that an AMS tray is occupied. +const GENERIC_FILAMENT_IDS: Record = { + ABS: 'GFB99', + ASA: 'GFB98', + BVOH: 'GFS97', + EVA: 'GFR99', + HIPS: 'GFS98', + PA: 'GFN99', + 'PA-CF': 'GFN98', + PC: 'GFC99', + PCTG: 'GFG97', + PE: 'GFP99', + 'PE-CF': 'GFP98', + PETG: 'GFG99', + 'PETG-CF': 'GFG98', + PHA: 'GFR98', + PLA: 'GFL99', + 'PLA-CF': 'GFL98', + PP: 'GFP97', + 'PP-CF': 'GFP96', + 'PP-GF': 'GFP95', + 'PPA-CF': 'GFN97', + 'PPA-GF': 'GFN96', + PPS: 'GFT97', + 'PPS-CF': 'GFT98', + PVA: 'GFS99', + TPU: 'GFU99', +}; + +/** Official generic ID for an editable AMS material, or undefined to fail closed. */ +export function genericBambuFilamentId(material: string): string | undefined { + return GENERIC_FILAMENT_IDS[material.trim().toUpperCase()]; +} + +export type BambuFilamentEditIdentity = + | { ok: true; filamentId: string } + | { ok: false; reason: 'empty' | 'unsupported-material' }; + +/** Separates the AMS occupancy bit from its optional material preset ID. */ +export function resolveBambuFilamentEditIdentity( + occupied: boolean, + reportedFilamentId: unknown, + material: string +): BambuFilamentEditIdentity { + if (!occupied) return { ok: false, reason: 'empty' }; + const filamentId = String(reportedFilamentId ?? '').trim() || genericBambuFilamentId(material); + return filamentId + ? { ok: true, filamentId } + : { ok: false, reason: 'unsupported-material' }; +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Deep-merges a delta into held state. Objects merge key by key; arrays are + * replaced wholesale, because Bambu resends a full array whenever any element + * of it changes and merging them positionally would strand removed entries. + */ +export function mergeBambuState(previous: BambuState, incoming: BambuState): BambuState { + const merged: BambuState = { ...previous }; + + for (const [key, value] of Object.entries(incoming)) { + const existing = merged[key]; + merged[key] = isPlainObject(value) && isPlainObject(existing) + ? mergeBambuState(existing, value) + : value; + } + + return merged; +} + +/** + * Folds one raw report into held state, returning the new state and whether + * this was a full dump. Reports that carry no `print` section (`info` replies + * to get_version, for instance) leave the state untouched. + */ +export function applyBambuReport( + state: BambuState, + report: Record +): { state: BambuState; isFullState: boolean } { + const print = report?.print; + if (!isPlainObject(print)) return { state, isFullState: false }; + + return { + state: mergeBambuState(state, print), + isFullState: print.msg === 0, + }; +} + +/** Bambu sends numbers as strings about as often as it sends them as numbers. */ +export function bambuNumber(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value !== 'string' || !value.trim()) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** + * Flattens the AMS units into one list indexed the way Bambu addresses trays + * globally: unit 0 owns 0-3, unit 1 owns 4-7, and so on. `tray_now` and the + * ams_change_filament command both speak in these indices. + */ +export function bambuTrays(state: BambuState): { index: number; tray: BambuTray }[] { + const units: BambuAmsUnit[] = Array.isArray(state?.ams?.ams) ? state.ams.ams : []; + const trays: { index: number; tray: BambuTray }[] = []; + + units.forEach((unit, unitPosition) => { + // Trust the unit's own id over its position — a second AMS can report out + // of order, and getting this wrong would load filament from the wrong bay. + const unitId = bambuNumber(unit?.id) ?? unitPosition; + const unitTrays = Array.isArray(unit?.tray) ? unit.tray : []; + + unitTrays.forEach((tray, trayPosition) => { + const trayId = bambuNumber(tray?.id) ?? trayPosition; + trays.push({ index: unitId * TRAYS_PER_AMS + trayId, tray }); + }); + }); + + return trays; +} + +/** + * True when a tray actually holds filament. Bambu leaves stale values in a + * tray after it is unloaded, so the presence of `tray_type` alone proves + * nothing; `tray_exist_bits` is the printer's own answer. + */ +export function isBambuTrayLoaded(state: BambuState, index: number): boolean { + const bits = parseInt(String(state?.ams?.tray_exist_bits ?? '0'), 16); + if (!Number.isFinite(bits)) return false; + return (bits & (1 << index)) !== 0; +} + +/** Globally-indexed tray currently loaded into the hotend, if any. */ +export function activeBambuTray(state: BambuState): number | null { + const now = bambuNumber(state?.ams?.tray_now); + if (now == null || now === BAMBU_NO_TRAY) return null; + return now; +} diff --git a/services/bespok3d.ts b/services/bespok3d.ts new file mode 100644 index 0000000..c6c2a8b --- /dev/null +++ b/services/bespok3d.ts @@ -0,0 +1,364 @@ +import { NativeModules, Platform } from 'react-native'; + +interface Bespok3dNativeModule { + probe(config: { host: string }): Promise; + preflightU1(config: { host: string; password: string }): Promise; + prepareU1Enrollment(): Promise; + enrollU1(config: Bespok3dPreparedEnrollment & { + host: string; + password: string; + sshHostKeySha256: string; + label: string; + }): Promise; + requestAccess(config: { + host: string; + label: string; + publicKey?: string; + }): Promise; + checkAccess(config: Bespok3dCredentials & { host: string }): Promise; + helixScreenState(config: Pick & { + host: string; + }): Promise; + configureHelixScreen(config: Pick & { + host: string; + selected: Bespok3dScreenUi; + }): Promise; + plugins(config: Pick & { + host: string; + }): Promise; + installPlugins(config: Pick & { + host: string; + pluginIds: string[]; + varsJson: string; + }): Promise; + installBundledHelixScreen( + config: Pick & { host: string }, + ): Promise; +} + +const nativeModule = NativeModules.HelixBespok3d as Bespok3dNativeModule | undefined; + +export interface Bespok3dProbe { + version: string; + license: 'AGPL-3.0-or-later'; + source: 'https://github.com/Bespok3d/daemon'; + /** TOFU certificate observed during the read-only probe. */ + certificatePem: string; + /** Colon-separated SHA-256 fingerprint shown during cross-client approval. */ + certificateSha256: string; +} + +export interface Bespok3dCredentials { + identity: string; + token: string; + certificatePem: string; + certificateSha256: string; +} + +export interface Bespok3dU1Preflight { + firmware: 'stock' | 'extended'; + model: string; + overlayActive: boolean; + workspacePresent: boolean; + daemonRunning: boolean; + printState: string; + eligible: boolean; + reason: string | null; + /** First-contact SSH host-key fingerprint to show before enrollment mutates the printer. */ + sshHostKeySha256: string; +} + +export interface Bespok3dPreparedEnrollment { + identity: string; + token: string; +} + +export interface Bespok3dU1EnrollmentResult extends Bespok3dCredentials { + daemonVersion: string; + jinniVersion: string; + completedSteps: string[]; +} + +export type Bespok3dPendingAccess = Bespok3dCredentials; + +export type Bespok3dAccessStatus = + | { granted: false; version?: undefined; printerUuid?: undefined } + | { granted: true; version: string; printerUuid: string }; + +export type Bespok3dScreenUi = 'snapmaker' | 'helixscreen'; + +export type Bespok3dHelixScreenState = + | { installed: false; selected: null } + | { installed: true; selected: Bespok3dScreenUi }; + +export interface Bespok3dPluginConfigField { + key: string; + label: string; + type: string; + defaultValue: string | null; + required: boolean; + options: string[]; + hint: string; + onValue: string; + offValue: string; +} + +export interface Bespok3dPlugin { + id: string; + title: string; + version: string; + tagline: string; + category: string; + dependencies: string[]; + config: Bespok3dPluginConfigField[]; +} + +export interface Bespok3dPluginCatalog { + plugins: Bespok3dPlugin[]; + installed: Record; +} + +export interface Bespok3dPluginInstallResult { + ok: boolean; + installedIds: string[]; + failures: Record; +} + +export type Bespok3dPluginVars = Record>; + +export class Bespok3dError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'Bespok3dError'; + this.code = code; + } +} + +export function isBespok3dAvailable(): boolean { + return Platform.OS === 'android' && !!nativeModule; +} + +/** Accepts a saved printer URL or a bare host and returns only its LAN hostname. */ +export function bespok3dHost(value: string): string { + const clean = value.trim(); + if (!clean) throw new Bespok3dError('bad-config', 'Printer address is required.'); + try { + const parsed = new URL(clean.includes('://') ? clean : `http://${clean}`); + const host = parsed.hostname.replace(/^\[|\]$/g, ''); + if (!host) throw new Error('missing host'); + return host; + } catch { + throw new Bespok3dError('bad-config', 'Printer address is invalid.'); + } +} + +function requireNative(): Bespok3dNativeModule { + if (!nativeModule) { + throw new Bespok3dError( + 'unavailable', + 'Bespok3d management needs the Android build of Helix.', + ); + } + return nativeModule; +} + +function asBespok3dError(error: unknown): Bespok3dError { + if (error instanceof Bespok3dError) return error; + const record = error && typeof error === 'object' ? error as Record : {}; + const code = typeof record.code === 'string' ? record.code : 'unknown'; + const message = typeof record.message === 'string' + ? record.message + : 'Bespok3d could not reach this printer.'; + return new Bespok3dError(code, message); +} + +/** Read-only detection through the daemon's public AGPL source-offer endpoint. */ +export async function probeBespok3d(printerAddress: string): Promise { + try { + return await requireNative().probe({ host: bespok3dHost(printerAddress) }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** + * Read-only stock-U1 enrollment check. The SSH password is passed directly to + * native code and is never included in the result or persisted by this service. + */ +export async function preflightBespok3dU1( + printerAddress: string, + password: string, +): Promise { + try { + if (!password) throw new Bespok3dError('bad-config', 'SSH password is required.'); + return await requireNative().preflightU1({ + host: bespok3dHost(printerAddress), + password, + }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** + * Generates the identity and token before any printer mutation. The UI must put + * this value in SecureStore before calling enroll so a disconnected run can retry. + */ +export async function prepareBespok3dU1Enrollment(): Promise { + try { + return await requireNative().prepareU1Enrollment(); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** + * Runs the signed stock-U1 enrollment recipe after preflight fingerprint confirmation. + * No screen calls this yet; the UI and explicit destructive confirmation are a later phase. + */ +export async function enrollBespok3dU1( + printerAddress: string, + password: string, + sshHostKeySha256: string, + label: string, + credentials: Bespok3dPreparedEnrollment, +): Promise { + try { + if (!password) throw new Bespok3dError('bad-config', 'SSH password is required.'); + return await requireNative().enrollU1({ + host: bespok3dHost(printerAddress), + password, + sshHostKeySha256: sshHostKeySha256.trim(), + label: label.trim(), + ...credentials, + }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** + * Creates a pending phone identity. The returned token and certificate are + * secrets and must be placed in SecureStore by the UI phase, never AsyncStorage. + */ +export async function requestBespok3dAccess( + printerAddress: string, + label: string, + publicKey?: string, +): Promise { + try { + return await requireNative().requestAccess({ + host: bespok3dHost(printerAddress), + label: label.trim(), + publicKey: publicKey?.trim(), + }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** A 401 is the normal "still waiting for desktop approval" result. */ +export async function checkBespok3dAccess( + printerAddress: string, + credentials: Bespok3dCredentials, +): Promise { + try { + return await requireNative().checkAccess({ + host: bespok3dHost(printerAddress), + ...credentials, + }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** Reads the signature-verified official catalog and the printer's live installed set. */ +export async function listBespok3dPlugins( + printerAddress: string, + credentials: Bespok3dCredentials, +): Promise { + try { + return await requireNative().plugins({ + host: bespok3dHost(printerAddress), + token: credentials.token, + certificatePem: credentials.certificatePem, + }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** Reads the persisted selection for the locally installed HelixScreen UI plugin. */ +export async function getBespok3dHelixScreenState( + printerAddress: string, + credentials: Bespok3dCredentials, +): Promise { + try { + return await requireNative().helixScreenState({ + host: bespok3dHost(printerAddress), + token: credentials.token, + certificatePem: credentials.certificatePem, + }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** Switches only the U1 touchscreen between the stock GUI and HelixScreen. */ +export async function configureBespok3dHelixScreen( + printerAddress: string, + credentials: Bespok3dCredentials, + selected: Bespok3dScreenUi, +): Promise { + try { + return await requireNative().configureHelixScreen({ + host: bespok3dHost(printerAddress), + token: credentials.token, + certificatePem: credentials.certificatePem, + selected, + }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** Installs the exact HelixScreen package embedded in and signed with this APK. */ +export async function installBundledBespok3dHelixScreen( + printerAddress: string, + credentials: Bespok3dCredentials, +): Promise { + try { + return await requireNative().installBundledHelixScreen({ + host: bespok3dHost(printerAddress), + token: credentials.token, + certificatePem: credentials.certificatePem, + }); + } catch (error) { + throw asBespok3dError(error); + } +} + +/** Installs only the explicitly selected ids; native code verifies every package before upload. */ +export async function installBespok3dPlugins( + printerAddress: string, + credentials: Bespok3dCredentials, + pluginIds: string[], + vars: Bespok3dPluginVars, +): Promise { + try { + if (pluginIds.length === 0) { + throw new Bespok3dError('bad-config', 'Select at least one Bespok3d plugin.'); + } + return await requireNative().installPlugins({ + host: bespok3dHost(printerAddress), + token: credentials.token, + certificatePem: credentials.certificatePem, + pluginIds, + varsJson: JSON.stringify(vars), + }); + } catch (error) { + throw asBespok3dError(error); + } +} diff --git a/services/bespok3dCredentials.ts b/services/bespok3dCredentials.ts new file mode 100644 index 0000000..b44af46 --- /dev/null +++ b/services/bespok3dCredentials.ts @@ -0,0 +1,125 @@ +import type { + Bespok3dPreparedEnrollment, + Bespok3dU1EnrollmentResult, +} from './bespok3d'; + +export const BESPOK3D_CREDENTIAL_RECORD_VERSION = 1; + +interface Bespok3dCredentialRecordBase extends Bespok3dPreparedEnrollment { + version: typeof BESPOK3D_CREDENTIAL_RECORD_VERSION; + printerId: string; + sshHostKeySha256: string; + label: string; +} + +export interface Bespok3dPreparedCredentialRecord extends Bespok3dCredentialRecordBase { + status: 'prepared'; +} + +export interface Bespok3dEnrolledCredentialRecord extends Bespok3dCredentialRecordBase { + status: 'enrolled'; + certificatePem: string; + certificateSha256: string; + daemonVersion: string; + jinniVersion: string; +} + +export type Bespok3dCredentialRecord = + | Bespok3dPreparedCredentialRecord + | Bespok3dEnrolledCredentialRecord; + +const IDENTITY = /^helix-[a-f0-9-]{36}$/; +const TOKEN = /^[a-f0-9]{64}$/; +const SSH_FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{43}$/; +const CERTIFICATE_FINGERPRINT = /^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$/; +const PRINTER_ID = /^[A-Za-z0-9._-]{1,128}$/; + +export function bespok3dCredentialStorageKey(printerId: string): string { + if (!PRINTER_ID.test(printerId)) throw new Error('Printer identity is invalid.'); + return `helix.bespok3d.${printerId}`; +} + +export function createPreparedBespok3dCredentialRecord( + printerId: string, + sshHostKeySha256: string, + label: string, + credentials: Bespok3dPreparedEnrollment, +): Bespok3dPreparedCredentialRecord { + const normalized = normalizeBespok3dCredentialRecord({ + version: BESPOK3D_CREDENTIAL_RECORD_VERSION, + status: 'prepared', + printerId, + sshHostKeySha256, + label, + ...credentials, + }); + if (normalized?.status !== 'prepared') throw new Error('Prepared Bespok3d credentials are invalid.'); + return normalized; +} + +export function createEnrolledBespok3dCredentialRecord( + prepared: Bespok3dPreparedCredentialRecord, + result: Bespok3dU1EnrollmentResult, +): Bespok3dEnrolledCredentialRecord { + const normalized = normalizeBespok3dCredentialRecord({ + ...prepared, + status: 'enrolled', + certificatePem: result.certificatePem, + certificateSha256: result.certificateSha256, + daemonVersion: result.daemonVersion, + jinniVersion: result.jinniVersion, + }); + if (normalized?.status !== 'enrolled') throw new Error('Enrolled Bespok3d credentials are invalid.'); + return normalized; +} + +export function normalizeBespok3dCredentialRecord( + raw: unknown, +): Bespok3dCredentialRecord | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const record = raw as Record; + if (record.version !== BESPOK3D_CREDENTIAL_RECORD_VERSION) return null; + if (record.status !== 'prepared' && record.status !== 'enrolled') return null; + if (typeof record.printerId !== 'string' || !PRINTER_ID.test(record.printerId)) return null; + if (typeof record.identity !== 'string' || !IDENTITY.test(record.identity)) return null; + if (typeof record.token !== 'string' || !TOKEN.test(record.token)) return null; + if ( + typeof record.sshHostKeySha256 !== 'string' + || !SSH_FINGERPRINT.test(record.sshHostKeySha256) + ) return null; + if ( + typeof record.label !== 'string' + || !record.label.trim() + || record.label.length > 64 + || [...record.label].some((character) => /[\u0000-\u001f\u007f]/.test(character)) + ) return null; + + const base: Bespok3dCredentialRecordBase = { + version: BESPOK3D_CREDENTIAL_RECORD_VERSION, + printerId: record.printerId, + identity: record.identity, + token: record.token, + sshHostKeySha256: record.sshHostKeySha256, + label: record.label, + }; + if (record.status === 'prepared') return { ...base, status: 'prepared' }; + + if ( + typeof record.certificatePem !== 'string' + || !record.certificatePem.includes('-----BEGIN CERTIFICATE-----') + || typeof record.certificateSha256 !== 'string' + || !CERTIFICATE_FINGERPRINT.test(record.certificateSha256) + || typeof record.daemonVersion !== 'string' + || !record.daemonVersion.trim() + || typeof record.jinniVersion !== 'string' + || !record.jinniVersion.trim() + ) return null; + return { + ...base, + status: 'enrolled', + certificatePem: record.certificatePem, + certificateSha256: record.certificateSha256, + daemonVersion: record.daemonVersion, + jinniVersion: record.jinniVersion, + }; +} diff --git a/services/bespok3dSecureStore.ts b/services/bespok3dSecureStore.ts new file mode 100644 index 0000000..edd0fd3 --- /dev/null +++ b/services/bespok3dSecureStore.ts @@ -0,0 +1,42 @@ +import * as SecureStore from 'expo-secure-store'; +import { + bespok3dCredentialStorageKey, + normalizeBespok3dCredentialRecord, + type Bespok3dCredentialRecord, +} from './bespok3dCredentials'; + +export async function readBespok3dCredentialRecord( + printerId: string, +): Promise { + const key = bespok3dCredentialStorageKey(printerId); + const serialized = await SecureStore.getItemAsync(key); + if (!serialized) return null; + const parsed = (() => { + try { + return JSON.parse(serialized) as unknown; + } catch { + return null; + } + })(); + const record = normalizeBespok3dCredentialRecord(parsed); + if (!record || record.printerId !== printerId) { + await SecureStore.deleteItemAsync(key); + return null; + } + return record; +} + +export async function writeBespok3dCredentialRecord( + record: Bespok3dCredentialRecord, +): Promise { + const normalized = normalizeBespok3dCredentialRecord(record); + if (!normalized) throw new Error('Bespok3d credentials are invalid.'); + await SecureStore.setItemAsync( + bespok3dCredentialStorageKey(record.printerId), + JSON.stringify(normalized), + ); +} + +export async function deleteBespok3dCredentialRecord(printerId: string): Promise { + await SecureStore.deleteItemAsync(bespok3dCredentialStorageKey(printerId)); +} diff --git a/services/dashboardSections.ts b/services/dashboardSections.ts new file mode 100644 index 0000000..da412db --- /dev/null +++ b/services/dashboardSections.ts @@ -0,0 +1,93 @@ +// Per-printer Home layout. +// +// The dashboard toggles used to be one global set, so hiding the GUI card on a +// FlashForge also hid it on every Snapmaker. Each printer now keeps its own +// layout, mirroring how macroDisplay is stored per printer. +// +// The legacy global `dashboard` survives as the template new printers start +// from, so adding a machine doesn't drop someone into a bare Home screen. +// crabcore + +import type { DashboardSections } from './settingsMigration'; + +export type { DashboardSections }; + +function booleanValue(raw: unknown, fallback: boolean): boolean { + return typeof raw === 'boolean' ? raw : fallback; +} + +/** Fill in every section from `template`, so a new toggle defaults sanely. */ +export function normalizeDashboardSections( + raw: unknown, + template: DashboardSections +): DashboardSections { + const value = raw && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as Partial) + : {}; + + return { + progress: booleanValue(value.progress, template.progress), + actions: booleanValue(value.actions, template.actions), + estop: booleanValue(value.estop, template.estop), + homeDock: booleanValue(value.homeDock, template.homeDock), + controls: booleanValue(value.controls, template.controls), + pandaBreath: booleanValue(value.pandaBreath, template.pandaBreath), + temps: booleanValue(value.temps, template.temps), + camera: booleanValue(value.camera, template.camera), + gui: booleanValue(value.gui, template.gui), + filaments: booleanValue(value.filaments, template.filaments), + macros: booleanValue(value.macros, template.macros), + }; +} + +export function normalizeDashboardByPrinter( + raw: unknown, + template: DashboardSections +): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + + const out: Record = {}; + for (const [printerId, value] of Object.entries(raw)) { + if (!printerId || !value || typeof value !== 'object' || Array.isArray(value)) continue; + out[printerId] = normalizeDashboardSections(value, template); + } + return out; +} + +/** + * The active printer's layout. Printers that have never been customised fall + * back to the global set, which is what they were showing before the split. + */ +export function getDashboardSections(settings: { + activePrinterId: string; + dashboard: DashboardSections; + dashboardByPrinter?: Record; + printers?: { id: string; kind?: string }[]; +}): DashboardSections { + const saved = settings.dashboardByPrinter?.[settings.activePrinterId]; + const sections = normalizeDashboardSections(saved, settings.dashboard); + const activeKind = settings.printers?.find( + (printer) => printer.id === settings.activePrinterId + )?.kind; + return dashboardSectionAvailable(activeKind, 'gui') ? sections : { ...sections, gui: false }; +} + +/** Sections that the selected printer can genuinely provide. */ +export function dashboardSectionAvailable( + printerKind: string | undefined, + section: keyof DashboardSections +): boolean { + return section !== 'gui' || printerKind !== 'bambu-lan'; +} + +export function setDashboardForPrinter( + dashboardByPrinter: Record, + printerId: string, + next: DashboardSections, + template: DashboardSections +): Record { + return { + ...dashboardByPrinter, + [printerId || 'default']: normalizeDashboardSections(next, template), + }; +} diff --git a/services/filamentSlots.ts b/services/filamentSlots.ts index ef00ac6..ab9e591 100644 --- a/services/filamentSlots.ts +++ b/services/filamentSlots.ts @@ -35,6 +35,21 @@ export interface ManualSlotSettings { slotSubtypes: string[]; } +/** + * Whether a held printer report may still describe physical filament slots. + * + * Moonraker sensor state is unsafe once its socket is down. Bambu is different: + * its provider retains the last complete MQTT state while a short reconnect is + * in progress, and discarding that state makes the UI flash unrelated manual + * U1 slot settings until the next `pushall` reply arrives. + */ +export function canUseReportedFilamentSlots( + connection: string, + printerKind: string | null, +): boolean { + return connection === 'connected' || printerKind === 'bambu-lan'; +} + function printerText(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const clean = value.trim(); @@ -89,3 +104,57 @@ export function resolveFilamentSlots( }; }); } + +/** + * The four lanes as the AD5X's material station actually reports them. + * + * The `print_task_config` path above is PAXX firmware: a FlashForge AD5X does + * not publish that object at all, so every lane fell through to the saved + * manual settings and the slicer offered spools left over from another printer. + * The station's real contents arrive over FlashForge's REST API instead + * (hooks/useMaterialStation), which is where these units come from. + * + * Returns null when the station has not reported yet. AD5X callers must turn + * that into neutral unknown lanes rather than falling back to manual U1 data. + */ +export function materialStationSlots( + units: { lanes: { index: number; status: string; brand?: string; material?: string; colorHex?: string }[] }[], +): ResolvedFilamentSlot[] | null { + const lanes = units[0]?.lanes; + if (!lanes || lanes.length === 0) return null; + + return Array.from({ length: 4 }, (_, index) => { + const lane = lanes.find((entry) => entry.index === index); + const material = (lane?.material ?? '').trim(); + // A drying lane cannot feed a print right now, and the slot UI has no + // separate state for it — busy is the honest equivalent. + const status = !lane ? 'empty' : lane.status === 'drying' ? 'busy' : lane.status; + + return { + index, + color: normalizeFilamentSlotColors([lane?.colorHex ?? ''])[0], + brand: lane?.brand?.trim() || undefined, + material, + mainType: material, + subType: '', + status: (status === 'loaded' || status === 'empty' || status === 'busy' + ? status + : 'unknown') as FilamentSlotStatus, + // The station is the machine speaking, not a saved preference. + source: 'printer' as const, + }; + }); +} + +/** Honest AD5X placeholders while its credentialed IFS query is unavailable. */ +export function unavailableMaterialStationSlots(): ResolvedFilamentSlot[] { + return Array.from({ length: 4 }, (_, index) => ({ + index, + color: '#30343A', + material: '', + mainType: '', + subType: '', + status: 'unknown' as const, + source: 'printer' as const, + })); +} diff --git a/services/flashforgeApi.ts b/services/flashforgeApi.ts new file mode 100644 index 0000000..e734ab3 --- /dev/null +++ b/services/flashforgeApi.ts @@ -0,0 +1,217 @@ +// FlashForge's own REST API (port 8898), used only for what Moonraker cannot +// answer. On a Klipper-modded AD5X the material station (IFS) stays owned by +// the stock firmware: Klipper exposes a single head filament sensor and no +// per-slot state at all, so slot colours/materials have to come from here. +// +// Everything else Helix does — control, temps, files, camera, history — still +// goes over Moonraker. This is a supplement, not a second driver. +// +// Shapes verified against Parallel-7/FlashForgeEmulator in AD5X mode. +// crabcore + +import { normalizeBaseUrl } from './moonraker'; + +/** FlashForge's REST API always listens here, independent of Moonraker's 7125. */ +export const FLASHFORGE_API_PORT = 8898; + +const DETAIL_TIMEOUT_MS = 4000; + +/** Credentials the printer requires on every call. `checkCode` is shown on the + * printer as "Printer ID" under Settings → Network, and lives in its config as + * `lanCode`. Treat it as a secret: it grants full control of the machine. */ +export interface FlashForgeCredentials { + serialNumber: string; + checkCode: string; +} + +export interface MaterialSlot { + /** Zero-based, so it lines up with Helix's filament-slot arrays. The wire + * format numbers slots from 1. */ + index: number; + loaded: boolean; + /** + * Empty when unknown. Note the printer remembers the last filament for a + * slot, so an unloaded slot can still report a material and colour — check + * `loaded` before presenting these as what is actually in the machine. + */ + material: string; + /** `#rrggbb`, or empty when the printer has no colour for the slot. */ + colorHex: string; +} + +export interface MaterialStation { + slots: MaterialSlot[]; + /** Zero-based active slot, or null when nothing is selected. */ + activeSlot: number | null; + /** Zero-based slot currently being loaded, or null when idle. */ + loadingSlot: number | null; +} + +export type FlashForgeError = + | 'missing-credentials' + | 'unreachable' + | 'auth-failed' + | 'bad-response' + | 'no-material-station'; + +export type FlashForgeResult = + | { ok: true; value: T } + | { ok: false; error: FlashForgeError }; + +/** Swap a Moonraker URL onto the FlashForge API port, keeping the host. */ +export function flashforgeApiUrl(printerUrl: string): string { + const base = normalizeBaseUrl(printerUrl); + if (!base) return ''; + + try { + const url = new URL(base); + url.port = String(FLASHFORGE_API_PORT); + url.pathname = ''; + return url.toString().replace(/\/+$/, ''); + } catch { + return ''; + } +} + +export function hasFlashForgeCredentials( + credentials: Partial | undefined +): boolean { + return Boolean(credentials?.serialNumber?.trim() && credentials?.checkCode?.trim()); +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +/** + * Firmware writes "?" into a slot it knows nothing about — the same sentinel + * the stock config uses for `ffmType0`. The emulator leaves those blank, so + * only real hardware shows this; without it the UI renders a literal "?". + */ +export function normalizeSlotMaterial(value: unknown): string { + const raw = text(value); + return raw === '?' ? '' : raw; +} + +/** Normalize `#RGB`/`RRGGBB`/`#RRGGBB` to `#rrggbb`; anything else is dropped. */ +export function normalizeSlotColor(value: unknown): string { + const raw = text(value).replace(/^#/, ''); + if (/^[0-9a-f]{6}$/i.test(raw)) return `#${raw.toLowerCase()}`; + if (/^[0-9a-f]{3}$/i.test(raw)) { + return `#${raw.toLowerCase().split('').map((c) => c + c).join('')}`; + } + return ''; +} + +/** + * Convert the wire `matlStationInfo` into Helix's zero-based view. + * Exported for tests and so callers can parse a payload they already hold. + */ +export function parseMaterialStation(detail: unknown): MaterialStation | null { + if (!detail || typeof detail !== 'object') return null; + const record = detail as Record; + + // Printers without an IFS report hasMatlStation:false and omit the block. + if (record.hasMatlStation === false) return null; + + const info = record.matlStationInfo; + if (!info || typeof info !== 'object') return null; + const station = info as Record; + + const rawSlots = Array.isArray(station.slotInfos) ? station.slotInfos : []; + const slots: MaterialSlot[] = []; + + rawSlots.forEach((entry, position) => { + const slot = (entry && typeof entry === 'object' ? entry : {}) as Record; + // slotId is 1-based on the wire; fall back to position when absent. + const slotId = typeof slot.slotId === 'number' ? slot.slotId : position + 1; + const index = slotId - 1; + if (index < 0) return; + + slots.push({ + index, + loaded: slot.hasFilament === true, + material: normalizeSlotMaterial(slot.materialName), + colorHex: normalizeSlotColor(slot.materialColor), + }); + }); + + slots.sort((a, b) => a.index - b.index); + + // Both are 1-based, and 0 is the sentinel for "none". + const oneBased = (value: unknown): number | null => + typeof value === 'number' && value > 0 ? value - 1 : null; + + return { + slots, + activeSlot: oneBased(station.currentSlot), + loadingSlot: oneBased(station.currentLoadSlot), + }; +} + +async function postDetail( + apiUrl: string, + credentials: FlashForgeCredentials, + timeoutMs: number, + signal?: AbortSignal +): Promise>> { + const controller = new AbortController(); + const abort = () => controller.abort(); + const timer = setTimeout(abort, timeoutMs); + signal?.addEventListener('abort', abort, { once: true }); + + try { + const response = await fetch(`${apiUrl}/detail`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serialNumber: credentials.serialNumber.trim(), + checkCode: credentials.checkCode.trim(), + }), + signal: controller.signal, + }); + if (!response.ok) return { ok: false, error: 'unreachable' }; + + const payload = (await response.json()) as Record; + // The API answers 200 for auth failures and signals them in the envelope. + if (payload?.code !== 0) { + return { ok: false, error: payload?.code === 1 ? 'auth-failed' : 'bad-response' }; + } + + const detail = payload.detail; + if (!detail || typeof detail !== 'object') return { ok: false, error: 'bad-response' }; + return { ok: true, value: detail as Record }; + } catch { + return { ok: false, error: 'unreachable' }; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + } +} + +/** Fetch the material station for a printer, given its Moonraker URL. */ +export async function fetchMaterialStation( + printerUrl: string, + credentials: Partial | undefined, + signal?: AbortSignal, + timeoutMs: number = DETAIL_TIMEOUT_MS +): Promise> { + if (!hasFlashForgeCredentials(credentials)) { + return { ok: false, error: 'missing-credentials' }; + } + + const apiUrl = flashforgeApiUrl(printerUrl); + if (!apiUrl) return { ok: false, error: 'unreachable' }; + + const detail = await postDetail( + apiUrl, + credentials as FlashForgeCredentials, + timeoutMs, + signal + ); + if (!detail.ok) return detail; + + const station = parseMaterialStation(detail.value); + if (!station) return { ok: false, error: 'no-material-station' }; + return { ok: true, value: station }; +} diff --git a/services/i18n.ts b/services/i18n.ts index f32ad27..1d62051 100644 --- a/services/i18n.ts +++ b/services/i18n.ts @@ -365,6 +365,7 @@ const DICT: Record> = { 'No printers configured yet.': 'Brak skonfigurowanych drukarek.', 'Could not save this filament.': 'Nie udało się zapisać tego filamentu.', Toolhead: 'Głowica', + Slot: 'Gniazdo', Empty: 'Pusty', 'Changes are written to the printer when you save.': 'Zmiany są zapisywane na drukarce przy zapisie.', 'Saving…': 'Zapisywanie…', @@ -796,6 +797,7 @@ const DICT: Record> = { 'No printers configured yet.': 'Aún no hay impresoras configuradas.', 'Could not save this filament.': 'No se pudo guardar este filamento.', Toolhead: 'Cabezal', + Slot: 'Ranura', Empty: 'Vacío', 'Changes are written to the printer when you save.': 'Los cambios se escriben en la impresora al guardar.', 'Saving…': 'Guardando…', @@ -1227,6 +1229,7 @@ const DICT: Record> = { 'No printers configured yet.': 'Noch keine Drucker konfiguriert.', 'Could not save this filament.': 'Dieses Filament konnte nicht gespeichert werden.', Toolhead: 'Werkzeugkopf', + Slot: 'Steckplatz', Empty: 'Leer', 'Changes are written to the printer when you save.': 'Änderungen werden beim Speichern auf den Drucker geschrieben.', 'Saving…': 'Speichern…', @@ -1658,6 +1661,7 @@ const DICT: Record> = { 'No printers configured yet.': 'Aucune imprimante configurée pour le moment.', 'Could not save this filament.': "Impossible d'enregistrer ce filament.", Toolhead: "Tête d'outil", + Slot: 'Emplacement', Empty: 'Vide', 'Changes are written to the printer when you save.': "Les modifications sont écrites sur l'imprimante lors de l'enregistrement.", 'Saving…': 'Enregistrement…', @@ -2089,6 +2093,7 @@ const DICT: Record> = { 'No printers configured yet.': '尚未配置打印机。', 'Could not save this filament.': '无法保存此耗材。', Toolhead: '工具头', + Slot: '料槽', Empty: '空', 'Changes are written to the printer when you save.': '保存时更改会写入打印机。', 'Saving…': '保存中…', diff --git a/services/klipperPrompt.ts b/services/klipperPrompt.ts new file mode 100644 index 0000000..ad41d95 --- /dev/null +++ b/services/klipperPrompt.ts @@ -0,0 +1,150 @@ +// Klipper's `action:prompt_*` dialog protocol. +// +// A printer can ask the operator a question mid-command by emitting a run of +// G-code responses that a client is expected to render as a dialog and answer +// by running the chosen button's G-code. Mainsail and Fluidd implement this; +// Helix did not, which is why a FlashForge AD5X (zmod) would accept a print, +// open "Select print materials", and then sit there forever while Helix +// reported the job as sent. +// +// The wire format, one directive per G-code response line: +// +// // action:prompt_begin +// // action:prompt_text <paragraph> +// // action:prompt_button_group_start +// // action:prompt_button <label>|<gcode>|<style>|<colour> +// // action:prompt_button_group_end +// // action:prompt_footer_button <label>|<gcode>|<style> +// // action:prompt_show +// // action:prompt_end +// +// Only `prompt_show` makes a prompt visible: everything between `prompt_begin` +// and `prompt_show` is the printer composing it. `prompt_end` closes it, and is +// also what a client sends back to dismiss one. +// crabcore + +/** A dismiss command a client sends to close a prompt it is showing. */ +export const PROMPT_DISMISS_GCODE = 'RESPOND TYPE=command MSG=action:prompt_end'; + +export interface KlipperPromptButton { + label: string; + /** G-code to run when tapped. Empty for a malformed button, which is dropped. */ + gcode: string; + /** + * Klipper's style hint — primary/secondary/info/warning/error. Free-form in + * practice: zmod sends `primary`, and sometimes an empty field followed by a + * hex colour. + */ + style: string | null; + /** Trailing hex colour some firmwares append. Not part of stock Klipper. */ + color: string | null; + /** Footer buttons are the commit/cancel row rather than an inline choice. */ + footer: boolean; +} + +export interface KlipperPrompt { + title: string; + /** Paragraphs, in emission order. */ + text: string[]; + buttons: KlipperPromptButton[]; +} + +/** A prompt still being composed; only surfaced to the UI once shown. */ +export interface PromptBuildState { + prompt: KlipperPrompt; + visible: boolean; +} + +const PREFIX = /^\s*(?:\/\/)?\s*action:\s*/i; + +/** + * True when [line] is any prompt directive. Used to keep the parser off the + * hot path for ordinary console chatter. + */ +export function isPromptLine(line: string): boolean { + return PREFIX.test(line) && /^prompt_/i.test(line.replace(PREFIX, '')); +} + +function splitFields(rest: string): string[] { + return rest.split('|').map((field) => field.trim()); +} + +function parseButton(rest: string, footer: boolean): KlipperPromptButton | null { + const [label, gcode, style, color] = splitFields(rest); + // A button with nothing to run is not actionable — rendering it would give + // the operator a control that silently does nothing. + if (!label || !gcode) return null; + return { + label, + gcode, + style: style || null, + color: color || null, + footer, + }; +} + +const EMPTY: KlipperPrompt = { title: '', text: [], buttons: [] }; + +/** + * Folds one G-code response line into the prompt being assembled. + * + * Returns the next state, or null when there is no prompt (either none was + * open, or this line closed it). Non-prompt lines pass [state] straight back so + * callers can pipe every response through without pre-filtering. + */ +export function reducePromptLine( + state: PromptBuildState | null, + line: string +): PromptBuildState | null { + if (!PREFIX.test(line)) return state; + const body = line.replace(PREFIX, '').trim(); + const match = /^(prompt_[a-z_]+)\s*(.*)$/is.exec(body); + if (!match) return state; + + const directive = match[1].toLowerCase(); + const rest = match[2].trim(); + + switch (directive) { + case 'prompt_begin': + // Always starts a fresh prompt, discarding any half-built one — a printer + // that reboots mid-compose must not leave a stale dialog fused to the new. + return { prompt: { ...EMPTY, title: rest, text: [], buttons: [] }, visible: false }; + + case 'prompt_end': + return null; + + case 'prompt_show': + // A show with no begin is noise from a firmware talking to another client. + if (!state) return null; + return { ...state, visible: true }; + + case 'prompt_text': + if (!state) return null; + return { ...state, prompt: { ...state.prompt, text: [...state.prompt.text, rest] } }; + + case 'prompt_button': + case 'prompt_footer_button': { + if (!state) return null; + const button = parseButton(rest, directive === 'prompt_footer_button'); + if (!button) return state; + return { + ...state, + prompt: { ...state.prompt, buttons: [...state.prompt.buttons, button] }, + }; + } + + // Grouping only affects desktop layout; Helix stacks buttons in one column, + // so these are accepted and ignored rather than treated as unknown. + case 'prompt_button_group_start': + case 'prompt_button_group_end': + return state; + + default: + return state; + } +} + +/** The prompt to render, or null while none is open or still being composed. */ +export function visiblePrompt(state: PromptBuildState | null): KlipperPrompt | null { + return state?.visible ? state.prompt : null; +} diff --git a/services/moonraker.ts b/services/moonraker.ts index e336b33..4869d0d 100644 --- a/services/moonraker.ts +++ b/services/moonraker.ts @@ -23,6 +23,99 @@ export function normalizeMoonrakerUrl(input: string): string { } } +/** + * helixd, Fluidd and the webcam share the AD5X's port-80 origin. Moonraker + * remains on 7125, so callers must keep this proxy origin separate from their + * telemetry connection URL. + */ +export function helixdLanBaseUrl(input: string): string { + const base = normalizeBaseUrl(input); + if (!base) return ''; + + try { + const url = new URL(base); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''; + url.port = ''; + url.pathname = ''; + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/+$/, ''); + } catch { + return ''; + } +} + +export interface HelixdStatus { + backend_state?: unknown; + dns_name?: unknown; + serve_url?: unknown; +} + +/** Returns an HTTPS/HTTP origin only while helixd says Tailscale is running. */ +export function helixdRemoteBaseUrl(status: unknown): string { + if (!status || typeof status !== 'object') return ''; + const value = status as HelixdStatus; + if (value.backend_state !== 'Running' || typeof value.serve_url !== 'string') return ''; + + const base = normalizeBaseUrl(value.serve_url); + if (!base) return ''; + try { + const url = new URL(base); + if (url.protocol !== 'http:' && url.protocol !== 'https:' || !url.hostname) return ''; + url.pathname = ''; + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/+$/, ''); + } catch { + return ''; + } +} + +/** + * Tailscale Serve fronts port 80 only. Moonraker stays directly reachable on + * the same discovered tailnet node at 7125. + */ +export function helixdRemoteMoonrakerUrl(remoteBaseUrl: string): string { + const base = normalizeBaseUrl(remoteBaseUrl); + if (!base) return ''; + try { + const url = new URL(base); + if (!url.hostname || !isTailscaleUrl(url.toString())) return ''; + url.protocol = 'http:'; + url.port = '7125'; + url.pathname = ''; + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/+$/, ''); + } catch { + return ''; + } +} + +/** + * null means the LAN status endpoint could not be reached. An empty string is + * different: helixd answered, but its Tailscale backend is not usable. + */ +export async function discoverHelixdRemoteBase( + lanUrl: string, + timeoutMs = 4000, +): Promise<string | null> { + const base = helixdLanBaseUrl(lanUrl); + if (!base) return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${base}/api/status`, { signal: controller.signal }); + if (!response.ok) return null; + return helixdRemoteBaseUrl(await response.json()); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + export function isTailscaleUrl(input: string): boolean { const base = normalizeBaseUrl(input); if (!base) return false; @@ -85,18 +178,66 @@ export function wsUrl(baseUrl: string): string { return baseUrl.replace(/^http/i, 'ws') + '/websocket'; } -// camera setting can be a bare path like /webcam/webrtc, resolved against -// whichever printer host we're currently talking to. this is the whole trick -// that makes the camera follow you between LAN and tailscale without editing -// settings. heads up: camera is on port 80, moonraker is 7125. +/** + * U1/PAXX per-print AI monitoring preference. + * + * Enabling explicitly arms both user-facing detectors so an earlier manual + * firmware setting cannot leave the master switch on while spaghetti or + * build-plate obstruction detection remains off. The residue and nozzle + * experiments are deliberately untouched. + */ +export type AiDetectionSensitivity = 'low' | 'high'; + +export function buildAiMonitoringCommand( + enabled: boolean, + sensitivity: AiDetectionSensitivity = 'low' +): string { + return enabled + ? `DEFECT_DETECTION_CONFIG MAIN_ENABLE=1 CLEAN_BED_ENABLE=1 NOODLE_ENABLE=1 SENSITIVITY=${sensitivity}` + : 'DEFECT_DETECTION_CONFIG MAIN_ENABLE=0'; +} + +function printerProxyOrigin(activeBaseUrl: string): string { + const base = normalizeBaseUrl(activeBaseUrl); + if (!base) return ''; + try { + const url = new URL(base); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''; + // Legacy U1 LAN connections still point directly at Moonraker :7125 while + // their nginx camera proxy lives on port 80. Tailscale Serve is HTTPS and + // must retain its own origin; rewriting it to HTTP silently breaks remote + // camera and screen access. + if (url.protocol === 'http:') url.port = ''; + url.pathname = ''; + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/+$/, ''); + } catch { + return ''; + } +} + +// Camera paths are advertised relative to the same proxy origin used by +// Fluidd and helixd. This makes one active base work for LAN and Tailscale. export function resolveCameraUrl(cameraUrl: string, activeBaseUrl: string): string { const cam = (cameraUrl || '').trim(); if (!cam) return ''; if (/^https?:\/\//i.test(cam)) return cam; - const base = normalizeBaseUrl(activeBaseUrl); - if (!base) return ''; - const host = base.replace(/^https?:\/\//i, '').replace(/:\d+$/, '').replace(/\/.*$/, ''); - return `http://${host}${cam.startsWith('/') ? cam : '/' + cam}`; + const origin = printerProxyOrigin(activeBaseUrl); + if (!origin) return ''; + try { + return new URL(cam.startsWith('/') ? cam : `/${cam}`, `${origin}/`).toString(); + } catch { + return ''; + } +} + +// helixd (the AD5X screen/touch daemon) shares the printer host with Moonraker +// but listens on port 80 — Moonraker itself is on :7125. Derive the HTTP root +// by stripping Moonraker's port and path, so the screen endpoint follows the +// active LAN/Tailscale selection exactly like the camera does. +export function resolveScreenApiUrl(activeBaseUrl: string): string { + return printerProxyOrigin(activeBaseUrl); } // The printer's own touchscreen, exposed as a webcam entry. Its WebView must @@ -176,6 +317,9 @@ export interface WebcamInfo { service: string; stream_url: string; snapshot_url: string; + target_fps?: number; + target_fps_idle?: number; + aspect_ratio?: string; } export interface HistoryJob { @@ -428,10 +572,13 @@ export const api = { queryObjects: <TStatus = Record<string, unknown>>( base: string, - objects: string[] + objects: string[], + timeoutMs?: number ) => request<MoonrakerQueryResponse<TStatus>>( base, - `/printer/objects/query?${objects.map((o) => encodeURIComponent(o)).join('&')}` + `/printer/objects/query?${objects.map((o) => encodeURIComponent(o)).join('&')}`, + {}, + timeoutMs ), }; diff --git a/services/nativeSlicer.ts b/services/nativeSlicer.ts index 7588a6d..5ccd6e7 100644 --- a/services/nativeSlicer.ts +++ b/services/nativeSlicer.ts @@ -1,5 +1,7 @@ import { DeviceEventEmitter, NativeModules, Platform } from 'react-native'; import type { NativeMaterialProfile } from './filamentProfiles'; +import type { AiDetectionSensitivity } from './moonraker'; +import type { MachineProfile } from './printerProfiles'; export type NativeSlicerStatus = { platform: string; @@ -67,6 +69,10 @@ export type SliceOptions = { // Per-colour remap: forces configureMultiTool to size the extruder array to // cover every mapped slot, even when the file declares fewer filaments. forceExtruderCount?: number; + // Target machine (bed size + Orca printer profile), JSON-encoded. Without it + // a background re-slice falls back to the U1 and can emit another printer's + // start G-code — see MachineProfile.fromJson. + machineProfile?: string; }; export type NativeSliceResult = { @@ -122,6 +128,7 @@ type HelixSlicerModule = { getSharedLink: () => Promise<SharedMakerWorldLink>; getSharedModelFile: () => Promise<SharedModelFile | null>; takePrintSentNotice: () => Promise<string | null>; + takeBambuSendRequest: () => Promise<boolean>; pickModelFile: () => Promise<SharedModelFile>; getModelPlates: (path: string) => Promise<ModelPlate[]>; getModelPlateStats: (path: string) => Promise<ModelPlateStats[]>; @@ -151,6 +158,7 @@ type HelixSlicerModule = { loadedToolMask: number, autoArrange: boolean, materialProfilesJson: string | null, + bedProfileJson: string | null, ) => Promise<boolean>; openGcodePreview: ( path: string, @@ -159,11 +167,13 @@ type HelixSlicerModule = { moonrakerUrl: string | null, initialTool: number, loadedToolMask: number, - usedToolMask: number + usedToolMask: number, + bedProfileJson: string | null, ) => Promise<boolean>; setFilamentSlotColors: (colors: string[]) => Promise<boolean>; setFilamentSlots?: (slots: NativeFilamentSlot[]) => Promise<boolean>; setPrinters: (printers: { name: string; url: string; tailscaleUrl?: string }[]) => Promise<boolean>; + setAiDetectionSensitivity?: (value: AiDetectionSensitivity) => Promise<boolean>; getLastSliceResult: () => Promise<NativeSliceResult | null>; getGcodeThumbnail: (path: string) => Promise<string | null>; getGcodeFilamentGrams: (path: string) => Promise<number[]>; @@ -243,6 +253,12 @@ export async function takeNativePrintSentNotice(): Promise<string | null> { return nativeModule.takePrintSentNotice(); } +/** One-shot native-preview handoff into the real Bambu FTPS/MQTT send flow. */ +export async function takeBambuSendRequest(): Promise<boolean> { + if (Platform.OS !== 'android' || !nativeModule?.takeBambuSendRequest) return false; + return nativeModule.takeBambuSendRequest(); +} + /** Opens the system file picker for .3mf / .stl and imports into app storage. */ export async function pickModelFile(): Promise<SharedModelFile> { if (Platform.OS !== 'android' || !nativeModule) { @@ -431,6 +447,7 @@ export async function openNativeModelPreview( loadedToolMask = -1, autoArrange = false, materialProfiles?: NativeMaterialProfile[], + machine?: MachineProfile | null, ): Promise<void> { if (Platform.OS !== 'android' || !nativeModule) { throw new Error('Native 3D preview is Android-only in this lab build.'); @@ -446,6 +463,7 @@ export async function openNativeModelPreview( loadedToolMask, autoArrange, materialProfiles ? JSON.stringify(materialProfiles) : null, + machine ? JSON.stringify(machine) : null, ); } @@ -484,6 +502,18 @@ export async function setNativePrinters(printers: { name: string; url: string; t } } +/** Keeps Android's native preview/print path aligned with the saved RN setting. */ +export async function setNativeAiDetectionSensitivity( + value: AiDetectionSensitivity +): Promise<void> { + if (Platform.OS !== 'android' || !nativeModule) return; + try { + await nativeModule.setAiDetectionSensitivity?.(value); + } catch { + // Older installed builds have no bridge yet; the native default remains Low. + } +} + export async function getLastSliceResult(): Promise<NativeSliceResult | null> { if (Platform.OS !== 'android' || !nativeModule) return null; const raw = await nativeModule.getLastSliceResult(); @@ -537,7 +567,8 @@ export async function openNativeGcodePreview( moonrakerUrl?: string | null, initialTool = 0, loadedToolMask = -1, - usedToolMask = -1 + usedToolMask = -1, + machine?: MachineProfile | null, ): Promise<void> { if (Platform.OS !== 'android' || !nativeModule) { throw new Error('Native G-code preview is Android-only in this lab build.'); @@ -550,6 +581,7 @@ export async function openNativeGcodePreview( initialTool, loadedToolMask, usedToolMask, + machine ? JSON.stringify(machine) : null, ); } diff --git a/services/printEta.ts b/services/printEta.ts index bf9006d..06ec682 100644 --- a/services/printEta.ts +++ b/services/printEta.ts @@ -18,7 +18,7 @@ export interface CapturedM73Estimate extends M73Estimate { export interface PrintEtaResult { slicerRemainingSeconds: number | null; liveRemainingSeconds: number | null; - source: 'm73' | 'fallback' | 'none'; + source: 'printer' | 'm73' | 'fallback' | 'slicer' | 'none'; } function finiteNonNegative(value: unknown): number | null { @@ -82,10 +82,25 @@ export function calculatePrintEtas(input: { slicerTotalSeconds: number | null; m73: CapturedM73Estimate | null; fallbackProgress: number; + /** Printer-calculated countdown, when the transport reports one directly. */ + printerRemainingSeconds?: number | null; }): PrintEtaResult { const printDuration = finiteNonNegative(input.printDuration) ?? 0; const slicerTotal = finiteNonNegative(input.slicerTotalSeconds); const fallbackProgress = clamp(finiteNonNegative(input.fallbackProgress) ?? 0, 0, 1); + const printerRemaining = finiteNonNegative(input.printerRemainingSeconds); + + // Bambu calculates this onboard and publishes it continuously. It remains + // useful during heating and the first layers, where progress extrapolation + // is intentionally suppressed as too unstable. + if (printerRemaining != null) { + return { + slicerRemainingSeconds: printerRemaining, + liveRemainingSeconds: printerRemaining, + source: 'printer', + }; + } + const capturedRemaining = finiteNonNegative(input.m73?.remainingSeconds); const captureDuration = finiteNonNegative(input.m73?.printDurationAtCapture) ?? printDuration; const elapsedSinceCapture = Math.max(0, printDuration - captureDuration); @@ -136,11 +151,39 @@ export function calculatePrintEtas(input: { return { slicerRemainingSeconds: slicerRemaining, - liveRemainingSeconds: null, - source: m73Remaining != null ? 'm73' : 'none', + // Before enough real progress exists to calculate printer pace, the + // slicer's countdown is still much more useful than displaying "--". + liveRemainingSeconds: slicerRemaining, + source: m73Remaining != null ? 'm73' : slicerRemaining != null ? 'slicer' : 'none', }; } +/** Local midnight starting the day [date] falls in. */ +function startOfDay(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +} + +/** + * Clock time a print finishes, carrying the day when that is not today. + * + * A multi-day print showing a bare "5:00 PM" is worse than useless — it reads + * as five hours away when it may be five days. Rounding the midnight-to-midnight + * gap rather than dividing elapsed milliseconds keeps the day count right across + * daylight-saving changes, where a calendar day is 23 or 25 hours long. + */ +export function finishClock(seconds: number): string { + const done = new Date(Date.now() + Math.max(0, seconds) * 1000); + const time = done.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + const days = Math.round((startOfDay(done) - startOfDay(new Date())) / 86_400_000); + + if (days <= 0) return time; + if (days === 1) return `${time} tomorrow`; + // Within the week a weekday name reads faster than counting days forward; + // past that it stops being unambiguous and a date is clearer. + if (days < 7) return `${time} ${done.toLocaleDateString(undefined, { weekday: 'short' })}`; + return `${time} ${done.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`; +} + export function smoothRemainingEstimate( previous: number | null, next: number | null, diff --git a/services/printIntent.ts b/services/printIntent.ts new file mode 100644 index 0000000..fae6f66 --- /dev/null +++ b/services/printIntent.ts @@ -0,0 +1,67 @@ +// What Helix just sent to print, held until the printer asks about it. +// +// A zmod printer answers a print start with a material-selection prompt, which +// arrives over the websocket well after the HTTP call that started the job and +// is handled app-wide rather than by the screen that sent it. This carries the +// slot mapping across that gap so the prompt can be answered with the user's +// choice instead of the printer's guess. +// +// Deliberately a module-level value rather than context: the prompt can land +// after the sending screen has unmounted (Helix navigates Home on send). +// crabcore + +export type PrintIntent = { + /** Name the file was uploaded as. Matched against the prompt by basename. */ + filename: string; + /** 0-based G-code tool → 0-based Helix slot. */ + toolToSlot: Readonly<Record<number, number>>; + /** + * Bed levelling, for machines that take it on the print macro rather than a + * preferences command. Undefined leaves the printer's own value alone. + */ + autoLevel?: boolean; + /** When it was staged, so a stale intent cannot answer a later prompt. */ + stagedAt: number; +}; + +/** + * How long a staged intent stays answerable. A prompt normally follows within + * a second or two; anything much later is a different job — most likely one + * started from the printer's own screen, which the operator should answer. + */ +export const PRINT_INTENT_TTL_MS = 120_000; + +let pending: PrintIntent | null = null; +const listeners = new Set<() => void>(); + +/** + * Notified whenever an intent is staged. + * + * The prompt and the intent race: a print sent from the native preview screen + * stages its intent only once Android hands control back to the RN app, which + * can be after the printer has already asked. Without this the prompt would sit + * on screen with an answer sitting right beside it. + */ +export function subscribePrintIntent(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function setPrintIntent(intent: Omit<PrintIntent, 'stagedAt'>): void { + pending = { ...intent, stagedAt: Date.now() }; + for (const listener of [...listeners]) listener(); +} + +/** The staged intent, or null when absent or expired. Does not consume it. */ +export function peekPrintIntent(now = Date.now()): PrintIntent | null { + if (!pending) return null; + if (now - pending.stagedAt > PRINT_INTENT_TTL_MS) { + pending = null; + return null; + } + return pending; +} + +export function clearPrintIntent(): void { + pending = null; +} diff --git a/services/printPreprocess.ts b/services/printPreprocess.ts index 47fd9a5..662b07c 100644 --- a/services/printPreprocess.ts +++ b/services/printPreprocess.ts @@ -6,8 +6,32 @@ // Blocking is reserved for genuinely unsatisfiable jobs — fewer usable lanes // than the file needs. import type { IconName } from '../components/dashboard/shared'; +import { normalizePrinterKind, PRINTER_PROFILES, type PrintPrefKey } from './printerProfiles'; -export type PrintPref = 'flowCal' | 'timelapse' | 'autoLevel'; +/** One definition, shared with the machine profiles that decide who gets what. */ +export type PrintPref = PrintPrefKey; + +/** + * How a machine names its material feeds in the Ticket dialog. + * 'tool' — U1/generic: tools are "T0"–"T3", the physical feeds are "lanes". + * 'lane' — AD5X/Bambu: the feeds themselves are the unit, shown as "Lane 1"–"Lane 4". + */ +export type LaneNaming = 'tool' | 'lane'; + +/** The noun for a physical feed: "lane" on the U1, "Lane" where the machine names them. */ +export function laneWord(naming: LaneNaming): string { + return naming === 'lane' ? 'Lane' : 'lane'; +} + +/** Chip label: "T2" on the U1, the bare number "3" where the feeds are named lanes. */ +export function toolChipLabel(fileTool: number, naming: LaneNaming): string { + return naming === 'lane' ? `${fileTool + 1}` : `T${fileTool}`; +} + +/** Full reference: "T0" on the U1, "Lane 1" where the feeds are named lanes. */ +export function toolLabel(fileTool: number, naming: LaneNaming): string { + return naming === 'lane' ? `Lane ${fileTool + 1}` : `T${fileTool}`; +} export type PreprocessLaneStatus = 'loaded' | 'empty' | 'busy' | 'unknown'; @@ -125,8 +149,13 @@ export function buildPreprocessChecks(input: { printerName: string; tools: PreprocessTool[]; lanes: PreprocessLane[]; + naming?: LaneNaming; }): PreprocessCheck[] { const { connected, printerBusy, printerName, tools } = input; + const naming = input.naming ?? 'tool'; + const word = laneWord(naming); + const routeText = (tool: PreprocessTool) => + `${toolLabel(tool.fileTool, naming)} to ${word} ${tool.assigned + 1}`; const starved = tools.filter((tool) => tool.lane.status === 'empty'); const loadedCount = input.lanes.filter((lane) => lane.status === 'loaded').length; const rerouted = tools.filter((tool) => tool.source === 'auto'); @@ -137,7 +166,7 @@ export function buildPreprocessChecks(input: { key: 'filament', label: 'Filament loaded', detail: `This file needs ${tools.length} materials and only ${loadedCount} ${ - loadedCount === 1 ? 'lane has' : 'lanes have' + loadedCount === 1 ? `${word} has` : `${word}s have` } filament`, tone: 'fail', icon: 'palette-swatch', @@ -149,9 +178,9 @@ export function buildPreprocessChecks(input: { key: 'filament', label: 'Filament routed', detail: rerouted - .map((tool) => `T${tool.fileTool} to lane ${tool.assigned + 1}`) + .map(routeText) .join(', ') - .concat(' — your own lanes were empty'), + .concat(` — your own ${word}s were empty`), tone: 'pass', icon: 'palette-swatch', blocking: true, @@ -160,7 +189,7 @@ export function buildPreprocessChecks(input: { : { key: 'filament', label: 'Filament loaded', - detail: `${tools.length} of ${input.lanes.length} lanes feed this print`, + detail: `${tools.length} of ${input.lanes.length} ${word}s feed this print`, tone: 'pass', icon: 'palette-swatch', blocking: true, @@ -190,15 +219,6 @@ export function buildPreprocessChecks(input: { ]; } -/** Clock time the print would finish — "4:12 PM" beats doing the arithmetic. */ -export function finishClock(seconds: number): string { - const done = new Date(Date.now() + Math.max(0, seconds) * 1000); - const time = done.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); - const days = Math.floor((done.getTime() - new Date().setHours(0, 0, 0, 0)) / 86_400_000); - if (days <= 0) return time; - return days === 1 ? `${time} tomorrow` : `${time} +${days}d`; -} - export function laneLabel(lane: PreprocessLane): string { if (lane.status === 'empty') return 'Empty'; return lane.mainType || lane.material || 'PLA'; @@ -233,4 +253,49 @@ export const PREF_COPY: { hint: 'Capture a frame every layer', icon: 'video-outline', }, + { + key: 'ifs', + label: 'Enable IFS', + hint: 'Feed colors from the material station', + icon: 'swap-horizontal', + }, ]; + +/** + * Which toggles the Ticket dialog offers for a given machine. + * + * The AD5X gets bed levelling and its material station, and nothing else: + * flow calibration is a PAXX macro it does not have, and it ships no + * TIMELAPSE_* macros either — offering a time-lapse there produces G-code the + * printer aborts on with "Unknown command". + * + * Levelling reaches the two machines by completely different routes: the U1 + * takes it in SET_PRINT_PREFERENCES, while the AD5X takes it as LEVELING= on + * the PRINT_ZCOLOR macro that answers its material prompt. + */ +export function prefCopyFor(input: { + printerKind?: string | null; + multicolor: boolean; +}): typeof PREF_COPY { + const kind = normalizePrinterKind(input.printerKind); + const offered = new Set(PRINTER_PROFILES[kind].printPrefs); + return PREF_COPY.filter(({ key }) => offered.has(key)).map((pref) => + kind === 'bambu-lan' && pref.key === 'autoLevel' + ? { + ...pref, + label: 'Bed & vibration calibration', + hint: 'Probe the bed and tune vibration before the first layer', + } + : pref, + ); +} + +export function applicablePrefs( + prefs: Readonly<Record<PrintPref, boolean>>, + input: { printerKind?: string | null; multicolor: boolean }, +): Record<PrintPref, boolean> { + const offered = new Set(prefCopyFor(input).map(({ key }) => key)); + const out = {} as Record<PrintPref, boolean>; + for (const { key } of PREF_COPY) out[key] = offered.has(key) ? prefs[key] === true : false; + return out; +} diff --git a/services/printerDiscovery.ts b/services/printerDiscovery.ts new file mode 100644 index 0000000..5014ee8 --- /dev/null +++ b/services/printerDiscovery.ts @@ -0,0 +1,299 @@ +import * as Network from 'expo-network'; + +import { isGuiWebcam, type WebcamInfo } from './moonraker'; +import { + detectPrinterKind, + printerProfile, + type PrinterKind, +} from './printerProfiles'; + +const FIRST_HOST = 1; +const LAST_HOST = 254; +const SCAN_CONCURRENCY = 24; +const SCAN_PROGRESS_STEP = 8; +const PROBE_TIMEOUT_MS = 1100; +const DETAILS_TIMEOUT_MS = 1800; +// Printers on weak Wi-Fi (the FlashForge AD5X in particular) drop off for +// seconds at a time, so a single 1.1s shot per host misses them far more often +// than it finds them. A second pass costs nothing on the empty addresses, +// which fail fast with connection-refused rather than a timeout. +const PROBE_ATTEMPTS = 2; + +export interface DiscoveredPrinter { + ip: string; + moonrakerUrl: string; + name: string; + serial: string | null; + machineType: string | null; + kind: PrinterKind; + cameraUrl: string; + cameraName: string | null; +} + +export interface PrinterDiscoveryProgress { + subnet: string; + scanned: number; + total: number; + found: number; +} + +interface ProductInfo { + device_name?: unknown; + machine_type?: unknown; + serial_number?: unknown; +} + +interface SystemInfoResult { + system_info?: { + product_info?: ProductInfo; + }; +} + +interface WebcamListResult { + webcams?: WebcamInfo[]; +} + +interface PrinterInfoResult { + hostname?: unknown; +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isPrivateLanAddress(ip: string): boolean { + const octets = ip.split('.').map(Number); + if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + + return ( + octets[0] === 10 || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] === 192 && octets[1] === 168) + ); +} + +export function parseDiscoverySubnet(input: string): { prefix: string; cidr: string } { + const trimmed = input.trim(); + const withoutScheme = trimmed.replace(/^https?:\/\//i, ''); + const host = withoutScheme.split('/')[0].replace(/:\d+$/, ''); + const withoutCidr = host.replace(/\/24$/, ''); + const parts = withoutCidr.split('.'); + + if (parts.length === 3) parts.push('0'); + if ( + parts.length !== 4 || + parts.some((part) => !/^\d{1,3}$/.test(part) || Number(part) < 0 || Number(part) > 255) + ) { + throw new Error('Enter a /24 subnet such as 192.168.1.0/24.'); + } + + const ip = parts.map(Number).join('.'); + if (!isPrivateLanAddress(ip)) { + throw new Error('Discovery only scans private LAN addresses (10.x, 172.16–31.x, or 192.168.x).'); + } + + const prefix = parts.slice(0, 3).map(Number).join('.'); + return { prefix, cidr: `${prefix}.0/24` }; +} + +function subnetFromUrl(url: string): string | null { + try { + const hostname = new URL(url).hostname; + if (!isPrivateLanAddress(hostname)) return null; + return parseDiscoverySubnet(hostname).cidr; + } catch { + return null; + } +} + +export async function getSuggestedDiscoverySubnet(printerUrls: string[] = []): Promise<string> { + try { + const ip = await Network.getIpAddressAsync(); + if (isPrivateLanAddress(ip)) return parseDiscoverySubnet(ip).cidr; + } catch { + // A saved LAN printer still gives us a useful fallback when Android cannot + // expose the active Wi-Fi address (or a VPN is the main interface). + } + + for (const url of printerUrls) { + const subnet = subnetFromUrl(url); + if (subnet) return subnet; + } + + throw new Error('Could not determine this Wi-Fi subnet. Enter it manually, for example 192.168.1.0/24.'); +} + +async function fetchJson<T>(url: string, timeoutMs: number, signal?: AbortSignal): Promise<T | null> { + const controller = new AbortController(); + const abort = () => controller.abort(); + const timer = setTimeout(abort, timeoutMs); + signal?.addEventListener('abort', abort, { once: true }); + + try { + if (signal?.aborted) return null; + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + return null; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + } +} + +// Stored camera URLs are host-relative so the stream follows the printer +// between LAN and Tailscale. resolveCameraUrl rebuilds them on port 80, so a +// stream served from any other port (the FlashForge mod runs ustreamer on +// 8080) has to keep its absolute form. +function cameraPath(url: string, printerIp: string): string { + const trimmed = url.trim(); + if (!trimmed || !/^https?:\/\//i.test(trimmed)) return trimmed; + + try { + const parsed = new URL(trimmed); + const servedOnPort80 = parsed.port === '' || parsed.port === '80'; + if (parsed.hostname === printerIp && servedOnPort80) { + return `${parsed.pathname}${parsed.search}`; + } + } catch { + return trimmed; + } + + return trimmed; +} + +function choosePrintCamera(webcams: WebcamInfo[], printerIp: string): { + url: string; + name: string | null; +} { + const candidates = webcams.filter((webcam) => { + const streamUrl = stringValue(webcam.stream_url); + return webcam.enabled !== false && streamUrl && !isGuiWebcam({ name: webcam.name || '', stream_url: streamUrl }); + }); + const camera = + candidates.find((webcam) => /camera|webcam|print/i.test(webcam.name || '')) ?? candidates[0]; + if (!camera) return { url: '', name: null }; + + const streamUrl = stringValue(camera.stream_url) || stringValue(camera.snapshot_url); + return { + url: cameraPath(streamUrl, printerIp), + name: stringValue(camera.name) || 'Print camera', + }; +} + +async function probePrinter(ip: string, signal?: AbortSignal): Promise<DiscoveredPrinter | null> { + // Anything answering machine/system_info is a Moonraker host, which is all + // Helix needs to drive it. Identity only decides which profile it gets, so an + // unrecognised Klipper machine is still listed rather than silently dropped. + const candidates = [`http://${ip}:7125`, `http://${ip}`]; + + let match: { baseUrl: string; payload: { result?: SystemInfoResult } | null } | undefined; + for (let attempt = 0; attempt < PROBE_ATTEMPTS && !match; attempt += 1) { + if (signal?.aborted) return null; + + const probes = await Promise.all( + candidates.map(async (baseUrl) => ({ + baseUrl, + payload: await fetchJson<{ result?: SystemInfoResult }>( + `${baseUrl}/machine/system_info`, + PROBE_TIMEOUT_MS, + signal + ), + })) + ); + match = probes.find(({ payload }) => payload?.result?.system_info != null); + } + if (!match) return null; + + const product = match.payload?.result?.system_info?.product_info ?? {}; + const moonrakerUrl = match.baseUrl; + + // product_info is a PAXX extension. Everything else identifies itself through + // printer/info's hostname, so both are needed before choosing a profile. + const [webcamPayload, infoPayload] = await Promise.all([ + fetchJson<{ result?: WebcamListResult }>( + `${moonrakerUrl}/server/webcams/list`, + DETAILS_TIMEOUT_MS, + signal + ), + fetchJson<{ result?: PrinterInfoResult }>( + `${moonrakerUrl}/printer/info`, + DETAILS_TIMEOUT_MS, + signal + ), + ]); + + const hostname = stringValue(infoPayload?.result?.hostname); + const kind = detectPrinterKind({ + machineType: stringValue(product.machine_type), + serial: stringValue(product.serial_number), + deviceName: stringValue(product.device_name), + hostname, + }); + const profile = printerProfile(kind); + const camera = choosePrintCamera(webcamPayload?.result?.webcams ?? [], ip); + + return { + ip, + moonrakerUrl, + name: stringValue(product.device_name) || `${profile.label} (${ip})`, + serial: stringValue(product.serial_number) || null, + machineType: stringValue(product.machine_type) || profile.label, + kind, + cameraUrl: camera.url || profile.defaultCameraPath, + cameraName: camera.name, + }; +} + +export async function discoverPrinters( + subnet: string, + onProgress?: (progress: PrinterDiscoveryProgress) => void, + signal?: AbortSignal +): Promise<DiscoveredPrinter[]> { + const { prefix, cidr } = parseDiscoverySubnet(subnet); + const hosts = Array.from( + { length: LAST_HOST - FIRST_HOST + 1 }, + (_, index) => `${prefix}.${index + FIRST_HOST}` + ); + const results: DiscoveredPrinter[] = []; + let cursor = 0; + let scanned = 0; + let lastReportedScanned = -SCAN_PROGRESS_STEP; + let lastReportedFound = 0; + + const report = (force = false) => { + const found = results.length; + if ( + !force && + found === lastReportedFound && + scanned - lastReportedScanned < SCAN_PROGRESS_STEP + ) { + return; + } + + lastReportedScanned = scanned; + lastReportedFound = found; + onProgress?.({ subnet: cidr, scanned, total: hosts.length, found }); + }; + report(true); + + const worker = async () => { + while (!signal?.aborted) { + const index = cursor++; + if (index >= hosts.length) return; + + const printer = await probePrinter(hosts[index], signal); + const foundBeforeProbe = results.length; + if (printer && !results.some((result) => result.ip === printer.ip)) results.push(printer); + scanned += 1; + report(results.length !== foundBeforeProbe); + } + }; + + await Promise.all(Array.from({ length: SCAN_CONCURRENCY }, () => worker())); + if (!signal?.aborted) report(true); + return results.sort((a, b) => a.ip.localeCompare(b.ip, undefined, { numeric: true })); +} diff --git a/services/printerProfiles.ts b/services/printerProfiles.ts new file mode 100644 index 0000000..b64c21e --- /dev/null +++ b/services/printerProfiles.ts @@ -0,0 +1,342 @@ +// Printer identity. Helix speaks Moonraker to every printer it supports, so a +// "profile" is not a transport — it is the set of per-machine assumptions the +// UI used to hardcode for the Snapmaker U1 (toolhead count, camera location, +// material-station shape, branding). +// +// Adding a machine that already runs Klipper + Moonraker means adding a row to +// PRINTER_PROFILES and a clause to detectPrinterKind. Machines that do NOT run +// Moonraker (stock FlashForge, which speaks its own protocol on :8899) would +// need a transport behind hooks/useMoonraker.tsx — deliberately not modelled +// here yet, but `kind` is the seam that layer will hang off. +// crabcore + +/** + * Print-preparation toggles a machine can honour. Defined here, with the rest + * of the per-machine facts, so the RN dialog and the native preprocess sheet + * cannot drift — they had separate hardcoded lists, and the native one kept + * offering an AD5X a time-lapse it has no macros for. + */ +export type PrintPrefKey = 'flowCal' | 'timelapse' | 'autoLevel' | 'ifs'; + +export type PrinterKind = + | 'snapmaker-u1' + | 'flashforge-ad5x' + | 'generic-klipper' + | 'bambu-lan'; + +/** + * Build volume plus the plate mesh drawn under the model in the 3D view. + * + * Sizes are Orca's `printable_area` / `printable_height` for the machine — the + * same numbers the engine slices against, so the preview and the slice agree. + */ +export interface BedProfile { + /** Printable area in mm. */ + sizeX: number; + sizeY: number; + /** Max Z in mm. */ + height: number; + /** + * Binary STL under the `bed/` asset dir, or null to draw a plain rectangle. + * + * These follow Orca's convention: the mesh is authored about the centre of + * the printable area, so the renderer shifts it by (sizeX/2, sizeY/2). The + * meshes are deliberately NOT symmetric — the extra material hanging off the + * front is the plate's grab handle, and it is meant to overhang. + */ + modelAsset: string | null; + /** + * Vendor wordmark watermarked on the plate in the 3D view. Null leaves it + * bare, which is the right answer for any machine whose mark we do not have + * — stamping "snapmaker" on a FlashForge plate is worse than stamping nothing. + */ + logoText: string | null; +} + +export interface PrinterProfile { + kind: PrinterKind; + /** Human label used when the printer does not name itself. */ + label: string; + /** + * Build volume + plate mesh. This is the bed for the whole kind; Bambu beds + * vary per model, so `bambu-lan` carries the common P1/X1 bed as its fallback + * and the per-model lookup keys off the serial. + */ + bed: BedProfile; + /** + * Orca printer profile bundled under `assets/orca_profiles/printer/`, read for + * this machine's start/end G-code. Null when we have no verified profile — the + * engine then emits its own bare defaults, which is safer than handing a + * machine another printer's homing and priming moves. + */ + sliceProfileAsset: string | null; + /** Preparation toggles this machine can actually act on. */ + printPrefs: PrintPrefKey[]; + /** + * The machine exposes PAXX's `print_task_config` object and its + * SET_PRINT_PREFERENCES / SET_PRINT_USED_EXTRUDERS macros (auto bed level, + * timelapse, flow calibration across four extruders). + * + * Snapmaker U1 firmware only. Stock Klipper and the FlashForge mod have + * neither the macros nor the object, so sending the preferences errors and + * the read-back verification can never pass — which is why printing to a + * non-U1 machine failed with "Printer rejected the selected print + * preferences" before this was gated. + */ + supportsPrintPreferences: boolean; + /** Independently heated toolheads. The U1 has 4; most machines have 1. */ + toolheads: number; + /** Lanes in the attached material station, 0 when there is none. */ + materialSlots: number; + /** + * Stream to fall back on when Moonraker reports no webcam. Host-relative, so + * the camera follows the printer between LAN and Tailscale — see + * resolveCameraUrl. Empty when the machine has no known default. + */ + defaultCameraPath: string; +} + +export const PRINTER_PROFILES: Record<PrinterKind, PrinterProfile> = { + 'snapmaker-u1': { + kind: 'snapmaker-u1', + label: 'Snapmaker U1', + // Orca's printable_area for the U1 is nominally 271x272 with a half-mm + // origin offset; the renderer and CopyArrangeCalculator have always used a + // flat 270x270 and the arrangement maths is tuned to it. Kept as-is so + // making the bed configurable does not quietly move every U1 print. + bed: { sizeX: 270, sizeY: 270, height: 270, modelAsset: 'u1_bed.stl', logoText: 'snapmaker' }, + sliceProfileAsset: 'snapmaker_u1.json', + printPrefs: ['autoLevel', 'flowCal', 'timelapse'], + supportsPrintPreferences: true, + toolheads: 4, + materialSlots: 4, + defaultCameraPath: '/webcam/webrtc', + }, + 'flashforge-ad5x': { + kind: 'flashforge-ad5x', + label: 'FlashForge AD5X', + // Adventurer 5M-series plate, shared by the 5M/5M Pro/AD5X. + bed: { sizeX: 220, sizeY: 220, height: 220, modelAsset: 'ad5x_bed.stl', logoText: 'flashforge' }, + sliceProfileAsset: 'flashforge_ad5x.json', + printPrefs: ['autoLevel', 'ifs'], + supportsPrintPreferences: false, + toolheads: 1, + materialSlots: 4, + // ustreamer listens on 8080, but zmod's httpd reverse-proxies it on port 80 + // and that is the URL Moonraker advertises — verified against the printer's + // [webcam video] section. + defaultCameraPath: '/webcam/?action=stream', + }, + 'bambu-lan': { + kind: 'bambu-lan', + label: 'Bambu Lab', + // One kind covers every Bambu because they all speak the same MQTT. The A1 + // mini is the only one with a different plate, so this 256 bed is both the + // P1/X1/A1 bed and a safe fallback — see resolveBedProfile. Height is the + // P1/X1's 250 rather than the A1's 256, so an unknown machine is assumed + // shorter rather than taller. + bed: { + sizeX: 256, + sizeY: 256, + height: 250, + modelAsset: 'bambu_x1_bed.stl', + logoText: 'bambu lab', + }, + // Exact P1S 0.4 profile from the BambuStudio project accepted by the real + // test printer. The send path additionally rejects every non-P1S marker + // and all multi-tool output before FTPS can be reached. + sliceProfileAsset: 'bambu_p1s.json', + // BambuStudio marks the P1S as supporting bed levelling and time-lapse, + // but not per-print flow calibration. The project_file wire field remains + // present and is forced false after applicablePrefs filters it out. + printPrefs: ['autoLevel', 'timelapse'], + supportsPrintPreferences: false, + toolheads: 1, + // One AMS unit. Machines without an AMS simply report no trays, and the + // slot cards already handle that. + materialSlots: 4, + // The P-series serves no HTTP camera at all: frames come over a private + // protocol on 6000, which the native layer republishes on loopback. The URL + // is per-session, so it cannot be a static path — see hooks/useBambu.tsx. + defaultCameraPath: '', + }, + 'generic-klipper': { + kind: 'generic-klipper', + label: 'Klipper printer', + // Unknown machine, so no plate mesh — a plain rectangle at the Ender-class + // size most bed-slinger Klipper builds use. + bed: { sizeX: 220, sizeY: 220, height: 250, modelAsset: null, logoText: null }, + sliceProfileAsset: null, + // Unknown firmware — every toggle here is vendor-specific. + printPrefs: [], + supportsPrintPreferences: false, + toolheads: 1, + materialSlots: 0, + defaultCameraPath: '', + }, +}; + +export const DEFAULT_PRINTER_KIND: PrinterKind = 'generic-klipper'; + +/** + * Kind assumed when a printer is typed in by hand instead of discovered. + * Discovery detects the real kind; manual entry has nothing to go on, so it + * keeps Helix's original machine rather than degrading everyone to generic. + */ +export const MANUAL_PRINTER_KIND: PrinterKind = 'snapmaker-u1'; + +export function isPrinterKind(value: unknown): value is PrinterKind { + return typeof value === 'string' && value in PRINTER_PROFILES; +} + +export function normalizePrinterKind(value: unknown): PrinterKind { + return isPrinterKind(value) ? value : DEFAULT_PRINTER_KIND; +} + +export function printerProfile(kind: unknown): PrinterProfile { + return PRINTER_PROFILES[normalizePrinterKind(kind)]; +} + +/** + * The A1 mini's plate — the only Bambu bed that is not the 256mm one. + * 180x180x180, from Orca's `Bambu Lab A1 mini` machine model. + */ +const BAMBU_A1_MINI_BED: BedProfile = { + sizeX: 180, + sizeY: 180, + height: 180, + modelAsset: 'bambu_a1_mini_bed.stl', + logoText: 'bambu lab', +}; + +/** Full-size A1: same 256mm plate footprint as P/X, but 6mm more Z travel. */ +const BAMBU_A1_BED: BedProfile = { + sizeX: 256, + sizeY: 256, + height: 256, + modelAsset: 'bambu_x1_bed.stl', + logoText: 'bambu lab', +}; + +/** + * Bambu encodes the machine in the first three characters of the serial. The + * code matches what SSDP reports as DevModel and what Orca stores as model_id: + * N1 = A1 mini, N2S = A1, C11 = P1P, C12 = P1S, C13 = X1E, + * BL-P001 = X1 Carbon, BL-P002 = X1. + * + * The full-size A1 shares the 256mm plate footprint, but has 256mm of Z travel + * rather than the P1/X1 fallback's conservative 250mm. It also needs its own + * machine startup profile, so both A-series prefixes are explicit here. + * + * The P1S prefix is confirmed against a real machine (its certificate CN is + * `01P00C611300996` — see BambuMqttConnection.kt); the A1 mini prefix comes + * from Bambu's published serial scheme and is not verified here. + */ +const BAMBU_A1_MINI_SERIAL_PREFIX = '030'; +const BAMBU_A1_SERIAL_PREFIX = '039'; +const VERIFIED_BAMBU_P1S_SERIAL_PREFIX = '01P'; + +/** Bed for a Bambu, chosen by serial. Falls back to the 256mm plate. */ +export function bambuBedForSerial(serial: unknown): BedProfile { + const prefix = typeof serial === 'string' ? serial.trim().toUpperCase().slice(0, 3) : ''; + if (prefix === BAMBU_A1_MINI_SERIAL_PREFIX) return BAMBU_A1_MINI_BED; + if (prefix === BAMBU_A1_SERIAL_PREFIX) return BAMBU_A1_BED; + return PRINTER_PROFILES['bambu-lan'].bed; +} + +/** Identity a saved printer carries that can narrow down its bed. */ +export interface BedIdentity { + kind?: unknown; + serialNumber?: string | null; +} + +/** + * The bed to slice and preview against for a saved printer. + * + * Most kinds map straight to their profile's bed. Bambu is the exception: one + * kind covers machines with different plates, so it is resolved from the serial. + */ +export function resolveBedProfile(printer: BedIdentity | null | undefined): BedProfile { + const kind = normalizePrinterKind(printer?.kind); + if (kind === 'bambu-lan') return bambuBedForSerial(printer?.serialNumber); + return PRINTER_PROFILES[kind].bed; +} + +/** What the native slice needs to know about the target machine. */ +export interface MachineProfile { + bed: BedProfile; + sliceProfileAsset: string | null; + supportsPrintPreferences: boolean; + /** Toggles the native preprocess sheet should offer. */ + printPrefs: PrintPrefKey[]; + /** + * How the machine names its material feeds: 'lane' shows Lane 1–4 (AD5X, + * Bambu), 'tool' shows T0–T3 feeding lanes (U1, everything else). Mirrors + * LaneNaming in services/printPreprocess.ts so the native sheet words itself + * the way the RN dialog does. + */ + laneNaming: 'tool' | 'lane'; +} + +/** + * Bed plus slice profile for a saved printer — the payload the native preview + * and slice both read. Sending this is what stops a job going out with another + * machine's bed size or start G-code. + */ +export function resolveMachineProfile(printer: BedIdentity | null | undefined): MachineProfile { + const kind = normalizePrinterKind(printer?.kind); + const serial = typeof printer?.serialNumber === 'string' + ? printer.serialNumber.trim().toUpperCase() + : ''; + const bambuSliceProfile = kind !== 'bambu-lan' + ? PRINTER_PROFILES[kind].sliceProfileAsset + : serial.startsWith(VERIFIED_BAMBU_P1S_SERIAL_PREFIX) + ? 'bambu_p1s.json' + : serial.startsWith(BAMBU_A1_SERIAL_PREFIX) + ? 'bambu_a1.json' + : null; + return { + bed: resolveBedProfile(printer), + sliceProfileAsset: bambuSliceProfile, + supportsPrintPreferences: PRINTER_PROFILES[kind].supportsPrintPreferences, + printPrefs: kind === 'bambu-lan' && !bambuSliceProfile ? [] : PRINTER_PROFILES[kind].printPrefs, + laneNaming: kind === 'flashforge-ad5x' || kind === 'bambu-lan' ? 'lane' : 'tool', + }; +} + + +/** Identity fields Moonraker exposes, from two different endpoints. */ +export interface PrinterIdentity { + /** `machine/system_info` → system_info.product_info (PAXX/Snapmaker only). */ + machineType?: string; + serial?: string; + deviceName?: string; + /** `printer/info` → hostname. The FlashForge Klipper mod reports "flashforge". */ + hostname?: string; +} + +function lower(value: string | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +export function detectPrinterKind(identity: PrinterIdentity): PrinterKind { + const machineType = lower(identity.machineType); + const serial = lower(identity.serial); + const hostname = lower(identity.hostname); + const deviceName = lower(identity.deviceName); + + // PAXX publishes product_info; stock Klipper builds do not. + if (machineType.includes('u1') || serial.startsWith('811')) return 'snapmaker-u1'; + + // The Adventurer 5M/5X Klipper mod keeps the vendor hostname, which is the + // only identity it reliably exposes — product_info is absent there. + if ( + hostname.startsWith('flashforge') || + /\bad5[xm]\b|adventurer\s*5/.test(machineType) || + /\bad5[xm]\b|adventurer\s*5/.test(deviceName) + ) { + return 'flashforge-ad5x'; + } + + return 'generic-klipper'; +} diff --git a/services/printerSetup.ts b/services/printerSetup.ts new file mode 100644 index 0000000..343c168 --- /dev/null +++ b/services/printerSetup.ts @@ -0,0 +1,21 @@ +import type { DiscoveredPrinter } from './printerDiscovery'; +import { printerProfile } from './printerProfiles'; +import type { PrinterEntry } from './settingsMigration'; + +/** Builds the editable settings entry used after selecting a network result. */ +export function printerEntryFromDiscovery( + printer: DiscoveredPrinter, + id: string, + fallbackName = 'Printer' +): PrinterEntry { + return { + id, + name: printer.name || fallbackName, + url: printer.moonrakerUrl, + tailscaleUrl: '', + cameraUrl: printer.cameraUrl || printerProfile(printer.kind).defaultCameraPath, + connectionMode: 'lan', + kind: printer.kind, + serialNumber: printer.serial ?? '', + }; +} diff --git a/services/settingsMigration.ts b/services/settingsMigration.ts index 72db757..5bf9560 100644 --- a/services/settingsMigration.ts +++ b/services/settingsMigration.ts @@ -1,6 +1,8 @@ +import { normalizeDashboardByPrinter } from './dashboardSections'; import { normalizeMacroDisplayByPrinter } from './macroDisplay'; import type { MacroDisplaySettings } from './macroDisplay'; -import { normalizeMoonrakerUrl } from './moonraker'; +import { normalizeMoonrakerUrl, type AiDetectionSensitivity } from './moonraker'; +import { isPrinterKind, type PrinterKind } from './printerProfiles'; import { normalizeTemperatureUnit } from './temperature'; import type { TemperatureUnit } from './temperature'; import { @@ -16,6 +18,15 @@ export interface PrinterEntry { tailscaleUrl: string; cameraUrl: string; connectionMode: ConnectionMode; + /** Which machine this is, driving toolhead count, camera and material UI. */ + kind: PrinterKind; + /** + * FlashForge REST credentials, needed only to read the AD5X material station + * (Moonraker cannot see it). Absent on every other machine. The check code is + * the printer's "Printer ID" and grants full control — treat it as a secret. + */ + serialNumber?: string; + checkCode?: string; } export interface DashboardSections { @@ -43,7 +54,10 @@ export interface Settings { connectionMode: ConnectionMode; printers: PrinterEntry[]; activePrinterId: string; + /** Template for printers with no saved layout of their own. */ dashboard: DashboardSections; + /** Home layout per printer id — personalisation does not leak between machines. */ + dashboardByPrinter: Record<string, DashboardSections>; /** Show a confirmation dialog before firing the emergency stop. */ estopConfirm: boolean; macroDisplayByPrinter: Record<string, MacroDisplaySettings>; @@ -64,6 +78,8 @@ export interface Settings { accentColor: string; language: string; temperatureUnit: TemperatureUnit; + /** Detection aggressiveness shared by spaghetti and build-plate monitoring. */ + aiDetectionSensitivity: AiDetectionSensitivity; /** Hex colours for the four physical filament slots (T0–T3), used by paint/preview. */ filamentSlotColors: string[]; /** Manual fallback material labels for the four physical filament slots. */ @@ -76,8 +92,10 @@ export interface Settings { seenNotificationIds: string[]; } -export const STORAGE_VERSION = 11; +export const STORAGE_VERSION = 13; export const LEGACY_DEFAULT_PRIMARY_URL = 'http://192.168.1.17:7125'; +/** Helix only supported the U1 before printer kinds existed. */ +const LEGACY_PRINTER_KIND: PrinterKind = 'snapmaker-u1'; export const DEFAULT_SETTINGS: Settings = { settingsVersion: STORAGE_VERSION, @@ -101,6 +119,7 @@ export const DEFAULT_SETTINGS: Settings = { macros: false, }, estopConfirm: true, + dashboardByPrinter: {}, macroDisplayByPrinter: {}, notificationMode: 'fcm', ntfyServer: 'https://ntfy.sh', @@ -119,6 +138,7 @@ export const DEFAULT_SETTINGS: Settings = { accentColor: '#2196f3', language: 'en', temperatureUnit: 'c', + aiDetectionSensitivity: 'low', filamentSlotColors: [...DEFAULT_FILAMENT_SLOT_COLORS], filamentSlotMaterials: ['PLA', 'PLA', 'PLA', 'PLA'], filamentSlotSubtypes: [DEFAULT_FILAMENT_SUBTYPE, DEFAULT_FILAMENT_SUBTYPE, DEFAULT_FILAMENT_SUBTYPE, DEFAULT_FILAMENT_SUBTYPE], @@ -198,14 +218,26 @@ function normalizePrinter(raw: unknown, index: number): PrinterEntry { ? (raw as Partial<PrinterEntry>) : {}; - return { + const entry: PrinterEntry = { id: stringValue(p.id) || `p${index + 1}`, name: stringValue(p.name) || `Snapmaker ${index + 1}`, url: normalizeMoonrakerUrl(stringValue(p.url) || ''), tailscaleUrl: normalizeMoonrakerUrl(stringValue(p.tailscaleUrl) || ''), cameraUrl: stringValue(p.cameraUrl) || DEFAULT_SETTINGS.cameraUrl, connectionMode: normalizeConnectionMode(p.connectionMode), + // Entries saved before v12 predate multi-machine support, so an absent + // kind can only mean the U1 — do not fall back to the generic profile. + kind: isPrinterKind(p.kind) ? p.kind : LEGACY_PRINTER_KIND, }; + + // Only carried when actually set, so printers with no FlashForge API keep a + // clean entry rather than gaining two permanently empty fields. + const serialNumber = stringValue(p.serialNumber); + const checkCode = stringValue(p.checkCode); + if (serialNumber) entry.serialNumber = serialNumber; + if (checkCode) entry.checkCode = checkCode; + + return entry; } export function migrateSettings(raw: Partial<Settings>): Settings { @@ -229,6 +261,10 @@ export function migrateSettings(raw: Partial<Settings>): Settings { cameraUrl: stringValue(parsed.cameraUrl) || DEFAULT_SETTINGS.cameraUrl, connectionMode: parsedConnectionMode, dashboard: normalizeDashboard(parsed.dashboard), + dashboardByPrinter: normalizeDashboardByPrinter( + parsed.dashboardByPrinter, + normalizeDashboard(parsed.dashboard) + ), estopConfirm: booleanValue(parsed.estopConfirm, DEFAULT_SETTINGS.estopConfirm), macroDisplayByPrinter: normalizeMacroDisplayByPrinter(parsed.macroDisplayByPrinter), notificationMode: notificationMode(parsed.notificationMode, parsed.ntfyTopic), @@ -269,6 +305,7 @@ export function migrateSettings(raw: Partial<Settings>): Settings { accentColor: stringValue(parsed.accentColor) || DEFAULT_SETTINGS.accentColor, language: stringValue(parsed.language) || DEFAULT_SETTINGS.language, temperatureUnit: normalizeTemperatureUnit(parsed.temperatureUnit), + aiDetectionSensitivity: parsed.aiDetectionSensitivity === 'high' ? 'high' : 'low', filamentSlotColors: normalizeFilamentSlotColors(parsed.filamentSlotColors), filamentSlotMaterials: normalizeFilamentSlotMaterials(parsed.filamentSlotMaterials), filamentSlotSubtypes: normalizeFilamentSlotSubtypes(parsed.filamentSlotSubtypes), @@ -279,7 +316,11 @@ export function migrateSettings(raw: Partial<Settings>): Settings { printers: Array.isArray(parsed.printers) ? parsed.printers .map((p, index) => normalizePrinter(p, index)) - .filter((p) => p.url || p.tailscaleUrl) + // AD5X editor intentionally omits the URL fields, so a FlashForge + // entry can legitimately have no LAN/Tailscale URL yet (the address + // arrives via discovery). Keep those; only drop URL-less U1/generic + // entries, which the editor never lets you save without a URL anyway. + .filter((p) => p.url || p.tailscaleUrl || p.kind === 'flashforge-ad5x') : [], }; @@ -293,6 +334,7 @@ export function migrateSettings(raw: Partial<Settings>): Settings { tailscaleUrl: merged.tailscaleUrl, cameraUrl: merged.cameraUrl, connectionMode: merged.connectionMode, + kind: LEGACY_PRINTER_KIND, }, ]; merged.activePrinterId = 'p1'; diff --git a/services/zmodPrintPrompt.ts b/services/zmodPrintPrompt.ts new file mode 100644 index 0000000..63ba4b1 --- /dev/null +++ b/services/zmodPrintPrompt.ts @@ -0,0 +1,121 @@ +// Answering zmod's "Select print materials" prompt on Helix's behalf. +// +// A FlashForge AD5X running zmod raises a Klipper prompt on every print start +// asking which IFS lane feeds each tool in the G-code. Helix already asked the +// same question in its own print dialog, so making the operator answer twice is +// just friction — worse, the printer's proposed mapping is its own guess, not +// the slot the user picked. +// +// So Helix answers it: take the printer's own commit macro, rewrite the lane +// arguments from the mapping Helix already holds, and send it back. +// +// Indexing, which is the whole reason this file exists: +// * The FlashForge wire numbers lanes from 1 (flashforgeApi normalises to 0). +// * Helix stores slots 0-based everywhere. +// * zmod's macro takes `T<tool>=<lane>` where <tool> is 0-based and <lane> is +// 1-based — confirmed by its own button labels, which render tool 0 as +// "1 -> 2" for lane 2. +// So a Helix slot index s becomes lane s + 1. +// crabcore + +import type { KlipperPrompt, KlipperPromptButton } from './klipperPrompt'; + +/** 0-based G-code tool → 0-based Helix slot. */ +export type ToolSlotMap = Readonly<Record<number, number>>; + +/** Lane arguments: `T0=2`, preceded by start-of-string or whitespace. */ +const TOOL_ARG = /(^|\s)T(\d+)=(-?\d+)/g; +const FILENAME_ARG = /FILENAME\s*=\s*(?:"([^"]*)"|(\S+))/i; + +/** + * The button that actually starts the print, or null when this prompt is not a + * zmod material selection (a runout or calibration prompt must still be shown + * to the operator rather than silently answered). + */ +export function zmodCommitButton(prompt: KlipperPrompt): KlipperPromptButton | null { + return prompt.buttons.find((button) => /^\s*PRINT_ZCOLOR\b/i.test(button.gcode)) ?? null; +} + +/** The file a macro invocation names, unquoted. */ +export function macroFilename(gcode: string): string | null { + const match = FILENAME_ARG.exec(gcode); + if (!match) return null; + const value = match[1] ?? match[2] ?? ''; + return value.trim() || null; +} + +/** Basename, lowercased — the printer's path and Helix's upload name can differ. */ +function fileKey(name: string): string { + const base = name.split(/[\\/]/).pop() ?? name; + return base.trim().toLowerCase(); +} + +/** True when a prompt's macro refers to the same file Helix just sent. */ +export function macroTargetsFile(gcode: string, filename: string): boolean { + const target = macroFilename(gcode); + if (!target || !filename.trim()) return false; + return fileKey(target) === fileKey(filename); +} + +/** + * Rewrites the lane arguments of [gcode] from [toolToSlot]. + * + * Only arguments already present are touched: the macro declares which tools it + * cares about, and inventing a `T4=` for a machine with four lanes would be a + * guess about firmware we have not verified. Tools with no mapping keep the + * printer's own proposal, which is the same value tapping the button would send. + */ +export function applyToolSlots(gcode: string, toolToSlot: ToolSlotMap): string { + return gcode.replace(TOOL_ARG, (whole, lead: string, tool: string, lane: string) => { + const slot = toolToSlot[Number(tool)]; + if (typeof slot !== 'number' || !Number.isFinite(slot) || slot < 0) return whole; + return `${lead}T${tool}=${slot + 1}`; + }); +} + +/** + * Sets `LEVELING=` from the user's preference. + * + * The AD5X has no SET_PRINT_PREFERENCES macro — bed levelling reaches it only + * as this argument, which is why the printer's own prompt carries a "Leveling + * Off" button that differs from its commit button by exactly this flag. Passing + * undefined leaves the printer's own value alone. + */ +export function applyLeveling(gcode: string, autoLevel?: boolean): string { + if (autoLevel === undefined) return gcode; + return gcode.replace(/(^|\s)LEVELING=(-?\d+)/gi, `$1LEVELING=${autoLevel ? 1 : 0}`); +} + +/** + * The G-code that starts this print with Helix's mapping applied, or null when + * the prompt is not a zmod material selection for [filename]. + * + * Returning null is the signal to show the dialog instead: an unrecognised + * prompt, or one about a different file, is the operator's to answer. + */ +export function autoAnswerGcode( + prompt: KlipperPrompt, + filename: string, + toolToSlot: ToolSlotMap, + autoLevel?: boolean +): string | null { + const commit = zmodCommitButton(prompt); + if (!commit) return null; + if (!macroTargetsFile(commit.gcode, filename)) return null; + return applyLeveling(applyToolSlots(commit.gcode, toolToSlot), autoLevel); +} + +/** + * Starts [filename] without the material station — zmod's per-print IFS-off + * path. Its own UI button "Hide color selection, print without IFS" sends + * exactly this: SILENT=2 skips the material-selection prompt entirely, and the + * print runs from the external side spool with every T-command in the G-code + * ignored. Nothing is persisted; the next print asks again. + * + * There is no per-tool variant of this: PRINT_ZCOLOR rejects slot 0, so an + * external spool can never be mapped in as one lane among several — IFS-off is + * all-or-nothing. + */ +export function ifsOffPrintGcode(filename: string, autoLevel: boolean): string { + return `SET_ZCOLOR FILENAME="${filename}" SILENT=2 LEVELING=${autoLevel ? 1 : 0}`; +} diff --git a/tools/bespok3d/helixscreen-ui/Dockerfile b/tools/bespok3d/helixscreen-ui/Dockerfile new file mode 100644 index 0000000..f63d156 --- /dev/null +++ b/tools/bespok3d/helixscreen-ui/Dockerfile @@ -0,0 +1,75 @@ +# syntax=docker/dockerfile:1 +# HelixScreen's Snapmaker U1 build is an aarch64 static binary produced by an +# amd64 cross-toolchain. b3-builder therefore runs this image as linux/amd64 +# even when the build host is arm64. +FROM debian:trixie-slim AS build + +ARG HELIXSCREEN_COMMIT=59e2b0b3ffde67666b89db80845057fa1b0b9a5b +ARG HELIXSCREEN_TAG=v0.99.112 + +ENV DEBIAN_FRONTEND=noninteractive +ENV CROSS_COMPILE=aarch64-linux-gnu- +ENV CC=aarch64-linux-gnu-gcc +ENV CXX=aarch64-linux-gnu-g++ +ENV AR=aarch64-linux-gnu-gcc-ar +ENV STRIP=aarch64-linux-gnu-strip +ENV RANLIB=aarch64-linux-gnu-gcc-ranlib +ENV LD=aarch64-linux-gnu-ld +ENV PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig +ENV PKG_CONFIG_SYSROOT_DIR=/ + +RUN echo 'Acquire::Retries "3";' > /etc/apt/apt.conf.d/80-retries && \ + apt-get update && apt-get install -y --no-install-recommends \ + autoconf automake bison build-essential ca-certificates ccache cmake \ + crossbuild-essential-arm64 file flex git libtool make pkg-config \ + python3 zip && \ + dpkg --add-architecture arm64 && apt-get update && \ + apt-get install -y --no-install-recommends \ + libc6-dev:arm64 libdrm-dev:arm64 libevdev-dev:arm64 \ + libinput-dev:arm64 libmtdev-dev:arm64 libssl-dev:arm64 \ + libstdc++-14-dev:arm64 libudev-dev:arm64 libzstd-dev:arm64 \ + zlib1g-dev:arm64 && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /src +RUN git init . && \ + git remote add origin https://github.com/prestonbrown/helixscreen.git && \ + git fetch --depth 1 origin "refs/tags/${HELIXSCREEN_TAG}" && \ + git checkout --detach FETCH_HEAD && \ + test "$(git rev-parse HEAD)" = "$HELIXSCREEN_COMMIT" && \ + git submodule update --init --recursive --depth 1 + +# lmd binds and supervises this ELF directly as /usr/bin/gui, so there is no +# launcher process from which HelixScreen can inherit environment variables. +# Add only deterministic Bespok3d defaults; overwrite=0 preserves an explicit +# value supplied by a future supervisor. +RUN <<'SH' +python3 - <<'PY' +from pathlib import Path + +path = Path("src/main.cpp") +source = path.read_text() +anchor = "int main(int argc, char** argv) {" +replacement = """int main(int argc, char** argv) { + // Bespok3d Snapmaker U1 package defaults. Keep explicit supervisor values. + ::setenv("HELIX_DATA_DIR", "/userdata/bespok3d/usr/local/plugins/helixscreen-ui/files/helixscreen", 0); + ::setenv("HELIX_CONFIG_DIR", "/userdata/bespok3d/var/lib/helixscreen-ui/config", 0); + ::setenv("HELIX_CACHE_DIR", "/userdata/bespok3d/var/lib/helixscreen-ui/cache", 0); + ::setenv("HELIX_SUPERVISED", "1", 0); + ::setenv("HELIX_DRM_DEVICE", "/dev/dri/card0", 0); + ::setenv("HELIX_REMOTE_SCREEN_FB0", "/dev/fb0", 0);""" +assert source.count(anchor) == 1, "main.cpp entry anchor changed" +path.write_text(source.replace(anchor, replacement)) +PY +SH + +RUN make PLATFORM_TARGET=snapmaker-u1 SKIP_OPTIONAL_DEPS=1 -j2 && \ + make PLATFORM_TARGET=snapmaker-u1 release-snapmaker-u1 && \ + mkdir -p /out && \ + tar -xzf releases/helixscreen-snapmaker-u1-v0.99.112.tar.gz -C /out && \ + file /out/helixscreen/bin/helix-screen | grep -q 'ARM aarch64' && \ + test "$(od -An -N4 -tx1 /out/helixscreen/bin/helix-screen | tr -d '[:space:]')" = 7f454c46 + +FROM scratch +COPY --from=build /out /out +CMD ["true"] diff --git a/tools/bespok3d/helixscreen-ui/doc/README.md b/tools/bespok3d/helixscreen-ui/doc/README.md new file mode 100644 index 0000000..b91efda --- /dev/null +++ b/tools/bespok3d/helixscreen-ui/doc/README.md @@ -0,0 +1,35 @@ +# HelixScreen Touchscreen for Bespok3d + +This experimental Snapmaker U1 plugin adds a reversible choice between the factory touchscreen and +[HelixScreen](https://github.com/prestonbrown/helixscreen). It does not replace the screen merely by +being installed: `snapmaker` is the default, and the factory `/usr/bin/gui` file is never deleted or +overwritten. + +When `helixscreen` is selected, the plugin read-only bind-mounts its HelixScreen ELF over +`/usr/bin/gui` and asks the U1 display supervisor (`lmd`) to restart. This matches the supervision +model the U1 expects: `lmd` continues to own the GUI process and its recovery behavior. Switching +back or uninstalling removes the bind mount before `lmd` restarts, revealing the untouched factory +binary immediately. Bespok3d blocks the display restart while a print is active. + +The plugin reapplies the selected mode at boot. Writable HelixScreen settings and cache live under +`/userdata/bespok3d/var/lib/helixscreen-ui`; uninstalling preserves that directory. If the packaged +binary is missing or fails its ELF check, selection fails closed to the factory screen. + +## Build and provenance + +The package builds HelixScreen v0.99.112 from pinned upstream commit +`59e2b0b3ffde67666b89db80845057fa1b0b9a5b` using upstream's Snapmaker U1 static cross-build. The +build adds deterministic Bespok3d defaults for the data, config, cache, DRM, supervision, and remote +framebuffer environment variables. Explicit supervisor-provided values still win. + +HelixScreen is Copyright (C) 2025-2026 356C LLC and contributors and is licensed under +GPL-3.0-or-later. The complete corresponding source is that pinned public commit plus the +reproducible modification in this plugin's Dockerfile. The selector and Bespok3d packaging are part +of Helix and use the repository's license. + +## Validation boundary + +The selector is unit-tested without mounting anything on the developer machine. A baked package +must additionally pass an aarch64 ELF check. Before promotion beyond `experiment`, test both +directions on a real idle U1: factory to HelixScreen, HelixScreen to factory, reboot persistence, +camera coexistence, remote-screen mirroring, and uninstall restoration. diff --git a/tools/bespok3d/helixscreen-ui/files/bin/helixscreen-ui-switch b/tools/bespok3d/helixscreen-ui/files/bin/helixscreen-ui-switch new file mode 100755 index 0000000..b188d78 --- /dev/null +++ b/tools/bespok3d/helixscreen-ui/files/bin/helixscreen-ui-switch @@ -0,0 +1,137 @@ +#!/bin/sh +set -eu + +BESPOK3D_ROOT=${BESPOK3D:-/userdata/bespok3d} +PLUGIN_ROOT=${BESPOK3D_PLUGINS:-$BESPOK3D_ROOT/usr/local/plugins}/helixscreen-ui +HELIX_ROOT=$PLUGIN_ROOT/files/helixscreen +HELIX_BINARY=$HELIX_ROOT/bin/helix-screen +GUI_TARGET=${HELIXSCREEN_GUI_TARGET:-/usr/bin/gui} +MOUNTS_FILE=${HELIXSCREEN_MOUNTS_FILE:-/proc/mounts} +MOUNT_BIN=${HELIXSCREEN_MOUNT_BIN:-mount} +UMOUNT_BIN=${HELIXSCREEN_UMOUNT_BIN:-umount} +LMDCTL=${HELIXSCREEN_LMDCTL:-$BESPOK3D_ROOT/etc/init.d/lmdctl} +STATE_DIR=$BESPOK3D_ROOT/var/lib/helixscreen-ui +MODE_FILE=$STATE_DIR/screen-ui + +log() { + if command -v logger >/dev/null 2>&1; then + logger -p user.info -t "helixscreen-ui[$$]" -- "$*" || true + fi + printf '%s\n' "$*" +} + +is_valid_binary() { + [ -s "$HELIX_BINARY" ] && [ -x "$HELIX_BINARY" ] && + [ "$(od -An -N4 -tx1 "$HELIX_BINARY" | tr -d '[:space:]')" = 7f454c46 ] +} + +is_gui_mounted() { + [ -r "$MOUNTS_FILE" ] && awk -v target="$GUI_TARGET" '$2 == target { found = 1 } END { exit !found }' "$MOUNTS_FILE" +} + +unmount_gui() { + if ! is_gui_mounted; then + return 0 + fi + "$UMOUNT_BIN" "$GUI_TARGET" || "$UMOUNT_BIN" -l "$GUI_TARGET" +} + +restore_stock() { + if is_gui_mounted; then + unmount_gui + log "Restored the stock Snapmaker touchscreen" + else + log "Stock Snapmaker touchscreen already selected" + fi +} + +select_helixscreen() { + if ! is_valid_binary; then + restore_stock + printf '%s\n' "HelixScreen binary is missing, non-executable, or not an ELF file" >&2 + return 1 + fi + + mkdir -p "$STATE_DIR/config" "$STATE_DIR/cache" + if is_gui_mounted && [ "$GUI_TARGET" -ef "$HELIX_BINARY" ]; then + log "HelixScreen touchscreen already selected" + return 0 + fi + + unmount_gui + if ! "$MOUNT_BIN" -o ro --bind "$HELIX_BINARY" "$GUI_TARGET"; then + restore_stock + printf '%s\n' "Could not bind HelixScreen over $GUI_TARGET; stock touchscreen remains active" >&2 + return 1 + fi + log "Selected the HelixScreen touchscreen" +} + +read_mode() { + if [ -r "$MODE_FILE" ]; then + mode=$(sed -n '1p' "$MODE_FILE") + else + mode=snapmaker + fi + case "$mode" in + snapmaker|helixscreen) printf '%s\n' "$mode" ;; + *) printf '%s\n' snapmaker ;; + esac +} + +write_mode() { + mode=$1 + case "$mode" in + snapmaker|helixscreen) ;; + *) printf '%s\n' "Invalid touchscreen interface: $mode" >&2; return 2 ;; + esac + mkdir -p "$STATE_DIR" + mode_tmp=$MODE_FILE.tmp.$$ + trap 'rm -f "$mode_tmp"' EXIT HUP INT TERM + printf '%s\n' "$mode" > "$mode_tmp" + mv "$mode_tmp" "$MODE_FILE" + trap - EXIT HUP INT TERM +} + +apply_mode() { + case "$1" in + snapmaker) restore_stock ;; + helixscreen) select_helixscreen ;; + *) printf '%s\n' "Invalid touchscreen interface: $1" >&2; return 2 ;; + esac +} + +case "${1:-}" in + configure) + [ "$#" -eq 2 ] || { printf '%s\n' "Usage: $0 configure {snapmaker|helixscreen}" >&2; exit 2; } + previous=$(read_mode) + apply_mode "$2" + if ! write_mode "$2"; then + apply_mode "$previous" || true + exit 1 + fi + ;; + start) + selected=$(read_mode) + apply_mode "$selected" + if [ "$selected" = helixscreen ]; then + "$LMDCTL" restart + fi + ;; + stop|restore-stock) + restore_stock + ;; + status) + selected=$(read_mode) + if is_gui_mounted && [ "$GUI_TARGET" -ef "$HELIX_BINARY" ]; then + active=helixscreen + else + active=snapmaker + fi + printf 'selected=%s active=%s\n' "$selected" "$active" + ;; + *) + printf '%s\n' "Usage: $0 {configure {snapmaker|helixscreen}|start|stop|restore-stock|status}" >&2 + exit 2 + ;; +esac diff --git a/tools/bespok3d/helixscreen-ui/manifest.json b/tools/bespok3d/helixscreen-ui/manifest.json new file mode 100644 index 0000000..f393e9a --- /dev/null +++ b/tools/bespok3d/helixscreen-ui/manifest.json @@ -0,0 +1,92 @@ +{ + "name": "helixscreen-ui", + "title": "HelixScreen Touchscreen", + "version": "0.1.0", + "sw_version": "HelixScreen v0.99.112", + "description": "Adds HelixScreen as an optional touchscreen interface for the Snapmaker U1. The stock Snapmaker screen remains the default and can be restored from the plugin settings or by uninstalling the plugin. Screen changes restart the display service and are blocked while a print is active.", + "tagline": "Choose HelixScreen or the stock Snapmaker touchscreen.", + "category": "screen", + "channel": "experiment", + "printer_specific": true, + "source": "https://github.com/FatBoy721/Helix/tree/play-store-prep/tools/bespok3d/helixscreen-ui", + "homepage": "https://github.com/prestonbrown/helixscreen", + "license": "https://www.gnu.org/licenses/gpl-3.0.html", + "publisher": "PLACEHOLDER", + "author": "Helix", + "attributions": "HelixScreen v0.99.112 is Copyright (C) 2025-2026 356C LLC and contributors, licensed GPL-3.0-or-later. This package builds pinned upstream commit 59e2b0b3ffde67666b89db80845057fa1b0b9a5b and adds only Bespok3d-specific runtime path defaults and reversible stock-screen selection.", + "min_daemon_version": "0.12.24", + "min_jinni_version": "0.1.10", + "provides": [ + { + "service": "touchscreen-ui", + "exclusive": true + } + ], + "require": [], + "conflicts": [], + "requires": { + "capabilities": [ + "snapmaker-u1" + ], + "variables": [] + }, + "config": [ + { + "key": "SCREEN_UI", + "label": "Touchscreen interface", + "type": "select", + "options": [ + "snapmaker", + "helixscreen" + ], + "default": "snapmaker", + "hint": "Snapmaker keeps the factory screen. HelixScreen replaces it after the display service restarts. Changing this is unavailable during a print.", + "required": true, + "userEditable": true, + "scope": "printer" + } + ], + "permissions": [ + "display-replacement", + "persistent-data", + "restart" + ], + "bake": [ + { + "class": "docker-c", + "dockerfile": "Dockerfile", + "context": ".", + "platform": "linux/amd64", + "out": "/out", + "members": [ + { + "path": "helixscreen", + "dest": "files/helixscreen", + "mode": "0755" + } + ] + } + ], + "install": { + "data": [ + "helixscreen-ui" + ], + "dirs": [], + "templates": [], + "symlinks": [ + { + "from": "files/bin/helixscreen-ui-switch", + "to": "$BESPOK3D/etc/init.d/autostart/s90helixscreen-ui" + } + ], + "start": [ + "$BESPOK3D_PLUGINS/helixscreen-ui/files/bin/helixscreen-ui-switch configure $SCREEN_UI" + ], + "restart": [ + "lmd" + ] + }, + "stop": [ + "$BESPOK3D_PLUGINS/helixscreen-ui/files/bin/helixscreen-ui-switch restore-stock" + ] +} diff --git a/tools/bespok3d/helixscreen-ui/tests/run.sh b/tools/bespok3d/helixscreen-ui/tests/run.sh new file mode 100755 index 0000000..e8fa3cf --- /dev/null +++ b/tools/bespok3d/helixscreen-ui/tests/run.sh @@ -0,0 +1,131 @@ +#!/bin/sh +set -eu + +PLUGIN_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +SWITCH=$PLUGIN_DIR/files/bin/helixscreen-ui-switch +MANIFEST=$PLUGIN_DIR/manifest.json +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/helixscreen-ui-test.XXXXXX") +trap 'rm -rf "$TEST_ROOT"' EXIT HUP INT TERM + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assert_contains() { + case "$1" in + *"$2"*) ;; + *) fail "expected [$1] to contain [$2]" ;; + esac +} + +assert_file_line() { + actual=$(sed -n '1p' "$1" 2>/dev/null || true) + [ "$actual" = "$2" ] || fail "expected $1 to contain [$2], got [$actual]" +} + +python3 - "$MANIFEST" <<'PY' +import json, sys +manifest = json.load(open(sys.argv[1], encoding="utf-8")) +assert manifest["name"] == "helixscreen-ui" +assert manifest["publisher"] == "PLACEHOLDER" +assert manifest["channel"] == "experiment" +field = next(item for item in manifest["config"] if item["key"] == "SCREEN_UI") +assert field["default"] == "snapmaker" +assert field["options"] == ["snapmaker", "helixscreen"] +assert manifest["install"]["restart"] == ["lmd"] +assert manifest["stop"] == ["$BESPOK3D_PLUGINS/helixscreen-ui/files/bin/helixscreen-ui-switch restore-stock"] +bake = manifest["bake"][0] +assert bake["platform"] == "linux/amd64" +assert bake["members"] == [{"path": "helixscreen", "dest": "files/helixscreen", "mode": "0755"}] +PY + +FAKE_BESPOK=$TEST_ROOT/bespok3d +FAKE_PLUGINS=$FAKE_BESPOK/usr/local/plugins +FAKE_PLUGIN=$FAKE_PLUGINS/helixscreen-ui +FAKE_GUI=$TEST_ROOT/gui +FAKE_STOCK=$TEST_ROOT/gui.stock +FAKE_MOUNTS=$TEST_ROOT/mounts +FAKE_BIN=$TEST_ROOT/bin +FAKE_LMD_LOG=$TEST_ROOT/lmd.log +mkdir -p "$FAKE_PLUGIN/files/bin" "$FAKE_PLUGIN/files/helixscreen/bin" "$FAKE_BIN" +cp "$SWITCH" "$FAKE_PLUGIN/files/bin/helixscreen-ui-switch" +printf '\177ELFfake-helixscreen\n' > "$FAKE_PLUGIN/files/helixscreen/bin/helix-screen" +chmod 755 "$FAKE_PLUGIN/files/helixscreen/bin/helix-screen" +printf 'stock-gui\n' > "$FAKE_STOCK" +cp "$FAKE_STOCK" "$FAKE_GUI" +: > "$FAKE_MOUNTS" + +cat > "$FAKE_BIN/mount" <<'SH' +#!/bin/sh +set -eu +source_path=$4 +target_path=$5 +rm -f "$target_path" +ln "$source_path" "$target_path" +printf 'fakefs %s none ro,bind 0 0\n' "$target_path" >> "$HELIXSCREEN_MOUNTS_FILE" +SH +cat > "$FAKE_BIN/umount" <<'SH' +#!/bin/sh +set -eu +target_path=$1 +tmp=$HELIXSCREEN_MOUNTS_FILE.tmp +awk -v target="$target_path" '$2 != target' "$HELIXSCREEN_MOUNTS_FILE" > "$tmp" +mv "$tmp" "$HELIXSCREEN_MOUNTS_FILE" +rm -f "$target_path" +cp "$HELIXSCREEN_TEST_STOCK" "$target_path" +SH +cat > "$FAKE_BIN/lmdctl" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >> "$HELIXSCREEN_TEST_LMD_LOG" +SH +chmod 755 "$FAKE_BIN/mount" "$FAKE_BIN/umount" "$FAKE_BIN/lmdctl" + +run_switch() { + PATH="$FAKE_BIN:$PATH" \ + BESPOK3D="$FAKE_BESPOK" \ + BESPOK3D_PLUGINS="$FAKE_PLUGINS" \ + HELIXSCREEN_GUI_TARGET="$FAKE_GUI" \ + HELIXSCREEN_MOUNTS_FILE="$FAKE_MOUNTS" \ + HELIXSCREEN_MOUNT_BIN="$FAKE_BIN/mount" \ + HELIXSCREEN_UMOUNT_BIN="$FAKE_BIN/umount" \ + HELIXSCREEN_LMDCTL="$FAKE_BIN/lmdctl" \ + HELIXSCREEN_TEST_STOCK="$FAKE_STOCK" \ + HELIXSCREEN_TEST_LMD_LOG="$FAKE_LMD_LOG" \ + "$FAKE_PLUGIN/files/bin/helixscreen-ui-switch" "$@" +} + +status=$(run_switch status) +assert_contains "$status" "selected=snapmaker active=snapmaker" + +run_switch configure helixscreen >/dev/null +assert_file_line "$FAKE_BESPOK/var/lib/helixscreen-ui/screen-ui" helixscreen +[ "$FAKE_GUI" -ef "$FAKE_PLUGIN/files/helixscreen/bin/helix-screen" ] || fail "HelixScreen was not bound" +grep -q " $FAKE_GUI " "$FAKE_MOUNTS" || fail "fake mount was not recorded" + +status=$(run_switch status) +assert_contains "$status" "selected=helixscreen active=helixscreen" + +run_switch start >/dev/null +assert_file_line "$FAKE_LMD_LOG" restart + +run_switch configure snapmaker >/dev/null +assert_file_line "$FAKE_BESPOK/var/lib/helixscreen-ui/screen-ui" snapmaker +cmp -s "$FAKE_GUI" "$FAKE_STOCK" || fail "factory GUI was not restored" +[ ! -s "$FAKE_MOUNTS" ] || fail "mount record remained after stock restore" + +if run_switch configure invalid >/dev/null 2>&1; then + fail "invalid mode was accepted" +fi +assert_file_line "$FAKE_BESPOK/var/lib/helixscreen-ui/screen-ui" snapmaker + +printf 'not-elf\n' > "$FAKE_PLUGIN/files/helixscreen/bin/helix-screen" +chmod 755 "$FAKE_PLUGIN/files/helixscreen/bin/helix-screen" +if run_switch configure helixscreen >/dev/null 2>&1; then + fail "non-ELF HelixScreen binary was accepted" +fi +cmp -s "$FAKE_GUI" "$FAKE_STOCK" || fail "invalid binary did not fail closed to stock" +assert_file_line "$FAKE_BESPOK/var/lib/helixscreen-ui/screen-ui" snapmaker + +sh -n "$SWITCH" +printf 'HelixScreen plugin tests passed\n'