chore(cleanup): NIRS/VIVAMYO 세트 완전 제거 (Category A · 사용자 요청)
배경: NIRS 는 초기 iOS 앱에서 이식했으나 현재 완전 미사용. Piezo 만 실사용.
완전 제거 (6 파일):
· managers/NirsManager.kt (94 L)
· managers/NirsCalcEngine.kt (206 L)
· models/NirsConstants.kt (49 L)
· models/NirsData.kt (106 L · NirsMcjPacket · NirsMagResponse · NirsOxyResult)
· ui/views/monitoring/VivamyoMonitoringView.kt (497 L)
· ui/views/calibration/CalibrationView.kt (216 L) + calibration/ 디렉토리
부분 편집:
· AppState.kt — NirsManager import · NIRS_CALIBRATION/VIVAMYO_MONITORING enum ·
calibrationComplete() · sensor 분기 3곳 (goHome/enterDemoMode/
deviceConnected/backToMonitoring) 모두 PIEZO 로 단순화
· MainActivity.kt — CalibrationView/VivamyoMonitoringView import + Crossfade branch +
back-handler branch 삭제
· BleManager.kt — 콜백 4개 (onNirsPowerOn/SensorActivated/MagReceived/McjReceived) ·
sendRawWrite when-branch 4개 · NIRS Commands 섹션 5 함수 ·
RX branch 4개 삭제
· MeasurementService.kt — performNirsMeasurement 제거 · performMeasurement 단순화 ·
stop() 콜백 정리 · SensorMode 유지 (센서 추가 대비)
· SensorMode.kt — NIRS enum value 삭제 · PIEZO 만 남음 · UserStorage 는
valueOf 실패 시 PIEZO fallback 이라 마이그레이션 불필요
· SensorSelectView.kt — NIRS SensorCard 제거
· strings.xml × 2 — sensor_nirs_desc · calibration_* 10개 · vivamyo_* 17개 삭제
(calibration_continue 는 PiezoPersonalization 사용 → 유지)
합계: 약 1,300 LOC 감소.
빌드: BUILD SUCCESSFUL 17s.
This commit is contained in:
@@ -6,15 +6,14 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.managers.NirsManager
|
||||
import com.medithings.vesiscan.models.*
|
||||
import com.medithings.vesiscan.services.*
|
||||
|
||||
enum class AppScreen {
|
||||
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
|
||||
NIRS_CALIBRATION, PIEZO_PERSONALIZATION,
|
||||
PIEZO_PERSONALIZATION,
|
||||
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
||||
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
||||
PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
||||
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
|
||||
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
||||
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
||||
@@ -93,8 +92,6 @@ class AppState(private val context: Context) {
|
||||
}
|
||||
|
||||
fun goHome() {
|
||||
NirsManager.instance.stopStreaming()
|
||||
NirsManager.instance.reset()
|
||||
// 회의 결정: 실제 BLE 연결은 HOME에서도 유지. 다음 Start 시 설정/scan skip하고
|
||||
// 바로 PiezoMonitoring으로 진입할 수 있도록.
|
||||
// Demo 모드는 가짜 연결이므로 isDeviceConnected reset.
|
||||
@@ -106,11 +103,7 @@ class AppState(private val context: Context) {
|
||||
fun enterDemoMode() {
|
||||
isDemoMode = true
|
||||
isDeviceConnected = true
|
||||
currentScreen = if (sensorMode == SensorMode.NIRS) {
|
||||
AppScreen.VIVAMYO_MONITORING
|
||||
} else {
|
||||
AppScreen.PIEZO_MONITORING
|
||||
}
|
||||
currentScreen = AppScreen.PIEZO_MONITORING
|
||||
}
|
||||
|
||||
fun startFromHome() {
|
||||
@@ -138,15 +131,7 @@ class AppState(private val context: Context) {
|
||||
currentScreen = AppScreen.CLINICAL_HOME
|
||||
return
|
||||
}
|
||||
currentScreen = if (sensorMode == SensorMode.NIRS) {
|
||||
AppScreen.NIRS_CALIBRATION
|
||||
} else {
|
||||
AppScreen.PIEZO_PERSONALIZATION
|
||||
}
|
||||
}
|
||||
|
||||
fun calibrationComplete() {
|
||||
currentScreen = AppScreen.VIVAMYO_MONITORING
|
||||
currentScreen = AppScreen.PIEZO_PERSONALIZATION
|
||||
}
|
||||
|
||||
fun personalizationComplete() {
|
||||
@@ -299,7 +284,7 @@ class AppState(private val context: Context) {
|
||||
}
|
||||
|
||||
fun backToMonitoring() {
|
||||
currentScreen = if (sensorMode == SensorMode.NIRS) AppScreen.VIVAMYO_MONITORING else AppScreen.PIEZO_MONITORING
|
||||
currentScreen = AppScreen.PIEZO_MONITORING
|
||||
}
|
||||
|
||||
fun addCatheterCount(amount: Int = 1) {
|
||||
|
||||
@@ -24,13 +24,11 @@ import com.medithings.vesiscan.services.KeychainService
|
||||
import com.medithings.vesiscan.services.NotificationService
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ui.theme.Medilightv2androidTheme
|
||||
import com.medithings.vesiscan.ui.views.calibration.CalibrationView
|
||||
import com.medithings.vesiscan.ui.views.connection.DeviceScanView
|
||||
import com.medithings.vesiscan.ui.views.history.MeasurementHistoryView
|
||||
import com.medithings.vesiscan.ui.views.home.HomeView
|
||||
import com.medithings.vesiscan.ui.views.monitoring.PlacementGuideView
|
||||
import com.medithings.vesiscan.ui.views.monitoring.PiezoMonitoringView
|
||||
import com.medithings.vesiscan.ui.views.monitoring.VivamyoMonitoringView
|
||||
import com.medithings.vesiscan.ui.views.monitoring.UrineCameraScreen
|
||||
import com.medithings.vesiscan.ui.views.monitoring.VoidingDiaryView
|
||||
import com.medithings.vesiscan.ui.views.onboarding.OnboardingView
|
||||
@@ -155,8 +153,6 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
AppScreen.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME
|
||||
// 기타
|
||||
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
|
||||
AppScreen.VIVAMYO_MONITORING -> appState.currentScreen = AppScreen.HOME
|
||||
AppScreen.NIRS_CALIBRATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
|
||||
// 온보딩/등록 (뒤로가기 막지 않되 Home으로)
|
||||
else -> appState.currentScreen = AppScreen.HOME
|
||||
}
|
||||
@@ -175,12 +171,10 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
AppScreen.HOME -> HomeView(appState)
|
||||
AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
|
||||
AppScreen.DEVICE_SCAN -> DeviceScanView(appState)
|
||||
AppScreen.NIRS_CALIBRATION -> CalibrationView(appState)
|
||||
AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState)
|
||||
AppScreen.PLACEMENT_GUIDE -> PlacementGuideView(appState)
|
||||
AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context)
|
||||
AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context)
|
||||
AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState)
|
||||
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
|
||||
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
|
||||
AppScreen.VOIDING_DIARY -> VoidingDiaryView(onBack = { appState.currentScreen = AppScreen.HOME })
|
||||
|
||||
@@ -287,10 +287,6 @@ class BleManager private constructor(private val context: Context) {
|
||||
|
||||
// Callbacks
|
||||
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
|
||||
var onNirsPowerOnReceived: (() -> Unit)? = null
|
||||
var onNirsSensorActivated: (() -> Unit)? = null
|
||||
var onNirsMagReceived: ((ByteArray) -> Unit)? = null
|
||||
var onNirsMcjReceived: ((ByteArray) -> Unit)? = null
|
||||
var onPiezoDataReceived: ((ByteArray) -> Unit)? = null
|
||||
var onUnexpectedDisconnect: (() -> Unit)? = null
|
||||
var onReconnectionFailed: (() -> Unit)? = null
|
||||
@@ -717,10 +713,6 @@ class BleManager private constructor(private val context: Context) {
|
||||
"mps" -> "piezo stop"
|
||||
"mec" -> "single burst"
|
||||
"msn" -> "battery query"
|
||||
"mta" -> if (data.size > 4 && data[4].toInt() == 1) "NIRS power ON" else "NIRS power OFF"
|
||||
"mqq" -> "NIRS activate"
|
||||
"mag" -> "NIRS gain query"
|
||||
"mcj" -> "NIRS MCJ query"
|
||||
"mid" -> "device info query"
|
||||
// mls 는 buildCommandBE 로 16bit BE 인코딩 → data[4]=High, data[5]=Low
|
||||
"mls" -> "LED mode ${
|
||||
@@ -831,28 +823,6 @@ class BleManager private constructor(private val context: Context) {
|
||||
return true
|
||||
}
|
||||
|
||||
// ── NIRS Commands ──
|
||||
|
||||
fun sendNirsPowerOn() {
|
||||
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(1)))
|
||||
}
|
||||
|
||||
fun sendNirsPowerOff() {
|
||||
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(0)))
|
||||
}
|
||||
|
||||
fun sendMqqQuery() {
|
||||
sendRaw(CRC16.buildCommandASCII("mqq", " "))
|
||||
}
|
||||
|
||||
fun sendMagQuery() {
|
||||
sendRaw(CRC16.buildCommandASCII("mag", " "))
|
||||
}
|
||||
|
||||
fun sendMcjQuery() {
|
||||
sendRaw(CRC16.buildCommandASCII("mcj", " "))
|
||||
}
|
||||
|
||||
// ── Common Commands ──
|
||||
|
||||
fun sendBatteryQuery() {
|
||||
@@ -1644,22 +1614,6 @@ class BleManager private constructor(private val context: Context) {
|
||||
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
||||
logd { "Received: $prefix (${data.size} bytes)" }
|
||||
when (prefix) {
|
||||
"rta:", "rta!" -> {
|
||||
debugLogger.rx("rta", data.size, "piezo power ON")
|
||||
onNirsPowerOnReceived?.invoke()
|
||||
}
|
||||
"rsh:", "rsh!", "rqq:", "rqq!" -> {
|
||||
debugLogger.rx(prefix.take(3), data.size, "sensor activated")
|
||||
onNirsSensorActivated?.invoke()
|
||||
}
|
||||
"rag:", "rag!" -> {
|
||||
debugLogger.rx("rag", data.size, "gain calibration (${(data.size - 6) / 2} values)")
|
||||
onNirsMagReceived?.invoke(data)
|
||||
}
|
||||
"rcj:", "rcj!" -> {
|
||||
debugLogger.rx("rcj", data.size, "MCJ packet")
|
||||
onNirsMcjReceived?.invoke(data)
|
||||
}
|
||||
"rpa:" -> {
|
||||
debugLogger.rx("rpa", data.size, "piezo ack")
|
||||
onPiezoDataReceived?.invoke(data)
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.models.NirsConstants
|
||||
import com.medithings.vesiscan.models.NirsMagResponse
|
||||
import com.medithings.vesiscan.models.NirsOxyResult
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
|
||||
class NirsCalcEngine {
|
||||
|
||||
// Gain Calibration
|
||||
private var gainValues: IntArray = intArrayOf()
|
||||
val hasGain: Boolean get() = gainValues.size >= 24
|
||||
|
||||
fun setGainFromMag(response: NirsMagResponse) {
|
||||
gainValues = response.gainValues
|
||||
}
|
||||
|
||||
fun clearGain() {
|
||||
gainValues = intArrayOf()
|
||||
}
|
||||
|
||||
// AGC DAC -> Software Amplification
|
||||
private fun agcGain(dacValue: Int): Double {
|
||||
return 10.0.pow(((dacValue.toDouble() * 3.3 / 4096.0 * -80.0) + 88.0) / 20.0)
|
||||
}
|
||||
|
||||
// DeGain Calculation
|
||||
fun degain(rawLed: IntArray): DoubleArray {
|
||||
if (rawLed.size < 24 || !hasGain) return DoubleArray(24) { 0.0 }
|
||||
|
||||
val result = DoubleArray(24)
|
||||
for (i in 0 until 24) {
|
||||
val raw = rawLed[i].toDouble()
|
||||
val sag = gainValues[i]
|
||||
val pkg = i / 6
|
||||
val wl = i % 6
|
||||
|
||||
val softAmp = agcGain(sag)
|
||||
val hAmp = NirsConstants.hardwareAmp[pkg]
|
||||
val spResponse = NirsConstants.spectralResponse[wl]
|
||||
val dpf = NirsConstants.dpf[wl]
|
||||
|
||||
val denominator = softAmp * hAmp
|
||||
if (denominator > 0) {
|
||||
var dg = raw / denominator
|
||||
dg /= spResponse
|
||||
dg *= dpf
|
||||
result[i] = dg
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Butterworth IIR Filter
|
||||
private var filterState: Array<DoubleArray> = emptyArray()
|
||||
private var filterInitialized = false
|
||||
var isFilterEnabled = true
|
||||
|
||||
fun resetFilter() {
|
||||
filterState = Array(24) { doubleArrayOf(0.0, 0.0, 0.0, 0.0) }
|
||||
filterInitialized = false
|
||||
}
|
||||
|
||||
private fun applyFilter(x: Double, state: DoubleArray): Double {
|
||||
val b = NirsConstants.filterB
|
||||
val a = NirsConstants.filterA
|
||||
|
||||
val y = b[0] * x + b[1] * state[0] + b[2] * state[1] - a[1] * state[2] - a[2] * state[3]
|
||||
|
||||
state[1] = state[0]
|
||||
state[0] = x
|
||||
state[3] = state[2]
|
||||
state[2] = y
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
fun filterDeGain(degainValues: DoubleArray): DoubleArray {
|
||||
if (degainValues.size < 24) return degainValues
|
||||
|
||||
if (filterState.isEmpty()) resetFilter()
|
||||
|
||||
if (!filterInitialized) {
|
||||
for (i in 0 until 24) {
|
||||
val v = degainValues[i]
|
||||
filterState[i] = doubleArrayOf(v, v, v, v)
|
||||
}
|
||||
filterInitialized = true
|
||||
}
|
||||
|
||||
val filtered = DoubleArray(24)
|
||||
for (i in 0 until 24) {
|
||||
filtered[i] = applyFilter(degainValues[i], filterState[i])
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Oxygenation Calculation
|
||||
fun calcOxygenation(degainValues: DoubleArray): NirsOxyResult {
|
||||
if (degainValues.size < 24) return NirsOxyResult.zero
|
||||
|
||||
val c = NirsConstants
|
||||
val muA = DoubleArray(c.numWavelengths)
|
||||
|
||||
for (i in 0 until c.numWavelengths) {
|
||||
val i2cm = degainValues[i]
|
||||
val i5cm = degainValues[18 + i]
|
||||
|
||||
val i2norm = i2cm / (c.ledPower[0] / c.referencePower)
|
||||
val i5norm = i5cm / (c.ledPower[3] / c.referencePower)
|
||||
|
||||
val mix = c.srsMixNear * i2norm + c.srsMixFar * i5norm
|
||||
|
||||
if (mix > 0) {
|
||||
val slope = -ln(mix) / c.srsPathLength
|
||||
muA[i] = -slope
|
||||
}
|
||||
}
|
||||
|
||||
val solution = solveNormalEq6x3(muA)
|
||||
|
||||
var hbO2 = max(0.0, solution.first) * c.oxyScale
|
||||
var hb = max(0.0, solution.second) * c.oxyScale
|
||||
|
||||
val tHb = hbO2 + hb
|
||||
val stO2 = if (tHb > 0) (hbO2 / tHb) * 100.0 else 0.0
|
||||
|
||||
return NirsOxyResult(hbO2 = hbO2, hb = hb, tHb = tHb, stO2 = stO2)
|
||||
}
|
||||
|
||||
// Least Squares Solver (6x3)
|
||||
private fun solveNormalEq6x3(muA: DoubleArray): Triple<Double, Double, Double> {
|
||||
val eps = arrayOf(
|
||||
NirsConstants.extinctionHbO2,
|
||||
NirsConstants.extinctionHb,
|
||||
NirsConstants.extinctionH2O
|
||||
)
|
||||
|
||||
val n = Array(3) { DoubleArray(3) }
|
||||
for (i in 0 until 3) {
|
||||
for (j in 0 until 3) {
|
||||
var sum = 0.0
|
||||
for (k in 0 until 6) sum += eps[i][k] * eps[j][k]
|
||||
n[i][j] = sum
|
||||
}
|
||||
}
|
||||
|
||||
val b = DoubleArray(3)
|
||||
for (i in 0 until 3) {
|
||||
var sum = 0.0
|
||||
for (k in 0 until 6) sum += eps[i][k] * muA[k]
|
||||
b[i] = sum
|
||||
}
|
||||
|
||||
val det = n[0][0] * (n[1][1] * n[2][2] - n[1][2] * n[2][1]) -
|
||||
n[0][1] * (n[1][0] * n[2][2] - n[1][2] * n[2][0]) +
|
||||
n[0][2] * (n[1][0] * n[2][1] - n[1][1] * n[2][0])
|
||||
|
||||
if (abs(det) < 1e-12) return Triple(0.0, 0.0, 0.0)
|
||||
|
||||
val inv = Array(3) { DoubleArray(3) }
|
||||
inv[0][0] = (n[1][1] * n[2][2] - n[1][2] * n[2][1]) / det
|
||||
inv[0][1] = -(n[0][1] * n[2][2] - n[0][2] * n[2][1]) / det
|
||||
inv[0][2] = (n[0][1] * n[1][2] - n[0][2] * n[1][1]) / det
|
||||
inv[1][0] = -(n[1][0] * n[2][2] - n[1][2] * n[2][0]) / det
|
||||
inv[1][1] = (n[0][0] * n[2][2] - n[0][2] * n[2][0]) / det
|
||||
inv[1][2] = -(n[0][0] * n[1][2] - n[0][2] * n[1][0]) / det
|
||||
inv[2][0] = (n[1][0] * n[2][1] - n[1][1] * n[2][0]) / det
|
||||
inv[2][1] = -(n[0][0] * n[2][1] - n[0][1] * n[2][0]) / det
|
||||
inv[2][2] = (n[0][0] * n[1][1] - n[0][1] * n[1][0]) / det
|
||||
|
||||
val c = DoubleArray(3) { i ->
|
||||
inv[i][0] * b[0] + inv[i][1] * b[1] + inv[i][2] * b[2]
|
||||
}
|
||||
|
||||
return Triple(c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
// Full Processing Pipeline
|
||||
data class ProcessResult(
|
||||
val degain: DoubleArray,
|
||||
val degainFiltered: DoubleArray,
|
||||
val oxy: NirsOxyResult,
|
||||
val oxyFiltered: NirsOxyResult
|
||||
)
|
||||
|
||||
fun process(rawLed: IntArray): ProcessResult {
|
||||
val dg = degain(rawLed)
|
||||
val oxyRaw = calcOxygenation(dg)
|
||||
|
||||
val dgf: DoubleArray
|
||||
val oxyF: NirsOxyResult
|
||||
if (isFilterEnabled) {
|
||||
dgf = filterDeGain(dg)
|
||||
oxyF = calcOxygenation(dgf)
|
||||
} else {
|
||||
dgf = dg
|
||||
oxyF = oxyRaw
|
||||
}
|
||||
|
||||
return ProcessResult(dg, dgf, oxyRaw, oxyF)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.medithings.vesiscan.models.NirsMagResponse
|
||||
import com.medithings.vesiscan.models.NirsMcjPacket
|
||||
import com.medithings.vesiscan.models.NirsConstants
|
||||
import com.medithings.vesiscan.BuildConfig
|
||||
import com.medithings.vesiscan.models.NirsOxyResult
|
||||
|
||||
class NirsManager private constructor() {
|
||||
companion object {
|
||||
val instance: NirsManager by lazy { NirsManager() }
|
||||
}
|
||||
|
||||
// State
|
||||
val isStreaming = mutableStateOf(false)
|
||||
val isCalibrated = mutableStateOf(false)
|
||||
val isFilterEnabled = mutableStateOf(true)
|
||||
|
||||
// Current Values
|
||||
val currentOxy = mutableStateOf(NirsOxyResult.zero)
|
||||
val sampleCount = mutableIntStateOf(0)
|
||||
|
||||
// Chart History
|
||||
val oxyHbO2History = mutableStateListOf<Double>()
|
||||
val oxyHbHistory = mutableStateListOf<Double>()
|
||||
|
||||
// Internal
|
||||
val calcEngine = NirsCalcEngine()
|
||||
private val maxPoints = NirsConstants.maxHistoryPoints
|
||||
|
||||
// Calibration
|
||||
fun setCalibrationGain(response: NirsMagResponse) {
|
||||
calcEngine.setGainFromMag(response)
|
||||
isCalibrated.value = true
|
||||
}
|
||||
|
||||
// Process mcj Packet
|
||||
fun processMcjPacket(packet: NirsMcjPacket) {
|
||||
val result = calcEngine.process(packet.ledValues)
|
||||
currentOxy.value = result.oxyFiltered
|
||||
sampleCount.intValue++
|
||||
|
||||
if (BuildConfig.DEBUG && (sampleCount.intValue <= 3 || sampleCount.intValue % 50 == 0)) {
|
||||
android.util.Log.d("NirsManager", "Sample #${sampleCount.intValue}: " +
|
||||
"hasGain=${calcEngine.hasGain}, " +
|
||||
"led[0..5]=${packet.ledValues.take(6)}, " +
|
||||
"degain[0..2]=${result.degain.take(3).map { "%.2f".format(it) }}, " +
|
||||
"oxy=StO2:${"%.1f".format(result.oxyFiltered.stO2)} HbO2:${"%.1f".format(result.oxyFiltered.hbO2)} HHb:${"%.1f".format(result.oxyFiltered.hb)}")
|
||||
}
|
||||
|
||||
appendTrimming(oxyHbO2History, result.oxyFiltered.hbO2)
|
||||
appendTrimming(oxyHbHistory, result.oxyFiltered.hb)
|
||||
}
|
||||
|
||||
// Controls
|
||||
fun toggleFilter() {
|
||||
isFilterEnabled.value = !isFilterEnabled.value
|
||||
calcEngine.isFilterEnabled = isFilterEnabled.value
|
||||
if (isFilterEnabled.value) calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
oxyHbO2History.clear()
|
||||
oxyHbHistory.clear()
|
||||
currentOxy.value = NirsOxyResult.zero
|
||||
sampleCount.intValue = 0
|
||||
calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun startStreaming() {
|
||||
isStreaming.value = true
|
||||
calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun stopStreaming() {
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
stopStreaming()
|
||||
clearData()
|
||||
isCalibrated.value = false
|
||||
calcEngine.clearGain()
|
||||
}
|
||||
|
||||
// Helpers
|
||||
private fun appendTrimming(list: MutableList<Double>, value: Double) {
|
||||
list.add(value)
|
||||
while (list.size > maxPoints) list.removeAt(0)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
object NirsConstants {
|
||||
// Hardware Parameters
|
||||
val hardwareAmp = doubleArrayOf(1.2, 5.008, 11.22, 37.072)
|
||||
val ledPower = doubleArrayOf(5.0, 10.0, 15.0, 20.0)
|
||||
val spectralResponse = doubleArrayOf(0.457, 0.561, 0.602, 0.653, 0.71, 0.725)
|
||||
val dpf = doubleArrayOf(7.0, 5.8, 5.5, 5.2, 4.9, 4.7)
|
||||
|
||||
// Extinction Coefficients
|
||||
val extinctionHbO2 = doubleArrayOf(0.303, 0.586, 0.816, 1.058, 1.224, 1.162)
|
||||
val extinctionHb = doubleArrayOf(2.968, 1.549, 0.762, 0.691, 0.777, 0.429)
|
||||
val extinctionH2O = doubleArrayOf(0.003, 0.026, 0.020, 0.044, 0.267, 0.448)
|
||||
|
||||
// Butterworth IIR Filter (2nd order, Fc=1.5Hz, Fs=4Hz)
|
||||
val filterB = doubleArrayOf(0.4208, 0.8416, 0.4208)
|
||||
val filterA = doubleArrayOf(1.0, 0.3533, 0.3300)
|
||||
|
||||
// SRS Parameters
|
||||
const val srsMixNear = 0.8
|
||||
const val srsMixFar = 0.2
|
||||
const val srsPathLength = 2.6
|
||||
const val referencePower = 20.0
|
||||
const val oxyScale = 22.0
|
||||
|
||||
// Dimensions
|
||||
const val numPackages = 4
|
||||
const val numWavelengths = 6
|
||||
const val numChannels = numPackages * numWavelengths
|
||||
|
||||
// Wavelength Definitions
|
||||
val wavelengths = intArrayOf(665, 760, 800, 850, 920, 970)
|
||||
val wavelengthNames = arrayOf("665nm", "760nm", "800nm", "850nm", "920nm", "970nm")
|
||||
|
||||
val wavelengthColors = listOf(
|
||||
Color.Red,
|
||||
Color.Blue,
|
||||
Color(0xFF33CC33),
|
||||
Color(0xFFFF9500),
|
||||
Color(0xFF9966FF),
|
||||
Color(0xFF666666)
|
||||
)
|
||||
|
||||
// Chart History
|
||||
const val maxHistoryPoints = 240
|
||||
const val streamingIntervalMs = 250L
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
data class NirsMcjPacket(
|
||||
val battery: Int,
|
||||
val temperature: Int,
|
||||
val accX: Short,
|
||||
val accY: Short,
|
||||
val accZ: Short,
|
||||
val gyrX: Short,
|
||||
val gyrY: Short,
|
||||
val gyrZ: Short,
|
||||
val ledValues: IntArray
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "NirsMcjPacket"
|
||||
|
||||
fun parse(data: ByteArray): NirsMcjPacket? {
|
||||
if (data.size < 72) {
|
||||
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
|
||||
android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun u16(offset: Int): Int =
|
||||
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
|
||||
fun s16(offset: Int): Short =
|
||||
u16(offset).toShort()
|
||||
|
||||
val base = 4
|
||||
val battery = u16(base)
|
||||
val temperature = u16(base + 2)
|
||||
val accX = s16(base + 4)
|
||||
val accY = s16(base + 6)
|
||||
val accZ = s16(base + 8)
|
||||
val gyrX = s16(base + 10)
|
||||
val gyrY = s16(base + 12)
|
||||
val gyrZ = s16(base + 14)
|
||||
|
||||
val ledValues = IntArray(24)
|
||||
for (i in 0 until 24) {
|
||||
val offset = base + 20 + (i * 2)
|
||||
if (offset + 1 >= data.size) {
|
||||
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
|
||||
android.util.Log.e(TAG, "parse: LED[$i] out of bounds: offset=${offset+1} >= size=${data.size}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
ledValues[i] = u16(offset)
|
||||
}
|
||||
|
||||
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
|
||||
android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}")
|
||||
}
|
||||
|
||||
return NirsMcjPacket(
|
||||
battery, temperature,
|
||||
accX, accY, accZ,
|
||||
gyrX, gyrY, gyrZ,
|
||||
ledValues
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is NirsMcjPacket) return false
|
||||
return battery == other.battery && ledValues.contentEquals(other.ledValues)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = battery * 31 + ledValues.contentHashCode()
|
||||
}
|
||||
|
||||
data class NirsMagResponse(val gainValues: IntArray) {
|
||||
companion object {
|
||||
fun parse(data: ByteArray): NirsMagResponse? {
|
||||
if (data.size < 52) return null
|
||||
val base = 4
|
||||
val values = IntArray(24) { i ->
|
||||
val offset = base + (i * 2)
|
||||
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
}
|
||||
return NirsMagResponse(values)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is NirsMagResponse) return false
|
||||
return gainValues.contentEquals(other.gainValues)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = gainValues.contentHashCode()
|
||||
}
|
||||
|
||||
data class NirsOxyResult(
|
||||
val hbO2: Double = 0.0,
|
||||
val hb: Double = 0.0,
|
||||
val tHb: Double = 0.0,
|
||||
val stO2: Double = 0.0
|
||||
) {
|
||||
companion object {
|
||||
val zero = NirsOxyResult()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
// 2026-08-12: NIRS 제거. 향후 다른 센서 추가 대비해 enum 유지.
|
||||
enum class SensorMode(val displayName: String) {
|
||||
NIRS("NIRS"),
|
||||
PIEZO("Piezo")
|
||||
}
|
||||
|
||||
@@ -19,25 +19,11 @@ class MeasurementService private constructor(context: Context) {
|
||||
|
||||
val isMeasuring = mutableStateOf(false)
|
||||
|
||||
// 2026-08-12: NIRS 제거 후 PIEZO 만 남음. 시그니처는 향후 센서 추가 대비 유지.
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
|
||||
isMeasuring.value = true
|
||||
when (mode) {
|
||||
SensorMode.NIRS -> performNirsMeasurement(completion)
|
||||
SensorMode.PIEZO -> performPiezoMeasurement(completion)
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-07-07 fix: MeasurementService 는 singleton. 콜백을 대입만 하고 정리하지 않으면
|
||||
// BleManager 에 stale closure 가 계속 살아있어 이후 응답 이벤트가 이미 완료된
|
||||
// measurement session 의 completion() 을 다시 호출. Self-clearing lambda 로 처리.
|
||||
private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
|
||||
bleManager.onNirsMcjReceived = null // 이전 stale 콜백 해제
|
||||
bleManager.onNirsMcjReceived = { _ ->
|
||||
bleManager.onNirsMcjReceived = null // 응답 1회 처리 후 자동 해제
|
||||
isMeasuring.value = false
|
||||
completion(true)
|
||||
}
|
||||
bleManager.sendMcjQuery()
|
||||
performPiezoMeasurement(completion)
|
||||
}
|
||||
|
||||
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
|
||||
@@ -52,8 +38,6 @@ class MeasurementService private constructor(context: Context) {
|
||||
|
||||
fun stop() {
|
||||
isMeasuring.value = false
|
||||
// 대기 중이던 콜백 취소.
|
||||
bleManager.onNirsMcjReceived = null
|
||||
bleManager.onPiezoDataReceived = null
|
||||
bleManager.sendPiezoStop()
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package com.medithings.vesiscan.ui.views.calibration
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.managers.NirsManager
|
||||
import com.medithings.vesiscan.models.NirsMagResponse
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CalibrationView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val nirsManager = remember { NirsManager.instance }
|
||||
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
val initMsg = stringResource(R.string.calibration_initializing)
|
||||
val completeMsg = stringResource(R.string.calibration_complete_status)
|
||||
val noResponseMsg = stringResource(R.string.calibration_no_response)
|
||||
val poweringMsg = stringResource(R.string.calibration_powering_on)
|
||||
val activatingMsg = stringResource(R.string.calibration_activating)
|
||||
val readingGainMsg = stringResource(R.string.calibration_reading_gain)
|
||||
|
||||
var statusMessage by remember { mutableStateOf(initMsg) }
|
||||
var isComplete by remember { mutableStateOf(false) }
|
||||
var isFailed by remember { mutableStateOf(false) }
|
||||
var didReceiveResponse by remember { mutableStateOf(false) }
|
||||
|
||||
val isServiceReady by bleManager.isServiceReady
|
||||
|
||||
fun completeCalibration() {
|
||||
if (isComplete) return
|
||||
statusMessage = completeMsg
|
||||
isComplete = true
|
||||
}
|
||||
|
||||
fun failCalibration() {
|
||||
if (isComplete || isFailed) return
|
||||
statusMessage = noResponseMsg
|
||||
isFailed = true
|
||||
}
|
||||
|
||||
fun startCalibration() {
|
||||
statusMessage = poweringMsg
|
||||
|
||||
bleManager.onNirsPowerOnReceived = {
|
||||
didReceiveResponse = true
|
||||
statusMessage = activatingMsg
|
||||
handler.postDelayed({ bleManager.sendMqqQuery() }, 2000)
|
||||
}
|
||||
|
||||
bleManager.onNirsSensorActivated = {
|
||||
statusMessage = readingGainMsg
|
||||
handler.postDelayed({ bleManager.sendMagQuery() }, 300)
|
||||
}
|
||||
|
||||
bleManager.onNirsMagReceived = { data ->
|
||||
NirsMagResponse.parse(data)?.let { response ->
|
||||
nirsManager.setCalibrationGain(response)
|
||||
}
|
||||
completeCalibration()
|
||||
}
|
||||
|
||||
bleManager.sendNirsPowerOn()
|
||||
|
||||
// Timeout
|
||||
handler.postDelayed({
|
||||
if (didReceiveResponse) completeCalibration() else failCalibration()
|
||||
}, 6000)
|
||||
}
|
||||
|
||||
fun retryCalibration() {
|
||||
isFailed = false
|
||||
isComplete = false
|
||||
didReceiveResponse = false
|
||||
statusMessage = initMsg
|
||||
startCalibration()
|
||||
}
|
||||
|
||||
LaunchedEffect(isServiceReady) {
|
||||
if (isServiceReady) startCalibration()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Bladdy with checkmark
|
||||
Box {
|
||||
BladdyIdleRiveView(size = 160.dp)
|
||||
|
||||
if (isComplete) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MlTeal,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = (-10).dp, y = (-10).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = when {
|
||||
isFailed -> stringResource(R.string.calibration_failed)
|
||||
isComplete -> stringResource(R.string.calibration_complete)
|
||||
else -> stringResource(R.string.calibration_in_progress)
|
||||
},
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isFailed) Color.Red else Color.Unspecified
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = statusMessage,
|
||||
fontSize = 16.sp,
|
||||
color = MlSecondaryText,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (isFailed) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { retryCalibration() },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Refresh, null, tint = Color.White)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.retry), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { appState.calibrationComplete() },
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(stringResource(R.string.calibration_skip), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
} else if (isComplete) {
|
||||
Button(
|
||||
onClick = { appState.calibrationComplete() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.calibration_continue), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
-497
@@ -1,497 +0,0 @@
|
||||
package com.medithings.vesiscan.ui.views.monitoring
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.managers.NirsManager
|
||||
import com.medithings.vesiscan.models.NirsMcjPacket
|
||||
import com.medithings.vesiscan.models.NirsOxyResult
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
fun VivamyoMonitoringView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val nirsManager = remember { NirsManager.instance }
|
||||
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
val isCompact = screenWidth < 400
|
||||
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var showClearConfirm by remember { mutableStateOf(false) }
|
||||
var isToggling by remember { mutableStateOf(false) }
|
||||
|
||||
val batteryLvl by bleManager.batteryLevel
|
||||
val isConnected by bleManager.isConnected
|
||||
val isStreaming by nirsManager.isStreaming
|
||||
val currentOxy by nirsManager.currentOxy
|
||||
val sampleCount by nirsManager.sampleCount
|
||||
val isFilterEnabled by nirsManager.isFilterEnabled
|
||||
|
||||
val isDemoMode = appState.isDemoMode
|
||||
var demoRunnable by remember { mutableStateOf<Runnable?>(null) }
|
||||
var demoBaseHbO2 by remember { mutableStateOf(12.0) }
|
||||
var demoBaseHb by remember { mutableStateOf(8.0) }
|
||||
|
||||
var mcjRetryCount = remember { mutableIntStateOf(0) }
|
||||
var mcjRetryRunnable by remember { mutableStateOf<Runnable?>(null) }
|
||||
|
||||
fun cancelMcjRetry() {
|
||||
mcjRetryRunnable?.let { handler.removeCallbacks(it) }
|
||||
mcjRetryRunnable = null
|
||||
mcjRetryCount.intValue = 0
|
||||
}
|
||||
|
||||
fun startNirsStream() {
|
||||
cancelMcjRetry()
|
||||
|
||||
bleManager.onNirsMcjReceived = { data ->
|
||||
cancelMcjRetry() // Got response, cancel retry
|
||||
if (nirsManager.isStreaming.value) {
|
||||
val packet = NirsMcjPacket.parse(data)
|
||||
if (packet != null) {
|
||||
nirsManager.processMcjPacket(packet)
|
||||
} else {
|
||||
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
|
||||
android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}")
|
||||
}
|
||||
}
|
||||
handler.postDelayed({
|
||||
if (nirsManager.isStreaming.value) {
|
||||
bleManager.sendMcjQuery()
|
||||
// Retry if no response within 2s
|
||||
val retry = Runnable {
|
||||
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 3) {
|
||||
mcjRetryCount.intValue++
|
||||
bleManager.sendMcjQuery()
|
||||
}
|
||||
}
|
||||
mcjRetryRunnable = retry
|
||||
handler.postDelayed(retry, 2000)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Start mcj chain directly (same as iOS)
|
||||
bleManager.sendMcjQuery()
|
||||
|
||||
// Retry if first mcj gets no response within 2s
|
||||
val initialRetry = Runnable {
|
||||
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 5) {
|
||||
mcjRetryCount.intValue++
|
||||
bleManager.sendMcjQuery()
|
||||
}
|
||||
}
|
||||
mcjRetryRunnable = initialRetry
|
||||
handler.postDelayed(initialRetry, 2000)
|
||||
}
|
||||
|
||||
fun startDemoStream() {
|
||||
val tick = object : Runnable {
|
||||
override fun run() {
|
||||
if (!nirsManager.isStreaming.value) return
|
||||
demoBaseHbO2 += Random.nextDouble(-0.3, 0.3)
|
||||
demoBaseHb += Random.nextDouble(-0.2, 0.2)
|
||||
val hbO2 = demoBaseHbO2.coerceIn(5.0, 25.0)
|
||||
val hb = demoBaseHb.coerceIn(3.0, 15.0)
|
||||
demoBaseHbO2 = hbO2
|
||||
demoBaseHb = hb
|
||||
val tHb = hbO2 + hb
|
||||
val stO2 = if (tHb > 0) (hbO2 / tHb * 100.0) else 0.0
|
||||
|
||||
nirsManager.currentOxy.value = NirsOxyResult(hbO2, hb, tHb, stO2)
|
||||
nirsManager.sampleCount.intValue++
|
||||
nirsManager.oxyHbO2History.add(hbO2)
|
||||
nirsManager.oxyHbHistory.add(hb)
|
||||
if (nirsManager.oxyHbO2History.size > 240) nirsManager.oxyHbO2History.removeAt(0)
|
||||
if (nirsManager.oxyHbHistory.size > 240) nirsManager.oxyHbHistory.removeAt(0)
|
||||
|
||||
handler.postDelayed(this, 250)
|
||||
}
|
||||
}
|
||||
demoRunnable = tick
|
||||
handler.post(tick)
|
||||
}
|
||||
|
||||
fun stopDemoStream() {
|
||||
demoRunnable?.let { handler.removeCallbacks(it) }
|
||||
demoRunnable = null
|
||||
nirsManager.stopStreaming()
|
||||
}
|
||||
|
||||
fun stopNirsStream() {
|
||||
// 1. Stop polling chain & clear callback first
|
||||
nirsManager.stopStreaming()
|
||||
cancelMcjRetry()
|
||||
bleManager.onNirsMcjReceived = null
|
||||
|
||||
// 2. Resume battery polling after in-flight mcj response has time to arrive
|
||||
handler.postDelayed({
|
||||
bleManager.startBatteryPolling()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
fun toggleStreaming() {
|
||||
if (isToggling) return
|
||||
isToggling = true
|
||||
if (isStreaming) {
|
||||
if (isDemoMode) stopDemoStream() else stopNirsStream()
|
||||
} else {
|
||||
if (isDemoMode) {
|
||||
nirsManager.startStreaming()
|
||||
startDemoStream()
|
||||
} else {
|
||||
bleManager.stopBatteryPolling()
|
||||
nirsManager.startStreaming()
|
||||
startNirsStream()
|
||||
}
|
||||
}
|
||||
handler.postDelayed({ isToggling = false }, 1000)
|
||||
}
|
||||
|
||||
// BLE 끊김 banner는 사내 테스트 피드백으로 제거됨 — BleManager 측 자동 재연결만 동작.
|
||||
LaunchedEffect(Unit) {
|
||||
bleManager.onUnexpectedDisconnect = { /* no-op: silent reconnect */ }
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose {
|
||||
if (isDemoMode) { stopDemoStream() }
|
||||
else if (nirsManager.isStreaming.value) stopNirsStream()
|
||||
else { cancelMcjRetry(); bleManager.onNirsMcjReceived = null }
|
||||
} }
|
||||
|
||||
// Clear confirm dialog
|
||||
if (showClearConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearConfirm = false },
|
||||
title = { Text(stringResource(R.string.vivamyo_clear_title)) },
|
||||
text = { Text(stringResource(R.string.vivamyo_clear_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
nirsManager.clearData()
|
||||
showClearConfirm = false
|
||||
}) { Text(stringResource(R.string.vivamyo_clear_button), color = Color.Red) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val stO2Color = when {
|
||||
currentOxy.stO2 < 50 -> Color.Red
|
||||
currentOxy.stO2 < 60 -> Color(0xFFFF9500)
|
||||
else -> Color.Green
|
||||
}
|
||||
val stO2StatusText = when {
|
||||
currentOxy.stO2 < 50 -> context.getString(R.string.vivamyo_status_low)
|
||||
currentOxy.stO2 < 60 -> context.getString(R.string.vivamyo_status_caution)
|
||||
currentOxy.stO2 <= 85 -> context.getString(R.string.vivamyo_status_normal)
|
||||
else -> context.getString(R.string.vivamyo_status_check)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
) {
|
||||
// Top bar
|
||||
val hPad = if (isCompact) 12.dp else 24.dp
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
nirsManager.reset()
|
||||
appState.goHome()
|
||||
}, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.Default.Home, stringResource(R.string.vivamyo_home_desc), tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = if (isCompact) 4.dp else 8.dp)) {
|
||||
Text(stringResource(R.string.vivamyo_title), fontSize = if (isCompact) 16.sp else 20.sp, fontWeight = FontWeight.Bold)
|
||||
if (!isCompact) Text(stringResource(R.string.vivamyo_subtitle), fontSize = 13.sp, color = MlSecondaryText)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isDemoMode) {
|
||||
Text("DEMO", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
|
||||
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFFFF9500)).padding(horizontal = 4.dp, vertical = 2.dp))
|
||||
} else {
|
||||
Text("$batteryLvl%", fontSize = if (isCompact) 11.sp else 13.sp, color = MlSecondaryText)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(if (isCompact) 4.dp else 8.dp))
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(if (isDemoMode || isConnected) Color.Green else Color.Red))
|
||||
}
|
||||
|
||||
// Tab picker
|
||||
TabRow(selectedTabIndex = selectedTab, modifier = Modifier.padding(horizontal = hPad)) {
|
||||
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text("SpO2", modifier = Modifier.padding(if (isCompact) 8.dp else 12.dp)) }
|
||||
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { Text("Oxy/Deoxy", modifier = Modifier.padding(if (isCompact) 8.dp else 12.dp)) }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (isCompact) 10.dp else 16.dp)
|
||||
) {
|
||||
if (selectedTab == 0) {
|
||||
// SpO2 tab
|
||||
BladdyIdleRiveView(
|
||||
size = if (isCompact) 80.dp else 120.dp,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(String.format("%.1f", currentOxy.stO2), fontSize = if (isCompact) 40.sp else 56.sp, fontWeight = FontWeight.Bold, color = stO2Color)
|
||||
Text("% StO2", fontSize = 18.sp, color = MlSecondaryText)
|
||||
Text(stO2StatusText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = stO2Color,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(stO2Color.copy(alpha = 0.15f)).padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
}
|
||||
|
||||
NirsMetricsCard(nirsManager)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.vivamyo_samples_label, sampleCount), fontSize = 11.sp, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.vivamyo_window_label), fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
} else {
|
||||
// Oxy/Deoxy tab
|
||||
LineChartCard("HbO2 (Oxy)", nirsManager.oxyHbO2History, Color.Red, "uM")
|
||||
LineChartCard("HHb (DeOxy)", nirsManager.oxyHbHistory, Color.Blue, "uM")
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.vivamyo_samples_label, sampleCount), fontSize = 11.sp, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isFilterEnabled) {
|
||||
Text(stringResource(R.string.vivamyo_filtered), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
|
||||
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFF9966FF)).padding(horizontal = 6.dp, vertical = 2.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(stringResource(R.string.vivamyo_window_label), fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
|
||||
NirsMetricsCard(nirsManager)
|
||||
}
|
||||
}
|
||||
|
||||
// Control bar
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = hPad).padding(bottom = if (isCompact) 16.dp else 32.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { showClearConfirm = true },
|
||||
modifier = Modifier.weight(1f).height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Delete, null, tint = Color.Red)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.vivamyo_clear), fontWeight = FontWeight.SemiBold, color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { toggleStreaming() },
|
||||
modifier = Modifier.weight(1f).height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues(),
|
||||
enabled = !isToggling
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
RoundedCornerShape(14.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (isStreaming) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
null, tint = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(if (isStreaming) stringResource(R.string.vivamyo_stop) else stringResource(R.string.vivamyo_start), fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NirsMetricsCard(nirsManager: NirsManager) {
|
||||
val oxy by nirsManager.currentOxy
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MetricBox("HbO2", oxy.hbO2, "uM", Color.Red, Modifier.weight(1f))
|
||||
MetricBox("HHb", oxy.hb, "uM", Color.Blue, Modifier.weight(1f))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MetricBox("tHb", oxy.tHb, "uM", Color(0xFF9966FF), Modifier.weight(1f))
|
||||
MetricBox("StO2", oxy.stO2, "%", Color.Green, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricBox(title: String, value: Double, unit: String, color: Color, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MlBackground)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(title, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
}
|
||||
Text(String.format("%.1f", value), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1C1B1F), modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
|
||||
Text(unit, fontSize = 10.sp, color = MlSecondaryText, modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LineChartCard(label: String, data: List<Double>, color: Color, unit: String) {
|
||||
val axisColor = MlSecondaryText.copy(alpha = 0.4f)
|
||||
val gridColor = MlSecondaryText.copy(alpha = 0.1f)
|
||||
val sampleRate = 4f // Hz
|
||||
val yLabelWidth = 36.dp
|
||||
val xLabelHeight = 18.dp
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(6.dp).clip(CircleShape).background(color))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(label, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
String.format("%.1f %s", data.lastOrNull() ?: 0.0, unit),
|
||||
fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = color
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (data.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(140.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) { Text(stringResource(R.string.vivamyo_no_data), fontSize = 11.sp, color = MlSecondaryText) }
|
||||
} else {
|
||||
val minVal = data.min()
|
||||
val maxVal = data.max()
|
||||
val range = if (maxVal - minVal > 0.001) maxVal - minVal else 1.0
|
||||
val yMin = minVal - range * 0.05
|
||||
val yMax = maxVal + range * 0.05
|
||||
val yRange = yMax - yMin
|
||||
|
||||
// Y-axis tick values (5 ticks)
|
||||
val yTicks = (0..4).map { yMin + yRange * it / 4.0 }
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().height(140.dp + xLabelHeight)) {
|
||||
// Y-axis labels
|
||||
Column(
|
||||
modifier = Modifier.width(yLabelWidth).height(140.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
yTicks.reversed().forEach { tick ->
|
||||
Text(
|
||||
String.format("%.1f", tick),
|
||||
fontSize = 8.sp,
|
||||
color = MlSecondaryText,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
// Chart area
|
||||
Canvas(modifier = Modifier.fillMaxWidth().height(140.dp)) {
|
||||
// Grid lines
|
||||
for (i in 0..4) {
|
||||
val y = size.height * i / 4f
|
||||
drawLine(gridColor, Offset(0f, y), Offset(size.width, y), strokeWidth = 1f)
|
||||
}
|
||||
|
||||
// Data line
|
||||
if (data.size >= 2) {
|
||||
val stepX = size.width / (data.size - 1).toFloat()
|
||||
val path = Path()
|
||||
data.forEachIndexed { index, value ->
|
||||
val x = index * stepX
|
||||
val y = size.height - ((value - yMin) / yRange * size.height).toFloat()
|
||||
if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
drawPath(path, color = color, style = Stroke(width = 3f))
|
||||
}
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
val totalSeconds = data.size / sampleRate
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(xLabelHeight),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(String.format("%.0fs", 0f), fontSize = 8.sp, color = MlSecondaryText)
|
||||
Text(String.format("%.0fs", totalSeconds / 2), fontSize = 8.sp, color = MlSecondaryText)
|
||||
Text(String.format("%.0fs", totalSeconds), fontSize = 8.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unit label
|
||||
Text(unit, fontSize = 8.sp, color = MlSecondaryText, modifier = Modifier.padding(start = yLabelWidth))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,15 +54,6 @@ fun SensorSelectView(appState: AppState) {
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
SensorCard(
|
||||
mode = SensorMode.NIRS,
|
||||
title = "NIRS",
|
||||
description = stringResource(R.string.sensor_nirs_desc),
|
||||
gradientColors = listOf(Color(0xFF00DBA1), Color(0xFF008FFF)),
|
||||
iconText = "NIRS",
|
||||
onClick = { appState.selectSensor(SensorMode.NIRS) }
|
||||
)
|
||||
|
||||
SensorCard(
|
||||
mode = SensorMode.PIEZO,
|
||||
title = "Piezo",
|
||||
|
||||
@@ -234,20 +234,9 @@
|
||||
<!-- Sensor Select -->
|
||||
<string name="sensor_select_title">센서 선택</string>
|
||||
<string name="sensor_select_subtitle">측정 방식을 선택하세요</string>
|
||||
<string name="sensor_nirs_desc">근적외선 분광법\n조직 산소화 모니터링</string>
|
||||
<string name="sensor_piezo_desc">초음파 에코\n방광 용량 측정</string>
|
||||
|
||||
<!-- Calibration -->
|
||||
<string name="calibration_initializing">초기화 중…</string>
|
||||
<string name="calibration_complete_status">NIRS 보정 완료</string>
|
||||
<string name="calibration_no_response">기기 응답이 없습니다. 기기가 켜져 있고 가까이 있는지 확인해주세요.</string>
|
||||
<string name="calibration_powering_on">NIRS 센서 켜는 중…</string>
|
||||
<string name="calibration_activating">센서 활성화 중…</string>
|
||||
<string name="calibration_reading_gain">보정값 읽는 중…</string>
|
||||
<string name="calibration_failed">보정 실패</string>
|
||||
<string name="calibration_complete">보정 완료</string>
|
||||
<string name="calibration_in_progress">NIRS 보정 중…</string>
|
||||
<string name="calibration_skip">건너뛰기</string>
|
||||
<string name="calibration_continue">계속</string>
|
||||
|
||||
<!-- Measurement History -->
|
||||
@@ -269,23 +258,6 @@
|
||||
<string name="history_today">오늘</string>
|
||||
|
||||
<!-- Vivamyo (NIRS Monitoring) -->
|
||||
<string name="vivamyo_title">Vivamyo</string>
|
||||
<string name="vivamyo_subtitle">NIRS 모니터링</string>
|
||||
<string name="vivamyo_clear_title">모든 데이터를 지우시겠습니까?</string>
|
||||
<string name="vivamyo_clear_message">수집된 NIRS 데이터가 영구적으로 삭제됩니다.</string>
|
||||
<string name="vivamyo_clear_button">데이터 지우기</string>
|
||||
<string name="vivamyo_status_low">낮음</string>
|
||||
<string name="vivamyo_status_caution">주의</string>
|
||||
<string name="vivamyo_status_normal">정상</string>
|
||||
<string name="vivamyo_status_check">확인 필요</string>
|
||||
<string name="vivamyo_samples_label">샘플: %d</string>
|
||||
<string name="vivamyo_window_label">4Hz | 60초 윈도우</string>
|
||||
<string name="vivamyo_filtered">필터 적용</string>
|
||||
<string name="vivamyo_no_data">데이터 없음</string>
|
||||
<string name="vivamyo_clear">지우기</string>
|
||||
<string name="vivamyo_stop">중지</string>
|
||||
<string name="vivamyo_start">시작</string>
|
||||
<string name="vivamyo_home_desc">홈</string>
|
||||
|
||||
<!-- Piezo Monitoring -->
|
||||
<string name="piezo_recorded">기록됨</string>
|
||||
|
||||
@@ -234,20 +234,9 @@
|
||||
<!-- Sensor Select -->
|
||||
<string name="sensor_select_title">Select Sensor</string>
|
||||
<string name="sensor_select_subtitle">Choose your measurement mode</string>
|
||||
<string name="sensor_nirs_desc">Near-Infrared Spectroscopy\nTissue oxygenation monitoring</string>
|
||||
<string name="sensor_piezo_desc">Ultrasound Echo\nBladder volume measurement</string>
|
||||
|
||||
<!-- Calibration -->
|
||||
<string name="calibration_initializing">Initializing…</string>
|
||||
<string name="calibration_complete_status">NIRS Calibrated</string>
|
||||
<string name="calibration_no_response">No response from device. Check the device is powered on and nearby.</string>
|
||||
<string name="calibration_powering_on">Powering on NIRS transducer…</string>
|
||||
<string name="calibration_activating">Activating sensor…</string>
|
||||
<string name="calibration_reading_gain">Reading gain calibration…</string>
|
||||
<string name="calibration_failed">Calibration Failed</string>
|
||||
<string name="calibration_complete">Calibration Complete</string>
|
||||
<string name="calibration_in_progress">Calibrating NIRS…</string>
|
||||
<string name="calibration_skip">Skip</string>
|
||||
<string name="calibration_continue">Continue</string>
|
||||
|
||||
<!-- Measurement History -->
|
||||
@@ -269,23 +258,6 @@
|
||||
<string name="history_today">Today</string>
|
||||
|
||||
<!-- Vivamyo (NIRS Monitoring) -->
|
||||
<string name="vivamyo_title">Vivamyo</string>
|
||||
<string name="vivamyo_subtitle">NIRS Monitoring</string>
|
||||
<string name="vivamyo_clear_title">Clear all data?</string>
|
||||
<string name="vivamyo_clear_message">All collected NIRS data will be permanently deleted.</string>
|
||||
<string name="vivamyo_clear_button">Clear Data</string>
|
||||
<string name="vivamyo_status_low">Low</string>
|
||||
<string name="vivamyo_status_caution">Caution</string>
|
||||
<string name="vivamyo_status_normal">Normal</string>
|
||||
<string name="vivamyo_status_check">Check</string>
|
||||
<string name="vivamyo_samples_label">Samples: %d</string>
|
||||
<string name="vivamyo_window_label">4Hz | 60s window</string>
|
||||
<string name="vivamyo_filtered">Filtered</string>
|
||||
<string name="vivamyo_no_data">No data</string>
|
||||
<string name="vivamyo_clear">Clear</string>
|
||||
<string name="vivamyo_stop">Stop</string>
|
||||
<string name="vivamyo_start">Start</string>
|
||||
<string name="vivamyo_home_desc">Home</string>
|
||||
|
||||
<!-- Piezo Monitoring -->
|
||||
<string name="piezo_recorded">Recorded</string>
|
||||
|
||||
Reference in New Issue
Block a user