feat(clinical): 프로브 파라미터 직접 설정 (mcs 수동 주입)

주파수(1.8/2.3) · cycle(3/5/7) 을 골라 "기기에 주입"으로 프로브에 써 넣는다.
병원 임상 모드 화면 아래, mcs 영구저장 경고가 있는 자리에 둔다.

## 왜 필요한가
프로토콜은 6조합을 자동으로 돌지만 **그 밖의 측정은 프로브에 마지막으로 남은
설정으로 돈다.** 프로토콜을 돌리면 2.3MHz·cycle 7 이, 정렬을 하면 2.3MHz·cycle 3 이
남는다. 원하는 조건으로 되돌리거나 특정 조건만 시험하려면 손으로 넣을 길이
있어야 한다는 현장 요구.

## echo 를 보고 나서 성공이라고 말한다
설정이 거부돼도 프로브는 옛 설정으로 측정을 계속한다. 그래서 `rcs:` 응답 값이
요청과 같은지까지 확인하고, 결과를 네 갈래로 구분해 보여 준다:
  적용됨 / 응답 없음 / 거부됨(펌웨어 사유) / 값 불일치(프로브에 남은 실제 값 표시)
프로토콜 루프가 조합마다 하는 검사와 같은 기준이다.

avg 10 · delay 10µs · samples 100 은 고정이며 화면에 명시한다. 측정 중에는
비활성 — 루프가 조합을 바꿔 가며 쓰는 중에 끼어들면 라벨과 데이터가 어긋난다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:23:46 +09:00
parent 7d44b22e96
commit a58c927066
@@ -47,6 +47,7 @@ import com.medithings.vesiscan.ui.theme.*
import androidx.compose.foundation.border import androidx.compose.foundation.border
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray import org.json.JSONArray
@@ -376,6 +377,9 @@ fun HospitalModeView(appState: AppState) {
} }
} }
Spacer(Modifier.height(20.dp))
ProbeParamCard(bleManager = bleManager, enabled = !running)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
"저장 위치: Downloads/VesiScan_Hospital/", "저장 위치: Downloads/VesiScan_Hospital/",
@@ -551,3 +555,100 @@ private fun AnchorCard(anchorCm: Int?, enabled: Boolean, onAlign: () -> Unit) {
) )
} }
} }
/**
* 프로브 파라미터 수동 주입 — 주파수·cycle 을 골라 `mcs` 로 직접 써 넣는다.
*
* ## 왜 필요한가
* 프로토콜은 6조합을 자동으로 돌지만, 그 밖의 측정(일반 측정·정렬 외 실험)은 프로브에
* **마지막으로 남은 설정**으로 돈다. 프로토콜을 돌리고 나면 2.3MHz·cycle 7 이 남고,
* 정렬을 하면 2.3MHz·cycle 3 이 남는다. 원하는 조건으로 되돌리거나 특정 조건만
* 시험하려면 손으로 넣을 길이 있어야 한다.
*
* ## echo 를 확인하고 나서 성공이라고 말한다
* 설정이 거부돼도 프로브는 옛 설정으로 측정을 계속한다. 응답(`rcs:`)의 값이 요청과
* 같은지까지 봐야 "들어갔다"고 할 수 있다 — 프로토콜 루프가 조합마다 하는 검사와 같다.
*/
@Composable
private fun ProbeParamCard(bleManager: BleManager, enabled: Boolean) {
val scope = rememberCoroutineScope()
var freq by remember { mutableStateOf(ProbeFrequency.F_2_3) }
var cycle by remember { mutableStateOf(ProbeCycle.C3) }
var busy by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
var ok by remember { mutableStateOf(false) }
val connected by bleManager.isConnected
Column(
Modifier.fillMaxWidth()
.background(Color.White, RoundedCornerShape(12.dp))
.padding(12.dp)
) {
Text("프로브 파라미터 직접 설정", fontSize = 15.sp, fontWeight = FontWeight.Bold)
Text(
"고른 값을 프로브에 써 넣습니다. avg ${HospitalFixedParams.AVG} · " +
"delay ${HospitalFixedParams.DELAY_US}µs · samples ${HospitalFixedParams.SAMPLES} 는 고정입니다.",
fontSize = 11.sp, color = MlSecondaryText,
modifier = Modifier.padding(top = 2.dp, bottom = 8.dp),
)
Label("주파수")
ChipRow(ProbeFrequency.entries, freq, { freq = it }, enabled && !busy) {
"${it.label}MHz"
}
Spacer(Modifier.height(10.dp))
Label("cycle")
ChipRow(ProbeCycle.entries, cycle, { cycle = it }, enabled && !busy) {
"${it.cycles}"
}
Spacer(Modifier.height(12.dp))
Button(
onClick = {
busy = true; result = null
scope.launch {
bleManager.piezoConfigEcho.value = null
bleManager.sendPiezoConfig(
freq = freq.freqOption,
cycles = cycle.cycles,
avg = HospitalFixedParams.AVG,
delayUs = HospitalFixedParams.DELAY_US,
samples = HospitalFixedParams.SAMPLES,
)
val echo = withTimeoutOrNull(CONFIG_TIMEOUT_MS) {
while (bleManager.piezoConfigEcho.value == null) delay(20)
bleManager.piezoConfigEcho.value
}
ok = echo?.matches(
freq.freqOption, cycle.cycles, HospitalFixedParams.AVG,
HospitalFixedParams.DELAY_US, HospitalFixedParams.SAMPLES,
) == true
result = when {
ok -> "적용됨 — ${freq.label}MHz · cycle ${cycle.cycles} (프로브에 저장)"
echo == null -> "응답 없음 — 연결과 프로브 상태를 확인하세요."
echo.error != null -> "거부됨 — ${echo.error}"
else -> "값 불일치 — 프로브는 ${echo.freq}/${echo.cycles} 로 남았습니다."
}
busy = false
}
},
enabled = enabled && !busy && connected,
modifier = Modifier.fillMaxWidth().height(46.dp),
shape = RoundedCornerShape(12.dp),
) {
Text(if (busy) "주입 중…" else "기기에 주입", fontWeight = FontWeight.Bold)
}
result?.let {
Text(
it, fontSize = 12.sp,
color = if (ok) MlSuccess else MlCritical,
modifier = Modifier.padding(top = 8.dp),
)
}
if (!connected) {
Text("기기를 먼저 연결하세요.", fontSize = 11.sp, color = MlSecondaryText,
modifier = Modifier.padding(top = 6.dp))
}
}
}