Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a678a593df | ||
|
|
9a264ba013 | ||
|
|
1462ee2172 |
@@ -24,8 +24,8 @@ android {
|
|||||||
applicationId = "kr.tkrmagid.easyappliance"
|
applicationId = "kr.tkrmagid.easyappliance"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 6
|
versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 10
|
||||||
versionName = (project.findProperty("verName") as String?) ?: "0.3.3"
|
versionName = (project.findProperty("verName") as String?) ?: "0.3.7"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ class MockDeviceRepository : DeviceRepository {
|
|||||||
"washer-1" -> DeviceStatus(
|
"washer-1" -> DeviceStatus(
|
||||||
powerOn = on,
|
powerOn = on,
|
||||||
operatingState = if (on) "운전 중" else "꺼짐",
|
operatingState = if (on) "운전 중" else "꺼짐",
|
||||||
|
running = on,
|
||||||
|
currentSettings = if (on) "물 온도 40도 · 탈수 강 · 헹굼 3회" else null,
|
||||||
remainingMinutes = if (on) 32 else null,
|
remainingMinutes = if (on) 32 else null,
|
||||||
reservation = null,
|
reservation = null,
|
||||||
)
|
)
|
||||||
"aircon-1" -> DeviceStatus(
|
"aircon-1" -> DeviceStatus(
|
||||||
powerOn = on,
|
powerOn = on,
|
||||||
operatingState = if (on) "냉방 중" else "꺼짐",
|
operatingState = if (on) "냉방 중" else "꺼짐",
|
||||||
|
running = on,
|
||||||
|
currentSettings = if (on) "냉방 · 바람 자동" else null,
|
||||||
currentTemperature = 27,
|
currentTemperature = 27,
|
||||||
targetTemperature = if (on) 24 else null,
|
targetTemperature = if (on) 24 else null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,4 +24,11 @@ data class MacroStep(
|
|||||||
data class DeviceMacro(
|
data class DeviceMacro(
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val steps: List<MacroStep> = emptyList(),
|
val steps: List<MacroStep> = emptyList(),
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* One-line human summary of the configured start options, e.g.
|
||||||
|
* "물 온도 40도 · 수위 높음". Empty when nothing is configured.
|
||||||
|
*/
|
||||||
|
fun summary(): String =
|
||||||
|
steps.mapNotNull { it.label.takeIf { l -> l.isNotBlank() } }.joinToString(" · ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ data class DeviceStatus(
|
|||||||
val powerOn: Boolean = false,
|
val powerOn: Boolean = false,
|
||||||
/** Human-readable run state, e.g. "운전 중", "정지", "일시정지". */
|
/** Human-readable run state, e.g. "운전 중", "정지", "일시정지". */
|
||||||
val operatingState: String? = null,
|
val operatingState: String? = null,
|
||||||
|
/** True when a cycle is actually in progress (run/pause), not just powered. */
|
||||||
|
val running: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Essential current settings while running, e.g. "물 온도 40도 · 수위 높음".
|
||||||
|
* Only the few important fields; null when not running / not reported.
|
||||||
|
*/
|
||||||
|
val currentSettings: String? = null,
|
||||||
/** Remaining minutes for a running cycle, if reported. */
|
/** Remaining minutes for a running cycle, if reported. */
|
||||||
val remainingMinutes: Int? = null,
|
val remainingMinutes: Int? = null,
|
||||||
/** Reservation / scheduled-start description, if set. */
|
/** Reservation / scheduled-start description, if set. */
|
||||||
|
|||||||
@@ -49,16 +49,32 @@ class SmartThingsDeviceRepository(
|
|||||||
StCommand(
|
StCommand(
|
||||||
capability = step.capability,
|
capability = step.capability,
|
||||||
command = step.command,
|
command = step.command,
|
||||||
arguments = step.args.map { it.toJsonElement() },
|
arguments = step.args.map { encodeArg(step.command, it) },
|
||||||
)
|
)
|
||||||
} + StCommand(capability = "switch", command = "on")
|
} + StCommand(capability = "switch", command = "on")
|
||||||
api.sendCommands(deviceId, StCommandsRequest(commands))
|
api.sendCommands(deviceId, StCommandsRequest(commands))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toJsonElement(): JsonElement =
|
/**
|
||||||
toIntOrNull()?.let { JsonPrimitive(it) }
|
* SmartThings commands are type-strict. Most appliance commands take string
|
||||||
?: toDoubleOrNull()?.let { JsonPrimitive(it) }
|
* enums (e.g. washer temperature "40", fan "1") and MUST be sent as strings —
|
||||||
?: JsonPrimitive(this)
|
* only setpoint-style commands take a number. So default to string and send a
|
||||||
|
* number only for the known numeric commands.
|
||||||
|
*/
|
||||||
|
private fun encodeArg(command: String, raw: String): JsonElement =
|
||||||
|
if (command in NUMERIC_COMMANDS) {
|
||||||
|
raw.toDoubleOrNull()?.let { d ->
|
||||||
|
if (d % 1.0 == 0.0) JsonPrimitive(d.toInt()) else JsonPrimitive(d)
|
||||||
|
} ?: JsonPrimitive(raw)
|
||||||
|
} else {
|
||||||
|
JsonPrimitive(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val NUMERIC_COMMANDS = setOf(
|
||||||
|
"setCoolingSetpoint", "setHeatingSetpoint", "setThermostatSetpoint",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun inferType(caps: Set<String>, label: String?, name: String?): DeviceType {
|
private fun inferType(caps: Set<String>, label: String?, name: String?): DeviceType {
|
||||||
val text = "${label.orEmpty()} ${name.orEmpty()}"
|
val text = "${label.orEmpty()} ${name.orEmpty()}"
|
||||||
|
|||||||
@@ -37,11 +37,25 @@ object SmartThingsStatusMapper {
|
|||||||
"stop" -> "정지"
|
"stop" -> "정지"
|
||||||
else -> if (powerOn) "켜짐" else "꺼짐"
|
else -> if (powerOn) "켜짐" else "꺼짐"
|
||||||
}
|
}
|
||||||
|
val running = machine == "run" || machine == "pause"
|
||||||
val remaining = numberAttr(main, "samsungce.washerOperatingState", "remainingTime")
|
val remaining = numberAttr(main, "samsungce.washerOperatingState", "remainingTime")
|
||||||
?.let { secondsToMinutes(it) }
|
?.let { secondsToMinutes(it) }
|
||||||
|
val temp = attr(main, "custom.washerWaterTemperature", "washerWaterTemperature")?.contentOrNull()
|
||||||
|
?.let { washerTempLabel(it) }
|
||||||
|
val spin = attr(main, "custom.washerSpinLevel", "washerSpinLevel")?.contentOrNull()
|
||||||
|
?.let { washerSpinLabel(it) }
|
||||||
|
val rinse = attr(main, "custom.washerRinseCycles", "washerRinseCycles")?.contentOrNull()
|
||||||
|
?.toIntOrNull()
|
||||||
|
val settings = listOfNotNull(
|
||||||
|
temp?.let { "물 온도 $it" },
|
||||||
|
spin?.let { "탈수 $it" },
|
||||||
|
rinse?.let { "헹굼 ${it}회" },
|
||||||
|
).joinToString(" · ").ifBlank { null }
|
||||||
return DeviceStatus(
|
return DeviceStatus(
|
||||||
powerOn = powerOn,
|
powerOn = powerOn,
|
||||||
operatingState = state,
|
operatingState = state,
|
||||||
|
running = running,
|
||||||
|
currentSettings = if (running) settings else null,
|
||||||
remainingMinutes = remaining,
|
remainingMinutes = remaining,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -50,14 +64,58 @@ object SmartThingsStatusMapper {
|
|||||||
val current = numberAttr(main, "temperatureMeasurement", "temperature")
|
val current = numberAttr(main, "temperatureMeasurement", "temperature")
|
||||||
val target = numberAttr(main, "thermostatCoolingSetpoint", "coolingSetpoint")
|
val target = numberAttr(main, "thermostatCoolingSetpoint", "coolingSetpoint")
|
||||||
?: numberAttr(main, "custom.thermostatSetpointControl", "coolingSetpoint")
|
?: numberAttr(main, "custom.thermostatSetpointControl", "coolingSetpoint")
|
||||||
|
val mode = attr(main, "airConditionerMode", "airConditionerMode")?.contentOrNull()
|
||||||
|
?.let { acModeLabel(it) }
|
||||||
|
val fan = attr(main, "airConditionerFanMode", "fanMode")?.contentOrNull()
|
||||||
|
?.let { acFanLabel(it) }
|
||||||
|
val settings = listOfNotNull(
|
||||||
|
mode,
|
||||||
|
fan?.let { "바람 $it" },
|
||||||
|
).joinToString(" · ").ifBlank { null }
|
||||||
return DeviceStatus(
|
return DeviceStatus(
|
||||||
powerOn = powerOn,
|
powerOn = powerOn,
|
||||||
operatingState = if (powerOn) "냉방 중" else "꺼짐",
|
operatingState = if (powerOn) (mode?.let { "$it 중" } ?: "켜짐") else "꺼짐",
|
||||||
|
running = powerOn,
|
||||||
|
currentSettings = if (powerOn) settings else null,
|
||||||
currentTemperature = current,
|
currentTemperature = current,
|
||||||
targetTemperature = if (powerOn) target else null,
|
targetTemperature = if (powerOn) target else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun washerTempLabel(raw: String): String = when (raw.lowercase()) {
|
||||||
|
"cold", "tapcold" -> "냉수"
|
||||||
|
"hot" -> "온수"
|
||||||
|
"none" -> "없음"
|
||||||
|
else -> raw.toIntOrNull()?.let { "${it}도" } ?: raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun washerSpinLabel(raw: String): String = when (raw.lowercase()) {
|
||||||
|
"nospin" -> "탈수없음"
|
||||||
|
"rinsehold" -> "헹굼정지"
|
||||||
|
"extralow" -> "매우약"
|
||||||
|
"low" -> "약"
|
||||||
|
"medium" -> "중"
|
||||||
|
"high" -> "강"
|
||||||
|
"extrahigh" -> "최강"
|
||||||
|
else -> raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acModeLabel(raw: String): String = when (raw.lowercase()) {
|
||||||
|
"cool" -> "냉방"
|
||||||
|
"dry" -> "제습"
|
||||||
|
"wind" -> "송풍"
|
||||||
|
"heat" -> "난방"
|
||||||
|
"aicomfort" -> "AI쾌적"
|
||||||
|
"auto" -> "자동"
|
||||||
|
else -> raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acFanLabel(raw: String): String = when (raw.lowercase()) {
|
||||||
|
"auto" -> "자동"
|
||||||
|
"max" -> "최대"
|
||||||
|
else -> raw.toIntOrNull()?.let { "${it}단" } ?: raw
|
||||||
|
}
|
||||||
|
|
||||||
private fun attr(main: JsonObject, capability: String, attribute: String): JsonPrimitive? =
|
private fun attr(main: JsonObject, capability: String, attribute: String): JsonPrimitive? =
|
||||||
runCatching {
|
runCatching {
|
||||||
main[capability]?.jsonObject?.get(attribute)?.jsonObject?.get("value") as? JsonPrimitive
|
main[capability]?.jsonObject?.get(attribute)?.jsonObject?.get("value") as? JsonPrimitive
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ fun AdminScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!state.usingRealApi) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"지금 목록은 예시(샘플) 기기입니다. 실제 기기를 보려면 아래 'SmartThings 연결'에서 토큰을 입력한 뒤 '목록 새로고침'을 누르세요.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onRefreshDevices,
|
onClick = onRefreshDevices,
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ fun HomeScreen(
|
|||||||
|
|
||||||
StatusCard(state)
|
StatusCard(state)
|
||||||
|
|
||||||
|
val status = state.status
|
||||||
|
val preStart = state.macroFor(device)
|
||||||
|
?.takeIf { it.enabled && it.steps.isNotEmpty() }
|
||||||
|
?.summary()
|
||||||
|
if (status?.running == true && !status.currentSettings.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SelectedOptionsCard(title = "현재 작동 설정", summary = status.currentSettings)
|
||||||
|
} else if (status?.running != true && preStart != null) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SelectedOptionsCard(title = "시작 설정", summary = preStart)
|
||||||
|
}
|
||||||
|
|
||||||
if (state.settings.showPowerControls) {
|
if (state.settings.showPowerControls) {
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
val macroOn = state.macroFor(device)?.enabled == true
|
val macroOn = state.macroFor(device)?.enabled == true
|
||||||
@@ -169,6 +181,33 @@ private fun StatusCard(state: HomeUiState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedOptionsCard(title: String, summary: String) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
summary,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PowerControls(isOn: Boolean, stacked: Boolean, onTogglePower: (Boolean) -> Unit) {
|
private fun PowerControls(isOn: Boolean, stacked: Boolean, onTogglePower: (Boolean) -> Unit) {
|
||||||
val onButton: @Composable (Modifier) -> Unit = { m ->
|
val onButton: @Composable (Modifier) -> Unit = { m ->
|
||||||
|
|||||||
@@ -34,23 +34,67 @@ import kr.tkrmagid.easyappliance.data.DeviceMacro
|
|||||||
import kr.tkrmagid.easyappliance.data.DeviceType
|
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||||
import kr.tkrmagid.easyappliance.data.MacroStep
|
import kr.tkrmagid.easyappliance.data.MacroStep
|
||||||
|
|
||||||
|
// Capabilities/commands and supported values below match the user's real
|
||||||
|
// Samsung washer/aircon (read live from the SmartThings API).
|
||||||
private const val TEMP_CAP = "custom.washerWaterTemperature"
|
private const val TEMP_CAP = "custom.washerWaterTemperature"
|
||||||
private const val TEMP_CMD = "setWasherWaterTemperature"
|
private const val TEMP_CMD = "setWasherWaterTemperature"
|
||||||
private const val LEVEL_CAP = "custom.washerWaterLevel"
|
private const val SPIN_CAP = "custom.washerSpinLevel"
|
||||||
private const val LEVEL_CMD = "setWasherWaterLevel"
|
private const val SPIN_CMD = "setWasherSpinLevel"
|
||||||
|
private const val RINSE_CAP = "custom.washerRinseCycles"
|
||||||
|
private const val RINSE_CMD = "setWasherRinseCycles"
|
||||||
|
|
||||||
|
private const val AC_MODE_CAP = "airConditionerMode"
|
||||||
|
private const val AC_MODE_CMD = "setAirConditionerMode"
|
||||||
|
private const val AC_TEMP_CAP = "thermostatCoolingSetpoint"
|
||||||
|
private const val AC_TEMP_CMD = "setCoolingSetpoint"
|
||||||
|
private const val AC_FAN_CAP = "airConditionerFanMode"
|
||||||
|
private const val AC_FAN_CMD = "setFanMode"
|
||||||
|
|
||||||
private data class Preset(val value: String, val label: String)
|
private data class Preset(val value: String, val label: String)
|
||||||
|
|
||||||
private val TEMP_PRESETS = listOf(
|
private val TEMP_PRESETS = listOf(
|
||||||
Preset("cold", "냉수"),
|
Preset("cold", "냉수"),
|
||||||
|
Preset("20", "20도"),
|
||||||
Preset("30", "30도"),
|
Preset("30", "30도"),
|
||||||
Preset("40", "40도"),
|
Preset("40", "40도"),
|
||||||
Preset("60", "60도"),
|
Preset("60", "60도"),
|
||||||
|
Preset("90", "90도"),
|
||||||
)
|
)
|
||||||
private val LEVEL_PRESETS = listOf(
|
private val SPIN_PRESETS = listOf(
|
||||||
Preset("low", "낮음"),
|
Preset("noSpin", "탈수없음"),
|
||||||
Preset("medium", "중간"),
|
Preset("low", "약"),
|
||||||
Preset("high", "높음"),
|
Preset("medium", "중"),
|
||||||
|
Preset("high", "강"),
|
||||||
|
Preset("extraHigh", "최강"),
|
||||||
|
)
|
||||||
|
private val RINSE_PRESETS = listOf(
|
||||||
|
Preset("1", "1회"),
|
||||||
|
Preset("2", "2회"),
|
||||||
|
Preset("3", "3회"),
|
||||||
|
Preset("4", "4회"),
|
||||||
|
Preset("5", "5회"),
|
||||||
|
)
|
||||||
|
private val AC_MODE_PRESETS = listOf(
|
||||||
|
Preset("cool", "냉방"),
|
||||||
|
Preset("dry", "제습"),
|
||||||
|
Preset("wind", "송풍"),
|
||||||
|
Preset("aIComfort", "AI쾌적"),
|
||||||
|
)
|
||||||
|
private val AC_TEMP_PRESETS = listOf(
|
||||||
|
Preset("18", "18도"),
|
||||||
|
Preset("20", "20도"),
|
||||||
|
Preset("22", "22도"),
|
||||||
|
Preset("24", "24도"),
|
||||||
|
Preset("26", "26도"),
|
||||||
|
Preset("28", "28도"),
|
||||||
|
)
|
||||||
|
private val AC_FAN_PRESETS = listOf(
|
||||||
|
Preset("auto", "자동"),
|
||||||
|
Preset("1", "1단"),
|
||||||
|
Preset("2", "2단"),
|
||||||
|
Preset("3", "3단"),
|
||||||
|
Preset("4", "4단"),
|
||||||
|
Preset("max", "최대"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@@ -80,45 +124,24 @@ fun MacroEditor(device: Device, macro: DeviceMacro, onChange: (DeviceMacro) -> U
|
|||||||
if (!macro.enabled) return@Column
|
if (!macro.enabled) return@Column
|
||||||
|
|
||||||
if (device.type == DeviceType.WASHER) {
|
if (device.type == DeviceType.WASHER) {
|
||||||
Spacer(Modifier.height(8.dp))
|
PresetGroup("물 온도", TEMP_CAP, TEMP_CMD, "물 온도", TEMP_PRESETS, macro, onChange)
|
||||||
Text("물 온도", style = MaterialTheme.typography.bodyMedium)
|
PresetGroup("탈수 세기", SPIN_CAP, SPIN_CMD, "탈수", SPIN_PRESETS, macro, onChange)
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
PresetGroup("헹굼 횟수", RINSE_CAP, RINSE_CMD, "헹굼", RINSE_PRESETS, macro, onChange)
|
||||||
TEMP_PRESETS.forEach { p ->
|
|
||||||
val selected = macro.steps.any { it.capability == TEMP_CAP && it.args == listOf(p.value) }
|
|
||||||
FilterChip(
|
|
||||||
selected = selected,
|
|
||||||
onClick = {
|
|
||||||
onChange(
|
|
||||||
macro.upsert(
|
|
||||||
MacroStep(TEMP_CAP, TEMP_CMD, listOf(p.value), "물 온도 ${p.label}"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = { Text(p.label) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text("수위(물 높이)", style = MaterialTheme.typography.bodyMedium)
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
LEVEL_PRESETS.forEach { p ->
|
|
||||||
val selected = macro.steps.any { it.capability == LEVEL_CAP && it.args == listOf(p.value) }
|
|
||||||
FilterChip(
|
|
||||||
selected = selected,
|
|
||||||
onClick = {
|
|
||||||
onChange(
|
|
||||||
macro.upsert(
|
|
||||||
MacroStep(LEVEL_CAP, LEVEL_CMD, listOf(p.value), "수위 ${p.label}"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = { Text(p.label) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
"※ 삼성 세탁기 기준 예시 명령입니다. 실제 기기에서 동작하지 않으면 아래 '직접 추가'로 조정하세요.",
|
"※ 연결된 세탁기가 실제로 지원하는 설정입니다. 물높이(수위)는 세탁기가 자동으로 맞추므로 직접 설정하지 않습니다.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.type == DeviceType.AIRCONDITIONER) {
|
||||||
|
PresetGroup("운전 모드", AC_MODE_CAP, AC_MODE_CMD, "", AC_MODE_PRESETS, macro, onChange)
|
||||||
|
PresetGroup("설정 온도", AC_TEMP_CAP, AC_TEMP_CMD, "설정 온도", AC_TEMP_PRESETS, macro, onChange)
|
||||||
|
PresetGroup("바람 세기", AC_FAN_CAP, AC_FAN_CMD, "바람 세기", AC_FAN_PRESETS, macro, onChange)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"※ 연결된 에어컨이 실제로 지원하는 설정입니다.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -157,6 +180,34 @@ fun MacroEditor(device: Device, macro: DeviceMacro, onChange: (DeviceMacro) -> U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun PresetGroup(
|
||||||
|
title: String,
|
||||||
|
capability: String,
|
||||||
|
command: String,
|
||||||
|
labelPrefix: String,
|
||||||
|
presets: List<Preset>,
|
||||||
|
macro: DeviceMacro,
|
||||||
|
onChange: (DeviceMacro) -> Unit,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
presets.forEach { p ->
|
||||||
|
val selected = macro.steps.any { it.capability == capability && it.args == listOf(p.value) }
|
||||||
|
val stepLabel = if (labelPrefix.isBlank()) p.label else "$labelPrefix ${p.label}"
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
onChange(macro.upsert(MacroStep(capability, command, listOf(p.value), stepLabel)))
|
||||||
|
},
|
||||||
|
label = { Text(p.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AdvancedStepAdder(onAdd: (MacroStep) -> Unit) {
|
private fun AdvancedStepAdder(onAdd: (MacroStep) -> Unit) {
|
||||||
var cap by remember { mutableStateOf("") }
|
var cap by remember { mutableStateOf("") }
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
// Larger-than-default type scale so text stays readable for older users.
|
// Larger-than-default type scale so text stays readable for older users.
|
||||||
|
// Every style is Bold so all text stays thick and easy to read.
|
||||||
val ElderlyTypography = Typography(
|
val ElderlyTypography = Typography(
|
||||||
displayLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 48.sp, lineHeight = 56.sp),
|
displayLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 48.sp, lineHeight = 56.sp),
|
||||||
headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 48.sp),
|
headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 48.sp),
|
||||||
headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp),
|
headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp),
|
||||||
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp),
|
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp),
|
||||||
bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 30.sp),
|
titleMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp),
|
||||||
bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 20.sp, lineHeight = 28.sp),
|
bodyLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 30.sp),
|
||||||
|
bodyMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 28.sp),
|
||||||
labelLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp),
|
labelLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp),
|
||||||
)
|
)
|
||||||
|
|||||||
37
app/src/test/java/kr/tkrmagid/easyappliance/MacroTest.kt
Normal file
37
app/src/test/java/kr/tkrmagid/easyappliance/MacroTest.kt
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.tkrmagid.easyappliance
|
||||||
|
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceMacro
|
||||||
|
import kr.tkrmagid.easyappliance.data.MacroStep
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MacroTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun summaryJoinsStepLabels() {
|
||||||
|
val macro = DeviceMacro(
|
||||||
|
enabled = true,
|
||||||
|
steps = listOf(
|
||||||
|
MacroStep("custom.washerWaterTemperature", "setWasherWaterTemperature", listOf("40"), "물 온도 40도"),
|
||||||
|
MacroStep("custom.washerWaterLevel", "setWasherWaterLevel", listOf("high"), "수위 높음"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("물 온도 40도 · 수위 높음", macro.summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun summaryIsEmptyWhenNoSteps() {
|
||||||
|
assertEquals("", DeviceMacro(enabled = true).summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun summarySkipsBlankLabels() {
|
||||||
|
val macro = DeviceMacro(
|
||||||
|
steps = listOf(
|
||||||
|
MacroStep("thermostatCoolingSetpoint", "setCoolingSetpoint", listOf("24"), "설정 온도 24도"),
|
||||||
|
MacroStep("airConditionerFanMode", "setFanMode", listOf("high"), ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("설정 온도 24도", macro.summary())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,9 +26,14 @@ class MockDeviceRepositoryTest {
|
|||||||
assertTrue(on.powerOn)
|
assertTrue(on.powerOn)
|
||||||
assertEquals("운전 중", on.operatingState)
|
assertEquals("운전 중", on.operatingState)
|
||||||
assertEquals(32, on.remainingMinutes)
|
assertEquals(32, on.remainingMinutes)
|
||||||
|
assertTrue(on.running)
|
||||||
|
assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings)
|
||||||
|
|
||||||
repo.setPower("washer-1", false)
|
repo.setPower("washer-1", false)
|
||||||
assertFalse(repo.getStatus("washer-1").powerOn)
|
val off = repo.getStatus("washer-1")
|
||||||
|
assertFalse(off.powerOn)
|
||||||
|
assertFalse(off.running)
|
||||||
|
assertEquals(null, off.currentSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ class SmartThingsStatusMapperTest {
|
|||||||
{"components":{"main":{
|
{"components":{"main":{
|
||||||
"switch":{"switch":{"value":"on"}},
|
"switch":{"switch":{"value":"on"}},
|
||||||
"washerOperatingState":{"machineState":{"value":"run"}},
|
"washerOperatingState":{"machineState":{"value":"run"}},
|
||||||
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}}
|
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}},
|
||||||
|
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}},
|
||||||
|
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}},
|
||||||
|
"custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}}
|
||||||
}}}
|
}}}
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
@@ -28,6 +31,26 @@ class SmartThingsStatusMapperTest {
|
|||||||
assertTrue(result.powerOn)
|
assertTrue(result.powerOn)
|
||||||
assertEquals("운전 중", result.operatingState)
|
assertEquals("운전 중", result.operatingState)
|
||||||
assertEquals(33, result.remainingMinutes)
|
assertEquals(33, result.remainingMinutes)
|
||||||
|
assertTrue(result.running)
|
||||||
|
assertEquals("물 온도 60도 · 탈수 최강 · 헹굼 3회", result.currentSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stoppedWasherHasNoCurrentSettings() {
|
||||||
|
val status = obj(
|
||||||
|
"""
|
||||||
|
{"components":{"main":{
|
||||||
|
"switch":{"switch":{"value":"on"}},
|
||||||
|
"washerOperatingState":{"machineState":{"value":"stop"}},
|
||||||
|
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"40"}},
|
||||||
|
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"high"}}
|
||||||
|
}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val result = SmartThingsStatusMapper.map(status, DeviceType.WASHER)
|
||||||
|
assertEquals("정지", result.operatingState)
|
||||||
|
assertTrue(!result.running)
|
||||||
|
assertNull(result.currentSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -36,6 +59,8 @@ class SmartThingsStatusMapperTest {
|
|||||||
"""
|
"""
|
||||||
{"components":{"main":{
|
{"components":{"main":{
|
||||||
"switch":{"switch":{"value":"on"}},
|
"switch":{"switch":{"value":"on"}},
|
||||||
|
"airConditionerMode":{"airConditionerMode":{"value":"cool"}},
|
||||||
|
"airConditionerFanMode":{"fanMode":{"value":"auto"}},
|
||||||
"temperatureMeasurement":{"temperature":{"value":27}},
|
"temperatureMeasurement":{"temperature":{"value":27}},
|
||||||
"thermostatCoolingSetpoint":{"coolingSetpoint":{"value":24}}
|
"thermostatCoolingSetpoint":{"coolingSetpoint":{"value":24}}
|
||||||
}}}
|
}}}
|
||||||
@@ -46,6 +71,7 @@ class SmartThingsStatusMapperTest {
|
|||||||
assertEquals("냉방 중", result.operatingState)
|
assertEquals("냉방 중", result.operatingState)
|
||||||
assertEquals(27, result.currentTemperature)
|
assertEquals(27, result.currentTemperature)
|
||||||
assertEquals(24, result.targetTemperature)
|
assertEquals(24, result.targetTemperature)
|
||||||
|
assertEquals("냉방 · 바람 자동", result.currentSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user