fix(ble): mcs 스펙 반영 — 인자 5개 · 주파수 0/5 · rcs echo 검증

## 앞선 커밋이 세 군데 틀렸다
2026-09-02 펌웨어팀 스펙을 받아 바로잡는다.

1. **인자가 2개가 아니라 5개다.**
   mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B
   avg·delay_us·samples 를 안 보내면 길이 부족으로 거부된다(freq=0xFFFF 응답).

2. **주파수 값이 1/2 가 아니라 0/5 다.**
   0=1.8 · 1=1.9 · 2=2.0 · 3=2.1 · 4=2.2 · 5=2.3 MHz.
   앞선 커밋의 가정(1.8→1, 2.3→2)은 둘 다 틀렸다 — 실제로는 2.0 과 2.3 을 재게 된다.
   가정을 파일명에 남겨 둔 안전장치가 없었다면 못 알아챌 뻔했다.

3. **응답을 확인하지 않고 있었다.** 이게 가장 위험하다 — 아래 참고.

## 설정 실패는 조용하다 — 그래서 반드시 확인한다
실패해도 `rcs:` 는 온다. 구분은 freq 값이다:
  · 0xFFFF — 파라미터 범위 초과 또는 데이터 길이 부족
  · 0xFFFD — 검증은 통과했으나 NVS 저장 실패

거부돼도 프로브는 **옛 설정으로 측정을 계속한다.** 확인하지 않으면 파일에는 요청한
값이 적힌 채 다른 조건의 데이터가 쌓인다 — 잘못된 데이터가 정상처럼 보이는, 임상에서
가장 나쁜 결과다.

그래서 조합마다 echo 를 받아 **요청한 다섯 값과 전부 일치할 때만** 측정한다.
불일치·무응답이면 그 조합을 통째로 건너뛰고 run json 에 `skipped_reason` 을 남기며
화면에 빨갛게 띄운다. 비는 편이 틀린 것보다 낫다.

## 고정 파라미터
프로토콜이 바꾸는 것은 주파수·cycle 뿐이다. 나머지 셋은 모든 조합에서 같아야 비교가
성립하므로 `HospitalFixedParams` 한 곳에 둔다 — avg 10 · delay_us 10 · samples 100
(펌웨어팀 예시값, samples 는 앱 채널 버퍼 100 과도 일치).

  1.8MHz·c3  6D 63 73 3F 00 00 00 03 00 0A 00 0A 00 64 3C 4A
  2.3MHz·c7  6D 63 73 3F 00 05 00 07 00 0A 00 0A 00 64 36 FC

## ⚠ 설정이 프로브에 영구 저장된다 (NVS)
전원을 껐다 켜도 유지된다. 즉 이 모드로 측정하고 나면 **일반 측정 화면도 마지막
조합(2.3MHz·cycle 7)으로 동작한다.** run json 에 남기고 화면에도 명시했다.
임상 후 원래 값으로 되돌릴지는 별도 결정이 필요하다 — 공장 기본값을 모른다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 17:11:25 +09:00
parent d1a5cfd4b4
commit c5dd0fdc3e
3 changed files with 165 additions and 37 deletions
@@ -23,6 +23,32 @@ data class BleDevice(
val address: String get() = device.address val address: String get() = device.address
} }
/**
* 프로브에 저장된 측정 파라미터 (`rcs:` echo).
*
* [freq] 는 정상이면 0~5 지만 실패 시 오류 코드가 실려 온다 — [error] 참고.
*/
data class PiezoConfig(
val freq: Int,
val cycles: Int,
val avg: Int,
val delayUs: Int,
val samples: Int,
) {
/** 펌웨어가 알려 주는 실패 사유. 정상이면 null. */
val error: String?
get() = when (freq) {
0xFFFF -> "파라미터 범위 초과 또는 데이터 길이 부족"
0xFFFD -> "NVS 저장 실패"
else -> null
}
/** 요청한 값이 그대로 저장됐는지. 하나라도 다르면 그 설정으로 측정하면 안 된다. */
fun matches(freq: Int, cycles: Int, avg: Int, delayUs: Int, samples: Int): Boolean =
error == null && this.freq == freq && this.cycles == cycles &&
this.avg == avg && this.delayUs == delayUs && this.samples == samples
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
class BleManager private constructor(private val context: Context) { class BleManager private constructor(private val context: Context) {
@@ -777,26 +803,28 @@ class BleManager private constructor(private val context: Context) {
sendRaw(CRC16.buildCommandBE("mpa", intArrayOf(freqOption, cycles))) sendRaw(CRC16.buildCommandBE("mpa", intArrayOf(freqOption, cycles)))
} }
/** `rcs:` echo — 프로브에 **실제로 저장된** 측정 파라미터. 아직 못 받았으면 null. */
val piezoConfigEcho = mutableStateOf<PiezoConfig?>(null)
/** /**
* 송신 주파수 · cycle 설정 (`mcs?`). * 측정 파라미터 (`mcs?`). 프로브 NVS 에 저장되어 전원을 껐다 켜도 유지된다.
* *
* ## mpa 가 아니다 (2026-09-02 펌웨어팀 확인) * 2026-09-02 펌웨어팀 확인 — 이 앱이 여태 쓰던 `mpa?`(piezo power ON)는 설정 명령이
* 이 앱은 여태 `mpa?`(piezo power ON)에 [freq, cycles] 를 실어 보내고 있었고, 그것이 * 아니었다. 즉 지금까지 주파수·cycle 을 한 번도 바꾼 적이 없다.
* 주파수 설정이라고 코드가 가정하고 있었다. 실제 설정 명령은 **`mcs?`** 다.
* 즉 지금까지 앱은 주파수·cycle 을 **한 번도 바꾼 적이 없다** — 프로브 기본값으로만
* 측정해 온 셈이다.
* *
* ## ⚠ 인자 구성은 아직 확인 전이다 * 요청 mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B
* 명령 이름만 확인됐고, 인자의 개수·순서·인코딩은 듣지 못했다. 여기서는 이 프로토콜의 * 응답 rcs: 같은 구성으로 저장된 값을 echo
* 다른 수치 명령과 같은 규약을 따른다고 **가정**한다:
* "mcs?" + freq(BE 2B) + cycles(BE 2B) + CRC16(LE 2B) = 10 B
* 그리고 값↔MHz 대응도 여전히 모른다(HospitalProtocol KDoc 참고).
* *
* 확인되면 이 함수 한 곳만 고치면 된다. 응답 태그는 `sendRaw` 가 m→r 로 바꿔 * 범위: freq 0~5(0=1.8 … 5=2.3MHz) · cycles 3~7 · avg 1~10 · delay_us 0~50 ·
* `rcs` 를 기다린다 — 파서에 rcs 분기가 없어도 큐는 태그로 해제된다. * samples 80~117.
*
* ⚠ **응답을 반드시 확인해야 한다.** 실패해도 `rcs:` 는 오고, 그때도 측정은 옛
* 설정으로 계속 돈다. 확인하지 않으면 파일에는 요청한 값이 적힌 채 다른 설정의
* 데이터가 쌓인다. [piezoConfigEcho] 를 보고 요청과 같은지 대조할 것.
*/ */
fun sendPiezoConfig(freqOption: Int, cycles: Int) { fun sendPiezoConfig(freq: Int, cycles: Int, avg: Int, delayUs: Int, samples: Int) {
sendRaw(CRC16.buildCommandBE("mcs", intArrayOf(freqOption, cycles))) piezoConfigEcho.value = null
sendRaw(CRC16.buildCommandBE("mcs", intArrayOf(freq, cycles, avg, delayUs, samples)))
} }
fun sendPiezoStop() { fun sendPiezoStop() {
@@ -1663,6 +1691,21 @@ class BleManager private constructor(private val context: Context) {
"rer:" -> { "rer:" -> {
debugLogger.rx("rer", data.size, "preliminary header") debugLogger.rx("rer", data.size, "preliminary header")
} }
"rcs:" -> {
// [rcs: 4B][freq][cycles][avg][delay_us][samples] 각 BE 2B + CRC 2B = 16B.
// 실패해도 이 응답은 온다 — freq 에 0xFFFF/0xFFFD 가 실린다(PiezoConfig.error).
if (data.size >= 14) {
fun be(i: Int) = ((data[i].toInt() and 0xFF) shl 8) or (data[i + 1].toInt() and 0xFF)
val cfg = PiezoConfig(be(4), be(6), be(8), be(10), be(12))
piezoConfigEcho.value = cfg
debugLogger.rx("rcs", data.size,
cfg.error?.let { "설정 실패: $it" }
?: "freq=${cfg.freq} cyc=${cfg.cycles} avg=${cfg.avg} " +
"delay=${cfg.delayUs} samples=${cfg.samples}")
} else {
debugLogger.rx("rcs", data.size, "too short")
}
}
"reb:" -> { "reb:" -> {
// 신구조 reb (210B): tag 4 + ch_info 2 + num_sample 2 + ADC + CRC 2 → ADC = size-10 // 신구조 reb (210B): tag 4 + ch_info 2 + num_sample 2 + ADC + CRC 2 → ADC = size-10
val samples = ((data.size - 10) / 2).coerceAtLeast(0) val samples = ((data.size - 10) / 2).coerceAtLeast(0)
@@ -9,31 +9,50 @@
* 사람이 개입하는 지점을 방광 채움 하나로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면 * 사람이 개입하는 지점을 방광 채움 하나로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면
* 반드시 빠뜨리거나 잘못 기록한다. 6조합 × n회가 한 번의 버튼으로 끝난다. * 반드시 빠뜨리거나 잘못 기록한다. 6조합 × n회가 한 번의 버튼으로 끝난다.
* *
* ## ⚠ 주파수 코드값이 아직 확인되지 않았다 (2026-09-02) * ## 측정 파라미터 (2026-09-02 펌웨어팀 확인)
* 펌웨어에 보내는 `mpa?` 명령의 첫 인자는 **정수**인데, 그 정수와 실제 MHz 의 대응이 * `mcs?` 는 **다섯** 개를 받는다 — freq · cycles · avg · delay_us · samples.
* 코드에도 `docs/BLE_PROTOCOL_REFERENCE.md` 에도 적혀 있지 않다. 기존 코드는 항상
* `freqOption = 2` 만 쓰고 있었다(PlacementGuideView · MeasurementService).
* *
* 그래서 [ProbeFrequency.freqOption] 값은 **가정**이다. 펌웨어팀 확인 후 이 표 한 줄만 * 요청: mcs? [tag 4B] [freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B] [crc 2B] = 16B
* 고치면 된다. * 응답: rcs: 같은 구성으로 **저장된 값을 echo**
* *
* 확인 전에 모은 데이터가 버려지지 않도록, 저장 파일명과 meta 에 **실제로 보낸 정수**를 * | 필드 | 범위 | 뜻 |
* `fopt{N}` 으로 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꿔 되살릴 수 있다. * |---|---|---|
* | freq | 0~5 | 0=1.8 · 1=1.9 · 2=2.0 · 3=2.1 · 4=2.2 · 5=2.3 MHz |
* | cycles | 3~7 | burst cycle 수 |
* | avg | 1~10 | averaging 횟수 |
* | delay_us | 0~50 | burst 후 capture delay |
* | samples | 80~117 | echo sample 개수 |
*
* ## ⚠ 설정은 프로브에 **영구 저장**된다 (NVS)
* 한 번 보내면 전원을 껐다 켜도 유지된다. 즉 이 모드로 측정하고 나면 **일반 측정
* 화면도 마지막으로 설정한 값으로 동작한다.** 임상이 끝나면 원래 값으로 되돌리든지,
* 적어도 무엇으로 바꿔 놓았는지 알고 있어야 한다 — run json 에 남긴다.
*
* ## 응답을 반드시 확인한다
* 실패해도 `rcs:` 는 온다. 구분은 freq 값이다:
* · 0xFFFF — 데이터 길이 부족 또는 파라미터 범위 초과
* · 0xFFFD — 검증은 통과했으나 NVS 저장 실패
* 확인하지 않으면 **설정이 거부된 채 옛 설정으로 측정**하고, 파일에는 요청한 값이
* 적힌다. 데이터가 조용히 잘못 라벨링되는 가장 나쁜 경우다.
*/ */
package com.medithings.vesiscan.models package com.medithings.vesiscan.models
/** 프로브 송신 주파수. [freqOption] 이 `mpa?` 의 첫 인자로 나간다. */ /**
* 프로브 송신 주파수. [freqOption] 이 `mcs?` 의 첫 인자다.
*
* 펌웨어는 0~5 여섯 단계를 지원하지만(0=1.8 … 5=2.3, 0.1MHz 간격) 이 프로토콜은 양 끝
* 둘만 쓴다. 중간값이 필요해지면 여기 항목만 더하면 된다.
*/
enum class ProbeFrequency(val label: String, val freqOption: Int) { enum class ProbeFrequency(val label: String, val freqOption: Int) {
// ⚠ freqOption 값은 펌웨어팀 확인 전의 가정이다. 위 KDoc 참고. F_1_8("1.8", 0),
F_1_8("1.8", 1), F_2_3("2.3", 5),
F_2_3("2.3", 2), // 기존 코드가 늘 쓰던 값이 2 다.
; ;
/** 파일명에 쓸 표기 — MHz 와 실제 보낸 정수를 함께 남긴다. */ /** 파일명에 쓸 표기 — MHz 와 실제 보낸 값을 함께 남긴다. */
val fileTag: String get() = "${label}MHz-fopt$freqOption" val fileTag: String get() = "${label}MHz-f$freqOption"
} }
/** 버스트 cycle 수. `mpa?` 의 둘째 인자로 그대로 나간다. */ /** 버스트 cycle 수. `mcs?` 의 둘째 인자로 그대로 나간다 (허용 3~7). */
enum class ProbeCycle(val cycles: Int) { enum class ProbeCycle(val cycles: Int) {
C3(3), C5(5), C7(7); C3(3), C5(5), C7(7);
@@ -65,3 +84,21 @@ val HOSPITAL_COMBINATIONS: List<Pair<ProbeFrequency, ProbeCycle>> =
/** 한 조합당 기본 반복 횟수. 화면에서 바꿀 수 있다. */ /** 한 조합당 기본 반복 횟수. 화면에서 바꿀 수 있다. */
const val HOSPITAL_DEFAULT_REPEATS = 20 const val HOSPITAL_DEFAULT_REPEATS = 20
/**
* 이 프로토콜이 고정으로 쓰는 나머지 측정 파라미터.
*
* 프로토콜이 바꾸는 것은 주파수와 cycle 뿐이다. 나머지 셋은 **모든 조합에서 같아야**
* 조합 간 비교가 성립하므로 여기 한 곳에 박아 둔다. 값은 펌웨어팀 예시
* (`00 05 00 03 00 0A 00 0A 00 64`)를 따랐다.
*
* ⚠ 이 값들도 프로브에 영구 저장된다. 바꾸면 이후 모든 측정에 영향을 준다.
*/
object HospitalFixedParams {
/** averaging 횟수 (허용 1~10). */
const val AVG = 10
/** burst 후 capture delay, µs (허용 0~50). */
const val DELAY_US = 10
/** echo sample 개수 (허용 80~117). 앱의 채널 버퍼가 100 샘플 기준이다. */
const val SAMPLES = 100
}
@@ -54,6 +54,9 @@ import java.util.Date
/** 한 회차 응답을 이만큼 기다린다. 넘으면 실패로 세고 다음 회차로 넘어간다. */ /** 한 회차 응답을 이만큼 기다린다. 넘으면 실패로 세고 다음 회차로 넘어간다. */
private const val MEASURE_TIMEOUT_MS = 3_000L private const val MEASURE_TIMEOUT_MS = 3_000L
/** `mcs?` 설정 echo(`rcs:`)를 기다리는 시간. */
private const val CONFIG_TIMEOUT_MS = 3_000L
/** 조합을 바꾼 뒤 프로브가 새 설정으로 안정될 때까지. */ /** 조합을 바꾼 뒤 프로브가 새 설정으로 안정될 때까지. */
private const val COMBINATION_SETTLE_MS = 800L private const val COMBINATION_SETTLE_MS = 800L
@@ -78,6 +81,7 @@ fun HospitalModeView(appState: AppState) {
var okCount by remember { mutableIntStateOf(0) } var okCount by remember { mutableIntStateOf(0) }
var failCount by remember { mutableIntStateOf(0) } var failCount by remember { mutableIntStateOf(0) }
var lastFile by remember { mutableStateOf<String?>(null) } var lastFile by remember { mutableStateOf<String?>(null) }
var configFailed by remember { mutableStateOf<String?>(null) }
val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS
val canStart = isConnected && patient.isNotBlank() && !running val canStart = isConnected && patient.isNotBlank() && !running
@@ -89,7 +93,7 @@ fun HospitalModeView(appState: AppState) {
val dir = HospitalRunStore.runDir(patient, startedAt) val dir = HospitalRunStore.runDir(patient, startedAt)
val fw = bleManager.firmwareVersion.value val fw = bleManager.firmwareVersion.value
val results = JSONArray() val results = JSONArray()
okCount = 0; failCount = 0 okCount = 0; failCount = 0; configFailed = null
// 6채널 완료를 코루틴에서 받는 통로. 콜백은 BLE 스레드에서 오므로 평범한 var 로 // 6채널 완료를 코루틴에서 받는 통로. 콜백은 BLE 스레드에서 오므로 평범한 var 로
// 주고받으면 가시성이 보장되지 않는다 — Channel 로 넘긴다. // 주고받으면 가시성이 보장되지 않는다 — Channel 로 넘긴다.
@@ -108,9 +112,39 @@ fun HospitalModeView(appState: AppState) {
val file = HospitalRunStore.combinationFile( val file = HospitalRunStore.combinationFile(
dir, patient, posture, fill, freq, cycle, startedAt) dir, patient, posture, fill, freq, cycle, startedAt)
// 조합 진입 — 프로브에 주파수·cycle 을 실어 보낸다. // 조합 진입 — 프로브에 측정 파라미터를 저장시킨다(`mcs?`).
// `mpa?`(power ON)가 아니라 `mcs?`(설정)다 — 2026-09-02 펌웨어팀 확인. //
bleManager.sendPiezoConfig(freqOption = freq.freqOption, cycles = cycle.cycles) // **echo 를 확인하고 나서 측정한다.** 설정이 거부돼도 프로브는 옛 설정으로
// 측정을 계속하므로, 확인하지 않으면 파일에는 요청한 값이 적힌 채 다른
// 조건의 데이터가 쌓인다 — 조용히 잘못 라벨링되는 가장 나쁜 경우다.
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
}
val configOk = echo?.matches(
freq.freqOption, cycle.cycles,
HospitalFixedParams.AVG, HospitalFixedParams.DELAY_US,
HospitalFixedParams.SAMPLES,
) == true
if (!configOk) {
// 이 조합은 통째로 건너뛴다. 잘못 라벨링된 데이터를 남기느니 비는 편이 낫다.
configFailed = echo?.error ?: "응답 없음 또는 설정 불일치"
results.put(JSONObject().apply {
put("freq_mhz", freq.label); put("freq_option", freq.freqOption)
put("cycles", cycle.cycles)
put("planned", repeats); put("saved", 0); put("failed", repeats)
put("skipped_reason", configFailed)
})
return@forEachIndexed
}
delay(COMBINATION_SETTLE_MS) delay(COMBINATION_SETTLE_MS)
var ok = 0; var fail = 0 var ok = 0; var fail = 0
@@ -160,7 +194,11 @@ fun HospitalModeView(appState: AppState) {
put("device", deviceName ?: "") put("device", deviceName ?: "")
put("firmware_version", fw) put("firmware_version", fw)
// ⚠ 주파수 코드값이 확인 전이라는 사실을 데이터와 함께 남긴다. // ⚠ 주파수 코드값이 확인 전이라는 사실을 데이터와 함께 남긴다.
put("freq_option_mapping_confirmed", false) // 프로브 NVS 에 남는 값 — 이 실행 뒤 일반 측정도 이 설정으로 돈다.
put("avg", HospitalFixedParams.AVG)
put("delay_us", HospitalFixedParams.DELAY_US)
put("samples", HospitalFixedParams.SAMPLES)
put("params_persist_in_probe_nvs", true)
put("combinations", results) put("combinations", results)
}, startedAt) }, startedAt)
running = false running = false
@@ -246,6 +284,14 @@ fun HospitalModeView(appState: AppState) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text("직전 실행 — 저장 $okCount · 실패 $failCount", Text("직전 실행 — 저장 $okCount · 실패 $failCount",
fontSize = 13.sp, color = MlSecondaryText) fontSize = 13.sp, color = MlSecondaryText)
// 설정이 거부된 조합이 있었으면 반드시 눈에 띄어야 한다. 그 조합은
// 데이터가 통째로 비어 있고, 방광을 다시 채우지 않으면 못 메운다.
configFailed?.let {
Text("⚠ 일부 조합에서 측정 파라미터 설정 실패: $it",
fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = Color(0xFFD32F2F),
modifier = Modifier.padding(top = 6.dp))
}
} }
} }
@@ -257,8 +303,10 @@ fun HospitalModeView(appState: AppState) {
// 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의 // 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의
// 한계를 알고 있어야 한다. // 한계를 알고 있어야 한다.
Text( Text(
"⚠ 주파수 코드값(mpa 첫 인자)은 펌웨어팀 확인 전입니다. " + "⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " +
"파일에 실제 전송값(fopt)을 함께 기록하므로 나중에 라벨을 고칠 수 있습니다.", "avg ${HospitalFixedParams.AVG} · delay ${HospitalFixedParams.DELAY_US}µs · " +
"samples ${HospitalFixedParams.SAMPLES}. 측정이 끝나도 마지막 조합" +
"(2.3MHz · cycle 7) 설정이 프로브에 남아 일반 측정에도 적용됩니다.",
fontSize = 11.sp, color = Color(0xFFB26A00), fontSize = 11.sp, color = Color(0xFFB26A00),
modifier = Modifier.padding(top = 6.dp, bottom = 24.dp), modifier = Modifier.padding(top = 6.dp, bottom = 24.dp),
) )