42 Commits

Author SHA1 Message Date
StevenBuzzi
dbf7d5ddde 1. Load에서 편집 기능 사용 시 Repetition 변경할 경우 앱이 튕기는 증상 수정
2. Pulse Duration 및 Fluence 변경 할 경우 Repetition 제한되는 값이 최대 범위를 벗어날 경우, 변경된 Pulse Duration 및 Fluence에 맞게 최대값으로 설정되도록 수정
2026-03-11 09:02:06 +09:00
babd667c5f Merge pull request 'Fix repetition angle handling for single-value list' (#61) from feature/ISSUE-27 into develop
Reviewed-on: #61
2026-03-08 07:07:00 +00:00
areumwoo
c3a3338c8f Fix repetition angle handling for single-value list 2026-03-08 15:24:12 +09:00
8a49c3c832 Merge pull request 'feature/ISSUE-57' (#59) from feature/ISSUE-57 into develop
Reviewed-on: #59
2026-03-08 06:08:11 +00:00
areumwoo
ca8d89e9bb Load preset values even without priority 2026-03-08 14:45:27 +09:00
areumwoo
7ec7d8674f Preserve fluence by value when pulse duration changes 2026-03-08 14:26:35 +09:00
e7a5ce0de2 Merge pull request '제공된 에너지 테이블과 매칭' (#58) from feature/ISSUE-13 into develop
Reviewed-on: #58
2026-03-08 05:14:13 +00:00
steven
c8a9323178 제공된 에너지 테이블과 매칭 2026-03-08 14:13:38 +09:00
aa618627df Merge pull request 'DCD 설정 관련 수정' (#55) from feature/ISSUE-51 into develop
Reviewed-on: #55
2026-03-06 04:43:56 +00:00
StevenBuzzi
55a1398136 Merge remote-tracking branch 'origin/feature/ISSUE-51' into feature/ISSUE-51
# Conflicts:
#	app/src/main/java/com/laseroptek/raman/ui/screens/main/MainViewModel.kt
2026-03-06 13:41:20 +09:00
StevenBuzzi
174cb4fb45 1. DCD 설정 시 Packet 0x41 고정으로 전송하도록 수정
2. DCD 설정값 변경 후 ok 누르면 Packet 전송하여 설정되도록 수정
3. 부팅 시 DCD Packet 0x41 고정으로 전송하도록 수정
2026-03-06 13:38:55 +09:00
StevenBuzzi
71ab4cc8d8 1. DCD Delay 설정 가능 범위 수정
2. DCD Pressure 설정 기본값 수정
2026-03-06 13:38:55 +09:00
StevenBuzzi
d2e375b305 Home화면 내 DCD 숫자 표시 위치 및 크기 조정 2026-03-06 13:38:55 +09:00
StevenBuzzi
92b679b996 잘못 적용된 초기화 값 수정 2026-03-06 13:38:07 +09:00
a5ef8f6da3 Merge pull request 'Packet 동작 이슈 수정' (#54) from feature/ISSUE-8 into develop
Reviewed-on: #54
2026-03-06 01:46:34 +00:00
StevenBuzzi
5ba515d123 1. DCD 설정 시 Packet 0x41 고정으로 전송하도록 수정
2. DCD 설정값 변경 후 ok 누르면 Packet 전송하여 설정되도록 수정
3. 부팅 시 DCD Packet 0x41 고정으로 전송하도록 수정
2026-03-06 10:35:44 +09:00
StevenBuzzi
8c85390d1e 1. DCD Delay 설정 가능 범위 수정
2. DCD Pressure 설정 기본값 수정
2026-03-05 18:32:29 +09:00
StevenBuzzi
72607b03a9 Home화면 내 DCD 숫자 표시 위치 및 크기 조정 2026-03-05 16:19:48 +09:00
StevenBuzzi
285e521f61 불필요한 내용 삭제 2026-03-05 15:35:33 +09:00
StevenBuzzi
ad27766342 부팅 시 KTP Temp write packet 전송하도록 수정 2026-03-05 15:35:31 +09:00
StevenBuzzi
0eeb830a9b q-interval packet 값이 비정상인 버그 수정 2026-03-05 14:29:08 +09:00
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
29 changed files with 840 additions and 230 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

@@ -9,7 +9,7 @@ package com.laseroptek.raman.const
val HzTable_10_10 = mapOf(
// Pulse Width = 0.5f
Pair(0.5f, 0.5f) to KEY_BLUE,
Pair(0.5f, 0.5f) to KEY_GREEN,
Pair(0.5f, 0.6f) to KEY_BLUE,
Pair(0.5f, 0.7f) to KEY_BLUE,
Pair(0.5f, 0.8f) to KEY_BLUE,

View File

@@ -24,7 +24,7 @@ val HzTable_5_5 = mapOf(
Pair(0.5f,3.4f) to KEY_BLUE,
Pair(0.5f,3.6f) to KEY_BLUE,
Pair(0.5f,3.8f) to KEY_BLUE,
Pair(0.5f,4.0f) to KEY_YELLOW,
Pair(0.5f,4.0f) to KEY_BLUE,
Pair(0.5f,4.2f) to KEY_YELLOW,
Pair(0.5f,4.4f) to KEY_YELLOW,
Pair(0.5f,4.6f) to KEY_YELLOW,

View File

@@ -108,7 +108,7 @@ val HzTable_7_7 = mapOf(
Pair(1.5f, 8.0f) to KEY_GRAY,
// Pulse Width = 3f
Pair(3f, 1.1f) to KEY_RED,
Pair(3f, 1.1f) to KEY_GRAY,
Pair(3f, 1.2f) to KEY_GRAY,
Pair(3f, 1.3f) to KEY_GRAY,
Pair(3f, 1.4f) to KEY_GRAY,

View File

@@ -61,7 +61,7 @@ const val MAX_Q_SWITCH_VALUE = 250.0f
const val MAX_REFER1_VALUE = 99999999 // 8 byte ascii (ENERGY DETECT MEASURED VALUE)
const val DEFAULT_REFER1_VALUE = 0 // initial state
const val DEFAULT_DCD_GAS_PRESSURE = 06.5f
const val DEFAULT_DCD_GAS_PRESSURE = 08.0f
const val DEFAULT_SPAY_DCD_TIME = 10
const val DEFAULT_SPAY_DCD_DELAY = 10
@@ -461,17 +461,17 @@ val dcdLifeSpanAdjustLists = listOf( 0.5f, 0.7f, 0.9f, 1.0f, 1.1f, 1.2f )
val dcdLifeSpanAdjustStringLists = dcdLifeSpanAdjustLists.map{ it.toString() }
val dcdSprayValues = listOf(10, 20, 30, 40, 50, 60, 70, 80, 90, 100).map{ it.toString() }
val dcdDelayValues = listOf(10, 20, 30, 40, 50, 60, 70, 80, 90, 100).map{ it.toString() }
val dcdDelayValues = listOf(10, 20, 30, 40).map{ it.toString() }
// default spray dcd list for options
val SprayDcdList = listOf<SprayDcd>(
SprayDcd(status = 0x44, sprayTime = 10, sprayDelay = 10), // default
SprayDcd(status = 0x44, sprayTime = 10, sprayDelay = 10),
SprayDcd(status = 0x44, sprayTime = 20, sprayDelay = 20),
SprayDcd(status = 0x44, sprayTime = 30, sprayDelay = 20),
SprayDcd(status = 0x44, sprayTime = 40, sprayDelay = 40),
SprayDcd(status = 0x44, sprayTime = 50, sprayDelay = 50),
SprayDcd(status = 0x44, sprayTime = 60, sprayDelay = 60),
SprayDcd(status = 0x41, sprayTime = 10, sprayDelay = 10), // default
SprayDcd(status = 0x41, sprayTime = 10, sprayDelay = 10),
SprayDcd(status = 0x41, sprayTime = 20, sprayDelay = 10),
SprayDcd(status = 0x41, sprayTime = 30, sprayDelay = 20),
SprayDcd(status = 0x41, sprayTime = 40, sprayDelay = 20),
SprayDcd(status = 0x41, sprayTime = 50, sprayDelay = 30),
SprayDcd(status = 0x41, sprayTime = 60, sprayDelay = 30),
)
val PresetList = listOf<Preset>(

View File

@@ -3,7 +3,7 @@ package com.laseroptek.raman.data.model.serial
import com.laseroptek.raman.const.DEFAULT_DCD_GAS_PRESSURE
data class DcdGas(
val status: Int = 0x41, // 1Byte(A(0x41): on, D(0x44): off, P(0x50): pressure)
val status: Int = 0x50, // 1Byte(A(0x41): on, D(0x44): off, P(0x50): pressure)
val pressure: Float = DEFAULT_DCD_GAS_PRESSURE, // Ascii 4Byte (xx.x): Pressure
val ok: Int = 0x00, // 1Byte(N(0x4E): Not OK, O(0x00F): OK)/
)

View File

@@ -1,5 +1,5 @@
package com.laseroptek.raman.data.model.serial
data class HandPiece(
val type:Int = 1 // ascii (00 ~ 99)
val type:Int = 0 // ascii (00 ~ 99)
)

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))
@@ -854,4 +854,4 @@ fun PreviewConfigScreen(
mainViewModel = mainViewModel,
configViewModel = configViewModel
)
}
}

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,
@@ -129,4 +129,4 @@ fun PreviewHourItemView() {
title = "Hour Item View"
)
}
}
}

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(
@@ -108,4 +121,4 @@ fun LifeTimeView(
@Composable
fun PreviewLifeTimeView() {
LifeTimeView()
}
}

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) {
@@ -116,4 +147,4 @@ fun TemperatureView(
@Composable
fun PreviewSerialNumber() {
TemperatureView()
}
}

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")
}
@@ -295,7 +304,7 @@ fun HomeScreen(
pulseType = pulseType,
handPieceType = handPiece.type,
type = LaserStatusType.REPETITION,
angle = if (pulseType == 0 || repetitionList.size < 2) 0f else repetitionAngle,
angle = if (pulseType == 0 || repetitionList.isEmpty()) 0f else repetitionAngle,
onChange = { angle ->
if (handPiece.type == 0) {
Toast.makeText(
@@ -306,7 +315,7 @@ fun HomeScreen(
return@LaserControlView
}
if (pulseType == 0 || repetitionList.size < 2) {
if (pulseType == 0 || repetitionList.isEmpty()) {
//Toast.makeText(context, "Single pulse type (0 Hz)", Toast.LENGTH_SHORT).show()
} else {
mainViewModel.onChangeRepetition(angle)
@@ -323,7 +332,7 @@ fun HomeScreen(
return@LaserControlView
}
if (pulseType == 0 || repetitionList.size < 2) {
if (pulseType == 0 || repetitionList.isEmpty()) {
//Toast.makeText(context, "Single pulse type (0 Hz)", Toast.LENGTH_SHORT).show()
} else {
//mainViewModel.onClickRepetition(state)
@@ -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

@@ -256,7 +256,11 @@ fun DcdSettingPopup(
mainViewModel.setSelectedSprayDcdIndex(i)
val optionValue = mainViewModel.sprayDcdList[i]
mainViewModel.setSprayDcd(optionValue)
mainViewModel.txPacket(READ_WRITE.WRITE, CMD.SPRAY_DCD, optionValue)
mainViewModel.txPacket(
READ_WRITE.WRITE,
CMD.SPRAY_DCD,
optionValue.copy(status = 0x41)
)
scope.launch {
mainViewModel.saveSprayDcdIndexToPreference()
@@ -303,7 +307,11 @@ fun DcdSettingPopup(
mainViewModel.setSelectedSprayDcdIndex(i)
val optionValue = mainViewModel.sprayDcdList[i]
mainViewModel.setSprayDcd(optionValue)
mainViewModel.txPacket(READ_WRITE.WRITE, CMD.SPRAY_DCD, optionValue)
mainViewModel.txPacket(
READ_WRITE.WRITE,
CMD.SPRAY_DCD,
optionValue.copy(status = 0x41)
)
scope.launch {
mainViewModel.saveSprayDcdToPreference()
@@ -334,7 +342,7 @@ fun DcdSettingPopup(
modifier = Modifier
.fillMaxHeight()
.size(40.px.dp, 210.px.dp),
chargeRate = gasChargeRate.toInt()
chargeRate = gasChargeRate
)
// Icon
@@ -603,6 +611,12 @@ fun DcdSettingPopup(
sprayDcd
)
mainViewModel.txPacket(
READ_WRITE.WRITE,
CMD.SPRAY_DCD,
sprayDcd.copy(status = 0x41)
)
mainViewModel.setSelectedSprayDcdIndex( selectedSprayDcdIndex )
//mainViewModel.setSprayDcdList( mainViewModel.sprayDcdList )

View File

@@ -97,7 +97,7 @@ fun DcdView(
Column(
modifier = Modifier
.width(45.px.dp)
.width(55.px.dp)
.fillMaxHeight()
, horizontalAlignment = Alignment.CenterHorizontally
, verticalArrangement = Arrangement.Center
@@ -120,7 +120,7 @@ fun DcdView(
)
}
Spacer( Modifier.width(10.px.dp))
Spacer( Modifier.width(5.px.dp))
VerticalDivider(
color = Color(161,161,170),
@@ -128,7 +128,7 @@ fun DcdView(
modifier = Modifier.size(1.px.dp, 30.px.dp)
)
Spacer( Modifier.width(10.px.dp))
Spacer( Modifier.width(5.px.dp))
// Delay
Timber.d("selectedSprayDcdIndex.value: ${selectedSprayDcdIndex}")

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
@@ -762,21 +780,25 @@ fun PresetLoadPopup(
.noRippleClickable(onClick = {
Timber.d("onClick - Preset Load")
val selectedPreset =
presetViewModel.getPreset(selectedPresetIndex)
val priority =
selectedPreset?.priority ?: 0
val selectedPreset = presetViewModel.getPreset(selectedPresetIndex)
if (selectedPreset == null) {
Timber.w("onClick - Preset Load: selectedPreset is null. index=$selectedPresetIndex")
return@noRippleClickable
}
val priority = selectedPreset.priority
Timber.d("onClick - Preset Load ($priority)")
// if (priority > 0) { // TODO : 검증 필요
if (priority > 0) {
mainViewModel.setSelectedPresetIndex( priority )
mainViewModel.applyPreset(priority)
presetViewModel.clearPreset()
onClick.invoke(false)
// } else {
// Timber.d("SKIP - Preset Load ($priority)")
// }
} else {
// 우선순위가 NONE인 경우, 인덱스 설정 X
mainViewModel.setSelectedPresetIndex(0)
mainViewModel.applyPreset(selectedPreset)
}
presetViewModel.clearPreset()
onClick.invoke(false)
})
.size(40.px.dp)
.background(Color.Transparent)
@@ -790,6 +812,8 @@ fun PresetLoadPopup(
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.width(10.px.dp))
}
}

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.math.abs
import javax.inject.Inject
@HiltViewModel
@@ -80,6 +81,49 @@ class PresetViewModel @Inject constructor(
_repetitionList.value = r
}
private data class ResolvedRepetitionContext(
val fluence: Float,
val repetitionOptions: List<Float>,
)
private fun resolveRepetitionContext(
pulseWidth: Float,
fluence: Float,
): ResolvedRepetitionContext {
val availableFluenceList = energyTable.value.getKey2ListForKey1(pulseWidth)
val resolvedFluence = availableFluenceList.minByOrNull { abs(it - fluence) }
?: availableFluenceList.firstOrNull()
?: fluence
val hzType = hzTable.value.getValue(pulseWidth, resolvedFluence)
val repetitionOptions = RepetitionsByColorKey[hzType] ?: RepetitionsByColorKey[KEY_YELLOW].orEmpty()
if (hzType == null) {
Timber.w(
"No hzType found for pulseWidth=%s, fluence=%s. Falling back to default repetition list.",
pulseWidth,
resolvedFluence
)
}
setRepetitionList(repetitionOptions)
return ResolvedRepetitionContext(
fluence = resolvedFluence,
repetitionOptions = repetitionOptions,
)
}
private fun resolveNearestLowerOrEqualRepetition(
currentRepetition: Float,
repetitionOptions: List<Float>,
): Float {
return repetitionOptions
.filter { it <= currentRepetition }
.maxOrNull()
?: repetitionOptions.minOrNull()
?: currentRepetition
}
fun loadHzTable(handPieceType: Int) {
Timber.d("loadHzTable($handPieceType)")
@@ -251,21 +295,17 @@ class PresetViewModel @Inject constructor(
// 4. fluence update (energyTable)
val newFluenceList = energyTable.value.getKey2ListForKey1(newPulseWidth)
val newFluence = newFluenceList.get(0)
val newFluence = newFluenceList.firstOrNull() ?: currentList[index].fluence
// 5. repetition update (hzTable)
val hzType = hzTable.value.getValue(newPulseWidth, newFluence)
Timber.d("hzType: ${hzType}")
val list = RepetitionsByColorKey.get(hzType)!!
setRepetitionList( list )
val newRepetitionIndex = 0
val newRepetition = list[newRepetitionIndex]
val repetitionContext = resolveRepetitionContext(newPulseWidth, newFluence)
Timber.d("Resolved repetition options for handPieceType update: ${repetitionContext.repetitionOptions}")
val newRepetition = repetitionContext.repetitionOptions.firstOrNull() ?: currentList[index].repetition
val updatedPreset = currentList[index].copy(
handPieceType = newHandPieceType,
pulseWidth = newPulseWidth,
fluence = newFluence,
fluence = repetitionContext.fluence,
repetition = newRepetition
)
currentList[index] = updatedPreset
@@ -311,20 +351,18 @@ class PresetViewModel @Inject constructor(
// 3. update fluence
val newFluenceList = energyTable.value.getKey2ListForKey1(newPulseWidth)
val newFluenceIndex = 0
val newFluence = newFluenceList.get(newFluenceIndex)
val newFluence = newFluenceList.firstOrNull() ?: currentList[index].fluence
// 4. update repetition
val hzType = hzTable.value.getValue(newPulseWidth, newFluence)
val list = RepetitionsByColorKey[hzType]!!
setRepetitionList( list )
val newRepetitionIndex = 0
val newRepetition = repetitionList.value[newRepetitionIndex]
val repetitionContext = resolveRepetitionContext(newPulseWidth, newFluence)
val newRepetition = resolveNearestLowerOrEqualRepetition(
currentRepetition = currentList[index].repetition,
repetitionOptions = repetitionContext.repetitionOptions,
)
val updatedPreset = currentList[index].copy(
pulseWidth = newPulseWidth,
fluence = newFluence,
fluence = repetitionContext.fluence,
repetition = newRepetition
)
currentList[index] = updatedPreset
@@ -354,17 +392,16 @@ class PresetViewModel @Inject constructor(
}
// 3. update repetition (0)
val hzType = hzTable.value.getValue(pulseWidth, newFluence)
val list = RepetitionsByColorKey[hzType]!!
setRepetitionList( list )
val newRepetitionIndex = 0
val newRepetition = repetitionList.value[newRepetitionIndex]
val repetitionContext = resolveRepetitionContext(pulseWidth, newFluence)
val newRepetition = resolveNearestLowerOrEqualRepetition(
currentRepetition = currentList[index].repetition,
repetitionOptions = repetitionContext.repetitionOptions,
)
// The original code here had a bug: it was updating `pulseWidth = newFluence`
// It should be `fluence = newFluence`
val updatedPreset = currentList[index].copy(
fluence = newFluence,
fluence = repetitionContext.fluence,
repetition = newRepetition
)
currentList[index] = updatedPreset
@@ -385,20 +422,28 @@ class PresetViewModel @Inject constructor(
loadHzTable(handPieceType)
// update repetition
val hzType = hzTable.value.getValue(pulseWidth, fluence)
val list = RepetitionsByColorKey[hzType]!!
setRepetitionList( list )
val repetitionContext = resolveRepetitionContext(pulseWidth, fluence)
val currentRepetitionList = repetitionContext.repetitionOptions
val newRepetitionIndex = repetitionList.value.indexOf(repetition).takeIf { it != -1 } ?: 0
val newRepetitionIndex = currentRepetitionList.indexOf(repetition).takeIf { it != -1 } ?: 0
val newRepetition = if (isUp == UpDownState.Up) {
// Original logic: repetitionIndex < PulseDurations.size -1 (Likely a typo, should be Repetitions.size)
if (newRepetitionIndex < repetitionList.value.size - 1) repetitionList.value[newRepetitionIndex + 1] else repetition
if (newRepetitionIndex < currentRepetitionList.size - 1) {
currentRepetitionList[newRepetitionIndex + 1]
} else {
repetition
}
} else {
// Original logic: repetitionIndex > 1
if (newRepetitionIndex > 0) repetitionList.value[newRepetitionIndex - 1] else repetition
if (newRepetitionIndex > 0) {
currentRepetitionList[newRepetitionIndex - 1]
} else {
repetition
}
}
val updatedPreset = currentList[index].copy(repetition = newRepetition)
val updatedPreset = currentList[index].copy(
fluence = repetitionContext.fluence,
repetition = newRepetition
)
currentList[index] = updatedPreset
//_presetList.value = currentList
setPresetList(currentList)
@@ -449,4 +494,4 @@ class PresetViewModel @Inject constructor(
init {
Timber.d("PresetViewModel init")
}
}
}

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

@@ -134,6 +134,7 @@ import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.abs
import javax.inject.Inject
import kotlin.experimental.or
import kotlin.time.Duration.Companion.milliseconds
@@ -583,22 +584,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 +815,62 @@ 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 Oven Write (align with Engineer KTP write source)
txPacket(READ_WRITE.WRITE, CMD.OVEN, Oven(ktp = temperature_write.value.ktp))
// tx SPRAY_DCD Write (DEFAULT VALUE)
txPacket(READ_WRITE.WRITE, CMD.SPRAY_DCD, sprayDcd.value)
// tx Guide Beam Write
txPacket(READ_WRITE.WRITE, CMD.GUIDE_BEAM, GuideBeam(value = getGuideBeamTxValue()))
// 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.copy(status = 0x41))
}
}
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
@@ -2043,28 +2082,58 @@ class MainViewModel @Inject constructor(
Timber.d("preset: ${preset}")
val newPreset = if (preset == null) {
val defaultPulseWidth = PulseDurations.first()
Preset(
handPieceType = handPiece.value.type,
priority = priority,
fluence = energyTable.value.getKey2ListForKey1(0.5f).first(),
fluence = energyTable.value.getKey2ListForKey1(defaultPulseWidth).firstOrNull() ?: 0f,
repetition = repetitionList.value.first(),
pulseWidth = PulseDurations.first(),
pulseWidth = defaultPulseWidth,
)
} else {
preset
}
applyPreset(newPreset)
}
val fluenceList = energyTable.value.getKey2ListForKey1(newPreset.pulseWidth)
fun applyPreset(preset: Preset) {
val newPreset = preset.copy()
val pulseStep = PulseDurations.indexOf(newPreset.pulseWidth).takeIf {it != -1} ?: 0
val fluenceStep = fluenceList.indexOf(newPreset.fluence).takeIf {it != -1} ?: 0
val repetitionStep = repetitionList.value.indexOf(newPreset.repetition).takeIf {it != -1} ?: 0
val resolvedPulseWidth = PulseDurations[pulseStep]
// 프리셋의 pulseWidth 기준으로 실제 사용 가능한 fluence 목록을 먼저 동기화한다.
val newFluenceList = energyTable.value.getKey2ListForKey1(resolvedPulseWidth)
if (newFluenceList != fluenceList.value) {
setFluenceList(newFluenceList)
}
// 프리셋 fluence가 정확히 없을 수 있으므로, 가장 가까운 값으로 보정한다.
val resolvedFluence = newFluenceList.minByOrNull { abs(it - newPreset.fluence) }
?: newFluenceList.firstOrNull()
?: 0f
val fluenceStep = newFluenceList.indexOf(resolvedFluence).takeIf { it != -1 } ?: 0
// 보정된 (pulseWidth, fluence) 조합으로 repetition 테이블을 다시 계산한다.
val hzType = hzTable.value.getValue(resolvedPulseWidth, resolvedFluence)
val newRepetitionList = RepetitionsByColorKey[hzType] ?: RepetitionsByColorKey[KEY_YELLOW]!!
if (newRepetitionList != repetitionList.value) {
setRepetitionList(newRepetitionList)
}
val repetitionStep = newRepetitionList.indexOf(newPreset.repetition).takeIf { it != -1 } ?: 0
Timber.d("pulseStep: ${pulseStep} fluenceStep: ${fluenceStep} repetitionStep: ${repetitionStep}")
val pulseAngle = pulseStep.stepToDegree(totalSteps = PulseDurations.size)
val fluenceAngle = fluenceStep.stepToDegree(totalSteps = fluenceList.size)
val repetitionAngle = repetitionStep.stepToDegree(totalSteps = repetitionList.value.size)
val fluenceAngle = if (newFluenceList.isNotEmpty()) {
fluenceStep.stepToDegree(totalSteps = newFluenceList.size)
} else {
0f
}
val repetitionAngle = repetitionIndexToAngle(
index = repetitionStep,
totalSteps = newRepetitionList.size,
)
Timber.d("pulseStep: ${pulseStep} fluenceStep: ${fluenceStep} repetitionStep: ${repetitionStep}")
@@ -2094,30 +2163,35 @@ class MainViewModel @Inject constructor(
setFluenceList(newFluenceList)
}
// 2-1. Resolve and apply fluence by value (not by old angle/index).
val resolvedFluence = newFluenceList.minByOrNull { abs(it - newFluence) } ?: 0f
val resolvedFluenceIndex = newFluenceList.indexOf(resolvedFluence).takeIf { it >= 0 } ?: 0
if (newFluenceList.isNotEmpty()) {
setFluenceAngle(resolvedFluenceIndex.stepToDegree(totalSteps = newFluenceList.size))
}
// 3. Safely Update Repetition List (Prevents NullPointerException)
val newHzType = hzTable.value.getValue(newPulseDuration, newFluence)
val newHzType = hzTable.value.getValue(newPulseDuration, resolvedFluence)
val newRepetitionList = RepetitionsByColorKey[newHzType] ?: RepetitionsByColorKey[KEY_YELLOW]!!
if (newRepetitionList != repetitionList.value) {
setRepetitionList(newRepetitionList)
}
// Smartly preserve Repetition Angle
// 4. Check if the old repetition value exists in the new list.
val oldRepetitionIndex = if (currentRepetitionValue != null) {
newRepetitionList.indexOf(currentRepetitionValue)
} else {
-1
}
// 4. Resolve and apply repetition by value (not by old angle/index).
val targetRepetition = currentRepetitionValue ?: 0f
val resolvedRepetition = newRepetitionList.minByOrNull { abs(it - targetRepetition) } ?: 0f
val resolvedRepetitionIndex = newRepetitionList.indexOf(resolvedRepetition).takeIf { it >= 0 } ?: 0
if (oldRepetitionIndex != -1) {
// If the old value exists, set the slider to that position.
val preservedAngle = oldRepetitionIndex.stepToDegree(totalSteps = newRepetitionList.size)
setRepetitionAngle(preservedAngle)
Timber.d("Repetition value $currentRepetitionValue preserved at new angle $preservedAngle.")
if (newRepetitionList.isNotEmpty()) {
val resolvedRepetitionAngle = repetitionIndexToAngle(
index = resolvedRepetitionIndex,
totalSteps = newRepetitionList.size,
)
setRepetitionAngle(resolvedRepetitionAngle)
Timber.d("Resolved repetition by value: old=$currentRepetitionValue, new=$resolvedRepetition, angle=$resolvedRepetitionAngle")
} else {
// If it doesn't exist, THEN reset the slider to the beginning.
setRepetitionAngle(0f)
Timber.d("Repetition value $currentRepetitionValue not supported in new list. Resetting angle.")
Timber.d("Repetition list is empty. Resetting repetition angle to 0f.")
}
// 5. Conditionally reset the fluence angle slider if needed
@@ -2130,7 +2204,7 @@ class MainViewModel @Inject constructor(
// 6. Any change invalidates the current preset selection.
setSelectedPresetIndex(0)
Timber.d("Updated Laser Parameters: pulse=$newPulseDuration, fluence=$newFluence -> newRepListSize=${newRepetitionList.size}")
Timber.d("Updated Laser Parameters: pulse=$newPulseDuration, fluence=$resolvedFluence -> newRepListSize=${newRepetitionList.size}")
}
/**
@@ -2150,13 +2224,16 @@ class MainViewModel @Inject constructor(
setPulseAngle(newPulseStep.stepToDegree(totalSteps = PulseDurations.size))
val newPulseDuration = PulseDurations[newPulseStep]
// When pulse duration changes via slider, we use the first available fluence for the new list.
val firstFluence = energyTable.value.getKey2ListForKey1(newPulseDuration).firstOrNull() ?: 0f
// Keep the current fluence value when pulse duration changes via slider.
val currentFluenceStep = fluenceAngle.value.degreeToStep(totalSteps = fluenceList.value.size)
val currentFluence = fluenceList.value.getOrNull(currentFluenceStep)
?: energyTable.value.getKey2ListForKey1(newPulseDuration).firstOrNull()
?: 0f
// Call the centralized helper, resetting the fluence slider.
// Call the centralized helper using the preserved fluence value.
updateLaserParameters(
newPulseDuration = newPulseDuration,
newFluence = firstFluence,
newFluence = currentFluence,
)
}
@@ -2168,12 +2245,17 @@ class MainViewModel @Inject constructor(
setPulseAngle(newStep.stepToDegree(totalSteps = PulseDurations.size))
val newPulseDuration = PulseDurations[newStep]
val firstFluence = energyTable.value.getKey2ListForKey1(newPulseDuration).firstOrNull() ?: 0f
// Keep the current fluence value when pulse duration changes via slider.
val currentFluenceStep = fluenceAngle.value.degreeToStep(totalSteps = fluenceList.value.size)
val currentFluence = fluenceList.value.getOrNull(currentFluenceStep)
?: energyTable.value.getKey2ListForKey1(newPulseDuration).firstOrNull()
?: 0f
// Call the centralized helper, resetting the fluence slider.
updateLaserParameters(
newPulseDuration = newPulseDuration,
newFluence = firstFluence,
newFluence = currentFluence,
)
}
}
@@ -2216,8 +2298,12 @@ class MainViewModel @Inject constructor(
fun onChangeRepetition(angle: Float) {
// This logic is simple and has no complex dependencies, so it can remain as is.
if (angle != repetitionAngle.value) {
setRepetitionAngle(angle)
val normalizedAngle = repetitionIndexToAngle(
index = angle.degreeToStep(totalSteps = repetitionList.value.size),
totalSteps = repetitionList.value.size,
)
if (normalizedAngle != repetitionAngle.value) {
setRepetitionAngle(normalizedAngle)
}
setSelectedPresetIndex(0)
}
@@ -2228,12 +2314,23 @@ class MainViewModel @Inject constructor(
val newStep = if (state == UpDownState.Up) currentStep + 1 else currentStep - 1
if (newStep in repetitionList.value.indices) {
val newRepetitionAngle = newStep.stepToDegree(totalSteps = repetitionList.value.size)
val newRepetitionAngle = repetitionIndexToAngle(
index = newStep,
totalSteps = repetitionList.value.size,
)
setRepetitionAngle(newRepetitionAngle)
setSelectedPresetIndex(0)
}
}
private fun repetitionIndexToAngle(index: Int, totalSteps: Int): Float {
return when {
totalSteps <= 0 -> 0f
totalSteps == 1 -> 270f
else -> index.stepToDegree(totalSteps = totalSteps)
}
}
/**
* Perform all heavy I/O in a single background block.
* This prevents the "Skipped frames" caused by 30+ sequential bridge calls
@@ -2286,4 +2383,4 @@ class MainViewModel @Inject constructor(
// ONLY do light, non-IO variable initialization here.
// DO NOT call load... functions here.
}
}
}

View File

@@ -487,7 +487,7 @@ fun QSwitch.toByteArray(): ByteArray {
val delayTimeIntegerPart = this.delayTime.toInt()
val delayTimeFractionPart = ((this.delayTime - delayTimeIntegerPart) * 10).toInt()
val intervalTimeIntegerPart = this.intervalTime.toInt()
val intervalTimeFractionPart = ((this.intervalTime - delayTimeIntegerPart) * 10).toInt()
val intervalTimeFractionPart = ((this.intervalTime - intervalTimeIntegerPart) * 10).toInt()
val delayTimeArray = byteArrayOf(
((delayTimeIntegerPart.getNthDigit(2) + 0x30) and 0xFF).toByte(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,23 @@
# 변경 요약 (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가 호출되어 초기 패킷이 누락되는 문제를 줄임

View File

@@ -0,0 +1,237 @@
# 변경 소스 설명 (2026-03-10)
## 대상
현재 워크스페이스에서 수정된 항목은 아래와 같습니다.
- `app/src/main/java/com/laseroptek/raman/ui/screens/home/preset/PresetViewModel.kt`
- `app/build.gradle.kts`
실제 로직 변경은 `PresetViewModel.kt`에 있습니다.
`app/build.gradle.kts``git status`상 수정으로 보이지만, 확인한 텍스트 diff 기준으로는 의미 있는 코드 변경이 보이지 않았습니다. 줄바꿈(CRLF/LF) 같은 형식 변화일 가능성이 큽니다.
## 이 파일이 하는 일
`PresetViewModel.kt`는 프리셋 화면의 상태를 관리하는 코드입니다.
쉽게 말하면 사용자가 화면에서 아래 값을 올리거나 내릴 때:
- 핸드피스 종류
- 펄스폭 (`pulseWidth`)
- 에너지 단계 (`fluence`)
- 반복수 (`repetition`)
그 조합에 맞는 다음 값들을 다시 계산해서 화면에 반영하는 역할입니다.
## 이번 변경의 핵심
핵심은 반복수(`repetition`) 계산을 더 안전하고 더 자연스럽게 만든 것입니다.
이전 방식은 다음 문제가 생길 수 있었습니다.
- `pulseWidth``fluence` 조합이 테이블에 정확히 없으면 적절한 반복수 목록을 못 찾을 수 있음
- 못 찾았을 때 강제 `!!` 사용으로 앱이 비정상 종료될 가능성이 있음
- `fluence` 변경 시 다른 필드를 잘못 갱신하는 버그가 있었음
이번 수정에서는 공통 계산 함수가 추가되어, 값이 정확히 일치하지 않아도 가장 가까운 `fluence`를 찾아서 처리하도록 바뀌었습니다.
또한 `pulse duration`이나 `fluence`를 바꿀 때 repetition이 무조건 목록의 첫 값(`1Hz` 등)으로 초기화되지 않도록 규칙이 바뀌었습니다.
새 규칙은 아래와 같습니다.
- 현재 repetition 값 이하인 후보들 중
- 가장 큰 값을 선택
- 만약 그런 값이 하나도 없으면 새 목록의 최소값 사용
예를 들어 현재 repetition이 `10Hz`이고, 새 조건에서 가능한 repetition 목록이 `1, 3, 5, 8`이라면 결과는 `8Hz`가 됩니다.
즉, "현재 값에 최대한 가깝게 유지하되, 새 조건에서 허용되는 값으로 내린다"는 방식입니다.
## 새로 추가된 구조
### 1. `ResolvedRepetitionContext`
```kotlin
private data class ResolvedRepetitionContext(
val fluence: Float,
val repetitionOptions: List<Float>,
)
```
이 구조는 반복수 계산 결과를 한 번에 묶어서 전달하기 위한 작은 데이터 상자입니다.
- `fluence`: 실제로 확정된 에너지 값
- `repetitionOptions`: 그 조건에서 선택 가능한 반복수 목록
### 2. `resolveRepetitionContext()`
이 함수가 이번 수정의 핵심입니다.
동작 순서는 아래와 같습니다.
1. 현재 `pulseWidth`에 대해 사용 가능한 `fluence` 목록을 가져옵니다.
2. 사용자가 가지고 있던 `fluence`와 가장 가까운 실제 값을 찾습니다.
3. 그 조합으로 `hzType`을 찾습니다.
4. `hzType`에 맞는 반복수 목록을 가져옵니다.
5. 만약 `hzType`을 찾지 못하면 기본값(`KEY_YELLOW`) 목록으로 대체합니다.
6. 최종 반복수 목록을 `setRepetitionList()`로 화면 상태에 반영합니다.
즉, 예전보다 훨씬 덜 깨지도록 방어 로직이 들어갔습니다.
### 3. `resolveNearestLowerOrEqualRepetition()`
이 함수는 repetition 선택 규칙을 담당합니다.
동작은 단순합니다.
1. 새 repetition 목록에서 현재 repetition 이하인 값만 고릅니다.
2. 그중 가장 큰 값을 선택합니다.
3. 없다면 새 목록의 최소값을 사용합니다.
이 함수가 추가되면서 `pulse duration` 또는 `fluence` 변경 시 repetition이 불필요하게 `1Hz`로 떨어지지 않게 되었습니다.
## 함수별로 무엇이 바뀌었는가
### 1. `updateHandPieceType()`
핸드피스 종류를 바꾸면:
- 해당 핸드피스에 맞는 테이블을 다시 불러오고
- 기본 `pulseWidth`를 고른 뒤
- 가능한 `fluence`를 정하고
- 그에 맞는 `repetition` 목록을 계산합니다
변경 전에는 반복수 목록을 직접 찾다가 실패 가능성이 있었고,
변경 후에는 `resolveRepetitionContext()`를 통해 안전하게 계산합니다.
또한 새 반복수 값도 목록의 첫 번째 값을 안전하게 가져오도록 바뀌었습니다.
### 2. `updatePulseWidth()`
펄스폭을 올리거나 내리면:
-`pulseWidth`에 맞는 `fluence`를 다시 정하고
- 그 조합에 맞는 `repetition` 목록을 다시 계산합니다
여기도 동일하게 공통 함수 사용으로 바뀌어,
정확히 일치하는 값이 없을 때 현재 값 또는 가장 가까운 값으로 안전하게 처리합니다.
추가로 repetition 선택 방식도 바뀌었습니다.
- 예전: 새 repetition 목록의 첫 번째 값으로 사실상 초기화
- 현재: 기존 repetition 이하에서 가장 가까운 최대값 선택
예:
- 기존 repetition = `10Hz`
- 새 목록 = `1, 3, 5, 8`
- 결과 = `8Hz`
### 3. `updateFluence()`
이 부분은 버그 수정이 포함되어 있어 중요합니다.
기존 코드에는 주석으로도 남아 있듯이,
원래 `fluence`를 업데이트해야 하는 상황에서 잘못된 필드를 갱신하던 문제가 있었습니다.
수정 후에는:
- `pulseWidth`에 맞는 `fluence` 목록에서 다음/이전 값을 고르고
- 그 값으로 반복수 목록을 다시 계산한 뒤
- repetition도 기존 값 이하에서 가장 가까운 최대값으로 다시 선택하고
- 실제 프리셋에는 `fluence = repetitionContext.fluence`를 저장합니다
즉, 사용자가 에너지 단계를 바꿨을 때 실제 프리셋 데이터도 올바르게 바뀌도록 고친 것입니다.
이제 `fluence` 변경 시에도 repetition이 불필요하게 최소값으로 초기화되지 않습니다.
### 4. `updateRepetition()`
반복수만 변경할 때도 사실은 현재 `pulseWidth``fluence`에 맞는 반복수 목록이 먼저 계산되어야 합니다.
이번 수정 후에는:
- 현재 조건에 맞는 반복수 목록을 공통 함수로 얻고
- 그 목록 안에서 위/아래 이동을 수행하고
- 필요하면 `fluence`도 보정된 값으로 같이 저장합니다
이전 코드보다 현재 조건과 실제 목록이 더 일관되게 맞춰집니다.
## 왜 이 수정이 필요한가
이 코드는 여러 테이블을 조합해서 동작합니다.
- `energyTable`: `(pulseWidth, fluence)` 조합에 대한 에너지 정보
- `hzTable`: `(pulseWidth, fluence)` 조합에 대한 `hzType`
- `RepetitionsByColorKey`: `hzType`에 따라 선택 가능한 반복수 목록
문제는 사용자가 가진 값이 테이블의 키와 딱 맞지 않을 수 있다는 점입니다.
예를 들어:
- 현재 `pulseWidth`는 존재하지만
- `fluence`가 소수점 차이로 정확히 일치하지 않거나
- 핸드피스 변경 후 이전 값이 새 테이블에 없는 경우
예전 코드는 이런 경우에 취약했습니다.
이번 수정은 이런 경계 상황에서도 앱이 가능한 범위 안에서 계속 동작하도록 만든 것입니다.
## 코틀린을 모르는 사람 기준으로 문법 설명
### `fun`
함수를 만든다는 뜻입니다.
```kotlin
fun updateFluence(...)
```
### `val`
한 번 정하면 다시 바꾸지 않는 값입니다.
```kotlin
val newFluence = ...
```
### `data class`
관련 있는 값 몇 개를 묶는 작은 자료형입니다.
```kotlin
data class ResolvedRepetitionContext(...)
```
### `copy(...)`
기존 객체를 복사하면서 일부 값만 바꾸는 방식입니다.
```kotlin
currentList[index].copy(
fluence = repetitionContext.fluence,
repetition = newRepetition
)
```
이 코드는 "기존 프리셋을 복사하되 `fluence``repetition`만 새 값으로 바꾼다"는 뜻입니다.
### `?:`
왼쪽 값이 없으면 오른쪽 값을 대신 사용하라는 뜻입니다.
```kotlin
val newFluence = newFluenceList.firstOrNull() ?: currentList[index].fluence
```
즉:
- 목록 첫 값이 있으면 그 값을 쓰고
- 없으면 현재 저장된 `fluence`를 그대로 씁니다
### `firstOrNull()`
목록의 첫 번째 값을 가져오되, 비어 있으면 오류 대신 `null`을 돌려줍니다.
안전한 코드에서 자주 쓰입니다.
## 한 줄 요약
이번 수정은 프리셋 편집 시 `pulseWidth`, `fluence`, `repetition`의 연결 계산을 더 안전하게 만들고, `pulse duration``fluence` 변경 시 repetition을 현재 값에 가장 가깝게 유지하도록 개선했으며, `fluence` 변경 시 잘못된 필드가 갱신되던 버그도 함께 바로잡은 변경입니다.

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) 수신 여부 확인