diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e1710b..a76dc3b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,10 @@ +import java.io.FileInputStream +import java.util.Properties +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +val keystoreProperties = Properties () +keystoreProperties . load ( FileInputStream ( rootProject . file ("keystore.properties") ) ) + plugins { alias ( libs . plugins . android . application ) alias ( libs . plugins . kotlin . android ) @@ -17,10 +24,23 @@ android { versionName = "1.0" } + signingConfigs { + create ("kernelmaft") { + keyAlias = "kernelmaft" + keyPassword = keystoreProperties ["keyPassword"] as String + storeFile = file ( keystoreProperties ["storeFile"] as String ) + storePassword = keystoreProperties ["storePassword"] as String + } + } + buildTypes { + debug { + signingConfig = signingConfigs . getByName ("kernelmaft") + } release { isMinifyEnabled = true isShrinkResources = true + signingConfig = signingConfigs . getByName ("kernelmaft") } } compileOptions { @@ -47,6 +67,7 @@ dependencies { implementation ( libs . androidx . compose . ui ) implementation ( libs . androidx . compose . ui . graphics ) debugImplementation ( libs . androidx . compose . ui . tooling ) + implementation ( libs . androidx . datastore . preferences ) implementation ( libs . androidx . lifecycle . runtime . ktx ) implementation ( libs . kotlinx . coroutines . android ) implementation ( libs . kotlinx . serialization . json ) @@ -55,7 +76,7 @@ dependencies { implementation ( libs . kmqtt . client ) } -tasks . withType ( org . jetbrains . kotlin . gradle . tasks . KotlinCompile :: class ) . all { +tasks . withType ( KotlinCompile :: class ) . all { compilerOptions { freeCompilerArgs . addAll ( "-opt-in=kotlin.ExperimentalUnsignedTypes" ) } diff --git a/app/src/main/java/com/kernelmaft/zanbur/app-state.kt b/app/src/main/java/com/kernelmaft/zanbur/app-state.kt index 8968415..062e959 100644 --- a/app/src/main/java/com/kernelmaft/zanbur/app-state.kt +++ b/app/src/main/java/com/kernelmaft/zanbur/app-state.kt @@ -1,17 +1,19 @@ package com.kernelmaft.zanbur import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue -typealias MutableStateMap < Key , Value > = MutableState < Map < Key , Value > > - object AppState { - val devices : MutableStateMap < DeviceName , Device > = mutableStateOf ( emptyMap () ) - val deviceStates : MutableStateMap < Device , LampState > = mutableStateOf ( emptyMap () ) - val scenes : MutableStateMap < SceneName , Scene > = mutableStateOf ( emptyMap () ) - val currentScene : MutableState < Scene ? > = mutableStateOf (null) + val groups : MutableState < List > = mutableStateOf ( emptyList () ) - fun putDevice ( device : Device ) { devices . value += Pair ( device . name , device ) } - fun putScene ( scene : Scene ) { scenes . value += Pair ( scene . name , scene ) } + fun setCurrentScene ( groupId : Int , currentScene : Scene ) { + var groups by this . groups + groups = groups . map { + if ( it . id == groupId ) it . copy ( currentScene = currentScene ) + else it + } + } } diff --git a/app/src/main/java/com/kernelmaft/zanbur/components.kt b/app/src/main/java/com/kernelmaft/zanbur/components.kt new file mode 100644 index 0000000..8437c64 --- /dev/null +++ b/app/src/main/java/com/kernelmaft/zanbur/components.kt @@ -0,0 +1,27 @@ +package com.kernelmaft.zanbur + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + + +@Composable fun SceneSwitcher ( + scenes : Collection , + currentScene : Scene ? , + onSwitch : (Scene) -> Unit , +) { + CenteredColumn { + for ( scene in scenes ) { + val colors = + if ( scene . id == currentScene ?. id ) ButtonDefaults . buttonColors () + else ButtonDefaults . filledTonalButtonColors () + + Button ( { onSwitch (scene) } , Modifier . fillMaxWidth () , true , ButtonDefaults . shape , colors ) { + Text ( scene . name ) + } + } + } +} diff --git a/app/src/main/java/com/kernelmaft/zanbur/config.kt b/app/src/main/java/com/kernelmaft/zanbur/config.kt index 8e15bca..79c2701 100644 --- a/app/src/main/java/com/kernelmaft/zanbur/config.kt +++ b/app/src/main/java/com/kernelmaft/zanbur/config.kt @@ -6,25 +6,13 @@ object Config { const val MQTT_SERVER_PORT = 1883 const val MQTT_TOPIC = "zigbee2mqtt" - private val deskLamp = TemperatureLamp ( DeviceName ("Desk lamp") ) - private val standingLamp = TemperatureLamp ( DeviceName ("Standing lamp") ) - private val diningTableLamp = TemperatureLamp ( DeviceName ("Dining table lamp") ) - private val ledStrip = RgbLamp ( DeviceName ("LED strip") ) - - val devices = listOf ( deskLamp , standingLamp , diningTableLamp , ledStrip ) - - val scenes = listOf ( - Scene ( SceneName ("All off") , mapOf ( - Pair ( deskLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) , - Pair ( standingLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) , - Pair ( diningTableLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) , - Pair ( ledStrip , RgbLampState ( Power . OFF , 254 , Cie1931Color ( 0.0 , 0.0 ) ) ) , - ) ) , - Scene ( SceneName ("Evening") , mapOf ( - Pair ( deskLamp , TemperatureLampState ( Power . ON , 128 , 454 ) ) , - Pair ( standingLamp , TemperatureLampState ( Power . ON , 192 , 454 ) ) , - Pair ( diningTableLamp , TemperatureLampState ( Power . ON , 192 , 454 ) ) , - Pair ( ledStrip , RgbLampState ( Power . ON , 128 , Cie1931Color ( 0.25 , 0.05 ) ) ) , - ) ) , + val groups = listOf ( + Group ( 0 , "All lights" , listOf ( + Scene ( 0 , "All off" ) , + Scene ( 1 , "Bright, warm, purple accent" ) , + Scene ( 2 , "Medium-bright, warm, purple accent" ) , + Scene ( 3 , "Dim, warm, purple accent" ) , + Scene ( 4 , "Medium-bright, cold, no accent" ) , + ) ) ) } diff --git a/app/src/main/java/com/kernelmaft/zanbur/main.kt b/app/src/main/java/com/kernelmaft/zanbur/main.kt index 67ab86c..6d8bce4 100644 --- a/app/src/main/java/com/kernelmaft/zanbur/main.kt +++ b/app/src/main/java/com/kernelmaft/zanbur/main.kt @@ -1,25 +1,25 @@ package com.kernelmaft.zanbur -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Arrangement.Center -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.layout.width import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.lifecycle.lifecycleScope @@ -27,27 +27,35 @@ class MainActivity : ComponentActivity () { override fun onCreate ( savedInstanceState : Bundle ? ) { super . onCreate (savedInstanceState) - val devices by AppState . devices - val scenes by AppState . scenes - var currentScene by AppState . currentScene + var groups by AppState . groups + groups = Config . groups - for ( device in Config . devices ) AppState . putDevice (device) - for ( scene in Config . scenes ) AppState . putScene (scene) + lifecycleScope . launch (IO) { + val prefs = applicationContext . dataStore . data . firstOrNull () + if ( prefs != null ) { + val savedSceneName = prefs [ stringPreferencesKey ("scene") ] + if ( savedSceneName != null ) { + val savedScene = groups [0] . scenes . find { it . name == savedSceneName } + if ( savedScene != null ) { + AppState . setCurrentScene ( 0 , savedScene ) + } + } + } + } enableEdgeToEdge () setContent { ZanburTheme { Scaffold { scaffoldPadding -> - val wrapperModifier = Modifier - .padding(scaffoldPadding) - .fillMaxWidth() - .fillMaxHeight() + CenteredColumn ( Modifier . padding (scaffoldPadding) . fillMaxSize () ) { - Column ( wrapperModifier , Center , CenterHorizontally ) { - SceneSwitcher ( scenes . values . map { it . name } , currentScene ?. name ) { - val newScene = scenes . getValue (it) - currentScene = newScene - handleSceneChange (newScene) + CenteredColumn ( Modifier . width ( 300 . dp ) ) { + Text (groups [0] . name) + Spacer ( Modifier . height ( compactSpacing ) ) + SceneSwitcher ( groups [0] . scenes , groups [0] . currentScene ) { + AppState . setCurrentScene ( 0 , it ) + publishSceneChange ( groups [0] , it ) + } } } } @@ -56,57 +64,17 @@ class MainActivity : ComponentActivity () { MqttClient . run (lifecycleScope) } -} -@Composable private fun SceneSwitcher ( - scenes : Collection , - currentScene : SceneName ? , - onSwitch : (SceneName) -> Unit , -) = Column { - for ( scene in scenes ) { - val colors = - if ( scene == currentScene ) ButtonDefaults . buttonColors () - else ButtonDefaults . filledTonalButtonColors () + override fun onStop () { + super . onStop () - Button ( { onSwitch (scene) } , Modifier , true , ButtonDefaults . shape , colors ) { - Text ( scene . value ) + val groups by AppState . groups + val currentScene = groups [0] . currentScene + + if ( currentScene != null ) lifecycleScope . launch (IO) { + applicationContext . dataStore . edit { + it [ stringPreferencesKey ("scene") ] = currentScene . name + } } } } - -private fun handleSceneChange ( newScene : Scene ) { - for ( ( device , newState ) in newScene . states ) { - val jsonString = when (device) { - is TemperatureLamp -> Json . encodeToString ( newState as TemperatureLampState ) - is RgbLamp -> Json . encodeToString ( newState as RgbLampState ) - else -> throw Error ("Unknown type of device state") - } - MqttClient . publish ( - Config . MQTT_TOPIC + "/" + device . name . value + "/set" , - jsonString . toByteArray () . asUByteArray () , - ) - } -} - -//private fun handlePowerChange ( deviceName : String , power : Power ) { -// when ( val device = AppState . devices . value . getValue (deviceName) ) { -// -// is TemperatureLamp -> -// if ( device . currentState != null && device . currentState . power != power ) { -// MqttClient . publish ( -// "zigbee2mqtt/$deviceName/set/state" , -// power . toString () . toByteArray () . asUByteArray () , -// ) -// AppState . putDevice ( TemperatureLamp ( deviceName , device . currentState . copy (power) ) ) -// } -// -// is RgbLamp -> -// if ( device . currentState != null && device . currentState . power != power ) { -// MqttClient . publish ( -// "zigbee2mqtt/$deviceName/set/state" , -// power . toString () . toByteArray () . asUByteArray () , -// ) -// AppState . putDevice ( RgbLamp ( deviceName , device . currentState . copy (power) ) ) -// } -// } -//} diff --git a/app/src/main/java/com/kernelmaft/zanbur/mqtt.kt b/app/src/main/java/com/kernelmaft/zanbur/mqtt.kt index 8666dbc..d48f3ba 100644 --- a/app/src/main/java/com/kernelmaft/zanbur/mqtt.kt +++ b/app/src/main/java/com/kernelmaft/zanbur/mqtt.kt @@ -3,6 +3,8 @@ package com.kernelmaft.zanbur import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import MQTTClient @@ -24,31 +26,11 @@ object MqttClient { val json = Json { ignoreUnknownKeys = true } coroutineScope . launch (IO) { - client = MQTTClient ( - MQTTVersion . MQTT5 , - Config . MQTT_SERVER_HOST , - Config . MQTT_SERVER_PORT , - null , - ) { + client = MQTTClient ( MQTTVersion . MQTT5 , Config . MQTT_SERVER_HOST , Config . MQTT_SERVER_PORT , null ) { for ( handler in publishHandlers ) handler ( it , json ) } client !! . subscribe ( listOf ( Subscription ( Config . MQTT_TOPIC + "/#" ) ) ) - for ( ( name , device ) in AppState . devices . value ) when (device) { - is TemperatureLamp -> publish ( - Config . MQTT_TOPIC + "/" + name + "/get" , - Json . encodeToString ( TemperatureLampState ( Power . OFF , 0 , 250 ) ) - . toByteArray () - . asUByteArray () , - ) - is RgbLamp -> publish ( - Config . MQTT_TOPIC + "/" + name + "/get" , - Json . encodeToString ( RgbLampState ( Power . OFF , 0 , Cie1931Color ( 0.0 , 0.0 ) ) ) - . toByteArray () - . asUByteArray () , - ) - } - client !! . run () } } diff --git a/app/src/main/java/com/kernelmaft/zanbur/ontology.kt b/app/src/main/java/com/kernelmaft/zanbur/ontology.kt index 80a2bb5..ea4285f 100644 --- a/app/src/main/java/com/kernelmaft/zanbur/ontology.kt +++ b/app/src/main/java/com/kernelmaft/zanbur/ontology.kt @@ -1,62 +1,14 @@ package com.kernelmaft.zanbur -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - - -typealias Real = Double +data class Group ( + val id : Int , + val name : String , + val scenes : Collection , + val currentScene : Scene ? = null , +) data class Scene ( - val name : SceneName , - val states : Map < Device , LampState > , -) - -data class SceneName ( val value : String ) - - -abstract class Device ( val name : DeviceName ) - -class TemperatureLamp ( name : DeviceName ) : Device (name) -class RgbLamp ( name : DeviceName ) : Device (name) - -data class DeviceName ( val value : String ) - - -abstract class LampState { - abstract val power : Power - abstract val brightness : Int // Range 0 to 254 -} - -@Serializable data class TemperatureLampState ( - @SerialName ("state") override val power : Power , - @SerialName ("brightness") override val brightness : Int , - @SerialName ("color_temp") val colorTemperature : Int , // Range 250 to 454 -) : LampState () - -@Serializable data class RgbLampState ( - @SerialName ("state") override val power : Power , - @SerialName ("brightness") override val brightness : Int , - @SerialName ("color") val color : Cie1931Color , -) : LampState () - -@Serializable enum class Power { - ON , OFF ; - - fun toBoolean () : Boolean = when (this) { - ON -> true - OFF -> false - } - - companion object { - fun fromBoolean ( power : Boolean ) : Power = when (power) { - true -> ON - false -> OFF - } - } -} - -@Serializable data class Cie1931Color ( - val x : Real , // Range 0.0 to 1.0 (for shrimp anyway) - val y : Real , // Idem + val id : Int , + val name : String , ) diff --git a/app/src/main/java/com/kernelmaft/zanbur/persistence.kt b/app/src/main/java/com/kernelmaft/zanbur/persistence.kt new file mode 100644 index 0000000..f825059 --- /dev/null +++ b/app/src/main/java/com/kernelmaft/zanbur/persistence.kt @@ -0,0 +1,9 @@ +package com.kernelmaft.zanbur + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore + + +val Context. dataStore : DataStore by preferencesDataStore ("state") diff --git a/app/src/main/java/com/kernelmaft/zanbur/theme.kt b/app/src/main/java/com/kernelmaft/zanbur/theme.kt index 352d3af..6eaa109 100644 --- a/app/src/main/java/com/kernelmaft/zanbur/theme.kt +++ b/app/src/main/java/com/kernelmaft/zanbur/theme.kt @@ -1,12 +1,17 @@ package com.kernelmaft.zanbur import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement.Center +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -24,3 +29,9 @@ val compactSpacing = 16 . dp MaterialTheme ( colorScheme, shapes , typography , content ) } + +@Composable fun CenteredColumn ( + modifier : Modifier = Modifier , + content : @Composable ColumnScope . () -> Unit , +) = + Column ( modifier , Center , CenterHorizontally , content ) diff --git a/app/src/main/java/com/kernelmaft/zanbur/zigbee2mqtt.kt b/app/src/main/java/com/kernelmaft/zanbur/zigbee2mqtt.kt new file mode 100644 index 0000000..9243ab5 --- /dev/null +++ b/app/src/main/java/com/kernelmaft/zanbur/zigbee2mqtt.kt @@ -0,0 +1,19 @@ +package com.kernelmaft.zanbur + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +fun publishSceneChange ( group : Group , newScene : Scene ) { + val topic = Config . MQTT_TOPIC + "/" + group . name + "/set" + val packet = Json . encodeToString ( SceneRecallPacket ( newScene . id ) ) + . toByteArray () + . asUByteArray () + MqttClient.publish ( topic , packet ) +} + +@Serializable private data class SceneRecallPacket ( + @SerialName ("scene_recall") val sceneRecall : Int , +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9d56bf..a7822ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,12 +3,13 @@ android-plugin = "8.5.1" kotlin = "2.0.0" # Runtime libraries -androidx-core-ktx = "1.13.1" android-material = "1.12.0" +androidx-activity-compose = "1.9.1" androidx-compose-material3 = "1.2.1" -androidx-lifecycle-runtime-ktx = "2.8.3" -androidx-activity-compose = "1.9.0" androidx-compose-ui = "1.6.8" +androidx-core-ktx = "1.13.1" +androidx-datastore-preferences = "1.1.1" +androidx-lifecycle-runtime-ktx = "2.8.4" kotlinx-coroutines-android = "1.8.1" kotlinx-serialization-json = "1.7.1" # Other libraries @@ -23,6 +24,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose-ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidx-compose-ui" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-ui" } +androidx-datastore-preferences = { group = "androidx.datastore" , name = "datastore-preferences" , version.ref = "androidx-datastore-preferences" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-ktx" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }