Merge branch 'master' of 192.168.1.103:fenopy/KX-Bridge-Release

This commit is contained in:
2026-06-03 06:14:14 -05:00
33 changed files with 1297 additions and 4 deletions

9
.gitignore vendored
View File

@@ -17,3 +17,12 @@ config/*.ini
data/
!data/orca_filaments.json
# Android build artifacts
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/**/*.iml
android/.idea/
android/app/release/

View File

@@ -0,0 +1,57 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.kxbridge"
compileSdk = 34
defaultConfig {
applicationId = "com.kxbridge"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
debugImplementation("androidx.compose.ui:ui-tooling")
}

2
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
-keep class com.kxbridge.data.model.** { *; }
-keepattributes *Annotation*

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:icon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.KxBridge"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,56 @@
package com.kxbridge
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.*
import com.kxbridge.ui.PrinterScreen
import com.kxbridge.ui.SetupScreen
import com.kxbridge.ui.theme.KxBridgeTheme
import com.kxbridge.viewmodel.PrinterViewModel
private const val PREFS = "kxbridge"
private const val KEY_URL = "server_url"
class MainActivity : ComponentActivity() {
private val viewModel: PrinterViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val savedUrl = prefs.getString(KEY_URL, null)
if (savedUrl != null) {
viewModel.connect(savedUrl)
}
setContent {
KxBridgeTheme {
var serverUrl by remember { mutableStateOf(savedUrl ?: "") }
var showSetup by remember { mutableStateOf(savedUrl == null) }
if (showSetup) {
SetupScreen(
initialUrl = serverUrl,
onConnect = { url ->
prefs.edit().putString(KEY_URL, url).apply()
serverUrl = url
showSetup = false
viewModel.connect(url)
},
)
} else {
PrinterScreen(
viewModel = viewModel,
onChangeServer = { showSetup = true },
)
}
}
}
}
}

View File

@@ -0,0 +1,594 @@
package com.kxbridge.ui
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.kxbridge.data.model.AmsSlot
import com.kxbridge.data.model.PrinterState
import com.kxbridge.ui.theme.Blue300
import com.kxbridge.ui.theme.ErrorRed
import com.kxbridge.ui.theme.Orange400
import com.kxbridge.viewmodel.PrinterViewModel
import com.kxbridge.viewmodel.UiState
import kotlin.math.roundToInt
private enum class TempTarget { NOZZLE, BED }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrinterScreen(
viewModel: PrinterViewModel,
onChangeServer: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsState()
val cameraFrame by viewModel.cameraFrame.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
var showCancelDialog by remember { mutableStateOf(false) }
if (showCancelDialog) {
AlertDialog(
onDismissRequest = { showCancelDialog = false },
title = { Text("Stop print?") },
text = { Text("This will stop the current print job.") },
confirmButton = {
TextButton(onClick = {
showCancelDialog = false
viewModel.cancel()
}) { Text("Stop print", color = ErrorRed) }
},
dismissButton = {
TextButton(onClick = { showCancelDialog = false }) { Text("Keep printing") }
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
val name = (uiState as? UiState.Success)?.state?.printerName
?.takeIf { it.isNotBlank() } ?: "KX-Bridge"
Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
actions = {
IconButton(onClick = onChangeServer) {
Icon(Icons.Default.Settings, contentDescription = "Change server")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
when (val s = uiState) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Error -> ErrorContent(s.message)
is UiState.Success -> PrinterContent(
state = s.state,
cameraFrame = cameraFrame,
cameraEnabled = cameraEnabled,
onPause = viewModel::pause,
onResume = viewModel::resume,
onCancel = { showCancelDialog = true },
onSetTemp = viewModel::setTemperature,
onToggleLight = { on -> viewModel.setLight(on) },
onToggleCamera = viewModel::setCameraEnabled,
)
}
}
}
}
@Composable
private fun ErrorContent(message: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Cannot reach bridge", style = MaterialTheme.typography.titleMedium, color = ErrorRed)
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
}
}
@Composable
private fun PrinterContent(
state: PrinterState,
cameraFrame: Bitmap?,
cameraEnabled: Boolean,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
onSetTemp: (nozzle: Double, bed: Double) -> Unit,
onToggleLight: (Boolean) -> Unit,
onToggleCamera: (Boolean) -> Unit,
) {
var tempDialog by remember { mutableStateOf<TempTarget?>(null) }
if (tempDialog != null) {
TempDialog(
target = tempDialog!!,
currentNozzle = state.nozzleTarget,
currentBed = state.bedTarget,
onConfirm = { nozzle, bed ->
onSetTemp(nozzle, bed)
tempDialog = null
},
onDismiss = { tempDialog = null },
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
StatusBadge(state.printState)
CameraCard(
cameraFrame = cameraFrame,
cameraEnabled = cameraEnabled,
onToggle = onToggleCamera,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TempCard(
label = "Nozzle",
current = state.nozzleTemp,
target = state.nozzleTarget,
activeColor = Orange400,
modifier = Modifier.weight(1f),
onClick = { tempDialog = TempTarget.NOZZLE },
)
TempCard(
label = "Bed",
current = state.bedTemp,
target = state.bedTarget,
activeColor = Blue300,
modifier = Modifier.weight(1f),
onClick = { tempDialog = TempTarget.BED },
)
}
if (state.isActive || state.printState == "complete") {
PrintProgressCard(state)
}
if (state.isActive) {
PrintControls(
isPrinting = state.isPrinting,
onPause = onPause,
onResume = onResume,
onCancel = onCancel,
)
}
LightCard(
isOn = state.lightOn,
brightness = state.lightBrightness,
onToggle = onToggleLight,
)
if (state.amsSlots.isNotEmpty()) {
FilamentCard(slots = state.amsSlots, mode = state.filamentMode)
}
}
}
@Composable
private fun StatusBadge(printState: String) {
val (label, color) = when (printState) {
"printing" -> "Printing" to Orange400
"paused" -> "Paused" to Blue300
"complete" -> "Complete" to Color(0xFF66BB6A)
"error" -> "Error" to ErrorRed
"cancelled" -> "Cancelled" to MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
else -> "Standby" to MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
}
Surface(
color = color.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small,
) {
Text(
text = label,
color = color,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun CameraCard(
cameraFrame: Bitmap?,
cameraEnabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Camera",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Switch(checked = cameraEnabled, onCheckedChange = onToggle)
}
if (cameraEnabled) {
if (cameraFrame != null) {
Image(
bitmap = cameraFrame.asImageBitmap(),
contentDescription = "Camera stream",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f),
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
}
}
@Composable
private fun TempCard(
label: String,
current: Double,
target: Double,
activeColor: Color,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
val isHeating = target > 0.0
val indicatorColor = if (isHeating) activeColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
Card(
onClick = onClick,
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
)
Text(
"Tap to set",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
)
}
Text(
text = "${current.roundToInt()}°",
style = MaterialTheme.typography.headlineMedium,
color = indicatorColor,
fontWeight = FontWeight.Bold,
)
Text(
text = if (isHeating) "${target.roundToInt()}°" else "Off",
style = MaterialTheme.typography.bodySmall,
color = indicatorColor.copy(alpha = 0.7f),
)
}
}
}
@Composable
private fun PrintProgressCard(state: PrinterState) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (state.filename.isNotBlank()) {
Text(
text = state.filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
val pct = (state.progress * 100).roundToInt()
LinearProgressIndicator(
progress = { state.progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("$pct%", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
val curr = state.currLayer
val total = state.totalLayers
if (curr != null && total != null && total > 0) {
Text(
"Layer $curr / $total",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
)
}
}
val remain = state.remainTime
if (state.printDuration > 0 || (remain != null && remain > 0)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.printDuration > 0) {
TimeLabel(
label = "Elapsed",
seconds = state.printDuration.toInt(),
modifier = Modifier.weight(1f),
)
}
if (remain != null && remain > 0) {
val estimate = state.printDuration.toInt() + remain
TimeLabel(label = "Est. Total", seconds = estimate, modifier = Modifier.weight(1f))
TimeLabel(label = "Remaining", seconds = remain, modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Composable
private fun TimeLabel(label: String, seconds: Int, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
Text(
formatDuration(seconds),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
)
}
}
private fun formatDuration(totalSeconds: Int): String {
val h = totalSeconds / 3600
val m = (totalSeconds % 3600) / 60
val s = totalSeconds % 60
return when {
h > 0 -> "${h}h ${m}m"
m > 0 -> "${m}m ${s}s"
else -> "${s}s"
}
}
@Composable
private fun PrintControls(
isPrinting: Boolean,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isPrinting) {
Button(onClick = onPause, modifier = Modifier.weight(1f)) { Text("Pause") }
} else {
Button(onClick = onResume, modifier = Modifier.weight(1f)) { Text("Resume") }
}
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = ErrorRed),
) {
Text("Stop")
}
}
}
@Composable
private fun LightCard(isOn: Boolean, brightness: Int, onToggle: (Boolean) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Light", style = MaterialTheme.typography.labelLarge)
Text(
if (isOn) "$brightness%" else "Off",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
}
Switch(
checked = isOn,
onCheckedChange = onToggle,
colors = SwitchDefaults.colors(
checkedThumbColor = Orange400,
checkedTrackColor = Orange400.copy(alpha = 0.5f),
),
)
}
}
}
@Composable
private fun FilamentCard(slots: List<AmsSlot>, mode: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = when (mode) {
"ace_direct" -> "AMS Direct"
"ace_hub" -> "AMS Hub"
else -> "Filament"
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
slots.forEach { slot -> FilamentSlot(slot, Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun FilamentSlot(slot: AmsSlot, modifier: Modifier = Modifier) {
val slotColor = if (slot.color.size >= 3) {
Color(slot.color[0] / 255f, slot.color[1] / 255f, slot.color[2] / 255f)
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(32.dp)
.background(slotColor, shape = CircleShape),
)
Text(
text = slot.type.ifBlank { "" },
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (slot.brand.isNotBlank()) {
Text(
text = slot.brand,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun TempDialog(
target: TempTarget,
currentNozzle: Double,
currentBed: Double,
onConfirm: (nozzle: Double, bed: Double) -> Unit,
onDismiss: () -> Unit,
) {
val label = if (target == TempTarget.NOZZLE) "Nozzle" else "Bed"
val currentValue = if (target == TempTarget.NOZZLE) currentNozzle else currentBed
var input by remember { mutableStateOf(currentValue.roundToInt().toString()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Set $label Temperature") },
text = {
OutlinedTextField(
value = input,
onValueChange = { input = it.filter { c -> c.isDigit() } },
label = { Text("Temperature (°C)") },
suffix = { Text("°C") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = {
val newTemp = input.toDoubleOrNull() ?: currentValue
if (target == TempTarget.NOZZLE) onConfirm(newTemp, currentBed)
else onConfirm(currentNozzle, newTemp)
}) { Text("Set") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}

View File

@@ -0,0 +1,70 @@
package com.kxbridge.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@Composable
fun SetupScreen(initialUrl: String = "", onConnect: (String) -> Unit) {
var url by remember { mutableStateOf(initialUrl.ifBlank { "http://" }) }
fun submit() {
val trimmed = url.trim()
if (trimmed.length > 7) onConnect(trimmed)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "KX-Bridge",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Enter your bridge server URL",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(40.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Server URL") },
placeholder = { Text("http://192.168.1.x:7125") },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { submit() }),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
Button(
onClick = ::submit,
modifier = Modifier.fillMaxWidth(),
enabled = url.trim().length > 7,
) {
Text("Connect")
}
}
}

View File

@@ -0,0 +1,13 @@
package com.kxbridge.ui.theme
import androidx.compose.ui.graphics.Color
val Orange400 = Color(0xFFFF6B35)
val Orange600 = Color(0xFFE84E0F) // darker for light-mode contrast on white
val Blue300 = Color(0xFF4FC3F7)
val Blue600 = Color(0xFF0288D1) // darker for light-mode contrast on white
val Surface900 = Color(0xFF121212)
val Surface800 = Color(0xFF1E1E1E)
val Surface700 = Color(0xFF2C2C2C)
val OnSurfaceDark = Color(0xFFE0E0E0)
val ErrorRed = Color(0xFFCF6679)

View File

@@ -0,0 +1,30 @@
package com.kxbridge.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val DarkColors = darkColorScheme(
primary = Orange400,
secondary = Blue300,
background = Surface900,
surface = Surface800,
surfaceVariant = Surface700,
onBackground = OnSurfaceDark,
onSurface = OnSurfaceDark,
error = ErrorRed,
)
private val LightColors = lightColorScheme(
primary = Orange600,
secondary = Blue600,
error = ErrorRed,
)
@Composable
fun KxBridgeTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
MaterialTheme(colorScheme = colors, content = content)
}

View File

@@ -0,0 +1,83 @@
package com.kxbridge.viewmodel
import android.graphics.Bitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kxbridge.data.PrinterRepository
import com.kxbridge.data.model.PrinterState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
sealed interface UiState {
data object Loading : UiState
data class Error(val message: String) : UiState
data class Success(val state: PrinterState) : UiState
}
class PrinterViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
private val _cameraFrame = MutableStateFlow<Bitmap?>(null)
val cameraFrame: StateFlow<Bitmap?> = _cameraFrame
private val _cameraEnabled = MutableStateFlow(false)
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private var pollJob: Job? = null
private var cameraJob: Job? = null
private var repo: PrinterRepository? = null
fun connect(baseUrl: String) {
pollJob?.cancel()
repo = PrinterRepository(baseUrl.trimEnd('/'))
_uiState.value = UiState.Loading
pollJob = viewModelScope.launch(Dispatchers.IO) {
repo!!.stateFlow().collect { result ->
_uiState.value = result.fold(
onSuccess = { UiState.Success(it) },
onFailure = { UiState.Error(it.message ?: "Connection failed") },
)
}
}
}
fun pause() = command { it.pause() }
fun resume() = command { it.resume() }
fun cancel() = command { it.cancel() }
fun setLight(on: Boolean, brightness: Int = 80) = command { it.setLight(on, brightness) }
fun setTemperature(nozzle: Double, bed: Double) = command { it.setTemperature(nozzle, bed) }
fun setCameraEnabled(enabled: Boolean) {
_cameraEnabled.value = enabled
if (enabled) {
command { it.cameraStart() }
cameraJob?.cancel()
cameraJob = viewModelScope.launch(Dispatchers.IO) {
repo?.cameraFrames()?.collect { _cameraFrame.value = it }
}
} else {
cameraJob?.cancel()
cameraJob = null
_cameraFrame.value = null
command { it.cameraStop() }
}
}
override fun onCleared() {
super.onCleared()
cameraJob?.cancel()
if (_cameraEnabled.value) {
viewModelScope.launch(Dispatchers.IO) { repo?.cameraStop() }
}
}
private fun command(block: (PrinterRepository) -> Unit) {
viewModelScope.launch(Dispatchers.IO) { repo?.let(block) }
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#121212" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Nozzle body -->
<path
android:fillColor="#FF6B35"
android:pathData="M38,28 L70,28 L70,62 L60,62 L60,72 L54,80 L48,72 L48,62 L38,62 Z" />
<!-- Nozzle tip -->
<path
android:fillColor="#FF6B35"
android:pathData="M48,72 L54,80 L60,72 Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KxBridge" parent="android:Theme.Material.NoActionBar" />
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">KX-Bridge</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KxBridge" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

6
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.3.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "KX-Bridge"
include(":app")

View File

@@ -166,6 +166,30 @@ def list_printers() -> list[dict]:
return printers
def list_notification_urls() -> list[dict]:
"""Reads [{url, events: [str]}] from the [notifications] section of config.ini."""
path = _find_config_file()
if not path:
return []
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
if not cfg.has_section("notifications"):
return []
result = []
idx = 1
while True:
url_key = f"url_{idx}"
if not cfg.has_option("notifications", url_key):
break
url = cfg.get("notifications", url_key).strip()
if url:
events_str = cfg.get("notifications", f"events_{idx}", fallback="finished,failed,cancelled")
events = [e.strip() for e in events_str.split(",") if e.strip()]
result.append({"url": url, "events": events})
idx += 1
return result
def list_filament_profiles() -> dict[int, dict]:
"""Liest die [filament_profiles]-Sektion aus config.ini.

View File

@@ -799,6 +799,12 @@ class KobraXBridge:
self._current_job_id: str = ""
self._camera_autostarted: bool = False
self.camera_cache: CameraCache = CameraCache()
self._prev_kobra_state: str = ""
try:
import config_loader as _cl2
self._notification_urls: list[dict] = _cl2.list_notification_urls()
except Exception:
self._notification_urls = []
self._thumbnail_b64: str = ""
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
@@ -891,6 +897,39 @@ class KobraXBridge:
out[key]["name"] = name or str(d.get("name", "Custom"))
return out
# -------------------------------------------------------------------------
# Notifications (apprise)
# -------------------------------------------------------------------------
def _notify(self, event: str, filename: str = ""):
urls = [e["url"] for e in self._notification_urls if event in e.get("events", [])]
if not urls:
return
printer_name = self._state.get("printer_name", "KX-Bridge")
titles = {
"finished": "Print Finished ✓",
"failed": "Print Failed ✗",
"cancelled": "Print Cancelled",
"paused": "Print Paused",
}
title = titles.get(event, "KX-Bridge")
body = filename if filename else printer_name
if filename and printer_name:
body = f"{filename}\n{printer_name}"
def _send():
try:
import apprise
ap = apprise.Apprise()
for url in urls:
ap.add(url)
ap.notify(title=title, body=body)
log.info(f"Benachrichtigung '{event}' gesendet ({len(urls)} URL(s))")
except Exception as e:
log.warning(f"Benachrichtigung fehlgeschlagen: {e}")
threading.Thread(target=_send, daemon=True).start()
# -------------------------------------------------------------------------
# MQTT callbacks (called from reader thread)
# -------------------------------------------------------------------------
@@ -946,6 +985,19 @@ class KobraXBridge:
log.info(f"Job abgebrochen: {self._current_job_id}")
self._current_job_id = ""
# Benachrichtigungen bei Statuswechsel (filename vor Cleanup holen)
if kobra_state and kobra_state != self._prev_kobra_state:
_notif_filename = d.get("filename", self._state.get("filename", ""))
if kobra_state == "finished":
self._notify("finished", _notif_filename)
elif kobra_state == "failed":
self._notify("failed", _notif_filename)
elif kobra_state in ("stoped", "canceled"):
self._notify("cancelled", _notif_filename)
elif kobra_state == "paused":
self._notify("paused", _notif_filename)
self._prev_kobra_state = kobra_state
# Nach Druckende das Upload-Banner verschwinden lassen (Issue #29): der
# Drucker meldet "finished" nach erfolgreichem Druck — file_ready wurde
# bisher nur bei stoped/canceled geleert, dadurch kam das Banner zurück.
@@ -3737,6 +3789,7 @@ class KobraXBridge:
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"ace_dry_presets": self._ace_dry_presets,
"notifications": self._notification_urls,
})
async def handle_api_settings_post(self, request):
@@ -3783,6 +3836,28 @@ class KobraXBridge:
cfg.set("ace_dry_presets", f"{key}_name", str(val.get("name", key.replace("_", " ").title())))
self._ace_dry_presets = presets
# Benachrichtigungen speichern
if cfg.has_section("notifications"):
cfg.remove_section("notifications")
incoming_notifs = data.get("notifications")
if isinstance(incoming_notifs, list):
valid_events = {"finished", "failed", "cancelled", "paused"}
entries = []
for entry in incoming_notifs:
if not isinstance(entry, dict):
continue
url = str(entry.get("url", "")).strip()
if not url:
continue
events = [e for e in entry.get("events", []) if e in valid_events]
entries.append({"url": url, "events": events})
if entries:
cfg.add_section("notifications")
for i, entry in enumerate(entries, 1):
cfg.set("notifications", f"url_{i}", entry["url"])
cfg.set("notifications", f"events_{i}", ",".join(entry["events"]))
self._notification_urls = entries
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n\n")
cfg.write(f)
@@ -3792,6 +3867,31 @@ class KobraXBridge:
asyncio.get_event_loop().call_later(0.3, self._restart_bridge)
return response
async def handle_api_notifications_test(self, request):
try:
data = await request.json()
except Exception:
return self._json_cors({"status": "error", "message": "invalid json"}, status=400)
url = str(data.get("url", "")).strip()
if not url:
return self._json_cors({"status": "error", "message": "url required"}, status=400)
printer_name = self._state.get("printer_name", "KX-Bridge")
try:
import apprise
ap = apprise.Apprise()
ap.add(url)
ok = await asyncio.get_event_loop().run_in_executor(
None,
lambda: ap.notify(title="KX-Bridge Test", body=f"Test notification from {printer_name}"),
)
if ok is False:
return self._json_cors({"status": "error", "message": "Notification failed (check URL)"})
return self._json_cors({"status": "ok"})
except ImportError:
return self._json_cors({"status": "error", "message": "apprise not installed"}, status=500)
except Exception as e:
return self._json_cors({"status": "error", "message": str(e)}, status=500)
async def handle_kx_printer_add(self, request):
"""Fügt einen Drucker hinzu: holt Credentials via IP, schreibt [printer_N], Neustart."""
try:
@@ -4627,6 +4727,7 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_get("/api/state", bridge.handle_api_state)
r.add_get("/api/settings", bridge.handle_api_settings_get)
r.add_post("/api/settings", bridge.handle_api_settings_post)
r.add_post("/api/notifications/test", bridge.handle_api_notifications_test)
r.add_get("/api/update/check", bridge.handle_api_update_check)
r.add_post("/api/update/apply", bridge.handle_api_update_apply)
r.add_post("/api/file_ready/clear", bridge.handle_api_file_ready_clear)

View File

@@ -16,6 +16,12 @@ datas += _d
binaries += _b
hiddenimports += _h
# apprise — alle Notification-Plugins (dynamisch geladen via entry_points)
_d, _b, _h = collect_all("apprise")
datas += _d
binaries += _b
hiddenimports += _h
a = Analysis(
["kobrax_moonraker_bridge.py"],
pathex=[],

View File

@@ -2,3 +2,4 @@ aiohttp>=3.9
imageio-ffmpeg>=0.4.9
requests>=2.30.0
pycryptodome>=3.20.0
apprise>=1.8

View File

@@ -311,6 +311,9 @@ function applyLang(){
setText('modal-sec-connection',T.settings_connection);
setText('modal-sec-print',T.settings_print);
setText('modal-sec-poll',T.settings_poll);
setText('modal-sec-notifications',T.settings_notifications);
setText('notif-hint',T.settings_notifications_hint);
setText('lbl-notif-add',T.settings_notif_add);
setText('modal-sec-version',T.settings_version);
// Custom-Profile-Import (Issue #41)
setText('modal-sec-orca-profiles',T.orca_profile_section);
@@ -883,6 +886,82 @@ function drawChart(id,_,series){
});
}
// ── Notifications ──
var _notifRows=[];
var _NOTIF_EVENTS=['finished','failed','cancelled','paused'];
function notifRenderList(entries){
_notifRows=entries.map(function(e){return {url:e.url||'',events:e.events||[]};});
notifRefreshDOM();
}
function notifRefreshDOM(){
var container=document.getElementById('notif-list');
if(!container)return;
if(!_notifRows.length){
container.innerHTML='<div style="font-size:11px;color:var(--txt2);padding:4px 0" id="notif-empty">'+(T.settings_notif_empty||'No notifications configured.')+'</div>';
return;
}
container.innerHTML=_notifRows.map(function(row,idx){
var evChecks=_NOTIF_EVENTS.map(function(ev){
var checked=row.events.indexOf(ev)>=0?'checked':'';
var lbl=T['settings_notif_ev_'+ev]||ev;
return '<label style="display:inline-flex;align-items:center;gap:3px;font-size:11px;cursor:pointer">'
+'<input type="checkbox" data-notif-idx="'+idx+'" data-notif-ev="'+ev+'" '+checked
+' onchange="notifToggleEvent('+idx+',\''+ev+'\')" style="width:auto;margin:0"> '+lbl+'</label>';
}).join(' ');
return '<div style="background:var(--raised);border-radius:6px;padding:8px;margin-bottom:6px">'
+'<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px">'
+'<input type="text" value="'+_escHtml(row.url)+'" placeholder="discord://… telegram://… gotify://…"'
+' oninput="notifSetUrl('+idx+',this.value)" style="flex:1;font-family:var(--mono);font-size:11px">'
+'<button onclick="notifTest('+idx+')" style="background:var(--raised2,var(--raised));border:1px solid var(--border);color:var(--txt);border-radius:4px;cursor:pointer;padding:2px 8px;font-size:11px" id="notif-test-btn-'+idx+'">'+(T.settings_notif_test||'Test')+'</button>'
+'<button onclick="notifRemoveRow('+idx+')" style="background:none;border:none;color:var(--err);cursor:pointer;font-size:16px;line-height:1" title="remove">✕</button>'
+'</div>'
+'<div style="display:flex;flex-wrap:wrap;gap:8px">'+evChecks+'</div>'
+'<div id="notif-test-status-'+idx+'" style="font-size:11px;margin-top:4px"></div>'
+'</div>';
}).join('');
}
function _escHtml(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function notifAddRow(){
_notifRows.push({url:'',events:['finished','failed']});
notifRefreshDOM();
}
function notifRemoveRow(idx){
_notifRows.splice(idx,1);
notifRefreshDOM();
}
function notifSetUrl(idx,val){
if(_notifRows[idx])_notifRows[idx].url=val;
}
function notifToggleEvent(idx,ev){
if(!_notifRows[idx])return;
var evs=_notifRows[idx].events;
var pos=evs.indexOf(ev);
if(pos>=0)evs.splice(pos,1);else evs.push(ev);
}
function notifCollect(){
return _notifRows.filter(function(r){return r.url.trim();}).map(function(r){
return {url:r.url.trim(),events:r.events};
});
}
function notifTest(idx){
var row=_notifRows[idx];
if(!row||!row.url.trim())return;
var btn=document.getElementById('notif-test-btn-'+idx);
var status=document.getElementById('notif-test-status-'+idx);
if(btn)btn.disabled=true;
if(status){status.textContent='…';status.style.color='var(--txt2)';}
post('/api/notifications/test',{url:row.url.trim()}).then(function(r){return r.json();}).then(function(d){
if(btn)btn.disabled=false;
if(status){
if(d&&d.status==='ok'){status.textContent='✓ '+(T.settings_notif_test_ok||'Sent');status.style.color='var(--ok)';}
else{status.textContent='✗ '+(d&&d.message?d.message:(T.settings_notif_test_fail||'Failed'));status.style.color='var(--err)';}
}
}).catch(function(e){
if(btn)btn.disabled=false;
if(status){status.textContent='✗ '+e;status.style.color='var(--err)';}
});
}
// ── Settings Modal ──
var _updateTag='';
var _updateUrl='';
@@ -903,6 +982,7 @@ function openSettings(){
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
notifRenderList(d.notifications||[]);
});
var v=localStorage.getItem('pollInterval')||'2000';
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
@@ -1272,6 +1352,7 @@ function saveSettings(){
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
web_upload_warning:webUploadWarning,
notifications: notifCollect(),
}).then(function(){
btn.textContent=T.update_restarting;
setTimeout(function(){

View File

@@ -134,6 +134,16 @@
</button>
</div>
<div>
<div class="modal-section" id="modal-sec-notifications">Notifications</div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:8px" id="notif-hint"></div>
<div id="notif-list" style="margin-bottom:8px"></div>
<button class="btn btn-sm" id="btn-notif-add" onclick="notifAddRow()"
style="background:var(--raised);color:var(--txt)">
+ <span id="lbl-notif-add">Add notification</span>
</button>
</div>
<div>
<div class="modal-section" id="modal-sec-version">Version</div>
<div class="update-row">

View File

@@ -243,5 +243,16 @@
"sf_new": "Neu",
"ss_date": "↓ Datum",
"ss_name": "AZ Name",
"ss_dur": "⏱ Druckzeit"
"ss_dur": "⏱ Druckzeit",
"settings_notifications": "Benachrichtigungen",
"settings_notifications_hint": "Benachrichtigungen über Apprise-URLs senden (discord://, telegram://, gotify://, slack://, …)",
"settings_notif_add": "Benachrichtigung hinzufügen",
"settings_notif_empty": "Keine Benachrichtigungen konfiguriert.",
"settings_notif_test": "Test",
"settings_notif_test_ok": "Gesendet",
"settings_notif_test_fail": "Fehlgeschlagen",
"settings_notif_ev_finished": "Fertig",
"settings_notif_ev_failed": "Fehler",
"settings_notif_ev_cancelled": "Abgebrochen",
"settings_notif_ev_paused": "Pausiert"
}

View File

@@ -243,5 +243,16 @@
"sf_new": "New",
"ss_date": "↓ Date",
"ss_name": "AZ Name",
"ss_dur": "⏱ Print time"
"ss_dur": "⏱ Print time",
"settings_notifications": "Notifications",
"settings_notifications_hint": "Send notifications via Apprise URLs (discord://, telegram://, gotify://, slack://, …)",
"settings_notif_add": "Add notification",
"settings_notif_empty": "No notifications configured.",
"settings_notif_test": "Test",
"settings_notif_test_ok": "Sent",
"settings_notif_test_fail": "Failed",
"settings_notif_ev_finished": "Finished",
"settings_notif_ev_failed": "Failed",
"settings_notif_ev_cancelled": "Cancelled",
"settings_notif_ev_paused": "Paused"
}

View File

@@ -243,5 +243,16 @@
"sf_new": "Nuevo",
"ss_date": "↓ Fecha",
"ss_name": "AZ Nombre",
"ss_dur": "⏱ Tiempo de impresión"
"ss_dur": "⏱ Tiempo de impresión",
"settings_notifications": "Notificaciones",
"settings_notifications_hint": "Enviar notificaciones via URLs de Apprise (discord://, telegram://, gotify://, slack://, …)",
"settings_notif_add": "Añadir notificación",
"settings_notif_empty": "No hay notificaciones configuradas.",
"settings_notif_test": "Probar",
"settings_notif_test_ok": "Enviado",
"settings_notif_test_fail": "Fallido",
"settings_notif_ev_finished": "Terminado",
"settings_notif_ev_failed": "Error",
"settings_notif_ev_cancelled": "Cancelado",
"settings_notif_ev_paused": "Pausado"
}

View File

@@ -243,5 +243,16 @@
"sf_new": "新",
"ss_date": "↓ 日期",
"ss_name": "AZ 名称",
"ss_dur": "⏱ 打印时间"
"ss_dur": "⏱ 打印时间",
"settings_notifications": "通知",
"settings_notifications_hint": "通过 Apprise URL 发送通知 (discord://, telegram://, gotify://, slack://, …)",
"settings_notif_add": "添加通知",
"settings_notif_empty": "未配置通知。",
"settings_notif_test": "测试",
"settings_notif_test_ok": "已发送",
"settings_notif_test_fail": "失败",
"settings_notif_ev_finished": "完成",
"settings_notif_ev_failed": "失败",
"settings_notif_ev_cancelled": "已取消",
"settings_notif_ev_paused": "已暂停"
}