|
|
@ -33,12 +33,15 @@ import com.dct.app.presentation.signaltree.AddToChartRequest |
|
|
import com.dct.app.presentation.signaltree.SignalTreeViewModel |
|
|
import com.dct.app.presentation.signaltree.SignalTreeViewModel |
|
|
import com.dct.app.presentation.signaltree.TreeItemSelection |
|
|
import com.dct.app.presentation.signaltree.TreeItemSelection |
|
|
import kotlinx.coroutines.CoroutineScope |
|
|
import kotlinx.coroutines.CoroutineScope |
|
|
|
|
|
import kotlinx.coroutines.CoroutineStart |
|
|
import kotlinx.coroutines.Job |
|
|
import kotlinx.coroutines.Job |
|
|
import kotlinx.coroutines.SupervisorJob |
|
|
import kotlinx.coroutines.SupervisorJob |
|
|
import kotlinx.coroutines.cancel |
|
|
import kotlinx.coroutines.cancel |
|
|
import kotlinx.coroutines.flow.MutableStateFlow |
|
|
import kotlinx.coroutines.flow.MutableStateFlow |
|
|
import kotlinx.coroutines.flow.StateFlow |
|
|
import kotlinx.coroutines.flow.StateFlow |
|
|
import kotlinx.coroutines.flow.asStateFlow |
|
|
import kotlinx.coroutines.flow.asStateFlow |
|
|
|
|
|
import kotlinx.coroutines.flow.combine |
|
|
|
|
|
import kotlinx.coroutines.flow.stateIn |
|
|
import kotlinx.coroutines.withContext |
|
|
import kotlinx.coroutines.withContext |
|
|
import kotlinx.coroutines.flow.update |
|
|
import kotlinx.coroutines.flow.update |
|
|
import kotlinx.coroutines.launch |
|
|
import kotlinx.coroutines.launch |
|
|
@ -94,6 +97,7 @@ data class MainUiState( |
|
|
val dashboardTiles: List<TileWidgetItem> = emptyList(), |
|
|
val dashboardTiles: List<TileWidgetItem> = emptyList(), |
|
|
val currentSelection: TreeItemSelection? = null, |
|
|
val currentSelection: TreeItemSelection? = null, |
|
|
val isConnected: Boolean = false, |
|
|
val isConnected: Boolean = false, |
|
|
|
|
|
val isCommunicationConnecting: Boolean = false, |
|
|
val communicationType: CommunicationType = CommunicationType.NONE, |
|
|
val communicationType: CommunicationType = CommunicationType.NONE, |
|
|
val connectedDeviceName: String? = null, |
|
|
val connectedDeviceName: String? = null, |
|
|
val statusMessage: String? = null, |
|
|
val statusMessage: String? = null, |
|
|
@ -125,6 +129,20 @@ class MainViewModel( |
|
|
private val calibrationMutex = kotlinx.coroutines.sync.Mutex() |
|
|
private val calibrationMutex = kotlinx.coroutines.sync.Mutex() |
|
|
private var calibrationJob: Job? = null |
|
|
private var calibrationJob: Job? = null |
|
|
private val sessionMutex = kotlinx.coroutines.sync.Mutex() |
|
|
private val sessionMutex = kotlinx.coroutines.sync.Mutex() |
|
|
|
|
|
private val sessionControlJobs = java.util.concurrent.ConcurrentHashMap.newKeySet<Job>() |
|
|
|
|
|
|
|
|
|
|
|
/** Register before starting so Disconnect can cancel even a queued command. */ |
|
|
|
|
|
private fun launchSessionControl(block: suspend () -> Unit): Job { |
|
|
|
|
|
val job = scope.launch(dispatchers.io, start = CoroutineStart.LAZY) { block() } |
|
|
|
|
|
sessionControlJobs.add(job) |
|
|
|
|
|
job.invokeOnCompletion { sessionControlJobs.remove(job) } |
|
|
|
|
|
job.start() |
|
|
|
|
|
return job |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun cancelSessionControls() { |
|
|
|
|
|
sessionControlJobs.toList().forEach { it.cancel() } |
|
|
|
|
|
} |
|
|
private val recordingMutex = kotlinx.coroutines.sync.Mutex() |
|
|
private val recordingMutex = kotlinx.coroutines.sync.Mutex() |
|
|
// A whole CSV/MF4 sample and start/close are indivisible, including STOP ACK/disconnect paths. |
|
|
// A whole CSV/MF4 sample and start/close are indivisible, including STOP ACK/disconnect paths. |
|
|
private val recordingSampleLock = Any() |
|
|
private val recordingSampleLock = Any() |
|
|
@ -134,8 +152,10 @@ class MainViewModel( |
|
|
@Volatile private var configurationRequired = false |
|
|
@Volatile private var configurationRequired = false |
|
|
private val chartTelemetryResolver = ChartTelemetryResolver() |
|
|
private val chartTelemetryResolver = ChartTelemetryResolver() |
|
|
private val formulaTelemetryLock = Any() |
|
|
private val formulaTelemetryLock = Any() |
|
|
|
|
|
private val telemetryResetLock = Any() |
|
|
private val formulaTelemetryEngine = com.dct.app.core.formula.FormulaTelemetryEngine() |
|
|
private val formulaTelemetryEngine = com.dct.app.core.formula.FormulaTelemetryEngine() |
|
|
private var configuredMdbSelection: List<Pair<Int, Int>>? = null |
|
|
private var configuredMdbSelection: List<Pair<Int, Int>>? = null |
|
|
|
|
|
private var configuredCanMessages: List<com.dct.app.domain.model.protocol.DctCanMessageConfiguration> = emptyList() |
|
|
|
|
|
|
|
|
private val _isCalibrationBusy = MutableStateFlow(false) |
|
|
private val _isCalibrationBusy = MutableStateFlow(false) |
|
|
val isCalibrationBusy: StateFlow<Boolean> = _isCalibrationBusy.asStateFlow() |
|
|
val isCalibrationBusy: StateFlow<Boolean> = _isCalibrationBusy.asStateFlow() |
|
|
@ -197,7 +217,7 @@ class MainViewModel( |
|
|
try { |
|
|
try { |
|
|
val nowSec = packet.receivedAtSec.takeIf { it.isFinite() } |
|
|
val nowSec = packet.receivedAtSec.takeIf { it.isFinite() } |
|
|
?: (packet.receivedAtMillis.takeIf { it > 0 } ?: System.currentTimeMillis()) / 1000.0 |
|
|
?: (packet.receivedAtMillis.takeIf { it > 0 } ?: System.currentTimeMillis()) / 1000.0 |
|
|
processIncomingPacket(packet, nowSec) |
|
|
synchronized(telemetryResetLock) { processIncomingPacket(packet, nowSec) } |
|
|
} catch (e: kotlinx.coroutines.CancellationException) { |
|
|
} catch (e: kotlinx.coroutines.CancellationException) { |
|
|
throw e |
|
|
throw e |
|
|
} catch (e: Exception) { |
|
|
} catch (e: Exception) { |
|
|
@ -237,7 +257,10 @@ class MainViewModel( |
|
|
communicationType = if (isDisconnectedOrError) CommunicationType.NONE else it.communicationType, |
|
|
communicationType = if (isDisconnectedOrError) CommunicationType.NONE else it.communicationType, |
|
|
connectedDeviceName = if (isDisconnectedOrError) null else it.connectedDeviceName, |
|
|
connectedDeviceName = if (isDisconnectedOrError) null else it.connectedDeviceName, |
|
|
statusMessage = when (state) { |
|
|
statusMessage = when (state) { |
|
|
DeviceConnectionState.ERROR -> "Wi-Fi 장비 연결이 종료되었습니다." |
|
|
// ERROR can be collected after the configuration coroutine |
|
|
|
|
|
// publishes its cause. Do not replace that recovery guidance. |
|
|
|
|
|
DeviceConnectionState.ERROR -> if (it.statusMessage == CONFIGURATION_TIMEOUT_MESSAGE) |
|
|
|
|
|
it.statusMessage else "Wi-Fi 장비 연결이 종료되었습니다." |
|
|
DeviceConnectionState.DISCONNECTED -> if (it.communicationType == CommunicationType.NONE) it.statusMessage else "장비 연결이 해제되었습니다." |
|
|
DeviceConnectionState.DISCONNECTED -> if (it.communicationType == CommunicationType.NONE) it.statusMessage else "장비 연결이 해제되었습니다." |
|
|
else -> it.statusMessage |
|
|
else -> it.statusMessage |
|
|
} |
|
|
} |
|
|
@ -733,6 +756,27 @@ class MainViewModel( |
|
|
renderers.forEach { it.clearSamplesOnly() } |
|
|
renderers.forEach { it.clearSamplesOnly() } |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** Debug-only display reset; never changes device settings, recording or project bindings. */ |
|
|
|
|
|
fun resetDebugValues() { |
|
|
|
|
|
if (!com.dct.app.BuildConfig.DEBUG) return |
|
|
|
|
|
// A packet must finish as a whole before its values and graph samples are cleared. |
|
|
|
|
|
synchronized(telemetryResetLock) { |
|
|
|
|
|
clearFormulaTelemetry() |
|
|
|
|
|
synchronized(recordingSampleLock) { latestMf4RawValues.clear() } |
|
|
|
|
|
resetGraphData() |
|
|
|
|
|
_uiState.update { current -> |
|
|
|
|
|
current.copy( |
|
|
|
|
|
packetCount = 0L, |
|
|
|
|
|
dashboardTiles = current.dashboardTiles.map { tile -> |
|
|
|
|
|
tile.historyBuffer.clear() |
|
|
|
|
|
tile.copy(value = 0.0) |
|
|
|
|
|
}, |
|
|
|
|
|
statusMessage = "값을 초기화했습니다. 계측 중에는 다음 수신부터 다시 표시됩니다." |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 기본 Signal list MDI 서브윈도우 인스턴스 생성 |
|
|
* 기본 Signal list MDI 서브윈도우 인스턴스 생성 |
|
|
*/ |
|
|
*/ |
|
|
@ -1034,6 +1078,8 @@ class MainViewModel( |
|
|
companion object { |
|
|
companion object { |
|
|
const val MAX_WORKSPACE_PAGES = 5 |
|
|
const val MAX_WORKSPACE_PAGES = 5 |
|
|
private const val LOG_TAG = "DctMainViewModel" |
|
|
private const val LOG_TAG = "DctMainViewModel" |
|
|
|
|
|
private const val CONFIGURATION_TIMEOUT_MESSAGE = |
|
|
|
|
|
"장비 설정 응답 시간 초과로 통신 연결이 해제되었습니다. Connect로 다시 연결하세요." |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -1590,9 +1636,11 @@ class MainViewModel( |
|
|
if (!ensureBackgroundExecution()) return |
|
|
if (!ensureBackgroundExecution()) return |
|
|
pendingWifiTarget.set(target) |
|
|
pendingWifiTarget.set(target) |
|
|
val generation = connectionGeneration.incrementAndGet() |
|
|
val generation = connectionGeneration.incrementAndGet() |
|
|
|
|
|
cancelSessionControls() |
|
|
|
|
|
_uiState.update { it.copy(isCommunicationConnecting = true) } |
|
|
configurationRequired = true |
|
|
configurationRequired = true |
|
|
val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트" |
|
|
val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트" |
|
|
scope.launch(dispatchers.io) { |
|
|
launchSessionControl { |
|
|
sessionMutex.withLock { |
|
|
sessionMutex.withLock { |
|
|
if (generation != connectionGeneration.get()) return@withLock |
|
|
if (generation != connectionGeneration.get()) return@withLock |
|
|
if (_uiState.value.isMeasuring || packetRepository.isConnected.value) { |
|
|
if (_uiState.value.isMeasuring || packetRepository.isConnected.value) { |
|
|
@ -1609,7 +1657,7 @@ class MainViewModel( |
|
|
wifiSocketManager.startDualMode( |
|
|
wifiSocketManager.startDualMode( |
|
|
isGroupOwner = isGroupOwner, hostIp = hostIp, port = port, |
|
|
isGroupOwner = isGroupOwner, hostIp = hostIp, port = port, |
|
|
onConnected = { _, remoteAddress -> |
|
|
onConnected = { _, remoteAddress -> |
|
|
scope.launch(dispatchers.io) { |
|
|
launchSessionControl { |
|
|
sessionMutex.withLock { |
|
|
sessionMutex.withLock { |
|
|
if (generation != connectionGeneration.get()) return@withLock |
|
|
if (generation != connectionGeneration.get()) return@withLock |
|
|
connectedWifiTarget.set(target) |
|
|
connectedWifiTarget.set(target) |
|
|
@ -1635,7 +1683,10 @@ class MainViewModel( |
|
|
DctResult.Loading -> Unit |
|
|
DctResult.Loading -> Unit |
|
|
} |
|
|
} |
|
|
} finally { |
|
|
} finally { |
|
|
if (generation == connectionGeneration.get()) pendingWifiTarget.compareAndSet(target, null) |
|
|
if (generation == connectionGeneration.get()) { |
|
|
|
|
|
pendingWifiTarget.compareAndSet(target, null) |
|
|
|
|
|
_uiState.update { it.copy(isCommunicationConnecting = false) } |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
@ -1650,7 +1701,7 @@ class MainViewModel( |
|
|
onFailure = { error -> |
|
|
onFailure = { error -> |
|
|
if (generation == connectionGeneration.get()) { |
|
|
if (generation == connectionGeneration.get()) { |
|
|
pendingWifiTarget.compareAndSet(target, null) |
|
|
pendingWifiTarget.compareAndSet(target, null) |
|
|
_uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, |
|
|
_uiState.update { it.copy(isConnected = false, isCommunicationConnecting = false, communicationType = CommunicationType.NONE, |
|
|
connectedDeviceName = null, statusMessage = "Wi-Fi 연결 실패: $error") } |
|
|
connectedDeviceName = null, statusMessage = "Wi-Fi 연결 실패: $error") } |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
@ -1666,10 +1717,39 @@ class MainViewModel( |
|
|
val wifiApState = _wifiApState.asStateFlow() |
|
|
val wifiApState = _wifiApState.asStateFlow() |
|
|
private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint()) |
|
|
private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint()) |
|
|
val wifiApEndpoint = _wifiApEndpoint.asStateFlow() |
|
|
val wifiApEndpoint = _wifiApEndpoint.asStateFlow() |
|
|
|
|
|
val canConnectCommunication = combine( |
|
|
|
|
|
_wifiApState, _wifiApEndpoint, _uiState, wifiSocketManager.isConnected |
|
|
|
|
|
) { ap, endpoint, ui, socket -> canStartWifiCommunication(ap, endpoint, ui, socket) } |
|
|
|
|
|
.stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false) |
|
|
private var wifiEndpointPreferences: com.dct.app.data.communication.WifiConnectionPreferences? = null |
|
|
private var wifiEndpointPreferences: com.dct.app.data.communication.WifiConnectionPreferences? = null |
|
|
|
|
|
|
|
|
|
|
|
private fun canStartWifiCommunication( |
|
|
|
|
|
ap: com.dct.app.data.communication.WifiApUiState, |
|
|
|
|
|
endpoint: com.dct.app.data.communication.WifiApEndpoint, |
|
|
|
|
|
ui: MainUiState, |
|
|
|
|
|
socket: Boolean |
|
|
|
|
|
): Boolean = ap.enabled && ap.permissionGranted && !ap.busy && endpoint.isValid && |
|
|
|
|
|
(ap.networkReady || ap.connectedNetworks.count { it.localAddress != null } == 1) && |
|
|
|
|
|
!ui.isConnected && !ui.isCommunicationConnecting && !socket && !ui.isMeasuring && |
|
|
|
|
|
ui.recordingState !in setOf(MeasurementRecordingState.STARTING, |
|
|
|
|
|
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) |
|
|
|
|
|
|
|
|
|
|
|
/** Only the ribbon Connect action may start TCP and the DCT handshake. */ |
|
|
|
|
|
fun connectCommunication() { |
|
|
|
|
|
val manager = registeredWifiApManager ?: return |
|
|
|
|
|
if (!canStartWifiCommunication(manager.uiState.value, _wifiApEndpoint.value, |
|
|
|
|
|
_uiState.value, wifiSocketManager.isConnected.value)) return |
|
|
|
|
|
// Adopting a Settings-connected network is synchronous when IPv4 is ready. |
|
|
|
|
|
// onReady itself deliberately never starts protocol traffic. |
|
|
|
|
|
if (!manager.uiState.value.networkReady) manager.connectCurrent() |
|
|
|
|
|
val ap = manager.uiState.value |
|
|
|
|
|
if (!ap.networkReady) return |
|
|
|
|
|
val endpoint = _wifiApEndpoint.value |
|
|
|
|
|
connectWifi(ap.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
fun setWifiApEndpoint(endpoint: com.dct.app.data.communication.WifiApEndpoint): Boolean { |
|
|
fun setWifiApEndpoint(endpoint: com.dct.app.data.communication.WifiApEndpoint): Boolean { |
|
|
if (!endpoint.isValid || _uiState.value.isMeasuring || |
|
|
if (!endpoint.isValid || _uiState.value.isCommunicationConnecting || _uiState.value.isMeasuring || |
|
|
_uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING, |
|
|
_uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING, |
|
|
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) || |
|
|
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) || |
|
|
registeredWifiApManager?.uiState?.value?.busy == true) return false |
|
|
registeredWifiApManager?.uiState?.value?.busy == true) return false |
|
|
@ -1692,7 +1772,7 @@ class MainViewModel( |
|
|
.onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) } |
|
|
.onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) } |
|
|
} |
|
|
} |
|
|
val manager = com.dct.app.data.communication.WifiApManager(context.applicationContext, |
|
|
val manager = com.dct.app.data.communication.WifiApManager(context.applicationContext, |
|
|
onReady = { name -> val endpoint = _wifiApEndpoint.value; connectWifi(name, endpoint.host, endpoint.port) }, |
|
|
onReady = { /* Wi-Fi/IP readiness alone must not start TCP or the DCT protocol. */ }, |
|
|
onLost = { closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") }, |
|
|
onLost = { closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") }, |
|
|
onConnecting = { closeWifiTransportAfterGroupLoss(null) }, |
|
|
onConnecting = { closeWifiTransportAfterGroupLoss(null) }, |
|
|
endpointProvider = { _wifiApEndpoint.value }) |
|
|
endpointProvider = { _wifiApEndpoint.value }) |
|
|
@ -1706,21 +1786,16 @@ class MainViewModel( |
|
|
fun refreshWifiAp() { registeredWifiApManager?.refresh() } |
|
|
fun refreshWifiAp() { registeredWifiApManager?.refresh() } |
|
|
fun scanWifiAp() { registeredWifiApManager?.scan() } |
|
|
fun scanWifiAp() { registeredWifiApManager?.scan() } |
|
|
fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) { |
|
|
fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) { |
|
|
if (registeredWifiApManager?.uiState?.value?.canScan == true && ensureBackgroundExecution()) |
|
|
if (registeredWifiApManager?.uiState?.value?.canScan == true) |
|
|
registeredWifiApManager?.connect(device, password) |
|
|
registeredWifiApManager?.connect(device, password) |
|
|
} |
|
|
} |
|
|
fun connectCurrentWifiAp() { |
|
|
fun connectCurrentWifiAp() { |
|
|
if (ensureBackgroundExecution()) registeredWifiApManager?.connectCurrent() |
|
|
registeredWifiApManager?.connectCurrent() |
|
|
} |
|
|
} |
|
|
fun cancelWifiApConnection() { |
|
|
fun cancelWifiApConnection() { |
|
|
registeredWifiApManager?.disconnect() |
|
|
registeredWifiApManager?.disconnect() |
|
|
closeWifiTransportAfterGroupLoss("Wi-Fi AP 연결을 취소했습니다.") |
|
|
closeWifiTransportAfterGroupLoss("Wi-Fi AP 연결을 취소했습니다.") |
|
|
} |
|
|
} |
|
|
fun retryWifiApTcp() { |
|
|
|
|
|
val endpoint = _wifiApEndpoint.value |
|
|
|
|
|
if (registeredWifiApManager?.uiState?.value?.networkReady == true) |
|
|
|
|
|
connectWifi(wifiApState.value.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState()) |
|
|
private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState()) |
|
|
val wifiDirectState: StateFlow<com.dct.app.data.communication.WifiDirectUiState> = _wifiDirectState.asStateFlow() |
|
|
val wifiDirectState: StateFlow<com.dct.app.data.communication.WifiDirectUiState> = _wifiDirectState.asStateFlow() |
|
|
@ -1781,9 +1856,11 @@ class MainViewModel( |
|
|
|
|
|
|
|
|
private fun closeWifiTransportAfterGroupLoss(message: String? = "Wi-Fi Direct 연결이 해제되었습니다.") { |
|
|
private fun closeWifiTransportAfterGroupLoss(message: String? = "Wi-Fi Direct 연결이 해제되었습니다.") { |
|
|
val generation = connectionGeneration.incrementAndGet() |
|
|
val generation = connectionGeneration.incrementAndGet() |
|
|
|
|
|
cancelSessionControls() |
|
|
pendingWifiTarget.set(null) |
|
|
pendingWifiTarget.set(null) |
|
|
connectedWifiTarget.set(null) |
|
|
connectedWifiTarget.set(null) |
|
|
configurationRequired = true |
|
|
configurationRequired = true |
|
|
|
|
|
_uiState.update { it.copy(isCommunicationConnecting = false) } |
|
|
// Invalidate immediately: queued cleanup must never close a newly formed group's socket. |
|
|
// Invalidate immediately: queued cleanup must never close a newly formed group's socket. |
|
|
wifiSocketManager.invalidateConnection() |
|
|
wifiSocketManager.invalidateConnection() |
|
|
scope.launch(dispatchers.io) { |
|
|
scope.launch(dispatchers.io) { |
|
|
@ -1802,23 +1879,24 @@ class MainViewModel( |
|
|
* Wi-Fi AP (or retained P2P) / TCP 통신 연결 해제. |
|
|
* Wi-Fi AP (or retained P2P) / TCP 통신 연결 해제. |
|
|
*/ |
|
|
*/ |
|
|
@Synchronized |
|
|
@Synchronized |
|
|
fun disconnectCommunication(context: Context? = null) { |
|
|
fun disconnectCommunication(context: Context? = null, releaseWifiAp: Boolean = false): Job? { |
|
|
if (registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect() |
|
|
if (releaseWifiAp && registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect() |
|
|
registeredWifiDirectManager?.takeIf { registeredWifiApManager == null }?.let { manager -> |
|
|
registeredWifiDirectManager?.takeIf { registeredWifiApManager == null }?.let { manager -> |
|
|
when { |
|
|
when { |
|
|
manager.uiState.value.link != null -> manager.disconnect() |
|
|
manager.uiState.value.link != null -> manager.disconnect() |
|
|
manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect() |
|
|
manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect() |
|
|
else -> closeWifiTransportAfterGroupLoss() |
|
|
else -> closeWifiTransportAfterGroupLoss() |
|
|
} |
|
|
} |
|
|
return |
|
|
return null |
|
|
} |
|
|
} |
|
|
val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately. |
|
|
val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately. |
|
|
|
|
|
cancelSessionControls() |
|
|
pendingWifiTarget.set(null) |
|
|
pendingWifiTarget.set(null) |
|
|
connectedWifiTarget.set(null) |
|
|
connectedWifiTarget.set(null) |
|
|
configurationRequired = true |
|
|
configurationRequired = true |
|
|
_uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, |
|
|
_uiState.update { it.copy(isConnected = false, isCommunicationConnecting = false, communicationType = CommunicationType.NONE, |
|
|
connectedDeviceName = null, statusMessage = "통신 연결 해제 중...") } |
|
|
connectedDeviceName = null, statusMessage = "통신 연결 해제 중...") } |
|
|
scope.launch(dispatchers.io) { |
|
|
return scope.launch(dispatchers.io) { |
|
|
sessionMutex.withLock { |
|
|
sessionMutex.withLock { |
|
|
if (disconnectGeneration != connectionGeneration.get()) return@withLock |
|
|
if (disconnectGeneration != connectionGeneration.get()) return@withLock |
|
|
try { |
|
|
try { |
|
|
@ -1828,7 +1906,7 @@ class MainViewModel( |
|
|
finishMeasurementSession() |
|
|
finishMeasurementSession() |
|
|
wifiSocketManager.disconnectSuspend() |
|
|
wifiSocketManager.disconnectSuspend() |
|
|
// Keep the AP lease until STOP and log finalization have completed. |
|
|
// Keep the AP lease until STOP and log finalization have completed. |
|
|
registeredWifiApManager?.disconnect() |
|
|
if (releaseWifiAp) registeredWifiApManager?.disconnect() |
|
|
_uiState.update { |
|
|
_uiState.update { |
|
|
it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE, |
|
|
it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE, |
|
|
connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.") |
|
|
connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.") |
|
|
@ -3189,13 +3267,17 @@ class MainViewModel( |
|
|
fun syncDeviceConfiguration(onComplete: ((Boolean) -> Unit)? = null) { |
|
|
fun syncDeviceConfiguration(onComplete: ((Boolean) -> Unit)? = null) { |
|
|
configurationRequired = true |
|
|
configurationRequired = true |
|
|
val generation = connectionGeneration.get() |
|
|
val generation = connectionGeneration.get() |
|
|
scope.launch(dispatchers.io) { |
|
|
launchSessionControl { |
|
|
val success = sessionMutex.withLock { |
|
|
var success = false |
|
|
|
|
|
try { |
|
|
|
|
|
success = sessionMutex.withLock { |
|
|
generation == connectionGeneration.get() && syncDeviceConfigurationLocked(generation) |
|
|
generation == connectionGeneration.get() && syncDeviceConfigurationLocked(generation) |
|
|
} |
|
|
} |
|
|
|
|
|
} finally { |
|
|
onComplete?.invoke(success) |
|
|
onComplete?.invoke(success) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
private suspend fun syncDeviceConfigurationLocked( |
|
|
private suspend fun syncDeviceConfigurationLocked( |
|
|
generation: Long, |
|
|
generation: Long, |
|
|
@ -3210,15 +3292,23 @@ class MainViewModel( |
|
|
} |
|
|
} |
|
|
val configuration = createDeviceMeasurementConfiguration() |
|
|
val configuration = createDeviceMeasurementConfiguration() |
|
|
val configResult = packetRepository.configureMeasurement(configuration) |
|
|
val configResult = packetRepository.configureMeasurement(configuration) |
|
|
|
|
|
if (generation != connectionGeneration.get()) return false |
|
|
if (configResult !is DctResult.Success) { |
|
|
if (configResult !is DctResult.Success) { |
|
|
val message = if (configResult is DctResult.Failure) "장비 설정 실패: ${configResult.error.message}" |
|
|
val message = when { |
|
|
else "장비 설정이 아직 완료되지 않았습니다." |
|
|
configResult is DctResult.Failure && |
|
|
|
|
|
configResult.error is com.dct.app.core.error.DctError.ProtocolError.ResponseTimeout && |
|
|
|
|
|
!packetRepository.isConnected.value -> |
|
|
|
|
|
CONFIGURATION_TIMEOUT_MESSAGE |
|
|
|
|
|
configResult is DctResult.Failure -> "장비 설정 실패: ${configResult.error.message}" |
|
|
|
|
|
else -> "장비 설정이 아직 완료되지 않았습니다." |
|
|
|
|
|
} |
|
|
_uiState.update { it.copy(statusMessage = message) } |
|
|
_uiState.update { it.copy(statusMessage = message) } |
|
|
return false |
|
|
return false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (generation != connectionGeneration.get()) return false |
|
|
if (generation != connectionGeneration.get()) return false |
|
|
configuredMdbSelection = configuration.selectedMdbSignals.map { it.reclid to it.idx } |
|
|
configuredMdbSelection = configuration.selectedMdbSignals.map { it.reclid to it.idx } |
|
|
|
|
|
configuredCanMessages = configuration.canMessages |
|
|
configurationRequired = false |
|
|
configurationRequired = false |
|
|
_uiState.update { it.copy(statusMessage = "Wi-Fi 장비 연결 및 설정 완료 (Ready)") } |
|
|
_uiState.update { it.copy(statusMessage = "Wi-Fi 장비 연결 및 설정 완료 (Ready)") } |
|
|
return true |
|
|
return true |
|
|
@ -3231,7 +3321,7 @@ class MainViewModel( |
|
|
fun startMeasure(frequencyHz: Int = 30) { |
|
|
fun startMeasure(frequencyHz: Int = 30) { |
|
|
if (packetRepository.connectionState.value == DeviceConnectionState.READY && !ensureBackgroundExecution()) return |
|
|
if (packetRepository.connectionState.value == DeviceConnectionState.READY && !ensureBackgroundExecution()) return |
|
|
val generation = connectionGeneration.get() |
|
|
val generation = connectionGeneration.get() |
|
|
scope.launch(dispatchers.io) { |
|
|
launchSessionControl { |
|
|
sessionMutex.withLock { |
|
|
sessionMutex.withLock { |
|
|
if (generation != connectionGeneration.get() || _uiState.value.isMeasuring) return@withLock |
|
|
if (generation != connectionGeneration.get() || _uiState.value.isMeasuring) return@withLock |
|
|
if (!_uiState.value.canDbcFileName.isNullOrBlank() && _canSignals.value.isEmpty()) { |
|
|
if (!_uiState.value.canDbcFileName.isNullOrBlank() && _canSignals.value.isEmpty()) { |
|
|
@ -3247,15 +3337,16 @@ class MainViewModel( |
|
|
setStatusMessage("Wi-Fi 장비 연결을 먼저 완료하세요.") |
|
|
setStatusMessage("Wi-Fi 장비 연결을 먼저 완료하세요.") |
|
|
return@withLock |
|
|
return@withLock |
|
|
} |
|
|
} |
|
|
// Formulas/graph bindings can change after the initial connection handshake. |
|
|
// Bindings can change after Connect. Register MDB inputs and selected |
|
|
// Register their raw MDB inputs before START, while the device is still READY. |
|
|
// CAN messages before START; a loaded DBC is not a monitoring selection. |
|
|
val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty() |
|
|
val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty() |
|
|
if (mdbSignals.isNotEmpty()) { |
|
|
val configuration = createDeviceMeasurementConfiguration() |
|
|
val selected = selectVisibleMdbSignals(mdbSignals).map { it.reclid to it.idx } |
|
|
val mdbChanged = mdbSignals.isNotEmpty() && |
|
|
if (selected != configuredMdbSelection && !syncDeviceConfigurationLocked(generation, notifyProgress = false)) { |
|
|
configuration.selectedMdbSignals.map { it.reclid to it.idx } != configuredMdbSelection |
|
|
|
|
|
if ((mdbChanged || configuration.canMessages != configuredCanMessages) && |
|
|
|
|
|
!syncDeviceConfigurationLocked(generation, notifyProgress = false)) { |
|
|
return@withLock |
|
|
return@withLock |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
setStatusMessage("계측 시작 요청 중...") |
|
|
setStatusMessage("계측 시작 요청 중...") |
|
|
measurementRequestMillis = System.currentTimeMillis() |
|
|
measurementRequestMillis = System.currentTimeMillis() |
|
|
when (val result = packetRepository.startStreaming(frequencyHz)) { |
|
|
when (val result = packetRepository.startStreaming(frequencyHz)) { |
|
|
@ -3265,7 +3356,9 @@ class MainViewModel( |
|
|
beginMeasurementSession(frequencyHz) |
|
|
beginMeasurementSession(frequencyHz) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
is DctResult.Failure -> _uiState.update { it.copy(isMeasuring = false, statusMessage = result.error.message) } |
|
|
is DctResult.Failure -> if (generation == connectionGeneration.get()) { |
|
|
|
|
|
_uiState.update { it.copy(isMeasuring = false, statusMessage = result.error.message) } |
|
|
|
|
|
} |
|
|
DctResult.Loading -> Unit |
|
|
DctResult.Loading -> Unit |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
@ -3280,14 +3373,22 @@ class MainViewModel( |
|
|
|
|
|
|
|
|
private fun createDeviceMeasurementConfiguration(): com.dct.app.domain.model.protocol.DctMeasurementConfiguration { |
|
|
private fun createDeviceMeasurementConfiguration(): com.dct.app.domain.model.protocol.DctMeasurementConfiguration { |
|
|
val project = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() |
|
|
val project = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() |
|
|
val maxDlc = if (project.canMode == "CAN(FD)") 64 else 8 |
|
|
val canSignals = _canSignals.value |
|
|
val messages = _canSignals.value |
|
|
val selectedKeys = selectedMeasurementKeys() |
|
|
.filter { it.messageId in 0L..0xFFFFL } |
|
|
// PC CheckCANDBCIDList registers messages referenced by selected signals, |
|
|
|
|
|
// not every schema entry in the loaded DBC. Keep the complete message DLC. |
|
|
|
|
|
val selectedCanIds = canSignals.asSequence() |
|
|
|
|
|
.filter { signal -> selectedKeys.any { com.dct.app.domain.model.mdb.CanSignalKey.matches(it, signal) } } |
|
|
|
|
|
.map { it.messageId }.toHashSet() |
|
|
|
|
|
val messages = canSignals |
|
|
|
|
|
.filter { it.messageId in 0L..0xFFFFL && it.messageId in selectedCanIds } |
|
|
.groupBy { it.messageId } |
|
|
.groupBy { it.messageId } |
|
|
.map { (canId, signals) -> |
|
|
.map { (canId, signals) -> |
|
|
val declaredDlc = signals.maxOfOrNull { it.messageDlc } ?: 0 |
|
|
val declaredDlc = signals.maxOfOrNull { it.messageDlc } ?: 0 |
|
|
com.dct.app.domain.model.protocol.DctCanMessageConfiguration(canId, declaredDlc.coerceAtMost(maxDlc)) |
|
|
// PC SendCANDBCIDData preserves the DBC byte length regardless of CAN mode. |
|
|
} |
|
|
// Mode selection is sent separately; silently truncating FD lengths changes the schema. |
|
|
|
|
|
com.dct.app.domain.model.protocol.DctCanMessageConfiguration(canId, declaredDlc) |
|
|
|
|
|
}.sortedBy { it.canId } |
|
|
Log.i( |
|
|
Log.i( |
|
|
LOG_TAG, |
|
|
LOG_TAG, |
|
|
"CAN 0x11 설정 준비: signals=${_canSignals.value.size}, messages=${messages.size}, " + |
|
|
"CAN 0x11 설정 준비: signals=${_canSignals.value.size}, messages=${messages.size}, " + |
|
|
@ -3308,14 +3409,13 @@ class MainViewModel( |
|
|
canMessages = messages, |
|
|
canMessages = messages, |
|
|
sensorModules = _sensorModules.value, |
|
|
sensorModules = _sensorModules.value, |
|
|
isHighSpeedCan = project.canMode == "CAN(HiSpeed)", |
|
|
isHighSpeedCan = project.canMode == "CAN(HiSpeed)", |
|
|
isOldModel = project.forOldModel |
|
|
isOldModel = project.forOldModel, |
|
|
|
|
|
didPreSetting = project.didInfo.preSetting |
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** Register visible MDB signals and Formula inputs in 0x13, even without raw graph bindings. */ |
|
|
/** Selection snapshot for configuration only; never scan definitions for each packet. */ |
|
|
private fun selectVisibleMdbSignals( |
|
|
private fun selectedMeasurementKeys(): Set<String> { |
|
|
mdbSignals: List<com.dct.app.domain.model.mdb.SignalDefinition> |
|
|
|
|
|
): List<com.dct.app.domain.model.mdb.SignalDefinition> { |
|
|
|
|
|
val selectedKeys = mutableSetOf<String>() |
|
|
val selectedKeys = mutableSetOf<String>() |
|
|
fun addSeries(series: com.dct.app.domain.model.chart.ChartSignalSeries) { |
|
|
fun addSeries(series: com.dct.app.domain.model.chart.ChartSignalSeries) { |
|
|
selectedKeys += series.signalId |
|
|
selectedKeys += series.signalId |
|
|
@ -3343,8 +3443,14 @@ class MainViewModel( |
|
|
if (dependency.startsWith("raw_sig_", ignoreCase = true)) selectedKeys += dependency.substring(4) |
|
|
if (dependency.startsWith("raw_sig_", ignoreCase = true)) selectedKeys += dependency.substring(4) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
val foldedKeys = selectedKeys.mapTo(hashSetOf()) { it.lowercase(java.util.Locale.ROOT) } |
|
|
return selectedKeys.mapTo(hashSetOf()) { it.lowercase(java.util.Locale.ROOT) } |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** Register visible MDB signals and Formula inputs in 0x13, even without raw graph bindings. */ |
|
|
|
|
|
private fun selectVisibleMdbSignals( |
|
|
|
|
|
mdbSignals: List<com.dct.app.domain.model.mdb.SignalDefinition> |
|
|
|
|
|
): List<com.dct.app.domain.model.mdb.SignalDefinition> { |
|
|
|
|
|
val foldedKeys = selectedMeasurementKeys() |
|
|
val matched = mdbSignals.filter { signal -> |
|
|
val matched = mdbSignals.filter { signal -> |
|
|
"sig_${signal.reclid}_${signal.idx}" in foldedKeys || signal.name.lowercase(java.util.Locale.ROOT) in foldedKeys |
|
|
"sig_${signal.reclid}_${signal.idx}" in foldedKeys || signal.name.lowercase(java.util.Locale.ROOT) in foldedKeys |
|
|
} |
|
|
} |
|
|
@ -4102,7 +4208,7 @@ class MainViewModel( |
|
|
*/ |
|
|
*/ |
|
|
fun stopMeasure() { |
|
|
fun stopMeasure() { |
|
|
val generation = connectionGeneration.get() |
|
|
val generation = connectionGeneration.get() |
|
|
scope.launch(dispatchers.io) { |
|
|
launchSessionControl { |
|
|
sessionMutex.withLock { |
|
|
sessionMutex.withLock { |
|
|
if (generation != connectionGeneration.get() || !_uiState.value.isMeasuring) return@withLock |
|
|
if (generation != connectionGeneration.get() || !_uiState.value.isMeasuring) return@withLock |
|
|
setStatusMessage("계측 정지 요청 중...") |
|
|
setStatusMessage("계측 정지 요청 중...") |
|
|
|