Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 112 additions & 28 deletions .github/workflows/build-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,80 +3,160 @@ name: Build APK
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: build-apk-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: write
contents: read

jobs:
build:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
# Full history + tags, needed to diff commits since the last release
fetch-depth: 0
node-version: 20
cache: npm
cache-dependency-path: |
package-lock.json
functions/package-lock.json

- name: Install app dependencies
run: npm ci

- name: Install Functions dependencies
run: npm ci --prefix functions

- name: Type-check
run: npx tsc --noEmit

- name: Lint
run: npx eslint . --quiet

- name: Run regression checks
run: npm run test:regressions

- uses: actions/setup-node@v4
- name: Build Functions
run: npm run build --prefix functions

build:
needs: validate
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 20
cache: npm

- uses: actions/setup-java@v4
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17

- name: Setup Android SDK
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4

- name: Install dependencies
run: npm ci

- name: Decode release keystore
- name: Validate release secrets
env:
FIREBASE_CONFIG_B64: ${{ secrets.FIREBASE_CONFIG_B64 }}
RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }}
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: |
# Skipped on forks: they have no secrets, so they build debug-signed.
if [ -n "$RELEASE_KEYSTORE_B64" ]; then
echo "$RELEASE_KEYSTORE_B64" | base64 -d > android/app/release.keystore
fi
for name in \
FIREBASE_CONFIG_B64 \
RELEASE_KEYSTORE_B64 \
RELEASE_KEYSTORE_PASSWORD \
RELEASE_KEY_ALIAS \
RELEASE_KEY_PASSWORD
do
if [ -z "${!name}" ]; then
echo "::error::$name is not configured"
exit 1
fi
done

- name: Decode release keystore
env:
RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }}
run: printf '%s' "$RELEASE_KEYSTORE_B64" | base64 -d > android/app/release.keystore

- name: Decode Firebase Android config
env:
FIREBASE_CONFIG_B64: ${{ secrets.FIREBASE_CONFIG_B64 }}
run: |
if [ -z "$FIREBASE_CONFIG_B64" ]; then
echo "FIREBASE_CONFIG_B64 is not configured"
exit 1
fi
printf '%s' "$FIREBASE_CONFIG_B64" | base64 -d > android/app/google-services.json
run: printf '%s' "$FIREBASE_CONFIG_B64" | base64 -d > android/app/google-services.json

- name: Build release APK
- name: Test and build release APK
env:
HELIX_DISTRIBUTION: github
NODE_ENV: production
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: chmod +x android/gradlew && cd android && ./gradlew assembleRelease --no-daemon
run: |
chmod +x android/gradlew
cd android
./gradlew testReleaseUnitTest assembleRelease --no-daemon

- name: Rename APK
run: cp android/app/build/outputs/apk/release/app-release.apk helix.apk

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: helix-apk
path: helix.apk

release:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
# Full history + tags are needed to build notes from the closest
# rolling or versioned release.
fetch-depth: 0

- uses: actions/download-artifact@v8
with:
name: helix-apk

- name: Build release notes
if: github.ref == 'refs/heads/main'
run: |
# The "latest" tag still points at the previous release's commit,
# so latest..HEAD is exactly what changed since the last build.
BASE_REF=""
if git rev-parse -q --verify refs/tags/latest >/dev/null; then
RAW="$(git log latest..HEAD --no-merges --pretty='- %s (%h)')"
BASE_REF="latest"
fi

VERSION_TAG="$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n 1)"
if [ -n "$VERSION_TAG" ] && git merge-base --is-ancestor "$VERSION_TAG" HEAD; then
if [ -z "$BASE_REF" ] || \
[ "$(git rev-list --count "$VERSION_TAG"..HEAD)" -lt "$(git rev-list --count "$BASE_REF"..HEAD)" ]; then
BASE_REF="$VERSION_TAG"
fi
fi

if [ -n "$BASE_REF" ]; then
RAW="$(git log "$BASE_REF"..HEAD --no-merges --pretty='- %s (%h)')"
else
RAW="$(git log -10 --no-merges --pretty='- %s (%h)')"
fi

# Hide internal commits (ci:, chore:, ...) from the public changelog;
# git history keeps them. Also read by the in-app update popup.
NOTES="$(printf '%s\n' "$RAW" | grep -Ev '^- (ci|chore|build|docs|test|refactor)(\([^)]*\))?:' || true)"
Expand All @@ -92,9 +172,13 @@ jobs:
echo 'NOTES_EOF'
} >> "$GITHUB_ENV"

- name: Advance rolling release tag
run: |
git tag --force latest "$GITHUB_SHA"
git push origin refs/tags/latest --force

- name: Update "latest" release
if: github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: latest
name: Helix (latest)
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ android {
applicationId 'org.crabcore.u1control'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 12
versionName "1.2.2"
versionCode 15
versionName "1.2.5"

buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.crabcore.u1control.slicing

import java.io.File
import java.util.Locale

/**
* The bundled slicer core is prebuilt, so Helix cannot add new native config
Expand All @@ -9,6 +10,9 @@ import java.io.File
*/
object GcodeToolMapper {
data class Result(val rewritten: Boolean, val usedToolMask: Int)
data class BedMeshResult(val success: Boolean, val rewritten: Boolean)

private data class BedMeshLineResult(val line: String, val valid: Boolean)

private val toolZeroToken = Regex("""(?<![A-Za-z0-9_])T0(?![A-Za-z0-9_])""")
private val toolAnyToken = Regex("""(?<![A-Za-z0-9_])T([0-3])(?![A-Za-z0-9_])""")
Expand All @@ -17,6 +21,16 @@ object GcodeToolMapper {
private val toolParamZero = Regex("""\bTOOL=0\b""", RegexOption.IGNORE_CASE)
private val extruderAny = Regex("""\bEXTRUDER=([0-3])\b""", RegexOption.IGNORE_CASE)
private val indexAny = Regex("""\bINDEX=([0-3])\b""", RegexOption.IGNORE_CASE)
private val bedMeshBounds = Regex(
"""(\bmesh_min=)(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(\s+mesh_max=)(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)""",
RegexOption.IGNORE_CASE,
)

// Keep adaptive probing inside the U1'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

fun applyInitialTool(path: String, initialTool: Int): Result {
val file = File(path)
Expand Down Expand Up @@ -89,6 +103,91 @@ object GcodeToolMapper {
return Result(changed, if (mask == 0) 1 shl tool else mask)
}

fun clampU1BedMeshBounds(path: String): BedMeshResult {
val file = File(path)
if (!file.exists() || !file.isFile) return BedMeshResult(false, false)

var needsRewrite = false
val safeToRewrite = runCatching {
file.bufferedReader().useLines { lines ->
lines.forEach { line ->
val result = rewriteBedMeshLine(line)
if (!result.valid) return@runCatching false
if (result.line != line) needsRewrite = true
}
}
true
}.getOrDefault(false)

if (!safeToRewrite) return BedMeshResult(false, false)
if (!needsRewrite) return BedMeshResult(true, false)

val tmp = File(file.parentFile, file.name + ".bedmesh.tmp")
val rewritten = runCatching {
tmp.bufferedWriter().use { out ->
file.bufferedReader().useLines { lines ->
lines.forEach { line ->
val result = rewriteBedMeshLine(line)
check(result.valid)
out.write(result.line)
out.newLine()
}
}
}
true
}.getOrDefault(false)

if (!rewritten) {
tmp.delete()
return BedMeshResult(false, false)
}
if (!tmp.renameTo(file)) {
tmp.delete()
return BedMeshResult(false, false)
}
return BedMeshResult(true, true)
}

private fun rewriteBedMeshLine(line: String): BedMeshLineResult {
val trimmed = line.trimStart()
if (!trimmed.startsWith("BED_MESH_CALIBRATE", ignoreCase = true)) {
return BedMeshLineResult(line, true)
}

val match = bedMeshBounds.find(line) ?: return BedMeshLineResult(line, true)
val minX = match.groupValues[2].toDoubleOrNull()
val minY = match.groupValues[3].toDoubleOrNull()
val maxX = match.groupValues[5].toDoubleOrNull()
val maxY = match.groupValues[6].toDoubleOrNull()
if (minX == null || minY == null || maxX == null || maxY == null) {
return BedMeshLineResult(line, false)
}

val safeX = safeAxisBounds(minX, maxX) ?: return BedMeshLineResult(line, false)
val safeY = safeAxisBounds(minY, maxY) ?: return BedMeshLineResult(line, false)
if (safeX.first == minX && safeX.second == maxX &&
safeY.first == minY && safeY.second == maxY
) {
return BedMeshLineResult(line, true)
}

val replacement = "${match.groupValues[1]}${formatCoordinate(safeX.first)},${formatCoordinate(safeY.first)}" +
"${match.groupValues[4]}${formatCoordinate(safeX.second)},${formatCoordinate(safeY.second)}"
return BedMeshLineResult(line.replaceRange(match.range, replacement), true)
}

private fun safeAxisBounds(rawMin: Double, rawMax: Double): Pair<Double, Double>? {
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)
return min to (min + span)
}

private fun formatCoordinate(value: Double): String =
String.format(Locale.US, "%.5f", value)

/** Tool bits used by [line]; 0 when inside a thumbnail block or a comment. */
private fun scanToolMaskLine(line: String, inThumbnail: Boolean): Int {
val trimmed = line.trimStart()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ object HelixSliceRunner {
val material: String,
val brand: String,
val nozzleTemp: Int,
val maxVolumetricSpeed: Float,
val pressureAdvance: Float,
)

fun parseMaterialProfiles(json: String?): List<MaterialProfile> {
Expand All @@ -47,6 +49,8 @@ object HelixSliceRunner {
material = item.optString("material", "PLA"),
brand = item.optString("brand", "Generic"),
nozzleTemp = item.optInt("nozzleTemp", 220).coerceIn(160, 300),
maxVolumetricSpeed = item.optDouble("maxVolumetricSpeed", 12.0).toFloat().coerceIn(1f, 30f),
pressureAdvance = item.optDouble("pressureAdvance", 0.0).toFloat().coerceIn(0f, 1f),
)
}
}.getOrDefault(emptyList())
Expand Down Expand Up @@ -128,6 +132,10 @@ object HelixSliceRunner {
}

if (result != null && result.success && result.gcodePath.isNotBlank()) {
val bedMesh = GcodeToolMapper.clampU1BedMeshBounds(result.gcodePath)
check(bedMesh.success) {
"Could not safely constrain the adaptive bed-mesh bounds"
}
onProgress(99, "applying first-layer flow limit")
val guard = GcodeFirstLayerGuard.apply(result.gcodePath)
check(guard.success) {
Expand Down Expand Up @@ -214,6 +222,8 @@ object HelixSliceRunner {
filamentTypes = Array(4) { index -> normalized[index].material }
extruderTemps = IntArray(4) { index -> normalized[index].nozzleTemp }
nozzleTemp = normalized[selectedTool].nozzleTemp
filamentMaxVolumetricSpeeds = FloatArray(4) { index -> normalized[index].maxVolumetricSpeed }
filamentPressureAdvances = FloatArray(4) { index -> normalized[index].pressureAdvance }
}

/** Size the config for [count] filaments (U1 has 4 ACE slots) so the engine keeps
Expand Down
Loading