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
}
/**
* 프로브에 저장된 측정 파라미터 (`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<PiezoConfig?>(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)