Use zigbee2mqtt scenes directly, persist last selected scene locally

This commit is contained in:
Reinout Meliesie 2024-08-01 00:36:05 +02:00
parent d129c407d3
commit ac8fa6f0c0
Signed by: zedfrigg
GPG key ID: 3AFCC06481308BC6
11 changed files with 163 additions and 182 deletions

View file

@ -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 { plugins {
alias ( libs . plugins . android . application ) alias ( libs . plugins . android . application )
alias ( libs . plugins . kotlin . android ) alias ( libs . plugins . kotlin . android )
@ -17,10 +24,23 @@ android {
versionName = "1.0" 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 { buildTypes {
debug {
signingConfig = signingConfigs . getByName ("kernelmaft")
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
signingConfig = signingConfigs . getByName ("kernelmaft")
} }
} }
compileOptions { compileOptions {
@ -47,6 +67,7 @@ dependencies {
implementation ( libs . androidx . compose . ui ) implementation ( libs . androidx . compose . ui )
implementation ( libs . androidx . compose . ui . graphics ) implementation ( libs . androidx . compose . ui . graphics )
debugImplementation ( libs . androidx . compose . ui . tooling ) debugImplementation ( libs . androidx . compose . ui . tooling )
implementation ( libs . androidx . datastore . preferences )
implementation ( libs . androidx . lifecycle . runtime . ktx ) implementation ( libs . androidx . lifecycle . runtime . ktx )
implementation ( libs . kotlinx . coroutines . android ) implementation ( libs . kotlinx . coroutines . android )
implementation ( libs . kotlinx . serialization . json ) implementation ( libs . kotlinx . serialization . json )
@ -55,7 +76,7 @@ dependencies {
implementation ( libs . kmqtt . client ) implementation ( libs . kmqtt . client )
} }
tasks . withType ( org . jetbrains . kotlin . gradle . tasks . KotlinCompile :: class ) . all { tasks . withType ( KotlinCompile :: class ) . all {
compilerOptions { compilerOptions {
freeCompilerArgs . addAll ( "-opt-in=kotlin.ExperimentalUnsignedTypes" ) freeCompilerArgs . addAll ( "-opt-in=kotlin.ExperimentalUnsignedTypes" )
} }

View file

@ -1,17 +1,19 @@
package com.kernelmaft.zanbur package com.kernelmaft.zanbur
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
typealias MutableStateMap < Key , Value > = MutableState < Map < Key , Value > >
object AppState { object AppState {
val devices : MutableStateMap < DeviceName , Device > = mutableStateOf ( emptyMap () ) val groups : MutableState < List <Group> > = mutableStateOf ( emptyList () )
val deviceStates : MutableStateMap < Device , LampState > = mutableStateOf ( emptyMap () )
val scenes : MutableStateMap < SceneName , Scene > = mutableStateOf ( emptyMap () )
val currentScene : MutableState < Scene ? > = mutableStateOf (null)
fun putDevice ( device : Device ) { devices . value += Pair ( device . name , device ) } fun setCurrentScene ( groupId : Int , currentScene : Scene ) {
fun putScene ( scene : Scene ) { scenes . value += Pair ( scene . name , scene ) } var groups by this . groups
groups = groups . map {
if ( it . id == groupId ) it . copy ( currentScene = currentScene )
else it
}
}
} }

View file

@ -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 <Scene> ,
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 )
}
}
}
}

View file

@ -6,25 +6,13 @@ object Config {
const val MQTT_SERVER_PORT = 1883 const val MQTT_SERVER_PORT = 1883
const val MQTT_TOPIC = "zigbee2mqtt" const val MQTT_TOPIC = "zigbee2mqtt"
private val deskLamp = TemperatureLamp ( DeviceName ("Desk lamp") ) val groups = listOf (
private val standingLamp = TemperatureLamp ( DeviceName ("Standing lamp") ) Group ( 0 , "All lights" , listOf (
private val diningTableLamp = TemperatureLamp ( DeviceName ("Dining table lamp") ) Scene ( 0 , "All off" ) ,
private val ledStrip = RgbLamp ( DeviceName ("LED strip") ) Scene ( 1 , "Bright, warm, purple accent" ) ,
Scene ( 2 , "Medium-bright, warm, purple accent" ) ,
val devices = listOf ( deskLamp , standingLamp , diningTableLamp , ledStrip ) Scene ( 3 , "Dim, warm, purple accent" ) ,
Scene ( 4 , "Medium-bright, cold, no accent" ) ,
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 ) ) ) ,
) ) ,
) )
} }

View file

@ -1,25 +1,25 @@
package com.kernelmaft.zanbur package com.kernelmaft.zanbur
import kotlinx.serialization.encodeToString import kotlinx.coroutines.Dispatchers.IO
import kotlinx.serialization.json.Json import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement.Center import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier 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 import androidx.lifecycle.lifecycleScope
@ -27,27 +27,35 @@ class MainActivity : ComponentActivity () {
override fun onCreate ( savedInstanceState : Bundle ? ) { override fun onCreate ( savedInstanceState : Bundle ? ) {
super . onCreate (savedInstanceState) super . onCreate (savedInstanceState)
val devices by AppState . devices var groups by AppState . groups
val scenes by AppState . scenes groups = Config . groups
var currentScene by AppState . currentScene
for ( device in Config . devices ) AppState . putDevice (device) lifecycleScope . launch (IO) {
for ( scene in Config . scenes ) AppState . putScene (scene) 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 () enableEdgeToEdge ()
setContent { setContent {
ZanburTheme { ZanburTheme {
Scaffold { scaffoldPadding -> Scaffold { scaffoldPadding ->
val wrapperModifier = Modifier CenteredColumn ( Modifier . padding (scaffoldPadding) . fillMaxSize () ) {
.padding(scaffoldPadding)
.fillMaxWidth()
.fillMaxHeight()
Column ( wrapperModifier , Center , CenterHorizontally ) { CenteredColumn ( Modifier . width ( 300 . dp ) ) {
SceneSwitcher ( scenes . values . map { it . name } , currentScene ?. name ) { Text (groups [0] . name)
val newScene = scenes . getValue (it) Spacer ( Modifier . height ( compactSpacing ) )
currentScene = newScene SceneSwitcher ( groups [0] . scenes , groups [0] . currentScene ) {
handleSceneChange (newScene) AppState . setCurrentScene ( 0 , it )
publishSceneChange ( groups [0] , it )
}
} }
} }
} }
@ -56,57 +64,17 @@ class MainActivity : ComponentActivity () {
MqttClient . run (lifecycleScope) MqttClient . run (lifecycleScope)
} }
}
@Composable private fun SceneSwitcher ( override fun onStop () {
scenes : Collection <SceneName> , super . onStop ()
currentScene : SceneName ? ,
onSwitch : (SceneName) -> Unit ,
) = Column {
for ( scene in scenes ) {
val colors =
if ( scene == currentScene ) ButtonDefaults . buttonColors ()
else ButtonDefaults . filledTonalButtonColors ()
Button ( { onSwitch (scene) } , Modifier , true , ButtonDefaults . shape , colors ) { val groups by AppState . groups
Text ( scene . value ) val currentScene = groups [0] . currentScene
}
}
}
private fun handleSceneChange ( newScene : Scene ) { if ( currentScene != null ) lifecycleScope . launch (IO) {
for ( ( device , newState ) in newScene . states ) { applicationContext . dataStore . edit {
val jsonString = when (device) { it [ stringPreferencesKey ("scene") ] = currentScene . name
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) ) )
// }
// }
//}

View file

@ -3,6 +3,8 @@ package com.kernelmaft.zanbur
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import MQTTClient import MQTTClient
@ -24,31 +26,11 @@ object MqttClient {
val json = Json { ignoreUnknownKeys = true } val json = Json { ignoreUnknownKeys = true }
coroutineScope . launch (IO) { coroutineScope . launch (IO) {
client = MQTTClient ( client = MQTTClient ( MQTTVersion . MQTT5 , Config . MQTT_SERVER_HOST , Config . MQTT_SERVER_PORT , null ) {
MQTTVersion . MQTT5 ,
Config . MQTT_SERVER_HOST ,
Config . MQTT_SERVER_PORT ,
null ,
) {
for ( handler in publishHandlers ) handler ( it , json ) for ( handler in publishHandlers ) handler ( it , json )
} }
client !! . subscribe ( listOf ( Subscription ( Config . MQTT_TOPIC + "/#" ) ) ) 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 () client !! . run ()
} }
} }

View file

@ -1,62 +1,14 @@
package com.kernelmaft.zanbur 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 <Scene> ,
val currentScene : Scene ? = null ,
)
data class Scene ( data class Scene (
val name : SceneName , val id : Int ,
val states : Map < Device , LampState > , val name : String ,
)
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
) )

View file

@ -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<Preferences> by preferencesDataStore ("state")

View file

@ -1,12 +1,17 @@
package com.kernelmaft.zanbur package com.kernelmaft.zanbur
import androidx.compose.foundation.isSystemInDarkTheme 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
import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable 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.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -24,3 +29,9 @@ val compactSpacing = 16 . dp
MaterialTheme ( colorScheme, shapes , typography , content ) MaterialTheme ( colorScheme, shapes , typography , content )
} }
@Composable fun CenteredColumn (
modifier : Modifier = Modifier ,
content : @Composable ColumnScope . () -> Unit ,
) =
Column ( modifier , Center , CenterHorizontally , content )

View file

@ -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 ,
)

View file

@ -3,12 +3,13 @@
android-plugin = "8.5.1" android-plugin = "8.5.1"
kotlin = "2.0.0" kotlin = "2.0.0"
# Runtime libraries # Runtime libraries
androidx-core-ktx = "1.13.1"
android-material = "1.12.0" android-material = "1.12.0"
androidx-activity-compose = "1.9.1"
androidx-compose-material3 = "1.2.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-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-coroutines-android = "1.8.1"
kotlinx-serialization-json = "1.7.1" kotlinx-serialization-json = "1.7.1"
# Other libraries # 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 = { 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-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-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" } 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-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" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }