feat(ble): 프로브 측정 파라미터 읽기(mcf?) · 임상 전후 보존 도구

지금까지 프로브에 무엇이 저장돼 있는지 **알 방법이 없었다.** `mcs?`(쓰기)의 echo
`rcs:` 로만 알 수 있었는데, 그건 이미 값을 바꾼 뒤다.

  · sendPiezoConfigQuery() — `mcf?` [tag][space][crc] 7B. 응답 `rcf:` 는 배치가
    `rcs:` 와 같아 파서를 그대로 옮겼다(실패 시 freq=0xFFFF 도 동일).
  · 응답은 piezoConfigRead 로 받는다. piezoConfigEcho 에 섞으면 안 된다 — 그쪽은
    "내가 방금 쓴 게 먹혔나"를 확인하는 자리라 쓰기 직전에 null 로 비우고 기다린다.
    조회 응답이 같은 곳에 들어오면 쓰기 검증이 엉뚱한 값을 보고 통과한다.

ProbeConfigGuard — 임상 측정 전후로 파라미터를 보존한다.

정렬과 병원 임상 모드는 자기 조건을 프로브 FDS 에 써 넣고 되돌리지 않는다. 일반 측정
화면은 mcs 를 아예 보내지 않고 프로브에 있는 값을 그대로 쓰므로, 임상을 한 번 돌리면
일반 측정의 취득 조건이 조용히 바뀐 채 남는다 — 주파수가 바뀌면 파형이 달라져 BV 에도
영향이 간다.

기본값을 박아 두지 않았다. "일반 측정용 기본값"이 앱 어디에도 없고, 제품이 어떤
조건으로 검증됐는지는 펌웨어·알고리즘 쪽 값이라 여기서 정할 수 없다. 정해서 박으면
그 값이 틀렸을 때 모든 임상 종료 시점에 틀린 값을 심는다. 대신 시작 전 값을 읽어
두었다가 그대로 되돌린다 — 원래 무엇이었든 전후가 같아진다.

  · 못 읽었으면 되돌리지 않는다. 짐작한 값을 쓰면 원래와 다른 것을 심어 놓고
    "복원했다"고 믿게 된다 — 안 되돌리는 것보다 나쁘다.
  · 쓰기 echo 까지 확인한다. 설정이 거부돼도 rcs: 는 오므로.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 15:49:53 +09:00
parent 49975b7792
commit 33e5946895
2 changed files with 136 additions and 0 deletions
@@ -806,6 +806,29 @@ class BleManager private constructor(private val context: Context) {
/** `rcs:` echo — 프로브에 **실제로 저장된** 측정 파라미터. 아직 못 받았으면 null. */
val piezoConfigEcho = mutableStateOf<PiezoConfig?>(null)
/**
* `rcf:` 응답 — 프로브 FDS 에 저장된 현재 측정 파라미터.
*
* [piezoConfigEcho] 와 **따로 둔다.** 그쪽은 "내가 방금 쓴 게 먹혔나"를 확인하는
* 용도라 쓰기 직전에 null 로 비우고 기다린다. 조회 응답이 같은 자리에 들어오면
* 쓰기 검증이 엉뚱한 값을 보고 통과해 버린다.
*/
val piezoConfigRead = mutableStateOf<PiezoConfig?>(null)
/**
* `mcf?` — 프로브에 저장된 측정 파라미터를 **읽는다**(쓰지 않는다).
*
* 이게 없던 동안에는 프로브 상태를 알 방법이 아예 없었다. `mcs?` 로 써 봐야 알 수
* 있었는데 그건 이미 바꾼 것이다. 임상 모드가 조합마다 파라미터를 덮어쓰고 되돌리지
* 않으므로, 일반 측정 전에 지금 무엇이 들어 있는지 확인할 수단이 필요하다.
*
* 응답은 [piezoConfigRead] 로 온다.
*/
fun sendPiezoConfigQuery() {
piezoConfigRead.value = null
sendRaw(CRC16.buildCommandASCII("mcf", " "))
}
/**
* 측정 파라미터 (`mcs?`). 프로브 NVS 에 저장되어 전원을 껐다 켜도 유지된다.
*
@@ -1706,6 +1729,22 @@ class BleManager private constructor(private val context: Context) {
debugLogger.rx("rcs", data.size, "too short")
}
}
"rcf:" -> {
// `mcf?` 응답. 배치가 `rcs:` 와 같다 —
// [rcf: 4B][freq][cycles][avg][delay_us][samples] 각 BE 2B + CRC 2B = 16B.
// 실패 시 freq 에 0xFFFF 가 실리는 것도 같다(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))
piezoConfigRead.value = cfg
debugLogger.rx("rcf", data.size,
cfg.error?.let { "조회 실패: $it" }
?: "freq=${cfg.freq} cyc=${cfg.cycles} avg=${cfg.avg} " +
"delay=${cfg.delayUs} samples=${cfg.samples}")
} else {
debugLogger.rx("rcf", 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)
@@ -0,0 +1,97 @@
/*
* ProbeConfigGuard — 임상 측정이 바꾼 프로브 파라미터를 원래대로 되돌린다.
*/
package com.medithings.vesiscan.ui.views.clinical
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.ble.PiezoConfig
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
/**
* 임상 측정 전후로 프로브 측정 파라미터(`mcs`)를 보존한다.
*
* ## 왜 필요한가
* 정렬과 병원 임상 모드는 자기 조건을 프로브에 **써 넣고 되돌리지 않는다.** 그 값은
* FDS(플래시)에 남아 다음 측정에도 적용되는데, 일반 측정 화면은 `mcs` 를 아예 보내지
* 않고 프로브에 있는 값을 그대로 쓴다. 그래서 임상을 한 번 돌리고 나면 일반 측정의
* 취득 조건이 조용히 바뀐 채로 남는다 — 주파수가 바뀌면 파형이 달라지므로 BV 에도 영향이 간다.
*
* ## 기본값을 박아 두지 않는 이유
* "일반 측정용 기본값"이 앱 어디에도 없다. 제품이 어떤 조건으로 검증됐는지는 펌웨어·
* 알고리즘 쪽 값이라 여기서 정할 수 없고, 정해서 박아 두면 그 값이 틀렸을 때 **모든
* 임상 종료 시점에** 틀린 값을 심게 된다.
*
* 대신 **시작 전 값을 읽어 두었다가 그대로 되돌린다**(`mcf?` → `mcs?`). 원래 무엇이었든
* 임상 전후가 같아진다. 기기마다 달라도, 나중에 제품 기본값이 바뀌어도 따라간다.
*
* ## 못 읽으면 되돌리지 않는다
* [capture] 가 실패하면 [restore] 는 아무것도 하지 않는다. 무엇으로 되돌려야 할지
* 모르는데 짐작한 값을 쓰면, 원래 값과 다른 것을 심어 놓고 "복원했다"고 믿게 된다.
* 그건 안 되돌리는 것보다 나쁘다 — 호출부가 [captured] 로 확인해 사용자에게 알린다.
*/
class ProbeConfigGuard(private val ble: BleManager) {
/** 시작 전에 읽어 둔 값. null 이면 못 읽었다는 뜻이고, 그때는 되돌리지 않는다. */
var captured: PiezoConfig? = null
private set
/** 되돌리기를 실제로 마쳤는가. 화면이 사용자에게 알리는 데 쓴다. */
var restored: Boolean = false
private set
/**
* 현재 프로브 설정을 읽어 둔다. 읽었으면 true.
*
* 실패해도 측정은 진행해야 한다 — 복원을 못 하는 것과 측정을 못 하는 것은 다른 문제다.
*/
suspend fun capture(): Boolean {
ble.sendPiezoConfigQuery()
val cfg = withTimeoutOrNull(TIMEOUT_MS) {
while (ble.piezoConfigRead.value == null) delay(POLL_MS)
ble.piezoConfigRead.value
}
// 펌웨어가 실패를 값으로 알려 준다(freq=0xFFFF). 그건 읽은 게 아니다.
captured = cfg?.takeIf { it.error == null }
return captured != null
}
/**
* 읽어 둔 값으로 되돌린다. 되돌렸으면 true.
*
* 쓰기 echo(`rcs:`)까지 확인한다 — 설정이 거부돼도 응답은 오므로, 확인하지 않으면
* 되돌리지 못한 것을 되돌렸다고 기록하게 된다.
*/
suspend fun restore(): Boolean {
val c = captured ?: return false
ble.piezoConfigEcho.value = null
ble.sendPiezoConfig(
freq = c.freq, cycles = c.cycles, avg = c.avg,
delayUs = c.delayUs, samples = c.samples,
)
val echo = withTimeoutOrNull(TIMEOUT_MS) {
while (ble.piezoConfigEcho.value == null) delay(POLL_MS)
ble.piezoConfigEcho.value
}
restored = echo != null && echo.error == null &&
echo.freq == c.freq && echo.cycles == c.cycles
return restored
}
/** 화면에 띄울 한 줄. 복원 결과를 사용자가 알아야 다음 측정을 신뢰할 수 있다. */
fun summary(): String? = when {
captured == null ->
"프로브 설정을 읽지 못해 측정 전 값으로 되돌리지 못했습니다. " +
"일반 측정 전에 파라미터를 확인하세요."
restored -> captured?.let {
"프로브 설정을 측정 전 값(freq ${it.freq} · cycle ${it.cycles})으로 되돌렸습니다."
}
else ->
"프로브 설정 복원에 실패했습니다. 일반 측정 전에 파라미터를 확인하세요."
}
private companion object {
const val TIMEOUT_MS = 3_000L
const val POLL_MS = 20L
}
}