diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000..2152305 --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -0,0 +1,36 @@ +name: Android CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# No secrets, signing credentials, pool credentials, or live equipment access. +# Unit tests use fake adapters and the in-process simulator only. + +jobs: + test-and-assemble: + name: Unit tests + debug assemble + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for Gradle wrapper + run: chmod +x gradlew + + - name: Run unit tests and assemble debug APK + run: ./gradlew testDebugUnitTest assembleDebug --stacktrace + + # Intentionally do not upload/publish APKs publicly in this sprint. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d4945a7..99b1d1e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,11 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") } +// Keep intermediates outside OneDrive-synced tree to avoid file-lock failures on Windows. +layout.buildDirectory.set( + file("${System.getenv("TEMP") ?: System.getProperty("java.io.tmpdir")}/westcurrent-app-build") +) + android { namespace = "com.pentaircontrol.pool" compileSdk = 35 diff --git a/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt b/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt index 0b4041b..b7dc5b2 100644 --- a/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt +++ b/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt @@ -12,34 +12,50 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.pentaircontrol.pool.ui.PoolApp import com.pentaircontrol.pool.ui.PoolViewModel -import com.pentaircontrol.pool.ui.theme.PentairPoolTheme +import com.pentaircontrol.pool.ui.PoolViewModelFactory +import com.pentaircontrol.pool.ui.theme.WestCurrentTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - PentairPoolTheme(darkTheme = true) { + WestCurrentTheme(darkTheme = true) { Surface(modifier = Modifier.fillMaxSize()) { - val vm: PoolViewModel = viewModel(factory = PoolViewModel.factory()) + val vm: PoolViewModel = viewModel(factory = PoolViewModelFactory(application)) val connection by vm.connection.collectAsStateWithLifecycle() - val status by vm.status.collectAsStateWithLifecycle() + val site by vm.site.collectAsStateWithLifecycle() val gateways by vm.gateways.collectAsStateWithLifecycle() + val receipts by vm.commandReceipts.collectAsStateWithLifecycle() + val statusMessage by vm.statusMessage.collectAsStateWithLifecycle() + val pendingHeat by vm.pendingHeatConfirmation.collectAsStateWithLifecycle() + val canRetry by vm.canRetryConnection.collectAsStateWithLifecycle() PoolApp( connection = connection, - status = status, + site = site, gateways = gateways, + commandReceipts = receipts, + statusMessage = statusMessage, + pendingHeatConfirmation = pendingHeat, onDiscover = vm::discover, onConnect = vm::connect, onManualConnect = vm::connectManual, - onRemoteConnect = vm::connectRemote, - onDemo = vm::connectDemo, + onRemoteConnect = { code, pass -> vm.connectRemote(code, pass) }, + onSimulator = vm::connectSimulator, onDisconnect = vm::disconnect, onToggleCircuit = vm::toggleCircuit, onSetPoint = vm::setHeatSetPoint, onHeatMode = vm::setHeatMode, - onLight = vm::sendLightCommand + onLightProgram = { programId, targetId -> + vm.sendLightProgram(programId, targetId) + }, + onConfirmElevatedHeat = vm::confirmElevatedHeat, + onCancelElevatedHeat = vm::cancelElevatedHeat, + onConfirmTemperatureUnit = vm::confirmTemperatureUnit, + canRetryConnection = canRetry, + onRetryConnection = vm::retryConnection, + onClearStatusMessage = vm::clearStatusMessage ) } } diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicAdapter.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicAdapter.kt new file mode 100644 index 0000000..e82965f --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicAdapter.kt @@ -0,0 +1,236 @@ +package com.pentaircontrol.pool.adapter.screenlogic + +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.Capability +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.domain.DomainCommand +import com.pentaircontrol.pool.domain.DomainCommandKind +import com.pentaircontrol.pool.domain.PoolSystemAdapter +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.protocol.Discovery +import com.pentaircontrol.pool.protocol.RemoteLogin +import com.pentaircontrol.pool.protocol.ScreenLogicClient +import com.pentaircontrol.pool.protocol.ScreenLogicException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Wraps existing ScreenLogic protocol client behind the vendor-neutral adapter boundary. + * All protocol integers and packet codes stay inside this adapter + protocol package. + * + * Accepts only [ScreenLogicLocalTarget] and [ScreenLogicRemoteTarget]. + * Password is passed into [ScreenLogicClient] only and is not retained on this adapter. + * Temperature unit is [TemperatureUnit.UNKNOWN] until the session confirms a unit. + */ +class ScreenLogicAdapter : PoolSystemAdapter { + override val adapterId: String = ScreenLogicIds.ADAPTER_ID + override val displayName: String = "Pentair ScreenLogic" + + private val mutex = Mutex() + private var client: ScreenLogicClient? = null + private var activePath: ConnectionPath = ConnectionPath.UNKNOWN + private var activeLabel: String = "" + private var activeAddress: String = "" + private var activePort: Int = 0 + /** Session-confirmed unit; not inferred from protocol. */ + private var sessionTemperatureUnit: TemperatureUnit = TemperatureUnit.UNKNOWN + + private val _connection = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _connection.asStateFlow() + + private val _snapshot = MutableStateFlow(null) + override val snapshot: StateFlow = _snapshot.asStateFlow() + + override val capabilities: Set = setOf( + Capability.CIRCUIT_TOGGLE, + Capability.HEAT_SETPOINT, + Capability.HEAT_MODE, + Capability.LIGHT_COMMAND, + Capability.LOCAL_DISCOVERY, + Capability.REMOTE_ACCESS + ) + + /** Apply user-confirmed temperature unit for the remainder of this adapter instance. */ + fun confirmTemperatureUnit(unit: TemperatureUnit) { + if (unit == TemperatureUnit.UNKNOWN) return + sessionTemperatureUnit = unit + _snapshot.value = _snapshot.value?.withConfirmedUnit(unit) + } + + override suspend fun discoverGateways(): List { + return Discovery.search().map { ScreenLogicMapper.gatewayInfoToDiscovered(it) } + } + + override suspend fun connect(target: AdapterConnectionTarget) = mutex.withLock { + if (target.adapterId != adapterId) { + _connection.value = ConnectionStatus.ConnectionFailed( + "ScreenLogicAdapter rejected foreign target (adapterId=${target.adapterId})." + ) + throw IllegalArgumentException( + "ScreenLogicAdapter only accepts ScreenLogic connection targets, got ${target::class.simpleName}" + ) + } + disconnectInternal() + // Do not reset session unit here if SiteSession re-applies after connect; + // default UNKNOWN until confirmTemperatureUnit is called. + sessionTemperatureUnit = TemperatureUnit.UNKNOWN + _connection.value = ConnectionStatus.Connecting + try { + when (target) { + is ScreenLogicLocalTarget -> { + val gw = ScreenLogicMapper.discoveredToGatewayInfo(target.gateway, isRemote = false) + // Password is not retained on this adapter after client construction. + val c = ScreenLogicClient(gw, target.password) + c.connect() + client = c + activePath = ConnectionPath.LOCAL_LAN + activeLabel = target.gateway.displayName + activeAddress = target.gateway.address + activePort = target.gateway.port + val snap = pullStatus(c) + _snapshot.value = snap + _connection.value = ConnectionStatus.ConnectedLocal( + gatewayLabel = activeLabel, + address = activeAddress, + port = activePort + ) + } + is ScreenLogicRemoteTarget -> { + val gwInfo = RemoteLogin.resolveGateway(target.systemCodeForConnect()) + val c = ScreenLogicClient(gwInfo, target.password) + try { + c.connect() + } catch (e: ScreenLogicException) { + if (e.message?.contains("Login rejected", ignoreCase = true) == true) { + _connection.value = ConnectionStatus.AuthenticationRequired + throw e + } + throw e + } + client = c + activePath = ConnectionPath.REMOTE_MANUFACTURER + activeLabel = gwInfo.name + activeAddress = gwInfo.address + activePort = gwInfo.port + val snap = pullStatus(c) + _snapshot.value = snap + _connection.value = ConnectionStatus.ConnectedRemote( + systemName = activeLabel, + address = activeAddress, + port = activePort + ) + } + else -> { + throw IllegalArgumentException( + "ScreenLogicAdapter cannot open ${target::class.simpleName}." + ) + } + } + } catch (e: Exception) { + client = null + if (_connection.value !is ConnectionStatus.AuthenticationRequired) { + // Never include target object (may hold password) in the failure message. + _connection.value = ConnectionStatus.ConnectionFailed( + e.message ?: "Failed to connect via ScreenLogic" + ) + } + throw e + } + } + + override suspend fun disconnect() = mutex.withLock { + disconnectInternal() + sessionTemperatureUnit = TemperatureUnit.UNKNOWN + _connection.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot = mutex.withLock { + val c = client ?: throw IllegalStateException("Not connected") + try { + val snap = pullStatus(c) + _snapshot.value = snap + _connection.value = when (activePath) { + ConnectionPath.LOCAL_LAN -> ConnectionStatus.ConnectedLocal( + activeLabel, activeAddress, activePort + ) + ConnectionPath.REMOTE_MANUFACTURER -> ConnectionStatus.ConnectedRemote( + activeLabel, activeAddress, activePort + ) + else -> _connection.value + } + snap + } catch (e: Exception) { + _connection.value = ConnectionStatus.StaleOrDegraded( + lastPath = activePath, + reason = e.message ?: "Refresh failed", + lastSuccessAtMs = _snapshot.value?.asOfMs + ) + throw e + } + } + + override suspend fun sendCommand(command: DomainCommand) { + mutex.withLock { + val c = client ?: throw IllegalStateException("Not connected") + when (command.kind) { + DomainCommandKind.SET_CIRCUIT -> { + val wireId = command.circuitId?.let { ScreenLogicIds.protocolCircuitId(it) } + ?: error("Invalid circuit id for ScreenLogic") + val on = command.circuitOn ?: error("circuitOn required") + c.setCircuitState(wireId, on) + } + DomainCommandKind.SET_HEAT_SETPOINT -> { + val body = command.bodyId?.let { ScreenLogicIds.protocolBodyIndex(it) } + ?: error("Invalid body id for ScreenLogic") + val temp = command.temperature ?: error("temperature required") + c.setHeatSetPoint(body, temp.value) + } + DomainCommandKind.SET_HEAT_MODE -> { + val body = command.bodyId?.let { ScreenLogicIds.protocolBodyIndex(it) } + ?: error("Invalid body id for ScreenLogic") + val mode = command.heatMode ?: error("heatMode required") + c.setHeatMode(body, ScreenLogicHeatMapper.toProtocol(mode)) + } + DomainCommandKind.SEND_LIGHT_PROGRAM -> { + val code = command.lightProgramId?.let { ScreenLogicLightPrograms.protocolCode(it) } + ?: error("Unknown ScreenLogic light program") + c.sendLightCommand(code) + } + } + Unit + } + } + + private suspend fun pullStatus(c: ScreenLogicClient): SiteSnapshot { + try { + c.refreshControllerConfig() + } catch (_: Exception) { + } + var status = c.getPoolStatus() + val version = try { + c.getVersion() + } catch (_: Exception) { + null + } + status = status.copy(version = version) + return ScreenLogicMapper.toDomain( + status, + activePath, + temperatureUnit = sessionTemperatureUnit + ) + } + + private suspend fun disconnectInternal() { + try { + client?.disconnect() + } catch (_: Exception) { + } + client = null + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicConnectionTargets.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicConnectionTargets.kt new file mode 100644 index 0000000..19fc46c --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicConnectionTargets.kt @@ -0,0 +1,65 @@ +package com.pentaircontrol.pool.adapter.screenlogic + +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.DiscoveredGateway + +/** + * ScreenLogic-owned connection targets. Not part of the shared domain sealed hierarchy. + * + * Passwords are held only for the in-process session lifetime — never persisted. + * [toString] is explicitly redacted so logs and exceptions never leak credentials. + * These are ordinary classes (not data classes) so Kotlin does not generate a + * password-including `toString()`. + */ +class ScreenLogicLocalTarget( + val gateway: DiscoveredGateway, + val password: String = "" +) : AdapterConnectionTarget { + override val adapterId: String = ScreenLogicIds.ADAPTER_ID + override val path: ConnectionPath = ConnectionPath.LOCAL_LAN + override val displayLabel: String = gateway.displayName + + override fun toString(): String = + "ScreenLogicLocalTarget(label=${gateway.displayName}, " + + "address=${gateway.address}:${gateway.port}, password=***)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ScreenLogicLocalTarget) return false + return gateway == other.gateway && password == other.password + } + + override fun hashCode(): Int = 31 * gateway.hashCode() + password.hashCode() +} + +class ScreenLogicRemoteTarget( + val systemCode: String, + val password: String = "" +) : AdapterConnectionTarget { + override val adapterId: String = ScreenLogicIds.ADAPTER_ID + override val path: ConnectionPath = ConnectionPath.REMOTE_MANUFACTURER + override val displayLabel: String = "Remote ${maskSystemCode(systemCode)}" + + /** Full system code for protocol use only — not for logs. */ + fun systemCodeForConnect(): String = systemCode.trim() + + override fun toString(): String = + "ScreenLogicRemoteTarget(systemCode=${maskSystemCode(systemCode)}, password=***)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ScreenLogicRemoteTarget) return false + return systemCode == other.systemCode && password == other.password + } + + override fun hashCode(): Int = 31 * systemCode.hashCode() + password.hashCode() + + companion object { + fun maskSystemCode(code: String): String { + val t = code.trim() + if (t.length <= 2) return "***" + return t.take(2) + "***" + } + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicDiscoveryParser.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicDiscoveryParser.kt new file mode 100644 index 0000000..919e18c --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicDiscoveryParser.kt @@ -0,0 +1,21 @@ +package com.pentaircontrol.pool.adapter.screenlogic + +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.protocol.ScreenLogicDiscoveryParser as ProtocolDiscoveryParser + +/** + * Domain-facing wrapper over the pure protocol discovery parser. + */ +object ScreenLogicDiscoveryParser { + fun parse(packetData: ByteArray, length: Int, address: String): DiscoveredGateway? { + val unit = ProtocolDiscoveryParser.parse(packetData, length, address) ?: return null + return DiscoveredGateway( + id = "${unit.address}:${unit.port}", + displayName = unit.name, + address = unit.address, + port = unit.port, + path = ConnectionPath.LOCAL_LAN + ) + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicHeatMapper.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicHeatMapper.kt new file mode 100644 index 0000000..e3e7dae --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicHeatMapper.kt @@ -0,0 +1,25 @@ +package com.pentaircontrol.pool.adapter.screenlogic + +import com.pentaircontrol.pool.domain.HeatMode + +/** + * Owns ScreenLogic heat-mode integer codes. Domain [HeatMode] has no protocol numbers. + */ +object ScreenLogicHeatMapper { + fun toProtocol(mode: HeatMode): Int = when (mode) { + HeatMode.OFF -> 0 + HeatMode.SOLAR -> 1 + HeatMode.SOLAR_PREFERRED -> 2 + HeatMode.HEATER -> 3 + HeatMode.DONT_CHANGE -> 4 + } + + fun fromProtocol(code: Int): HeatMode = when (code) { + 0 -> HeatMode.OFF + 1 -> HeatMode.SOLAR + 2 -> HeatMode.SOLAR_PREFERRED + 3 -> HeatMode.HEATER + 4 -> HeatMode.DONT_CHANGE + else -> HeatMode.OFF + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicIds.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicIds.kt new file mode 100644 index 0000000..3996ecd --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicIds.kt @@ -0,0 +1,42 @@ +package com.pentaircontrol.pool.adapter.screenlogic + +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.CircuitId +import com.pentaircontrol.pool.domain.LightProgramId + +/** + * ScreenLogic-qualified opaque domain IDs. + * Protocol integers stay inside this adapter boundary only. + */ +object ScreenLogicIds { + const val ADAPTER_ID = "pentair.screenlogic" + + fun body(protocolIndex: Int): BodyId = + BodyId("screenlogic:body:$protocolIndex") + + fun circuit(protocolId: Int): CircuitId = + CircuitId("screenlogic:circuit:$protocolId") + + fun lightProgram(key: String): LightProgramId = + LightProgramId("screenlogic:light:$key") + + /** Extract protocol body index for wire encoding only. */ + fun protocolBodyIndex(id: BodyId): Int? { + val prefix = "screenlogic:body:" + if (!id.value.startsWith(prefix)) return null + return id.value.removePrefix(prefix).toIntOrNull() + } + + /** Extract protocol circuit id for wire encoding only. */ + fun protocolCircuitId(id: CircuitId): Int? { + val prefix = "screenlogic:circuit:" + if (!id.value.startsWith(prefix)) return null + return id.value.removePrefix(prefix).toIntOrNull() + } + + fun lightKey(id: LightProgramId): String? { + val prefix = "screenlogic:light:" + if (!id.value.startsWith(prefix)) return null + return id.value.removePrefix(prefix) + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicLightPrograms.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicLightPrograms.kt new file mode 100644 index 0000000..0bf9231 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicLightPrograms.kt @@ -0,0 +1,80 @@ +package com.pentaircontrol.pool.adapter.screenlogic + +import com.pentaircontrol.pool.domain.LightActionCategory +import com.pentaircontrol.pool.domain.LightProgram +import com.pentaircontrol.pool.domain.LightProgramId +import com.pentaircontrol.pool.domain.LightTarget +import com.pentaircontrol.pool.domain.LightTargetId + +/** + * ScreenLogic-declared light programs and their protocol integers. + * Not part of the vendor-neutral domain enum. + * + * ScreenLogic exposes a single global lighting group for color light commands + * (`screenlogic:lights:all`). Future multi-zone systems may use multiple targets. + */ +object ScreenLogicLightPrograms { + + val ALL_LIGHTS_TARGET_ID: LightTargetId = LightTargetId("screenlogic:lights:all") + + data class Entry( + val key: String, + val displayName: String, + val category: LightActionCategory, + val protocolCode: Int + ) + + private val entries: List = listOf( + Entry("all_off", "All Off", LightActionCategory.OFF, 0), + Entry("all_on", "All On", LightActionCategory.ON, 1), + Entry("color_set", "Color Set", LightActionCategory.SET_PROGRAM, 2), + Entry("color_sync", "Color Sync", LightActionCategory.SYNCHRONIZE, 3), + Entry("color_swim", "Color Swim", LightActionCategory.SET_PROGRAM, 4), + Entry("party", "Party", LightActionCategory.SET_PROGRAM, 5), + Entry("romance", "Romance", LightActionCategory.SET_PROGRAM, 6), + Entry("caribbean", "Caribbean", LightActionCategory.SET_PROGRAM, 7), + Entry("american", "American", LightActionCategory.SET_PROGRAM, 8), + Entry("sunset", "Sunset", LightActionCategory.SET_PROGRAM, 9), + Entry("royal", "Royal", LightActionCategory.SET_PROGRAM, 10), + Entry("save", "Save Color", LightActionCategory.OTHER, 11), + Entry("recall", "Recall Color", LightActionCategory.RECALL, 12), + Entry("blue", "Blue", LightActionCategory.SET_PROGRAM, 13), + Entry("green", "Green", LightActionCategory.SET_PROGRAM, 14), + Entry("red", "Red", LightActionCategory.SET_PROGRAM, 15), + Entry("white", "White", LightActionCategory.SET_PROGRAM, 16), + Entry("magenta", "Magenta", LightActionCategory.SET_PROGRAM, 17), + Entry("thumper", "Thumper", LightActionCategory.SET_PROGRAM, 18), + Entry("next_mode", "Next Mode", LightActionCategory.OTHER, 19), + Entry("reset", "Reset", LightActionCategory.OTHER, 20), + Entry("hold", "Hold", LightActionCategory.HOLD, 21) + ) + + fun domainPrograms(): List = entries.map { + LightProgram( + id = ScreenLogicIds.lightProgram(it.key), + displayName = it.displayName, + category = it.category, + adapterId = ScreenLogicIds.ADAPTER_ID, + targetId = ALL_LIGHTS_TARGET_ID + ) + } + + fun domainTargets(): List { + val programs = domainPrograms() + return listOf( + LightTarget( + id = ALL_LIGHTS_TARGET_ID, + displayName = "All Lights", + adapterId = ScreenLogicIds.ADAPTER_ID, + programIds = programs.map { it.id }.toSet() + ) + ) + } + + fun protocolCode(id: LightProgramId): Int? { + val key = ScreenLogicIds.lightKey(id) ?: return null + return entries.find { it.key == key }?.protocolCode + } + + fun allIds() = domainPrograms().map { it.id }.toSet() +} diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicMapper.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicMapper.kt new file mode 100644 index 0000000..8158102 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/screenlogic/ScreenLogicMapper.kt @@ -0,0 +1,129 @@ +package com.pentaircontrol.pool.adapter.screenlogic + +import com.pentaircontrol.pool.data.BodyStatus +import com.pentaircontrol.pool.data.Circuit +import com.pentaircontrol.pool.data.GatewayInfo +import com.pentaircontrol.pool.data.PoolStatus +import com.pentaircontrol.pool.domain.BodyKind +import com.pentaircontrol.pool.domain.BodyState +import com.pentaircontrol.pool.domain.ChemistrySnapshot +import com.pentaircontrol.pool.domain.CircuitState +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.domain.DomainCommandKind +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.SystemCapabilities +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.data.HeatMode as LegacyHeatMode + +/** + * Maps ScreenLogic DTOs into vendor-neutral domain models. + * Protocol integers become opaque domain IDs here. + * + * ## Temperature unit evidence (Phase 1.2) + * + * Inspected protocol surfaces in this codebase and community references: + * - Pool status (`POOLSTATUS`) decodes raw i32 temperature values with **no unit field**. + * - `CTRLCONFIG` refresh is best-effort for circuit names only; this client does not decode + * a temperature-scale flag from the controller config payload. + * - Official ScreenLogic UI allows Fahrenheit/Celsius in General Settings, and community + * APIs note values are "in whatever scale your pool is set to" — but that scale is **not** + * exposed as a reliable field on the IP status path we implement today. + * + * Therefore [temperatureUnit] defaults to [TemperatureUnit.UNKNOWN] unless the caller + * supplies a session-confirmed unit. We do **not** infer °F from locale, IP, or magnitude. + */ +object ScreenLogicMapper { + + fun toDomain( + status: PoolStatus, + path: ConnectionPath, + asOfMs: Long = System.currentTimeMillis(), + temperatureUnit: TemperatureUnit = TemperatureUnit.UNKNOWN + ): SiteSnapshot { + val bodies = buildList { + add(bodyToDomain(status.pool, BodyKind.POOL, protocolIndex = 0, temperatureUnit)) + add(bodyToDomain(status.spa, BodyKind.SPA, protocolIndex = 1, temperatureUnit)) + } + val circuits = status.circuits.map { circuitToDomain(it) } + val lightPrograms = ScreenLogicLightPrograms.domainPrograms() + val lightTargets = ScreenLogicLightPrograms.domainTargets() + val caps = SystemCapabilities( + commandKinds = setOf( + DomainCommandKind.SET_CIRCUIT, + DomainCommandKind.SET_HEAT_SETPOINT, + DomainCommandKind.SET_HEAT_MODE, + DomainCommandKind.SEND_LIGHT_PROGRAM + ), + controllableCircuits = circuits.filter { it.isControllable }.map { it.id }.toSet(), + heatCapableBodies = bodies.filter { it.supportsHeat }.map { it.id }.toSet(), + lightPrograms = lightPrograms.map { it.id }.toSet(), + lightTargets = lightTargets.map { it.id }.toSet() + ) + return SiteSnapshot( + bodies = bodies, + circuits = circuits, + lightPrograms = lightPrograms, + lightTargets = lightTargets, + airTemp = Temperature(status.airTemp, temperatureUnit), + freezeMode = status.freezeMode, + chemistry = ChemistrySnapshot( + pH = status.pH, + orp = status.orp, + saltPpm = status.saltPpm + ), + controllerVersion = status.version, + asOfMs = asOfMs, + source = path, + capabilities = caps, + temperatureUnit = temperatureUnit + ) + } + + fun gatewayInfoToDiscovered(gw: GatewayInfo): DiscoveredGateway = + DiscoveredGateway( + id = "${gw.address}:${gw.port}", + displayName = gw.name, + address = gw.address, + port = gw.port, + path = if (gw.isRemote) ConnectionPath.REMOTE_MANUFACTURER else ConnectionPath.LOCAL_LAN + ) + + fun discoveredToGatewayInfo(gw: DiscoveredGateway, isRemote: Boolean = false): GatewayInfo = + GatewayInfo( + name = gw.displayName, + address = gw.address, + port = gw.port, + isRemote = isRemote + ) + + private fun bodyToDomain( + body: BodyStatus, + kind: BodyKind, + protocolIndex: Int, + unit: TemperatureUnit + ): BodyState = + BodyState( + id = ScreenLogicIds.body(protocolIndex), + kind = kind, + displayName = body.name, + currentTemp = Temperature(body.currentTemp, unit), + heatSetPoint = Temperature(body.heatSetPoint, unit), + coolSetPoint = Temperature(body.coolSetPoint, unit), + heatMode = ScreenLogicHeatMapper.fromProtocol(legacyHeatCode(body.heatMode)), + heatActive = body.heatActive, + supportsHeat = true + ) + + private fun legacyHeatCode(mode: LegacyHeatMode): Int = mode.code + + private fun circuitToDomain(c: Circuit): CircuitState = + CircuitState( + id = ScreenLogicIds.circuit(c.id), + displayName = c.name, + isOn = c.isOn, + isControllable = true, + hasDelay = c.hasDelay + ) +} diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/simulator/SimulatorAdapter.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/simulator/SimulatorAdapter.kt new file mode 100644 index 0000000..6e05c99 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/simulator/SimulatorAdapter.kt @@ -0,0 +1,404 @@ +package com.pentaircontrol.pool.adapter.simulator + +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.BodyKind +import com.pentaircontrol.pool.domain.BodyState +import com.pentaircontrol.pool.domain.Capability +import com.pentaircontrol.pool.domain.ChemistrySnapshot +import com.pentaircontrol.pool.domain.CircuitId +import com.pentaircontrol.pool.domain.CircuitState +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.DomainCommand +import com.pentaircontrol.pool.domain.DomainCommandKind +import com.pentaircontrol.pool.domain.HeatMode +import com.pentaircontrol.pool.domain.LightActionCategory +import com.pentaircontrol.pool.domain.LightProgram +import com.pentaircontrol.pool.domain.LightProgramId +import com.pentaircontrol.pool.domain.LightTarget +import com.pentaircontrol.pool.domain.LightTargetId +import com.pentaircontrol.pool.domain.PoolSystemAdapter +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.SystemCapabilities +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Configurable in-process equipment simulator. Never talks to real hardware. + * Supports Fahrenheit and Celsius profiles via [Config.temperatureUnit]. + */ +class SimulatorAdapter( + private val config: Config = Config() +) : PoolSystemAdapter { + + data class Config( + val profileName: String = "Demo EasyTouch", + val applyDelayMs: Long = 300L, + val failNextCommand: Boolean = false, + val timeoutNextCommand: Boolean = false, + val hangCommandMs: Long = 0L, + val disconnectAfterCommands: Int = 0, + val failNextRefresh: Boolean = false, + val bodyKinds: Set = setOf(BodyKind.POOL, BodyKind.SPA), + val supportCircuits: Boolean = true, + val supportHeat: Boolean = true, + val supportLights: Boolean = true, + /** Explicit unit for this profile — never UNKNOWN for simulator demos. */ + val temperatureUnit: TemperatureUnit = TemperatureUnit.FAHRENHEIT, + /** Optional second independent light target for multi-target tests. */ + val dualLightTargets: Boolean = false + ) + + override val adapterId: String = ADAPTER_ID + override val displayName: String = "WestCurrent Simulator" + + private val mutex = Mutex() + private var connected = false + private var commandsSent = 0 + private var failNext = config.failNextCommand + private var timeoutNext = config.timeoutNextCommand + private var failRefresh = config.failNextRefresh + private var hangMs = config.hangCommandMs + private var connectCount = 0 + + private val _connection = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _connection.asStateFlow() + + private var state = initialSnapshot(config) + private val _snapshot = MutableStateFlow(null) + override val snapshot: StateFlow = _snapshot.asStateFlow() + + override val capabilities: Set + get() = buildSet { + if (config.supportCircuits) add(Capability.CIRCUIT_TOGGLE) + if (config.supportHeat) { + add(Capability.HEAT_SETPOINT) + add(Capability.HEAT_MODE) + } + if (config.supportLights) add(Capability.LIGHT_COMMAND) + } + + /** Test/inspection: how many times [connect] completed successfully. */ + fun successfulConnectCount(): Int = connectCount + + fun armFailNextCommand() { + failNext = true + } + + fun armTimeoutNextCommand(hangMs: Long = 0L) { + timeoutNext = true + this.hangMs = hangMs + } + + fun armFailNextRefresh() { + failRefresh = true + } + + fun forceConnectionLoss(reason: String = "Simulated connection loss") { + connected = false + _connection.value = ConnectionStatus.StaleOrDegraded( + lastPath = ConnectionPath.SIMULATOR, + reason = reason, + lastSuccessAtMs = _snapshot.value?.asOfMs + ) + } + + fun restoreConnection() { + connected = true + _connection.value = ConnectionStatus.Simulator(config.profileName) + } + + override suspend fun connect(target: AdapterConnectionTarget) = mutex.withLock { + _connection.value = ConnectionStatus.Connecting + delay(20) + if (target.adapterId != adapterId || target !is SimulatorConnectionTarget) { + _connection.value = ConnectionStatus.ConnectionFailed( + "SimulatorAdapter only accepts SimulatorConnectionTarget" + ) + throw IllegalArgumentException( + "SimulatorAdapter only accepts SimulatorConnectionTarget, got ${target::class.simpleName}" + ) + } + connected = true + commandsSent = 0 + connectCount++ + state = initialSnapshot(config.copy(profileName = target.profileName)) + _snapshot.value = state + _connection.value = ConnectionStatus.Simulator(target.profileName) + } + + override suspend fun disconnect() = mutex.withLock { + connected = false + _connection.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot = mutex.withLock { + if (!connected) { + _connection.value = ConnectionStatus.StaleOrDegraded( + lastPath = ConnectionPath.SIMULATOR, + reason = "Simulator not connected", + lastSuccessAtMs = _snapshot.value?.asOfMs + ) + throw IllegalStateException("Simulator not connected") + } + if (failRefresh) { + failRefresh = false + _connection.value = ConnectionStatus.StaleOrDegraded( + lastPath = ConnectionPath.SIMULATOR, + reason = "Simulated refresh failure", + lastSuccessAtMs = _snapshot.value?.asOfMs + ) + throw IllegalStateException("Simulated refresh failure") + } + state = tick(state) + _snapshot.value = state + _connection.value = ConnectionStatus.Simulator(config.profileName) + state + } + + override suspend fun sendCommand(command: DomainCommand) { + mutex.withLock { + if (!connected) throw IllegalStateException("Simulator not connected") + if (failNext) { + failNext = false + throw IllegalStateException("Simulated command failure") + } + } + if (hangMs > 0) { + val wait = hangMs + hangMs = 0 + delay(wait) + return + } + if (timeoutNext) { + timeoutNext = false + return + } + delay(config.applyDelayMs) + mutex.withLock { + if (!connected) throw IllegalStateException("Simulator not connected") + state = applyCommand(state, command) + _snapshot.value = state + commandsSent++ + if (config.disconnectAfterCommands > 0 && + commandsSent >= config.disconnectAfterCommands + ) { + connected = false + _connection.value = ConnectionStatus.StaleOrDegraded( + lastPath = ConnectionPath.SIMULATOR, + reason = "Simulated disconnect after command", + lastSuccessAtMs = state.asOfMs + ) + } + } + } + + private fun applyCommand(current: SiteSnapshot, command: DomainCommand): SiteSnapshot { + val now = System.currentTimeMillis() + return when (command.kind) { + DomainCommandKind.SET_CIRCUIT -> { + val id = command.circuitId ?: return current + val on = command.circuitOn ?: return current + current.copy( + circuits = current.circuits.map { + if (it.id == id) it.copy(isOn = on) else it + }, + asOfMs = now + ) + } + DomainCommandKind.SET_HEAT_SETPOINT -> { + val bodyId = command.bodyId ?: return current + val temp = command.temperature ?: return current + current.copy( + bodies = current.bodies.map { + if (it.id == bodyId) it.copy(heatSetPoint = temp) else it + }, + asOfMs = now + ) + } + DomainCommandKind.SET_HEAT_MODE -> { + val bodyId = command.bodyId ?: return current + val mode = command.heatMode ?: return current + val active = mode != HeatMode.OFF && mode != HeatMode.DONT_CHANGE + current.copy( + bodies = current.bodies.map { + if (it.id == bodyId) it.copy(heatMode = mode, heatActive = active) else it + }, + asOfMs = now + ) + } + DomainCommandKind.SEND_LIGHT_PROGRAM -> { + val program = command.lightProgramId?.let { current.lightProgram(it) } + val lightsOn = program?.category != LightActionCategory.OFF + current.copy( + circuits = current.circuits.map { c -> + if (c.displayName.contains("Light", ignoreCase = true)) { + c.copy(isOn = lightsOn) + } else c + }, + asOfMs = now + ) + } + } + } + + private fun tick(current: SiteSnapshot): SiteSnapshot { + val bodies = current.bodies.map { body -> + val set = body.heatSetPoint ?: return@map body + val cur = body.currentTemp ?: return@map body + if (body.heatActive && cur.value < set.value) { + body.copy(currentTemp = cur.copy(value = cur.value + 1)) + } else body + } + return current.copy(bodies = bodies, asOfMs = System.currentTimeMillis()) + } + + companion object { + const val ADAPTER_ID = "westcurrent.simulator" + + val PRIMARY_LIGHT_TARGET: LightTargetId = LightTargetId("simulator:lights:primary") + val SECONDARY_LIGHT_TARGET: LightTargetId = LightTargetId("simulator:lights:secondary") + + fun bodyId(kind: BodyKind): BodyId = when (kind) { + BodyKind.POOL -> BodyId("simulator:body:pool") + BodyKind.SPA -> BodyId("simulator:body:spa") + BodyKind.OTHER -> BodyId("simulator:body:other") + } + + fun circuitId(key: String): CircuitId = CircuitId("simulator:circuit:$key") + + fun lightId(key: String): LightProgramId = LightProgramId("simulator:light:$key") + + fun initialSnapshot(config: Config): SiteSnapshot { + val unit = config.temperatureUnit.let { + if (it == TemperatureUnit.UNKNOWN) TemperatureUnit.FAHRENHEIT else it + } + val isC = unit == TemperatureUnit.CELSIUS + + fun t(fValue: Int, cValue: Int): Temperature = + if (isC) Temperature.celsius(cValue) else Temperature.fahrenheit(fValue) + + val bodies = buildList { + if (BodyKind.POOL in config.bodyKinds) { + add( + BodyState( + id = bodyId(BodyKind.POOL), + kind = BodyKind.POOL, + displayName = "Pool", + currentTemp = t(82, 28), + heatSetPoint = t(86, 30), + heatMode = HeatMode.OFF, + heatActive = false, + supportsHeat = config.supportHeat + ) + ) + } + if (BodyKind.SPA in config.bodyKinds) { + add( + BodyState( + id = bodyId(BodyKind.SPA), + kind = BodyKind.SPA, + displayName = "Spa", + currentTemp = t(97, 36), + heatSetPoint = t(100, 38), + heatMode = HeatMode.HEATER, + heatActive = true, + supportsHeat = config.supportHeat + ) + ) + } + } + val circuits = if (config.supportCircuits) { + listOf( + CircuitState(circuitId("spa"), "Spa", isOn = true), + CircuitState(circuitId("waterfall"), "Waterfall", isOn = false), + CircuitState(circuitId("pool_light"), "Pool Light", isOn = true), + CircuitState(circuitId("spa_light"), "Spa Light", isOn = true), + CircuitState(circuitId("cleaner"), "Cleaner", isOn = false), + CircuitState(circuitId("pool_low"), "Pool Low", isOn = true) + ) + } else emptyList() + + val lights: List + val targets: List + if (config.supportLights) { + if (config.dualLightTargets) { + val primaryPrograms = listOf( + LightProgram(lightId("all_off"), "All Off", LightActionCategory.OFF, ADAPTER_ID, PRIMARY_LIGHT_TARGET), + LightProgram(lightId("party"), "Party", LightActionCategory.SET_PROGRAM, ADAPTER_ID, PRIMARY_LIGHT_TARGET), + LightProgram(lightId("blue"), "Blue", LightActionCategory.SET_PROGRAM, ADAPTER_ID, PRIMARY_LIGHT_TARGET) + ) + val secondaryPrograms = listOf( + LightProgram(lightId("sec_off"), "Sec Off", LightActionCategory.OFF, ADAPTER_ID, SECONDARY_LIGHT_TARGET), + LightProgram(lightId("sec_party"), "Sec Party", LightActionCategory.SET_PROGRAM, ADAPTER_ID, SECONDARY_LIGHT_TARGET) + ) + lights = primaryPrograms + secondaryPrograms + targets = listOf( + LightTarget( + PRIMARY_LIGHT_TARGET, "Primary Lights", ADAPTER_ID, + primaryPrograms.map { it.id }.toSet() + ), + LightTarget( + SECONDARY_LIGHT_TARGET, "Secondary Lights", ADAPTER_ID, + secondaryPrograms.map { it.id }.toSet() + ) + ) + } else { + lights = listOf( + LightProgram(lightId("all_off"), "All Off", LightActionCategory.OFF, ADAPTER_ID, PRIMARY_LIGHT_TARGET), + LightProgram(lightId("all_on"), "All On", LightActionCategory.ON, ADAPTER_ID, PRIMARY_LIGHT_TARGET), + LightProgram(lightId("party"), "Party", LightActionCategory.SET_PROGRAM, ADAPTER_ID, PRIMARY_LIGHT_TARGET), + LightProgram(lightId("blue"), "Blue", LightActionCategory.SET_PROGRAM, ADAPTER_ID, PRIMARY_LIGHT_TARGET), + LightProgram(lightId("hold"), "Hold", LightActionCategory.HOLD, ADAPTER_ID, PRIMARY_LIGHT_TARGET) + ) + targets = listOf( + LightTarget( + PRIMARY_LIGHT_TARGET, "Pool & Spa Lights", ADAPTER_ID, + lights.map { it.id }.toSet() + ) + ) + } + } else { + lights = emptyList() + targets = emptyList() + } + + val kinds = buildSet { + if (config.supportCircuits) add(DomainCommandKind.SET_CIRCUIT) + if (config.supportHeat) { + add(DomainCommandKind.SET_HEAT_SETPOINT) + add(DomainCommandKind.SET_HEAT_MODE) + } + if (config.supportLights) add(DomainCommandKind.SEND_LIGHT_PROGRAM) + } + + return SiteSnapshot( + bodies = bodies, + circuits = circuits, + lightPrograms = lights, + lightTargets = targets, + airTemp = t(78, 26), + freezeMode = false, + chemistry = ChemistrySnapshot(pH = 7.4f, orp = 720, saltPpm = 3200), + controllerVersion = "Simulator 1.2 (${config.profileName}, ${unit.name})", + asOfMs = System.currentTimeMillis(), + source = ConnectionPath.SIMULATOR, + capabilities = SystemCapabilities( + commandKinds = kinds, + controllableCircuits = circuits.filter { it.isControllable }.map { it.id }.toSet(), + heatCapableBodies = bodies.filter { it.supportsHeat }.map { it.id }.toSet(), + lightPrograms = lights.map { it.id }.toSet(), + lightTargets = targets.map { it.id }.toSet() + ), + temperatureUnit = unit + ) + } + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/adapter/simulator/SimulatorConnectionTarget.kt b/app/src/main/java/com/pentaircontrol/pool/adapter/simulator/SimulatorConnectionTarget.kt new file mode 100644 index 0000000..d5bfcd0 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/adapter/simulator/SimulatorConnectionTarget.kt @@ -0,0 +1,15 @@ +package com.pentaircontrol.pool.adapter.simulator + +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.ConnectionPath + +/** + * Simulator-owned connection target. Not shared with ScreenLogic / future vendor packages. + */ +data class SimulatorConnectionTarget( + val profileName: String = "Demo EasyTouch" +) : AdapterConnectionTarget { + override val adapterId: String = SimulatorAdapter.ADAPTER_ID + override val path: ConnectionPath = ConnectionPath.SIMULATOR + override val displayLabel: String = "Simulator · $profileName" +} diff --git a/app/src/main/java/com/pentaircontrol/pool/data/DemoPoolEngine.kt b/app/src/main/java/com/pentaircontrol/pool/data/DemoPoolEngine.kt deleted file mode 100644 index b7f5ccf..0000000 --- a/app/src/main/java/com/pentaircontrol/pool/data/DemoPoolEngine.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.pentaircontrol.pool.data - -/** - * In-memory pool simulator so the app is fully usable without ScreenLogic hardware. - */ -class DemoPoolEngine { - private var state = PoolStatus( - controllerState = 1, - freezeMode = false, - airTemp = 78, - pool = BodyStatus( - bodyId = 0, - name = "Pool", - currentTemp = 82, - heatSetPoint = 86, - heatMode = HeatMode.OFF, - heatActive = false - ), - spa = BodyStatus( - bodyId = 1, - name = "Spa", - currentTemp = 97, - heatSetPoint = 100, - heatMode = HeatMode.HEATER, - heatActive = true - ), - circuits = listOf( - Circuit(500, "Spa", isOn = true), - Circuit(501, "Waterfall", isOn = false), - Circuit(502, "Pool Light", isOn = true), - Circuit(503, "Spa Light", isOn = true), - Circuit(504, "Cleaner", isOn = false), - Circuit(505, "Pool Low", isOn = true), - Circuit(506, "Yard Light", isOn = false), - Circuit(508, "Pool High", isOn = false), - Circuit(510, "Feature 1", isOn = false), - Circuit(511, "Feature 2", isOn = false) - ), - pH = 7.4f, - orp = 720, - saltPpm = 3200, - version = "Demo 1.0", - isDemo = true - ) - - val status: PoolStatus get() = state - - fun tick() { - // Mild simulated drift toward setpoints when heater active - val pool = state.pool - val spa = state.spa - val newPool = if (pool.heatActive && pool.currentTemp < pool.heatSetPoint) { - pool.copy(currentTemp = pool.currentTemp + 1) - } else pool - val newSpa = if (spa.heatActive && spa.currentTemp < spa.heatSetPoint) { - spa.copy(currentTemp = spa.currentTemp + 1) - } else if (!spa.heatActive && spa.currentTemp > 90) { - spa.copy(currentTemp = spa.currentTemp - 1) - } else spa - state = state.copy(pool = newPool, spa = newSpa) - } - - fun setCircuit(circuitId: Int, on: Boolean) { - state = state.copy( - circuits = state.circuits.map { - if (it.id == circuitId) it.copy(isOn = on) else it - } - ) - // Spa circuit mirrors spa heat activity loosely - if (circuitId == 500) { - state = state.copy( - spa = state.spa.copy(heatActive = on && state.spa.heatMode != HeatMode.OFF) - ) - } - } - - fun setHeatSetPoint(bodyId: Int, temp: Int) { - state = when (bodyId) { - 0 -> state.copy(pool = state.pool.copy(heatSetPoint = temp.coerceIn(40, 104))) - 1 -> state.copy(spa = state.spa.copy(heatSetPoint = temp.coerceIn(40, 104))) - else -> state - } - } - - fun setHeatMode(bodyId: Int, mode: HeatMode) { - state = when (bodyId) { - 0 -> state.copy( - pool = state.pool.copy( - heatMode = mode, - heatActive = mode != HeatMode.OFF && mode != HeatMode.DONT_CHANGE - ) - ) - 1 -> state.copy( - spa = state.spa.copy( - heatMode = mode, - heatActive = mode != HeatMode.OFF && mode != HeatMode.DONT_CHANGE - ) - ) - else -> state - } - } - - fun sendLightCommand(command: LightCommand) { - val lightsOn = command != LightCommand.ALL_OFF - state = state.copy( - circuits = state.circuits.map { c -> - if (c.name.contains("Light", ignoreCase = true)) c.copy(isOn = lightsOn) else c - } - ) - } -} diff --git a/app/src/main/java/com/pentaircontrol/pool/data/PoolRepository.kt b/app/src/main/java/com/pentaircontrol/pool/data/PoolRepository.kt deleted file mode 100644 index 7328603..0000000 --- a/app/src/main/java/com/pentaircontrol/pool/data/PoolRepository.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.pentaircontrol.pool.data - -import com.pentaircontrol.pool.protocol.Discovery -import com.pentaircontrol.pool.protocol.RemoteLogin -import com.pentaircontrol.pool.protocol.ScreenLogicClient -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** - * High-level pool control API. Switches between live ScreenLogic client and demo mode. - * Supports local LAN discovery and remote connect via system code (XX-XX-XX). - */ -class PoolRepository { - private val mutex = Mutex() - private var client: ScreenLogicClient? = null - private var demo: DemoPoolEngine? = null - - private val _connection = MutableStateFlow(ConnectionState.Disconnected) - val connection: StateFlow = _connection.asStateFlow() - - private val _status = MutableStateFlow(PoolStatus()) - val status: StateFlow = _status.asStateFlow() - - private val _gateways = MutableStateFlow>(emptyList()) - val gateways: StateFlow> = _gateways.asStateFlow() - - suspend fun discover() { - _connection.value = ConnectionState.Discovering - try { - val found = Discovery.search() - _gateways.value = found - if (found.isEmpty()) { - _connection.value = ConnectionState.Error( - "No ScreenLogic gateways found on this Wi‑Fi network. " + - "Use Remote Connect with your system code, manual IP, or Demo Mode." - ) - } else { - _connection.value = ConnectionState.Disconnected - } - } catch (e: Exception) { - _connection.value = ConnectionState.Error(e.message ?: "Discovery failed") - } - } - - /** - * Remote connect using the 6-character adapter code (e.g. A1-B2-C3 / Pentair: A1-B2-C3) - * and optional remote access password (often blank; default in ScreenLogic is often 1234). - */ - suspend fun connectRemote(systemCode: String, password: String = "") { - _connection.value = ConnectionState.Connecting - try { - val gateway = RemoteLogin.resolveGateway(systemCode) - connect(gateway, password) - } catch (e: Exception) { - client = null - _connection.value = ConnectionState.Error( - e.message ?: "Remote connect failed for $systemCode" - ) - } - } - - suspend fun connect(gateway: GatewayInfo, password: String = "") = mutex.withLock { - disconnectInternal() - _connection.value = ConnectionState.Connecting - try { - val c = ScreenLogicClient(gateway, password) - c.connect() - try { - c.refreshControllerConfig() - } catch (_: Exception) { - // Non-fatal — circuit names fall back to defaults - } - val status = try { - var s = c.getPoolStatus() - val version = try { - c.getVersion() - } catch (_: Exception) { - null - } - s.copy(version = version) - } catch (e: Exception) { - // Login succeeded but status decode/read failed — still treat as hard fail - // with a clear stage so we can tell it apart from dispatcher/login issues. - throw Exception( - "Connected to ${gateway.address}:${gateway.port} but failed reading pool status: " + - (e.message ?: e.javaClass.simpleName), - e - ) - } - client = c - demo = null - _status.value = status - _connection.value = ConnectionState.Connected(gateway, isDemo = false) - } catch (e: Exception) { - try { - client?.disconnect() - } catch (_: Exception) { - } - client = null - _connection.value = ConnectionState.Error( - e.message ?: "Failed to connect to ${gateway.address}" - ) - } - } - - suspend fun connectDemo() = mutex.withLock { - disconnectInternal() - val engine = DemoPoolEngine() - demo = engine - client = null - _status.value = engine.status - _connection.value = ConnectionState.Connected( - GatewayInfo(name = "Demo Pool", address = "127.0.0.1", port = 0), - isDemo = true - ) - } - - suspend fun refresh() = mutex.withLock { - val c = client - val d = demo - when { - c != null && c.isConnected -> { - try { - var status = c.getPoolStatus() - status = status.copy(version = _status.value.version) - _status.value = status - } catch (e: Exception) { - _connection.value = ConnectionState.Error(e.message ?: "Refresh failed") - } - } - d != null -> { - d.tick() - _status.value = d.status - } - } - } - - suspend fun setCircuit(circuitId: Int, on: Boolean) = mutex.withLock { - val c = client - val d = demo - when { - c != null -> { - c.setCircuitState(circuitId, on) - delay(200) - _status.value = c.getPoolStatus().copy(version = _status.value.version) - } - d != null -> { - d.setCircuit(circuitId, on) - _status.value = d.status - } - } - } - - suspend fun setHeatSetPoint(bodyId: Int, temp: Int) = mutex.withLock { - val c = client - val d = demo - when { - c != null -> { - c.setHeatSetPoint(bodyId, temp) - delay(200) - _status.value = c.getPoolStatus().copy(version = _status.value.version) - } - d != null -> { - d.setHeatSetPoint(bodyId, temp) - _status.value = d.status - } - } - } - - suspend fun setHeatMode(bodyId: Int, mode: HeatMode) = mutex.withLock { - val c = client - val d = demo - when { - c != null -> { - c.setHeatMode(bodyId, mode.code) - delay(200) - _status.value = c.getPoolStatus().copy(version = _status.value.version) - } - d != null -> { - d.setHeatMode(bodyId, mode) - _status.value = d.status - } - } - } - - suspend fun sendLightCommand(command: LightCommand) = mutex.withLock { - val c = client - val d = demo - when { - c != null -> { - c.sendLightCommand(command.code) - delay(200) - _status.value = c.getPoolStatus().copy(version = _status.value.version) - } - d != null -> { - d.sendLightCommand(command) - _status.value = d.status - } - } - } - - suspend fun disconnect() = mutex.withLock { - disconnectInternal() - _connection.value = ConnectionState.Disconnected - } - - private suspend fun disconnectInternal() { - try { - client?.disconnect() - } catch (_: Exception) { - } - client = null - demo = null - _status.value = PoolStatus() - } -} diff --git a/app/src/main/java/com/pentaircontrol/pool/domain/CommandOrchestrator.kt b/app/src/main/java/com/pentaircontrol/pool/domain/CommandOrchestrator.kt new file mode 100644 index 0000000..bd70dbc --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/domain/CommandOrchestrator.kt @@ -0,0 +1,245 @@ +package com.pentaircontrol.pool.domain + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks desired commands through lifecycle states. + * + * Concurrency: a global short lock protects receipt bookkeeping; **target-scoped** mutexes + * serialize conflicting commands to the same equipment target without blocking unrelated targets + * for the full confirmation window. + * + * Light commands use [DomainCommand.lightTargetId] as the coordination key so different programs + * on the same lighting group serialize. + */ +class CommandOrchestrator( + private val safetyPolicy: SafetyPolicy = SafetyPolicy(), + private val confirmTimeoutMs: Long = DEFAULT_CONFIRM_TIMEOUT_MS, + private val pollIntervalMs: Long = DEFAULT_POLL_INTERVAL_MS, + private val clock: () -> Long = { System.currentTimeMillis() } +) { + private val bookkeeping = Mutex() + private val targetLocks = ConcurrentHashMap() + private val _receipts = MutableStateFlow>(emptyList()) + val receipts: StateFlow> = _receipts.asStateFlow() + + data class SubmitResult( + val receipt: CommandReceipt? = null, + val requiresConfirmation: Boolean = false, + val confirmationReason: String? = null, + val rejectedReason: String? = null + ) { + val isConfirmationRequired: Boolean get() = requiresConfirmation + val isRejected: Boolean get() = rejectedReason != null + } + + suspend fun submit( + commandInput: DomainCommand, + connection: ConnectionStatus, + adapter: PoolSystemAdapter?, + currentSnapshot: SiteSnapshot?, + capabilities: SystemCapabilities + ): SubmitResult { + val command = commandInput.copy( + id = commandInput.id.ifBlank { UUID.randomUUID().toString() }, + issuedAtMs = clock() + ) + + val pendingSnapshot = bookkeeping.withLock { _receipts.value } + val decision = safetyPolicy.validate( + command, + SafetyPolicy.Context( + connection = connection, + pendingCommands = pendingSnapshot, + capabilities = capabilities, + snapshot = currentSnapshot + ) + ) + + when (decision) { + is SafetyPolicy.Decision.Reject -> { + return SubmitResult(rejectedReason = decision.reason) + } + is SafetyPolicy.Decision.RequireConfirmation -> { + return SubmitResult( + requiresConfirmation = true, + confirmationReason = decision.reason + ) + } + SafetyPolicy.Decision.Allow -> Unit + } + + if (adapter == null) { + val receipt = CommandReceipt( + command = command, + lifecycle = CommandLifecycle.Failed, + message = "No equipment adapter is active.", + updatedAtMs = clock() + ) + upsert(receipt) + return SubmitResult(receipt = receipt) + } + + val targetKey = command.targetKey() + val targetMutex = targetLocks.getOrPut(targetKey) { Mutex() } + + return targetMutex.withLock { + val stillDup = bookkeeping.withLock { + _receipts.value.any { + it.lifecycle == CommandLifecycle.Pending && + it.command.targetKey() == targetKey + } + } + if (stillDup) { + return@withLock SubmitResult( + rejectedReason = "A matching command is already pending for this equipment." + ) + } + + val pending = CommandReceipt( + command = command, + lifecycle = CommandLifecycle.Pending, + message = "Sending command…", + updatedAtMs = clock() + ) + upsert(pending) + + try { + adapter.sendCommand(command) + } catch (e: Exception) { + val failed = pending.copy( + lifecycle = CommandLifecycle.Failed, + message = e.message ?: "Adapter failed to send command", + updatedAtMs = clock() + ) + upsert(failed) + return@withLock SubmitResult(receipt = failed) + } + + upsert( + pending.copy( + message = "Write accepted by adapter; confirming against reported state…", + updatedAtMs = clock() + ) + ) + + val confirmed = awaitConfirmation(adapter, command) + upsert(confirmed) + SubmitResult(receipt = confirmed) + } + } + + private suspend fun awaitConfirmation( + adapter: PoolSystemAdapter, + command: DomainCommand + ): CommandReceipt { + if (command.kind == DomainCommandKind.SEND_LIGHT_PROGRAM) { + return try { + adapter.refresh() + CommandReceipt( + command = command, + lifecycle = CommandLifecycle.SentUnverified, + message = "Command was sent, but this controller does not report the active light program.", + evidence = ConfirmationEvidence.NO_CHANNEL, + updatedAtMs = clock() + ) + } catch (e: Exception) { + CommandReceipt( + command = command, + lifecycle = CommandLifecycle.Failed, + message = e.message ?: "Light command send could not be verified; session unhealthy.", + evidence = ConfirmationEvidence.TRANSPORT_ACK, + updatedAtMs = clock() + ) + } + } + + val deadline = clock() + confirmTimeoutMs + var lastSnapshot: SiteSnapshot? = null + while (clock() < deadline) { + try { + val snap = adapter.refresh() + lastSnapshot = snap + if (isApplied(command, snap)) { + return CommandReceipt( + command = command, + lifecycle = CommandLifecycle.Applied, + message = "Equipment reported the requested state.", + evidence = ConfirmationEvidence.REPORTED_STATE, + updatedAtMs = clock() + ) + } + } catch (_: Exception) { + } + delay(pollIntervalMs) + } + + lastSnapshot?.let { + if (isApplied(command, it)) { + return CommandReceipt( + command = command, + lifecycle = CommandLifecycle.Applied, + message = "Equipment reported the requested state.", + evidence = ConfirmationEvidence.REPORTED_STATE, + updatedAtMs = clock() + ) + } + } + + return CommandReceipt( + command = command, + lifecycle = CommandLifecycle.TimedOut, + message = "Timed out waiting for equipment to report the requested state. " + + "The command may still have been received — check status and try again if needed.", + evidence = ConfirmationEvidence.REPORTED_STATE, + updatedAtMs = clock() + ) + } + + fun isApplied(command: DomainCommand, snapshot: SiteSnapshot): Boolean { + return when (command.kind) { + DomainCommandKind.SET_CIRCUIT -> { + val id = command.circuitId ?: return false + val on = command.circuitOn ?: return false + snapshot.circuit(id)?.isOn == on + } + DomainCommandKind.SET_HEAT_SETPOINT -> { + val bodyId = command.bodyId ?: return false + val temp = command.temperature ?: return false + val reported = snapshot.body(bodyId)?.heatSetPoint ?: return false + reported.value == temp.value && + (reported.unit == temp.unit || + reported.unit == TemperatureUnit.UNKNOWN || + temp.unit == TemperatureUnit.UNKNOWN) + } + DomainCommandKind.SET_HEAT_MODE -> { + val bodyId = command.bodyId ?: return false + val mode = command.heatMode ?: return false + snapshot.body(bodyId)?.heatMode == mode + } + DomainCommandKind.SEND_LIGHT_PROGRAM -> false + } + } + + private suspend fun upsert(receipt: CommandReceipt) = bookkeeping.withLock { + val others = _receipts.value.filterNot { it.command.id == receipt.command.id } + _receipts.value = (listOf(receipt) + others).take(40) + } + + fun clear() { + _receipts.value = emptyList() + targetLocks.clear() + } + + companion object { + const val DEFAULT_CONFIRM_TIMEOUT_MS = 8_000L + const val DEFAULT_POLL_INTERVAL_MS = 400L + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/domain/DomainModels.kt b/app/src/main/java/com/pentaircontrol/pool/domain/DomainModels.kt new file mode 100644 index 0000000..fc3e830 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/domain/DomainModels.kt @@ -0,0 +1,381 @@ +package com.pentaircontrol.pool.domain + +/** + * Vendor-neutral domain model for WestCurrent. + * + * Rules (Phase 1.1 + 1.2): + * - No ScreenLogic / Pentair / Jandy packet numbers + * - Identifiers are opaque stable strings + * - Bodies are a collection (pool-only, spa-only, multi-body OK) + * - Light programs are adapter-declared and bound to light targets + * - Connection targets are adapter-owned (see [AdapterConnectionTarget]) + * - Temperatures always carry an explicit unit + */ + +/** Opaque stable body identifier (e.g. "screenlogic:body:0"). Do not parse protocol numbers from this. */ +@JvmInline +value class BodyId(val value: String) { + override fun toString(): String = value +} + +/** Opaque stable circuit identifier. */ +@JvmInline +value class CircuitId(val value: String) { + override fun toString(): String = value +} + +/** Opaque stable light program identifier declared by an adapter. */ +@JvmInline +value class LightProgramId(val value: String) { + override fun toString(): String = value +} + +/** + * Opaque stable lighting target / group identifier. + * Commands coordinate by this id, not by the selected program. + */ +@JvmInline +value class LightTargetId(val value: String) { + override fun toString(): String = value +} + +/** Explicit temperature scale. Never infer from locale or value magnitude. */ +enum class TemperatureUnit { + FAHRENHEIT, + CELSIUS, + UNKNOWN +} + +/** + * Temperature with mandatory unit. Never display a bare ° without F/C. + */ +data class Temperature( + val value: Int, + val unit: TemperatureUnit +) { + /** Explicit display: "86°F", "30°C", or "86°?" when unknown. */ + fun format(): String = when (unit) { + TemperatureUnit.FAHRENHEIT -> "${value}°F" + TemperatureUnit.CELSIUS -> "${value}°C" + TemperatureUnit.UNKNOWN -> "${value}°?" + } + + fun isKnown(): Boolean = unit != TemperatureUnit.UNKNOWN + + companion object { + fun fahrenheit(value: Int) = Temperature(value, TemperatureUnit.FAHRENHEIT) + fun celsius(value: Int) = Temperature(value, TemperatureUnit.CELSIUS) + fun unknown(value: Int) = Temperature(value, TemperatureUnit.UNKNOWN) + } +} + +/** Centralized conversion helpers (tested). Not applied silently by SafetyPolicy. */ +object TemperatureConversion { + fun fahrenheitToCelsius(f: Int): Int = ((f - 32) * 5) / 9 + fun celsiusToFahrenheit(c: Int): Int = (c * 9) / 5 + 32 + + fun convert(temp: Temperature, to: TemperatureUnit): Temperature? { + if (temp.unit == TemperatureUnit.UNKNOWN || to == TemperatureUnit.UNKNOWN) return null + if (temp.unit == to) return temp + return when { + temp.unit == TemperatureUnit.FAHRENHEIT && to == TemperatureUnit.CELSIUS -> + Temperature.celsius(fahrenheitToCelsius(temp.value)) + temp.unit == TemperatureUnit.CELSIUS && to == TemperatureUnit.FAHRENHEIT -> + Temperature.fahrenheit(celsiusToFahrenheit(temp.value)) + else -> null + } + } +} + +/** Semantic heat modes only — no vendor codes. */ +enum class HeatMode(val label: String) { + OFF("Off"), + SOLAR("Solar"), + SOLAR_PREFERRED("Solar Preferred"), + HEATER("Heater"), + DONT_CHANGE("Don't Change") +} + +/** General light action categories (not vendor program names). */ +enum class LightActionCategory { + ON, + OFF, + SYNCHRONIZE, + SET_PROGRAM, + HOLD, + RECALL, + OTHER +} + +/** + * Adapter-declared light action / program. Protocol values live only in the owning adapter. + * Bound to a [LightTargetId] so coordination is by target, not program. + */ +data class LightProgram( + val id: LightProgramId, + val displayName: String, + val category: LightActionCategory, + val adapterId: String, + val targetId: LightTargetId +) + +/** + * Controllable lighting target or group. Programs available for this target are listed + * via [programIds]; adapters may expose one or many targets. + */ +data class LightTarget( + val id: LightTargetId, + val displayName: String, + val adapterId: String, + val programIds: Set +) + +enum class BodyKind { POOL, SPA, OTHER } + +enum class ConnectionPath { + LOCAL_LAN, + REMOTE_MANUFACTURER, + SIMULATOR, + CACHED, + UNKNOWN +} + +/** + * Vendor-neutral connection target boundary. + * Concrete types live with their adapters (ScreenLogic local/remote, Simulator, future Jandy). + * Shared domain must not grow vendor-specific sealed login variants. + */ +interface AdapterConnectionTarget { + val adapterId: String + val path: ConnectionPath + val displayLabel: String +} + +sealed class ConnectionStatus { + data object Disconnected : ConnectionStatus() + data object Connecting : ConnectionStatus() + data class ConnectedLocal( + val gatewayLabel: String, + val address: String, + val port: Int + ) : ConnectionStatus() + + data class ConnectedRemote( + val systemName: String, + val address: String, + val port: Int + ) : ConnectionStatus() + + data class Simulator(val profileName: String) : ConnectionStatus() + data object Reconnecting : ConnectionStatus() + data class StaleOrDegraded( + val lastPath: ConnectionPath, + val reason: String, + val lastSuccessAtMs: Long? + ) : ConnectionStatus() + + data object AuthenticationRequired : ConnectionStatus() + data class ConnectionFailed(val message: String) : ConnectionStatus() + + val isLiveConnected: Boolean + get() = this is ConnectedLocal || this is ConnectedRemote || this is Simulator + + val path: ConnectionPath + get() = when (this) { + is ConnectedLocal -> ConnectionPath.LOCAL_LAN + is ConnectedRemote -> ConnectionPath.REMOTE_MANUFACTURER + is Simulator -> ConnectionPath.SIMULATOR + is StaleOrDegraded -> lastPath + else -> ConnectionPath.UNKNOWN + } + + val displayLabel: String + get() = when (this) { + Disconnected -> "Disconnected" + Connecting -> "Connecting…" + is ConnectedLocal -> "Local · $gatewayLabel" + is ConnectedRemote -> "Remote · $systemName" + is Simulator -> "Simulator · $profileName" + Reconnecting -> "Reconnecting…" + is StaleOrDegraded -> "Stale · $reason" + AuthenticationRequired -> "Authentication required" + is ConnectionFailed -> "Connection failed" + } +} + +/** + * Command lifecycle with honest unverified transport outcomes. + */ +enum class CommandLifecycle { + /** Waiting for evidence of application. */ + Pending, + /** Reported equipment state confirms the request. */ + Applied, + /** Transport accepted / session healthy, but controller exposes no proof of result. */ + SentUnverified, + /** Submission failed. */ + Failed, + /** Expected confirmation did not arrive. */ + TimedOut +} + +enum class ConfirmationEvidence { + REPORTED_STATE, + TRANSPORT_ACK, + REFRESH_ONLY, + NO_CHANNEL +} + +enum class DomainCommandKind { + SET_CIRCUIT, + SET_HEAT_SETPOINT, + SET_HEAT_MODE, + SEND_LIGHT_PROGRAM +} + +data class DomainCommand( + val id: String, + val kind: DomainCommandKind, + val circuitId: CircuitId? = null, + val circuitOn: Boolean? = null, + val bodyId: BodyId? = null, + val temperature: Temperature? = null, + val heatMode: HeatMode? = null, + val lightProgramId: LightProgramId? = null, + val lightTargetId: LightTargetId? = null, + val elevatedHeatConfirmed: Boolean = false, + val issuedAtMs: Long = System.currentTimeMillis() +) { + /** Key used for target-scoped command coordination. */ + fun targetKey(): String = when (kind) { + DomainCommandKind.SET_CIRCUIT -> "circuit:${circuitId?.value}" + DomainCommandKind.SET_HEAT_SETPOINT, + DomainCommandKind.SET_HEAT_MODE -> "body:${bodyId?.value}" + // Coordinate by lighting target/group — not program id (Party vs Romance are same target). + DomainCommandKind.SEND_LIGHT_PROGRAM -> + "light-target:${lightTargetId?.value ?: "unspecified"}" + } +} + +data class CommandReceipt( + val command: DomainCommand, + val lifecycle: CommandLifecycle, + val message: String? = null, + val evidence: ConfirmationEvidence? = null, + val updatedAtMs: Long = System.currentTimeMillis() +) + +data class BodyState( + val id: BodyId, + val kind: BodyKind, + val displayName: String, + val currentTemp: Temperature? = null, + val heatSetPoint: Temperature? = null, + val coolSetPoint: Temperature? = null, + val heatMode: HeatMode? = null, + val heatActive: Boolean = false, + val supportsHeat: Boolean = true +) + +data class CircuitState( + val id: CircuitId, + val displayName: String, + val isOn: Boolean, + val isControllable: Boolean = true, + val hasDelay: Boolean = false +) + +data class ChemistrySnapshot( + val pH: Float? = null, + val orp: Int? = null, + val saltPpm: Int? = null +) + +/** + * Active-system capabilities derived by the adapter from configuration / known support. + * Empty sets mean "none", not "everything". + */ +data class SystemCapabilities( + val commandKinds: Set = emptySet(), + val controllableCircuits: Set = emptySet(), + val heatCapableBodies: Set = emptySet(), + val lightPrograms: Set = emptySet(), + val lightTargets: Set = emptySet() +) { + fun supports(kind: DomainCommandKind): Boolean = kind in commandKinds +} + +/** + * Reported equipment snapshot. Bodies are a list — pool-only and spa-only are valid. + */ +data class SiteSnapshot( + val bodies: List = emptyList(), + val circuits: List = emptyList(), + val lightPrograms: List = emptyList(), + val lightTargets: List = emptyList(), + val airTemp: Temperature? = null, + val freezeMode: Boolean = false, + val chemistry: ChemistrySnapshot = ChemistrySnapshot(), + val controllerVersion: String? = null, + val asOfMs: Long = System.currentTimeMillis(), + val source: ConnectionPath = ConnectionPath.UNKNOWN, + val capabilities: SystemCapabilities = SystemCapabilities(), + /** + * System / session temperature unit for this snapshot. + * UNKNOWN means heat setpoint submission must wait for explicit session confirmation. + */ + val temperatureUnit: TemperatureUnit = TemperatureUnit.UNKNOWN +) { + val activeCircuitCount: Int get() = circuits.count { it.isOn } + + fun body(id: BodyId): BodyState? = bodies.find { it.id == id } + fun circuit(id: CircuitId): CircuitState? = circuits.find { it.id == id } + fun lightProgram(id: LightProgramId): LightProgram? = lightPrograms.find { it.id == id } + fun lightTarget(id: LightTargetId): LightTarget? = lightTargets.find { it.id == id } + + fun primaryPool(): BodyState? = bodies.find { it.kind == BodyKind.POOL } + fun primarySpa(): BodyState? = bodies.find { it.kind == BodyKind.SPA } + + /** Re-label all temperatures with a confirmed unit (raw values unchanged). */ + fun withConfirmedUnit(unit: TemperatureUnit): SiteSnapshot { + if (unit == TemperatureUnit.UNKNOWN) return this + fun Temperature?.relabel(): Temperature? = this?.copy(unit = unit) + return copy( + temperatureUnit = unit, + airTemp = airTemp.relabel(), + bodies = bodies.map { b -> + b.copy( + currentTemp = b.currentTemp.relabel(), + heatSetPoint = b.heatSetPoint.relabel(), + coolSetPoint = b.coolSetPoint.relabel() + ) + } + ) + } +} + +data class DiscoveredGateway( + val id: String, + val displayName: String, + val address: String, + val port: Int, + val path: ConnectionPath = ConnectionPath.LOCAL_LAN +) + +enum class Capability { + CIRCUIT_TOGGLE, + HEAT_SETPOINT, + HEAT_MODE, + LIGHT_COMMAND, + LOCAL_DISCOVERY, + REMOTE_ACCESS +} + +/** + * Result of preflight / submit before or without recording equipment failure. + */ +sealed class CommandPreflight { + data class Proceed(val receipt: CommandReceipt) : CommandPreflight() + data class Rejected(val reason: String) : CommandPreflight() + data class ConfirmationRequired(val reason: String) : CommandPreflight() +} diff --git a/app/src/main/java/com/pentaircontrol/pool/domain/PoolSystemAdapter.kt b/app/src/main/java/com/pentaircontrol/pool/domain/PoolSystemAdapter.kt new file mode 100644 index 0000000..7272314 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/domain/PoolSystemAdapter.kt @@ -0,0 +1,42 @@ +package com.pentaircontrol.pool.domain + +import kotlinx.coroutines.flow.StateFlow + +/** + * Vendor adapter boundary. UI and domain services never talk to vendor protocols directly. + * Future Jandy / IntelliCenter adapters implement this without UI rewrites. + * + * [connect] must accept only targets owned by this adapter ([AdapterConnectionTarget.adapterId] + * matching [adapterId]) and reject foreign targets clearly. + */ +interface PoolSystemAdapter { + val adapterId: String + val displayName: String + + val connection: StateFlow + val snapshot: StateFlow + + /** Coarse capability flags for UI chrome; detailed sets live on [SiteSnapshot.capabilities]. */ + val capabilities: Set + + suspend fun connect(target: AdapterConnectionTarget) + suspend fun disconnect() + + /** + * Best-effort refresh of reported equipment state. + * Does not imply command success. + */ + suspend fun refresh(): SiteSnapshot + + /** + * Send a domain command to equipment. + * Returns only after the adapter believes the write was accepted by the transport/session — + * NOT after equipment state is confirmed. Confirmation is the orchestrator's job. + */ + suspend fun sendCommand(command: DomainCommand) + + /** + * Optional local discovery (ScreenLogic LAN, etc.). Default empty. + */ + suspend fun discoverGateways(): List = emptyList() +} diff --git a/app/src/main/java/com/pentaircontrol/pool/domain/SafetyPolicy.kt b/app/src/main/java/com/pentaircontrol/pool/domain/SafetyPolicy.kt new file mode 100644 index 0000000..593b903 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/domain/SafetyPolicy.kt @@ -0,0 +1,270 @@ +package com.pentaircontrol.pool.domain + +/** + * SafetyPolicy v0.2 — reject unsafe / unsupported intents before they reach an adapter. + * Does not silently clamp or convert temperatures. Capabilities come from the active system. + * + * Temperature ranges are unit-explicit (Fahrenheit and Celsius). Unknown units reject heat + * setpoint commands. + */ +class SafetyPolicy { + sealed class Decision { + data object Allow : Decision() + data class RequireConfirmation(val reason: String) : Decision() + data class Reject(val reason: String) : Decision() + } + + data class Context( + val connection: ConnectionStatus, + val pendingCommands: List, + val capabilities: SystemCapabilities, + val snapshot: SiteSnapshot? + ) + + fun validate(command: DomainCommand, context: Context): Decision { + // Order matters: specific unavailable states before generic disconnected. + when (val c = context.connection) { + is ConnectionStatus.Reconnecting -> + return Decision.Reject("Reconnecting to equipment. Try again in a moment.") + is ConnectionStatus.StaleOrDegraded -> + return Decision.Reject( + "Connection is stale or degraded (${c.reason}). " + + "Wait for a live connection before controlling equipment." + ) + is ConnectionStatus.AuthenticationRequired -> + return Decision.Reject( + "Authentication required. Check the remote access password and reconnect." + ) + is ConnectionStatus.Connecting -> + return Decision.Reject("Still connecting. Wait until the session is live.") + is ConnectionStatus.ConnectionFailed -> + return Decision.Reject( + "Connection failed: ${c.message}. Reconnect before sending commands." + ) + ConnectionStatus.Disconnected -> + return Decision.Reject( + "Cannot send commands while disconnected. " + + "Connect locally, remotely, or open the simulator first." + ) + is ConnectionStatus.ConnectedLocal, + is ConnectionStatus.ConnectedRemote, + is ConnectionStatus.Simulator -> Unit + } + + if (!context.capabilities.supports(command.kind)) { + return Decision.Reject( + "This system does not support ${command.kind.name.replace('_', ' ').lowercase()}." + ) + } + + val duplicate = context.pendingCommands.any { receipt -> + receipt.lifecycle == CommandLifecycle.Pending && isDuplicate(receipt.command, command) + } + if (duplicate) { + return Decision.Reject( + "A matching command is already pending. Wait for it to finish before sending again." + ) + } + + return when (command.kind) { + DomainCommandKind.SET_CIRCUIT -> validateCircuit(command, context) + DomainCommandKind.SET_HEAT_SETPOINT -> validateHeatSetpoint(command, context) + DomainCommandKind.SET_HEAT_MODE -> validateHeatMode(command, context) + DomainCommandKind.SEND_LIGHT_PROGRAM -> validateLight(command, context) + } + } + + private fun validateCircuit(command: DomainCommand, context: Context): Decision { + val id = command.circuitId + ?: return Decision.Reject("Circuit command is missing a circuit id.") + if (command.circuitOn == null) { + return Decision.Reject("Circuit command is missing on/off state.") + } + if (id !in context.capabilities.controllableCircuits) { + return Decision.Reject("Circuit is unknown or not controllable on this system.") + } + val circuit = context.snapshot?.circuit(id) + if (circuit != null && !circuit.isControllable) { + return Decision.Reject("Circuit \"${circuit.displayName}\" is not controllable.") + } + return Decision.Allow + } + + private fun validateHeatMode(command: DomainCommand, context: Context): Decision { + val bodyId = command.bodyId + ?: return Decision.Reject("Heat mode command is missing a body.") + if (command.heatMode == null) { + return Decision.Reject("Heat mode command is missing a mode.") + } + if (bodyId !in context.capabilities.heatCapableBodies) { + return Decision.Reject("This body does not support heat control on the active system.") + } + val body = context.snapshot?.body(bodyId) + if (body != null && !body.supportsHeat) { + return Decision.Reject("\"${body.displayName}\" does not support heating.") + } + return Decision.Allow + } + + private fun validateHeatSetpoint(command: DomainCommand, context: Context): Decision { + val bodyId = command.bodyId + ?: return Decision.Reject("Heat setpoint is missing a body.") + val temp = command.temperature + ?: return Decision.Reject("Heat setpoint is missing a temperature value.") + + if (temp.unit == TemperatureUnit.UNKNOWN) { + return Decision.Reject( + "Temperature unit is unknown. Confirm °F or °C for this session before " + + "changing heat setpoints. WestCurrent will not guess the unit." + ) + } + + if (bodyId !in context.capabilities.heatCapableBodies) { + return Decision.Reject("This body does not support heat control on the active system.") + } + + val body = context.snapshot?.body(bodyId) + if (body != null && !body.supportsHeat) { + return Decision.Reject("\"${body.displayName}\" does not support heating.") + } + + // Mismatched command vs body / system unit + val bodyUnit = body?.heatSetPoint?.unit ?: body?.currentTemp?.unit + val systemUnit = context.snapshot?.temperatureUnit + if (bodyUnit != null && bodyUnit != TemperatureUnit.UNKNOWN && bodyUnit != temp.unit) { + return Decision.Reject( + "Command unit (${temp.unit.label()}) does not match body unit " + + "(${bodyUnit.label()}). Convert explicitly before sending." + ) + } + if (systemUnit != null && systemUnit != TemperatureUnit.UNKNOWN && systemUnit != temp.unit) { + return Decision.Reject( + "Command unit (${temp.unit.label()}) does not match system unit " + + "(${systemUnit.label()})." + ) + } + if (systemUnit == TemperatureUnit.UNKNOWN) { + return Decision.Reject( + "System temperature unit is unknown. Confirm °F or °C for this session first." + ) + } + + val kind = body?.kind + val limits = limitsFor(kind, temp.unit) + ?: return Decision.Reject("Unsupported temperature unit for heat safety limits.") + + val label = body?.displayName ?: "Body" + if (temp.value < limits.min || temp.value > limits.max) { + return Decision.Reject( + "$label temperature ${temp.format()} is out of range " + + "(${limits.min}–${limits.max}${temp.unit.symbol()}). " + + "WestCurrent will not clamp this value — enter a valid temperature." + ) + } + + if (kind == BodyKind.SPA && + temp.value >= limits.elevatedThreshold && + !command.elevatedHeatConfirmed + ) { + return Decision.RequireConfirmation( + "Spa setpoint ${temp.format()} is elevated " + + "(≥ ${limits.elevatedThreshold}${temp.unit.symbol()}). " + + "Confirm only if you intend to heat the spa this high." + ) + } + + return Decision.Allow + } + + private fun validateLight(command: DomainCommand, context: Context): Decision { + val programId = command.lightProgramId + ?: return Decision.Reject("Light command is missing a program id.") + val targetId = command.lightTargetId + ?: return Decision.Reject("Light command is missing a light target id.") + + if (targetId !in context.capabilities.lightTargets) { + return Decision.Reject("Light target is unknown or not controllable on this system.") + } + if (programId !in context.capabilities.lightPrograms) { + return Decision.Reject( + "Light program is not available on this system (or lighting is not supported)." + ) + } + + val target = context.snapshot?.lightTarget(targetId) + if (target != null && programId !in target.programIds) { + return Decision.Reject( + "Program is not available for light target \"${target.displayName}\"." + ) + } + + val program = context.snapshot?.lightProgram(programId) + if (program != null && program.targetId != targetId) { + return Decision.Reject( + "Program \"${program.displayName}\" does not belong to the selected light target." + ) + } + + return Decision.Allow + } + + private fun isDuplicate(a: DomainCommand, b: DomainCommand): Boolean { + if (a.kind != b.kind) return false + return when (a.kind) { + DomainCommandKind.SET_CIRCUIT -> + a.circuitId == b.circuitId && a.circuitOn == b.circuitOn + DomainCommandKind.SET_HEAT_SETPOINT -> + a.bodyId == b.bodyId && a.temperature == b.temperature + DomainCommandKind.SET_HEAT_MODE -> + a.bodyId == b.bodyId && a.heatMode == b.heatMode + DomainCommandKind.SEND_LIGHT_PROGRAM -> + a.lightTargetId == b.lightTargetId && a.lightProgramId == b.lightProgramId + } + } + + data class TempLimits(val min: Int, val max: Int, val elevatedThreshold: Int) + + companion object { + // Fahrenheit (legacy EasyTouch-style bounds) + const val SPA_MIN_F = 40 + const val SPA_MAX_F = 104 + const val POOL_MIN_F = 40 + const val POOL_MAX_F = 104 + const val ELEVATED_SPA_F = 100 + + // Celsius equivalents (rounded): 40°F≈4°C, 104°F≈40°C, 100°F≈38°C + const val SPA_MIN_C = 4 + const val SPA_MAX_C = 40 + const val POOL_MIN_C = 4 + const val POOL_MAX_C = 40 + const val ELEVATED_SPA_C = 38 + + fun limitsFor(kind: BodyKind?, unit: TemperatureUnit): TempLimits? { + return when (unit) { + TemperatureUnit.FAHRENHEIT -> when (kind) { + BodyKind.SPA -> TempLimits(SPA_MIN_F, SPA_MAX_F, ELEVATED_SPA_F) + BodyKind.POOL, BodyKind.OTHER, null -> + TempLimits(POOL_MIN_F, POOL_MAX_F, ELEVATED_SPA_F) + } + TemperatureUnit.CELSIUS -> when (kind) { + BodyKind.SPA -> TempLimits(SPA_MIN_C, SPA_MAX_C, ELEVATED_SPA_C) + BodyKind.POOL, BodyKind.OTHER, null -> + TempLimits(POOL_MIN_C, POOL_MAX_C, ELEVATED_SPA_C) + } + TemperatureUnit.UNKNOWN -> null + } + } + } +} + +private fun TemperatureUnit.label(): String = when (this) { + TemperatureUnit.FAHRENHEIT -> "Fahrenheit" + TemperatureUnit.CELSIUS -> "Celsius" + TemperatureUnit.UNKNOWN -> "unknown" +} + +private fun TemperatureUnit.symbol(): String = when (this) { + TemperatureUnit.FAHRENHEIT -> "°F" + TemperatureUnit.CELSIUS -> "°C" + TemperatureUnit.UNKNOWN -> "°?" +} diff --git a/app/src/main/java/com/pentaircontrol/pool/protocol/Discovery.kt b/app/src/main/java/com/pentaircontrol/pool/protocol/Discovery.kt index 6f9e63c..6aaa714 100644 --- a/app/src/main/java/com/pentaircontrol/pool/protocol/Discovery.kt +++ b/app/src/main/java/com/pentaircontrol/pool/protocol/Discovery.kt @@ -7,15 +7,12 @@ import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress import java.net.SocketTimeoutException -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.charset.Charset /** * Discovers ScreenLogic protocol adapters on the local subnet via UDP broadcast on port 1444. + * Packet parsing is delegated to [ScreenLogicDiscoveryParser] (community-aligned layout). */ object Discovery { - private val ASCII = Charset.forName("US-ASCII") private const val SEARCH_MS = 4000 suspend fun search(timeoutMs: Int = SEARCH_MS): List = withContext(Dispatchers.IO) { @@ -55,43 +52,18 @@ object Discovery { found.values.toList() } - /** - * Parse a discovery response. Layout (community protocol): - * type(int), port(short), gatewayType(byte), gatewaySubtype(byte), name(string...) - * Implementations vary slightly by firmware; we are best-effort. - */ - private fun parseResponse(packet: DatagramPacket): GatewayInfo? { - if (packet.length < 8) return null + internal fun parseResponse(packet: DatagramPacket): GatewayInfo? { val address = packet.address?.hostAddress ?: return null - val data = packet.data.copyOf(packet.length) - return try { - val bb = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) - val type = bb.int - val port = bb.short.toInt() and 0xFFFF - val gatewayType = bb.get().toInt() and 0xFF - val gatewaySubtype = bb.get().toInt() and 0xFF - val nameBytes = ByteArray(maxOf(0, data.size - bb.position())) - if (nameBytes.isNotEmpty()) { - bb.get(nameBytes) - } - val name = nameBytes.toString(ASCII).trim { it <= ' ' || it == '\u0000' } - .ifBlank { "Pentair: $address" } - - GatewayInfo( - name = name, - address = address, - port = if (port in 1..65535) port else MessageCodes.DEFAULT_TCP_PORT, - type = type, - gatewayType = gatewayType, - gatewaySubtype = gatewaySubtype - ) - } catch (_: Exception) { - GatewayInfo( - name = "ScreenLogic @ $address", - address = address, - port = MessageCodes.DEFAULT_TCP_PORT - ) - } + val unit = ScreenLogicDiscoveryParser.parse(packet.data, packet.length, address) + ?: return null + return GatewayInfo( + name = unit.name, + address = unit.address, + port = unit.port, + type = unit.type, + gatewayType = unit.gatewayType, + gatewaySubtype = unit.gatewaySubtype + ) } /** diff --git a/app/src/main/java/com/pentaircontrol/pool/protocol/ScreenLogicDiscoveryParser.kt b/app/src/main/java/com/pentaircontrol/pool/protocol/ScreenLogicDiscoveryParser.kt new file mode 100644 index 0000000..0158a52 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/protocol/ScreenLogicDiscoveryParser.kt @@ -0,0 +1,63 @@ +package com.pentaircontrol.pool.protocol + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.Charset + +/** + * Pure discovery response parser aligned with community clients (node-screenlogic FindUnits): + * + * Layout (little-endian): + * - int32 type at offset 0 (must be 2 for ScreenLogic unit) + * - int16 port at offset 8 + * - uint8 gatewayType at 10 + * - uint8 gatewaySubtype at 11 + * - name bytes from 12 (typically through ~29, null-padded) + * + * Bytes 4–7 are reserved / type-adjacent fields and must NOT be read as the port. + */ +object ScreenLogicDiscoveryParser { + private val ASCII = Charset.forName("US-ASCII") + const val TYPE_SCREENLOGIC = 2 + const val DEFAULT_PORT = 80 + + data class ParsedUnit( + val type: Int, + val port: Int, + val gatewayType: Int, + val gatewaySubtype: Int, + val name: String, + val address: String + ) + + fun parse(packetData: ByteArray, length: Int, address: String): ParsedUnit? { + if (length < 12 || address.isBlank()) return null + val data = if (packetData.size == length) packetData else packetData.copyOf(length) + return try { + val bb = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + val type = bb.getInt(0) + if (type != TYPE_SCREENLOGIC) return null + + val portRaw = bb.getShort(8).toInt() and 0xFFFF + val port = if (portRaw in 1..65535) portRaw else DEFAULT_PORT + val gatewayType = bb.get(10).toInt() and 0xFF + val gatewaySubtype = bb.get(11).toInt() and 0xFF + val nameEnd = minOf(data.size, 29) + val nameBytes = if (nameEnd > 12) data.copyOfRange(12, nameEnd) else ByteArray(0) + val name = nameBytes.toString(ASCII) + .trim { it <= ' ' || it == '\u0000' } + .ifBlank { "ScreenLogic @ $address" } + + ParsedUnit( + type = type, + port = port, + gatewayType = gatewayType, + gatewaySubtype = gatewaySubtype, + name = name, + address = address + ) + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/session/AdapterCleanupScheduler.kt b/app/src/main/java/com/pentaircontrol/pool/session/AdapterCleanupScheduler.kt new file mode 100644 index 0000000..6550c85 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/session/AdapterCleanupScheduler.kt @@ -0,0 +1,78 @@ +package com.pentaircontrol.pool.session + +import com.pentaircontrol.pool.domain.PoolSystemAdapter +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Best-effort asynchronous adapter disposal after session invalidation. + * + * Per-session instance (not a process-global scope). Disconnect work is bounded by + * [timeoutMs] and cannot install or reactivate adapters. Tests inject a controllable + * dispatcher and can [awaitIdle]. + */ +class AdapterCleanupScheduler( + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val timeoutMs: Long = DEFAULT_TIMEOUT_MS +) { + private val rootJob = SupervisorJob() + private val scope = CoroutineScope(rootJob + dispatcher) + private val activeJobs = ConcurrentHashMap.newKeySet() + private val scheduledCount = AtomicInteger(0) + private val completedCount = AtomicInteger(0) + + fun scheduleDisconnect(adapter: PoolSystemAdapter?) { + if (adapter == null) return + scheduledCount.incrementAndGet() + val job = scope.launch { + try { + withTimeout(timeoutMs) { + try { + adapter.disconnect() + } catch (_: Exception) { + } + } + } catch (_: TimeoutCancellationException) { + // Bounded: abandon after timeout. + } catch (_: Exception) { + } finally { + completedCount.incrementAndGet() + } + } + activeJobs.add(job) + job.invokeOnCompletion { activeJobs.remove(job) } + } + + /** Wait for all currently scheduled cleanups (tests). */ + suspend fun awaitIdle() { + while (true) { + val snapshot = activeJobs.toList() + if (snapshot.isEmpty()) return + snapshot.forEach { it.join() } + } + } + + fun scheduledCount(): Int = scheduledCount.get() + fun completedCount(): Int = completedCount.get() + + /** + * Cancel outstanding cleanups. Called only when the owning session is permanently + * done and tests need isolation — not required for normal close (work is bounded). + */ + fun shutdown() { + rootJob.cancel() + activeJobs.clear() + } + + companion object { + const val DEFAULT_TIMEOUT_MS = 5_000L + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/session/ReconnectInstrumentation.kt b/app/src/main/java/com/pentaircontrol/pool/session/ReconnectInstrumentation.kt new file mode 100644 index 0000000..1eb0818 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/session/ReconnectInstrumentation.kt @@ -0,0 +1,42 @@ +package com.pentaircontrol.pool.session + +import java.util.concurrent.atomic.AtomicInteger + +/** + * Optional counters for deterministic reconnect concurrency tests. + * Production uses a no-op instance. + */ +class ReconnectInstrumentation { + val reconnectCycleStarts = AtomicInteger(0) + val concurrentReconnect = AtomicInteger(0) + val maxConcurrentReconnect = AtomicInteger(0) + val adapterCreations = AtomicInteger(0) + val connectCalls = AtomicInteger(0) + val successfulInstalls = AtomicInteger(0) + + fun onReconnectCycleStart() { + reconnectCycleStarts.incrementAndGet() + val now = concurrentReconnect.incrementAndGet() + maxConcurrentReconnect.updateAndGet { maxOf(it, now) } + } + + fun onReconnectCycleEnd() { + concurrentReconnect.updateAndGet { (it - 1).coerceAtLeast(0) } + } + + fun onAdapterCreated() { + adapterCreations.incrementAndGet() + } + + fun onConnectCall() { + connectCalls.incrementAndGet() + } + + fun onSuccessfulInstall() { + successfulInstalls.incrementAndGet() + } + + companion object { + val NOOP = ReconnectInstrumentation() + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/session/ReconnectPolicy.kt b/app/src/main/java/com/pentaircontrol/pool/session/ReconnectPolicy.kt new file mode 100644 index 0000000..36fe978 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/session/ReconnectPolicy.kt @@ -0,0 +1,55 @@ +package com.pentaircontrol.pool.session + +/** + * Bounded reconnection timing. Production defaults use multi-second backoff; + * tests inject [FAST_TEST] (or a custom policy with zero/near-zero delays). + */ +data class ReconnectPolicy( + /** Consecutive refresh failures required before starting a reconnect cycle. */ + val failuresBeforeReconnect: Int = 2, + /** Maximum connect attempts in one automatic reconnect cycle. */ + val maxAttempts: Int = 4, + /** + * Delay **before** attempt [attemptIndex] (0-based). + * Attempt 0 is typically immediate (0 ms). + * Values are clamped to [maxBackoffMs]. + */ + val backoffBeforeAttemptMs: (attemptIndex: Int) -> Long = PRODUCTION_BACKOFF, + /** Hard upper bound for any single backoff delay. */ + val maxBackoffMs: Long = 30_000L +) { + fun delayBeforeAttempt(attemptIndex: Int): Long { + val raw = backoffBeforeAttemptMs(attemptIndex).coerceAtLeast(0L) + return raw.coerceAtMost(maxBackoffMs) + } + + companion object { + /** + * Production: after 2 consecutive refresh failures, + * attempt 1 immediate, then ~2s, ~5s, ~10s (4 attempts total). + */ + val PRODUCTION_BACKOFF: (Int) -> Long = { attempt -> + when (attempt) { + 0 -> 0L + 1 -> 2_000L + 2 -> 5_000L + else -> 10_000L + } + } + + val PRODUCTION = ReconnectPolicy( + failuresBeforeReconnect = 2, + maxAttempts = 4, + backoffBeforeAttemptMs = PRODUCTION_BACKOFF, + maxBackoffMs = 30_000L + ) + + /** Near-zero delays for deterministic unit tests. */ + val FAST_TEST = ReconnectPolicy( + failuresBeforeReconnect = 2, + maxAttempts = 3, + backoffBeforeAttemptMs = { attempt -> if (attempt <= 0) 0L else 1L }, + maxBackoffMs = 50L + ) + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt b/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt new file mode 100644 index 0000000..2144f65 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt @@ -0,0 +1,729 @@ +package com.pentaircontrol.pool.session + +import androidx.annotation.VisibleForTesting +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicAdapter +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLocalTarget +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget +import com.pentaircontrol.pool.adapter.simulator.SimulatorAdapter +import com.pentaircontrol.pool.adapter.simulator.SimulatorConnectionTarget +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.CommandOrchestrator +import com.pentaircontrol.pool.domain.CommandReceipt +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.CircuitId +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.domain.DomainCommand +import com.pentaircontrol.pool.domain.DomainCommandKind +import com.pentaircontrol.pool.domain.HeatMode +import com.pentaircontrol.pool.domain.LightProgramId +import com.pentaircontrol.pool.domain.LightTargetId +import com.pentaircontrol.pool.domain.PoolSystemAdapter +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.SystemCapabilities +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.settings.AppPreferencesStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +/** + * Application session: owns active adapter, last-known snapshot, connection presentation, + * and command orchestration. + * + * ## Lifecycle (Phase 1.4) + * Construct with a scope owned by the caller (typically [androidx.lifecycle.viewModelScope]). + * [close] is **non-blocking**: it synchronously invalidates the session and schedules + * best-effort adapter disposal on [cleanup] without blocking the caller thread. + * + * ## Connection generation + * A monotonic [connectionGeneration] is bumped on disconnect, close, and new-target connect. + * Reconnect/retry cycles capture the generation and refuse to install if it no longer matches. + * + * ## Reconnect / retry ownership + * Automatic reconnect and explicit retry share one [reconnectJob] + single-flight flag. + * Disconnect/close cancel that job so stale connects cannot install. + */ +class SiteSession( + private val scope: CoroutineScope, + private val settings: AppPreferencesStore, + private val screenLogicFactory: () -> PoolSystemAdapter = { ScreenLogicAdapter() }, + private val simulatorFactory: () -> SimulatorAdapter = { SimulatorAdapter() }, + private val orchestrator: CommandOrchestrator = CommandOrchestrator(), + private val pollIntervalMs: Long = 5_000L, + private val reconnectPolicy: ReconnectPolicy = ReconnectPolicy.PRODUCTION, + private val instrumentation: ReconnectInstrumentation = ReconnectInstrumentation.NOOP, + private val cleanup: AdapterCleanupScheduler = AdapterCleanupScheduler() +) { + private val installMutex = Mutex() + private val adapterRef = AtomicReference(null) + private var pollJob: Job? = null + /** Owns both automatic reconnect and explicit retry. */ + private var reconnectJob: Job? = null + private val reconnectInFlight = AtomicBoolean(false) + private val closed = AtomicBoolean(false) + + /** + * Monotonic generation. Bumped on disconnect, close, and new connection target selection. + * Install only when the cycle's captured generation still matches. + */ + private val connectionGeneration = AtomicInteger(0) + + private var lastLivePath: ConnectionPath = ConnectionPath.UNKNOWN + + /** In-memory only — never persisted. Cleared on disconnect/close. */ + private val activeTargetRef = AtomicReference(null) + private val activeFactoryRef = AtomicReference<(() -> PoolSystemAdapter)?>(null) + private var consecutiveRefreshFailures: Int = 0 + + /** + * User-confirmed temperature unit for the **current** connection session. + * Survives reconnect to the same target; cleared on disconnect, close, or new target. + */ + private val confirmedTemperatureUnit = AtomicReference(null) + + private val lastReconnectAttempts = AtomicInteger(0) + + private val _connection = MutableStateFlow(ConnectionStatus.Disconnected) + val connection: StateFlow = _connection.asStateFlow() + + private val _site = MutableStateFlow(null) + val site: StateFlow = _site.asStateFlow() + + private val _gateways = MutableStateFlow>(emptyList()) + val gateways: StateFlow> = _gateways.asStateFlow() + + val commandReceipts: StateFlow> = orchestrator.receipts + + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError.asStateFlow() + + private val _lastReject = MutableStateFlow(null) + val lastReject: StateFlow = _lastReject.asStateFlow() + + private val _pendingConfirmation = MutableStateFlow(null) + val pendingConfirmation: StateFlow = _pendingConfirmation.asStateFlow() + + private val _canRetryConnection = MutableStateFlow(false) + val canRetryConnection: StateFlow = _canRetryConnection.asStateFlow() + + data class PendingHeatConfirmation( + val bodyId: BodyId, + val temperature: Temperature, + val reason: String + ) + + init { + settings.clearSensitiveConnectionKeys() + } + + fun isClosed(): Boolean = closed.get() + + /** + * Idempotent, **non-blocking** teardown for [androidx.lifecycle.ViewModel.onCleared]. + * + * Synchronously: marks closed, bumps generation, cancels poll/reconnect/retry, + * clears credentials/target/unit/receipts, detaches adapter reference, publishes Disconnected. + * Does **not** wait on mutex, network, or adapter disconnect. + * Best-effort [PoolSystemAdapter.disconnect] runs asynchronously via [cleanup]. + */ + fun close() { + if (!closed.compareAndSet(false, true)) return + invalidateGeneration() + cancelPoll() + cancelReconnectOwned() + _canRetryConnection.value = false + val old = adapterRef.getAndSet(null) + clearCredentialsSync() + orchestrator.clear() + _pendingConfirmation.value = null + _connection.value = ConnectionStatus.Disconnected + cleanup.scheduleDisconnect(old) + } + + suspend fun discoverLocal() { + if (closed.get()) { + _connection.value = ConnectionStatus.ConnectionFailed(CLOSED_MESSAGE) + return + } + _connection.value = ConnectionStatus.Connecting + _lastError.value = null + try { + val sl = screenLogicFactory() + val found = sl.discoverGateways() + _gateways.value = found + if (found.isEmpty()) { + _connection.value = ConnectionStatus.ConnectionFailed( + "No ScreenLogic gateways found on this Wi‑Fi. " + + "Use Remote Connect, manual IP, or Simulator." + ) + } else { + _connection.value = ConnectionStatus.Disconnected + } + } catch (e: Exception) { + _connection.value = ConnectionStatus.ConnectionFailed( + e.message ?: "Discovery failed" + ) + } + } + + suspend fun connectLocal(gateway: DiscoveredGateway, password: String = "") { + connect(ScreenLogicLocalTarget(gateway, password), screenLogicFactory) + } + + suspend fun connectManual(ip: String, port: Int, password: String = "") { + val gw = DiscoveredGateway( + id = "$ip:$port", + displayName = "Manual $ip", + address = ip.trim(), + port = port, + path = ConnectionPath.LOCAL_LAN + ) + connectLocal(gw, password) + } + + suspend fun connectRemote(systemCode: String, password: String = "") { + connect( + ScreenLogicRemoteTarget(systemCode.trim(), password), + screenLogicFactory + ) + } + + suspend fun connectSimulator(profileName: String = "Demo EasyTouch") { + connect( + SimulatorConnectionTarget(profileName), + { simulatorFactory() } + ) + } + + suspend fun connectWithFactory( + target: AdapterConnectionTarget, + factory: () -> PoolSystemAdapter + ) { + connect(target, factory) + } + + suspend fun connectWithAdapter(target: AdapterConnectionTarget, newAdapter: PoolSystemAdapter) { + connect(target) { newAdapter } + } + + private suspend fun connect( + target: AdapterConnectionTarget, + factory: () -> PoolSystemAdapter + ) { + if (closed.get()) { + _connection.value = ConnectionStatus.ConnectionFailed(CLOSED_MESSAGE) + _lastError.value = CLOSED_MESSAGE + return + } + + // New target selection: invalidate any in-flight reconnect/retry for prior generation. + val gen = invalidateGeneration() + cancelPoll() + cancelReconnectOwned() + val previous = adapterRef.getAndSet(null) + cleanup.scheduleDisconnect(previous) + + orchestrator.clear() + _lastError.value = null + _lastReject.value = null + _canRetryConnection.value = false + consecutiveRefreshFailures = 0 + // New target starts a new unit context (do not inherit prior system's unit). + confirmedTemperatureUnit.set(null) + activeTargetRef.set(target) + activeFactoryRef.set(factory) + _connection.value = ConnectionStatus.Connecting + + instrumentation.onAdapterCreated() + val newAdapter = factory() + try { + instrumentation.onConnectCall() + newAdapter.connect(target) + var snap = newAdapter.snapshot.value ?: newAdapter.refresh() + snap = applyConfirmedUnitIfPresent(newAdapter, snap) + if (!tryInstallAdapter(gen, target, newAdapter, snap)) { + cleanup.scheduleDisconnect(newAdapter) + if (closed.get()) { + _connection.value = ConnectionStatus.Disconnected + } else if (connectionGeneration.get() != gen) { + // Superseded by disconnect/close/another connect — leave their state. + } else { + _connection.value = ConnectionStatus.ConnectionFailed( + "Connection was canceled." + ) + } + return + } + } catch (e: Exception) { + cleanup.scheduleDisconnect(newAdapter) + if (closed.get() || connectionGeneration.get() != gen) return + val status = newAdapter.connection.value + _connection.value = when (status) { + is ConnectionStatus.AuthenticationRequired -> status + is ConnectionStatus.ConnectionFailed -> status + else -> ConnectionStatus.ConnectionFailed( + e.message ?: "Connection failed" + ) + } + _lastError.value = e.message + } + } + + fun confirmSessionTemperatureUnit(unit: TemperatureUnit) { + if (closed.get()) return + if (unit == TemperatureUnit.UNKNOWN) return + confirmedTemperatureUnit.set(unit) + val a = adapterRef.get() + if (a is ScreenLogicAdapter) { + a.confirmTemperatureUnit(unit) + } + _site.value = _site.value?.withConfirmedUnit(unit) + } + + fun sessionConfirmedTemperatureUnit(): TemperatureUnit? = confirmedTemperatureUnit.get() + + /** + * Invalidates the current generation, cancels reconnect/retry, stops poll, + * clears credentials. Adapter dispose is asynchronous. + */ + suspend fun disconnect() { + if (closed.get()) return + invalidateGeneration() + cancelPoll() + cancelReconnectOwned() + val old = adapterRef.getAndSet(null) + clearCredentialsSync() + consecutiveRefreshFailures = 0 + orchestrator.clear() + _canRetryConnection.value = false + _pendingConfirmation.value = null + _connection.value = ConnectionStatus.Disconnected + cleanup.scheduleDisconnect(old) + } + + /** + * Explicit retry after automatic reconnect exhaustion. + * Shares [reconnectJob] with automatic reconnect (single-flight, cancelable by disconnect/close). + */ + suspend fun retryConnection(): Boolean { + if (closed.get()) { + _lastError.value = CLOSED_MESSAGE + return false + } + if (!reconnectInFlight.compareAndSet(false, true)) { + return false + } + val target = activeTargetRef.get() + val factory = activeFactoryRef.get() + if (target == null || factory == null) { + reconnectInFlight.set(false) + _lastError.value = "No saved connection for retry. Connect again." + _canRetryConnection.value = false + return false + } + val gen = connectionGeneration.get() + _canRetryConnection.value = false + _connection.value = ConnectionStatus.Reconnecting + + val result = CompletableDeferred() + reconnectJob = scope.launch { + try { + instrumentation.onReconnectCycleStart() + try { + val ok = attemptConnectCycle( + target = target, + factory = factory, + generation = gen, + maxAttempts = reconnectPolicy.maxAttempts, + recordAttempts = true + ) + result.complete(ok) + } finally { + instrumentation.onReconnectCycleEnd() + } + } catch (e: CancellationException) { + result.complete(false) + throw e + } catch (_: Exception) { + result.complete(false) + } finally { + reconnectInFlight.set(false) + } + } + return try { + result.await() + } catch (_: CancellationException) { + false + } + } + + suspend fun refreshNow() { + if (closed.get()) return + val a = adapterRef.get() ?: return + try { + val snap = a.refresh() + if (closed.get()) return + _site.value = applyConfirmedUnitIfPresent(a, snap) + consecutiveRefreshFailures = 0 + _connection.value = a.connection.value + } catch (e: Exception) { + handleRefreshFailure(e.message ?: "Refresh failed") + } + } + + suspend fun toggleCircuit(circuitId: CircuitId, currentlyOn: Boolean): CommandOrchestrator.SubmitResult { + if (closed.get()) return closedReject() + return submit( + DomainCommand( + id = UUID.randomUUID().toString(), + kind = DomainCommandKind.SET_CIRCUIT, + circuitId = circuitId, + circuitOn = !currentlyOn + ) + ) + } + + suspend fun setHeatSetPoint( + bodyId: BodyId, + temperature: Temperature, + elevatedConfirmed: Boolean = false + ): CommandOrchestrator.SubmitResult { + if (closed.get()) return closedReject() + val result = submit( + DomainCommand( + id = UUID.randomUUID().toString(), + kind = DomainCommandKind.SET_HEAT_SETPOINT, + bodyId = bodyId, + temperature = temperature, + elevatedHeatConfirmed = elevatedConfirmed + ) + ) + if (result.requiresConfirmation) { + _pendingConfirmation.value = PendingHeatConfirmation( + bodyId = bodyId, + temperature = temperature, + reason = result.confirmationReason ?: "Confirmation required" + ) + } else { + _pendingConfirmation.value = null + } + return result + } + + fun clearPendingConfirmation() { + _pendingConfirmation.value = null + } + + suspend fun setHeatMode(bodyId: BodyId, mode: HeatMode): CommandOrchestrator.SubmitResult { + if (closed.get()) return closedReject() + return submit( + DomainCommand( + id = UUID.randomUUID().toString(), + kind = DomainCommandKind.SET_HEAT_MODE, + bodyId = bodyId, + heatMode = mode + ) + ) + } + + suspend fun sendLightProgram( + programId: LightProgramId, + targetId: LightTargetId? = null + ): CommandOrchestrator.SubmitResult { + if (closed.get()) return closedReject() + val resolvedTarget = targetId + ?: _site.value?.lightProgram(programId)?.targetId + return submit( + DomainCommand( + id = UUID.randomUUID().toString(), + kind = DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = programId, + lightTargetId = resolvedTarget + ) + ) + } + + private fun closedReject() = CommandOrchestrator.SubmitResult( + rejectedReason = CLOSED_MESSAGE + ) + + private fun activeCapabilities(): SystemCapabilities { + return _site.value?.capabilities ?: SystemCapabilities() + } + + private suspend fun submit(command: DomainCommand): CommandOrchestrator.SubmitResult { + val a = adapterRef.get() + val result = orchestrator.submit( + commandInput = command, + connection = _connection.value, + adapter = a, + currentSnapshot = _site.value, + capabilities = activeCapabilities() + ) + if (result.rejectedReason != null) { + _lastReject.value = result.rejectedReason + } + if (a != null && _connection.value.isLiveConnected && !closed.get()) { + try { + val snap = a.snapshot.value ?: a.refresh() + _site.value = applyConfirmedUnitIfPresent(a, snap) + _connection.value = a.connection.value + } catch (_: Exception) { + } + } + return result + } + + private fun startPolling(forGeneration: Int) { + if (closed.get()) return + cancelPoll() + pollJob = scope.launch { + while (isActive && !closed.get() && connectionGeneration.get() == forGeneration) { + delay(pollIntervalMs) + if (closed.get() || reconnectInFlight.get()) continue + if (connectionGeneration.get() != forGeneration) break + val status = _connection.value + if (status.isLiveConnected || status is ConnectionStatus.StaleOrDegraded) { + try { + val a = adapterRef.get() ?: continue + val snap = a.refresh() + if (closed.get() || connectionGeneration.get() != forGeneration) break + _site.value = applyConfirmedUnitIfPresent(a, snap) + consecutiveRefreshFailures = 0 + _connection.value = a.connection.value + } catch (e: Exception) { + if (!closed.get() && connectionGeneration.get() == forGeneration) { + handleRefreshFailure(e.message ?: "Lost equipment connection") + } + } + } + } + } + } + + private fun handleRefreshFailure(reason: String) { + if (closed.get()) return + consecutiveRefreshFailures++ + _connection.value = ConnectionStatus.StaleOrDegraded( + lastPath = lastLivePath, + reason = reason, + lastSuccessAtMs = _site.value?.asOfMs + ) + _lastError.value = reason + if (consecutiveRefreshFailures >= reconnectPolicy.failuresBeforeReconnect) { + scheduleReconnect() + } + } + + private fun scheduleReconnect() { + if (closed.get()) return + if (!reconnectInFlight.compareAndSet(false, true)) { + return + } + val gen = connectionGeneration.get() + val target = activeTargetRef.get() + val factory = activeFactoryRef.get() + if (target == null || factory == null) { + reconnectInFlight.set(false) + return + } + reconnectJob = scope.launch { + try { + if (closed.get() || connectionGeneration.get() != gen) return@launch + instrumentation.onReconnectCycleStart() + try { + performBoundedReconnect(gen, target, factory) + } finally { + instrumentation.onReconnectCycleEnd() + } + } finally { + reconnectInFlight.set(false) + } + } + } + + private suspend fun performBoundedReconnect( + generation: Int, + target: AdapterConnectionTarget, + factory: () -> PoolSystemAdapter + ) { + if (closed.get() || connectionGeneration.get() != generation) return + if (activeTargetRef.get() !== target) return + + _connection.value = ConnectionStatus.Reconnecting + _canRetryConnection.value = false + + // Detach failed adapter without bumping generation (same connection session). + val failed = adapterRef.getAndSet(null) + cleanup.scheduleDisconnect(failed) + + val ok = attemptConnectCycle( + target = target, + factory = factory, + generation = generation, + maxAttempts = reconnectPolicy.maxAttempts, + recordAttempts = true + ) + if (!ok && !closed.get() && connectionGeneration.get() == generation) { + _canRetryConnection.value = + activeTargetRef.get() != null && activeFactoryRef.get() != null + } + } + + /** + * @return true if a live adapter was installed for [generation] + */ + private suspend fun attemptConnectCycle( + target: AdapterConnectionTarget, + factory: () -> PoolSystemAdapter, + generation: Int, + maxAttempts: Int, + recordAttempts: Boolean + ): Boolean { + var lastError: String? = null + var attempts = 0 + for (attempt in 0 until maxAttempts) { + if (!isGenerationLive(generation, target)) return false + val delayMs = reconnectPolicy.delayBeforeAttempt(attempt) + if (delayMs > 0) delay(delayMs) + if (!isGenerationLive(generation, target)) return false + attempts++ + _connection.value = ConnectionStatus.Reconnecting + instrumentation.onAdapterCreated() + val fresh = factory() + try { + instrumentation.onConnectCall() + fresh.connect(target) + var snap = fresh.snapshot.value ?: fresh.refresh() + snap = applyConfirmedUnitIfPresent(fresh, snap) + if (!tryInstallAdapter(generation, target, fresh, snap)) { + cleanup.scheduleDisconnect(fresh) + return false + } + if (recordAttempts) lastReconnectAttempts.set(attempts) + return true + } catch (e: CancellationException) { + cleanup.scheduleDisconnect(fresh) + throw e + } catch (e: Exception) { + lastError = e.message + cleanup.scheduleDisconnect(fresh) + } + } + if (recordAttempts) lastReconnectAttempts.set(attempts) + if (isGenerationLive(generation, target)) { + _connection.value = ConnectionStatus.ConnectionFailed( + "Reconnection failed after $maxAttempts attempts" + + (lastError?.let { ": $it" } ?: ".") + ) + _lastError.value = lastError + } + return false + } + + private fun isGenerationLive(generation: Int, target: AdapterConnectionTarget): Boolean { + return !closed.get() && + connectionGeneration.get() == generation && + activeTargetRef.get() === target + } + + /** + * Install only if session is open, generation matches, and target is still active. + * @return true if installed + */ + private suspend fun tryInstallAdapter( + generation: Int, + target: AdapterConnectionTarget, + fresh: PoolSystemAdapter, + snap: SiteSnapshot + ): Boolean { + if (!isGenerationLive(generation, target)) return false + return installMutex.withLock { + if (!isGenerationLive(generation, target)) return@withLock false + adapterRef.set(fresh) + _site.value = snap + consecutiveRefreshFailures = 0 + lastLivePath = target.path + _connection.value = fresh.connection.value + _canRetryConnection.value = false + instrumentation.onSuccessfulInstall() + startPolling(forGeneration = generation) + true + } + } + + private fun applyConfirmedUnitIfPresent( + adapter: PoolSystemAdapter, + snap: SiteSnapshot + ): SiteSnapshot { + val unit = confirmedTemperatureUnit.get() ?: return snap + if (adapter is ScreenLogicAdapter) { + adapter.confirmTemperatureUnit(unit) + } + return snap.withConfirmedUnit(unit) + } + + private fun clearCredentialsSync() { + activeTargetRef.set(null) + activeFactoryRef.set(null) + confirmedTemperatureUnit.set(null) + } + + private fun invalidateGeneration(): Int = connectionGeneration.incrementAndGet() + + private fun cancelPoll() { + pollJob?.cancel() + pollJob = null + } + + private fun cancelReconnectOwned() { + reconnectJob?.cancel() + reconnectJob = null + reconnectInFlight.set(false) + } + + fun isReconnectInFlight(): Boolean = reconnectInFlight.get() + + fun isPollingActive(): Boolean = pollJob?.isActive == true + + @VisibleForTesting + fun lastAutomaticReconnectAttempts(): Int = lastReconnectAttempts.get() + + @VisibleForTesting + fun connectionGenerationForTest(): Int = connectionGeneration.get() + + @VisibleForTesting + fun activeConnectionTargetForTest(): AdapterConnectionTarget? = activeTargetRef.get() + + @VisibleForTesting + fun adapterForTest(): PoolSystemAdapter? = adapterRef.get() + + @VisibleForTesting + suspend fun awaitCleanupIdle() = cleanup.awaitIdle() + + @VisibleForTesting + fun cleanupScheduledCount(): Int = cleanup.scheduledCount() + + @VisibleForTesting + fun cleanupCompletedCount(): Int = cleanup.completedCount() + + companion object { + const val CLOSED_MESSAGE = "Session is closed." + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/settings/AndroidAppPreferencesStore.kt b/app/src/main/java/com/pentaircontrol/pool/settings/AndroidAppPreferencesStore.kt new file mode 100644 index 0000000..5242406 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/settings/AndroidAppPreferencesStore.kt @@ -0,0 +1,39 @@ +package com.pentaircontrol.pool.settings + +import android.content.Context +import android.content.SharedPreferences + +/** + * Android app-private SharedPreferences store. + * + * **Not encrypted.** MODE_PRIVATE only prevents other apps from reading the file; + * it does not encrypt contents at rest. + * + * On construction, clears stale system-code preference keys from earlier builds. + */ +class AndroidAppPreferencesStore(context: Context) : AppPreferencesStore { + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences( + "westcurrent_app_prefs", + Context.MODE_PRIVATE + ) + + init { + clearSensitiveConnectionKeys() + } + + override fun getString(key: String): String? = prefs.getString(key, null) + + override fun putString(key: String, value: String?) { + prefs.edit().apply { + if (value == null) remove(key) else putString(key, value) + }.apply() + } + + override fun remove(key: String) { + prefs.edit().remove(key).apply() + } +} + +/** @deprecated Use [AndroidAppPreferencesStore]. */ +typealias AndroidSecureSettingsStore = AndroidAppPreferencesStore diff --git a/app/src/main/java/com/pentaircontrol/pool/settings/AppPreferencesStore.kt b/app/src/main/java/com/pentaircontrol/pool/settings/AppPreferencesStore.kt new file mode 100644 index 0000000..96a9d31 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/settings/AppPreferencesStore.kt @@ -0,0 +1,63 @@ +package com.pentaircontrol.pool.settings + +/** + * App-private preference store. **Not encrypted.** + * + * Values are stored in MODE_PRIVATE SharedPreferences on Android (or in-memory for tests). + * That is process/user sandbox isolation, not encryption at rest. + * + * Phase 1.2: system codes and passwords are **never** persisted. Only non-sensitive display / + * preference flags may live here. Connection targets and remote passwords remain in-process + * memory for the session / bounded reconnect lifetime only. + * + * Follow-up (future security sprint): Keystore-backed protected storage if identifiers must + * be remembered. + */ +interface AppPreferencesStore { + fun getString(key: String): String? + fun putString(key: String, value: String?) + fun remove(key: String) + + /** + * Remove stale keys that previously stored system codes or remember-flags. + * Safe to call repeatedly. + */ + fun clearSensitiveConnectionKeys() { + remove(STALE_KEY_LAST_SYSTEM_CODE) + remove(STALE_KEY_REMEMBER_SYSTEM_CODE) + remove(STALE_KEY_LAST_PASSWORD) // never should have existed; clear if present + } + + companion object { + /** Stale keys from Phase 1.0/1.1 — cleared and ignored. */ + const val STALE_KEY_LAST_SYSTEM_CODE = "westcurrent.last_system_code" + const val STALE_KEY_REMEMBER_SYSTEM_CODE = "westcurrent.remember_system_code" + const val STALE_KEY_LAST_PASSWORD = "westcurrent.last_password" + + /** Example non-sensitive preference key (display). */ + const val KEY_PREFER_SIMULATOR_HINT = "westcurrent.prefer_simulator_hint" + } +} + +class InMemoryAppPreferencesStore : AppPreferencesStore { + private val map = linkedMapOf() + + override fun getString(key: String): String? = map[key] + + override fun putString(key: String, value: String?) { + if (value == null) map.remove(key) else map[key] = value + } + + override fun remove(key: String) { + map.remove(key) + } + + /** Test helper: inspect raw map (including after stale clear). */ + fun snapshot(): Map = map.toMap() +} + +/** @deprecated Use [AppPreferencesStore] / [InMemoryAppPreferencesStore]. */ +typealias SecureSettingsStore = AppPreferencesStore + +/** @deprecated Use [InMemoryAppPreferencesStore]. */ +typealias InMemorySecureSettingsStore = InMemoryAppPreferencesStore diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt b/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt index 1a7aa2a..372412f 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt @@ -1,5 +1,7 @@ package com.pentaircontrol.pool.ui +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Dashboard @@ -11,6 +13,7 @@ import androidx.compose.material.icons.outlined.Dashboard import androidx.compose.material.icons.outlined.Lightbulb import androidx.compose.material.icons.outlined.Thermostat import androidx.compose.material.icons.outlined.ToggleOn +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -19,7 +22,9 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -30,11 +35,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import com.pentaircontrol.pool.data.ConnectionState -import com.pentaircontrol.pool.data.GatewayInfo -import com.pentaircontrol.pool.data.HeatMode -import com.pentaircontrol.pool.data.LightCommand -import com.pentaircontrol.pool.data.PoolStatus +import androidx.compose.ui.unit.dp +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.CommandLifecycle +import com.pentaircontrol.pool.domain.CommandReceipt +import com.pentaircontrol.pool.domain.CircuitId +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.domain.HeatMode +import com.pentaircontrol.pool.domain.LightProgramId +import com.pentaircontrol.pool.domain.LightTargetId +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.session.SiteSession import com.pentaircontrol.pool.ui.screens.CircuitsScreen import com.pentaircontrol.pool.ui.screens.ConnectScreen import com.pentaircontrol.pool.ui.screens.DashboardScreen @@ -43,6 +57,10 @@ import com.pentaircontrol.pool.ui.screens.LightsScreen import com.pentaircontrol.pool.ui.theme.PoolAccent import com.pentaircontrol.pool.ui.theme.PoolDeep import com.pentaircontrol.pool.ui.theme.PoolSurface +import com.pentaircontrol.pool.ui.theme.PoolWarm +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale private data class Tab( val label: String, @@ -53,21 +71,35 @@ private data class Tab( @OptIn(ExperimentalMaterial3Api::class) @Composable fun PoolApp( - connection: ConnectionState, - status: PoolStatus, - gateways: List, + connection: ConnectionStatus, + site: SiteSnapshot?, + gateways: List, + commandReceipts: List, + statusMessage: String?, + pendingHeatConfirmation: SiteSession.PendingHeatConfirmation?, onDiscover: () -> Unit, - onConnect: (GatewayInfo, String) -> Unit, + onConnect: (DiscoveredGateway, String) -> Unit, onManualConnect: (String, Int, String) -> Unit, onRemoteConnect: (systemCode: String, password: String) -> Unit, - onDemo: () -> Unit, + onSimulator: () -> Unit, onDisconnect: () -> Unit, - onToggleCircuit: (Int, Boolean) -> Unit, - onSetPoint: (Int, Int) -> Unit, - onHeatMode: (Int, HeatMode) -> Unit, - onLight: (LightCommand) -> Unit + onToggleCircuit: (CircuitId, Boolean) -> Unit, + onSetPoint: (BodyId, Temperature) -> Unit, + onHeatMode: (BodyId, HeatMode) -> Unit, + onLightProgram: (LightProgramId, LightTargetId) -> Unit, + onConfirmElevatedHeat: () -> Unit, + onCancelElevatedHeat: () -> Unit, + onConfirmTemperatureUnit: (TemperatureUnit) -> Unit, + canRetryConnection: Boolean = false, + onRetryConnection: () -> Unit = {}, + onClearStatusMessage: () -> Unit ) { - if (connection !is ConnectionState.Connected) { + val showMainShell = connection.isLiveConnected || + connection is ConnectionStatus.StaleOrDegraded || + connection is ConnectionStatus.Reconnecting || + (connection is ConnectionStatus.ConnectionFailed && site != null) + + if (!showMainShell) { ConnectScreen( connection = connection, gateways = gateways, @@ -75,7 +107,7 @@ fun PoolApp( onConnect = onConnect, onManualConnect = onManualConnect, onRemoteConnect = onRemoteConnect, - onDemo = onDemo + onSimulator = onSimulator ) return } @@ -88,18 +120,29 @@ fun PoolApp( Tab("Lights", Icons.Filled.Lightbulb, Icons.Outlined.Lightbulb) ) + val pending = commandReceipts.firstOrNull { it.lifecycle == CommandLifecycle.Pending } + Scaffold( topBar = { TopAppBar( title = { - Text( - when { - connection.isDemo -> "Demo Pool" - connection.gateway.isRemote -> connection.gateway.name - else -> connection.gateway.name - }, - fontWeight = FontWeight.SemiBold - ) + Column { + Text( + when (connection) { + is ConnectionStatus.Simulator -> "WestCurrent · SIMULATOR" + is ConnectionStatus.StaleOrDegraded -> "WestCurrent · STALE" + is ConnectionStatus.Reconnecting -> "WestCurrent · Reconnecting" + is ConnectionStatus.ConnectionFailed -> "WestCurrent · Offline" + else -> "WestCurrent" + }, + fontWeight = FontWeight.SemiBold + ) + Text( + connectionSubtitle(connection, site), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } }, actions = { IconButton(onClick = onDisconnect) { @@ -139,15 +182,125 @@ fun PoolApp( ) } } + }, + snackbarHost = { + statusMessage?.let { msg -> + Snackbar( + modifier = Modifier.padding(12.dp), + action = { + TextButton(onClick = onClearStatusMessage) { Text("OK") } + } + ) { Text(msg) } + } } ) { padding -> - androidx.compose.foundation.layout.Box(Modifier.padding(padding)) { - when (tab) { - 0 -> DashboardScreen(status) - 1 -> CircuitsScreen(status.circuits, onToggleCircuit) - 2 -> HeatScreen(status, onSetPoint, onHeatMode) - 3 -> LightsScreen(onLight) + androidx.compose.foundation.layout.Box(modifier = Modifier.padding(padding)) { + Column { + if (connection is ConnectionStatus.Simulator) { + Text( + "SIMULATOR — not connected to real equipment", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + color = PoolWarm, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge + ) + } + if (connection is ConnectionStatus.StaleOrDegraded) { + Text( + "Showing last known state · ${connection.reason}", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + color = PoolWarm, + style = MaterialTheme.typography.labelMedium + ) + } + if (connection is ConnectionStatus.Reconnecting) { + Text( + "Reconnecting… last known state is still shown. Commands are paused.", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + color = PoolWarm, + style = MaterialTheme.typography.labelMedium + ) + } + if (connection is ConnectionStatus.ConnectionFailed && site != null) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) { + Text( + "Connection lost after reconnect attempts · ${connection.message}", + color = PoolWarm, + style = MaterialTheme.typography.labelMedium + ) + if (canRetryConnection) { + TextButton(onClick = onRetryConnection) { + Text("Retry connection", color = PoolAccent, fontWeight = FontWeight.Bold) + } + } + } + } + pending?.let { + Text( + "Command pending: ${it.message ?: "waiting for equipment…"}", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + color = PoolAccent, + style = MaterialTheme.typography.labelMedium + ) + } + val snapshot = site + if (snapshot == null) { + Text( + "Waiting for equipment status…", + modifier = Modifier.padding(24.dp) + ) + } else { + when (tab) { + 0 -> DashboardScreen(snapshot, connection) + 1 -> CircuitsScreen( + circuits = snapshot.circuits, + controlsEnabled = connection.isLiveConnected, + pendingCircuitId = pending?.command?.circuitId, + onToggle = onToggleCircuit + ) + 2 -> HeatScreen( + site = snapshot, + controlsEnabled = connection.isLiveConnected, + onSetPoint = onSetPoint, + onHeatMode = onHeatMode, + onConfirmUnit = onConfirmTemperatureUnit + ) + 3 -> LightsScreen( + programs = snapshot.lightPrograms, + controlsEnabled = connection.isLiveConnected && + snapshot.capabilities.lightPrograms.isNotEmpty(), + onProgram = onLightProgram + ) + } + } } } } + + pendingHeatConfirmation?.let { pendingHeat -> + AlertDialog( + onDismissRequest = onCancelElevatedHeat, + title = { Text("Confirm elevated spa heat") }, + text = { Text(pendingHeat.reason) }, + confirmButton = { + TextButton(onClick = onConfirmElevatedHeat) { + Text("Confirm ${pendingHeat.temperature.format()}") + } + }, + dismissButton = { + TextButton(onClick = onCancelElevatedHeat) { Text("Cancel") } + } + ) + } } + +private fun connectionSubtitle(connection: ConnectionStatus, site: SiteSnapshot?): String { + val path = connection.displayLabel + val updated = site?.asOfMs?.let { formatTime(it) } ?: "—" + return "$path · Updated $updated" +} + +private fun formatTime(ms: Long): String = + SimpleDateFormat("h:mm:ss a", Locale.getDefault()).format(Date(ms)) diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt b/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt index bdd77a4..e61c5e5 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt @@ -1,124 +1,206 @@ package com.pentaircontrol.pool.ui +import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.pentaircontrol.pool.data.ConnectionState -import com.pentaircontrol.pool.data.GatewayInfo -import com.pentaircontrol.pool.data.HeatMode -import com.pentaircontrol.pool.data.LightCommand -import com.pentaircontrol.pool.data.PoolRepository -import com.pentaircontrol.pool.data.PoolStatus -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.CommandLifecycle +import com.pentaircontrol.pool.domain.CommandReceipt +import com.pentaircontrol.pool.domain.CircuitId +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.domain.HeatMode +import com.pentaircontrol.pool.domain.LightProgramId +import com.pentaircontrol.pool.domain.LightTargetId +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.AndroidAppPreferencesStore +import com.pentaircontrol.pool.settings.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +/** + * UI ViewModel — domain types only. No protocol models or packet codes. + * + * [sessionFactory] receives [viewModelScope] so all session work is canceled when the + * ViewModel is cleared. [onCleared] also calls [SiteSession.close] for adapter teardown + * (disconnect runs under NonCancellable after scope cancellation). + * + * Do not use GlobalScope or an application-lifetime free-floating scope. + */ class PoolViewModel( - private val repository: PoolRepository = PoolRepository() + sessionFactory: (CoroutineScope) -> SiteSession ) : ViewModel() { - val connection: StateFlow = repository.connection - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ConnectionState.Disconnected) + private val session: SiteSession = sessionFactory(viewModelScope) - val status: StateFlow = repository.status - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PoolStatus()) + val connection: StateFlow = session.connection + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ConnectionStatus.Disconnected) - val gateways: StateFlow> = repository.gateways + val site: StateFlow = session.site + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val gateways: StateFlow> = session.gateways + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val commandReceipts: StateFlow> = session.commandReceipts .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - private var pollJob: Job? = null + val lastError: StateFlow = session.lastError + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val pendingHeatConfirmation: StateFlow = + session.pendingConfirmation + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val canRetryConnection: StateFlow = session.canRetryConnection + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + private val _statusMessage = MutableStateFlow(null) + val statusMessage: StateFlow = _statusMessage + + /** Test hook: inspect the bound session. */ + internal fun sessionForTest(): SiteSession = session fun discover() { - viewModelScope.launch { repository.discover() } + viewModelScope.launch { session.discoverLocal() } } - fun connect(gateway: GatewayInfo, password: String = "") { - viewModelScope.launch { - repository.connect(gateway, password) - startPolling() - } + fun connect(gateway: DiscoveredGateway, password: String = "") { + viewModelScope.launch { session.connectLocal(gateway, password) } } fun connectManual(ip: String, port: Int, password: String) { - val gw = GatewayInfo( - name = "Manual", - address = ip.trim(), - port = port - ) - connect(gw, password) - } - - /** - * Remote connect with system code from the protocol adapter label (XX-XX-XX) - * and optional remote access password. - */ + viewModelScope.launch { session.connectManual(ip, port, password) } + } + fun connectRemote(systemCode: String, password: String) { + viewModelScope.launch { session.connectRemote(systemCode, password) } + } + + fun connectSimulator() { + viewModelScope.launch { session.connectSimulator() } + } + + fun disconnect() { + viewModelScope.launch { session.disconnect() } + } + + /** Explicit user retry after automatic reconnect exhaustion (same in-memory target). */ + fun retryConnection() { viewModelScope.launch { - repository.connectRemote(systemCode, password) - startPolling() + val ok = session.retryConnection() + if (!ok && session.lastError.value != null) { + _statusMessage.value = session.lastError.value + } } } - fun connectDemo() { + fun confirmTemperatureUnit(unit: TemperatureUnit) { + session.confirmSessionTemperatureUnit(unit) + _statusMessage.value = "Temperature unit set to ${when (unit) { + TemperatureUnit.FAHRENHEIT -> "°F" + TemperatureUnit.CELSIUS -> "°C" + TemperatureUnit.UNKNOWN -> "unknown" + }} for this session" + } + + fun toggleCircuit(circuitId: CircuitId, currentlyOn: Boolean) { viewModelScope.launch { - repository.connectDemo() - startPolling() + val result = session.toggleCircuit(circuitId, currentlyOn) + publishResult(result) } } - fun disconnect() { - pollJob?.cancel() - pollJob = null - viewModelScope.launch { repository.disconnect() } + fun setHeatSetPoint(bodyId: BodyId, temp: Temperature) { + viewModelScope.launch { + val result = session.setHeatSetPoint(bodyId, temp, elevatedConfirmed = false) + if (!result.requiresConfirmation) { + publishResult(result) + } + } } - fun refresh() { - viewModelScope.launch { repository.refresh() } + fun confirmElevatedHeat() { + val pending = session.pendingConfirmation.value ?: return + viewModelScope.launch { + session.clearPendingConfirmation() + val result = session.setHeatSetPoint( + bodyId = pending.bodyId, + temperature = pending.temperature, + elevatedConfirmed = true + ) + publishResult(result) + } } - fun toggleCircuit(circuitId: Int, currentlyOn: Boolean) { - viewModelScope.launch { repository.setCircuit(circuitId, !currentlyOn) } + fun cancelElevatedHeat() { + session.clearPendingConfirmation() } - fun setHeatSetPoint(bodyId: Int, temp: Int) { - viewModelScope.launch { repository.setHeatSetPoint(bodyId, temp) } + fun setHeatMode(bodyId: BodyId, mode: HeatMode) { + viewModelScope.launch { + publishResult(session.setHeatMode(bodyId, mode)) + } } - fun setHeatMode(bodyId: Int, mode: HeatMode) { - viewModelScope.launch { repository.setHeatMode(bodyId, mode) } + fun sendLightProgram(programId: LightProgramId, targetId: LightTargetId? = null) { + viewModelScope.launch { + publishResult(session.sendLightProgram(programId, targetId)) + } } - fun sendLightCommand(command: LightCommand) { - viewModelScope.launch { repository.sendLightCommand(command) } + private fun publishResult(result: com.pentaircontrol.pool.domain.CommandOrchestrator.SubmitResult) { + when { + result.rejectedReason != null -> + _statusMessage.value = result.rejectedReason + result.receipt != null -> + _statusMessage.value = formatReceipt(result.receipt) + } } - private fun startPolling() { - pollJob?.cancel() - pollJob = viewModelScope.launch { - while (isActive) { - delay(5_000) - if (connection.value is ConnectionState.Connected) { - repository.refresh() - } - } + private fun formatReceipt(receipt: CommandReceipt): String { + val prefix = when (receipt.lifecycle) { + CommandLifecycle.Pending -> "Pending" + CommandLifecycle.Applied -> "Confirmed" + CommandLifecycle.SentUnverified -> "Sent (unverified)" + CommandLifecycle.Failed -> "Failed" + CommandLifecycle.TimedOut -> "Timed out" } + return listOfNotNull(prefix, receipt.message).joinToString(": ") + } + + fun clearStatusMessage() { + _statusMessage.value = null } override fun onCleared() { - pollJob?.cancel() + session.close() super.onCleared() } - companion object { - fun factory(): ViewModelProvider.Factory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return PoolViewModel() as T - } - } + /** Test-only: invoke the same cleanup path as [onCleared]. */ + @androidx.annotation.VisibleForTesting + fun clearForTest() { + onCleared() + } +} + +class PoolViewModelFactory( + private val application: Application +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val settings: AppPreferencesStore = AndroidAppPreferencesStore(application) + return PoolViewModel { scope -> + SiteSession(scope = scope, settings = settings) + } as T } } diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/CircuitsScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/CircuitsScreen.kt index 698f764..636ec42 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/CircuitsScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/CircuitsScreen.kt @@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults @@ -21,8 +23,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.pentaircontrol.pool.data.Circuit -import com.pentaircontrol.pool.ui.theme.PoolAccent +import com.pentaircontrol.pool.domain.CircuitId +import com.pentaircontrol.pool.domain.CircuitState import com.pentaircontrol.pool.ui.theme.PoolAqua import com.pentaircontrol.pool.ui.theme.PoolCard import com.pentaircontrol.pool.ui.theme.PoolDeep @@ -30,8 +32,10 @@ import com.pentaircontrol.pool.ui.theme.PoolTeal @Composable fun CircuitsScreen( - circuits: List, - onToggle: (circuitId: Int, currentlyOn: Boolean) -> Unit + circuits: List, + controlsEnabled: Boolean, + pendingCircuitId: CircuitId?, + onToggle: (CircuitId, Boolean) -> Unit ) { if (circuits.isEmpty()) { Column( @@ -41,7 +45,7 @@ fun CircuitsScreen( ) { Text("No circuits reported", style = MaterialTheme.typography.titleMedium) Text( - "Connect to a ScreenLogic gateway or use Demo Mode.", + "Connect to equipment or open the Simulator.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp) @@ -57,51 +61,63 @@ fun CircuitsScreen( ) { item { Text( - "Tap a switch to turn equipment on or off", + if (controlsEnabled) { + "Switches send a command, then wait for equipment to report the new state." + } else { + "Controls paused — connection is not live. Last known state is shown." + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 4.dp) ) } - items(circuits, key = { it.id }) { circuit -> - CircuitRow(circuit = circuit, onToggle = { onToggle(circuit.id, circuit.isOn) }) - } - } -} - -@Composable -private fun CircuitRow(circuit: Circuit, onToggle: () -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = if (circuit.isOn) PoolTeal.copy(alpha = 0.35f) else PoolCard - ) - ) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text(circuit.name, fontWeight = FontWeight.SemiBold) - Text( - "ID ${circuit.id}" + if (circuit.hasDelay) " · delay" else "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + items(circuits, key = { it.id.value }) { circuit -> + val pending = pendingCircuitId == circuit.id + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (circuit.isOn) PoolTeal.copy(alpha = 0.35f) else PoolCard ) + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(circuit.displayName, fontWeight = FontWeight.SemiBold) + Text( + when { + pending -> "Pending confirmation…" + !circuit.isControllable -> "Not controllable" + circuit.hasDelay -> "Delay active" + else -> "Reported ${if (circuit.isOn) "ON" else "OFF"}" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (pending) { + CircularProgressIndicator( + modifier = Modifier.padding(end = 12.dp).size(20.dp), + strokeWidth = 2.dp + ) + } + Switch( + checked = circuit.isOn, + onCheckedChange = { onToggle(circuit.id, circuit.isOn) }, + enabled = controlsEnabled && !pending && circuit.isControllable, + colors = SwitchDefaults.colors( + checkedThumbColor = PoolDeep, + checkedTrackColor = PoolAqua, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = PoolDeep + ) + ) + } } - Switch( - checked = circuit.isOn, - onCheckedChange = { onToggle() }, - colors = SwitchDefaults.colors( - checkedThumbColor = PoolDeep, - checkedTrackColor = PoolAqua, - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = PoolDeep - ) - ) } } } diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt index 5db5c5c..088a806 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt @@ -50,8 +50,8 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.pentaircontrol.pool.data.ConnectionState -import com.pentaircontrol.pool.data.GatewayInfo +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.DiscoveredGateway import com.pentaircontrol.pool.ui.theme.PoolAccent import com.pentaircontrol.pool.ui.theme.PoolAqua import com.pentaircontrol.pool.ui.theme.PoolDeep @@ -60,13 +60,13 @@ import com.pentaircontrol.pool.ui.theme.PoolTeal @Composable fun ConnectScreen( - connection: ConnectionState, - gateways: List, + connection: ConnectionStatus, + gateways: List, onDiscover: () -> Unit, - onConnect: (GatewayInfo, String) -> Unit, + onConnect: (DiscoveredGateway, String) -> Unit, onManualConnect: (String, Int, String) -> Unit, onRemoteConnect: (systemCode: String, password: String) -> Unit, - onDemo: () -> Unit + onSimulator: () -> Unit ) { var ip by remember { mutableStateOf("") } var port by remember { mutableStateOf("80") } @@ -75,7 +75,7 @@ fun ConnectScreen( var showManual by remember { mutableStateOf(false) } var showLan by remember { mutableStateOf(false) } - val busy = connection is ConnectionState.Discovering || connection is ConnectionState.Connecting + val busy = connection is ConnectionStatus.Connecting Box( modifier = Modifier @@ -112,20 +112,26 @@ fun ConnectScreen( Spacer(Modifier.height(16.dp)) Text( - text = "Pool Control", + text = "WestCurrent", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground ) Text( - text = "ScreenLogic 2 · EasyTouch 8", + text = "by 8 West Ventures", + style = MaterialTheme.typography.titleMedium, + color = PoolAccent, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 2.dp) + ) + Text( + text = "Local-first pool control · calm and clear", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 4.dp, bottom = 28.dp) ) - // —— Remote connect (primary — works away from home) —— Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -133,7 +139,7 @@ fun ConnectScreen( ), shape = RoundedCornerShape(20.dp) ) { - Column(Modifier.padding(20.dp)) { + Column(modifier = Modifier.padding(20.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.Cloud, contentDescription = null, tint = PoolAccent) Text( @@ -144,8 +150,9 @@ fun ConnectScreen( ) } Text( - "Same login as ScreenLogic Connect. System code is on the protocol adapter " + - "label (bottom right, 6 characters, e.g. A1-B2-C3).", + "System code is on the ScreenLogic protocol adapter label " + + "(bottom right, 6 characters, e.g. A1-B2-C3). " + + "System codes and passwords are not saved on this device.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp, bottom = 14.dp) @@ -155,7 +162,7 @@ fun ConnectScreen( value = systemCode, onValueChange = { systemCode = it.uppercase() }, label = { Text("System code") }, - placeholder = { Text("A1-B2-C3 or Pentair: A1-B2-C3") }, + placeholder = { Text("A1-B2-C3") }, singleLine = true, modifier = Modifier.fillMaxWidth(), colors = fieldColors() @@ -170,25 +177,18 @@ fun ConnectScreen( modifier = Modifier.fillMaxWidth().padding(top = 10.dp), colors = fieldColors() ) - Text( - "If you set a remote access password in ScreenLogic General Settings, enter it here. " + - "Many systems leave this blank (some still use default 1234).", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp, bottom = 12.dp) - ) Button( onClick = { onRemoteConnect(systemCode, password) }, enabled = !busy && systemCode.isNotBlank(), - modifier = Modifier.fillMaxWidth().height(52.dp), + modifier = Modifier.fillMaxWidth().height(52.dp).padding(top = 12.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors( containerColor = PoolAccent, contentColor = PoolDeep ) ) { - if (connection is ConnectionState.Connecting) { + if (busy) { CircularProgressIndicator( modifier = Modifier.size(22.dp), strokeWidth = 2.dp, @@ -205,7 +205,6 @@ fun ConnectScreen( Spacer(Modifier.height(16.dp)) - // —— Local LAN —— OutlinedButton( onClick = { showLan = !showLan }, modifier = Modifier.fillMaxWidth(), @@ -224,9 +223,9 @@ fun ConnectScreen( ), shape = RoundedCornerShape(16.dp) ) { - Column(Modifier.padding(16.dp)) { + Column(modifier = Modifier.padding(16.dp)) { Text( - "Discovers ScreenLogic adapters on this Wi‑Fi (UDP 1444).", + "Discovers adapters on this Wi‑Fi (local control preferred).", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp) @@ -237,11 +236,8 @@ fun ConnectScreen( modifier = Modifier.fillMaxWidth().height(48.dp), shape = RoundedCornerShape(12.dp) ) { - if (connection is ConnectionState.Discovering) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) + if (busy) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } else { Icon(Icons.Default.Search, contentDescription = null) Spacer(Modifier.size(8.dp)) @@ -249,7 +245,7 @@ fun ConnectScreen( } } AnimatedVisibility(visible = gateways.isNotEmpty()) { - Column(Modifier.padding(top = 12.dp)) { + Column(modifier = Modifier.padding(top = 12.dp)) { gateways.forEach { gw -> GatewayRow( gateway = gw, @@ -282,7 +278,7 @@ fun ConnectScreen( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), shape = RoundedCornerShape(16.dp) ) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { OutlinedTextField( value = ip, onValueChange = { ip = it }, @@ -309,11 +305,7 @@ fun ConnectScreen( enabled = !busy && ip.isNotBlank(), modifier = Modifier.fillMaxWidth() ) { - if (connection is ConnectionState.Connecting) { - CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) - } else { - Text("Connect") - } + Text("Connect") } } } @@ -322,7 +314,7 @@ fun ConnectScreen( Spacer(Modifier.height(20.dp)) Button( - onClick = onDemo, + onClick = onSimulator, enabled = !busy, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(14.dp), @@ -333,32 +325,27 @@ fun ConnectScreen( ) { Icon(Icons.Default.PlayArrow, contentDescription = null) Spacer(Modifier.size(8.dp)) - Text("Try Demo Mode", fontWeight = FontWeight.Bold) + Text("Open Simulator", fontWeight = FontWeight.Bold) } Text( - "Demo simulates a full EasyTouch-style system — no hardware required.", + "Simulator is unmistakably labeled and never controls real equipment.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 10.dp) ) - if (connection is ConnectionState.Error) { - Spacer(Modifier.height(16.dp)) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), - shape = RoundedCornerShape(12.dp) - ) { - Text( - connection.message, - color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.padding(14.dp), - style = MaterialTheme.typography.bodyMedium - ) + when (connection) { + is ConnectionStatus.ConnectionFailed -> { + Spacer(Modifier.height(16.dp)) + ErrorCard(connection.message) } + is ConnectionStatus.AuthenticationRequired -> { + Spacer(Modifier.height(16.dp)) + ErrorCard("Login rejected. Check the remote access password.") + } + else -> Unit } Spacer(Modifier.height(40.dp)) @@ -367,7 +354,24 @@ fun ConnectScreen( } @Composable -private fun GatewayRow(gateway: GatewayInfo, enabled: Boolean, onClick: () -> Unit) { +private fun ErrorCard(message: String) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + message, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(14.dp), + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun GatewayRow(gateway: DiscoveredGateway, enabled: Boolean, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -379,8 +383,8 @@ private fun GatewayRow(gateway: GatewayInfo, enabled: Boolean, onClick: () -> Un ) { Icon(Icons.Default.Wifi, contentDescription = null, tint = PoolAccent) Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text(gateway.name, fontWeight = FontWeight.SemiBold) + Column(modifier = Modifier.weight(1f)) { + Text(gateway.displayName, fontWeight = FontWeight.SemiBold) Text( "${gateway.address}:${gateway.port}", style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/DashboardScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/DashboardScreen.kt index 682ce7b..7bd7082 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/DashboardScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/DashboardScreen.kt @@ -16,10 +16,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Air +import androidx.compose.material.icons.filled.HotTub import androidx.compose.material.icons.filled.LocalFireDepartment import androidx.compose.material.icons.filled.Science import androidx.compose.material.icons.filled.WaterDrop -import androidx.compose.material.icons.filled.HotTub import androidx.compose.material.icons.filled.Waves import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -35,18 +35,22 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.pentaircontrol.pool.data.BodyStatus -import com.pentaircontrol.pool.data.PoolStatus +import com.pentaircontrol.pool.domain.BodyKind +import com.pentaircontrol.pool.domain.BodyState +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.SiteSnapshot import com.pentaircontrol.pool.ui.theme.PoolAccent import com.pentaircontrol.pool.ui.theme.PoolAqua import com.pentaircontrol.pool.ui.theme.PoolCard import com.pentaircontrol.pool.ui.theme.PoolDeep import com.pentaircontrol.pool.ui.theme.PoolMid -import com.pentaircontrol.pool.ui.theme.PoolTeal import com.pentaircontrol.pool.ui.theme.PoolWarm +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Composable -fun DashboardScreen(status: PoolStatus) { +fun DashboardScreen(site: SiteSnapshot, connection: ConnectionStatus) { Column( modifier = Modifier .fillMaxSize() @@ -54,36 +58,44 @@ fun DashboardScreen(status: PoolStatus) { .padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { - if (status.isDemo) { - Card( - colors = CardDefaults.cardColors(containerColor = PoolTeal.copy(alpha = 0.35f)), - shape = RoundedCornerShape(12.dp) - ) { + Card( + colors = CardDefaults.cardColors(containerColor = PoolCard), + shape = RoundedCornerShape(12.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { Text( - "Demo mode — changes stay on this device only", - modifier = Modifier.padding(12.dp), + "Connection: ${connection.displayLabel}", + fontWeight = FontWeight.SemiBold, + color = PoolAccent + ) + Text( + "Last updated: ${formatTime(site.asOfMs)}", style = MaterialTheme.typography.bodySmall, - color = PoolAqua + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - BodyCard( - body = status.pool, - icon = Icons.Default.Waves, - accent = PoolAccent, - modifier = Modifier.weight(1f) - ) - BodyCard( - body = status.spa, - icon = Icons.Default.HotTub, - accent = PoolWarm, - modifier = Modifier.weight(1f) + if (site.bodies.isEmpty()) { + Text( + "No water bodies reported for this system.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + site.bodies.take(2).forEach { body -> + val icon = when (body.kind) { + BodyKind.SPA -> Icons.Default.HotTub + else -> Icons.Default.Waves + } + val accent = if (body.kind == BodyKind.SPA) PoolWarm else PoolAccent + BodyCard(body, icon, accent, Modifier.weight(1f)) + } + } } Row( @@ -91,45 +103,41 @@ fun DashboardScreen(status: PoolStatus) { horizontalArrangement = Arrangement.spacedBy(12.dp) ) { StatChip( - icon = Icons.Default.Air, - label = "Air", - value = "${status.airTemp}°", - modifier = Modifier.weight(1f) + Icons.Default.Air, + "Air", + site.airTemp?.format() ?: "—", + Modifier.weight(1f) ) StatChip( - icon = Icons.Default.LocalFireDepartment, - label = "Active", - value = "${status.activeCircuitCount}", - modifier = Modifier.weight(1f) + Icons.Default.LocalFireDepartment, + "Running", + "${site.activeCircuitCount}", + Modifier.weight(1f) ) } - if (status.pH != null || status.orp != null || status.saltPpm != null) { + val chem = site.chemistry + if (chem.pH != null || chem.orp != null || chem.saltPpm != null) { Text( "Water chemistry", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 4.dp) + fontWeight = FontWeight.SemiBold ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - status.pH?.let { + chem.pH?.let { ChemCard("pH", String.format("%.1f", it), Icons.Default.Science, Modifier.weight(1f)) } - status.orp?.let { + chem.orp?.let { ChemCard("ORP", "$it mV", Icons.Default.WaterDrop, Modifier.weight(1f)) } - status.saltPpm?.let { + chem.saltPpm?.let { ChemCard("Salt", "$it ppm", Icons.Default.WaterDrop, Modifier.weight(1f)) } } } - Text( - "Circuits", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - status.circuits.take(8).forEach { circuit -> + Text("Equipment", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + site.circuits.take(8).forEach { circuit -> Row( modifier = Modifier .fillMaxWidth() @@ -146,7 +154,7 @@ fun DashboardScreen(status: PoolStatus) { ) ) Text( - circuit.name, + circuit.displayName, modifier = Modifier.padding(start = 12.dp).weight(1f), fontWeight = FontWeight.Medium ) @@ -159,7 +167,7 @@ fun DashboardScreen(status: PoolStatus) { } } - status.version?.let { + site.controllerVersion?.let { Text( "Controller: $it", style = MaterialTheme.typography.bodySmall, @@ -168,9 +176,9 @@ fun DashboardScreen(status: PoolStatus) { ) } - if (status.freezeMode) { + if (site.freezeMode) { Text( - "⚠ Freeze protection active", + "Freeze protection active", color = PoolWarm, fontWeight = FontWeight.Bold ) @@ -180,7 +188,7 @@ fun DashboardScreen(status: PoolStatus) { @Composable private fun BodyCard( - body: BodyStatus, + body: BodyState, icon: ImageVector, accent: Color, modifier: Modifier = Modifier @@ -193,29 +201,29 @@ private fun BodyCard( Box( modifier = Modifier .fillMaxSize() - .background( - Brush.linearGradient(listOf(PoolCard, PoolMid.copy(alpha = 0.7f))) - ) + .background(Brush.linearGradient(listOf(PoolCard, PoolMid.copy(alpha = 0.7f)))) .padding(16.dp) ) { Column { Row(verticalAlignment = Alignment.CenterVertically) { Icon(icon, contentDescription = null, tint = accent, modifier = Modifier.size(22.dp)) Text( - body.name, + body.displayName, modifier = Modifier.padding(start = 8.dp), fontWeight = FontWeight.SemiBold ) } Spacer(Modifier.weight(1f)) Text( - "${body.currentTemp}°", + body.currentTemp?.format() ?: "—", fontSize = 40.sp, fontWeight = FontWeight.Bold, color = accent ) + val set = body.heatSetPoint?.let { "Set ${it.format()}" } ?: "No setpoint" + val mode = body.heatMode?.label ?: "No heat" Text( - "Set ${body.heatSetPoint}° · ${body.heatMode.label}", + "$set · $mode", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -228,23 +236,15 @@ private fun BodyCard( } @Composable -private fun StatChip( - icon: ImageVector, - label: String, - value: String, - modifier: Modifier = Modifier -) { +private fun StatChip(icon: ImageVector, label: String, value: String, modifier: Modifier = Modifier) { Card( modifier = modifier, shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = PoolCard) ) { - Row( - Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Icon(icon, contentDescription = null, tint = PoolAccent) - Column(Modifier.padding(start = 12.dp)) { + Column(modifier = Modifier.padding(start = 12.dp)) { Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(value, fontWeight = FontWeight.Bold, fontSize = 20.sp) } @@ -253,21 +253,19 @@ private fun StatChip( } @Composable -private fun ChemCard( - label: String, - value: String, - icon: ImageVector, - modifier: Modifier = Modifier -) { +private fun ChemCard(label: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) { Card( modifier = modifier, shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = PoolDeep.copy(alpha = 0.5f)) ) { - Column(Modifier.padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Column(modifier = Modifier.padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) { Icon(icon, contentDescription = null, tint = PoolAqua, modifier = Modifier.size(18.dp)) Text(value, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 4.dp)) Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } + +private fun formatTime(ms: Long): String = + SimpleDateFormat("MMM d, h:mm:ss a", Locale.getDefault()).format(Date(ms)) diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/HeatScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/HeatScreen.kt index 24dc545..f9648d7 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/HeatScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/HeatScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip @@ -22,6 +23,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -29,9 +31,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.pentaircontrol.pool.data.BodyStatus -import com.pentaircontrol.pool.data.HeatMode -import com.pentaircontrol.pool.data.PoolStatus +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.BodyState +import com.pentaircontrol.pool.domain.HeatMode +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit import com.pentaircontrol.pool.ui.theme.PoolAccent import com.pentaircontrol.pool.ui.theme.PoolCard import com.pentaircontrol.pool.ui.theme.PoolDeep @@ -40,10 +45,16 @@ import com.pentaircontrol.pool.ui.theme.PoolWarm @Composable fun HeatScreen( - status: PoolStatus, - onSetPoint: (bodyId: Int, temp: Int) -> Unit, - onHeatMode: (bodyId: Int, mode: HeatMode) -> Unit + site: SiteSnapshot, + controlsEnabled: Boolean, + onSetPoint: (BodyId, Temperature) -> Unit, + onHeatMode: (BodyId, HeatMode) -> Unit, + onConfirmUnit: (TemperatureUnit) -> Unit = {} ) { + val heatBodies = site.bodies.filter { it.supportsHeat } + val unitKnown = site.temperatureUnit != TemperatureUnit.UNKNOWN + val heatEnabled = controlsEnabled && unitKnown + Column( modifier = Modifier .fillMaxSize() @@ -51,38 +62,82 @@ fun HeatScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - BodyHeatCard( - body = status.pool, - accent = PoolAccent, - onSetPoint = { onSetPoint(0, it) }, - onHeatMode = { onHeatMode(0, it) } - ) - BodyHeatCard( - body = status.spa, - accent = PoolWarm, - onSetPoint = { onSetPoint(1, it) }, - onHeatMode = { onHeatMode(1, it) } - ) + if (!controlsEnabled) { + Text( + "Controls paused — showing last known heat state.", + color = PoolWarm, + style = MaterialTheme.typography.bodyMedium + ) + } + if (!unitKnown) { + Card( + colors = CardDefaults.cardColors(containerColor = PoolCard), + shape = RoundedCornerShape(16.dp) + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Confirm temperature unit", + fontWeight = FontWeight.Bold, + color = PoolWarm + ) + Text( + "This controller did not report °F or °C. Choose the scale used by your " + + "equipment for this session. WestCurrent will not guess from locale or values.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { onConfirmUnit(TemperatureUnit.FAHRENHEIT) }) { + Text("Use °F") + } + OutlinedButton(onClick = { onConfirmUnit(TemperatureUnit.CELSIUS) }) { + Text("Use °C") + } + } + } + } + } + if (heatBodies.isEmpty()) { + Text( + "No heat-capable bodies on this system.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + heatBodies.forEach { body -> + BodyHeatCard( + body = body, + accent = if (body.kind.name == "SPA") PoolWarm else PoolAccent, + enabled = heatEnabled && + body.id in site.capabilities.heatCapableBodies, + onSetPoint = { onSetPoint(body.id, it) }, + onHeatMode = { onHeatMode(body.id, it) } + ) + } + } Spacer(Modifier.height(24.dp)) } } @Composable private fun BodyHeatCard( - body: BodyStatus, + body: BodyState, accent: androidx.compose.ui.graphics.Color, - onSetPoint: (Int) -> Unit, + enabled: Boolean, + onSetPoint: (Temperature) -> Unit, onHeatMode: (HeatMode) -> Unit ) { + val setPoint = body.heatSetPoint + ?: Temperature(80, body.currentTemp?.unit ?: TemperatureUnit.UNKNOWN) Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = PoolCard) ) { - Column(Modifier.padding(18.dp)) { + Column(modifier = Modifier.padding(18.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - body.name, + body.displayName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = accent @@ -94,7 +149,7 @@ private fun BodyHeatCard( } Text( - "Current ${body.currentTemp}°", + "Current ${body.currentTemp?.format() ?: "—"} · reported setpoint ${setPoint.format()}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp, bottom = 12.dp) @@ -106,20 +161,26 @@ private fun BodyHeatCard( modifier = Modifier.padding(vertical = 8.dp) ) { IconButton( - onClick = { onSetPoint(body.heatSetPoint - 1) }, + onClick = { + onSetPoint(setPoint.copy(value = setPoint.value - 1)) + }, + enabled = enabled, colors = IconButtonDefaults.iconButtonColors(containerColor = PoolDeep) ) { Icon(Icons.Default.Remove, contentDescription = "Decrease") } Text( - "${body.heatSetPoint}°", + setPoint.format(), fontSize = 36.sp, fontWeight = FontWeight.Bold, color = accent, modifier = Modifier.padding(horizontal = 20.dp) ) IconButton( - onClick = { onSetPoint(body.heatSetPoint + 1) }, + onClick = { + onSetPoint(setPoint.copy(value = setPoint.value + 1)) + }, + enabled = enabled, colors = IconButtonDefaults.iconButtonColors(containerColor = PoolDeep) ) { Icon(Icons.Default.Add, contentDescription = "Increase") @@ -137,6 +198,7 @@ private fun BodyHeatCard( FilterChip( selected = body.heatMode == mode, onClick = { onHeatMode(mode) }, + enabled = enabled, label = { Text(mode.label, fontSize = 12.sp) }, colors = FilterChipDefaults.filterChipColors( selectedContainerColor = PoolTeal, @@ -150,6 +212,7 @@ private fun BodyHeatCard( FilterChip( selected = body.heatMode == mode, onClick = { onHeatMode(mode) }, + enabled = enabled, label = { Text(mode.label, fontSize = 12.sp) }, colors = FilterChipDefaults.filterChipColors( selectedContainerColor = PoolTeal, diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/LightsScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/LightsScreen.kt index bbe2d33..e1a6db9 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/LightsScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/LightsScreen.kt @@ -19,7 +19,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.pentaircontrol.pool.data.LightCommand +import com.pentaircontrol.pool.domain.LightActionCategory +import com.pentaircontrol.pool.domain.LightProgram +import com.pentaircontrol.pool.domain.LightProgramId +import com.pentaircontrol.pool.domain.LightTargetId import com.pentaircontrol.pool.ui.theme.PoolAccent import com.pentaircontrol.pool.ui.theme.PoolAqua import com.pentaircontrol.pool.ui.theme.PoolCard @@ -28,20 +31,34 @@ import com.pentaircontrol.pool.ui.theme.PoolTeal import com.pentaircontrol.pool.ui.theme.PoolWarm @Composable -fun LightsScreen(onCommand: (LightCommand) -> Unit) { +fun LightsScreen( + programs: List, + controlsEnabled: Boolean, + onProgram: (LightProgramId, LightTargetId) -> Unit +) { Column(Modifier.fillMaxSize().padding(16.dp)) { Text( - "Color lights", + "Lights", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) Text( - "Sends IntelliBrite / color-light show commands to the controller.", + if (programs.isEmpty()) { + "No light programs available from the active adapter." + } else if (controlsEnabled) { + "Programs and targets come from the connected system. " + + "Light actions are sent but not confirmed as applied — " + + "this controller does not report the active program." + } else { + "Controls paused — connection is not live." + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp, bottom = 12.dp) ) + if (programs.isEmpty()) return + LazyVerticalGrid( columns = GridCells.Fixed(2), contentPadding = PaddingValues(bottom = 24.dp), @@ -49,32 +66,35 @@ fun LightsScreen(onCommand: (LightCommand) -> Unit) { verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxSize() ) { - items(LightCommand.entries) { cmd -> - val colors = when (cmd) { - LightCommand.ALL_OFF -> ButtonDefaults.buttonColors( + items(programs, key = { it.id.value }) { program -> + val colors = when (program.category) { + LightActionCategory.OFF -> ButtonDefaults.buttonColors( containerColor = PoolDeep, contentColor = MaterialTheme.colorScheme.onSurface ) - LightCommand.ALL_ON, LightCommand.WHITE -> ButtonDefaults.buttonColors( + LightActionCategory.ON -> ButtonDefaults.buttonColors( containerColor = PoolAccent, contentColor = PoolDeep ) - LightCommand.PARTY, LightCommand.ROMANCE, LightCommand.SUNSET -> + LightActionCategory.SET_PROGRAM -> ButtonDefaults.buttonColors( + containerColor = PoolTeal, + contentColor = PoolAqua + ) + LightActionCategory.HOLD, LightActionCategory.RECALL -> ButtonDefaults.buttonColors(containerColor = PoolWarm, contentColor = PoolDeep) - LightCommand.BLUE, LightCommand.CARIBBEAN, LightCommand.ROYAL -> - ButtonDefaults.buttonColors(containerColor = PoolTeal, contentColor = PoolAqua) else -> ButtonDefaults.buttonColors( containerColor = PoolCard, contentColor = MaterialTheme.colorScheme.onSurface ) } Button( - onClick = { onCommand(cmd) }, + onClick = { onProgram(program.id, program.targetId) }, + enabled = controlsEnabled, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), colors = colors ) { - Text(cmd.label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text(program.displayName, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) } } } diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/theme/Theme.kt b/app/src/main/java/com/pentaircontrol/pool/ui/theme/Theme.kt index 86ae1fe..00b328e 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/theme/Theme.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/theme/Theme.kt @@ -57,8 +57,8 @@ private val LightColors = lightColorScheme( ) @Composable -fun PentairPoolTheme( - darkTheme: Boolean = true, // Pool night vibe by default +fun WestCurrentTheme( + darkTheme: Boolean = true, // Coastal night vibe by default content: @Composable () -> Unit ) { val colors = if (darkTheme || isSystemInDarkTheme()) DarkColors else LightColors @@ -68,3 +68,10 @@ fun PentairPoolTheme( content = content ) } + +/** @deprecated Use [WestCurrentTheme] */ +@Composable +fun PentairPoolTheme( + darkTheme: Boolean = true, + content: @Composable () -> Unit +) = WestCurrentTheme(darkTheme, content) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f963d9..e65fd39 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,9 +1,9 @@ - Pool Control - Connect to Pentair + WestCurrent + Connect WestCurrent Discover on LAN - Try Demo Mode + Open Simulator Dashboard Circuits Heat diff --git a/app/src/test/java/com/pentaircontrol/pool/domain/Phase12MergeReadinessTest.kt b/app/src/test/java/com/pentaircontrol/pool/domain/Phase12MergeReadinessTest.kt new file mode 100644 index 0000000..34f4f79 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/domain/Phase12MergeReadinessTest.kt @@ -0,0 +1,676 @@ +package com.pentaircontrol.pool.domain + +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicIds +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLightPrograms +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLocalTarget +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicMapper +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget +import com.pentaircontrol.pool.adapter.simulator.SimulatorAdapter +import com.pentaircontrol.pool.adapter.simulator.SimulatorConnectionTarget +import com.pentaircontrol.pool.data.BodyStatus +import com.pentaircontrol.pool.data.Circuit +import com.pentaircontrol.pool.data.PoolStatus +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicAdapter +import com.pentaircontrol.pool.session.ReconnectPolicy +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.AppPreferencesStore +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import com.pentaircontrol.pool.data.HeatMode as LegacyHeatMode +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +/** + * Phase 1.2 merge-readiness: reconnect, light targets, connection targets, + * temperature units, and preference non-persistence. + */ +class Phase12MergeReadinessTest { + + // ——— Temperature units ——— + + @Test + fun temperature_format_isExplicitFAndC() { + assertEquals("86°F", Temperature.fahrenheit(86).format()) + assertEquals("30°C", Temperature.celsius(30).format()) + assertEquals("72°?", Temperature.unknown(72).format()) + assertFalse(Temperature.fahrenheit(86).format().endsWith("°") && + !Temperature.fahrenheit(86).format().contains("F")) + } + + @Test + fun safety_fahrenheitRanges_andElevated() { + val policy = SafetyPolicy() + val spa = BodyId("b:spa") + val snap = SiteSnapshot( + bodies = listOf( + BodyState(spa, BodyKind.SPA, "Spa", heatSetPoint = Temperature.fahrenheit(100), supportsHeat = true) + ), + temperatureUnit = TemperatureUnit.FAHRENHEIT, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_HEAT_SETPOINT), + heatCapableBodies = setOf(spa) + ) + ) + val ctx = liveCtx(snap) + val ok = policy.validate(heatCmd(spa, Temperature.fahrenheit(86)), ctx) + assertTrue(ok is SafetyPolicy.Decision.Allow) + + val high = policy.validate(heatCmd(spa, Temperature.fahrenheit(102)), ctx) + assertTrue(high is SafetyPolicy.Decision.RequireConfirmation) + + val out = policy.validate(heatCmd(spa, Temperature.fahrenheit(200)), ctx) + assertTrue(out is SafetyPolicy.Decision.Reject) + assertTrue((out as SafetyPolicy.Decision.Reject).reason.contains("°F")) + } + + @Test + fun safety_celsiusRanges_doNotUseFahrenheitLimits() { + val policy = SafetyPolicy() + val spa = BodyId("b:spa") + val snap = SiteSnapshot( + bodies = listOf( + BodyState(spa, BodyKind.SPA, "Spa", heatSetPoint = Temperature.celsius(38), supportsHeat = true) + ), + temperatureUnit = TemperatureUnit.CELSIUS, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_HEAT_SETPOINT), + heatCapableBodies = setOf(spa) + ) + ) + val ctx = liveCtx(snap) + // 39°C is valid C but would look "low" if wrongly treated as F + val ok = policy.validate(heatCmd(spa, Temperature.celsius(39), elevated = true), ctx) + assertTrue(ok is SafetyPolicy.Decision.Allow) + + // 39°C elevated (>=38) without confirm + val elev = policy.validate(heatCmd(spa, Temperature.celsius(39)), ctx) + assertTrue(elev is SafetyPolicy.Decision.RequireConfirmation) + + // 50°C out of C range (would be fine as F spa max is 104) + val out = policy.validate(heatCmd(spa, Temperature.celsius(50)), ctx) + assertTrue(out is SafetyPolicy.Decision.Reject) + assertTrue((out as SafetyPolicy.Decision.Reject).reason.contains("°C")) + } + + @Test + fun safety_unknownUnit_rejected() { + val policy = SafetyPolicy() + val spa = BodyId("b:spa") + val snap = SiteSnapshot( + bodies = listOf(BodyState(spa, BodyKind.SPA, "Spa", supportsHeat = true)), + temperatureUnit = TemperatureUnit.UNKNOWN, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_HEAT_SETPOINT), + heatCapableBodies = setOf(spa) + ) + ) + val r = policy.validate(heatCmd(spa, Temperature.unknown(100)), liveCtx(snap)) + assertTrue(r is SafetyPolicy.Decision.Reject) + assertTrue((r as SafetyPolicy.Decision.Reject).reason.contains("unknown", ignoreCase = true)) + } + + @Test + fun safety_mismatchedUnits_rejected() { + val policy = SafetyPolicy() + val spa = BodyId("b:spa") + val snap = SiteSnapshot( + bodies = listOf( + BodyState(spa, BodyKind.SPA, "Spa", heatSetPoint = Temperature.fahrenheit(100), supportsHeat = true) + ), + temperatureUnit = TemperatureUnit.FAHRENHEIT, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_HEAT_SETPOINT), + heatCapableBodies = setOf(spa) + ) + ) + val r = policy.validate(heatCmd(spa, Temperature.celsius(38)), liveCtx(snap)) + assertTrue(r is SafetyPolicy.Decision.Reject) + assertTrue((r as SafetyPolicy.Decision.Reject).reason.contains("does not match", ignoreCase = true)) + } + + @Test + fun temperatureConversion_roundTripHelpers() { + assertEquals(0, TemperatureConversion.fahrenheitToCelsius(32)) + assertEquals(100, TemperatureConversion.celsiusToFahrenheit(38).let { + // 38C -> 100.4 -> 100 int + it + }) + val c = TemperatureConversion.convert(Temperature.fahrenheit(86), TemperatureUnit.CELSIUS) + assertEquals(30, c!!.value) + } + + @Test + fun screenLogic_mapsUnknownUnitByDefault() { + val legacy = PoolStatus( + airTemp = 70, + pool = BodyStatus(0, "Pool", 80, 84, heatMode = LegacyHeatMode.HEATER, heatActive = true), + spa = BodyStatus(1, "Spa", 99, 100, heatMode = LegacyHeatMode.OFF, heatActive = false), + circuits = listOf(Circuit(500, "Spa", true)) + ) + val snap = ScreenLogicMapper.toDomain(legacy, ConnectionPath.LOCAL_LAN) + assertEquals(TemperatureUnit.UNKNOWN, snap.temperatureUnit) + assertEquals(TemperatureUnit.UNKNOWN, snap.primaryPool()!!.currentTemp!!.unit) + assertTrue(snap.primaryPool()!!.currentTemp!!.format().contains("°?")) + + val confirmed = ScreenLogicMapper.toDomain( + legacy, ConnectionPath.LOCAL_LAN, temperatureUnit = TemperatureUnit.FAHRENHEIT + ) + assertEquals(TemperatureUnit.FAHRENHEIT, confirmed.temperatureUnit) + assertEquals("80°F", confirmed.primaryPool()!!.currentTemp!!.format()) + } + + @Test + fun simulator_fahrenheitAndCelsiusProfiles() { + val f = SimulatorAdapter.initialSnapshot( + SimulatorAdapter.Config(temperatureUnit = TemperatureUnit.FAHRENHEIT) + ) + assertEquals(TemperatureUnit.FAHRENHEIT, f.temperatureUnit) + assertTrue(f.primaryPool()!!.currentTemp!!.format().endsWith("°F")) + + val c = SimulatorAdapter.initialSnapshot( + SimulatorAdapter.Config(temperatureUnit = TemperatureUnit.CELSIUS) + ) + assertEquals(TemperatureUnit.CELSIUS, c.temperatureUnit) + assertTrue(c.primaryPool()!!.currentTemp!!.format().endsWith("°C")) + assertTrue(c.primaryPool()!!.currentTemp!!.value < 50) // C magnitudes + } + + // ——— Light targets ——— + + @Test + fun lightTargetKey_usesTargetNotProgram() { + val party = DomainCommand( + "1", DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = LightProgramId("p:party"), + lightTargetId = LightTargetId("t:all") + ) + val romance = DomainCommand( + "2", DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = LightProgramId("p:romance"), + lightTargetId = LightTargetId("t:all") + ) + assertEquals(party.targetKey(), romance.targetKey()) + assertTrue(party.targetKey().startsWith("light-target:")) + assertFalse(party.targetKey().contains("party")) + } + + @Test + fun sameLightTarget_serializes_differentPrograms() = runBlocking { + val sim = SimulatorAdapter( + SimulatorAdapter.Config(applyDelayMs = 200, dualLightTargets = false) + ) + sim.connect(SimulatorConnectionTarget()) + val orch = CommandOrchestrator(confirmTimeoutMs = 3_000, pollIntervalMs = 30) + val target = SimulatorAdapter.PRIMARY_LIGHT_TARGET + val party = DomainCommand( + UUID.randomUUID().toString(), DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = SimulatorAdapter.lightId("party"), + lightTargetId = target + ) + val blue = DomainCommand( + UUID.randomUUID().toString(), DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = SimulatorAdapter.lightId("blue"), + lightTargetId = target + ) + val first = async { + orch.submit(party, sim.connection.value, sim, sim.snapshot.value, sim.snapshot.value!!.capabilities) + } + delay(40) + val second = orch.submit(blue, sim.connection.value, sim, sim.snapshot.value, sim.snapshot.value!!.capabilities) + // Second should reject as pending on same target, or wait until first done + val firstResult = first.await() + assertEquals(CommandLifecycle.SentUnverified, firstResult.receipt!!.lifecycle) + // Either rejected concurrent or serialized success + if (second.rejectedReason != null) { + assertTrue(second.rejectedReason!!.contains("pending", ignoreCase = true)) + } else { + assertEquals(CommandLifecycle.SentUnverified, second.receipt!!.lifecycle) + } + } + + @Test + fun independentLightTargets_doNotBlockEachOther() = runBlocking { + val sim = SimulatorAdapter( + SimulatorAdapter.Config(applyDelayMs = 150, dualLightTargets = true) + ) + sim.connect(SimulatorConnectionTarget()) + val orch = CommandOrchestrator(confirmTimeoutMs = 3_000, pollIntervalMs = 30) + val caps = sim.snapshot.value!!.capabilities + val a = async { + orch.submit( + DomainCommand( + UUID.randomUUID().toString(), DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = SimulatorAdapter.lightId("party"), + lightTargetId = SimulatorAdapter.PRIMARY_LIGHT_TARGET + ), + sim.connection.value, sim, sim.snapshot.value, caps + ) + } + delay(20) + val b = orch.submit( + DomainCommand( + UUID.randomUUID().toString(), DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = SimulatorAdapter.lightId("sec_party"), + lightTargetId = SimulatorAdapter.SECONDARY_LIGHT_TARGET + ), + sim.connection.value, sim, sim.snapshot.value, caps + ) + val ar = a.await() + assertNull(b.rejectedReason) + assertEquals(CommandLifecycle.SentUnverified, ar.receipt!!.lifecycle) + assertEquals(CommandLifecycle.SentUnverified, b.receipt!!.lifecycle) + } + + @Test + fun foreignLightTarget_rejected() { + val policy = SafetyPolicy() + val target = LightTargetId("sim:t1") + val prog = LightProgramId("sim:p1") + val snap = SiteSnapshot( + lightPrograms = listOf( + LightProgram(prog, "Party", LightActionCategory.SET_PROGRAM, "sim", target) + ), + lightTargets = listOf(LightTarget(target, "All", "sim", setOf(prog))), + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SEND_LIGHT_PROGRAM), + lightPrograms = setOf(prog), + lightTargets = setOf(target) + ) + ) + val r = policy.validate( + DomainCommand( + "1", DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = prog, + lightTargetId = LightTargetId("foreign:t") + ), + liveCtx(snap) + ) + assertTrue(r is SafetyPolicy.Decision.Reject) + } + + @Test + fun validTarget_invalidProgram_rejected() { + val policy = SafetyPolicy() + val target = LightTargetId("sim:t1") + val prog = LightProgramId("sim:p1") + val snap = SiteSnapshot( + lightPrograms = listOf( + LightProgram(prog, "Party", LightActionCategory.SET_PROGRAM, "sim", target) + ), + lightTargets = listOf(LightTarget(target, "All", "sim", setOf(prog))), + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SEND_LIGHT_PROGRAM), + lightPrograms = setOf(prog), + lightTargets = setOf(target) + ) + ) + val r = policy.validate( + DomainCommand( + "1", DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = LightProgramId("sim:missing"), + lightTargetId = target + ), + liveCtx(snap) + ) + assertTrue(r is SafetyPolicy.Decision.Reject) + } + + @Test + fun lightCommand_withoutReportedProgramChannel_isSentUnverified() = runBlocking { + // Orchestrator marks all light programs SentUnverified (no active-program channel). + // ScreenLogic target id is declared separately; this path uses the simulator adapter. + val sim = SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 5)) + sim.connect(SimulatorConnectionTarget()) + val orch = CommandOrchestrator(confirmTimeoutMs = 1_000, pollIntervalMs = 20) + val program = sim.snapshot.value!!.lightPrograms.first { it.displayName == "Party" } + val result = orch.submit( + DomainCommand( + UUID.randomUUID().toString(), DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = program.id, + lightTargetId = program.targetId + ), + sim.connection.value, sim, sim.snapshot.value, sim.snapshot.value!!.capabilities + ) + assertEquals(CommandLifecycle.SentUnverified, result.receipt!!.lifecycle) + assertEquals(LightTargetId("screenlogic:lights:all"), ScreenLogicLightPrograms.ALL_LIGHTS_TARGET_ID) + } + + // ——— Connection targets ——— + + @Test + fun realScreenLogicAdapter_rejectsSimulatorTarget_withoutNetwork() = runBlocking { + val adapter = ScreenLogicAdapter() + var threw = false + try { + adapter.connect(SimulatorConnectionTarget()) + } catch (_: IllegalArgumentException) { + threw = true + } + assertTrue(threw) + assertTrue(adapter.connection.value is ConnectionStatus.ConnectionFailed) + } + + @Test + fun simulator_acceptsOwnTarget_rejectsForeign() = runBlocking { + val sim = SimulatorAdapter() + sim.connect(SimulatorConnectionTarget("Demo")) + assertTrue(sim.connection.value is ConnectionStatus.Simulator) + + val foreign = object : AdapterConnectionTarget { + override val adapterId = "future.jandy" + override val path = ConnectionPath.LOCAL_LAN + override val displayLabel = "Jandy box" + } + var rejected = false + try { + sim.connect(foreign) + } catch (_: IllegalArgumentException) { + rejected = true + } + assertTrue(rejected) + } + + @Test + fun futureVendorAdapter_ownsTargetWithoutSharedDomainChange() { + data class JandyTarget(val serial: String) : AdapterConnectionTarget { + override val adapterId = "future.jandy" + override val path = ConnectionPath.LOCAL_LAN + override val displayLabel = "Jandy $serial" + } + val t = JandyTarget("ABC") + assertEquals("future.jandy", t.adapterId) + assertEquals(ConnectionPath.LOCAL_LAN, t.path) + assertEquals("Jandy ABC", t.displayLabel) + } + + @Test + fun screenLogicLocalAndRemote_ownAdapterId() { + val local = ScreenLogicLocalTarget( + DiscoveredGateway("1", "GW", "10.0.0.1", 80) + ) + val remote = ScreenLogicRemoteTarget("AB-CD-EF", "secret") + assertEquals(ScreenLogicIds.ADAPTER_ID, local.adapterId) + assertEquals(ScreenLogicIds.ADAPTER_ID, remote.adapterId) + assertEquals(ConnectionPath.LOCAL_LAN, local.path) + assertEquals(ConnectionPath.REMOTE_MANUFACTURER, remote.path) + } + + // ——— Preferences (non-sensitive + stale clear; remote path covered in Phase13) ——— + + @Test + fun nonSensitivePreferences_work_andStaleKeysCleared() = runBlocking { + val prefs = InMemoryAppPreferencesStore() + prefs.putString(AppPreferencesStore.STALE_KEY_LAST_SYSTEM_CODE, "OLDCODE") + prefs.putString(AppPreferencesStore.STALE_KEY_REMEMBER_SYSTEM_CODE, "1") + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = prefs, + simulatorFactory = { SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 5)) }, + reconnectPolicy = ReconnectPolicy.FAST_TEST, + pollIntervalMs = 60_000 + ) + assertNull(prefs.getString(AppPreferencesStore.STALE_KEY_LAST_SYSTEM_CODE)) + session.connectSimulator() + prefs.putString(AppPreferencesStore.KEY_PREFER_SIMULATOR_HINT, "1") + assertEquals("1", prefs.getString(AppPreferencesStore.KEY_PREFER_SIMULATOR_HINT)) + assertNull(prefs.getString(AppPreferencesStore.STALE_KEY_LAST_PASSWORD)) + assertNotNull(session.activeConnectionTargetForTest()) + session.disconnect() + assertNull(session.activeConnectionTargetForTest()) + session.close() + } + + // ——— Reconnect (core; strengthened concurrent/bounded cases in Phase13) ——— + + private fun testPolicy(failuresBefore: Int = 2, maxAttempts: Int = 3) = + ReconnectPolicy( + failuresBeforeReconnect = failuresBefore, + maxAttempts = maxAttempts, + backoffBeforeAttemptMs = { 0L }, + maxBackoffMs = 0L + ) + + @Test + fun oneRefreshFailure_preservesSnapshot_doesNotReconnectImmediately() = runBlocking { + val creations = AtomicInteger(0) + val adapters = mutableListOf() + val factory = { + val a = FakeReconnectAdapter(id = creations.incrementAndGet()) + adapters.add(a) + a + } + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + screenLogicFactory = factory, + reconnectPolicy = testPolicy(failuresBefore = 2, maxAttempts = 3), + pollIntervalMs = 60_000 + ) + session.connectWithFactory(SimulatorConnectionTarget(), factory) + val before = session.site.value!!.asOfMs + assertEquals(1, creations.get()) + + adapters.last().armFailRefresh() + session.refreshNow() + assertTrue(session.connection.value is ConnectionStatus.StaleOrDegraded) + assertEquals(before, session.site.value!!.asOfMs) + assertEquals(1, creations.get()) + session.close() + } + + @Test + fun repeatedFailures_createNewAdapter_callConnect_disconnectOld() = runBlocking { + val creations = AtomicInteger(0) + val adapters = mutableListOf() + val factory = { + val a = FakeReconnectAdapter(id = creations.incrementAndGet()) + adapters.add(a) + a + } + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = testPolicy(failuresBefore = 2, maxAttempts = 3), + pollIntervalMs = 60_000 + ) + session.connectWithFactory(SimulatorConnectionTarget(), factory) + assertEquals(1, creations.get()) + val first = adapters.first() + + first.armFailRefresh() + session.refreshNow() + first.armFailRefresh() + session.refreshNow() + + var waited = 0 + while (session.isReconnectInFlight() && waited < 100) { + delay(10) + waited++ + } + assertTrue(creations.get() >= 2) + assertTrue(first.disconnectCount >= 1) + assertTrue(adapters.last().connectCount >= 1) + assertNotNull(session.site.value) + session.close() + } + + @Test + fun commandsRejectedWhileReconnecting() { + val policy = SafetyPolicy() + val cmd = DomainCommand("1", DomainCommandKind.SET_CIRCUIT, circuitId = CircuitId("x"), circuitOn = true) + val caps = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_CIRCUIT), + controllableCircuits = setOf(CircuitId("x")) + ) + val r = policy.validate( + cmd, + SafetyPolicy.Context(ConnectionStatus.Reconnecting, emptyList(), caps, null) + ) + assertTrue(r is SafetyPolicy.Decision.Reject) + assertTrue((r as SafetyPolicy.Decision.Reject).reason.contains("Reconnect", ignoreCase = true)) + } + + @Test + fun successfulReconnect_installsNewAdapterAndLiveStatus() = runBlocking { + val adapters = mutableListOf() + val factory = { + FakeReconnectAdapter(id = adapters.size + 1).also { adapters.add(it) } + } + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = testPolicy(failuresBefore = 1, maxAttempts = 3), + pollIntervalMs = 60_000 + ) + session.connectWithFactory(SimulatorConnectionTarget(), factory) + assertTrue(session.connection.value.isLiveConnected) + adapters.first().armFailRefresh() + session.refreshNow() + var waited = 0 + while (session.isReconnectInFlight() && waited < 100) { + delay(10) + waited++ + } + assertTrue(adapters.size >= 2) + assertTrue(session.connection.value.isLiveConnected) + assertTrue(adapters.last().connectCount >= 1) + session.close() + } + + // ——— Phase 1.1 regression with new types ——— + + @Test + fun elevatedSpa_usesTemperature_notBareInt() = runBlocking { + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { + SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 5, temperatureUnit = TemperatureUnit.FAHRENHEIT)) + }, + orchestrator = CommandOrchestrator(confirmTimeoutMs = 500, pollIntervalMs = 20), + pollIntervalMs = 60_000 + ) + session.connectSimulator() + val spa = session.site.value!!.primarySpa()!!.id + val result = session.setHeatSetPoint(spa, Temperature.fahrenheit(102), elevatedConfirmed = false) + assertTrue(result.requiresConfirmation) + assertNull(result.receipt) + session.disconnect() + } + + // helpers + + private fun heatCmd(body: BodyId, temp: Temperature, elevated: Boolean = false) = + DomainCommand( + "h", DomainCommandKind.SET_HEAT_SETPOINT, + bodyId = body, temperature = temp, elevatedHeatConfirmed = elevated + ) + + private fun liveCtx(snap: SiteSnapshot) = SafetyPolicy.Context( + ConnectionStatus.Simulator("t"), + emptyList(), + snap.capabilities, + snap + ) + + private fun throwingAdapter(): PoolSystemAdapter = object : PoolSystemAdapter { + override val adapterId = "x" + override val displayName = "x" + override val connection: StateFlow = + MutableStateFlow(ConnectionStatus.Disconnected) + override val snapshot: StateFlow = MutableStateFlow(null) + override val capabilities: Set = emptySet() + override suspend fun connect(target: AdapterConnectionTarget) {} + override suspend fun disconnect() {} + override suspend fun refresh(): SiteSnapshot = error("n/a") + override suspend fun sendCommand(command: DomainCommand) {} + } + + /** + * Controllable fake for reconnect tests. + */ + private class FakeReconnectAdapter( + val id: Int, + val alwaysFailConnect: Boolean = false + ) : PoolSystemAdapter { + override val adapterId = "fake.reconnect" + override val displayName = "Fake $id" + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot: StateFlow = _s.asStateFlow() + override val capabilities: Set = setOf(Capability.CIRCUIT_TOGGLE) + var connectCount = 0 + var disconnectCount = 0 + private var failRefresh = false + private var connected = false + + fun armFailRefresh() { + failRefresh = true + } + + override suspend fun connect(target: AdapterConnectionTarget) { + if (alwaysFailConnect) { + _c.value = ConnectionStatus.ConnectionFailed("forced fail") + throw IllegalStateException("forced connect failure") + } + connectCount++ + connected = true + val snap = SiteSnapshot( + bodies = listOf( + BodyState( + BodyId("fake:pool"), BodyKind.POOL, "Pool", + currentTemp = Temperature.fahrenheit(80), + heatSetPoint = Temperature.fahrenheit(84) + ) + ), + airTemp = Temperature.fahrenheit(70), + temperatureUnit = TemperatureUnit.FAHRENHEIT, + source = ConnectionPath.SIMULATOR, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_CIRCUIT) + ), + asOfMs = System.currentTimeMillis() + ) + _s.value = snap + _c.value = ConnectionStatus.Simulator("fake-$id") + } + + override suspend fun disconnect() { + disconnectCount++ + connected = false + _c.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot { + if (!connected) throw IllegalStateException("not connected") + if (failRefresh) { + failRefresh = false + _c.value = ConnectionStatus.StaleOrDegraded( + ConnectionPath.SIMULATOR, "fail", _s.value?.asOfMs + ) + throw IllegalStateException("refresh fail") + } + val snap = _s.value!!.copy(asOfMs = System.currentTimeMillis()) + _s.value = snap + _c.value = ConnectionStatus.Simulator("fake-$id") + return snap + } + + override suspend fun sendCommand(command: DomainCommand) {} + } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/domain/Phase13LifecycleReliabilityTest.kt b/app/src/test/java/com/pentaircontrol/pool/domain/Phase13LifecycleReliabilityTest.kt new file mode 100644 index 0000000..b74e354 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/domain/Phase13LifecycleReliabilityTest.kt @@ -0,0 +1,523 @@ +package com.pentaircontrol.pool.domain + +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicAdapter +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLocalTarget +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget +import com.pentaircontrol.pool.adapter.simulator.SimulatorAdapter +import com.pentaircontrol.pool.adapter.simulator.SimulatorConnectionTarget +import com.pentaircontrol.pool.session.ReconnectInstrumentation +import com.pentaircontrol.pool.session.ReconnectPolicy +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.AppPreferencesStore +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import com.pentaircontrol.pool.ui.PoolViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * Phase 1.3: lifecycle ownership, production reconnect policy (with fast test inject), + * unit preservation, credential redaction, and deterministic reconnect assertions. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class Phase13LifecycleReliabilityTest { + + private fun fastPolicy( + failuresBefore: Int = 2, + maxAttempts: Int = 3 + ) = ReconnectPolicy( + failuresBeforeReconnect = failuresBefore, + maxAttempts = maxAttempts, + backoffBeforeAttemptMs = { 0L }, + maxBackoffMs = 0L + ) + + private fun session( + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + prefs: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + factory: (() -> PoolSystemAdapter)? = null, + policy: ReconnectPolicy = fastPolicy(), + instrumentation: ReconnectInstrumentation = ReconnectInstrumentation(), + pollMs: Long = 60_000L + ): SiteSession { + return SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = factory ?: { FakeSlAdapter() }, + simulatorFactory = { SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 0)) }, + reconnectPolicy = policy, + instrumentation = instrumentation, + pollIntervalMs = pollMs + ) + } + + // ——— Lifecycle ——— + + @Test + fun close_cancelsPolling_disconnectsAdapter_clearsTarget() = runBlocking { + val adapters = mutableListOf() + val factory = { + FakeSlAdapter().also { adapters.add(it) } + } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val s = session(scope = scope, factory = factory, pollMs = 5) + s.connectWithFactory(SimulatorConnectionTarget(), factory) + assertTrue(s.isPollingActive()) + assertNotNull(s.activeConnectionTargetForTest()) + val first = adapters.first() + s.close() + assertTrue(s.isClosed()) + assertFalse(s.isPollingActive()) + assertFalse(s.isReconnectInFlight()) + assertNull(s.activeConnectionTargetForTest()) + s.awaitCleanupIdle() + assertTrue(first.disconnectCount >= 1) + assertEquals(ConnectionStatus.Disconnected, s.connection.value) + // repeated close safe + s.close() + scope.cancel() + } + + @Test + fun close_duringReconnect_preventsInstall() = runBlocking { + val hangConnect = AtomicBoolean(true) + val installBlocked = AtomicBoolean(false) + val adapters = mutableListOf() + val factory = { + FakeSlAdapter( + connectBlock = { + // First connect succeeds; later connects hang until close + if (adapters.size > 1) { + while (hangConnect.get()) { + delay(5) + } + throw IllegalStateException("aborted") + } + } + ).also { adapters.add(it) } + } + val s = session( + factory = factory, + policy = fastPolicy(failuresBefore = 1, maxAttempts = 5), + pollMs = 60_000 + ) + s.connectWithFactory(SimulatorConnectionTarget(), factory) + adapters.first().armFailRefresh() + s.refreshNow() + // Reconnect should start + var spins = 0 + while (!s.isReconnectInFlight() && spins < 50) { + delay(2) + spins++ + } + s.close() + hangConnect.set(false) + delay(30) + // After close, no live adapter install from stale job + assertTrue(s.isClosed()) + assertNull(s.activeConnectionTargetForTest()) + assertFalse(s.connection.value.isLiveConnected) + installBlocked.set(true) + } + + @Test + fun closedSession_rejectsConnectAndCommands() = runBlocking { + val s = session() + s.close() + s.connectSimulator() + assertTrue(s.connection.value is ConnectionStatus.ConnectionFailed) + assertTrue((s.connection.value as ConnectionStatus.ConnectionFailed).message.contains("closed", ignoreCase = true)) + val r = s.toggleCircuit(CircuitId("x"), false) + assertNotNull(r.rejectedReason) + assertTrue(r.rejectedReason!!.contains("closed", ignoreCase = true)) + } + + @Test + fun viewModelClear_invokesSessionClose() { + val prefs = InMemoryAppPreferencesStore() + val vm = PoolViewModel { scope -> + SiteSession(scope, prefs, reconnectPolicy = fastPolicy(), pollIntervalMs = 60_000) + } + val sess = vm.sessionForTest() + assertFalse(sess.isClosed()) + vm.clearForTest() + assertTrue(sess.isClosed()) + assertFalse(sess.isPollingActive()) + assertFalse(sess.isReconnectInFlight()) + } + + // ——— Production policy defaults ——— + + @Test + fun productionPolicy_usesMultiSecondBackoff() { + val p = ReconnectPolicy.PRODUCTION + assertEquals(2, p.failuresBeforeReconnect) + assertEquals(4, p.maxAttempts) + assertEquals(0L, p.delayBeforeAttempt(0)) + assertEquals(2_000L, p.delayBeforeAttempt(1)) + assertEquals(5_000L, p.delayBeforeAttempt(2)) + assertEquals(10_000L, p.delayBeforeAttempt(3)) + assertTrue(p.delayBeforeAttempt(1) >= 1_000L) + assertTrue(p.maxBackoffMs >= 10_000L) + } + + // ——— Reconnect deterministic ——— + + @Test + fun noConcurrentReconnectJobs_maxConcurrentIsOne() = runBlocking { + val instr = ReconnectInstrumentation() + val adapters = mutableListOf() + val factory = { + FakeSlAdapter(slowConnectMs = 40).also { adapters.add(it) } + } + val s = session( + factory = factory, + policy = fastPolicy(failuresBefore = 1, maxAttempts = 4), + instrumentation = instr, + pollMs = 60_000 + ) + s.connectWithFactory(SimulatorConnectionTarget(), factory) + adapters.first().armFailRefresh() + // Fire multiple failure signals that would each try to schedule reconnect + s.refreshNow() + s.refreshNow() + s.refreshNow() + var spins = 0 + while (s.isReconnectInFlight() && spins < 100) { + delay(5) + spins++ + } + delay(20) + assertEquals(1, instr.maxConcurrentReconnect.get()) + assertTrue(instr.reconnectCycleStarts.get() >= 1) + // Adapter creations: 1 initial + at most maxAttempts for reconnect + assertTrue( + "creations=${instr.adapterCreations.get()}", + instr.adapterCreations.get() <= 1 + 4 + ) + s.close() + } + + @Test + fun reconnectAttemptsBounded_exactAttempts_failedState_preservesSnapshot() = runBlocking { + val instr = ReconnectInstrumentation() + val adapters = mutableListOf() + val maxAttempts = 3 + val factory = { + FakeSlAdapter( + alwaysFailConnect = adapters.isNotEmpty() // first succeeds, rest fail + ).also { adapters.add(it) } + } + val s = session( + factory = factory, + policy = fastPolicy(failuresBefore = 1, maxAttempts = maxAttempts), + instrumentation = instr, + pollMs = 60_000 + ) + s.connectWithFactory(SimulatorConnectionTarget(), factory) + val preserved = s.site.value!!.bodies.first().currentTemp + val first = adapters.first() + first.armFailRefresh() + s.refreshNow() + var spins = 0 + while ((s.isReconnectInFlight() || s.connection.value is ConnectionStatus.Reconnecting) && spins < 200) { + delay(5) + spins++ + } + delay(20) + assertEquals(ConnectionStatus.ConnectionFailed::class.java, s.connection.value::class.java) + assertFalse(s.isReconnectInFlight()) + assertEquals(preserved, s.site.value!!.bodies.first().currentTemp) + assertEquals(maxAttempts, s.lastAutomaticReconnectAttempts()) + assertTrue(first.disconnectCount >= 1) + // Every unsuccessful fresh adapter was disconnected + adapters.drop(1).forEach { + assertTrue("adapter ${it.id} disconnect=${it.disconnectCount}", it.disconnectCount >= 1) + } + assertTrue(s.canRetryConnection.value) + s.close() + } + + @Test + fun explicitRetry_afterExhaustion_reusesTarget_singleFlight() = runBlocking { + val adapters = mutableListOf() + var allowSuccess = false + val factory = { + FakeSlAdapter( + alwaysFailConnect = adapters.isNotEmpty() && !allowSuccess + ).also { adapters.add(it) } + } + val s = session( + factory = factory, + policy = fastPolicy(failuresBefore = 1, maxAttempts = 2), + pollMs = 60_000 + ) + s.connectWithFactory(SimulatorConnectionTarget(), factory) + adapters.first().armFailRefresh() + s.refreshNow() + var spins = 0 + while (s.isReconnectInFlight() && spins < 100) { + delay(5) + spins++ + } + assertTrue(s.connection.value is ConnectionStatus.ConnectionFailed) + assertTrue(s.canRetryConnection.value) + assertNotNull(s.activeConnectionTargetForTest()) + + allowSuccess = true + val ok = s.retryConnection() + assertTrue(ok) + assertTrue(s.connection.value.isLiveConnected) + assertFalse(s.canRetryConnection.value) + s.close() + } + + // ——— Temperature unit preservation ——— + + @Test + fun confirmedUnit_survivesReconnect_clearsOnDisconnectAndNewTarget() = runBlocking { + val adapters = mutableListOf() + val factory = { + FakeSlAdapter(reportUnknownUnit = true).also { adapters.add(it) } + } + val prefs = InMemoryAppPreferencesStore() + val s = session(factory = factory, prefs = prefs, policy = fastPolicy(failuresBefore = 1), pollMs = 60_000) + s.connectWithFactory(ScreenLogicRemoteTarget("AB-CD-EF", "secret"), factory) + assertEquals(TemperatureUnit.UNKNOWN, s.site.value!!.temperatureUnit) + + s.confirmSessionTemperatureUnit(TemperatureUnit.FAHRENHEIT) + assertEquals(TemperatureUnit.FAHRENHEIT, s.site.value!!.temperatureUnit) + assertEquals(TemperatureUnit.FAHRENHEIT, s.sessionConfirmedTemperatureUnit()) + + adapters.first().armFailRefresh() + s.refreshNow() + var spins = 0 + while (s.isReconnectInFlight() && spins < 100) { + delay(5) + spins++ + } + // Successful reconnect (default fake succeeds) should reapply unit + if (s.connection.value.isLiveConnected) { + assertEquals(TemperatureUnit.FAHRENHEIT, s.site.value!!.temperatureUnit) + assertEquals(TemperatureUnit.FAHRENHEIT, s.sessionConfirmedTemperatureUnit()) + } + // Not in preferences + assertNull(prefs.getString(AppPreferencesStore.STALE_KEY_LAST_SYSTEM_CODE)) + assertTrue(prefs.snapshot().values.none { it.contains("secret") }) + + s.disconnect() + assertNull(s.sessionConfirmedTemperatureUnit()) + assertNull(s.activeConnectionTargetForTest()) + + // New session / new target starts unknown + s.connectWithFactory(ScreenLogicRemoteTarget("ZZ-ZZ-ZZ", "other"), factory) + assertNull(s.sessionConfirmedTemperatureUnit()) + // Fake reports unknown until confirm + assertEquals(TemperatureUnit.UNKNOWN, s.site.value?.temperatureUnit ?: TemperatureUnit.UNKNOWN) + s.close() + } + + @Test + fun confirmCelsius_updatesSnapshot() = runBlocking { + val factory = { FakeSlAdapter(reportUnknownUnit = true) } + val s = session(factory = factory) + s.connectWithFactory(ScreenLogicLocalTarget( + DiscoveredGateway("1", "GW", "10.0.0.1", 80) + ), factory) + s.confirmSessionTemperatureUnit(TemperatureUnit.CELSIUS) + assertEquals(TemperatureUnit.CELSIUS, s.site.value!!.temperatureUnit) + assertTrue(s.site.value!!.bodies.first().currentTemp!!.format().endsWith("°C")) + s.close() + } + + // ——— Credential redaction ——— + + @Test + fun localAndRemoteTargets_toString_hidesPassword() { + val local = ScreenLogicLocalTarget( + DiscoveredGateway("1", "Pool GW", "192.168.1.9", 80), + password = "super-secret-pass" + ) + val remote = ScreenLogicRemoteTarget("A1-B2-C3", password = "hunter2") + assertFalse(local.toString().contains("super-secret-pass")) + assertFalse(remote.toString().contains("hunter2")) + assertTrue(local.toString().contains("***")) + assertTrue(remote.toString().contains("***")) + assertFalse(local.displayLabel.contains("super-secret")) + assertFalse(remote.displayLabel.contains("hunter2")) + } + + @Test + fun errorMessages_doNotEmbedPassword() = runBlocking { + val secret = "do-not-leak-password" + val factory = { + FakeSlAdapter(alwaysFailConnect = true, failMessage = "login failed") + } + val s = session(factory = factory) + s.connectWithFactory(ScreenLogicRemoteTarget("AB-CD-EF", secret), factory) + val msg = when (val c = s.connection.value) { + is ConnectionStatus.ConnectionFailed -> c.message + else -> s.lastError.value + } + assertNotNull(msg) + assertFalse(msg!!.contains(secret)) + assertFalse(s.toString().contains(secret)) + s.close() + } + + @Test + fun realScreenLogicAdapter_rejectsForeignTargetWithoutNetwork() = runBlocking { + val adapter = ScreenLogicAdapter() + var threw = false + try { + adapter.connect(SimulatorConnectionTarget("x")) + } catch (e: IllegalArgumentException) { + threw = true + assertFalse(e.message!!.contains("password", ignoreCase = true)) + } + assertTrue(threw) + assertTrue( + adapter.connection.value is ConnectionStatus.ConnectionFailed || + adapter.connection.value is ConnectionStatus.Disconnected || + adapter.connection.value is ConnectionStatus.Connecting + ) + } + + // ——— Remote persistence via connectRemote ——— + + @Test + fun connectRemote_doesNotPersistSystemCodeOrPassword() = runBlocking { + val prefs = InMemoryAppPreferencesStore() + prefs.putString(AppPreferencesStore.STALE_KEY_LAST_SYSTEM_CODE, "OLD") + prefs.putString(AppPreferencesStore.STALE_KEY_REMEMBER_SYSTEM_CODE, "1") + val secret = "remote-password-xyz" + val factory = { FakeSlAdapter() } + val s = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + settings = prefs, + screenLogicFactory = factory, + reconnectPolicy = fastPolicy(), + pollIntervalMs = 60_000 + ) + // init clears stale + assertNull(prefs.getString(AppPreferencesStore.STALE_KEY_LAST_SYSTEM_CODE)) + s.connectRemote("AB-CD-EF", secret) + assertTrue(s.connection.value.isLiveConnected || s.site.value != null) + val snap = prefs.snapshot() + assertFalse(snap.values.any { it.contains("AB-CD-EF") || it.contains(secret) }) + assertNull(prefs.getString(AppPreferencesStore.STALE_KEY_LAST_SYSTEM_CODE)) + assertNull(prefs.getString(AppPreferencesStore.STALE_KEY_LAST_PASSWORD)) + assertNotNull(s.activeConnectionTargetForTest()) + s.disconnect() + assertNull(s.activeConnectionTargetForTest()) + s.close() + } + + // ——— Fake adapter ——— + + private class FakeSlAdapter( + val alwaysFailConnect: Boolean = false, + val reportUnknownUnit: Boolean = false, + val failMessage: String = "forced connect failure", + val slowConnectMs: Long = 0L, + val connectBlock: (suspend () -> Unit)? = null + ) : PoolSystemAdapter { + override val adapterId = "fake.sl" + override val displayName = "Fake SL" + val id = nextId.incrementAndGet() + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot: StateFlow = _s.asStateFlow() + override val capabilities: Set = setOf(Capability.CIRCUIT_TOGGLE) + var disconnectCount = 0 + private var failRefresh = false + private var connected = false + private var unit = if (reportUnknownUnit) TemperatureUnit.UNKNOWN else TemperatureUnit.FAHRENHEIT + + fun armFailRefresh() { + failRefresh = true + } + + fun confirmTemperatureUnit(u: TemperatureUnit) { + unit = u + _s.value = _s.value?.withConfirmedUnit(u) + } + + override suspend fun connect(target: AdapterConnectionTarget) { + connectBlock?.invoke() + if (slowConnectMs > 0) delay(slowConnectMs) + if (alwaysFailConnect) { + _c.value = ConnectionStatus.ConnectionFailed(failMessage) + throw IllegalStateException(failMessage) + } + connected = true + unit = if (reportUnknownUnit) TemperatureUnit.UNKNOWN else TemperatureUnit.FAHRENHEIT + val snap = SiteSnapshot( + bodies = listOf( + BodyState( + BodyId("fake:pool"), BodyKind.POOL, "Pool", + currentTemp = Temperature(80, unit), + heatSetPoint = Temperature(84, unit) + ) + ), + airTemp = Temperature(70, unit), + temperatureUnit = unit, + source = ConnectionPath.REMOTE_MANUFACTURER, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_CIRCUIT) + ), + asOfMs = System.currentTimeMillis() + ) + _s.value = snap + _c.value = ConnectionStatus.ConnectedRemote("fake", "1.1.1.1", 80) + } + + override suspend fun disconnect() { + disconnectCount++ + connected = false + _c.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot { + if (!connected) throw IllegalStateException("not connected") + if (failRefresh) { + failRefresh = false + _c.value = ConnectionStatus.StaleOrDegraded( + ConnectionPath.REMOTE_MANUFACTURER, "fail", _s.value?.asOfMs + ) + throw IllegalStateException("refresh fail") + } + val snap = _s.value!!.copy(asOfMs = System.currentTimeMillis()) + _s.value = snap + _c.value = ConnectionStatus.ConnectedRemote("fake", "1.1.1.1", 80) + return snap + } + + override suspend fun sendCommand(command: DomainCommand) {} + + companion object { + private val nextId = AtomicInteger(0) + } + } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/domain/Phase14TeardownRetryRaceTest.kt b/app/src/test/java/com/pentaircontrol/pool/domain/Phase14TeardownRetryRaceTest.kt new file mode 100644 index 0000000..1431977 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/domain/Phase14TeardownRetryRaceTest.kt @@ -0,0 +1,511 @@ +package com.pentaircontrol.pool.domain + +import com.pentaircontrol.pool.adapter.simulator.SimulatorConnectionTarget +import com.pentaircontrol.pool.session.AdapterCleanupScheduler +import com.pentaircontrol.pool.session.ReconnectPolicy +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.system.measureTimeMillis + +/** + * Phase 1.4: non-blocking close and generation-guarded reconnect/retry races. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class Phase14TeardownRetryRaceTest { + + private fun fastPolicy(maxAttempts: Int = 3) = ReconnectPolicy( + failuresBeforeReconnect = 1, + maxAttempts = maxAttempts, + backoffBeforeAttemptMs = { 0L }, + maxBackoffMs = 0L + ) + + // ——— Non-blocking close ——— + + @Test + fun close_returnsWithoutWaitingForSuspendedDisconnect() = runBlocking { + val disconnectStarted = CompletableDeferred() + val allowDisconnect = CompletableDeferred() + val factory = { + HangingAdapter( + onDisconnect = { + disconnectStarted.complete(Unit) + allowDisconnect.await() + } + ) + } + val cleanup = AdapterCleanupScheduler(dispatcher = Dispatchers.Default, timeoutMs = 10_000) + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = fastPolicy(), + cleanup = cleanup, + pollIntervalMs = 60_000 + ) + session.connectWithFactory(SimulatorConnectionTarget("hang"), factory) + assertTrue(session.connection.value.isLiveConnected) + + val closeMs = measureTimeMillis { + session.close() + } + // Must not block waiting for hanging disconnect (would take forever without allow). + assertTrue("close took ${closeMs}ms", closeMs < 500) + assertTrue(session.isClosed()) + assertNull(session.activeConnectionTargetForTest()) + assertNull(session.sessionConfirmedTemperatureUnit()) + assertFalse(session.isPollingActive()) + assertFalse(session.isReconnectInFlight()) + assertEquals(ConnectionStatus.Disconnected, session.connection.value) + + // Cleanup eventually runs + assertTrue(disconnectStarted.await().let { true }) + allowDisconnect.complete(Unit) + withTimeout(5_000) { cleanup.awaitIdle() } + assertTrue(session.cleanupCompletedCount() >= 1) + } + + @Test + fun close_returnsWhileConnectInProgress_preventsInstall() = runBlocking { + val connectGate = CompletableDeferred() + val connectEntered = CompletableDeferred() + val factory = { + HangingAdapter( + onConnect = { + connectEntered.complete(Unit) + connectGate.await() + } + ) + } + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = fastPolicy(), + cleanup = AdapterCleanupScheduler(dispatcher = Dispatchers.Default), + pollIntervalMs = 60_000 + ) + val connectJob = async { + session.connectWithFactory(SimulatorConnectionTarget("slow"), factory) + } + connectEntered.await() + val closeMs = measureTimeMillis { session.close() } + assertTrue(closeMs < 500) + connectGate.complete(Unit) + connectJob.await() + assertTrue(session.isClosed()) + assertNull(session.adapterForTest()) + assertFalse(session.connection.value.isLiveConnected) + assertFalse(session.isPollingActive()) + // Multiple close safe + session.close() + session.close() + } + + @Test + fun productionLifecycle_hasNoRunBlockingInSiteSessionClose() { + val candidates = listOf( + java.io.File("app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt"), + java.io.File("src/main/java/com/pentaircontrol/pool/session/SiteSession.kt"), + java.io.File( + System.getProperty("user.dir"), + "app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt" + ) + ) + val source = candidates.firstOrNull { it.exists() } + assertTrue( + "SiteSession.kt not found from ${System.getProperty("user.dir")}", + source != null + ) + val codeLines = source!!.readLines().filter { line -> + val t = line.trim() + !t.startsWith("//") && !t.startsWith("*") && !t.startsWith("/*") + } + assertFalse( + "SiteSession production code must not call runBlocking", + codeLines.any { it.contains("runBlocking") } + ) + assertFalse(codeLines.any { it.contains("GlobalScope") }) + } + + // ——— Retry vs disconnect ——— + + @Test + fun retryThenDisconnect_staleConnectDoesNotInstall() = runBlocking { + // phase: live → fail (exhaust auto-reconnect) → hang (explicit retry) + val phase = AtomicInteger(0) + val connectGate = CompletableDeferred() + val retryEntered = CompletableDeferred() + val factory = { + when (phase.get()) { + 0 -> InstantAdapter() + 1 -> InstantAdapter(failConnect = true) + else -> HangingAdapter( + onConnect = { + retryEntered.complete(Unit) + connectGate.await() + } + ) + } + } + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = fastPolicy(maxAttempts = 2), + cleanup = AdapterCleanupScheduler(dispatcher = Dispatchers.Default), + pollIntervalMs = 60_000 + ) + session.connectWithFactory(SimulatorConnectionTarget("A"), factory) + phase.set(1) + (session.adapterForTest() as InstantAdapter).armFailRefresh() + session.refreshNow() + var spins = 0 + while ( + (session.isReconnectInFlight() || + session.connection.value !is ConnectionStatus.ConnectionFailed) && + spins < 200 + ) { + delay(5) + spins++ + } + assertTrue( + "state=${session.connection.value}", + session.connection.value is ConnectionStatus.ConnectionFailed + ) + assertTrue(session.canRetryConnection.value) + + phase.set(2) + val retryJob = async { session.retryConnection() } + withTimeout(3_000) { retryEntered.await() } + session.disconnect() + assertEquals(ConnectionStatus.Disconnected, session.connection.value) + assertNull(session.activeConnectionTargetForTest()) + assertFalse(session.isReconnectInFlight()) + connectGate.complete(Unit) + assertFalse(retryJob.await()) + assertEquals(ConnectionStatus.Disconnected, session.connection.value) + assertNull(session.adapterForTest()) + assertFalse(session.isPollingActive()) + assertNull(session.activeConnectionTargetForTest()) + session.awaitCleanupIdle() + session.close() + } + + // ——— Retry A vs connect B ——— + + @Test + fun retryA_thenConnectB_staleADoesNotOverwrite() = runBlocking { + // phase: 0 live A, 1 fail reconnect, 2 hang retry A, B uses separate factory + val phase = AtomicInteger(0) + val hangGate = CompletableDeferred() + val hangEntered = CompletableDeferred() + val targetA = SimulatorConnectionTarget("TargetA") + val targetB = SimulatorConnectionTarget("TargetB") + val factoryA = { + when (phase.get()) { + 0 -> InstantAdapter(label = "A-live") + 1 -> InstantAdapter(label = "A-fail", failConnect = true) + else -> HangingAdapter( + label = "A-stale", + onConnect = { + hangEntered.complete(Unit) + hangGate.await() + } + ) + } + } + val factoryB = { InstantAdapter(label = "B-live") } + + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = fastPolicy(maxAttempts = 2), + cleanup = AdapterCleanupScheduler(dispatcher = Dispatchers.Default), + pollIntervalMs = 60_000 + ) + session.connectWithFactory(targetA, factoryA) + phase.set(1) + (session.adapterForTest() as InstantAdapter).armFailRefresh() + session.refreshNow() + var spins = 0 + while ( + (session.isReconnectInFlight() || + session.connection.value !is ConnectionStatus.ConnectionFailed) && + spins < 200 + ) { + delay(5) + spins++ + } + assertTrue(session.canRetryConnection.value) + + phase.set(2) + val retryJob = async { session.retryConnection() } + withTimeout(3_000) { hangEntered.await() } + + // Connect B while A retry is mid-connect + session.connectWithFactory(targetB, factoryB) + assertTrue(session.connection.value.isLiveConnected) + assertEquals(targetB, session.activeConnectionTargetForTest()) + val bAdapter = session.adapterForTest() as InstantAdapter + assertEquals("B-live", bAdapter.label) + + hangGate.complete(Unit) + retryJob.await() + + // B still active + assertTrue(session.connection.value.isLiveConnected) + assertEquals(targetB, session.activeConnectionTargetForTest()) + assertEquals("B-live", (session.adapterForTest() as InstantAdapter).label) + assertTrue(session.isPollingActive()) + session.close() + } + + // ——— Auto reconnect vs disconnect ——— + + @Test + fun automaticReconnect_thenDisconnect_preventsInstall() = runBlocking { + val first = AtomicBoolean(true) + val hangGate = CompletableDeferred() + val hangEntered = CompletableDeferred() + val factory = { + if (first.getAndSet(false)) { + InstantAdapter() + } else { + HangingAdapter( + onConnect = { + hangEntered.complete(Unit) + hangGate.await() + } + ) + } + } + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = fastPolicy(maxAttempts = 3), + cleanup = AdapterCleanupScheduler(dispatcher = Dispatchers.Default), + pollIntervalMs = 60_000 + ) + session.connectWithFactory(SimulatorConnectionTarget("auto"), factory) + (session.adapterForTest() as InstantAdapter).armFailRefresh() + session.refreshNow() + hangEntered.await() + session.disconnect() + hangGate.complete(Unit) + delay(50) + assertEquals(ConnectionStatus.Disconnected, session.connection.value) + assertNull(session.adapterForTest()) + assertFalse(session.isPollingActive()) + assertFalse(session.isReconnectInFlight()) + assertNull(session.activeConnectionTargetForTest()) + session.close() + } + + // ——— Close during retry ——— + + @Test + fun close_duringRetry_remainsClosed_noInstall() = runBlocking { + val phase = AtomicInteger(0) + val hangGate = CompletableDeferred() + val hangEntered = CompletableDeferred() + val factory = { + when (phase.get()) { + 0 -> InstantAdapter() + 1 -> InstantAdapter(failConnect = true) + else -> HangingAdapter( + onConnect = { + hangEntered.complete(Unit) + hangGate.await() + } + ) + } + } + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = fastPolicy(maxAttempts = 2), + cleanup = AdapterCleanupScheduler(dispatcher = Dispatchers.Default), + pollIntervalMs = 60_000 + ) + session.connectWithFactory(SimulatorConnectionTarget("c"), factory) + phase.set(1) + (session.adapterForTest() as InstantAdapter).armFailRefresh() + session.refreshNow() + var spins = 0 + while ( + (session.isReconnectInFlight() || + session.connection.value !is ConnectionStatus.ConnectionFailed) && + spins < 200 + ) { + delay(5) + spins++ + } + phase.set(2) + val retryJob = async { session.retryConnection() } + withTimeout(3_000) { hangEntered.await() } + session.close() + hangGate.complete(Unit) + retryJob.await() + assertTrue(session.isClosed()) + assertNull(session.adapterForTest()) + assertFalse(session.isPollingActive()) + assertEquals(ConnectionStatus.Disconnected, session.connection.value) + } + + // ——— Unit preservation still works on valid reconnect ——— + + @Test + fun validReconnect_sameTarget_preservesConfirmedUnit() = runBlocking { + val adapters = mutableListOf() + val factory = { + InstantAdapter(reportUnknownUnit = true).also { adapters.add(it) } + } + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = fastPolicy(maxAttempts = 2), + cleanup = AdapterCleanupScheduler(dispatcher = Dispatchers.Default), + pollIntervalMs = 60_000 + ) + session.connectWithFactory(SimulatorConnectionTarget("u"), factory) + session.confirmSessionTemperatureUnit(TemperatureUnit.FAHRENHEIT) + assertEquals(TemperatureUnit.FAHRENHEIT, session.site.value!!.temperatureUnit) + adapters.first().armFailRefresh() + session.refreshNow() + var spins = 0 + while (session.isReconnectInFlight() && spins < 100) { + delay(5) + spins++ + } + assertTrue(session.connection.value.isLiveConnected) + assertEquals(TemperatureUnit.FAHRENHEIT, session.sessionConfirmedTemperatureUnit()) + assertEquals(TemperatureUnit.FAHRENHEIT, session.site.value!!.temperatureUnit) + session.close() + } + + // ——— Helpers ——— + + private class InstantAdapter( + val label: String = "instant", + private val reportUnknownUnit: Boolean = false, + private val failConnect: Boolean = false + ) : PoolSystemAdapter { + override val adapterId = "test.instant" + override val displayName = label + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot: StateFlow = _s.asStateFlow() + override val capabilities: Set = setOf(Capability.CIRCUIT_TOGGLE) + private var failRefresh = false + private var connected = false + var disconnectCount = 0 + + fun armFailRefresh() { + failRefresh = true + } + + override suspend fun connect(target: AdapterConnectionTarget) { + if (failConnect) { + _c.value = ConnectionStatus.ConnectionFailed("fail connect") + throw IllegalStateException("fail connect") + } + connected = true + val unit = if (reportUnknownUnit) TemperatureUnit.UNKNOWN else TemperatureUnit.FAHRENHEIT + _s.value = SiteSnapshot( + bodies = listOf( + BodyState( + BodyId("t:pool"), BodyKind.POOL, "Pool", + currentTemp = Temperature(80, unit), + heatSetPoint = Temperature(84, unit) + ) + ), + temperatureUnit = unit, + airTemp = Temperature(70, unit), + source = ConnectionPath.SIMULATOR, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_CIRCUIT) + ) + ) + _c.value = ConnectionStatus.Simulator(label) + } + + override suspend fun disconnect() { + disconnectCount++ + connected = false + _c.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot { + if (!connected) throw IllegalStateException("not connected") + if (failRefresh) { + failRefresh = false + throw IllegalStateException("refresh fail") + } + val snap = _s.value!!.copy(asOfMs = System.currentTimeMillis()) + _s.value = snap + _c.value = ConnectionStatus.Simulator(label) + return snap + } + + override suspend fun sendCommand(command: DomainCommand) {} + } + + private class HangingAdapter( + val label: String = "hang", + private val onConnect: (suspend () -> Unit)? = null, + private val onDisconnect: (suspend () -> Unit)? = null + ) : PoolSystemAdapter { + override val adapterId = "test.hang" + override val displayName = label + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot: StateFlow = _s.asStateFlow() + override val capabilities: Set = emptySet() + + override suspend fun connect(target: AdapterConnectionTarget) { + onConnect?.invoke() + _s.value = SiteSnapshot( + bodies = listOf( + BodyState(BodyId("h:pool"), BodyKind.POOL, "Pool", + currentTemp = Temperature.fahrenheit(80)) + ), + temperatureUnit = TemperatureUnit.FAHRENHEIT, + source = ConnectionPath.SIMULATOR + ) + _c.value = ConnectionStatus.Simulator(label) + } + + override suspend fun disconnect() { + onDisconnect?.invoke() + _c.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot = _s.value!! + override suspend fun sendCommand(command: DomainCommand) {} + } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/domain/Phase1FoundationTest.kt b/app/src/test/java/com/pentaircontrol/pool/domain/Phase1FoundationTest.kt new file mode 100644 index 0000000..c7a923c --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/domain/Phase1FoundationTest.kt @@ -0,0 +1,397 @@ +package com.pentaircontrol.pool.domain + +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicHeatMapper +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicIds +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLightPrograms +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicMapper +import com.pentaircontrol.pool.adapter.simulator.SimulatorAdapter +import com.pentaircontrol.pool.adapter.simulator.SimulatorConnectionTarget +import com.pentaircontrol.pool.data.BodyStatus +import com.pentaircontrol.pool.data.Circuit +import com.pentaircontrol.pool.data.PoolStatus +import com.pentaircontrol.pool.protocol.ScreenLogicDiscoveryParser as ProtocolDiscoveryParser +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +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.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import com.pentaircontrol.pool.data.HeatMode as LegacyHeatMode +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID + +class Phase1FoundationTest { + + // ——— Discovery ——— + + @Test + fun discoveryParser_readsPortAtOffset8_andRequiresType2() { + val buf = ByteBuffer.allocate(40).order(ByteOrder.LITTLE_ENDIAN) + buf.putInt(0, 2) + buf.putInt(4, 0xDEADBEEF.toInt()) + buf.putShort(8, 80.toShort()) + buf.put(10, 1) + buf.put(11, 0) + val name = "Pentair: AB-CD-EF" + System.arraycopy(name.toByteArray(Charsets.US_ASCII), 0, buf.array(), 12, name.length) + val unit = ProtocolDiscoveryParser.parse(buf.array(), 40, "192.168.1.50") + assertNotNull(unit) + assertEquals(80, unit!!.port) + } + + @Test + fun discoveryParser_rejectsNonType2() { + val buf = ByteBuffer.allocate(20).order(ByteOrder.LITTLE_ENDIAN) + buf.putInt(0, 1) + buf.putShort(8, 80.toShort()) + assertNull(ProtocolDiscoveryParser.parse(buf.array(), 20, "10.0.0.1")) + } + + // ——— Domain neutrality ——— + + @Test + fun domainHeatMode_hasNoProtocolNumericMappingProperties() { + assertEquals("Heater", HeatMode.HEATER.label) + assertEquals("Off", HeatMode.OFF.label) + assertEquals(3, ScreenLogicHeatMapper.toProtocol(HeatMode.HEATER)) + assertEquals(HeatMode.HEATER, ScreenLogicHeatMapper.fromProtocol(3)) + assertEquals(0, ScreenLogicHeatMapper.toProtocol(HeatMode.OFF)) + } + + @Test + fun lightProtocolValues_onlyInScreenLogicAdapter() { + val programs = ScreenLogicLightPrograms.domainPrograms() + assertTrue(programs.isNotEmpty()) + assertTrue(programs.all { it.adapterId == ScreenLogicIds.ADAPTER_ID }) + assertTrue(programs.all { it.targetId == ScreenLogicLightPrograms.ALL_LIGHTS_TARGET_ID }) + val party = programs.find { it.displayName == "Party" } + assertNotNull(party) + assertEquals(5, ScreenLogicLightPrograms.protocolCode(party!!.id)) + assertTrue(DomainCommandKind.SEND_LIGHT_PROGRAM.name.contains("LIGHT")) + } + + @Test + fun screenLogic_mapsIntsToStableOpaqueIds() { + val legacy = PoolStatus( + airTemp = 70, + pool = BodyStatus(0, "Pool", 80, 84, heatMode = LegacyHeatMode.HEATER, heatActive = true), + spa = BodyStatus(1, "Spa", 99, 100, heatMode = LegacyHeatMode.OFF, heatActive = false), + circuits = listOf(Circuit(500, "Spa", true)) + ) + val snap = ScreenLogicMapper.toDomain(legacy, ConnectionPath.LOCAL_LAN) + assertEquals(BodyId("screenlogic:body:0"), snap.primaryPool()!!.id) + assertEquals(CircuitId("screenlogic:circuit:500"), snap.circuits[0].id) + assertTrue(snap.primaryPool()!!.id.value.startsWith("screenlogic:")) + assertEquals(ScreenLogicLightPrograms.ALL_LIGHTS_TARGET_ID, snap.lightTargets.first().id) + } + + @Test + fun poolOnly_and_spaOnly_snapshots_work() { + val poolOnly = SimulatorAdapter.initialSnapshot( + SimulatorAdapter.Config(bodyKinds = setOf(BodyKind.POOL), supportLights = false) + ) + assertEquals(1, poolOnly.bodies.size) + assertNotNull(poolOnly.primaryPool()) + assertNull(poolOnly.primarySpa()) + + val spaOnly = SimulatorAdapter.initialSnapshot( + SimulatorAdapter.Config(bodyKinds = setOf(BodyKind.SPA)) + ) + assertEquals(1, spaOnly.bodies.size) + assertNotNull(spaOnly.primarySpa()) + assertNull(spaOnly.primaryPool()) + } + + @Test + fun fakeNonScreenLogicAdapter_providesDomainTypesOnly() = runBlocking { + val fake = limitedAdapter( + kinds = setOf(DomainCommandKind.SET_CIRCUIT), + circuits = setOf(CircuitId("fake:circuit:filter")), + bodies = emptySet(), + lights = emptySet() + ) + fake.connect(SimulatorConnectionTarget()) + val snap = fake.snapshot.value!! + assertTrue(snap.bodies.isEmpty() || snap.bodies.none { it.id.value.startsWith("screenlogic:") }) + assertTrue(snap.circuits.all { it.id.value.startsWith("fake:") }) + } + + // ——— Safety / connection states ——— + + @Test + fun safety_staleAndReconnecting_messages_areReachable() { + val policy = SafetyPolicy() + val cmd = DomainCommand("1", DomainCommandKind.SET_CIRCUIT, circuitId = CircuitId("x"), circuitOn = true) + val caps = SystemCapabilities(commandKinds = setOf(DomainCommandKind.SET_CIRCUIT), controllableCircuits = setOf(CircuitId("x"))) + val stale = policy.validate( + cmd, + SafetyPolicy.Context( + ConnectionStatus.StaleOrDegraded(ConnectionPath.LOCAL_LAN, "timeout", null), + emptyList(), + caps, + null + ) + ) + assertTrue(stale is SafetyPolicy.Decision.Reject) + assertTrue((stale as SafetyPolicy.Decision.Reject).reason.contains("stale", ignoreCase = true)) + + val reconnecting = policy.validate( + cmd, + SafetyPolicy.Context(ConnectionStatus.Reconnecting, emptyList(), caps, null) + ) + assertTrue((reconnecting as SafetyPolicy.Decision.Reject).reason.contains("Reconnect", ignoreCase = true)) + + val auth = policy.validate( + cmd, + SafetyPolicy.Context(ConnectionStatus.AuthenticationRequired, emptyList(), caps, null) + ) + assertTrue((auth as SafetyPolicy.Decision.Reject).reason.contains("Authentication", ignoreCase = true)) + } + + @Test + fun safety_elevatedSpa_requireConfirmation_notFailedReceipt() = runBlocking { + val sim = SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 5)) + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { sim }, + orchestrator = CommandOrchestrator(confirmTimeoutMs = 1_000, pollIntervalMs = 20), + pollIntervalMs = 60_000 + ) + session.connectSimulator() + val spaId = session.site.value!!.primarySpa()!!.id + val before = session.commandReceipts.value.size + val result = session.setHeatSetPoint(spaId, Temperature.fahrenheit(102), elevatedConfirmed = false) + assertTrue(result.requiresConfirmation) + assertNull(result.receipt) + assertEquals(before, session.commandReceipts.value.size) + assertFalse(session.commandReceipts.value.any { it.lifecycle == CommandLifecycle.Failed }) + session.disconnect() + } + + // ——— SiteSession full path capabilities ——— + + @Test + fun siteSession_rejectsHeatWhenAdapterHasNoHeatCapability() = runBlocking { + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { + SimulatorAdapter( + SimulatorAdapter.Config( + supportHeat = false, + supportLights = false, + bodyKinds = setOf(BodyKind.POOL) + ) + ) + }, + orchestrator = CommandOrchestrator(confirmTimeoutMs = 500, pollIntervalMs = 20), + pollIntervalMs = 60_000 + ) + session.connectSimulator() + val body = session.site.value!!.bodies.first().id + val result = session.setHeatSetPoint(body, Temperature.fahrenheit(86)) + assertNotNull(result.rejectedReason) + assertTrue( + result.rejectedReason!!.contains("does not support", ignoreCase = true) || + result.rejectedReason!!.contains("heat", ignoreCase = true) + ) + session.disconnect() + } + + @Test + fun siteSession_rejectsLightWhenNoLights() = runBlocking { + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { + SimulatorAdapter(SimulatorAdapter.Config(supportLights = false)) + }, + orchestrator = CommandOrchestrator(confirmTimeoutMs = 500, pollIntervalMs = 20), + pollIntervalMs = 60_000 + ) + session.connectSimulator() + val result = session.sendLightProgram(LightProgramId("simulator:light:party")) + assertNotNull(result.rejectedReason) + session.disconnect() + } + + @Test + fun siteSession_rejectsUnknownCircuit() = runBlocking { + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 5)) }, + orchestrator = CommandOrchestrator(confirmTimeoutMs = 500, pollIntervalMs = 20), + pollIntervalMs = 60_000 + ) + session.connectSimulator() + val result = session.toggleCircuit(CircuitId("unknown:circuit"), currentlyOn = false) + assertNotNull(result.rejectedReason) + assertTrue( + result.rejectedReason!!.contains("not controllable", ignoreCase = true) || + result.rejectedReason!!.contains("unknown", ignoreCase = true) + ) + session.disconnect() + } + + @Test + fun siteSession_poolOnly_noFabricatedSpa() = runBlocking { + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { + SimulatorAdapter( + SimulatorAdapter.Config(bodyKinds = setOf(BodyKind.POOL), supportLights = false) + ) + }, + pollIntervalMs = 60_000 + ) + session.connectSimulator() + val site = session.site.value!! + assertNull(site.primarySpa()) + assertNotNull(site.primaryPool()) + assertEquals(1, site.bodies.size) + session.disconnect() + } + + @Test + fun siteSession_lightProgramsComeFromAdapterOnly() = runBlocking { + val session = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 5)) }, + orchestrator = CommandOrchestrator(confirmTimeoutMs = 1_500, pollIntervalMs = 20), + pollIntervalMs = 60_000 + ) + session.connectSimulator() + val programs = session.site.value!!.lightPrograms + assertTrue(programs.isNotEmpty()) + assertTrue(programs.all { it.adapterId == SimulatorAdapter.ADAPTER_ID }) + val bad = session.sendLightProgram(ScreenLogicIds.lightProgram("party")) + assertNotNull(bad.rejectedReason) + session.disconnect() + } + + // ——— Command lifecycle ——— + + @Test + fun circuit_pendingToApplied_usesDomainIds() = runBlocking { + val sim = SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 5)) + sim.connect(SimulatorConnectionTarget()) + val orch = CommandOrchestrator(confirmTimeoutMs = 2_000, pollIntervalMs = 20) + val id = SimulatorAdapter.circuitId("waterfall") + val cmd = DomainCommand( + UUID.randomUUID().toString(), + DomainCommandKind.SET_CIRCUIT, + circuitId = id, + circuitOn = true + ) + val result = orch.submit( + cmd, + sim.connection.value, + sim, + sim.snapshot.value, + sim.snapshot.value!!.capabilities + ) + assertEquals(CommandLifecycle.Applied, result.receipt!!.lifecycle) + assertTrue(sim.snapshot.value!!.circuit(id)!!.isOn) + } + + @Test + fun light_command_isSentUnverified_notApplied() = runBlocking { + val sim = SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 5)) + sim.connect(SimulatorConnectionTarget()) + val orch = CommandOrchestrator(confirmTimeoutMs = 1_000, pollIntervalMs = 20) + val programId = SimulatorAdapter.lightId("party") + val result = orch.submit( + DomainCommand( + UUID.randomUUID().toString(), + DomainCommandKind.SEND_LIGHT_PROGRAM, + lightProgramId = programId, + lightTargetId = SimulatorAdapter.PRIMARY_LIGHT_TARGET + ), + sim.connection.value, + sim, + sim.snapshot.value, + sim.snapshot.value!!.capabilities + ) + assertEquals(CommandLifecycle.SentUnverified, result.receipt!!.lifecycle) + assertTrue(result.receipt!!.message!!.contains("does not report", ignoreCase = true)) + assertFalse(result.receipt!!.message!!.contains("Equipment reported the requested state.")) + } + + @Test + fun writeSuccessWithoutStateChange_timesOut() = runBlocking { + val sim = SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 0)) + sim.connect(SimulatorConnectionTarget()) + sim.armTimeoutNextCommand(0) + val orch = CommandOrchestrator(confirmTimeoutMs = 300, pollIntervalMs = 40) + val id = SimulatorAdapter.circuitId("cleaner") + val result = orch.submit( + DomainCommand(UUID.randomUUID().toString(), DomainCommandKind.SET_CIRCUIT, circuitId = id, circuitOn = true), + sim.connection.value, + sim, + sim.snapshot.value, + sim.snapshot.value!!.capabilities + ) + assertEquals(CommandLifecycle.TimedOut, result.receipt!!.lifecycle) + } + + @Test + fun connectionLoss_preservesLastKnown() = runBlocking { + val sim = SimulatorAdapter() + sim.connect(SimulatorConnectionTarget()) + val before = sim.snapshot.value + sim.forceConnectionLoss("drop") + assertTrue(sim.connection.value is ConnectionStatus.StaleOrDegraded) + assertEquals(before!!.bodies.first().currentTemp, sim.snapshot.value!!.bodies.first().currentTemp) + } + + // helpers + + private fun limitedAdapter( + kinds: Set, + circuits: Set, + bodies: Set, + lights: Set + ): PoolSystemAdapter { + val caps = SystemCapabilities(kinds, circuits, bodies, lights) + val snap = SiteSnapshot( + bodies = emptyList(), + circuits = circuits.map { CircuitState(it, it.value, false) }, + lightPrograms = emptyList(), + capabilities = caps, + source = ConnectionPath.SIMULATOR, + temperatureUnit = TemperatureUnit.FAHRENHEIT + ) + return object : PoolSystemAdapter { + override val adapterId = "fake" + override val displayName = "fake" + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot = _s.asStateFlow() + override val capabilities = setOf(Capability.CIRCUIT_TOGGLE) + override suspend fun connect(target: AdapterConnectionTarget) { + _c.value = ConnectionStatus.Simulator("fake") + _s.value = snap + } + override suspend fun disconnect() { + _c.value = ConnectionStatus.Disconnected + } + override suspend fun refresh(): SiteSnapshot = _s.value!! + override suspend fun sendCommand(command: DomainCommand) {} + } + } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/domain/SimulatorWalkthroughTest.kt b/app/src/test/java/com/pentaircontrol/pool/domain/SimulatorWalkthroughTest.kt new file mode 100644 index 0000000..736d074 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/domain/SimulatorWalkthroughTest.kt @@ -0,0 +1,169 @@ +package com.pentaircontrol.pool.domain + +import com.pentaircontrol.pool.adapter.simulator.SimulatorAdapter +import com.pentaircontrol.pool.adapter.simulator.SimulatorConnectionTarget +import com.pentaircontrol.pool.session.ReconnectPolicy +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Programmatic simulator walkthrough covering Phase 1.3 merge checklist items + * that do not require live pool hardware. + * + * Note: On-device Compose UI rendering requires an emulator/device; this environment + * had no AVD or attached device. Session/domain behavior for the simulator path + * is verified here end-to-end. + */ +class SimulatorWalkthroughTest { + + private val fast = ReconnectPolicy( + failuresBeforeReconnect = 1, + maxAttempts = 3, + backoffBeforeAttemptMs = { 0L }, + maxBackoffMs = 0L + ) + + @Test + fun simulatorWalkthrough_fAndC_circuits_heat_lights_reconnect_retry_close() = runBlocking { + // Fahrenheit profile + val fSim = SimulatorAdapter( + SimulatorAdapter.Config(applyDelayMs = 5, temperatureUnit = TemperatureUnit.FAHRENHEIT) + ) + val sessionF = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { fSim }, + orchestrator = CommandOrchestrator(confirmTimeoutMs = 2_000, pollIntervalMs = 20), + reconnectPolicy = fast, + pollIntervalMs = 60_000 + ) + sessionF.connectSimulator("Walkthrough F") + assertTrue(sessionF.connection.value is ConnectionStatus.Simulator) + val poolF = sessionF.site.value!!.primaryPool()!! + assertTrue(poolF.currentTemp!!.format().endsWith("°F")) + assertTrue(sessionF.site.value!!.airTemp!!.format().endsWith("°F")) + + // Circuit pending → applied + val circuit = sessionF.site.value!!.circuits.first { it.displayName == "Waterfall" } + val toggle = sessionF.toggleCircuit(circuit.id, circuit.isOn) + assertEquals(CommandLifecycle.Applied, toggle.receipt!!.lifecycle) + + // Elevated spa confirmation required; cancel path leaves no failure receipt + val spa = sessionF.site.value!!.primarySpa()!!.id + val elev = sessionF.setHeatSetPoint(spa, Temperature.fahrenheit(102), elevatedConfirmed = false) + assertTrue(elev.requiresConfirmation) + assertNull(elev.receipt) + sessionF.clearPendingConfirmation() + val elevOk = sessionF.setHeatSetPoint(spa, Temperature.fahrenheit(102), elevatedConfirmed = true) + assertEquals(CommandLifecycle.Applied, elevOk.receipt!!.lifecycle) + + // Light SentUnverified + val party = sessionF.site.value!!.lightPrograms.first { it.displayName == "Party" } + val light = sessionF.sendLightProgram(party.id, party.targetId) + assertEquals(CommandLifecycle.SentUnverified, light.receipt!!.lifecycle) + assertTrue(light.receipt!!.message!!.contains("does not report", ignoreCase = true)) + + // Connection loss preserves last known + val known = sessionF.site.value!!.bodies.first().currentTemp + fSim.forceConnectionLoss("walkthrough loss") + assertTrue(fSim.connection.value is ConnectionStatus.StaleOrDegraded) + assertEquals(known, fSim.snapshot.value!!.bodies.first().currentTemp) + + sessionF.close() + assertTrue(sessionF.isClosed()) + assertFalse(sessionF.isPollingActive()) + + // Celsius profile + val sessionC = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + settings = InMemoryAppPreferencesStore(), + simulatorFactory = { + SimulatorAdapter( + SimulatorAdapter.Config(applyDelayMs = 5, temperatureUnit = TemperatureUnit.CELSIUS) + ) + }, + reconnectPolicy = fast, + pollIntervalMs = 60_000 + ) + sessionC.connectSimulator("Walkthrough C") + assertTrue(sessionC.site.value!!.primaryPool()!!.currentTemp!!.format().endsWith("°C")) + sessionC.disconnect() + assertEquals(ConnectionStatus.Disconnected, sessionC.connection.value) + sessionC.close() + + // Exhausted reconnect + explicit retry + val adapters = mutableListOf() + var allow = true + val factory = { + CountingSim(failConnect = !allow && adapters.isNotEmpty()).also { adapters.add(it) } + } + val sessionR = SiteSession( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + settings = InMemoryAppPreferencesStore(), + reconnectPolicy = fast, + pollIntervalMs = 60_000 + ) + sessionR.connectWithFactory(SimulatorConnectionTarget("retry"), factory) + val preserved = sessionR.site.value + allow = false + adapters.first().armFailRefresh() + sessionR.refreshNow() + var spins = 0 + while (sessionR.isReconnectInFlight() && spins < 100) { + delay(5) + spins++ + } + assertTrue(sessionR.connection.value is ConnectionStatus.ConnectionFailed) + assertNotNull(sessionR.site.value) + assertEquals(preserved!!.bodies.first().currentTemp, sessionR.site.value!!.bodies.first().currentTemp) + assertTrue(sessionR.canRetryConnection.value) + + allow = true + assertTrue(sessionR.retryConnection()) + assertTrue(sessionR.connection.value.isLiveConnected) + sessionR.close() + assertTrue(sessionR.isClosed()) + } + + private class CountingSim(private val failConnect: Boolean = false) : PoolSystemAdapter { + private val inner = SimulatorAdapter(SimulatorAdapter.Config(applyDelayMs = 0)) + private var failRefresh = false + override val adapterId = SimulatorAdapter.ADAPTER_ID + override val displayName = "counting" + override val connection get() = inner.connection + override val snapshot get() = inner.snapshot + override val capabilities get() = inner.capabilities + + fun armFailRefresh() { + failRefresh = true + } + + override suspend fun connect(target: AdapterConnectionTarget) { + if (failConnect) throw IllegalStateException("fail connect") + inner.connect(target) + } + + override suspend fun disconnect() = inner.disconnect() + + override suspend fun refresh(): SiteSnapshot { + if (failRefresh) { + failRefresh = false + throw IllegalStateException("fail refresh") + } + return inner.refresh() + } + + override suspend fun sendCommand(command: DomainCommand) = inner.sendCommand(command) + } +} diff --git a/docs/PHASE_1_WESTCURRENT_FOUNDATION.md b/docs/PHASE_1_WESTCURRENT_FOUNDATION.md new file mode 100644 index 0000000..718d994 --- /dev/null +++ b/docs/PHASE_1_WESTCURRENT_FOUNDATION.md @@ -0,0 +1,422 @@ +# WestCurrent Phase 1 — Product Foundation + +**Product:** WestCurrent by 8 West Ventures, LLC +**Branch:** `phase-1-westcurrent-foundation` +**Baseline:** `d12af9d` on `main` +**Phase 1.1 corrections:** applied on same branch after review + +--- + +## Phase 1.1 Review Corrections + +| Review finding | Correction | +|----------------|------------| +| Domain `HeatMode` / `LightCommand` carried protocol integers | Domain heat modes are semantic-only; light programs are adapter-declared `LightProgram` + opaque `LightProgramId`; ScreenLogic packet ints live in `ScreenLogicHeatMapper` / `ScreenLogicLightPrograms` | +| Raw body/circuit ints as universal IDs | Opaque `BodyId` / `CircuitId` value classes; ScreenLogic creates `screenlogic:body:N` / `screenlogic:circuit:N` only inside the adapter | +| Forced pool+spa on snapshot | `SiteSnapshot.bodies: List` with helpers `primaryPool()` / `primarySpa()`; pool-only and spa-only supported | +| `DEFAULT_SUPPORTED` always allowed all commands | Removed; `SystemCapabilities` from active snapshot required for validation | +| Light success via `asOfMs` | New lifecycle `SentUnverified` + honest messaging; never “Equipment reported the requested state” for lights | +| Confirmation-required heat recorded as Failed | Preflight returns `requiresConfirmation` with **no** receipt | +| Stale/reconnecting messages unreachable | SafetyPolicy evaluates Reconnecting → Stale → Auth → Connecting → Failed → Disconnected before live states | +| `AndroidSecureSettingsStore` claimed secure encryption | Renamed to `AppPreferencesStore` / `AndroidAppPreferencesStore` — **app-private, not encrypted**; remember system code opt-in default **false** | +| Weak architecture tests | Expanded tests for mapping ownership, pool-only, full SiteSession rejection paths, light unverified, confirmation without Failed | +| Global mutex for entire confirm loop | Target-scoped locks per command target; bookkeeping mutex only for receipts | +| Dual production paths (`PoolRepository` / `DemoPoolEngine`) | **Deleted** — only `SiteSession` production path remains | + +--- + +## Architecture (current) + +```text +UI (domain types only) + → PoolViewModel + → SiteSession + → SafetyPolicy (capabilities + snapshot + connection state order) + → CommandOrchestrator (Pending / Applied / SentUnverified / Failed / TimedOut) + → PoolSystemAdapter + ├─ ScreenLogicAdapter → protocol/* + ScreenLogic* mappers + └─ SimulatorAdapter (configurable capabilities / bodies) + → AppPreferencesStore (app-private prefs, not encrypted) +``` + +**Vendor-neutral:** domain package has no packet codes. +**Vendor-owned:** ScreenLogic heat ints, light program ints, discovery layout, TCP session. + +--- + +## Identifiers + +| Type | Example | Notes | +|------|---------|--------| +| `BodyId` | `screenlogic:body:0`, `simulator:body:pool` | Opaque; do not parse in UI/session/safety | +| `CircuitId` | `screenlogic:circuit:500` | Opaque | +| `LightProgramId` | `screenlogic:light:party` | Opaque; programs listed by adapter | + +--- + +## Command lifecycle + +| State | Meaning | +|-------|---------| +| Pending | Waiting for evidence | +| Applied | Reported state matches request (circuits/heat) | +| SentUnverified | Transport OK; no controller proof (light programs) | +| Failed | Send failed | +| TimedOut | Confirmation window expired | + +--- + +## Capabilities + +`SiteSnapshot.capabilities: SystemCapabilities` includes: + +- `commandKinds` +- `controllableCircuits` +- `heatCapableBodies` +- `lightPrograms` + +`SiteSession` always passes active snapshot capabilities into SafetyPolicy. Empty means none, not all. + +--- + +## Storage (truthful) + +- Interface: `AppPreferencesStore` +- Android: `AndroidAppPreferencesStore` → `MODE_PRIVATE` SharedPreferences +- **Not encrypted at rest** +- Optional last system code only when user checks remember (default unchecked) +- Passwords never stored +- Follow-up: Keystore encryption before release + +--- + +## Command concurrency + +- Receipt list updates under a short bookkeeping mutex +- Conflicting commands to the **same target** serialize on a target-scoped mutex +- Unrelated targets do not wait on each other’s full confirmation timeout + +--- + +## Legacy code + +| File | Status | +|------|--------| +| `data/PoolRepository.kt` | **Removed** | +| `data/DemoPoolEngine.kt` | **Removed** | +| `data/Models.kt` | Retained as **protocol-layer DTO** used by ScreenLogicClient status path only | +| Old “Secure*” settings names | Replaced by AppPreferences* (typealiases removed with files) | + +--- + +## Phase 1.2 Merge-Readiness Corrections + +Correction and validation pass only. No Phase 2 features. + +### Actual reconnect lifecycle + +`SiteSession` retains **in memory only** (never persisted): + +| Item | Storage | +|------|---------| +| Active adapter factory | process memory | +| Active `AdapterConnectionTarget` | process memory | +| Transient password on that target | process memory | +| Last successfully reported `SiteSnapshot` | `StateFlow` (preserved) | +| Last successful connection path | process memory | + +On consecutive refresh failures (`failuresBeforeReconnect`, default **2**): + +1. Mark session `ConnectionStatus.Reconnecting` (SafetyPolicy rejects equipment commands). +2. Disconnect and dispose the failed adapter. +3. Create a **fresh** adapter via the in-memory factory. +4. Call `connect(target)` again with the in-memory target. +5. Install the new adapter **only after** connect + status snapshot succeed. +6. Resume polling; last-known site remains visible throughout. + +**Not** a `refresh()` retry on the same TCP session. + +### Reconnect attempt and backoff policy (superseded by Phase 1.3) + +Phase 1.2 used short injectable delays. **Phase 1.3** introduced `ReconnectPolicy.PRODUCTION` +with multi-second backoff (see Phase 1.3 section). Tests inject `ReconnectPolicy.FAST_TEST`. + +### Light target model + +- `LightTargetId` — opaque vendor-neutral target / group id. +- `LightTarget` — display name + set of available `LightProgramId`s. +- `LightProgram.targetId` binds each program to its target. +- `DomainCommand.lightTargetId` required for light commands. +- Coordination key: `light-target:{id}` — **not** the program id. +- ScreenLogic exposes one target: `screenlogic:lights:all`. +- Simulator exposes `simulator:lights:primary` (optional dual-target mode for tests). +- SafetyPolicy rejects foreign targets and programs not listed for the selected target. +- ScreenLogic light outcomes remain `SentUnverified`. + +### Vendor-specific connection target ownership + +**Before:** shared sealed `ConnectionTarget` with `Local` / `Remote(systemCode, password)` / `Simulator`. + +**After:** shared interface only: + +```kotlin +interface AdapterConnectionTarget { + val adapterId: String + val path: ConnectionPath + val displayLabel: String +} +``` + +Concrete types live with adapters: + +- `ScreenLogicLocalTarget`, `ScreenLogicRemoteTarget` +- `SimulatorConnectionTarget` +- Future vendors define their own types without modifying shared domain models + +`PoolSystemAdapter.connect()` rejects foreign `adapterId` / unexpected types. + +### Temperature unit model + +```kotlin +enum class TemperatureUnit { FAHRENHEIT, CELSIUS, UNKNOWN } +data class Temperature(val value: Int, val unit: TemperatureUnit) +// format() → "86°F" / "30°C" / "72°?" — never bare "°" +``` + +Applied to body current/set/cool, air temp, heat commands, SafetyPolicy, confirmation UI. + +**ScreenLogic unit evidence:** `POOLSTATUS` decodes raw i32 temps with **no unit field**. This client does not decode a temperature-scale flag from `CTRLCONFIG`. Official UI allows °F/°C in General Settings, but the scale is not reliably exposed on the IP status path we implement. Mapping therefore uses `UNKNOWN` unless the user confirms a **session** unit. No inference from locale, IP, or value magnitude. + +**Simulator:** explicit F and C profiles via `SimulatorAdapter.Config.temperatureUnit`. + +**Safety ranges (explicit, no silent clamp/convert):** + +| Unit | Spa range | Pool range | Elevated spa threshold | +|------|-----------|------------|------------------------| +| °F | 40–104 | 40–104 | 100 | +| °C | 4–40 | 4–40 | 38 | + +Rejects: unknown units, mismatched command/body/system units, out-of-range values. Conversion helpers live only in `TemperatureConversion` (tested). + +### Removal of system-code persistence + +- “Remember system code” UI and APIs removed. +- System codes and passwords are **never** written to preferences. +- Stale keys (`westcurrent.last_system_code`, `westcurrent.remember_system_code`) are cleared on store init / session init and ignored. +- `AppPreferencesStore` remains for non-sensitive flags only (e.g. display/simulator hints). +- **No encrypted storage claimed.** Future Keystore sprint may add protected storage. + +### GitHub Actions workflow + +`.github/workflows/android-ci.yml`: + +- Triggers: PRs to `main`, pushes to `main` +- JDK 17 (Temurin) + Gradle wrapper +- `./gradlew testDebugUnitTest assembleDebug` +- No signing secrets, no pool credentials, no live equipment, no public APK publish +- Dependency cache via `gradle/actions/setup-gradle` + +### Tests added (Phase 1.2) + +`Phase12MergeReadinessTest` (+ updated `Phase1FoundationTest`): + +- Temperature format F/C; F and C safety ranges; unknown/mismatch rejection; elevated both units; conversion helpers +- ScreenLogic UNKNOWN default + confirmed unit mapping; simulator F/C profiles +- Light target key equality; same-target serialization; dual-target independence; foreign/invalid program reject; SentUnverified +- Adapter-owned targets; ScreenLogic/Simulator accept/reject; future vendor target without domain change +- Preferences: no system code/password persistence; stale clear; non-sensitive keys work +- Reconnect: single failure preserves snapshot; repeated failures create new adapter + connect + disconnect old; commands rejected while reconnecting; bounded attempts; no concurrent reconnect; successful reconnect restores live + +--- + +## Phase 1.3 Final Lifecycle and Reliability Corrections + +Final merge-readiness correction pass for PR #1. No Phase 2 features. + +### ViewModel / session lifecycle ownership + +**Before:** `PoolViewModelFactory` created a free-floating `CoroutineScope(SupervisorJob() + Dispatchers.Default)` not owned by the ViewModel and never canceled. + +**After:** +- `PoolViewModel` is constructed with `sessionFactory: (CoroutineScope) -> SiteSession`. +- The factory receives **`viewModelScope`** only (no `GlobalScope`, no application-lifetime free-floating scope). +- `onCleared()` calls `SiteSession.close()`. + +### Session close behavior + +`SiteSession.close()` is **idempotent** and safe after scope cancellation: + +1. Sets a closed-state flag (no further reconnect/install). +2. Cancels poll and reconnect jobs; clears reconnect-in-flight. +3. Under `NonCancellable` + `runBlocking`: disconnects adapter, clears target/factory/password references, clears confirmed temperature unit, clears orchestrator receipts, sets `Disconnected`. + +Repeated `close()` is a no-op. Closed sessions reject connect and command APIs with a clear message. + +### Closed-state protection + +- Delayed reconnect jobs check `closed` before installing a new adapter. +- Successful connect after close disconnects the fresh adapter and does not install it. +- Polling loop exits when closed. + +### Production reconnect timing + +`ReconnectPolicy.PRODUCTION` (default for production `SiteSession`): + +| Attempt index | Delay before attempt | +|---------------|----------------------| +| 0 | immediate (0 ms) | +| 1 | ~2 s | +| 2 | ~5 s | +| 3 | ~10 s | + +- Failures before reconnect: **2** +- Max attempts per cycle: **4** +- Max single backoff cap: **30 s** +- Single-flight reconnect (instrumented for tests) +- Tests inject `ReconnectPolicy.FAST_TEST` or zero-delay policies + +### Explicit retry after exhausted reconnect + +After a bounded automatic cycle fails: + +- Last-known snapshot remains visible. +- Connection becomes `ConnectionFailed`. +- `canRetryConnection` is true while the in-memory target remains. +- User may call `retryConnection()` (UI “Retry connection”) — single-flight, reuses target/password, no infinite background loop. +- Disconnect/close clears the target (no credential retention after leave). + +### In-session temperature-unit preservation + +- Confirmed °F/°C is stored on **`SiteSession`** (memory only), not only on the adapter instance. +- Successful reconnect to the **same** target re-applies the unit to the fresh adapter + snapshot. +- Cleared on: manual disconnect, session close, connection to a different target, process death. +- Never persisted; never inferred from locale/IP/magnitude. + +### Credential redaction + +- `ScreenLogicLocalTarget` / `ScreenLogicRemoteTarget` are ordinary classes with redacted `toString()` (`password=***`, masked system code in display label). +- Connection failure messages never interpolate password-bearing targets. +- `ScreenLogicAdapter` does not retain a password field after client construction. +- `activeConnectionTargetForTest()` is test-only; production UI does not expose credential-bearing targets. + +### Strengthened deterministic tests + +| Weak / misleading (1.2) | Replacement assertion (1.3) | +|-------------------------|-----------------------------| +| `noConcurrentReconnectJobs` (no assert) | Instrumentation: max concurrent reconnect == 1; creations ≤ 1 + maxAttempts | +| `reconnectAttemptsBounded` (loose end states) | Exact attempt count, `ConnectionFailed`, reconnect not in flight, snapshot preserved, all failed adapters disconnected | +| “Remote” persistence via simulator | `connectRemote` with fake ScreenLogic factory; prefs free of code/password | +| Handmade “ScreenLogic” reject | Real `ScreenLogicAdapter` rejects simulator target without network | +| `screenLogic_lightStillSentUnverified` on simulator | Renamed to honest light/orchestrator unverified test | + +Also: lifecycle close tests, ViewModel clear → session close, production policy multi-second backoff values, unit survival across reconnect, credential `toString` tests, `SimulatorWalkthroughTest` checklist path. + +### Manual simulator walkthrough + +**Environment note:** This agent host had **no Android AVD** (`emulator -list-avds` empty) and **no attached device** (`adb devices` empty). On-device Compose UI could not be launched here. + +**Covered instead:** `SimulatorWalkthroughTest` end-to-end session path for Fahrenheit/Celsius labels, circuit Applied, elevated heat confirm, light SentUnverified, connection-loss snapshot, exhausted reconnect + explicit retry, disconnect/close without residual polling. + +Human reviewer should still smoke-test the APK once on an emulator/device before merge if UI polish is required. + +### Remaining limitations + +- ScreenLogic may still emit pool+spa body blocks from firmware +- Lights remain `SentUnverified` on ScreenLogic +- ScreenLogic temperature unit requires session confirmation (protocol gap) +- Preferences not encrypted (system codes not stored) +- Package name still `com.pentaircontrol.pool` +- On-device UI walkthrough not executed in this agent environment (no AVD/device) +- No live pool hardware controlled + +--- + +## Phase 1.4 Teardown and Retry Race Correction + +Final concurrency fixes for PR #1. + +### Non-blocking teardown + +**Problem:** `close()` used `runBlocking` + session mutex + suspending `adapter.disconnect()`, which could block Android’s main thread during `ViewModel.onCleared()` if disconnect waited on network or a held mutex. + +**Design:** +- `close()` is fully synchronous and non-blocking: mark closed, bump connection generation, cancel poll + reconnect/retry jobs, clear target/factory/unit/receipts, detach adapter reference, publish `Disconnected`. +- No `runBlocking`, no waiting on the install mutex, no network wait on the caller thread. +- Best-effort `adapter.disconnect()` is scheduled on a **per-session** `AdapterCleanupScheduler` (IO dispatcher, 5s timeout). Cleanup cannot install or reactivate adapters. +- Tests inject the scheduler dispatcher and can `awaitCleanupIdle()`. + +### Connection-generation design + +- Monotonic `connectionGeneration` (atomic int). +- Bumped on: new-target `connect`, `disconnect`, `close`. +- Automatic reconnect keeps the same generation (same target session). +- Every connect/reconnect/retry cycle captures generation + target identity. +- Before install: not closed, generation still matches, `activeTarget ===` expected target. +- On failure: dispose the fresh adapter asynchronously; do not mutate site/connection for an obsolete generation. + +### Reconnect/retry job ownership + +- Automatic reconnect and explicit `retryConnection()` share one `reconnectJob` + single-flight flag. +- `disconnect` / `close` / new-target connect cancel that job and clear `reconnectInFlight`. +- Retry and auto-reconnect cannot run concurrently. + +### Race tests (`Phase14TeardownRetryRaceTest`) + +| Scenario | Expected | +|----------|----------| +| `close` while disconnect hangs | returns < 500ms; target/unit cleared; cleanup eventually runs | +| `close` mid-connect | no adapter install after connect resumes | +| Retry then `disconnect` | stays Disconnected; no install; no polling; target null | +| Retry A then connect B | B remains active; A cannot overwrite | +| Auto-reconnect then disconnect | same protection | +| Close during retry | remains closed; no install | +| Valid same-target reconnect | confirmed unit still preserved | +| Source guard | no `runBlocking` / `GlobalScope` in production SiteSession code | + +### Remaining limitations (after 1.4) + +Same product limitations as Phase 1.3; race windows for stale install after disconnect/close are closed by generation + job cancel. + +--- + +## Known limitations + +(See Phase 1.4 / 1.3 sections for the authoritative list.) + +--- + +## Verification + +```text +.\gradlew.bat clean testDebugUnitTest assembleDebug +→ BUILD SUCCESSFUL +``` + +(Intermediates under `%TEMP%\westcurrent-app-build` to avoid OneDrive locks.) + +--- + +## Phase 1 original goals (status after 1.3) + +| Goal | Status after 1.3 | +|------|------------------| +| Rebrand WestCurrent | Met | +| Vendor-neutral domain | Met | +| PoolSystemAdapter | Met | +| ScreenLogicAdapter | Met | +| SimulatorAdapter | Met | +| Command lifecycle + confirmation | Met | +| Connection states + last-known | Met | +| Real bounded reconnection | Met (production multi-second policy + explicit retry) | +| ViewModel lifecycle ownership | Met (viewModelScope + close) | +| Temperature unit session survival | Met | +| Credential redaction | Met | +| Discovery fix | Met | +| SafetyPolicy | Met | +| Settings | No system-code persistence; not encrypted | +| UI on domain types | Met | +| Tests | Phase 1.3 deterministic suite + walkthrough test | +| CI | GitHub Actions | +| Docs match code | This revision | + +**Ready for final human merge approval when CI is green on the new head — not self-merged.** Do not treat Phase 1 as approved solely from this document. diff --git a/settings.gradle.kts b/settings.gradle.kts index 6d708e3..fd8e9e8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,5 @@ dependencyResolutionManagement { } } -rootProject.name = "PentairPoolControl" -include(":app") +rootProject.name = "WestCurrent" include(":app")