Compare commits

...

21 Commits

Author SHA1 Message Date
StevenBuzzi
628f7544be 부팅 시 serial packet이 전송되지 않는 버그 수정 2026-03-04 17:11:09 +09:00
steven
90c0a93a5e 부팅 시 가이드빔 패킷 맞도록 수정 2026-03-03 22:31:33 +09:00
69c22322c8 Merge pull request '삭제 버튼 위치 조정 / 프리셋 버튼 관련 수정' (#41) from feature/ISSUE-34 into develop
Reviewed-on: #41
2026-03-03 11:08:58 +00:00
areumwoo
28f0430e1d feat: 빠진 이미지 추가 2026-03-03 19:09:54 +09:00
fac756eacb Merge pull request 'Guide beam packet 오전송 수정' (#47) from feature/ISSUE-5 into develop
Reviewed-on: #47
2026-03-03 08:55:48 +00:00
StevenBuzzi
d0b8a78847 Guide beam packet 오전송 수정
1. Engineer; Min/Max에 표기된 값을 전송하도록 수정
2. Config; Guide beam Step에 맞게 전송하도록 수정
2026-03-03 17:40:25 +09:00
a817a5f14f Merge pull request 'LifeTimeView Count/Hour 분리' (#39) from feature/ISSUE-38 into develop
Reviewed-on: #39
2026-03-03 06:23:35 +00:00
StevenBuzzi
2099f4d178 1. Modified the range of applied item
2. Adjust padding @ Life Time, Temp
2026-03-03 14:44:09 +09:00
f7fca4219a Merge pull request 'Gradient 슬라이더 계산 로직 수정' (#40) from feature/ISSUE-20 into develop
Reviewed-on: #40
2026-03-03 05:01:57 +00:00
9da71a962b Merge pull request 'Enforce standby when lamp lifetime exceeded' (#37) from feature/ISSUE-11 into develop
Reviewed-on: #37
2026-03-03 04:36:08 +00:00
d8bd898442 Merge pull request '우선순위 중복 시 기존 항목을 NONE으로 초기화하도록 수정' (#36) from feature/ISSUE-10 into develop
Reviewed-on: #36
2026-03-03 03:36:58 +00:00
a97d5e4354 Merge pull request 'InfoScreen 체크박스 상태 저장' (#33) from feature/ISSUE-7 into develop
Reviewed-on: #33
2026-03-03 02:04:27 +00:00
areumwoo
6cba0674cf 프리셋 저장 버튼/로드 버튼 크기 조정 2026-03-02 16:57:10 +09:00
areumwoo
b87d911cea 삭제 버튼 위치 및 여백 조정 2026-03-02 16:41:38 +09:00
areumwoo
1fc9640e38 Gradient 슬라이더 계산 로직 수정 2026-03-02 16:27:04 +09:00
areumwoo
00f2ec73d5 LifeTimeView Count/Hour 분리 2026-03-02 15:22:47 +09:00
areumwoo
f5d7187c0f Enforce standby when lamp lifetime exceeded 2026-03-02 14:54:34 +09:00
areumwoo
3c9a07bf9a feat: 우선순위 중복 시 기존 항목을 NONE으로 초기화하도록 수정 2026-03-02 13:08:44 +09:00
areumwoo
d6efa9bcaa InfoScreen 체크박스 상태 저장 2026-03-02 12:45:05 +09:00
woo
53cd5976bb Merge pull request 'feat: #72 Read/Write 표시 위치 수정 미적용' (#31) from feature/ISSUE-6 into develop
Reviewed-on: #31
Reviewed-by: steven <jbj213@naver.com>
2026-03-02 01:59:50 +00:00
areumwoo
841d9221a6 feat: #72 Read/Write 표시 위치 수정 미적용 2026-03-02 10:50:44 +09:00
19 changed files with 408 additions and 130 deletions

View File

@@ -214,3 +214,10 @@
- `loadFluenceTable()`, `loadHzTable()`, `calculateInterpolatedC()` 영향 확인
- 성능/안정성 이슈 시
- `txPacketLoop()` 주기, `RX_TIMEOUT_THRESHOLD`, DB 로그 적재량 점검
## 10) Troubleshooting
- 시리얼 초기 TX 누락(Startup race, FD 설명, 수정 내역):
- [`docs/serial_tx_startup_race.md`](docs/serial_tx_startup_race.md)
- 변경 요약 문서(`git diff` 기준):
- [`docs/change_summary_2026-03-04.md`](docs/change_summary_2026-03-04.md)

View File

@@ -88,4 +88,7 @@ interface Preference {
suspend fun getPowerSupplySerialListFromPreference(): Flow<List<String>>
/////
suspend fun saveInfoChartLineStates(states: Map<String, Boolean>)
suspend fun getInfoChartLineStates(): Flow<Map<String, Boolean>>
}

View File

@@ -68,6 +68,9 @@ class PreferenceRepository(private val context: Context) : Preference {
val PRODUCT_SERIAL = stringPreferencesKey("PRODUCT_SERIAL")
val LASER_HAND_SERIAL = stringPreferencesKey("LASER_HAND_SERIAL")
val POWER_SUPPLY_SERIAL = stringPreferencesKey("POWER_SUPPLY_SERIAL")
// InfoScreen Chart Checkboxes
val INFO_CHART_LINE_STATES = stringPreferencesKey("INFO_CHART_LINE_STATES")
}
override suspend fun clearAllPreferences() {
@@ -559,4 +562,31 @@ class PreferenceRepository(private val context: Context) : Preference {
emit(listOf("B", "U", "O", "C", "L", "D"))
}
}
override suspend fun saveInfoChartLineStates(states: Map<String, Boolean>) {
try {
val stateJson = gson.toJson(states)
context.datastore.edit { preferences ->
preferences[INFO_CHART_LINE_STATES] = stateJson
}
} catch (e: Exception) {
Timber.e(e, "Failed to serialize INFO_CHART_LINE_STATES to JSON.")
}
}
override suspend fun getInfoChartLineStates(): Flow<Map<String, Boolean>> {
return context.datastore.data.map { preferences ->
preferences[INFO_CHART_LINE_STATES]
}.distinctUntilChanged().map { jsonString ->
if (!jsonString.isNullOrBlank()) {
val type = object : TypeToken<Map<String, Boolean>>() {}.type
gson.fromJson<Map<String, Boolean>>(jsonString, type) ?: emptyMap()
} else {
emptyMap()
}
}.catch { e ->
Timber.e(e, "Failed to get or parse INFO_CHART_LINE_STATES. Emitting default.")
emit(emptyMap())
}
}
}

View File

@@ -260,9 +260,11 @@ class MainActivity : ComponentActivity() {
// This prevents serial interrupts from stealing CPU during the first frame.
delay(200)
vm.txPacketOnce()
// IMPORTANT:
// rxPacketLoop() starts serial open() asynchronously.
// Start RX first so txPacketOnce() is less likely to run before FD is ready.
vm.rxPacketLoop()
vm.txPacketOnce()
vm.txPacketLoop()
Timber.d("System fully operational.")

View File

@@ -439,15 +439,15 @@ fun ConfigScreen(
mainViewModel.saveGuideBeamToPreference()
}
/*
val value = when(guideBeam.toInt()) {
0 -> 0
1 -> guideBeamMin
10 -> guideBeamMax
else -> (guideBeamMin + (guideBeam.toInt() - 1) * ((guideBeamMax - guideBeamMin) / 9))
// Guide Beam step mapping (0~10):
// 0 -> fixed 0
// 1~10 -> min~max range in 10 steps (10 -> max)
val step = guideBeam.toInt().coerceIn(0, 10)
val value = if (step == 0) {
0
} else {
guideBeamMin + ((step-1) * (guideBeamMax - guideBeamMin) / 9)
}
*/
val value = (guideBeamMin + (guideBeam.toInt() - 1) * ((guideBeamMax - guideBeamMin) / 9))
Timber.d("guideBeam: $value, guideBeamMax: $guideBeamMax, guideBeamMin: $guideBeamMin")
mainViewModel.txPacket(READ_WRITE.WRITE, CMD.GUIDE_BEAM, GuideBeam(value = value))

View File

@@ -31,7 +31,7 @@ fun HourItemView(
modifier: Modifier = Modifier,
hour: Int = 0,
onClick: () -> Unit = {},
title:String = ""
title: String = ""
) {
Row(modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,

View File

@@ -32,11 +32,11 @@ fun LifeTimeView(
) {
Column(modifier = Modifier
//.noRippleClickable(onClick = onClick)
.size(388.px.dp, 276.px.dp)
.size(388.px.dp, 258.px.dp)
.clip(RoundedCornerShape(12.px.dp))
.border(width = 1.px.dp, color = Color(209, 209, 209), shape = RoundedCornerShape(10.px.dp))
.background(Color.White)
.padding(16.px.dp),
.padding(3.px.dp, 16.px.dp),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -55,11 +55,11 @@ fun LifeTimeView(
),
)
Spacer(modifier = Modifier.height(10.px.dp))
Spacer(modifier = Modifier.height(2.px.dp))
// Temp 0..7
for (i in 0..lifeTimeTypes.size -1) {
val hour = when (i) {
val value = when (i) {
0 -> lifeTime.lamp
1 -> lifeTime.hp5x5
2 -> lifeTime.hp7x7
@@ -70,22 +70,35 @@ fun LifeTimeView(
7 -> lifeTime.water
else -> 0
}
HourItemView(
modifier = Modifier
.fillMaxSize()
.weight(1f)
.padding(
start = 20.px.dp,
end = 20.px.dp,
//bottom = 10.px.dp
),
title = lifeTimeTypes[i],
hour = hour,
onClick = {
Timber.d("onClick > Temp $i (${lifeTimeTypes[i]})")
onClick.invoke(i)
}
)
val modifier = Modifier
.fillMaxSize()
.weight(1f)
.padding(
start = 20.px.dp,
end = 20.px.dp,
//bottom = 10.px.dp
)
val title = lifeTimeTypes[i]
val onItemClick = {
Timber.d("onClick > Temp $i ($title)")
onClick.invoke(i)
}
if (i <= 5) {
CountItemView(
modifier = modifier,
title = title,
count = value,
onClick = onItemClick
)
} else {
HourItemView(
modifier = modifier,
title = title,
hour = value,
onClick = onItemClick
)
}
if (i < lifeTimeTypes.size -1) {
HorizontalDivider(

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,15 +14,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.laseroptek.raman.const.temperatureTypes
import com.laseroptek.raman.data.model.serial.Temperature
import com.laseroptek.raman.ui.theme.RobotoTypography
import com.laseroptek.raman.utils.ext.px
import timber.log.Timber
@@ -37,7 +42,7 @@ fun TemperatureView(
.clip(RoundedCornerShape(12.px.dp))
.border(width = 1.px.dp, color = Color(209, 209, 209), shape = RoundedCornerShape(10.px.dp))
.background(Color.White)
.padding(16.px.dp),
.padding(3.px.dp, 16.px.dp),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -56,7 +61,33 @@ fun TemperatureView(
),
)
Spacer(modifier = Modifier.height(10.px.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.px.dp, end = 20.px.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.weight(1.5f))
Text(
modifier = Modifier.weight(1f),
text = "Read",
style = RobotoTypography.labelMedium,
fontSize = 12.px.sp,
color = Color.Black,
textAlign = TextAlign.End
)
Text(
modifier = Modifier.weight(1f),
text = "Write",
style = RobotoTypography.labelMedium,
fontSize = 12.px.sp,
color = Color.Black,
textAlign = TextAlign.End
)
Spacer(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(2.px.dp))
// Tempearture
for (i in 0..temperatureTypes.size -1) {

View File

@@ -75,25 +75,6 @@ fun TwoCountItemView(
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.height(30.px.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Read",
style = RobotoTypography.bodyMedium,
fontWeight = FontWeight.Normal,
fontSize = 12.px.sp,
color = Color.Black,
textAlign = TextAlign.End
)
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -113,25 +94,6 @@ fun TwoCountItemView(
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.height(30.px.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Write",
style = RobotoTypography.bodyMedium,
fontWeight = FontWeight.Normal,
fontSize = 12.px.sp,
color = Color.Black,
textAlign = TextAlign.End
)
}
Column(
modifier = Modifier
//.noRippleClickable(onClick = onDeviceOpTimeClick)

View File

@@ -35,6 +35,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.laseroptek.raman.const.LaserParameter
import com.laseroptek.raman.const.LASER_STATUS
import com.laseroptek.raman.const.LaserStatusType
import com.laseroptek.raman.const.MAX_LASER_COUNT
import com.laseroptek.raman.const.PresetButtonType
@@ -92,10 +93,18 @@ fun HomeScreen(
val presetList by mainViewModel.presetList.collectAsState()
LaunchedEffect(Unit) {
LaunchedEffect(lampCount, lifeTime.lamp, laserStatus.laserStatus) {
Timber.d("LaunchedEffect - HomeScreen")
focusManager.clearFocus(force = true) // Hide the keyboard
// Ensure the system returns to StandBy when lamp thresholds are exceeded
val lampLifetimeLimit = lifeTime.lamp
val reachedLifetimeLimit = lampLifetimeLimit > 0 && lampCount >= lampLifetimeLimit
if (reachedLifetimeLimit && laserStatus.laserStatus != LASER_STATUS.STAND_BY) {
Timber.d("HomeScreen load - forcing StandBy state due to lamp count limit")
mainViewModel.txLaserStatusEntry(LASER_STATUS.STAND_BY)
}
Timber.d("Attempted to hide keyboard on EngineerScreen launch")
}
@@ -482,6 +491,18 @@ fun HomeScreen(
return@StandByButton
}
val lampLifetimeLimit = lifeTime.lamp
if (lampLifetimeLimit > 0 && lampCount >= lampLifetimeLimit) {
Toast.makeText(
context,
"Lamp lifetime limit reached",
Toast.LENGTH_SHORT
).show()
return@StandByButton
}
val hpCount = mainViewModel.getHPCount()
if (hpCount < 1) {
Toast.makeText(

View File

@@ -334,7 +334,7 @@ fun DcdSettingPopup(
modifier = Modifier
.fillMaxHeight()
.size(40.px.dp, 210.px.dp),
chargeRate = gasChargeRate.toInt()
chargeRate = gasChargeRate
)
// Icon

View File

@@ -28,14 +28,26 @@ import com.laseroptek.raman.ui.screens.main.MainViewModel
import com.laseroptek.raman.utils.DefaultDispatcherProvider
import com.laseroptek.raman.utils.ext.px
import timber.log.Timber
import kotlin.math.abs
import kotlin.math.ceil
@Composable
fun GradientSlider(
modifier: Modifier = Modifier,
chargeRate: Int = 0, // Value from (0 .. 100)
chargeRate: Float = 0f,
) {
val chargeIndex = ((chargeRate.coerceIn(0, 100) + 4) / 5).toInt() // 0..19
val normalizedRate = chargeRate.coerceIn(0f, 100f)
val bucket = normalizedRate / 5f
val remainder = normalizedRate % 5f
val isExactMultiple = abs(remainder) < 0.0001f
val chargeIndex = when {
normalizedRate == 0f -> -1
isExactMultiple -> (bucket - 1f).toInt().coerceAtLeast(-1)
else -> (ceil(bucket.toDouble()).toInt() - 1)
}.coerceIn(-1, 19)
Timber.d("chargeRate: $chargeRate, chargeIndex: $chargeIndex")
Box(
@@ -103,6 +115,6 @@ fun GradientSliderPreview(
//mainViewModel = mainViewModel
modifier = Modifier
.size(40.px.dp, 210.px.dp),
chargeRate = 20
chargeRate = 20f
)
}

View File

@@ -108,15 +108,15 @@ fun PresetIconButton(
Image(
painter = painterResource(id =
if (type == PresetButtonType.SAVE) {
R.drawable.ic_preset_save
R.drawable.ic_preset_save2
} else {
R.drawable.ic_preset_load
}
),
contentDescription = "",
modifier = Modifier
.size(20.px.dp),
contentScale = ContentScale.Crop
.size(30.px.dp),
contentScale = ContentScale.Fit
)
}
}

View File

@@ -552,12 +552,12 @@ fun PresetLoadPopup(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.weight(1f))
if (isEditMode) {
////////////////////////////////////////////////////
// Edit Mode
Spacer(modifier = Modifier.width(10.px.dp))
// Preset Delete (Delete confirm popup)
Box(
modifier = Modifier
@@ -578,7 +578,7 @@ fun PresetLoadPopup(
)
}
Spacer(Modifier.width(10.px.dp))
Spacer(Modifier.weight(1f))
// Preset Cancel (Reload selected item from mainViewModel)
Box(
@@ -646,7 +646,7 @@ fun PresetLoadPopup(
Timber.d("onClick - Confirm Save")
// Check empty names and conflict priority exist in the preset viewmodel's presetList
val listToValidate = presetViewModel.presetList.value
var listToValidate = presetViewModel.presetList.value
// Check for any presets with an empty name
val hasEmptyName =
@@ -678,18 +678,32 @@ fun PresetLoadPopup(
}
// Check for duplicate priorities (ignoring priority 0)
val priorityConflicts = listToValidate
.filter { it.priority > 0 } // Only consider prioritized items
.groupBy { it.priority } // Group them by priority
.any { it.value.size > 1 } // Check if any group is larger than 1
val duplicatePriorityGroups = listToValidate
.filter { it.priority > 0 }
.groupBy { it.priority }
.filter { it.value.size > 1 }
if (priorityConflicts) {
Toast.makeText(
context,
"There are duplicate priorities. Please ensure each priority is unique.",
Toast.LENGTH_LONG
).show()
return@noRippleClickable // Stop the process
if (duplicatePriorityGroups.isNotEmpty()) {
val resolvedList = listToValidate.map { it.copy() }.toMutableList()
val selectedPreset = resolvedList.getOrNull(selectedPresetIndex)
duplicatePriorityGroups.forEach { (priorityValue, presets) ->
val keeperId = presets
.firstOrNull { preset ->
selectedPreset != null && preset.id == selectedPreset.id
}
?.id
?: presets.first().id
resolvedList.forEachIndexed { index, preset ->
if (preset.priority == priorityValue && preset.id != keeperId) {
resolvedList[index] = preset.copy(priority = 0)
}
}
}
presetViewModel.setPresetList(resolvedList)
listToValidate = resolvedList
}
Timber.d("Validation successful. Saving list to MainViewModel.")
@@ -729,7 +743,11 @@ fun PresetLoadPopup(
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.width(10.px.dp))
} else {
Spacer(Modifier.weight(1f))
////////////////////////////////////////////////////
// Select Mode - hide Keyboard
focusManager.clearFocus(force = true) // Hide the keyboard
@@ -790,6 +808,8 @@ fun PresetLoadPopup(
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.width(10.px.dp))
}
}

View File

@@ -1,10 +1,14 @@
package com.laseroptek.raman.ui.screens.info
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import com.laseroptek.raman.repository.PreferenceRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -25,10 +29,13 @@ data class ChartUiState(
val chamber2State: Boolean = true,
val basePlateState: Boolean = true,
val waterState: Boolean = true,
)
) {
companion object
}
@HiltViewModel
class InfoViewModel @Inject constructor(
private val preferenceRepository: PreferenceRepository,
) : ViewModel() {
// This is the single source of truth for the checkbox states.
@@ -39,7 +46,7 @@ class InfoViewModel @Inject constructor(
fun toggleLine(line: ChartLine) {
// .update is a thread-safe way to update the state.
_chartUiState.update { currentState ->
when (line) {
val updatedState = when (line) {
ChartLine.INT_TEMP -> currentState.copy(intTempState = !currentState.intTempState)
ChartLine.EXT_TEMP -> currentState.copy(extTempState = !currentState.extTempState)
ChartLine.INT_HUMIDITY -> currentState.copy(intHumidityState = !currentState.intHumidityState)
@@ -50,10 +57,51 @@ class InfoViewModel @Inject constructor(
ChartLine.BASE_PLATE -> currentState.copy(basePlateState = !currentState.basePlateState)
ChartLine.WATER -> currentState.copy(waterState = !currentState.waterState)
}
persistChartUiState(updatedState)
updatedState
}
}
init {
Timber.d("InfoViewModel init")
viewModelScope.launch {
preferenceRepository.getInfoChartLineStates().collectLatest { savedStates ->
if (savedStates.isEmpty()) return@collectLatest
_chartUiState.update { ChartUiState.fromPreference(savedStates) }
}
}
}
private fun persistChartUiState(state: ChartUiState) {
viewModelScope.launch {
preferenceRepository.saveInfoChartLineStates(state.toPreferenceMap())
}
}
}
private fun ChartUiState.toPreferenceMap(): Map<String, Boolean> = mapOf(
ChartLine.INT_TEMP.name to intTempState,
ChartLine.EXT_TEMP.name to extTempState,
ChartLine.INT_HUMIDITY.name to intHumidityState,
ChartLine.EXT_HUMIDITY.name to extHumidityState,
ChartLine.KTP.name to ktpState,
ChartLine.CHAMBER1.name to chamber1State,
ChartLine.CHAMBER2.name to chamber2State,
ChartLine.BASE_PLATE.name to basePlateState,
ChartLine.WATER.name to waterState,
)
private fun ChartUiState.Companion.fromPreference(savedStates: Map<String, Boolean>): ChartUiState {
val defaults = ChartUiState()
return ChartUiState(
intTempState = savedStates[ChartLine.INT_TEMP.name] ?: defaults.intTempState,
extTempState = savedStates[ChartLine.EXT_TEMP.name] ?: defaults.extTempState,
intHumidityState = savedStates[ChartLine.INT_HUMIDITY.name] ?: defaults.intHumidityState,
extHumidityState = savedStates[ChartLine.EXT_HUMIDITY.name] ?: defaults.extHumidityState,
ktpState = savedStates[ChartLine.KTP.name] ?: defaults.ktpState,
chamber1State = savedStates[ChartLine.CHAMBER1.name] ?: defaults.chamber1State,
chamber2State = savedStates[ChartLine.CHAMBER2.name] ?: defaults.chamber2State,
basePlateState = savedStates[ChartLine.BASE_PLATE.name] ?: defaults.basePlateState,
waterState = savedStates[ChartLine.WATER.name] ?: defaults.waterState,
)
}

View File

@@ -583,22 +583,22 @@ class MainViewModel @Inject constructor(
saveGuideBeamMinToPreference()
saveGuideBeamMaxToPreference()
// After updating the state, send the packet
val newMin = guideBeamMin.value // get the potentially updated value
val newMax = guideBeamMax.value // get the potentially updated value
val guideBeam = guideBeam.value.toInt()
// Engineer 화면에서는 Min/Max 버튼에 따라 표시된 Min 또는 Max 값을 그대로 송신
val newMin = guideBeamMin.value
val newMax = guideBeamMax.value
val value = when (state) {
MinMaxUpDownState.MinDown,
MinMaxUpDownState.MinUp,
MinMaxUpDownState.MinLongDown,
MinMaxUpDownState.MinLongUp -> newMin
/*
val value = when(guideBeamValue) {
0 -> 0
1 -> newMin
10 -> newMax
else -> (newMin + (guideBeamValue - 1) * ((newMax - newMin) / 9))
MinMaxUpDownState.MaxDown,
MinMaxUpDownState.MaxUp,
MinMaxUpDownState.MaxLongDown,
MinMaxUpDownState.MaxLongUp -> newMax
}
*/
val value = (newMin + (guideBeam - 1) * ((newMax - newMin) / 9))
Timber.d("guideBeam: $value, guideBeam: $guideBeam, guideBeamMax: $newMax, guideBeamMin: $newMin")
Timber.d("Engineer guideBeam tx value: $value, guideBeamMax: $newMax, guideBeamMin: $newMin")
txPacket(READ_WRITE.WRITE, CMD.GUIDE_BEAM, GuideBeam(value = value))
}
@@ -814,24 +814,59 @@ class MainViewModel @Inject constructor(
}
fun txPacketOnce() {
// viewModel init 으로 이동. 필요.
// 경고 정보 조회 (주기적 heart beat)
// repeatOnLifecycle은 Activity가 포그라운드에 있을 때로 한정지어, 특정 Lifecycle이 Trigger 되었을 때 동작하도록 만드는 block 임.
viewModelScope.launch(dispatcherProvider.io) {
// txPacketOnce is called during app startup.
// Because serial open() is started in rxPacketLoop() asynchronously,
// FD can still be -1 here (startup race). Wait briefly before first TX burst.
if (!waitUntilSerialReady()) {
Timber.e("txPacketOnce skipped: serial port is not ready (fd=%d)", serialPortRepository.getFD())
return@launch
}
// tx Version Read
txPacket(READ_WRITE.READ, CMD.VERSION, byteArrayOf(0x41.toByte()))
// viewModel init 으로 이동. 필요.
// 경고 정보 조회 (주기적 heart beat)
// repeatOnLifecycle은 Activity가 포그라운드에 있을 때로 한정지어, 특정 Lifecycle이 Trigger 되었을 때 동작하도록 만드는 block 임.
// tx Q-Switch Write
txPacket(READ_WRITE.WRITE, CMD.Q_SWITCH, qSwitch.value)
// tx Version Read
txPacket(READ_WRITE.READ, CMD.VERSION, byteArrayOf(0x41.toByte()))
// tx Guide Beam Write
txPacket(READ_WRITE.WRITE, CMD.GUIDE_BEAM, GuideBeam(value = guideBeam.value.toInt()))
// tx Q-Switch Write
txPacket(READ_WRITE.WRITE, CMD.Q_SWITCH, qSwitch.value)
// tx DCD_GAS Write (DEFAULT VALUE)
txPacket(READ_WRITE.WRITE, CMD.DCD_GAS, dcdGas.value.copy(status = 0x50))
// tx Guide Beam Write
txPacket(READ_WRITE.WRITE, CMD.GUIDE_BEAM, GuideBeam(value = getGuideBeamTxValue()))
// tx SPRAY_DCD Write (DEFAULT VALUE)
txPacket(READ_WRITE.WRITE, CMD.SPRAY_DCD, sprayDcd.value)
// tx DCD_GAS Write (DEFAULT VALUE)
txPacket(READ_WRITE.WRITE, CMD.DCD_GAS, dcdGas.value.copy(status = 0x50))
// tx SPRAY_DCD Write (DEFAULT VALUE)
txPacket(READ_WRITE.WRITE, CMD.SPRAY_DCD, sprayDcd.value)
}
}
private suspend fun waitUntilSerialReady(
timeoutMillis: Long = 2000L,
pollIntervalMillis: Long = 20L
): Boolean {
// Poll FD until open() completes, with a bounded timeout to avoid blocking forever.
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < timeoutMillis) {
if (serialPortRepository.getFD() != -1) return true
delay(pollIntervalMillis)
}
return serialPortRepository.getFD() != -1
}
// Guide Beam step mapping (0~10)
// 0 -> fixed 0
// 1~10 -> min~max range in 10 steps (10 -> max)
private fun getGuideBeamTxValue(): Int {
val step = guideBeam.value.toInt().coerceIn(0, 10)
return if (step == 0) {
0
} else {
guideBeamMin.value + ((step - 1) * (guideBeamMax.value - guideBeamMin.value) / 9)
}
}
// Example: Emitting an event after a delay

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,53 @@
# 변경 요약 (2026-03-04)
이 문서는 현재 `git diff` 기준으로 반영된 변경을 정리합니다.
## 1) 시리얼 초기 전송 누락(Startup race) 대응
### 변경 파일
- `app/src/main/java/com/laseroptek/raman/ui/MainActivity.kt`
- `app/src/main/java/com/laseroptek/raman/ui/screens/main/MainViewModel.kt`
### 변경 내용
- `MainActivity.initialize()`의 시리얼 시작 순서를 조정
- 이전: `txPacketOnce()` -> `rxPacketLoop()` -> `txPacketLoop()`
- 이후: `rxPacketLoop()` -> `txPacketOnce()` -> `txPacketLoop()`
- `MainViewModel.txPacketOnce()`를 코루틴(IO)에서 실행하도록 변경
- `waitUntilSerialReady()` 추가
- 최대 2초 동안 20ms 간격으로 `FD != -1` 확인
- 준비 실패 시 에러 로그 후 초기 TX 중단
- 관련 설명 주석 추가
### 의도/효과
- 앱 시작 직후 `open()` 완료 전 TX가 먼저 발생하는 레이스를 완화/방어
- 포트 미준비 상태(`FD == -1`)에서 write가 호출되어 초기 패킷이 누락되는 문제를 줄임
## 2) HandPiece 기본값 변경
### 변경 파일
- `app/src/main/java/com/laseroptek/raman/data/model/serial/HandPiece.kt`
### 변경 내용
- `HandPiece.type` 기본값 변경
- 이전: `1`
- 이후: `0`
### 영향 포인트
- 기본 인스턴스 생성 시 handpiece type의 초기 상태가 달라집니다.
- 초기 테이블 선택/상태 표시 로직에서 기본 타입 가정이 있다면 함께 점검 필요합니다.
## 3) 문서화
### 추가/수정 파일
- `docs/serial_tx_startup_race.md` (신규)
- `README.md` (Troubleshooting 링크 추가)
### 내용
- FD(File Descriptor) 개념
- startup race 원인 및 수정 내역
- 왜 TX에서 직접 open하지 않았는지
- 확인 포인트
## 4) 참고
- 현재 요약은 커밋 로그가 아닌 워크트리 `git diff` 기준입니다.
- 빌드 검증은 `gradlew` CRLF 문제로 로컬 셸에서 미실행 상태입니다.

View File

@@ -0,0 +1,41 @@
# Serial TX Startup Race 정리
## 1) FD(File Descriptor)란?
- `FD`는 리눅스/안드로이드에서 열린 리소스(파일/소켓/시리얼 포트)를 가리키는 정수 핸들입니다.
- 이 프로젝트에서 시리얼 포트 상태는 다음처럼 판단합니다.
- `FD == -1`: 포트 미오픈(유효하지 않음)
- `FD >= 0`: 포트 오픈 완료(유효)
- 따라서 `FD == -1` 상태에서 `write()`를 호출하면 실제 시리얼 전송이 되지 않습니다.
## 2) 문제 원인
- `txPacketOnce()`가 앱 시작 직후 실행됩니다.
- 시리얼 포트 `open()``rxPacketLoop()` 내부에서 코루틴으로 비동기 시작됩니다.
- 기존 순서에서 `txPacketOnce()`가 먼저 호출되면, 포트 오픈 완료 전(`FD == -1`)에 TX가 시도되어 초기 패킷 전송이 누락될 수 있습니다.
## 3) 적용한 수정
### A. 초기 호출 순서 조정
- 파일: `app/src/main/java/com/laseroptek/raman/ui/MainActivity.kt`
- 변경:
- 이전: `txPacketOnce()` -> `rxPacketLoop()` -> `txPacketLoop()`
- 이후: `rxPacketLoop()` -> `txPacketOnce()` -> `txPacketLoop()`
- 목적: RX 루프가 먼저 포트 오픈을 시작하도록 해서 초기 TX 레이스 확률을 줄임
### B. `txPacketOnce()`에 포트 준비 대기 추가
- 파일: `app/src/main/java/com/laseroptek/raman/ui/screens/main/MainViewModel.kt`
- 변경:
- `txPacketOnce()`를 IO 코루틴에서 실행
- `waitUntilSerialReady()`(최대 2초, 20ms 폴링)로 `FD != -1` 확인 후 TX 진행
- 시간 내 준비 실패 시 로그를 남기고 전송 중단
- 목적: 순서만으로 보장되지 않는 코루틴 스케줄링 레이스를 방어
## 4) 왜 TX에서 직접 open()하지 않았는가?
- 현재 구조에서 `open()`의 데이터 콜백은 `rxPacketLoop()``callbackFlow`와 연결됩니다.
- TX 경로에서 별도 `open()`을 하면 중복 오픈/콜백 소유권/FD 교체 타이밍 이슈가 생길 수 있습니다.
- 안정적인 패턴은:
- 포트 오픈 책임: RX(단일 지점)
- 포트 사용(TX): ready 확인 후 write
## 5) 확인 포인트
- 앱 시작 직후 로그에서 `FD`가 유효해진 뒤 `txPacketOnce()`의 TX 로그가 출력되는지 확인
- 장비 측 시리얼 모니터에서 초기 패킷(Version/Q-Switch/GuideBeam/DCD/SprayDCD) 수신 여부 확인