neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Put the brightness clamps and scheduling in the android app
54988b8 · Gabriel Morell · 2026-09-16 22:49
Message
{commit_body(@commit)}
Files changed
modified
android/app/src/main/java/org/neiam/lambent/app/MainActivity.kt
+4
−0
| 10 | 10 | import androidx.compose.material.icons.Icons |
| 11 | 11 | import androidx.compose.material.icons.filled.Cable |
| 12 | 12 | import androidx.compose.material.icons.filled.Cloud |
| 13 | +import androidx.compose.material.icons.filled.LightMode | |
| 13 | 14 | import androidx.compose.material.icons.filled.Lightbulb |
| 14 | 15 | import androidx.compose.material.icons.filled.Memory |
| 15 | 16 | import androidx.compose.material3.ExperimentalMaterial3Api |
| 36 | 37 | import org.neiam.lambent.app.data.BaseUrlStore |
| 37 | 38 | import org.neiam.lambent.app.data.ConnectionState |
| 38 | 39 | import org.neiam.lambent.app.data.LambentSocket |
| 40 | +import org.neiam.lambent.app.ui.BrightnessScreen | |
| 39 | 41 | import org.neiam.lambent.app.ui.ConnectScreen |
| 40 | 42 | import org.neiam.lambent.app.ui.DevicesScreen |
| 41 | 43 | import org.neiam.lambent.app.ui.LinksScreen |
| 69 | 71 | Devices("Devices", Icons.Filled.Lightbulb), |
| 70 | 72 | Machines("Machines", Icons.Filled.Memory), |
| 71 | 73 | Links("Links", Icons.Filled.Cable), |
| 74 | + Brightness("Bright", Icons.Filled.LightMode), | |
| 72 | 75 | Services("SVCs", Icons.Filled.Cloud), |
| 73 | 76 | } |
| 74 | 77 |
| 144 | 147 | Tab.Devices -> DevicesScreen() |
| 145 | 148 | Tab.Machines -> MachinesScreen() |
| 146 | 149 | Tab.Links -> LinksScreen() |
| 150 | + Tab.Brightness -> BrightnessScreen() | |
| 147 | 151 | Tab.Services -> ServicesScreen( |
| 148 | 152 | baseUrl = baseUrl.orEmpty(), |
| 149 | 153 | onForget = { |
modified
android/app/src/main/java/org/neiam/lambent/app/data/LambentSocket.kt
+89
−1
| 12 | 12 | enum class ConnectionState { Idle, Connecting, Live, Offline } |
| 13 | 13 | |
| 14 | 14 | /** |
| 15 | − * One socket, four channels, held for the life of the process. | |
| 15 | + * One socket, one channel per section of the ui, held for the life of the | |
| 16 | + * process. | |
| 16 | 17 | * |
| 17 | 18 | * The server publishes whole state on a timer rather than diffs-with-versions, |
| 18 | 19 | * so every screen is a reducer over pushes exactly like its LiveView is: merge |
| 45 | 46 | private val _previews = MutableStateFlow<Map<String, List<Int>>>(emptyMap()) |
| 46 | 47 | val previews: StateFlow<Map<String, List<Int>>> = _previews.asStateFlow() |
| 47 | 48 | |
| 49 | + /** the global brightness clamp, and one per place that carries one */ | |
| 50 | + private val _globalClamp = MutableStateFlow(Clamp(null, Clamp.FULL, Clamp.FULL, 0)) | |
| 51 | + val globalClamp: StateFlow<Clamp> = _globalClamp.asStateFlow() | |
| 52 | + | |
| 53 | + private val _placeClamps = MutableStateFlow<Map<String, Clamp>>(emptyMap()) | |
| 54 | + val placeClamps: StateFlow<Map<String, Clamp>> = _placeClamps.asStateFlow() | |
| 55 | + | |
| 56 | + /** the named brightness rungs and fade rates, pushed on join */ | |
| 57 | + private val _ladder = MutableStateFlow<List<Named>>(emptyList()) | |
| 58 | + val ladder: StateFlow<List<Named>> = _ladder.asStateFlow() | |
| 59 | + | |
| 60 | + private val _fades = MutableStateFlow<List<Named>>(emptyList()) | |
| 61 | + val fades: StateFlow<List<Named>> = _fades.asStateFlow() | |
| 62 | + | |
| 63 | + /** what a schedule can be set to do, by action */ | |
| 64 | + private val _cronosActions = MutableStateFlow<List<Choice>>(emptyList()) | |
| 65 | + val cronosActions: StateFlow<List<Choice>> = _cronosActions.asStateFlow() | |
| 66 | + | |
| 67 | + private val _cronosArgs = MutableStateFlow<Map<String, List<Choice>>>(emptyMap()) | |
| 68 | + val cronosArgs: StateFlow<Map<String, List<Choice>>> = _cronosArgs.asStateFlow() | |
| 69 | + | |
| 48 | 70 | /** the machine library, pushed on join: class name to its entries */ |
| 49 | 71 | private val _library = MutableStateFlow<Map<String, List<String>>>(emptyMap()) |
| 50 | 72 | val library: StateFlow<Map<String, List<String>>> = _library.asStateFlow() |
| 81 | 103 | joinMachines(s) |
| 82 | 104 | joinLinks(s) |
| 83 | 105 | joinServices(s) |
| 106 | + joinBrightness(s) | |
| 84 | 107 | } |
| 85 | 108 | |
| 86 | 109 | fun disconnect() { |
| 94 | 117 | _links.value = emptyMap() |
| 95 | 118 | _services.value = emptyMap() |
| 96 | 119 | _previews.value = emptyMap() |
| 120 | + _placeClamps.value = emptyMap() | |
| 121 | + _globalClamp.value = Clamp(null, Clamp.FULL, Clamp.FULL, 0) | |
| 97 | 122 | } |
| 98 | 123 | |
| 99 | 124 | // ----- joins ----- |
| 178 | 203 | _services.value = _services.value + (kind to list) |
| 179 | 204 | } |
| 180 | 205 | |
| 206 | + ch.on("cronos_options") { message -> | |
| 207 | + _cronosActions.value = message.payload["actions"].asList().map { Choice.from(it.asMap()) } | |
| 208 | + _cronosArgs.value = message.payload["args"].asMap() | |
| 209 | + .mapValues { (_, list) -> list.asList().map { Choice.from(it.asMap()) } } | |
| 210 | + } | |
| 211 | + | |
| 181 | 212 | ch.join().receive("error") { _notices.tryEmit("services join failed") } |
| 182 | 213 | channels["services"] = ch |
| 183 | 214 | } |
| 184 | 215 | |
| 216 | + private fun joinBrightness(s: Socket) { | |
| 217 | + val ch = s.channel("brightness", emptyMap()) | |
| 218 | + | |
| 219 | + ch.on("ladder") { message -> | |
| 220 | + _ladder.value = message.payload["steps"].asList().map { Named.step(it.asMap()) } | |
| 221 | + _fades.value = message.payload["fades"].asList().map { Named.fade(it.asMap()) } | |
| 222 | + } | |
| 223 | + | |
| 224 | + ch.on("brightness") { message -> | |
| 225 | + message.payload["global"].asMap().let { raw -> | |
| 226 | + if (raw.isNotEmpty()) _globalClamp.value = Clamp.from(null, raw) | |
| 227 | + } | |
| 228 | + _placeClamps.value = message.payload["places"].asMap() | |
| 229 | + .mapValues { (place, raw) -> Clamp.from(place, raw.asMap()) } | |
| 230 | + } | |
| 231 | + | |
| 232 | + ch.join().receive("error") { _notices.tryEmit("brightness join failed") } | |
| 233 | + channels["brightness"] = ch | |
| 234 | + } | |
| 235 | + | |
| 185 | 236 | // ----- commands ----- |
| 186 | 237 | |
| 187 | 238 | // Payload is Map<String, Any>, so nothing nullable goes over the wire |
| 245 | 296 | // services |
| 246 | 297 | fun deleteService(kind: String, id: String) = |
| 247 | 298 | push("services", "delete", mapOf("kind" to kind, "id" to id)) |
| 299 | + | |
| 300 | + /** the server pushes these on request; the schedule form needs them */ | |
| 301 | + fun cronosOptions() = push("services", "cronos_options", emptyMap()) | |
| 302 | + | |
| 303 | + fun createCronos( | |
| 304 | + name: String, | |
| 305 | + time: String, | |
| 306 | + action: String, | |
| 307 | + arg: String?, | |
| 308 | + targets: List<String>, | |
| 309 | + ) = push( | |
| 310 | + "services", "create_cronos", | |
| 311 | + mapOf( | |
| 312 | + "cronos" to buildMap { | |
| 313 | + put("name", name) | |
| 314 | + put("time", time) | |
| 315 | + put("action", action) | |
| 316 | + put("targets", targets) | |
| 317 | + if (arg != null) put("arg", arg) | |
| 318 | + } | |
| 319 | + ) | |
| 320 | + ) | |
| 321 | + | |
| 322 | + // brightness clamps. a null place is the global clamp | |
| 323 | + private fun clampPayload(place: String?, rest: Map<String, Any>): Map<String, Any> = | |
| 324 | + if (place == null) rest else rest + mapOf("place" to place) | |
| 325 | + | |
| 326 | + fun setClamp(place: String?, value: Int) = | |
| 327 | + push("brightness", "set", clampPayload(place, mapOf("value" to value))) | |
| 328 | + | |
| 329 | + fun clampStep(place: String?, up: Boolean) = | |
| 330 | + push("brightness", if (up) "brighter" else "dimmer", clampPayload(place, emptyMap())) | |
| 331 | + | |
| 332 | + fun setClampRate(place: String?, rate: Int) = | |
| 333 | + push("brightness", "rate", clampPayload(place, mapOf("rate" to rate))) | |
| 334 | + | |
| 335 | + fun clearClamp(place: String) = push("brightness", "clear", mapOf("place" to place)) | |
| 248 | 336 | } |
modified
android/app/src/main/java/org/neiam/lambent/app/data/Models.kt
+69
−0
| 27 | 27 | val sox: Int, |
| 28 | 28 | val indicator: Int?, |
| 29 | 29 | val dname: String?, |
| 30 | + /** firmware version, which only the esp32rs devices report */ | |
| 31 | + val version: String?, | |
| 30 | 32 | ) { |
| 31 | 33 | /** strand count; a legacy 8266 reports none and still has one */ |
| 32 | 34 | val channels: Int get() = maxOf(sox, 1) |
| 45 | 47 | sox = raw["sox"].asInt() ?: 0, |
| 46 | 48 | indicator = raw["ind"].asInt(), |
| 47 | 49 | dname = raw["dname"].asStr(), |
| 50 | + version = raw["ver"].asStr(), | |
| 48 | 51 | ) |
| 49 | 52 | } |
| 50 | 53 | } |
| 96 | 99 | } |
| 97 | 100 | } |
| 98 | 101 | |
| 102 | +/** | |
| 103 | + * A brightness clamp: where it is, where it is headed, and how long the walk | |
| 104 | + * between the two takes. The global clamp has no place. | |
| 105 | + */ | |
| 106 | +data class Clamp( | |
| 107 | + val place: String?, | |
| 108 | + val current: Int, | |
| 109 | + val target: Int, | |
| 110 | + val rate: Int, | |
| 111 | +) { | |
| 112 | + val walking: Boolean get() = current != target | |
| 113 | + val isGlobal: Boolean get() = place == null | |
| 114 | + | |
| 115 | + companion object { | |
| 116 | + fun from(place: String?, raw: Map<String, Any?>) = Clamp( | |
| 117 | + place = place, | |
| 118 | + current = raw["curr"].asInt() ?: FULL, | |
| 119 | + target = raw["tgt"].asInt() ?: FULL, | |
| 120 | + rate = raw["rate"].asInt() ?: 0, | |
| 121 | + ) | |
| 122 | + | |
| 123 | + /** a place the server has never been asked to clamp is simply full */ | |
| 124 | + fun unclamped(place: String, rate: Int) = Clamp(place, FULL, FULL, rate) | |
| 125 | + | |
| 126 | + const val FULL = 255 | |
| 127 | + } | |
| 128 | +} | |
| 129 | + | |
| 130 | +/** a named stop, either on the brightness ladder or on the fade ladder */ | |
| 131 | +data class Named(val value: Int, val name: String) { | |
| 132 | + companion object { | |
| 133 | + fun step(raw: Map<String, Any?>) = Named(raw["value"].asInt() ?: 0, raw["name"].asStr() ?: "") | |
| 134 | + fun fade(raw: Map<String, Any?>) = Named(raw["rate"].asInt() ?: 0, raw["name"].asStr() ?: "") | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +/** one choice in the schedule form, as the server describes it */ | |
| 139 | +data class Choice(val label: String, val value: String) { | |
| 140 | + companion object { | |
| 141 | + fun from(raw: Map<String, Any?>) = | |
| 142 | + Choice(raw["label"].asStr() ?: "", raw["value"].asStr() ?: "") | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 99 | 146 | data class Service(val kind: String, val name: String?, val entry: String) { |
| 100 | 147 | companion object { |
| 101 | 148 | fun from(kind: String, raw: Map<String, Any?>) = Service( |
| 124 | 171 | |
| 125 | 172 | fun brightnessPercent(v: Int): Int = (v / 255f * 100).toInt() |
| 126 | 173 | |
| 174 | +/** | |
| 175 | + * Where a value sits on the brightness ladder, interpolated between rungs. | |
| 176 | + * The rungs are not evenly spaced in 0..255, so a control that lays them out | |
| 177 | + * evenly needs this to put a walking value in the right place. | |
| 178 | + */ | |
| 179 | +fun ladderPosition(value: Int, ladder: List<Named>): Float { | |
| 180 | + if (ladder.isEmpty()) return 0f | |
| 181 | + val rungs = ladder.map { it.value } | |
| 182 | + val last = rungs.size - 1 | |
| 183 | + if (value <= rungs.first()) return 0f | |
| 184 | + if (value >= rungs.last()) return last.toFloat() | |
| 185 | + | |
| 186 | + val upper = rungs.indexOfFirst { it > value } | |
| 187 | + val low = rungs[upper - 1] | |
| 188 | + val high = rungs[upper] | |
| 189 | + return (upper - 1) + (value - low).toFloat() / (high - low) | |
| 190 | +} | |
| 191 | + | |
| 192 | +/** the nearest rung to a value, as an index into the ladder */ | |
| 193 | +fun ladderIndex(value: Int, ladder: List<Named>): Int = | |
| 194 | + Math.round(ladderPosition(value, ladder)) | |
| 195 | + | |
| 127 | 196 | val ORDERS = listOf("rgb", "grb", "rgbww", "rgbnw", "rgbcw", "rgbaw", "rgbxw") |
added
android/app/src/main/java/org/neiam/lambent/app/ui/BrightnessScreen.kt
+222
−0
| 1 | +package org.neiam.lambent.app.ui | |
| 2 | + | |
| 3 | +import androidx.compose.foundation.layout.Arrangement | |
| 4 | +import androidx.compose.foundation.layout.Column | |
| 5 | +import androidx.compose.foundation.layout.PaddingValues | |
| 6 | +import androidx.compose.foundation.layout.Row | |
| 7 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 8 | +import androidx.compose.foundation.layout.padding | |
| 9 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 10 | +import androidx.compose.foundation.lazy.items | |
| 11 | +import androidx.compose.material.icons.Icons | |
| 12 | +import androidx.compose.material.icons.filled.Close | |
| 13 | +import androidx.compose.material.icons.filled.LightMode | |
| 14 | +import androidx.compose.material.icons.outlined.LightMode | |
| 15 | +import androidx.compose.material3.AssistChip | |
| 16 | +import androidx.compose.material3.Card | |
| 17 | +import androidx.compose.material3.DropdownMenu | |
| 18 | +import androidx.compose.material3.DropdownMenuItem | |
| 19 | +import androidx.compose.material3.Icon | |
| 20 | +import androidx.compose.material3.IconButton | |
| 21 | +import androidx.compose.material3.LinearProgressIndicator | |
| 22 | +import androidx.compose.material3.MaterialTheme | |
| 23 | +import androidx.compose.material3.Slider | |
| 24 | +import androidx.compose.material3.Text | |
| 25 | +import androidx.compose.runtime.Composable | |
| 26 | +import androidx.compose.runtime.LaunchedEffect | |
| 27 | +import androidx.compose.runtime.getValue | |
| 28 | +import androidx.compose.runtime.mutableStateOf | |
| 29 | +import androidx.compose.runtime.remember | |
| 30 | +import androidx.compose.runtime.setValue | |
| 31 | +import androidx.compose.ui.Alignment | |
| 32 | +import androidx.compose.ui.Modifier | |
| 33 | +import androidx.compose.ui.unit.dp | |
| 34 | +import androidx.lifecycle.compose.collectAsStateWithLifecycle | |
| 35 | +import org.neiam.lambent.app.data.Clamp | |
| 36 | +import org.neiam.lambent.app.data.LambentSocket | |
| 37 | +import org.neiam.lambent.app.data.Named | |
| 38 | +import org.neiam.lambent.app.data.brightnessPercent | |
| 39 | +import org.neiam.lambent.app.data.ladderIndex | |
| 40 | +import org.neiam.lambent.app.data.ladderPosition | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * The global clamp and one row per place, mirroring /cfg/brightness. A clamp | |
| 44 | + * is a ceiling over what the machines produce, not a level, and it walks to | |
| 45 | + * its target rather than snapping — so each row shows where it is headed and | |
| 46 | + * where it has actually got to. | |
| 47 | + */ | |
| 48 | +@Composable | |
| 49 | +fun BrightnessScreen() { | |
| 50 | + val global by LambentSocket.globalClamp.collectAsStateWithLifecycle() | |
| 51 | + val clamps by LambentSocket.placeClamps.collectAsStateWithLifecycle() | |
| 52 | + val devices by LambentSocket.devices.collectAsStateWithLifecycle() | |
| 53 | + val ladder by LambentSocket.ladder.collectAsStateWithLifecycle() | |
| 54 | + val fades by LambentSocket.fades.collectAsStateWithLifecycle() | |
| 55 | + | |
| 56 | + // every place a device claims, plus any carrying a clamp with no device | |
| 57 | + // answering right now | |
| 58 | + val places = remember(devices, clamps) { | |
| 59 | + (devices.values.mapNotNull { it.place } + clamps.keys).distinct().sorted() | |
| 60 | + } | |
| 61 | + | |
| 62 | + val defaultRate = remember(fades) { fades.firstOrNull { it.name == "TWOMINS" }?.value ?: 0 } | |
| 63 | + | |
| 64 | + LazyColumn( | |
| 65 | + contentPadding = PaddingValues(12.dp), | |
| 66 | + verticalArrangement = Arrangement.spacedBy(8.dp), | |
| 67 | + ) { | |
| 68 | + item { | |
| 69 | + Text( | |
| 70 | + "A clamp is a ceiling over everything underneath it. The global one scales " + | |
| 71 | + "every machine; a place clamp scales whatever reaches a device in that place.", | |
| 72 | + style = MaterialTheme.typography.labelSmall, | |
| 73 | + ) | |
| 74 | + } | |
| 75 | + | |
| 76 | + item { | |
| 77 | + ClampCard( | |
| 78 | + title = "Global", | |
| 79 | + subtitle = "${devices.size} devices", | |
| 80 | + clamp = global, | |
| 81 | + ladder = ladder, | |
| 82 | + fades = fades, | |
| 83 | + ) | |
| 84 | + } | |
| 85 | + | |
| 86 | + if (places.isEmpty()) { | |
| 87 | + item { | |
| 88 | + Text( | |
| 89 | + "No places yet. Give a device a place and it shows up here.", | |
| 90 | + style = MaterialTheme.typography.bodyMedium, | |
| 91 | + ) | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + items(places, key = { it }) { place -> | |
| 96 | + val clamp = clamps[place] ?: Clamp.unclamped(place, defaultRate) | |
| 97 | + ClampCard( | |
| 98 | + title = place, | |
| 99 | + subtitle = "${devices.values.count { it.place == place }} devices", | |
| 100 | + clamp = clamp, | |
| 101 | + ladder = ladder, | |
| 102 | + fades = fades, | |
| 103 | + onClear = if (clamps.containsKey(place)) { | |
| 104 | + { LambentSocket.clearClamp(place) } | |
| 105 | + } else { | |
| 106 | + null | |
| 107 | + }, | |
| 108 | + ) | |
| 109 | + } | |
| 110 | + } | |
| 111 | +} | |
| 112 | + | |
| 113 | +@Composable | |
| 114 | +private fun ClampCard( | |
| 115 | + title: String, | |
| 116 | + subtitle: String, | |
| 117 | + clamp: Clamp, | |
| 118 | + ladder: List<Named>, | |
| 119 | + fades: List<Named>, | |
| 120 | + onClear: (() -> Unit)? = null, | |
| 121 | +) { | |
| 122 | + Card(modifier = Modifier.fillMaxWidth()) { | |
| 123 | + Column( | |
| 124 | + modifier = Modifier.padding(12.dp), | |
| 125 | + verticalArrangement = Arrangement.spacedBy(6.dp), | |
| 126 | + ) { | |
| 127 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 128 | + Column(modifier = Modifier.weight(1f)) { | |
| 129 | + Text(title, style = MaterialTheme.typography.titleMedium) | |
| 130 | + Text( | |
| 131 | + if (clamp.walking) { | |
| 132 | + "${brightnessPercent(clamp.current)}% → " + | |
| 133 | + "${brightnessPercent(clamp.target)}% · $subtitle" | |
| 134 | + } else { | |
| 135 | + "${brightnessPercent(clamp.current)}% · $subtitle" | |
| 136 | + }, | |
| 137 | + style = MaterialTheme.typography.labelSmall, | |
| 138 | + ) | |
| 139 | + } | |
| 140 | + | |
| 141 | + IconButton(onClick = { LambentSocket.clampStep(clamp.place, up = false) }) { | |
| 142 | + Icon(Icons.Outlined.LightMode, contentDescription = "Dimmer") | |
| 143 | + } | |
| 144 | + IconButton(onClick = { LambentSocket.clampStep(clamp.place, up = true) }) { | |
| 145 | + Icon(Icons.Filled.LightMode, contentDescription = "Brighter") | |
| 146 | + } | |
| 147 | + if (onClear != null) { | |
| 148 | + IconButton(onClick = onClear) { | |
| 149 | + Icon( | |
| 150 | + Icons.Filled.Close, | |
| 151 | + contentDescription = "Drop this clamp", | |
| 152 | + tint = MaterialTheme.colorScheme.error, | |
| 153 | + ) | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } | |
| 157 | + | |
| 158 | + if (ladder.size > 1) { | |
| 159 | + RungSlider(clamp = clamp, ladder = ladder) | |
| 160 | + | |
| 161 | + // where the walk has actually got to, on the same scale as the | |
| 162 | + // slider above it | |
| 163 | + LinearProgressIndicator( | |
| 164 | + progress = { | |
| 165 | + ladderPosition(clamp.current, ladder) / (ladder.size - 1) | |
| 166 | + }, | |
| 167 | + modifier = Modifier.fillMaxWidth(), | |
| 168 | + ) | |
| 169 | + } | |
| 170 | + | |
| 171 | + FadeChip(clamp = clamp, fades = fades) | |
| 172 | + } | |
| 173 | + } | |
| 174 | +} | |
| 175 | + | |
| 176 | +/** | |
| 177 | + * The target, which can only land on one of the named rungs. The rungs are | |
| 178 | + * bunched into the bottom of 0..255, so the slider runs over their positions | |
| 179 | + * and gives them even spacing. | |
| 180 | + */ | |
| 181 | +@Composable | |
| 182 | +private fun RungSlider(clamp: Clamp, ladder: List<Named>) { | |
| 183 | + val last = ladder.size - 1 | |
| 184 | + var position by remember { mutableStateOf(ladderIndex(clamp.target, ladder).toFloat()) } | |
| 185 | + | |
| 186 | + // follow the server unless this is the drag that set it | |
| 187 | + LaunchedEffect(clamp.target, ladder) { | |
| 188 | + position = ladderIndex(clamp.target, ladder).toFloat() | |
| 189 | + } | |
| 190 | + | |
| 191 | + Slider( | |
| 192 | + value = position, | |
| 193 | + onValueChange = { position = it }, | |
| 194 | + onValueChangeFinished = { | |
| 195 | + val rung = ladder.getOrNull(position.toInt().coerceIn(0, last)) ?: return@Slider | |
| 196 | + LambentSocket.setClamp(clamp.place, rung.value) | |
| 197 | + }, | |
| 198 | + valueRange = 0f..last.toFloat(), | |
| 199 | + steps = (last - 1).coerceAtLeast(0), | |
| 200 | + ) | |
| 201 | +} | |
| 202 | + | |
| 203 | +@Composable | |
| 204 | +private fun FadeChip(clamp: Clamp, fades: List<Named>) { | |
| 205 | + var open by remember { mutableStateOf(false) } | |
| 206 | + val label = fades.firstOrNull { it.value == clamp.rate }?.name ?: "${clamp.rate}ms" | |
| 207 | + | |
| 208 | + Column { | |
| 209 | + AssistChip(onClick = { open = true }, label = { Text("fade $label") }) | |
| 210 | + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { | |
| 211 | + fades.forEach { fade -> | |
| 212 | + DropdownMenuItem( | |
| 213 | + text = { Text(fade.name) }, | |
| 214 | + onClick = { | |
| 215 | + LambentSocket.setClampRate(clamp.place, fade.value) | |
| 216 | + open = false | |
| 217 | + }, | |
| 218 | + ) | |
| 219 | + } | |
| 220 | + } | |
| 221 | + } | |
| 222 | +} |
modified
android/app/src/main/java/org/neiam/lambent/app/ui/DevicesScreen.kt
+4
−1
| 121 | 121 | ) |
| 122 | 122 | if (device.isSevenSevenSevenSeven) OrderChip(device) |
| 123 | 123 | Text( |
| 124 | − "${device.type ?: "?"} · ${device.channels}ch", | |
| 124 | + listOfNotNull( | |
| 125 | + "${device.type ?: "?"} · ${device.channels}ch", | |
| 126 | + device.version, | |
| 127 | + ).joinToString(" · "), | |
| 125 | 128 | style = MaterialTheme.typography.labelSmall, |
| 126 | 129 | ) |
| 127 | 130 | } |
modified
android/app/src/main/java/org/neiam/lambent/app/ui/ServicesScreen.kt
+187
−0
Click to load diff…
added
lib/lambent_ex_web/channels/brightness_channel.ex
+106
−0
Click to load diff…
modified
lib/lambent_ex_web/channels/links_channel.ex
+3
−3
Click to load diff…
modified
lib/lambent_ex_web/channels/services_channel.ex
+43
−2
Click to load diff…
modified
lib/lambent_ex_web/channels/user_socket.ex
+2
−1
Click to load diff…
modified
test/lambent_ex_web/channels/channels_test.exs
+101
−0
Click to load diff…
Parents: 5e87a85