diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7871dc..278079e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,14 @@ name: Release -# Tag push (e.g. v0.1.0) -> signed APK + AAB, attached to a GitHub Release. +# Tag push (e.g. v0.1.0) -> Android APK/AAB + Linux .deb + Windows .msi on GitHub Release. on: push: tags: ['v*'] workflow_dispatch: jobs: - release: + android: runs-on: ubuntu-latest - permissions: - contents: write # required to create the GitHub Release steps: - uses: actions/checkout@v4 @@ -42,16 +40,80 @@ jobs: RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} run: ./gradlew :app:assembleRelease :app:bundleRelease --stacktrace - - name: Upload artifacts + - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: - name: rtk-router-release + name: android path: | app/build/outputs/apk/release/app-release.apk app/build/outputs/bundle/release/app-release.aab - - name: Create GitHub Release (APK for sideload) + desktop-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build Linux .deb + run: ./gradlew :desktopApp:packageReleaseDeb --stacktrace -Dorg.gradle.configureondemand=true + + - name: Upload Linux .deb + uses: actions/upload-artifact@v4 + with: + name: desktop-linux + path: desktopApp/build/compose/binaries/main-release/deb/*.deb + + desktop-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build Windows .msi + shell: pwsh + run: .\gradlew :desktopApp:packageReleaseMsi --stacktrace -Dorg.gradle.configureondemand=true + + - name: Upload Windows .msi + uses: actions/upload-artifact@v4 + with: + name: desktop-windows + path: desktopApp/build/compose/binaries/main-release/msi/*.msi + + publish: + needs: [android, desktop-linux, desktop-windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Collect release files + run: | + mkdir -p release + find dist -type f \( -name '*.apk' -o -name '*.aab' -o -name '*.deb' -o -name '*.msi' \) -exec cp -v {} release/ \; + ls -la release/ + + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: app/build/outputs/apk/release/app-release.apk + files: release/* generate_release_notes: true diff --git a/README.md b/README.md index 9a45771..d241794 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,19 @@ NTRIP caster ──RTCM3/TCP──▶ [폰 NTRIP client] ──▶ [USB serial T | M5 | 운영 | 수신기 프로파일 다중 + 세션 로깅 | ## 빌드 / 실행 -- APK: `./gradlew assembleDebug` (phone-sensor 패턴) — 산출물 `app/build/outputs/apk/debug/`. + +### Android +- APK: `./gradlew assembleDebug` — 산출물 `app/build/outputs/apk/debug/`. - 폰: NTRIP 설정(host/port/mount/auth) 입력 → USB 수신기 연결(OTG) → baud 선택 → START. +### Desktop (Linux & Windows) +- 실행: `./gradlew :desktopApp:run` (JDK 17) +- Linux: `/dev/ttyACM0`, `/dev/ttyUSB0` — 시리얼 권한은 앱 내 pkexec/sudo 또는 `dialout` 그룹 +- Windows: `COM*` 포트 선택 (드라이버 설치 후) +- 릴리즈: 태그 `v*` push → GitHub Release에 APK/AAB + `.deb` + `.msi` 자동 첨부 +- 로컬 패키지: `./gradlew :desktopApp:packageReleaseDeb` (Linux) / `packageReleaseMsi` (Windows) +- 상세: [desktopApp/README.md](desktopApp/README.md) + ## 참조 - phone-sensor-stream: 같은 Kotlin/Compose 스택, USB/GNSS(gnsstest) 코드 재사용 후보. - NovAtel: RTKASSIST / RTCM 입력 포트 문서. u-blox: u-center, UBX-CFG. diff --git a/build.gradle.kts b/build.gradle.kts index cd353b3..ba22515 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,5 +2,7 @@ plugins { id("com.android.application") version "8.5.2" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false + id("org.jetbrains.kotlin.jvm") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false + id("org.jetbrains.compose") version "1.6.11" apply false } diff --git a/desktopApp/README.md b/desktopApp/README.md new file mode 100644 index 0000000..9b47b56 --- /dev/null +++ b/desktopApp/README.md @@ -0,0 +1,71 @@ +# RTK Router — Desktop (Linux & Windows) + +NTRIP caster에서 RTCM3 보정 데이터를 받아 USB/serial로 GNSS 수신기(u-blox F9P 등)에 주입하는 GUI 앱. + +| OS | Serial port | Package | +|----|-------------|---------| +| Linux | `/dev/ttyACM*`, `/dev/ttyUSB*` | `.deb` | +| Windows | `COM*` | `.msi` | + +## Build & Run (dev) + +JDK 17 required. + +```bash +# from repo root +./gradlew :desktopApp:run +``` + +## Release packages (local) + +```bash +# Linux .deb +./gradlew :desktopApp:packageReleaseDeb + +# Windows .msi (run on Windows) +.\gradlew :desktopApp:packageReleaseMsi +``` + +Outputs under `desktopApp/build/compose/binaries/main-release/`. + +## GitHub Release (CI) + +`v*` tag push (e.g. `v0.1.0`) triggers [.github/workflows/release.yml](../.github/workflows/release.yml): + +- Android: `app-release.apk`, `app-release.aab` +- Linux: `rtk-router_*_amd64.deb` +- Windows: `rtk-router-*.msi` + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +## Linux serial permissions + +`/dev/tty*` 장치에 읽기/쓰기 권한이 필요합니다. + +1. **앱 내 (임시)**: START 시 권한이 없으면 다이얼로그에서 + - **pkexec로 권한 부여** — `chmod a+rw /dev/ttyACM0` + - **sudo로 권한 부여** — 앱 내 비밀번호 입력 + - **dialout 영구 추가** — `usermod -aG dialout $USER` (재로그인) + +2. **수동 (권장, 영구)**: + +```bash +sudo usermod -aG dialout $USER +# log out and back in +``` + +## Windows serial + +USB-UART 또는 u-blox CDC 드라이버 설치 후 Device Manager에서 COM 포트 번호를 확인하세요. 별도 권한 설정은 필요 없습니다. + +## Device notes + +| Device | Linux | Windows | Baud | +|--------|-------|---------|------| +| u-blox ZED-F9P (USB CDC) | `/dev/ttyACM0` | `COM3` (varies) | ignored (native CDC) | +| USB-UART (FTDI/CP210x/CH340) | `/dev/ttyUSB0` | `COM4` (varies) | must match receiver UART | + +Settings: Linux `~/.config/rtk-router/settings.json`, Windows `%USERPROFILE%\.config\rtk-router\settings.json`. diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 0000000..911f20e --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,42 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("jvm") + id("org.jetbrains.compose") + kotlin("plugin.serialization") +} + +group = "com.onlyti.rtkrouter" +version = "0.1.0" + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + + // Linux /dev/tty* and Windows COM* serial access. + implementation("com.fazecast:jSerialComm:2.10.4") +} + +compose.desktop { + application { + mainClass = "com.onlyti.rtkrouter.desktop.MainKt" + buildTypes.release.proguard { + isEnabled.set(false) + } + nativeDistributions { + targetFormats(TargetFormat.Deb, TargetFormat.Msi) + packageName = "rtk-router" + packageVersion = "0.1.0" + description = "NTRIP RTCM router for GNSS receivers" + vendor = "onlyti" + } + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/Main.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/Main.kt new file mode 100644 index 0000000..c2a2d7d --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/Main.kt @@ -0,0 +1,17 @@ +package com.onlyti.rtkrouter.desktop + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.onlyti.rtkrouter.desktop.ui.DesktopApp + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "RTK Router", + state = rememberWindowState(width = 920.dp, height = 900.dp), + ) { + DesktopApp() + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/bridge/RtkBridge.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/bridge/RtkBridge.kt new file mode 100644 index 0000000..df2a871 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/bridge/RtkBridge.kt @@ -0,0 +1,376 @@ +package com.onlyti.rtkrouter.desktop.bridge + +import com.onlyti.rtkrouter.desktop.config.CasterProfile +import com.onlyti.rtkrouter.desktop.config.EndpointMode +import com.onlyti.rtkrouter.desktop.config.RtkConfig +import com.onlyti.rtkrouter.desktop.gnss.Nmea +import com.onlyti.rtkrouter.desktop.ntrip.NtripClient +import com.onlyti.rtkrouter.desktop.ntrip.StrEntry +import com.onlyti.rtkrouter.desktop.ntrip.rtcmFormatRank +import com.onlyti.rtkrouter.desktop.serial.SerialLink +import com.onlyti.rtkrouter.desktop.service.ErrorInfo +import com.onlyti.rtkrouter.desktop.service.GeoPt +import com.onlyti.rtkrouter.desktop.service.RtkState +import com.onlyti.rtkrouter.desktop.service.RtkStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.Locale +import java.util.concurrent.atomic.AtomicLong + +/** Desktop bridge: NTRIP caster -> serial -> GNSS receiver (Android RtkService port). */ +class RtkBridge { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var statusJob: Job? = null + + private var config: RtkConfig = RtkConfig() + private var serialDevicePath: String = "" + private var serial: SerialLink? = null + + private class BaseStream( + val id: Int, + val profile: CasterProfile, + val mount: String, + val distanceKm: Double, + requiresGga: Boolean, + ) { + @Volatile var client: NtripClient? = null + @Volatile var connected = false + @Volatile var connectedAtMs = 0L + @Volatile var lastDataMs = 0L + @Volatile var nextRetryMs = 0L + @Volatile var retryCount = 0 + @Volatile var ggaActive = requiresGga + val rxBytes = AtomicLong(0) + } + + @Volatile private var streams: List = emptyList() + @Volatile private var activeStreamId = -1 + + private val sessionRx = AtomicLong(0) + private val sessionTx = AtomicLong(0) + @Volatile private var lastRtcmAtMs = 0L + @Volatile private var startedAtMs = 0L + @Volatile private var lastFixQuality = -1 + @Volatile private var rxFix: Nmea.GgaFix? = null + @Volatile private var lastNmea = "" + private val rateWindow = ArrayDeque>() + private val track = ArrayDeque>() + + @Volatile private var resolvedMount = "" + @Volatile private var resolvedMode = "" + @Volatile private var ggaActive = false + @Volatile private var ntripConnected = false + @Volatile private var healthyCount = 0 + @Volatile private var streamLines: List = emptyList() + @Volatile private var failoverLevel = "L0" + @Volatile private var activeProfileName = "" + @Volatile private var serialConnected = false + @Volatile private var serialDeviceName = "" + @Volatile private var detail = "starting" + @Volatile private var error = ErrorInfo() + + fun start(cfg: RtkConfig, devicePath: String) { + stop() + config = cfg + serialDevicePath = devicePath + startedAtMs = System.currentTimeMillis() + detail = "starting" + + serial = SerialLink( + devicePath = devicePath, + baud = config.baud, + onConnected = { name -> + serialConnected = true + serialDeviceName = name + detail = "serial: $name @ ${config.baud}" + }, + onDisconnected = { reason -> + serialConnected = false + error = ErrorInfo("Serial", "L0", reason) + detail = reason + }, + onData = { bytes -> onSerialData(bytes) }, + ).also { it.connect() } + + scope.launch(Dispatchers.IO) { resolveAndConnectNtrip() } + statusJob = scope.launch { statusLoop() } + } + + fun stop() { + statusJob?.cancel() + statusJob = null + streams.forEach { it.client?.stop() } + streams = emptyList() + serial?.close() + serial = null + RtkState.reset() + } + + fun resetUsage() { + sessionRx.set(0) + sessionTx.set(0) + serial?.txBytes?.set(0) + serial?.rxBytes?.set(0) + rateWindow.clear() + } + + fun shutdown() { + stop() + scope.cancel() + } + + private suspend fun resolveAndConnectNtrip() { + val profiles = config.profiles + .filter { it.enabled && it.host.isNotBlank() } + .sortedBy { it.priority } + if (profiles.isEmpty()) { + error = ErrorInfo("Config", "L0", "no enabled caster with host") + return + } + + val all = ArrayList() + var lastMode = "" + for (profile in profiles) { + var entries: List = emptyList() + if (config.endpointMode != EndpointMode.MANUAL) { + NtripClient.fetchSourcetable(profile) + .onSuccess { entries = it } + .onFailure { + if (profile.preferredMount.isBlank()) return@onFailure + } + } + val (targets, mode) = chooseTargets(profile, profile.preferredMount, entries) + if (mode.isNotEmpty()) lastMode = mode + all.addAll(targets) + } + + if (all.isEmpty()) { + error = ErrorInfo("Config", "L0", "no mount resolved on any caster") + return + } + val capped = all.take(MAX_STREAMS) + resolvedMode = if (profiles.size > 1) "MULTI/$lastMode" else lastMode + resolvedMount = capped.first().mount + + val list = capped.mapIndexed { i, t -> BaseStream(i, t.profile, t.mount, t.distanceKm, t.requiresGga) } + list.forEach { startStream(it) } + streams = list + activeStreamId = 0 + } + + private class StreamTarget(val profile: CasterProfile, val mount: String, val requiresGga: Boolean, val distanceKm: Double) + + private fun chooseTargets(profile: CasterProfile, preferred: String, entries: List): Pair, String> { + if (config.endpointMode == EndpointMode.MANUAL || preferred.isNotBlank()) { + val e = entries.firstOrNull { it.mount == preferred } + return listOf(StreamTarget(profile, preferred, e?.requiresGga ?: false, Double.NaN)) to "MANUAL" + } + if (entries.isEmpty()) return emptyList() to "" + + if (config.endpointMode == EndpointMode.AUTO) { + val vrsList = entries.filter { it.requiresGga } + val vrs = vrsList.filter { rtcmFormatRank(it.format) >= 0 } + .maxByOrNull { rtcmFormatRank(it.format) } + ?: vrsList.firstOrNull() + if (vrs != null) return listOf(StreamTarget(profile, vrs.mount, true, Double.NaN)) to "VRS" + } + val n = config.hotStandbyCount.coerceIn(1, 5) + val picked = entries.filter { rtcmFormatRank(it.format) >= 0 } + .sortedByDescending { rtcmFormatRank(it.format) } + .take(n) + .map { StreamTarget(profile, it.mount, it.requiresGga, Double.NaN) } + return picked to "NEAREST" + } + + private fun startStream(s: BaseStream) { + s.client?.stop() + s.client = NtripClient( + profile = s.profile, + mount = s.mount, + onRtcm = { buf, n -> onStreamRtcm(s, buf, n) }, + onState = { connected, d -> + s.connected = connected + if (connected) { s.retryCount = 0; s.connectedAtMs = System.currentTimeMillis() } + if (s.id == activeStreamId) detail = d + }, + ggaProvider = { if (s.ggaActive) ggaForUpload() else null }, + ).also { it.start() } + } + + private fun ggaForUpload(): String? { + val raw = lastNmea + if (lastFixQuality >= 1 && raw.startsWith("\$")) { + val gga = if (raw.endsWith("\r\n")) raw else "$raw\r\n" + sessionTx.addAndGet(gga.length.toLong()) + return gga + } + return null + } + + private fun onStreamRtcm(s: BaseStream, buf: ByteArray, n: Int) { + s.rxBytes.addAndGet(n.toLong()) + s.lastDataMs = System.currentTimeMillis() + sessionRx.addAndGet(n.toLong()) + if (s.id == activeStreamId) { + lastRtcmAtMs = s.lastDataMs + serial?.write(buf, n) + } + } + + private val nmeaBuf = StringBuilder() + + private fun onSerialData(bytes: ByteArray) { + synchronized(nmeaBuf) { + nmeaBuf.append(String(bytes, Charsets.US_ASCII)) + if (nmeaBuf.length > NMEA_BUF_CAP) { + nmeaBuf.delete(0, nmeaBuf.length - NMEA_BUF_CAP) + } + var nl = nmeaBuf.indexOf("\n") + while (nl >= 0) { + val line = nmeaBuf.substring(0, nl) + nmeaBuf.delete(0, nl + 1) + Nmea.parseGga(line)?.let { gga -> + lastFixQuality = gga.quality + rxFix = gga + lastNmea = line.trim() + } + nl = nmeaBuf.indexOf("\n") + } + } + } + + private fun superviseStreams(now: Long) { + if (streams.isEmpty()) return + val deadMs = config.failover.deadTimeoutSec * 1000L + fun healthy(s: BaseStream) = s.connected && s.lastDataMs > 0 && now - s.lastDataMs < deadMs + + if (config.autoReconnect) { + for (s in streams) { + if (!s.connected && now >= s.nextRetryMs) { + val backoff = minOf(1L shl s.retryCount.coerceIn(0, 5), config.failover.backoffMaxSec.toLong()) * 1000L + s.nextRetryMs = now + backoff + s.retryCount++ + startStream(s) + } + } + } + + val active = streams.find { it.id == activeStreamId } + if (active == null || !healthy(active)) { + val next = streams.firstOrNull { healthy(it) } + if (next != null && next.id != activeStreamId) { + activeStreamId = next.id + resolvedMount = next.mount + } + } else { + resolvedMount = active.mount + } + + for (s in streams) { + if (!s.ggaActive && s.connected && s.connectedAtMs > 0 && + s.rxBytes.get() == 0L && now - s.connectedAtMs > GGA_PROBE_MS + ) { + s.ggaActive = true + } + } + + val act = streams.find { it.id == activeStreamId } + ntripConnected = streams.any { it.connected } + healthyCount = streams.count { healthy(it) } + ggaActive = act?.ggaActive ?: false + activeProfileName = act?.profile?.name ?: "" + failoverLevel = when { + act == null || activeStreamId <= 0 -> "L0" + streams.firstOrNull()?.profile?.name != act.profile.name -> "L2" + else -> "L1" + } + streamLines = streams.map { s -> + val star = if (s.id == activeStreamId) "▶ " else " " + val dist = if (s.distanceKm.isNaN()) "" else String.format(Locale.US, " %.1f km", s.distanceKm) + val st = if (healthy(s)) "ok" else if (s.connected) "stale" else "down" + "$star${s.profile.name}/${s.mount}$dist · $st" + } + if (healthyCount == 0 && now - startedAtMs > 5000) { + error = ErrorInfo("NoRtcmData", failoverLevel, "no healthy base stream") + } + } + + private fun windowedRate(): Long { + val w = rateWindow + if (w.size < 2) return 0 + val (t0, b0) = w.first() + val (t1, b1) = w.last() + val dt = t1 - t0 + return if (dt > 0) (b1 - b0) * 1000 / dt else 0 + } + + private suspend fun statusLoop() { + while (scope.isActive) { + val now = System.currentTimeMillis() + val rx = sessionRx.get() + rateWindow.addLast(now to rx) + while (rateWindow.size > 1 && now - rateWindow.first().first > RATE_WINDOW_MS) { + rateWindow.removeFirst() + } + val rate = windowedRate() + + val rf = rxFix + if (rf != null && !rf.lat.isNaN() && !rf.lon.isNaN() && rf.quality >= 1) { + track.addLast(now to GeoPt(rf.lat, rf.lon)) + while (track.isNotEmpty() && now - track.first().first > TRACK_WINDOW_MS) track.removeFirst() + } + + superviseStreams(now) + + RtkState.update( + RtkStatus( + running = true, + ntripConnected = ntripConnected, + serialConnected = serialConnected, + deviceName = serialDeviceName, + serialPortCount = 1, + serialPortIndex = config.serialPortIndex, + activeProfileName = activeProfileName, + activeMount = resolvedMount, + activeMode = resolvedMode, + ggaActive = ggaActive, + failoverLevel = failoverLevel, + streamCount = streams.size, + healthyCount = healthyCount, + streamLines = streamLines, + rtcmBytesPerSec = if (rate < 0) 0 else rate, + sessionRxBytes = rx, + sessionTxBytes = sessionTx.get(), + serialTxBytes = serial?.txBytes?.get() ?: 0, + serialRxBytes = serial?.rxBytes?.get() ?: 0, + lastFixQuality = lastFixQuality, + lastError = error, + uptimeSec = (now - startedAtMs) / 1000, + detail = detail, + rxLat = rxFix?.lat ?: Double.NaN, + rxLon = rxFix?.lon ?: Double.NaN, + rxSats = rxFix?.satellites ?: 0, + rxHdop = rxFix?.hdop ?: Double.NaN, + rxAltM = rxFix?.altMeters ?: Double.NaN, + lastNmea = lastNmea, + trajectory = track.map { it.second }, + ), + ) + delay(500) + } + } + + companion object { + private const val RATE_WINDOW_MS = 4000L + private const val NMEA_BUF_CAP = 4096 + private const val GGA_PROBE_MS = 6000L + private const val MAX_STREAMS = 6 + private const val TRACK_WINDOW_MS = 60_000L + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/config/CasterPresets.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/config/CasterPresets.kt new file mode 100644 index 0000000..074e4c3 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/config/CasterPresets.kt @@ -0,0 +1,35 @@ +package com.onlyti.rtkrouter.desktop.config + +data class CasterPreset( + val country: String, + val name: String, + val host: String, + val port: Int, + val hint: String, +) + +object CasterPresets { + val ALL: List = listOf( + CasterPreset( + country = "KR", + name = "국토지리정보원 통합센터 (gnssdata)", + host = "www.gnssdata.or.kr", + port = 2101, + hint = "고정국망. mount 예: SUWN-RTCM32", + ), + CasterPreset( + country = "KR", + name = "서울특별시 (eseoul)", + host = "gnss.eseoul.go.kr", + port = 2101, + hint = "VRS. mount 예: VRS-RTCM32", + ), + CasterPreset( + country = "WW", + name = "RTK2GO (community, free)", + host = "rtk2go.com", + port = 2101, + hint = "커뮤니티 고정국", + ), + ) +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/config/RtkConfig.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/config/RtkConfig.kt new file mode 100644 index 0000000..92f9e54 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/config/RtkConfig.kt @@ -0,0 +1,70 @@ +package com.onlyti.rtkrouter.desktop.config + +import kotlinx.serialization.Serializable + +enum class NtripVersion { V1, V2 } + +enum class EndpointMode { + AUTO, + MANUAL, + NEAREST, +} + +@Serializable +data class CasterProfile( + val name: String = "default", + val host: String = "", + val port: Int = 2101, + val user: String = "", + val pass: String = "", + val ntripVersion: NtripVersion = NtripVersion.V2, + val preferredMount: String = "", + val priority: Int = 0, + val preset: Boolean = false, + val enabled: Boolean = true, +) + +@Serializable +data class BaseStation( + val mount: String, + val name: String = "", + val lat: Double = Double.NaN, + val lon: Double = Double.NaN, + val ellipHeight: Double = 0.0, + val requiresGga: Boolean = false, +) + +@Serializable +data class FailoverPolicy( + val minByteRate: Int = 10, + val deadTimeoutSec: Int = 10, + val sameMountRetries: Int = 3, + val backoffMaxSec: Int = 30, + val reprobeSec: Int = 120, + val recoveryHysteresisSec: Int = 30, +) + +@Serializable +data class RtkConfig( + val profiles: List = listOf(CasterProfile()), + val activeIndex: Int = 0, + val endpointMode: EndpointMode = EndpointMode.AUTO, + val baud: Int = 115200, + val serialPortIndex: Int = 0, + val hotStandbyCount: Int = 3, + val validateRtcm3: Boolean = false, + val autoReconnect: Boolean = true, + val showDataUsage: Boolean = true, + val fallbackStations: List = emptyList(), + val failover: FailoverPolicy = FailoverPolicy(), +) { + val activeProfile: CasterProfile + get() = profiles.getOrElse(activeIndex) { CasterProfile() } +} + +@Serializable +data class DesktopSettings( + val config: RtkConfig = RtkConfig(), + /** e.g. /dev/ttyACM0 (Linux F9P) or COM3 (Windows). */ + val serialDevicePath: String = "", +) diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/gnss/Nmea.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/gnss/Nmea.kt new file mode 100644 index 0000000..d9707f7 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/gnss/Nmea.kt @@ -0,0 +1,90 @@ +package com.onlyti.rtkrouter.desktop.gnss + +import java.util.Locale +import kotlin.math.abs +import kotlin.math.floor + +object Nmea { + fun checksum(body: String): String { + var c = 0 + for (ch in body) c = c xor ch.code + return String.format(Locale.US, "%02X", c and 0xFF) + } + + fun buildGga( + lat: Double, + lon: Double, + altMeters: Double, + utcSecOfDay: Double, + satellites: Int = 10, + ): String { + val hh = (utcSecOfDay / 3600).toInt() + val mm = ((utcSecOfDay % 3600) / 60).toInt() + val ss = utcSecOfDay % 60 + val time = String.format(Locale.US, "%02d%02d%05.2f", hh, mm, ss) + val (latStr, ns) = degToDm(lat, isLat = true) + val (lonStr, ew) = degToDm(lon, isLat = false) + val body = String.format( + Locale.US, + "GPGGA,%s,%s,%s,%s,%s,1,%02d,1.0,%.1f,M,0.0,M,,", + time, latStr, ns, lonStr, ew, satellites, altMeters, + ) + return "\$$body*${checksum(body)}\r\n" + } + + private fun degToDm(deg: Double, isLat: Boolean): Pair { + val hemi = if (isLat) (if (deg >= 0) 'N' else 'S') else (if (deg >= 0) 'E' else 'W') + val a = abs(deg) + val d = floor(a).toInt() + val minutes = (a - d) * 60.0 + return if (isLat) { + String.format(Locale.US, "%02d%08.5f", d, minutes) to hemi + } else { + String.format(Locale.US, "%03d%08.5f", d, minutes) to hemi + } + } + + fun parseGgaQuality(sentence: String): Int? = parseGga(sentence)?.quality + + data class GgaFix( + val quality: Int, + val lat: Double, + val lon: Double, + val satellites: Int, + val hdop: Double, + val altMeters: Double, + ) + + fun parseGga(sentence: String): GgaFix? { + val s = sentence.trim() + if (!s.startsWith("\$") || s.length < 6) return null + if (!s.substring(1, 6).endsWith("GGA")) return null + val f = s.substringBefore('*').split(',') + val q = f.getOrNull(6)?.toIntOrNull() ?: return null + return GgaFix( + quality = q, + lat = dmToDeg(f.getOrNull(2), f.getOrNull(3)), + lon = dmToDeg(f.getOrNull(4), f.getOrNull(5)), + satellites = f.getOrNull(7)?.toIntOrNull() ?: 0, + hdop = f.getOrNull(8)?.toDoubleOrNull() ?: Double.NaN, + altMeters = f.getOrNull(9)?.toDoubleOrNull() ?: Double.NaN, + ) + } + + private fun dmToDeg(dm: String?, hemi: String?): Double { + val v = dm?.toDoubleOrNull() ?: return Double.NaN + val deg = floor(v / 100.0) + var dec = deg + (v - deg * 100.0) / 60.0 + if (hemi == "S" || hemi == "W") dec = -dec + return dec + } + + fun fixQualityLabel(q: Int): String = when (q) { + 0 -> "no fix" + 1 -> "GPS (single)" + 2 -> "DGPS" + 4 -> "RTK FIXED" + 5 -> "RTK float" + else -> "q=$q" + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ntrip/NtripClient.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ntrip/NtripClient.kt new file mode 100644 index 0000000..7852f86 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ntrip/NtripClient.kt @@ -0,0 +1,207 @@ +package com.onlyti.rtkrouter.desktop.ntrip + +import com.onlyti.rtkrouter.desktop.config.CasterProfile +import com.onlyti.rtkrouter.desktop.config.NtripVersion +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.util.Base64 +import java.util.concurrent.atomic.AtomicBoolean + +fun rtcmFormatRank(format: String): Int { + val f = format.uppercase().replace(" ", "") + return when { + "RTCM3.3" in f -> 5 + "RTCM3.2" in f -> 4 + "RTCM3" in f && "3.1" !in f && "3.0" !in f -> 3 + "RTCM3.1" in f -> 2 + "RTCM3.0" in f -> 1 + else -> -1 + } +} + +data class StrEntry( + val mount: String, + val identifier: String, + val format: String, + val lat: Double, + val lon: Double, + val requiresGga: Boolean, +) + +class NtripClient( + private val profile: CasterProfile, + private val mount: String, + private val onRtcm: (ByteArray, Int) -> Unit, + private val onState: (connected: Boolean, detail: String) -> Unit, + private val ggaProvider: () -> String?, +) { + private val running = AtomicBoolean(false) + @Volatile private var worker: Thread? = null + @Volatile private var ggaThread: Thread? = null + @Volatile private var out: OutputStream? = null + + fun start() { + if (running.getAndSet(true)) return + worker = Thread({ run() }, "ntrip-$mount").also { it.start() } + } + + fun stop() { + running.set(false) + worker?.interrupt() + ggaThread?.interrupt() + worker = null + ggaThread = null + } + + private fun run() { + var sock: Socket? = null + try { + onState(false, "connecting ${profile.host}:${profile.port}/$mount") + sock = Socket().apply { connect(InetSocketAddress(profile.host, profile.port), CONNECT_TIMEOUT_MS) } + sock.soTimeout = READ_TIMEOUT_MS + val os = sock.getOutputStream() + val ins = sock.getInputStream() + out = os + + os.write(requestHeader(mount).toByteArray(Charsets.US_ASCII)) + os.flush() + + if (!readHandshakeOk(ins)) { + onState(false, "handshake rejected") + return + } + onState(true, "streaming /$mount") + startGgaUploader(os) + + val buf = ByteArray(4096) + while (running.get()) { + val n = try { + ins.read(buf) + } catch (_: java.net.SocketTimeoutException) { + 0 + } + if (n < 0) { + onState(false, "stream closed by caster") + break + } + if (n > 0) onRtcm(buf, n) + } + } catch (t: Throwable) { + onState(false, "error: ${t.message}") + } finally { + try { sock?.close() } catch (_: Throwable) {} + out = null + } + } + + private fun startGgaUploader(os: OutputStream) { + ggaThread = Thread({ + while (running.get()) { + try { + val gga = ggaProvider() + if (gga != null) { + synchronized(os) { + os.write(gga.toByteArray(Charsets.US_ASCII)) + os.flush() + } + } + Thread.sleep(GGA_INTERVAL_MS) + } catch (_: InterruptedException) { + break + } catch (_: Throwable) { + break + } + } + }, "ntrip-gga-$mount").also { it.start() } + } + + private fun requestHeader(target: String): String { + val auth = Base64.getEncoder().encodeToString( + "${profile.user}:${profile.pass}".toByteArray(Charsets.US_ASCII), + ) + val sb = StringBuilder() + sb.append("GET /$target HTTP/1.1\r\n") + sb.append("Host: ${profile.host}:${profile.port}\r\n") + if (profile.ntripVersion == NtripVersion.V2) sb.append("Ntrip-Version: Ntrip/2.0\r\n") + sb.append("User-Agent: NTRIP rtk-router-desktop/0.1\r\n") + sb.append("Authorization: Basic $auth\r\n") + sb.append("\r\n") + return sb.toString() + } + + private fun readHandshakeOk(ins: InputStream): Boolean { + val first = readLine(ins) ?: return false + val ok = first.contains("200") || first.contains("ICY 200 OK", ignoreCase = true) + if (first.startsWith("HTTP", ignoreCase = true)) { + while (true) { + val l = readLine(ins) ?: break + if (l.isEmpty()) break + } + } + return ok + } + + private fun readLine(ins: InputStream): String? { + val sb = StringBuilder() + while (true) { + val b = ins.read() + if (b < 0) return if (sb.isEmpty()) null else sb.toString() + if (b == '\n'.code) return sb.toString().trimEnd('\r') + sb.append(b.toChar()) + } + } + + companion object { + private const val CONNECT_TIMEOUT_MS = 8000 + private const val READ_TIMEOUT_MS = 5000 + private const val GGA_INTERVAL_MS = 1000L + + fun fetchSourcetable(profile: CasterProfile): Result> = runCatching { + Socket().use { sock -> + sock.connect(InetSocketAddress(profile.host, profile.port), CONNECT_TIMEOUT_MS) + sock.soTimeout = READ_TIMEOUT_MS + val auth = Base64.getEncoder().encodeToString( + "${profile.user}:${profile.pass}".toByteArray(Charsets.US_ASCII), + ) + val req = buildString { + append("GET / HTTP/1.1\r\n") + append("Host: ${profile.host}:${profile.port}\r\n") + if (profile.ntripVersion == NtripVersion.V2) append("Ntrip-Version: Ntrip/2.0\r\n") + append("User-Agent: NTRIP rtk-router-desktop/0.1\r\n") + append("Authorization: Basic $auth\r\n") + append("Connection: close\r\n\r\n") + } + sock.getOutputStream().write(req.toByteArray(Charsets.US_ASCII)) + sock.getOutputStream().flush() + + val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.US_ASCII)) + val entries = ArrayList() + var line: String? + var firstLine = true + while (reader.readLine().also { line = it } != null) { + val l = line!! + if (firstLine) firstLine = false + if (l.startsWith("ENDSOURCETABLE")) break + if (!l.startsWith("STR;")) continue + val f = l.split(';') + if (f.size < 12) continue + entries.add( + StrEntry( + mount = f[1], + identifier = f.getOrElse(2) { "" }, + format = f.getOrElse(3) { "" }, + lat = f.getOrElse(9) { "" }.toDoubleOrNull() ?: Double.NaN, + lon = f.getOrElse(10) { "" }.toDoubleOrNull() ?: Double.NaN, + requiresGga = f.getOrElse(11) { "0" }.trim() == "1", + ), + ) + } + entries + } + } + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/platform/Platform.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/platform/Platform.kt new file mode 100644 index 0000000..eb1ce37 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/platform/Platform.kt @@ -0,0 +1,19 @@ +package com.onlyti.rtkrouter.desktop.platform + +object Platform { + private val osName: String = System.getProperty("os.name").lowercase() + + val isLinux: Boolean get() = osName.contains("linux") + val isWindows: Boolean get() = osName.contains("win") + + val serialPortHint: String + get() = if (isWindows) "COM3 (USB-UART or u-blox CDC)" else "/dev/ttyACM0 or /dev/ttyUSB0" + + val serialPermissionHint: String? + get() = if (isLinux) { + "권한 없으면 START 시 pkexec/sudo로 /dev/tty* 접근 권한을 부여할 수 있습니다.\n" + + "영구 해결: sudo usermod -aG dialout \$USER 후 재로그인" + } else { + null + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/prefs/DesktopPrefs.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/prefs/DesktopPrefs.kt new file mode 100644 index 0000000..c0201bd --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/prefs/DesktopPrefs.kt @@ -0,0 +1,33 @@ +package com.onlyti.rtkrouter.desktop.prefs + +import com.onlyti.rtkrouter.desktop.config.DesktopSettings +import kotlinx.serialization.json.Json +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +class DesktopPrefs { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val path: Path = Path.of( + System.getProperty("user.home"), + ".config", + "rtk-router", + "settings.json", + ) + + fun load(): DesktopSettings { + if (!path.exists()) return DesktopSettings() + return try { + json.decodeFromString(DesktopSettings.serializer(), path.readText()) + } catch (_: Throwable) { + DesktopSettings() + } + } + + fun save(settings: DesktopSettings) { + Files.createDirectories(path.parent) + path.writeText(json.encodeToString(DesktopSettings.serializer(), settings)) + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialLink.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialLink.kt new file mode 100644 index 0000000..1d70197 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialLink.kt @@ -0,0 +1,95 @@ +package com.onlyti.rtkrouter.desktop.serial + +import com.fazecast.jSerialComm.SerialPort +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * Desktop serial bridge via jSerialComm (Linux /dev/tty*, Windows COM*). + * Writes RTCM to the receiver, reads NMEA in a background thread. + */ +class SerialLink( + private val devicePath: String, + private val baud: Int, + private val onConnected: (deviceName: String) -> Unit, + private val onDisconnected: (reason: String) -> Unit, + private val onData: (ByteArray) -> Unit, +) { + private var port: SerialPort? = null + private val running = AtomicBoolean(false) + private var readerThread: Thread? = null + val rxBytes = AtomicLong(0) + val txBytes = AtomicLong(0) + + fun connect() { + if (devicePath.isBlank()) { + onDisconnected("no serial device selected") + return + } + if (SerialPermission.needsPermissionFix() && !SerialPermission.canReadWrite(devicePath)) { + onDisconnected("permission denied on $devicePath — use 'Fix permissions'") + return + } + try { + val p = SerialPort.getCommPort(devicePath) + p.setComPortParameters(baud, 8, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY) + p.setComPortTimeouts(SerialPort.TIMEOUT_WRITE_BLOCKING, 2000, 0) + if (!p.openPort()) { + val err = if (SerialPermission.needsPermissionFix() && !SerialPermission.canReadWrite(devicePath)) { + "permission denied on $devicePath" + } else { + "failed to open $devicePath (in use or driver missing)" + } + onDisconnected(err) + return + } + port = p + running.set(true) + readerThread = Thread({ readLoop(p) }, "serial-rx-$devicePath").also { it.isDaemon = true; it.start() } + onConnected(devicePath) + } catch (t: Throwable) { + onDisconnected("open failed: ${t.message}") + } + } + + private fun readLoop(p: SerialPort) { + val buf = ByteArray(1024) + while (running.get()) { + try { + val n = p.readBytes(buf, buf.size) + if (n > 0) { + rxBytes.addAndGet(n.toLong()) + onData(buf.copyOf(n)) + } else if (n < 0) { + break + } else { + Thread.sleep(10) + } + } catch (_: InterruptedException) { + break + } catch (e: Exception) { + onDisconnected("serial read error: ${e.message}") + break + } + } + } + + fun write(data: ByteArray, len: Int) { + val p = port ?: return + try { + val payload = if (len == data.size) data else data.copyOf(len) + val n = p.writeBytes(payload, payload.size) + if (n > 0) txBytes.addAndGet(n.toLong()) + } catch (_: Throwable) { + // transient; watchdog handles persistent failures + } + } + + fun close() { + running.set(false) + readerThread?.interrupt() + readerThread = null + try { port?.closePort() } catch (_: Throwable) {} + port = null + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialPermission.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialPermission.kt new file mode 100644 index 0000000..c187816 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialPermission.kt @@ -0,0 +1,121 @@ +package com.onlyti.rtkrouter.desktop.serial + +import com.onlyti.rtkrouter.desktop.platform.Platform +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission +import java.util.concurrent.TimeUnit + +/** + * Serial device permission helpers. Linux needs dialout/pkexec; Windows COM ports need no extra step. + */ +object SerialPermission { + class PermissionException(val devicePath: String, message: String) : Exception(message) + + fun needsPermissionFix(): Boolean = Platform.isLinux + + fun isPermissionError(t: Throwable): Boolean { + val msg = (t.message ?: "").lowercase() + return msg.contains("permission denied") || + msg.contains("access denied") || + msg.contains("error 13") || + t is PermissionException + } + + fun canReadWrite(devicePath: String): Boolean { + if (devicePath.isBlank()) return false + if (Platform.isWindows) return true + return linuxCanReadWrite(devicePath) + } + + private fun linuxCanReadWrite(devicePath: String): Boolean { + val f = File(devicePath) + if (!f.exists()) return false + return try { + Files.getPosixFilePermissions(f.toPath()).let { perms -> + val user = System.getProperty("user.name") + val owner = Files.getOwner(f.toPath()).toString() + val ownerRw = owner == user && + PosixFilePermission.OWNER_READ in perms && + PosixFilePermission.OWNER_WRITE in perms + val worldRw = PosixFilePermission.OTHERS_READ in perms && + PosixFilePermission.OTHERS_WRITE in perms + val groupRw = PosixFilePermission.GROUP_READ in perms && + PosixFilePermission.GROUP_WRITE in perms + ownerRw || worldRw || groupRw + } + } catch (_: UnsupportedOperationException) { + f.canRead() && f.canWrite() + } catch (_: Throwable) { + false + } + } + + /** pkexec shows the system polkit password dialog (Linux only). */ + fun fixWithPkexec(devicePath: String): Result { + if (!Platform.isLinux) return Result.failure(Exception("not supported on this OS")) + return runCommand(listOf("pkexec", "chmod", "a+rw", devicePath), timeoutSec = 120) + } + + /** In-app sudo password prompt fallback when pkexec is unavailable (Linux only). */ + fun fixWithSudo(devicePath: String, password: CharArray): Result { + if (!Platform.isLinux) return Result.failure(Exception("not supported on this OS")) + val proc = ProcessBuilder("sudo", "-S", "chmod", "a+rw", devicePath) + .redirectErrorStream(true) + .start() + return try { + proc.outputStream.bufferedWriter().use { w -> + w.write(password.concatToString()) + w.newLine() + w.flush() + } + password.fill('\u0000') + val finished = proc.waitFor(60, TimeUnit.SECONDS) + val out = proc.inputStream.bufferedReader().readText().trim() + if (!finished) { + proc.destroyForcibly() + Result.failure(Exception("sudo timed out")) + } else if (proc.exitValue() == 0) { + Result.success(out.ifBlank { "permissions updated" }) + } else { + Result.failure(Exception(out.ifBlank { "sudo failed (exit ${proc.exitValue()})" })) + } + } catch (t: Throwable) { + password.fill('\u0000') + Result.failure(t) + } + } + + /** Permanent fix: add current user to dialout (Linux only, requires re-login). */ + fun addUserToDialoutPkexec(): Result { + if (!Platform.isLinux) return Result.failure(Exception("not supported on this OS")) + val user = System.getProperty("user.name") + return runCommand( + listOf("pkexec", "usermod", "-aG", "dialout", user), + timeoutSec = 120, + ).map { "added $user to dialout — log out and back in" } + } + + fun hasPkexec(): Boolean { + if (!Platform.isLinux) return false + return runCommand(listOf("which", "pkexec"), timeoutSec = 5).isSuccess + } + + private fun runCommand(cmd: List, timeoutSec: Long): Result { + return try { + val proc = ProcessBuilder(cmd).redirectErrorStream(true).start() + val finished = proc.waitFor(timeoutSec, TimeUnit.SECONDS) + val out = proc.inputStream.bufferedReader().readText().trim() + if (!finished) { + proc.destroyForcibly() + Result.failure(Exception("command timed out: ${cmd.joinToString(" ")}")) + } else if (proc.exitValue() == 0) { + Result.success(out) + } else { + Result.failure(Exception(out.ifBlank { "exit ${proc.exitValue()}: ${cmd.joinToString(" ")}" })) + } + } catch (t: Throwable) { + Result.failure(t) + } + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialPortHelper.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialPortHelper.kt new file mode 100644 index 0000000..e08819a --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/serial/SerialPortHelper.kt @@ -0,0 +1,37 @@ +package com.onlyti.rtkrouter.desktop.serial + +import com.fazecast.jSerialComm.SerialPort +import com.onlyti.rtkrouter.desktop.platform.Platform + +data class SerialPortInfo( + val systemPortName: String, + val descriptiveName: String, + val portDescription: String, +) + +object SerialPortHelper { + fun listPorts(): List = + SerialPort.getCommPorts() + .map { p -> + SerialPortInfo( + systemPortName = p.systemPortPath, + descriptiveName = p.descriptivePortName ?: p.systemPortPath, + portDescription = p.portDescription ?: "", + ) + } + .sortedBy { it.systemPortName } + + /** Likely GNSS receivers: CDC-ACM (F9P) or common USB-UART chips. */ + fun guessGnssPorts(): List = + listPorts().filter { info -> + val d = "${info.descriptiveName} ${info.portDescription}".lowercase() + val name = info.systemPortName.lowercase() + val osMatch = when { + Platform.isWindows -> name.startsWith("com") + else -> name.startsWith("/dev/ttyacm") || name.startsWith("/dev/ttyusb") + } + osMatch || + "ublox" in d || "u-blox" in d || "novatel" in d || + "ftdi" in d || "cp210" in d || "ch340" in d || "cdc" in d + } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/service/RtkState.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/service/RtkState.kt new file mode 100644 index 0000000..f2a8a2c --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/service/RtkState.kt @@ -0,0 +1,54 @@ +package com.onlyti.rtkrouter.desktop.service + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +data class ErrorInfo( + val failureMode: String = "", + val level: String = "", + val reason: String = "", +) + +data class RtkStatus( + val running: Boolean = false, + val ntripConnected: Boolean = false, + val serialConnected: Boolean = false, + val deviceName: String = "", + val serialPortCount: Int = 0, + val serialPortIndex: Int = 0, + val activeProfileName: String = "", + val activeMount: String = "", + val activeMode: String = "", + val ggaActive: Boolean = false, + val failoverLevel: String = "L0", + val streamCount: Int = 0, + val healthyCount: Int = 0, + val streamLines: List = emptyList(), + val rtcmBytesPerSec: Long = 0, + val sessionRxBytes: Long = 0, + val sessionTxBytes: Long = 0, + val serialTxBytes: Long = 0, + val serialRxBytes: Long = 0, + val lastFixQuality: Int = -1, + val lastError: ErrorInfo = ErrorInfo(), + val uptimeSec: Long = 0, + val detail: String = "stopped", + val rxLat: Double = Double.NaN, + val rxLon: Double = Double.NaN, + val rxSats: Int = 0, + val rxHdop: Double = Double.NaN, + val rxAltM: Double = Double.NaN, + val lastNmea: String = "", + val trajectory: List = emptyList(), +) + +data class GeoPt(val lat: Double, val lon: Double) + +object RtkState { + private val _status = MutableStateFlow(RtkStatus()) + val status: StateFlow = _status + + fun update(s: RtkStatus) { _status.value = s } + fun current(): RtkStatus = _status.value + fun reset() { _status.value = RtkStatus() } +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ui/DesktopApp.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ui/DesktopApp.kt new file mode 100644 index 0000000..9919971 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ui/DesktopApp.kt @@ -0,0 +1,461 @@ +package com.onlyti.rtkrouter.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.onlyti.rtkrouter.desktop.config.CasterPresets +import com.onlyti.rtkrouter.desktop.config.DesktopSettings +import com.onlyti.rtkrouter.desktop.config.EndpointMode +import com.onlyti.rtkrouter.desktop.config.RtkConfig +import com.onlyti.rtkrouter.desktop.gnss.Nmea +import com.onlyti.rtkrouter.desktop.platform.Platform +import com.onlyti.rtkrouter.desktop.serial.SerialPermission +import com.onlyti.rtkrouter.desktop.serial.SerialPortInfo +import com.onlyti.rtkrouter.desktop.service.RtkStatus + +@Composable +fun DesktopApp() { + val vm = remember { DesktopViewModel() } + DisposableEffect(Unit) { + vm.refreshPorts() + onDispose { vm.shutdown() } + } + + val settings by vm.settings.collectAsState() + val status by vm.status.collectAsState() + + Surface(modifier = Modifier.fillMaxSize()) { + Column { + DesktopScreen(vm, settings, status) + PermissionDialog(vm) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun DesktopScreen(vm: DesktopViewModel, settings: DesktopSettings, status: RtkStatus) { + val config = settings.config + val ports by vm.ports.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("RTK Router", style = MaterialTheme.typography.headlineSmall) + Text( + "NTRIP RTCM → USB/serial → GNSS receiver (u-blox F9P, USB-UART adapter)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + + SerialPortSection(vm, settings.serialDevicePath, ports, status.running) + + ProfilesSection(vm, config) + + Text("Baud (UART adapter only; F9P CDC ignores baud)", style = MaterialTheme.typography.labelLarge) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (b in listOf(4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600)) { + FilterChip( + selected = config.baud == b, + onClick = { vm.setBaud(b) }, + enabled = !status.running, + label = { Text("$b") }, + ) + } + } + + Text("Endpoint mode", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (m in EndpointMode.entries) { + FilterChip( + selected = config.endpointMode == m, + onClick = { vm.setEndpointMode(m) }, + enabled = !status.running, + label = { Text(m.name) }, + ) + } + } + + Text("Standby bases (fixed only)", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (c in 1..3) { + FilterChip( + selected = config.hotStandbyCount == c, + onClick = { vm.setHotStandbyCount(c) }, + enabled = !status.running, + label = { Text("$c") }, + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = vm::start, enabled = !status.running) { Text("START") } + Button(onClick = vm::stop, enabled = status.running) { Text("STOP") } + } + + StatusCard(status, showDataUsage = config.showDataUsage, onResetUsage = vm::resetUsage) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SerialPortSection( + vm: DesktopViewModel, + selectedPath: String, + ports: List, + running: Boolean, +) { + var expanded by remember { mutableStateOf(false) } + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Serial port", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = vm::refreshPorts, enabled = !running) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh ports") + } + } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { if (!running) expanded = it }) { + OutlinedTextField( + value = selectedPath.ifBlank { "(select port)" }, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + label = { Text(Platform.serialPortHint) }, + enabled = !running, + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (ports.isEmpty()) { + DropdownMenuItem( + text = { Text("no ports found — plug in USB device") }, + onClick = { expanded = false }, + ) + } else { + for (p in ports) { + val label = buildString { + append(p.systemPortName) + if (p.descriptiveName.isNotBlank() && p.descriptiveName != p.systemPortName) { + append(" · ") + append(p.descriptiveName) + } + } + DropdownMenuItem( + text = { Text(label) }, + onClick = { + vm.setSerialDevicePath(p.systemPortName) + expanded = false + }, + ) + } + } + } + } + Platform.serialPermissionHint?.let { hint -> + Text( + hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun ProfilesSection(vm: DesktopViewModel, config: RtkConfig) { + Text("Casters (multi-network hot-standby)", style = MaterialTheme.typography.titleMedium) + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = "+ add preset caster", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + label = { Text("presets") }, + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + for (p in CasterPresets.ALL) { + DropdownMenuItem( + text = { Text("[${p.country}] ${p.name} · ${p.host}:${p.port}") }, + onClick = { vm.addPreset(p); expanded = false }, + ) + } + DropdownMenuItem(text = { Text("+ empty caster") }, onClick = { vm.addProfile(); expanded = false }) + } + } + + config.profiles.forEachIndexed { i, p -> + val isActive = i == config.activeIndex + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = isActive, + onClick = { vm.setActiveIndex(if (isActive) -1 else i) }, + label = { Text(if (isActive) "▼" else "▶") }, + ) + Text( + " ${p.name} · ${p.host.ifBlank { "(no host)" }}:${p.port}", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + Switch(checked = p.enabled, onCheckedChange = { vm.setEnabled(i, it) }) + if (config.profiles.size > 1) { + TextButton(onClick = { vm.removeProfile(i) }, contentPadding = PaddingValues(4.dp)) { + Text("✕") + } + } + } + if (isActive) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(bottom = 6.dp)) { + OutlinedTextField( + value = p.host, + onValueChange = { vm.setHost(i, it) }, + label = { Text("host") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = p.port.toString(), + onValueChange = { vm.setPort(i, it) }, + label = { Text("port") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = p.preferredMount, + onValueChange = { vm.setMount(i, it) }, + label = { Text("mount (blank=AUTO)") }, + singleLine = true, + modifier = Modifier.weight(2f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = p.user, + onValueChange = { vm.setUser(i, it) }, + label = { Text("user") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = p.pass, + onValueChange = { vm.setPass(i, it) }, + label = { Text("pass") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.weight(1f), + ) + } + ScanSection(vm) + } + } + } + } + } +} + +@Composable +private fun ScanSection(vm: DesktopViewModel) { + val scan by vm.scan.collectAsState() + OutlinedButton(onClick = vm::scanEndpoints) { Text("SCAN endpoints") } + when (val s = scan) { + is ScanState.Idle -> {} + is ScanState.Scanning -> Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) + Text("scanning sourcetable...") + } + is ScanState.Error -> Text( + "scan failed: ${s.msg}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + is ScanState.Done -> Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(8.dp)) { + Text("${s.entries.size} mountpoints (click to select)", style = MaterialTheme.typography.labelMedium) + Column(modifier = Modifier.heightIn(max = 260.dp).verticalScroll(rememberScrollState())) { + for (e in s.entries) { + val tag = if (e.requiresGga) "VRS" else "FIX" + Text( + "${e.mount} · ${e.format} · $tag", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .clickable { vm.pickMount(e.mount) } + .padding(vertical = 6.dp), + ) + } + } + } + } + } +} + +@Composable +private fun StatusCard(s: RtkStatus, showDataUsage: Boolean, onResetUsage: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Status", style = MaterialTheme.typography.titleMedium) + line("NTRIP", if (s.ntripConnected) "connected" else "down") + line("Serial", if (s.serialConnected) s.deviceName else "down") + line("Provider/Mount", "${s.activeProfileName} / ${s.activeMount.ifBlank { "-" }}") + line("Mode / GGA", "${s.activeMode.ifBlank { "-" }} / ${if (s.ggaActive) "on" else "off"}") + line("Failover", s.failoverLevel) + if (s.streamCount > 0) { + line("Bases", "${s.healthyCount}/${s.streamCount} healthy") + for (l in s.streamLines) { + Text(l, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) + } + } + val fix = if (s.lastFixQuality >= 0) Nmea.fixQualityLabel(s.lastFixQuality) else "-" + line("Fix", fix) + if (s.lastFixQuality >= 0) { + line("Rx pos", if (s.rxLat.isNaN()) "-" else String.format("%.7f, %.7f", s.rxLat, s.rxLon)) + line("Rx sats / HDOP", "${s.rxSats} / ${if (s.rxHdop.isNaN()) "-" else String.format("%.1f", s.rxHdop)}") + line("Rx alt", if (s.rxAltM.isNaN()) "-" else String.format("%.1f m", s.rxAltM)) + } + if (showDataUsage) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Data usage", style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onResetUsage, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { + Text("reset", style = MaterialTheme.typography.bodySmall) + } + } + line("RTCM rate", "${s.rtcmBytesPerSec} B/s") + line("Rx (caster)", human(s.sessionRxBytes)) + line("Tx GGA (caster)", human(s.sessionTxBytes)) + line("Serial Tx/Rx", "${human(s.serialTxBytes)} / ${human(s.serialRxBytes)}") + } + line("Uptime", "${s.uptimeSec}s") + if (s.lastError.reason.isNotBlank()) { + line("Error", "[${s.lastError.failureMode}/${s.lastError.level}] ${s.lastError.reason}") + } + if (s.detail.isNotBlank()) line("Detail", s.detail) + if (s.lastNmea.isNotBlank()) { + Text(s.lastNmea, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) + } + } + } +} + +@Composable +private fun PermissionDialog(vm: DesktopViewModel) { + val dlg by vm.permissionDialog.collectAsState() + if (!dlg.visible) return + + AlertDialog( + onDismissRequest = vm::dismissPermissionDialog, + title = { Text("Serial port permissions") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(dlg.message) + if (dlg.busy) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) + Text("권한 요청 중...") + } + } + if (dlg.resultMessage.isNotBlank()) { + Text(dlg.resultMessage, color = MaterialTheme.colorScheme.primary) + } + if (!dlg.usePkexec && dlg.devicePath.isNotBlank()) { + OutlinedTextField( + value = dlg.sudoPassword, + onValueChange = vm::setSudoPassword, + label = { Text("sudo password") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { + if (SerialPermission.needsPermissionFix() && dlg.devicePath.isNotBlank()) { + if (dlg.usePkexec) { + TextButton(onClick = vm::fixPermissionsWithPkexec, enabled = !dlg.busy) { + Text("pkexec로 권한 부여") + } + } else { + TextButton(onClick = vm::fixPermissionsWithSudo, enabled = !dlg.busy && dlg.sudoPassword.isNotEmpty()) { + Text("sudo로 권한 부여") + } + } + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (SerialPermission.needsPermissionFix() && dlg.devicePath.isNotBlank()) { + TextButton(onClick = vm::addUserToDialout, enabled = !dlg.busy) { + Text("dialout 영구 추가") + } + } + TextButton(onClick = vm::dismissPermissionDialog) { Text("닫기") } + } + }, + ) +} + +@Composable +private fun line(k: String, v: String) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(k, style = MaterialTheme.typography.bodyMedium) + Text(v, style = MaterialTheme.typography.bodyMedium) + } +} + +private fun human(bytes: Long): String = when { + bytes >= 1_000_000 -> String.format("%.1f MB", bytes / 1_000_000.0) + bytes >= 1_000 -> String.format("%.1f kB", bytes / 1_000.0) + else -> "$bytes B" +} diff --git a/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ui/DesktopViewModel.kt b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ui/DesktopViewModel.kt new file mode 100644 index 0000000..ec3f254 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/onlyti/rtkrouter/desktop/ui/DesktopViewModel.kt @@ -0,0 +1,221 @@ +package com.onlyti.rtkrouter.desktop.ui + +import com.onlyti.rtkrouter.desktop.bridge.RtkBridge +import com.onlyti.rtkrouter.desktop.config.CasterPreset +import com.onlyti.rtkrouter.desktop.config.CasterProfile +import com.onlyti.rtkrouter.desktop.config.DesktopSettings +import com.onlyti.rtkrouter.desktop.config.EndpointMode +import com.onlyti.rtkrouter.desktop.config.RtkConfig +import com.onlyti.rtkrouter.desktop.ntrip.NtripClient +import com.onlyti.rtkrouter.desktop.ntrip.StrEntry +import com.onlyti.rtkrouter.desktop.ntrip.rtcmFormatRank +import com.onlyti.rtkrouter.desktop.prefs.DesktopPrefs +import com.onlyti.rtkrouter.desktop.serial.SerialPermission +import com.onlyti.rtkrouter.desktop.serial.SerialPortHelper +import com.onlyti.rtkrouter.desktop.serial.SerialPortInfo +import com.onlyti.rtkrouter.desktop.service.RtkState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed interface ScanState { + data object Idle : ScanState + data object Scanning : ScanState + data class Done(val entries: List) : ScanState + data class Error(val msg: String) : ScanState +} + +data class PermissionDialogState( + val visible: Boolean = false, + val devicePath: String = "", + val message: String = "", + val usePkexec: Boolean = true, + val sudoPassword: String = "", + val busy: Boolean = false, + val resultMessage: String = "", +) + +class DesktopViewModel { + private val prefs = DesktopPrefs() + private val bridge = RtkBridge() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _settings = MutableStateFlow(prefs.load()) + val settings: StateFlow = _settings + + val status = RtkState.status + + private val _scan = MutableStateFlow(ScanState.Idle) + val scan: StateFlow = _scan + + private val _ports = MutableStateFlow(SerialPortHelper.listPorts()) + val ports: StateFlow> = _ports + + private val _permissionDialog = MutableStateFlow(PermissionDialogState()) + val permissionDialog: StateFlow = _permissionDialog + + private fun save(settings: DesktopSettings) { + _settings.value = settings + prefs.save(settings) + } + + private fun updateConfig(transform: (RtkConfig) -> RtkConfig) { + save(_settings.value.copy(config = transform(_settings.value.config))) + } + + private fun updateProfileAt(i: Int, transform: (CasterProfile) -> CasterProfile) = updateConfig { cfg -> + val list = cfg.profiles.toMutableList() + if (i in list.indices) list[i] = transform(list[i]) + cfg.copy(profiles = list) + } + + fun refreshPorts() { + _ports.value = SerialPortHelper.listPorts() + val guess = SerialPortHelper.guessGnssPorts() + if (_settings.value.serialDevicePath.isBlank() && guess.isNotEmpty()) { + setSerialDevicePath(guess.first().systemPortName) + } + } + + fun setSerialDevicePath(path: String) { + save(_settings.value.copy(serialDevicePath = path)) + } + + fun setHost(i: Int, v: String) = updateProfileAt(i) { it.copy(host = v.trim()) } + fun setPort(i: Int, v: String) = updateProfileAt(i) { it.copy(port = v.trim().toIntOrNull() ?: it.port) } + fun setUser(i: Int, v: String) = updateProfileAt(i) { it.copy(user = v) } + fun setPass(i: Int, v: String) = updateProfileAt(i) { it.copy(pass = v) } + fun setMount(i: Int, v: String) = updateProfileAt(i) { it.copy(preferredMount = v.trim()) } + fun setEnabled(i: Int, v: Boolean) = updateProfileAt(i) { it.copy(enabled = v) } + fun setActiveIndex(i: Int) = updateConfig { it.copy(activeIndex = i.coerceIn(-1, it.profiles.size - 1)) } + + fun addProfile(host: String = "", port: Int = 2101, name: String = "caster") = updateConfig { cfg -> + val list = cfg.profiles.toMutableList() + list.add(CasterProfile(name = name, host = host, port = port, priority = list.size)) + cfg.copy(profiles = list, activeIndex = list.size - 1) + } + + fun addPreset(preset: CasterPreset) = addProfile(host = preset.host, port = preset.port, name = preset.name) + + fun removeProfile(i: Int) = updateConfig { cfg -> + if (cfg.profiles.size <= 1) return@updateConfig cfg + val list = cfg.profiles.toMutableList().also { it.removeAt(i) } + cfg.copy(profiles = list, activeIndex = cfg.activeIndex.coerceIn(0, list.size - 1)) + } + + fun setBaud(v: Int) = updateConfig { it.copy(baud = v) } + fun setHotStandbyCount(v: Int) = updateConfig { it.copy(hotStandbyCount = v) } + fun setEndpointMode(m: EndpointMode) = updateConfig { it.copy(endpointMode = m) } + + fun scanEndpoints() { + val profile = _settings.value.config.activeProfile + if (profile.host.isBlank()) { + _scan.value = ScanState.Error("caster host empty") + return + } + _scan.value = ScanState.Scanning + scope.launch(Dispatchers.IO) { + _scan.value = NtripClient.fetchSourcetable(profile).fold( + onSuccess = { list -> + val sorted = list.sortedWith( + compareByDescending { rtcmFormatRank(it.format) }.thenBy { it.mount }, + ) + ScanState.Done(sorted) + }, + onFailure = { ScanState.Error(it.message ?: "scan failed") }, + ) + } + } + + fun pickMount(mount: String) { + setMount(_settings.value.config.activeIndex, mount) + setEndpointMode(EndpointMode.MANUAL) + _scan.value = ScanState.Idle + } + + fun start() { + val s = _settings.value + val path = s.serialDevicePath + if (path.isBlank()) { + _permissionDialog.value = PermissionDialogState( + visible = true, + devicePath = "", + message = "시리얼 포트를 선택하세요.", + ) + return + } + if (SerialPermission.needsPermissionFix() && !SerialPermission.canReadWrite(path)) { + _permissionDialog.value = PermissionDialogState( + visible = true, + devicePath = path, + message = "$path 에 접근 권한이 없습니다.\npkexec 또는 sudo로 권한을 부여하세요.", + usePkexec = SerialPermission.hasPkexec(), + ) + return + } + bridge.start(s.config, path) + } + + fun stop() = bridge.stop() + fun resetUsage() = bridge.resetUsage() + + fun dismissPermissionDialog() { + _permissionDialog.value = _permissionDialog.value.copy(visible = false, sudoPassword = "", resultMessage = "") + } + + fun setSudoPassword(v: String) { + _permissionDialog.value = _permissionDialog.value.copy(sudoPassword = v) + } + + fun fixPermissionsWithPkexec() { + val path = _permissionDialog.value.devicePath + if (path.isBlank()) return + _permissionDialog.value = _permissionDialog.value.copy(busy = true, resultMessage = "") + scope.launch(Dispatchers.IO) { + val result = SerialPermission.fixWithPkexec(path) + _permissionDialog.value = _permissionDialog.value.copy( + busy = false, + resultMessage = result.fold( + onSuccess = { "권한 부여 완료. START를 다시 누르세요." }, + onFailure = { "pkexec 실패: ${it.message}" }, + ), + ) + } + } + + fun fixPermissionsWithSudo() { + val path = _permissionDialog.value.devicePath + if (path.isBlank()) return + val pwd = _permissionDialog.value.sudoPassword.toCharArray() + _permissionDialog.value = _permissionDialog.value.copy(busy = true, resultMessage = "", sudoPassword = "") + scope.launch(Dispatchers.IO) { + val result = SerialPermission.fixWithSudo(path, pwd) + _permissionDialog.value = _permissionDialog.value.copy( + busy = false, + resultMessage = result.fold( + onSuccess = { "권한 부여 완료. START를 다시 누르세요." }, + onFailure = { "sudo 실패: ${it.message}" }, + ), + ) + } + } + + fun addUserToDialout() { + _permissionDialog.value = _permissionDialog.value.copy(busy = true, resultMessage = "") + scope.launch(Dispatchers.IO) { + val result = SerialPermission.addUserToDialoutPkexec() + _permissionDialog.value = _permissionDialog.value.copy( + busy = false, + resultMessage = result.fold( + onSuccess = { it }, + onFailure = { "dialout 추가 실패: ${it.message}" }, + ), + ) + } + } + + fun shutdown() = bridge.shutdown() +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0bb4fb8..1abb0de 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,3 +24,4 @@ dependencyResolutionManagement { rootProject.name = "rtk-router" include(":app") +include(":desktopApp")