diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index 18c6523..cf7f8e7 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -23,6 +23,32 @@ data class BleDevice( 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") 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))) } + /** `rcs:` echo — 프로브에 **실제로 저장된** 측정 파라미터. 아직 못 받았으면 null. */ + val piezoConfigEcho = mutableStateOf(null) + /** - * 송신 주파수 · cycle 설정 (`mcs?`). + * 측정 파라미터 (`mcs?`). 프로브 NVS 에 저장되어 전원을 껐다 켜도 유지된다. * - * ## mpa 가 아니다 (2026-09-02 펌웨어팀 확인) - * 이 앱은 여태 `mpa?`(piezo power ON)에 [freq, cycles] 를 실어 보내고 있었고, 그것이 - * 주파수 설정이라고 코드가 가정하고 있었다. 실제 설정 명령은 **`mcs?`** 다. - * 즉 지금까지 앱은 주파수·cycle 을 **한 번도 바꾼 적이 없다** — 프로브 기본값으로만 - * 측정해 온 셈이다. + * 2026-09-02 펌웨어팀 확인 — 이 앱이 여태 쓰던 `mpa?`(piezo power ON)는 설정 명령이 + * 아니었다. 즉 지금까지 주파수·cycle 을 한 번도 바꾼 적이 없다. * - * ## ⚠ 인자 구성은 아직 확인 전이다 - * 명령 이름만 확인됐고, 인자의 개수·순서·인코딩은 듣지 못했다. 여기서는 이 프로토콜의 - * 다른 수치 명령과 같은 규약을 따른다고 **가정**한다: - * "mcs?" + freq(BE 2B) + cycles(BE 2B) + CRC16(LE 2B) = 10 B - * 그리고 값↔MHz 대응도 여전히 모른다(HospitalProtocol KDoc 참고). + * 요청 mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B + * 응답 rcs: 같은 구성으로 저장된 값을 echo * - * 확인되면 이 함수 한 곳만 고치면 된다. 응답 태그는 `sendRaw` 가 m→r 로 바꿔 - * `rcs` 를 기다린다 — 파서에 rcs 분기가 없어도 큐는 태그로 해제된다. + * 범위: freq 0~5(0=1.8 … 5=2.3MHz) · cycles 3~7 · avg 1~10 · delay_us 0~50 · + * samples 80~117. + * + * ⚠ **응답을 반드시 확인해야 한다.** 실패해도 `rcs:` 는 오고, 그때도 측정은 옛 + * 설정으로 계속 돈다. 확인하지 않으면 파일에는 요청한 값이 적힌 채 다른 설정의 + * 데이터가 쌓인다. [piezoConfigEcho] 를 보고 요청과 같은지 대조할 것. */ - fun sendPiezoConfig(freqOption: Int, cycles: Int) { - sendRaw(CRC16.buildCommandBE("mcs", intArrayOf(freqOption, cycles))) + fun sendPiezoConfig(freq: Int, cycles: Int, avg: Int, delayUs: Int, samples: Int) { + piezoConfigEcho.value = null + sendRaw(CRC16.buildCommandBE("mcs", intArrayOf(freq, cycles, avg, delayUs, samples))) } fun sendPiezoStop() { @@ -1663,6 +1691,21 @@ class BleManager private constructor(private val context: Context) { "rer:" -> { 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 (210B): tag 4 + ch_info 2 + num_sample 2 + ADC + CRC 2 → ADC = size-10 val samples = ((data.size - 10) / 2).coerceAtLeast(0) diff --git a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt index 38e25f2..b3ff2c8 100644 --- a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt +++ b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt @@ -9,31 +9,50 @@ * 사람이 개입하는 지점을 방광 채움 하나로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면 * 반드시 빠뜨리거나 잘못 기록한다. 6조합 × n회가 한 번의 버튼으로 끝난다. * - * ## ⚠ 주파수 코드값이 아직 확인되지 않았다 (2026-09-02) - * 펌웨어에 보내는 `mpa?` 명령의 첫 인자는 **정수**인데, 그 정수와 실제 MHz 의 대응이 - * 코드에도 `docs/BLE_PROTOCOL_REFERENCE.md` 에도 적혀 있지 않다. 기존 코드는 항상 - * `freqOption = 2` 만 쓰고 있었다(PlacementGuideView · MeasurementService). + * ## 측정 파라미터 (2026-09-02 펌웨어팀 확인) + * `mcs?` 는 **다섯** 개를 받는다 — freq · cycles · avg · delay_us · samples. * - * 그래서 [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 -/** 프로브 송신 주파수. [freqOption] 이 `mpa?` 의 첫 인자로 나간다. */ +/** + * 프로브 송신 주파수. [freqOption] 이 `mcs?` 의 첫 인자다. + * + * 펌웨어는 0~5 여섯 단계를 지원하지만(0=1.8 … 5=2.3, 0.1MHz 간격) 이 프로토콜은 양 끝 + * 둘만 쓴다. 중간값이 필요해지면 여기 항목만 더하면 된다. + */ enum class ProbeFrequency(val label: String, val freqOption: Int) { - // ⚠ freqOption 값은 펌웨어팀 확인 전의 가정이다. 위 KDoc 참고. - F_1_8("1.8", 1), - F_2_3("2.3", 2), // 기존 코드가 늘 쓰던 값이 2 다. + F_1_8("1.8", 0), + F_2_3("2.3", 5), ; - /** 파일명에 쓸 표기 — MHz 와 실제 보낸 정수를 함께 남긴다. */ - val fileTag: String get() = "${label}MHz-fopt$freqOption" + /** 파일명에 쓸 표기 — MHz 와 실제 보낸 값을 함께 남긴다. */ + val fileTag: String get() = "${label}MHz-f$freqOption" } -/** 버스트 cycle 수. `mpa?` 의 둘째 인자로 그대로 나간다. */ +/** 버스트 cycle 수. `mcs?` 의 둘째 인자로 그대로 나간다 (허용 3~7). */ enum class ProbeCycle(val cycles: Int) { C3(3), C5(5), C7(7); @@ -65,3 +84,21 @@ val HOSPITAL_COMBINATIONS: List> = /** 한 조합당 기본 반복 횟수. 화면에서 바꿀 수 있다. */ 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 +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index 0218cd4..fdc158c 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -54,6 +54,9 @@ import java.util.Date /** 한 회차 응답을 이만큼 기다린다. 넘으면 실패로 세고 다음 회차로 넘어간다. */ 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 @@ -78,6 +81,7 @@ fun HospitalModeView(appState: AppState) { var okCount by remember { mutableIntStateOf(0) } var failCount by remember { mutableIntStateOf(0) } var lastFile by remember { mutableStateOf(null) } + var configFailed by remember { mutableStateOf(null) } val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS val canStart = isConnected && patient.isNotBlank() && !running @@ -89,7 +93,7 @@ fun HospitalModeView(appState: AppState) { val dir = HospitalRunStore.runDir(patient, startedAt) val fw = bleManager.firmwareVersion.value val results = JSONArray() - okCount = 0; failCount = 0 + okCount = 0; failCount = 0; configFailed = null // 6채널 완료를 코루틴에서 받는 통로. 콜백은 BLE 스레드에서 오므로 평범한 var 로 // 주고받으면 가시성이 보장되지 않는다 — Channel 로 넘긴다. @@ -108,9 +112,39 @@ fun HospitalModeView(appState: AppState) { val file = HospitalRunStore.combinationFile( dir, patient, posture, fill, freq, cycle, startedAt) - // 조합 진입 — 프로브에 주파수·cycle 을 실어 보낸다. - // `mpa?`(power ON)가 아니라 `mcs?`(설정)다 — 2026-09-02 펌웨어팀 확인. - bleManager.sendPiezoConfig(freqOption = freq.freqOption, cycles = cycle.cycles) + // 조합 진입 — 프로브에 측정 파라미터를 저장시킨다(`mcs?`). + // + // **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) var ok = 0; var fail = 0 @@ -160,7 +194,11 @@ fun HospitalModeView(appState: AppState) { put("device", deviceName ?: "") 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) }, startedAt) running = false @@ -246,6 +284,14 @@ fun HospitalModeView(appState: AppState) { Spacer(Modifier.height(12.dp)) Text("직전 실행 — 저장 $okCount · 실패 $failCount", 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( - "⚠ 주파수 코드값(mpa 첫 인자)은 펌웨어팀 확인 전입니다. " + - "파일에 실제 전송값(fopt)을 함께 기록하므로 나중에 라벨을 고칠 수 있습니다.", + "⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " + + "avg ${HospitalFixedParams.AVG} · delay ${HospitalFixedParams.DELAY_US}µs · " + + "samples ${HospitalFixedParams.SAMPLES}. 측정이 끝나도 마지막 조합" + + "(2.3MHz · cycle 7) 설정이 프로브에 남아 일반 측정에도 적용됩니다.", fontSize = 11.sp, color = Color(0xFFB26A00), modifier = Modifier.padding(top = 6.dp, bottom = 24.dp), )