Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 24 additions & 8 deletions app/src/main/java/com/pentaircontrol/pool/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>(ConnectionStatus.Disconnected)
override val connection: StateFlow<ConnectionStatus> = _connection.asStateFlow()

private val _snapshot = MutableStateFlow<SiteSnapshot?>(null)
override val snapshot: StateFlow<SiteSnapshot?> = _snapshot.asStateFlow()

override val capabilities: Set<Capability> = 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<DiscoveredGateway> {
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
}
}
Loading
Loading