feat(infrared): expand remote library and creation flow #13

Merged
KremeCN merged 1 commits from feat/redesign-infrared-add-remote into main 2026-08-28 09:23:07 +08:00
12 changed files with 966 additions and 280 deletions
Showing only changes of commit 70667808d4 - Show all commits
@@ -111,7 +111,7 @@ interface InfraredDao {
"""
SELECT * FROM virtual_remotes
WHERE profileId = :profileId AND deviceName = :deviceName
ORDER BY createdAtEpochMillis DESC
ORDER BY createdAtEpochMillis ASC, id ASC
""",
)
fun observeVirtualRemotes(profileId: String, deviceName: String): Flow<List<VirtualRemoteEntity>>
@@ -192,6 +192,13 @@ object InfraredFeatureModule {
): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction =
com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway)
@Provides
@Singleton
fun provideTestPresetInfraredCode(
gateway: InfraredGateway,
): com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode =
com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode(gateway)
@Provides
@Singleton
fun provideDeleteVirtualRemote(
@@ -105,6 +105,7 @@ fun InfraredHubRoute(
onOpenRemote = onOpenRemote,
onDeleteRemote = viewModel::deleteRemote,
onCreateRemote = viewModel::createRemote,
onTestPreset = viewModel::testPreset,
onSaveDeviceName = viewModel::saveDeviceName,
onMoveRemote = viewModel::moveRemote,
onUpdateRemote = viewModel::updateRemote,
@@ -134,6 +135,7 @@ internal fun InfraredHubScreen(
onOpenRemote: (String) -> Unit,
onDeleteRemote: (VirtualRemoteId) -> Unit,
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
onTestPreset: (String, (Boolean) -> Unit) -> Unit = { _, result -> result(false) },
onSaveDeviceName: (String) -> Unit = {},
onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> },
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> },
@@ -156,6 +158,7 @@ internal fun InfraredHubScreen(
val resources = LocalResources.current
var showCreateDialog by remember { mutableStateOf(false) }
var showManagement by remember { mutableStateOf(false) }
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
LaunchedEffect(state.message) {
state.message?.let { msg ->
@@ -303,7 +306,7 @@ internal fun InfraredHubScreen(
onDeleteRemote = onDeleteRemote,
onSaveDeviceName = onSaveDeviceName,
onMoveRemote = onMoveRemote,
onUpdateRemote = onUpdateRemote,
onEditRemote = { editingRemote = it },
)
}
}
@@ -311,7 +314,9 @@ internal fun InfraredHubScreen(
}
if (showCreateDialog) {
CreateVirtualRemoteDialog(
CreateVirtualRemoteFlow(
slotQuota = state.slotQuota,
onTestPreset = onTestPreset,
onDismiss = { showCreateDialog = false },
onConfirm = { name, type, brandModel, transMode, layoutStyle ->
onCreateRemote(name, type, brandModel, transMode, layoutStyle)
@@ -320,6 +325,21 @@ internal fun InfraredHubScreen(
)
}
editingRemote?.let { remote ->
EditRemoteScreen(
remote = remote,
onDismiss = { editingRemote = null },
onSave = { name, layout, mode ->
editingRemote = null
onUpdateRemote(remote.id, name, layout, mode)
},
onDelete = {
editingRemote = null
onDeleteRemote(remote.id)
},
)
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.fillMaxSize().padding(bottom = 16.dp),
@@ -369,11 +389,10 @@ private fun InfraredManagementScreen(
onDeleteRemote: (VirtualRemoteId) -> Unit,
onSaveDeviceName: (String) -> Unit,
onMoveRemote: (VirtualRemoteId, Int) -> Unit,
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit,
onEditRemote: (VirtualRemote) -> Unit,
) {
var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) }
var reorderRemoteId by remember { mutableStateOf<VirtualRemoteId?>(null) }
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
@@ -438,7 +457,7 @@ private fun InfraredManagementScreen(
)
}
}
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { editingRemote = remote })
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { onEditRemote(remote) })
}
}
}
@@ -466,24 +485,11 @@ private fun InfraredManagementScreen(
}
Spacer(Modifier.height(24.dp))
}
editingRemote?.let { remote ->
EditRemoteDialog(
remote = remote,
onDismiss = { editingRemote = null },
onSave = { name, layout, mode ->
editingRemote = null
onUpdateRemote(remote.id, name, layout, mode)
},
onDelete = {
editingRemote = null
onDeleteRemote(remote.id)
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditRemoteDialog(
private fun EditRemoteScreen(
remote: VirtualRemote,
onDismiss: () -> Unit,
onSave: (String, PanelLayoutStyle, TransmissionMode) -> Unit,
@@ -491,39 +497,153 @@ private fun EditRemoteDialog(
) {
var name by remember(remote.id) { mutableStateOf(remote.displayName) }
var layout by remember(remote.id) { mutableStateOf(remote.layoutStyle) }
var mode by remember(remote.id) { mutableStateOf(remote.transmissionMode) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_edit_remote)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.infrared_remote_name)) }, singleLine = true)
Text(stringResource(R.string.infrared_layout), fontWeight = FontWeight.Bold)
PanelLayoutStyle.entries.forEach { option ->
Row(Modifier.fillMaxWidth().clickable { layout = option }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = layout == option, onClick = { layout = option })
Text(layoutLabel(option))
var showDeleteConfirmation by remember(remote.id) { mutableStateOf(false) }
val isCustom = remote.type == RemoteDeviceType.CUSTOM
val hasChanges = name.trim() != remote.displayName || layout != remote.layoutStyle
ABoxBackLayer(onBack = onDismiss) {
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(stringResource(R.string.infrared_edit_remote))
Text(
remote.displayName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
navigationIcon = {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = stringResource(R.string.infrared_back),
onClick = onDismiss,
)
},
actions = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) }
},
)
},
bottomBar = {
Surface(shadowElevation = 4.dp) {
Button(
onClick = { onSave(name.trim(), layout, remote.transmissionMode) },
enabled = name.isNotBlank() && hasChanges,
modifier = Modifier.fillMaxWidth().padding(16.dp).height(48.dp),
) {
Text(stringResource(R.string.infrared_device_save_changes))
}
}
Text(stringResource(R.string.infrared_transmission_mode), fontWeight = FontWeight.Bold)
TransmissionMode.entries.forEach { option ->
Row(Modifier.fillMaxWidth().clickable { mode = option }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = mode == option, onClick = { mode = option })
Text(transmissionLabel(option))
},
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
WizardHeading(
stringResource(R.string.infrared_edit_identity_title),
stringResource(R.string.infrared_edit_identity_desc),
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.infrared_remote_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
RemoteConfigurationSummary(remote)
if (isCustom) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
WizardHeading(
stringResource(R.string.infrared_edit_layout_title),
stringResource(R.string.infrared_edit_layout_desc),
)
LearningLayoutOption.entries.forEach { option ->
WizardChoice(
title = stringResource(option.titleRes),
description = stringResource(option.descriptionRes),
icon = option.icon,
selected = layout == option.layout,
onClick = { layout = option.layout },
)
}
}
} else {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Text(
stringResource(R.string.infrared_edit_library_locked),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
stringResource(R.string.infrared_edit_delete_title),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
stringResource(R.string.infrared_edit_delete_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
TextButton(
onClick = { showDeleteConfirmation = true },
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error)
}
}
}
TextButton(onClick = onDelete) { Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = { Button(onClick = { onSave(name.trim(), layout, mode) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.infrared_device_save_changes)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) } },
)
}
}
if (showDeleteConfirmation) {
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
title = { Text(stringResource(R.string.infrared_edit_delete_confirm_title)) },
text = { Text(stringResource(R.string.infrared_edit_delete_confirm_message, remote.displayName)) },
confirmButton = {
TextButton(onClick = onDelete) {
Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
}
private fun remoteIcon(type: RemoteDeviceType): ABoxIconKey = when (type) {
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
RemoteDeviceType.PROJECTOR -> ABoxIconKey.DEVICES
RemoteDeviceType.AUDIO -> ABoxIconKey.ACTIVITY
RemoteDeviceType.SET_TOP_BOX -> ABoxIconKey.DEVICES
RemoteDeviceType.STREAMING_DEVICE -> ABoxIconKey.ACTIVITY
RemoteDeviceType.LIGHT -> ABoxIconKey.EFFECTS
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
}
@@ -541,6 +661,19 @@ private fun transmissionLabel(mode: TransmissionMode): String = when (mode) {
TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_dynamic_codes)
}
@Composable
private fun remoteTypeLabel(type: RemoteDeviceType): String = when (type) {
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
RemoteDeviceType.PROJECTOR -> stringResource(R.string.infrared_hub_device_type_projector)
RemoteDeviceType.AUDIO -> stringResource(R.string.infrared_hub_device_type_audio)
RemoteDeviceType.SET_TOP_BOX -> stringResource(R.string.infrared_hub_device_type_set_top_box)
RemoteDeviceType.STREAMING_DEVICE -> stringResource(R.string.infrared_hub_device_type_streaming)
RemoteDeviceType.LIGHT -> stringResource(R.string.infrared_hub_device_type_light)
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
}
@Composable
private fun VirtualRemoteCard(
remote: VirtualRemote,
@@ -569,6 +702,11 @@ private fun VirtualRemoteCard(
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
RemoteDeviceType.PROJECTOR -> ABoxIconKey.DEVICES
RemoteDeviceType.AUDIO -> ABoxIconKey.ACTIVITY
RemoteDeviceType.SET_TOP_BOX -> ABoxIconKey.DEVICES
RemoteDeviceType.STREAMING_DEVICE -> ABoxIconKey.ACTIVITY
RemoteDeviceType.LIGHT -> ABoxIconKey.EFFECTS
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
}
ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp))
@@ -672,239 +810,473 @@ private fun InfraredHardwareSummary(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateVirtualRemoteDialog(
private fun CreateVirtualRemoteFlow(
slotQuota: SlotQuotaSummary,
onTestPreset: (String, (Boolean) -> Unit) -> Unit,
onDismiss: () -> Unit,
onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit,
) {
var step by remember { mutableStateOf(CreateRemoteStep.SOURCE) }
var source by remember { mutableStateOf<CreateRemoteSource?>(null) }
var name by remember { mutableStateOf("") }
var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) }
var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) }
var selectedCustomLayout by remember { mutableStateOf(PanelLayoutStyle.TV_NAV) }
var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) }
var testState by remember { mutableStateOf(PresetTestState.NOT_TESTED) }
val isLearning = source == CreateRemoteSource.LEARNING
val effectiveType = if (isLearning) RemoteDeviceType.CUSTOM else selectedType
val defaultName = if (isLearning) {
stringResource(R.string.infrared_create_default_custom_name)
} else {
selectedBrand.displayName
}
val finalName = name.trim().ifBlank { defaultName }
val canCreate = !isLearning || !slotQuota.isStaticFull
val requiresPresetTest = !isLearning && selectedType != RemoteDeviceType.AIR_CONDITIONER
val presetConfirmed = !requiresPresetTest || testState == PresetTestState.CONFIRMED
var typeDropdownExpanded by remember { mutableStateOf(false) }
var brandDropdownExpanded by remember { mutableStateOf(false) }
var layoutDropdownExpanded by remember { mutableStateOf(false) }
val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_hub_create_dialog_title)) },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// 1. 设备类型选择
ExposedDropdownMenuBox(
expanded = typeDropdownExpanded,
onExpandedChange = { typeDropdownExpanded = !typeDropdownExpanded },
) {
OutlinedTextField(
value = when (selectedType) {
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
},
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.infrared_hub_device_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeDropdownExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = typeDropdownExpanded,
onDismissRequest = { typeDropdownExpanded = false },
ABoxBackLayer(onBack = {
step = when (step) {
CreateRemoteStep.SOURCE -> return@ABoxBackLayer onDismiss()
CreateRemoteStep.OPTIONS -> CreateRemoteStep.SOURCE
CreateRemoteStep.DETAILS -> CreateRemoteStep.OPTIONS
}
}) {
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(stringResource(R.string.infrared_create_title))
Text(
stringResource(R.string.infrared_create_step, step.ordinal + 1, 3),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
navigationIcon = {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = stringResource(R.string.infrared_back),
onClick = {
step = when (step) {
CreateRemoteStep.SOURCE -> return@ABoxIconButton onDismiss()
CreateRemoteStep.OPTIONS -> CreateRemoteStep.SOURCE
CreateRemoteStep.DETAILS -> CreateRemoteStep.OPTIONS
}
},
)
},
actions = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) }
},
)
},
bottomBar = {
Surface(shadowElevation = 4.dp) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RemoteDeviceType.entries.forEach { type ->
val label = when (type) {
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
}
DropdownMenuItem(
text = { Text(label) },
if (step != CreateRemoteStep.SOURCE) {
TextButton(
onClick = {
step = if (step == CreateRemoteStep.DETAILS) CreateRemoteStep.OPTIONS else CreateRemoteStep.SOURCE
},
modifier = Modifier.height(48.dp),
) { Text(stringResource(R.string.infrared_back)) }
}
Button(
onClick = {
when (step) {
CreateRemoteStep.SOURCE -> step = CreateRemoteStep.OPTIONS
CreateRemoteStep.OPTIONS -> step = CreateRemoteStep.DETAILS
CreateRemoteStep.DETAILS -> {
onConfirm(
finalName,
effectiveType,
if (isLearning) "custom_blank" else selectedBrand.id,
if (isLearning) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH,
if (isLearning) selectedCustomLayout else null,
)
}
}
},
enabled = when (step) {
CreateRemoteStep.SOURCE -> source != null
CreateRemoteStep.OPTIONS -> source != null
CreateRemoteStep.DETAILS -> canCreate && finalName.isNotBlank() && presetConfirmed
},
modifier = Modifier.weight(1f).height(48.dp).testTag("create_remote_confirm_button"),
) {
Text(
if (step == CreateRemoteStep.DETAILS) stringResource(R.string.infrared_create_action)
else stringResource(R.string.infrared_create_continue),
)
}
}
}
},
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (step) {
CreateRemoteStep.SOURCE -> {
WizardHeading(
stringResource(R.string.infrared_create_source_title),
stringResource(R.string.infrared_create_source_desc),
)
WizardChoice(
title = stringResource(R.string.infrared_create_library_title),
description = stringResource(R.string.infrared_create_library_desc),
icon = ABoxIconKey.ADD,
selected = source == CreateRemoteSource.LIBRARY,
onClick = {
source = CreateRemoteSource.LIBRARY
selectedType = RemoteDeviceType.TV
selectedBrand = PresetCodeLibrary.availablePresets.first { it.deviceType == RemoteDeviceType.TV }
testState = PresetTestState.NOT_TESTED
},
)
WizardChoice(
title = stringResource(R.string.infrared_create_learning_title),
description = stringResource(R.string.infrared_create_learning_desc),
icon = ABoxIconKey.INFRARED,
selected = source == CreateRemoteSource.LEARNING,
onClick = { source = CreateRemoteSource.LEARNING },
)
}
CreateRemoteStep.OPTIONS -> if (isLearning) {
WizardHeading(
stringResource(R.string.infrared_create_layout_title),
stringResource(R.string.infrared_create_layout_desc),
)
LearningLayoutOption.entries.forEach { option ->
WizardChoice(
title = stringResource(option.titleRes),
description = stringResource(option.descriptionRes),
icon = option.icon,
selected = selectedCustomLayout == option.layout,
onClick = { selectedCustomLayout = option.layout },
)
}
SlotAvailabilityCard(slotQuota)
} else {
WizardHeading(
stringResource(R.string.infrared_create_device_title),
stringResource(R.string.infrared_create_device_desc),
)
PresetCodeLibrary.availablePresets
.map { it.deviceType }
.filter { it != RemoteDeviceType.CUSTOM }
.distinct()
.forEach { type ->
WizardChoice(
title = remoteTypeLabel(type),
description = remoteTypeDescription(type),
icon = remoteIcon(type),
selected = selectedType == type,
onClick = {
selectedType = type
val matched = PresetCodeLibrary.availablePresets.firstOrNull { it.deviceType == type }
if (matched != null) selectedBrand = matched
if (type == RemoteDeviceType.AIR_CONDITIONER) {
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH
} else if (type == RemoteDeviceType.CUSTOM) {
transmissionMode = TransmissionMode.STATIC_SLOTS
selectedBrand = PresetCodeLibrary.availablePresets.first { it.deviceType == type }
testState = PresetTestState.NOT_TESTED
},
)
}
Text(
stringResource(R.string.infrared_create_preset_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp),
)
PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }.forEach { preset ->
WizardChoice(
title = preset.displayName,
description = stringResource(R.string.infrared_create_preset_desc),
icon = ABoxIconKey.ADD,
selected = selectedBrand.id == preset.id,
onClick = {
selectedBrand = preset
testState = PresetTestState.NOT_TESTED
},
)
}
}
CreateRemoteStep.DETAILS -> {
WizardHeading(
stringResource(R.string.infrared_create_name_title),
stringResource(R.string.infrared_create_name_desc),
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.infrared_remote_name)) },
placeholder = { Text(defaultName) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
)
CreateRemoteSummary(
source = source,
type = effectiveType,
presetName = selectedBrand.displayName,
layout = selectedCustomLayout,
finalName = finalName,
)
if (requiresPresetTest) {
PresetTestCard(
state = testState,
onTest = {
testState = PresetTestState.TESTING
onTestPreset(selectedBrand.id) { successful ->
testState = if (successful) PresetTestState.WAITING_CONFIRMATION else PresetTestState.FAILED
}
typeDropdownExpanded = false
},
onConfirmed = { testState = PresetTestState.CONFIRMED },
onFailed = { testState = PresetTestState.NOT_TESTED },
)
}
}
}
// 2. 遥控器名称输入
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("遥控器名称(如:电视、我的遥控器)") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
)
// 3. 自定义遥控器时:选择初始布局模板
if (selectedType == RemoteDeviceType.CUSTOM) {
ExposedDropdownMenuBox(
expanded = layoutDropdownExpanded,
onExpandedChange = { layoutDropdownExpanded = !layoutDropdownExpanded },
) {
OutlinedTextField(
value = when (selectedCustomLayout) {
PanelLayoutStyle.TV_NAV -> "电视导航布局(内置方向盘、音量、频道)"
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放器布局(播放、快进、切歌)"
PanelLayoutStyle.GRID -> "自由网格布局(可自由增删按键)"
PanelLayoutStyle.AC_COMPACT -> "空调布局"
},
onValueChange = {},
readOnly = true,
label = { Text("选择初始布局模板") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = layoutDropdownExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = layoutDropdownExpanded,
onDismissRequest = { layoutDropdownExpanded = false },
) {
DropdownMenuItem(
text = { Text("电视导航布局(内置方向盘、音量、频道)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.TV_NAV
layoutDropdownExpanded = false
},
)
DropdownMenuItem(
text = { Text("媒体播放器布局(播放、快进、切歌)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
layoutDropdownExpanded = false
},
)
DropdownMenuItem(
text = { Text("自由网格布局(可自由增删按键)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.GRID
layoutDropdownExpanded = false
},
)
}
}
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "💡 自定义学习按键由硬件固化录制,将自动分配独占静态槽位 (1..99),创建后开启学习模式即可对准录码。",
fontSize = 12.sp,
modifier = Modifier.padding(10.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else if (filteredPresets.isNotEmpty()) {
// 码库预设设备:选择品牌型号
ExposedDropdownMenuBox(
expanded = brandDropdownExpanded,
onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded },
) {
OutlinedTextField(
value = selectedBrand.displayName,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.infrared_hub_brand_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = brandDropdownExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = brandDropdownExpanded,
onDismissRequest = { brandDropdownExpanded = false },
) {
filteredPresets.forEach { preset ->
DropdownMenuItem(
text = { Text(preset.displayName) },
onClick = {
selectedBrand = preset
Review

验证绕过

选择任意空调预设时,requiresPresetTest 会被设为 false,详情页不会显示电源测试和设备响应确认,用户可直接创建码库遥控器。这绕过了 PR 声明的码库遥控器创建前验证要求;应为该类预设提供可测试指令,或在无法测试时禁止创建,而不是直接免除验证。

**验证绕过** 选择任意空调预设时,`requiresPresetTest` 会被设为 false,详情页不会显示电源测试和设备响应确认,用户可直接创建码库遥控器。这绕过了 PR 声明的码库遥控器创建前验证要求;应为该类预设提供可测试指令,或在无法测试时禁止创建,而不是直接免除验证。 <!-- pr-agent-dedup: af0443a57c44 --> <!-- pr-agent-key-issue-location: b294b29b478a -->
brandDropdownExpanded = false
},
)
}
}
}
}
// 4. 传输模式选择(仅对非空调、非纯自定义设备可选)
if (selectedType != RemoteDeviceType.AIR_CONDITIONER && selectedType != RemoteDeviceType.CUSTOM) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.STATIC_SLOTS },
) {
RadioButton(
selected = transmissionMode == TransmissionMode.STATIC_SLOTS,
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
)
Column {
Text("静态独占槽位模式", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
) {
RadioButton(
selected = transmissionMode == TransmissionMode.DYNAMIC_SCRATCH,
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
)
Column {
Text("动态写码模式", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else if (selectedType == RemoteDeviceType.AIR_CONDITIONER) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "空调多维状态自动采用状态机和动态缓冲写码,避免状态冲突并节省槽位。",
fontSize = 12.sp,
modifier = Modifier.padding(10.dp),
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
if (isLearning) SlotAvailabilityCard(slotQuota)
}
}
}
},
confirmButton = {
Button(
onClick = {
val finalName = if (name.isNotBlank()) name.trim() else if (selectedType == RemoteDeviceType.CUSTOM) "自定义遥控器" else selectedBrand.displayName
val layout = if (selectedType == RemoteDeviceType.CUSTOM) selectedCustomLayout else null
onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode, layout)
}
}
}
private enum class CreateRemoteStep { SOURCE, OPTIONS, DETAILS }
private enum class CreateRemoteSource { LIBRARY, LEARNING }
private enum class PresetTestState { NOT_TESTED, TESTING, WAITING_CONFIRMATION, CONFIRMED, FAILED }
private enum class LearningLayoutOption(
val layout: PanelLayoutStyle,
val titleRes: Int,
val descriptionRes: Int,
val icon: ABoxIconKey,
) {
TV(PanelLayoutStyle.TV_NAV, R.string.infrared_layout_tv, R.string.infrared_layout_tv_desc, ABoxIconKey.DEVICES),
MEDIA(PanelLayoutStyle.MEDIA_PLAYER, R.string.infrared_layout_media, R.string.infrared_layout_media_desc, ABoxIconKey.ACTIVITY),
GRID(PanelLayoutStyle.GRID, R.string.infrared_layout_grid, R.string.infrared_layout_grid_desc, ABoxIconKey.DEVICES),
}
@Composable
private fun WizardHeading(title: String, description: String) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun WizardChoice(
title: String,
description: String,
icon: ABoxIconKey,
selected: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
),
border = BorderStroke(
width = if (selected) 2.dp else 1.dp,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
),
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
) {
ABoxIcon(
key = icon,
contentDescription = title,
modifier = Modifier.padding(10.dp).size(24.dp),
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
RadioButton(selected = selected, onClick = onClick)
}
}
}
@Composable
private fun SlotAvailabilityCard(quota: SlotQuotaSummary) {
val available = quota.remainingStaticCount
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = if (available > 0) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringResource(R.string.infrared_create_slots_available, available),
fontWeight = FontWeight.Bold,
color = if (available > 0) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
)
Text(
if (available > 0) stringResource(R.string.infrared_create_slots_desc)
else stringResource(R.string.infrared_create_slots_full),
style = MaterialTheme.typography.bodySmall,
color = if (available > 0) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
@Composable
private fun PresetTestCard(
state: PresetTestState,
onTest: () -> Unit,
onConfirmed: () -> Unit,
onFailed: () -> Unit,
) {
val successful = state == PresetTestState.CONFIRMED
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = when (state) {
PresetTestState.CONFIRMED -> MaterialTheme.colorScheme.secondaryContainer
PresetTestState.FAILED -> MaterialTheme.colorScheme.errorContainer
else -> MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
stringResource(R.string.infrared_preset_test_title),
fontWeight = FontWeight.Bold,
)
Text(
when (state) {
PresetTestState.NOT_TESTED -> stringResource(R.string.infrared_preset_test_desc)
PresetTestState.TESTING -> stringResource(R.string.infrared_preset_test_sending)
PresetTestState.WAITING_CONFIRMATION -> stringResource(R.string.infrared_preset_test_confirm_prompt)
PresetTestState.CONFIRMED -> stringResource(R.string.infrared_preset_test_confirmed)
PresetTestState.FAILED -> stringResource(R.string.infrared_preset_test_failed)
},
modifier = Modifier.testTag("create_remote_confirm_button"),
) {
Text(stringResource(R.string.infrared_dialog_confirm))
style = MaterialTheme.typography.bodySmall,
color = when {
successful -> MaterialTheme.colorScheme.onSecondaryContainer
state == PresetTestState.FAILED -> MaterialTheme.colorScheme.onErrorContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
when (state) {
PresetTestState.NOT_TESTED, PresetTestState.FAILED -> Button(
onClick = onTest,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.infrared_preset_test_action)) }
PresetTestState.TESTING -> Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.infrared_preset_test_sending), modifier = Modifier.padding(start = 10.dp))
}
PresetTestState.WAITING_CONFIRMATION -> Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
TextButton(onClick = onFailed, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.infrared_preset_test_no_response))
}
Button(onClick = onConfirmed, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.infrared_preset_test_responded))
}
}
PresetTestState.CONFIRMED -> Unit
}
}
}
}
@Composable
private fun CreateRemoteSummary(
source: CreateRemoteSource?,
type: RemoteDeviceType,
presetName: String,
layout: PanelLayoutStyle,
finalName: String,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.infrared_create_summary_title), fontWeight = FontWeight.Bold)
SummaryRow(stringResource(R.string.infrared_remote_name), finalName)
SummaryRow(
stringResource(R.string.infrared_create_source_label),
if (source == CreateRemoteSource.LEARNING) stringResource(R.string.infrared_create_learning_title)
else stringResource(R.string.infrared_create_library_title),
)
if (source == CreateRemoteSource.LEARNING) {
SummaryRow(stringResource(R.string.infrared_layout), layoutLabel(layout))
} else {
SummaryRow(stringResource(R.string.infrared_hub_device_type_label), remoteTypeLabel(type))
SummaryRow(stringResource(R.string.infrared_hub_brand_label), presetName)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
}
}
@Composable
private fun SummaryRow(label: String, value: String) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontWeight = FontWeight.Medium, modifier = Modifier.padding(start = 16.dp))
}
}
@Composable
private fun remoteTypeDescription(type: RemoteDeviceType): String = when (type) {
RemoteDeviceType.TV -> stringResource(R.string.infrared_create_device_tv_desc)
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_create_device_ac_desc)
RemoteDeviceType.FAN -> stringResource(R.string.infrared_create_device_fan_desc)
RemoteDeviceType.PROJECTOR -> stringResource(R.string.infrared_create_device_projector_desc)
RemoteDeviceType.AUDIO -> stringResource(R.string.infrared_create_device_audio_desc)
RemoteDeviceType.SET_TOP_BOX -> stringResource(R.string.infrared_create_device_set_top_box_desc)
RemoteDeviceType.STREAMING_DEVICE -> stringResource(R.string.infrared_create_device_streaming_desc)
RemoteDeviceType.LIGHT -> stringResource(R.string.infrared_create_device_light_desc)
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_create_learning_desc)
}
@Composable
private fun RemoteConfigurationSummary(remote: VirtualRemote) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.infrared_edit_configuration_title), fontWeight = FontWeight.Bold)
SummaryRow(stringResource(R.string.infrared_hub_device_type_label), remoteTypeLabel(remote.type))
if (remote.type == RemoteDeviceType.CUSTOM) {
SummaryRow(
stringResource(R.string.infrared_create_source_label),
stringResource(R.string.infrared_create_learning_title),
)
} else {
SummaryRow(
stringResource(R.string.infrared_hub_brand_label),
remote.brandModel ?: stringResource(R.string.infrared_custom),
)
}
},
)
SummaryRow(stringResource(R.string.infrared_transmission_mode), transmissionLabel(remote.transmissionMode))
}
}
}
@@ -65,6 +65,7 @@ class InfraredHubViewModel @Inject constructor(
private val addVirtualRemoteKey: AddVirtualRemoteKey,
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
private val activityWriter: ActivityEventWriter,
private val testPresetInfraredCode: com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode? = null,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
@@ -244,6 +245,15 @@ class InfraredHubViewModel @Inject constructor(
}
}
fun testPreset(presetId: String, onResult: (Boolean) -> Unit) {
val currentProfile = profileId ?: return onResult(false)
val tester = testPresetInfraredCode ?: return onResult(false)
val currentDevice = state.value.savedDeviceName
viewModelScope.launch {
onResult(tester(currentProfile, currentDevice, presetId) is DomainResult.Success)
}
}
fun selectRemote(remoteId: VirtualRemoteId) {
selectedRemoteIdFlow.value = remoteId
localAcStateFlow.value = null
@@ -112,7 +112,12 @@
<string name="infrared_hub_raw_keys_button">硬件槽位与底层按键管理</string>
<string name="infrared_hub_device_type_tv">电视机 / 电视盒子</string>
<string name="infrared_hub_device_type_ac">空调设备</string>
<string name="infrared_hub_device_type_fan">🌀 电风扇 / 空气净化器</string>
<string name="infrared_hub_device_type_fan">电风扇 / 空气净化器</string>
<string name="infrared_hub_device_type_projector">投影仪</string>
<string name="infrared_hub_device_type_audio">音响 / 功放</string>
<string name="infrared_hub_device_type_set_top_box">机顶盒</string>
<string name="infrared_hub_device_type_streaming">流媒体设备</string>
<string name="infrared_hub_device_type_light">灯具</string>
<string name="infrared_hub_device_type_custom">自定义面板</string>
<string name="infrared_set_temperature">设定温度</string>
<string name="infrared_running_mode">运行模式</string>
@@ -189,4 +194,55 @@
<string name="infrared_layout">面板布局</string>
<string name="infrared_transmission_mode">传输模式</string>
<string name="infrared_delete_remote">删除遥控器</string>
<string name="infrared_create_title">添加遥控器</string>
<string name="infrared_create_step">第 %1$d 步,共 %2$d 步</string>
<string name="infrared_create_continue">继续</string>
<string name="infrared_create_action">创建遥控器</string>
<string name="infrared_create_source_title">你想如何添加遥控器?</string>
<string name="infrared_create_source_desc">请选择红外指令的来源。两种方式会进入不同的设置流程。</string>
<string name="infrared_create_library_title">从红外码库添加</string>
<string name="infrared_create_library_desc">选择设备类型和预设方案,下载后按键可直接使用,无需学习。</string>
<string name="infrared_create_learning_title">通过学习创建</string>
<string name="infrared_create_learning_desc">创建自定义面板,再使用实体遥控器逐个录制红外指令。</string>
<string name="infrared_create_device_title">选择设备与预设方案</string>
<string name="infrared_create_device_desc">先选择要控制的设备类型,再选择对应的红外码库方案。</string>
<string name="infrared_create_device_tv_desc">包含导航、音量、频道、信号源等常用电视控制</string>
<string name="infrared_create_device_ac_desc">包含温度、模式、风速、扫风和电源等状态控制</string>
<string name="infrared_create_device_fan_desc">包含电源、风速、摇头、定时和运行模式控制</string>
<string name="infrared_create_device_projector_desc">包含电源、信号源、菜单、导航、画面模式和投影控制</string>
<string name="infrared_create_device_audio_desc">包含电源、音量、静音、输入源、播放和音效控制</string>
<string name="infrared_create_device_set_top_box_desc">包含电源、频道、导航、节目单、菜单和数字按键</string>
<string name="infrared_create_device_streaming_desc">包含电源、导航、播放、主页、返回和媒体控制</string>
<string name="infrared_create_device_light_desc">包含电源、亮度、颜色、场景、定时和灯效控制</string>
<string name="infrared_create_preset_title">红外码库方案</string>
<string name="infrared_create_preset_desc">为所选设备下载这套红外指令</string>
<string name="infrared_create_layout_title">选择学习面板</string>
<string name="infrared_create_layout_desc">面板布局决定开始学习前会预置哪些按键。</string>
<string name="infrared_create_name_title">命名并确认</string>
<string name="infrared_create_name_desc">设置一个容易识别的名称,并在创建前检查选择结果。</string>
<string name="infrared_create_default_custom_name">自定义遥控器</string>
<string name="infrared_create_slots_available">剩余 %1$d 个静态槽位</string>
<string name="infrared_create_slots_desc">学习遥控器使用静态硬件槽位,录制指令时会逐个分配。</string>
<string name="infrared_create_slots_full">没有可用静态槽位。请先删除不再使用的已学习指令。</string>
<string name="infrared_create_summary_title">设置摘要</string>
<string name="infrared_create_source_label">添加方式</string>
<string name="infrared_edit_identity_title">遥控器信息</string>
<string name="infrared_edit_identity_desc">修改遥控器列表和自动化指令中显示的名称。</string>
<string name="infrared_edit_configuration_title">当前配置</string>
<string name="infrared_edit_layout_title">面板布局</string>
<string name="infrared_edit_layout_desc">学习创建的遥控器可以切换支持的按键布局,但不会变成码库遥控器。</string>
<string name="infrared_edit_library_locked">码库遥控器会保留创建时的设备类型、预设方案、布局和传输方式。如需其他方案,请重新添加一个遥控器。</string>
<string name="infrared_edit_delete_title">移除遥控器</string>
<string name="infrared_edit_delete_desc">从 App 中删除该遥控器及其保存的按键绑定。</string>
<string name="infrared_edit_delete_confirm_title">删除这个遥控器?</string>
<string name="infrared_edit_delete_confirm_message">将删除“%1$s”及其按键配置。</string>
<string name="infrared_preset_test_title">测试这套码库</string>
<string name="infrared_preset_test_desc">保存前先发送电源指令,确认设备是否响应。测试可能会打开或关闭设备。</string>
<string name="infrared_preset_test_action">测试电源指令</string>
<string name="infrared_preset_test_sending">正在发送测试指令…</string>
<string name="infrared_preset_test_confirm_prompt">指令已发送,设备是否有响应?</string>
<string name="infrared_preset_test_responded">有响应</string>
<string name="infrared_preset_test_no_response">无响应</string>
<string name="infrared_preset_test_confirmed">码库已验证,可以创建遥控器。</string>
<string name="infrared_preset_test_failed">测试指令发送失败,请检查红外控制器后重试。</string>
</resources>
@@ -112,7 +112,12 @@
<string name="infrared_hub_raw_keys_button">Hardware Slot / Raw Key Management</string>
<string name="infrared_hub_device_type_tv">Television (TV)</string>
<string name="infrared_hub_device_type_ac">Air Conditioner (AC)</string>
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
<string name="infrared_hub_device_type_fan">Fan / Air Purifier</string>
<string name="infrared_hub_device_type_projector">Projector</string>
<string name="infrared_hub_device_type_audio">Audio / Receiver</string>
<string name="infrared_hub_device_type_set_top_box">Set-top Box</string>
<string name="infrared_hub_device_type_streaming">Streaming Device</string>
<string name="infrared_hub_device_type_light">Lighting</string>
<string name="infrared_hub_device_type_custom">Custom Panel</string>
<string name="infrared_set_temperature">Set temperature</string>
<string name="infrared_running_mode">Operating mode</string>
@@ -189,4 +194,55 @@
<string name="infrared_layout">Panel layout</string>
<string name="infrared_transmission_mode">Transmission mode</string>
<string name="infrared_delete_remote">Delete remote</string>
<string name="infrared_create_title">Add remote</string>
<string name="infrared_create_step">Step %1$d of %2$d</string>
<string name="infrared_create_continue">Continue</string>
<string name="infrared_create_action">Create remote</string>
<string name="infrared_create_source_title">How do you want to add the remote?</string>
<string name="infrared_create_source_desc">Choose where the infrared commands come from. The two methods use different setup flows.</string>
<string name="infrared_create_library_title">Add from infrared library</string>
<string name="infrared_create_library_desc">Choose a device type and preset. Commands are downloaded and ready to use without learning.</string>
<string name="infrared_create_learning_title">Create through learning</string>
<string name="infrared_create_learning_desc">Build a custom panel, then record each command from a physical remote in Learn mode.</string>
<string name="infrared_create_device_title">Choose the appliance and preset</string>
<string name="infrared_create_device_desc">Select the appliance type first, then choose the matching preset library.</string>
<string name="infrared_create_device_tv_desc">Navigation, volume, channel, source, and common TV controls</string>
<string name="infrared_create_device_ac_desc">State-based temperature, mode, fan, swing, and power controls</string>
<string name="infrared_create_device_fan_desc">Power, speed, swing, timer, and operating mode controls</string>
<string name="infrared_create_device_projector_desc">Power, input, menu, navigation, picture mode, and projection controls</string>
<string name="infrared_create_device_audio_desc">Power, volume, mute, source, playback, and sound controls</string>
<string name="infrared_create_device_set_top_box_desc">Power, channel, navigation, guide, menu, and numeric controls</string>
<string name="infrared_create_device_streaming_desc">Power, navigation, playback, home, back, and media controls</string>
<string name="infrared_create_device_light_desc">Power, brightness, color, scene, timer, and effect controls</string>
<string name="infrared_create_preset_title">Preset library</string>
<string name="infrared_create_preset_desc">Download this command set for the selected appliance</string>
<string name="infrared_create_layout_title">Choose the learning panel</string>
<string name="infrared_create_layout_desc">The layout defines which buttons are created before you start recording commands.</string>
<string name="infrared_create_name_title">Name and confirm</string>
<string name="infrared_create_name_desc">Give the remote a recognizable name and review the setup before creating it.</string>
<string name="infrared_create_default_custom_name">Custom remote</string>
<string name="infrared_create_slots_available">%1$d static slots available</string>
<string name="infrared_create_slots_desc">Learning remotes use static hardware slots. Slots are assigned as commands are recorded.</string>
<string name="infrared_create_slots_full">No static slots are available. Remove unused learned commands before creating another learning remote.</string>
<string name="infrared_create_summary_title">Setup summary</string>
<string name="infrared_create_source_label">Add method</string>
<string name="infrared_edit_identity_title">Remote identity</string>
<string name="infrared_edit_identity_desc">Change the name shown in the remote list and automation actions.</string>
<string name="infrared_edit_configuration_title">Current configuration</string>
<string name="infrared_edit_layout_title">Panel layout</string>
<string name="infrared_edit_layout_desc">Learning remotes can switch between supported button layouts without becoming library remotes.</string>
<string name="infrared_edit_library_locked">Library remotes keep the appliance type, preset, layout, and transmission behavior used when they were created. Create another remote to use a different preset.</string>
<string name="infrared_edit_delete_title">Remove remote</string>
<string name="infrared_edit_delete_desc">Removes the remote and its saved button bindings from this app.</string>
<string name="infrared_edit_delete_confirm_title">Remove this remote?</string>
<string name="infrared_edit_delete_confirm_message">“%1$s” and its button configuration will be removed.</string>
<string name="infrared_preset_test_title">Test this preset</string>
<string name="infrared_preset_test_desc">Before saving, send the preset power command and check whether the appliance responds. The appliance may turn on or off.</string>
<string name="infrared_preset_test_action">Test power command</string>
<string name="infrared_preset_test_sending">Sending the test command…</string>
<string name="infrared_preset_test_confirm_prompt">The command was sent. Did the appliance respond?</string>
<string name="infrared_preset_test_responded">Yes, it responded</string>
<string name="infrared_preset_test_no_response">No response</string>
<string name="infrared_preset_test_confirmed">Preset verified. You can create the remote.</string>
<string name="infrared_preset_test_failed">The test command could not be sent. Check the infrared controller and try again.</string>
</resources>
+19
View File
@@ -0,0 +1,19 @@
# Flipper-IRDB attribution
The built-in open-source infrared presets in this project are derived from selected files in:
- Project: Flipper-IRDB
- Upstream: https://github.com/Lucaslhm/Flipper-IRDB
- License: CC0 1.0 Universal
- Imported signal formats: parsed NEC and NECext only
Raw pulse data and protocols other than NEC/NECext are intentionally excluded because the ABox gateway's `irCode` encoding contract is not publicly specified for those formats.
Each imported preset retains its upstream relative `sourcePath` in `OpenSourceIrCatalog.kt` for traceability. The source data was converted to the four-byte hexadecimal representation already used by the ABox application:
- NEC: address, inverted address, command, inverted command
- NECext: extended address byte 1, extended address byte 2, command byte 1, command byte 2
Flipper-IRDB is dedicated to the public domain under CC0 1.0. The full license text is available at:
https://creativecommons.org/publicdomain/zero/1.0/legalcode
@@ -0,0 +1,77 @@
package com.flagship.abox.manager.domain.infrared
internal data class OpenSourceIrPreset(
val option: PresetBrandOption,
val buttons: List<PresetCodeLibrary.PresetButtonTemplate>,
)
internal object OpenSourceIrCatalog {
val presets = listOf(
preset("firetv_omni_4k", "Amazon Fire TV Omni 4K", RemoteDeviceType.TV, "TVs/Amazon/FireTV_Omni_Series_4K.ir",
"POWER" to "电源" to "0x027D46B9", "HOME" to "主页" to "0x027D9F60", "MENU" to "菜单" to "0x027D45BA",
"BACK" to "返回" to "0x027D0DF2", "VOL_UP" to "音量 +" to "0x027D0CF3", "VOL_DOWN" to "音量 -" to "0x027D19E6",
"MUTE" to "静音" to "0x027D4CB3", "UP" to "上" to "0x027D48B7", "DOWN" to "下" to "0x027D4DB2",
"LEFT" to "左" to "0x027D4EB1", "RIGHT" to "右" to "0x027D49B6", "OK" to "确定" to "0x027D4AB5"),
preset("akai_ate_22y604w", "Akai ATE-22Y604W", RemoteDeviceType.TV, "TVs/Akai/AKAI_ATE_22Y604W.ir",
"POWER" to "电源" to "0x01721EE1", "MUTE" to "静音" to "0x01725CA3", "VOL_UP" to "音量 +" to "0x01720AF5",
"VOL_DOWN" to "音量 -" to "0x017206F9", "CH_UP" to "频道 +" to "0x017248B7", "CH_DOWN" to "频道 -" to "0x017244BB",
"SOURCE" to "信号源" to "0x017207F8", "OK" to "确定" to "0x01725FA0", "UP" to "上" to "0x017203FC", "DOWN" to "下" to "0x01725EA1"),
preset("acer_h6517st", "Acer H6517ST", RemoteDeviceType.PROJECTOR, "Projectors/Acer/Acer_H6517ST.ir",
"POWER" to "电源" to "0x08138778", "SOURCE" to "信号源" to "0x08138C73", "SETUP" to "设置" to "0x08133BC4",
"MODE" to "模式" to "0x0813807F", "ZOOM" to "缩放" to "0x08138B74", "MENU" to "菜单" to "0x0813847B",
"UP" to "上" to "0x0813827D", "DOWN" to "下" to "0x0813857A", "LEFT" to "左" to "0x0813837C", "RIGHT" to "右" to "0x0813817E"),
preset("nebula_capsule", "Anker Nebula Capsule", RemoteDeviceType.PROJECTOR, "Projectors/Anker/Nebula_Capsule_Mini_Projector.ir",
"POWER" to "电源" to "0x807F51AE", "OK" to "确定" to "0x807F0DF2", "UP" to "上" to "0x807F26D9",
"DOWN" to "下" to "0x807F28D7", "LEFT" to "左" to "0x807F25DA", "RIGHT" to "右" to "0x807F27D8",
"MENU" to "菜单" to "0x807F49B6", "BACK" to "返回" to "0x807F1BE4", "HOME" to "主页" to "0x807F53AC"),
preset("bose_solo_5", "Bose Solo 5", RemoteDeviceType.AUDIO, "SoundBars/Bose/Bose_Solo_5.ir",
"POWER" to "电源" to "0xBAA04CB3", "MUTE" to "静音" to "0xBAA001FE", "VOL_UP" to "音量 +" to "0xBAA003FC",
"VOL_DOWN" to "音量 -" to "0xBAA002FD", "PLAY_PAUSE" to "播放/暂停" to "0xBAA055AA", "NEXT" to "下一曲" to "0xBAA05AA5",
"PREVIOUS" to "上一曲" to "0xBAA059A6", "BLUETOOTH" to "蓝牙" to "0xBAA0817E", "BASS" to "低音" to "0xBAA0827D"),
preset("fosi_zd3", "Fosi Audio ZD3", RemoteDeviceType.AUDIO, "Audio_and_Video_Receivers/Fosi_Audio/Fosi_Audio_ZD3.ir",
"POWER" to "电源" to "0xFA0105FA", "MUTE" to "静音" to "0xFA0102FD", "VOL_UP" to "音量 +" to "0xFA0103FC",
"VOL_DOWN" to "音量 -" to "0xFA0107F8", "PLAY_PAUSE" to "播放/暂停" to "0xFA010AF5", "NEXT" to "下一曲" to "0xFA0106F9",
"PREVIOUS" to "上一曲" to "0xFA0104FB", "OPTICAL" to "光纤" to "0xFA0108F7", "HDMI" to "HDMI" to "0xFA0113EC", "BLUETOOTH" to "蓝牙" to "0xFA0115EA"),
preset("humax_generic", "Humax Cable Box", RemoteDeviceType.SET_TOP_BOX, "Cable_Boxes/Humax/Humax_generic_cable_box.ir",
"POWER" to "电源" to "0x001000FF", "TV_RADIO" to "电视/广播" to "0x00100DF2", "SOURCE" to "信号源" to "0x001002FD",
"VOL_UP" to "音量 +" to "0x00101FE0", "VOL_DOWN" to "音量 -" to "0x001040BF", "CH_UP" to "频道 +" to "0x001010EF", "CH_DOWN" to "频道 -" to "0x00100FF0"),
preset("huawei_dn300", "Huawei DN300", RemoteDeviceType.SET_TOP_BOX, "Cable_Boxes/Huawei/Huawei_dn300_mgts(mts).ir",
"POWER" to "电源" to "0x00BF43BC", "VOL_UP" to "音量 +" to "0x00BF52AD", "VOL_DOWN" to "音量 -" to "0x00BF53AC",
"CH_UP" to "频道 +" to "0x00BF4BB4", "CH_DOWN" to "频道 -" to "0x00BF4CB3", "UP" to "上" to "0x00BF15EA",
"DOWN" to "下" to "0x00BF16E9", "LEFT" to "左" to "0x00BF17E8", "RIGHT" to "右" to "0x00BF18E7", "BACK" to "返回" to "0x00BF51AE", "OK" to "确定" to "0x00BF19E6"),
preset("firetv_stick_4k_max", "Amazon Fire TV Stick 4K Max", RemoteDeviceType.STREAMING_DEVICE, "Streaming_Devices/Amazon/FireTV_Stick_4K_Max.ir",
"POWER" to "电源" to "0x03FC1DE2", "MUTE" to "静音" to "0x007F4EB1", "VOL_UP" to "音量 +" to "0x03FC11EE", "VOL_DOWN" to "音量 -" to "0x03FC15EA"),
preset("apple_tv_4k_gen2", "Apple TV 4K Gen 2", RemoteDeviceType.STREAMING_DEVICE, "Streaming_Devices/Apple/Apple_TV_4K_Gen2.ir",
"POWER_OFF" to "关机" to "0xE5872AFE", "POWER_ON" to "开机" to "0xE5872CFE", "TV" to "电视" to "0xE58740FE",
"OK" to "确定" to "0xEE875C8C", "UP" to "上" to "0xEE870A8C", "DOWN" to "下" to "0xEE870C8C",
"LEFT" to "左" to "0xEE87098C", "RIGHT" to "右" to "0xEE87068C", "BACK" to "返回" to "0xEE87038C", "PLAY_PAUSE" to "播放/暂停" to "0xEE875F8C"),
preset("atomberg_fan", "Atomberg Fan", RemoteDeviceType.FAN, "Fans/Atomberg/Atomberg_fan.ir",
"POWER" to "电源" to "0x00F3916E", "SPEED_1" to "风速 1" to "0x00F38B74", "SPEED_2" to "风速 2" to "0x00F3906F",
"SPEED_3" to "风速 3" to "0x00F38A75", "SPEED_4" to "风速 4" to "0x00F3936C", "SPEED_5" to "风速 5" to "0x00F38877",
"BOOST" to "强劲" to "0x00F38F70", "TIMER" to "定时" to "0x00F39669", "SLEEP" to "睡眠" to "0x00F38E71"),
preset("daewoo_ddv200", "Daewoo DDV200", RemoteDeviceType.FAN, "Fans/Daewoo/Daewoo_DDV200.ir",
"POWER" to "电源" to "0x00FF45BA", "SPEED" to "风速" to "0x00FF47B8", "WIND" to "风类" to "0x00FF40BF",
"TIMER" to "定时" to "0x00FF19E6", "OSCILLATE" to "摇头" to "0x00FF1CE3"),
preset("adj_uc_ir", "ADJ UC-IR Lighting", RemoteDeviceType.LIGHT, "LED_Lighting/ADJ/ADJ_UC-IR.ir",
"POWER" to "电源" to "0x00FF47B8", "BRIGHTER" to "调亮" to "0x00FF43BC", "DIMMER" to "调暗" to "0x00FF09F6",
"FULL_ON" to "全亮" to "0x00FF44BB", "FADE" to "渐变" to "0x00FF40BF", "STROBE" to "频闪" to "0x00FF07F8", "COLOR" to "颜色" to "0x00FF15EA"),
preset("antizer_candles", "Antizer Flameless Candles", RemoteDeviceType.LIGHT, "LED_Lighting/Antizer_Flameless_Candles/Antizer_Flameless_Candles.ir",
"ON" to "开启" to "0x08B700FF", "OFF" to "关闭" to "0x08B702FD", "DIMMER" to "调暗" to "0x08B710EF",
"BRIGHTER" to "调亮" to "0x08B712ED", "CANDLE" to "烛光模式" to "0x08B70CF3", "LIGHT" to "常亮模式" to "0x08B70EF1"),
)
fun preset(id: String?): OpenSourceIrPreset? = presets.firstOrNull { it.option.id == id }
private fun preset(
id: String,
name: String,
type: RemoteDeviceType,
sourcePath: String,
vararg buttons: Pair<Pair<String, String>, String>,
) = OpenSourceIrPreset(
PresetBrandOption(id, name, type, sourcePath),
buttons.map { (identity, code) -> PresetCodeLibrary.PresetButtonTemplate(identity.first, identity.second, code) },
)
private infix fun Pair<String, String>.to(code: String): Pair<Pair<String, String>, String> = Pair(this, code)
}
@@ -4,30 +4,21 @@ data class PresetBrandOption(
val id: String,
val displayName: String,
val deviceType: RemoteDeviceType,
val sourcePath: String? = null,
)
object PresetCodeLibrary {
val availablePresets: List<PresetBrandOption> = listOf(
// 电视预设
PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV),
PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV),
PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV),
PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV),
// 空调预设
PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER),
// 风扇预设
PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN),
PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN),
// 自定义
PresetBrandOption("custom_blank", "自定义遥控器 (Custom)", RemoteDeviceType.CUSTOM),
)
) + OpenSourceIrCatalog.presets.map { it.option }
data class PresetButtonTemplate(
val buttonId: String,
@@ -39,8 +30,8 @@ object PresetCodeLibrary {
type: RemoteDeviceType,
presetId: String? = null,
layoutStyle: PanelLayoutStyle? = null,
): List<PresetButtonTemplate> = when (type) {
RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true)
): List<PresetButtonTemplate> = OpenSourceIrCatalog.preset(presetId)?.buttons ?: when (type) {
RemoteDeviceType.TV -> emptyList()
RemoteDeviceType.AIR_CONDITIONER -> listOf(
PresetButtonTemplate("AC_POWER", "电源"),
PresetButtonTemplate("AC_TEMP_UP", "升温"),
@@ -51,13 +42,12 @@ object PresetCodeLibrary {
PresetButtonTemplate("AC_SWING_H", "左右扫风"),
PresetButtonTemplate("AC_ECO", "节能"),
)
RemoteDeviceType.FAN -> listOf(
PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"),
PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"),
PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"),
PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"),
PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"),
)
RemoteDeviceType.FAN,
RemoteDeviceType.PROJECTOR,
RemoteDeviceType.AUDIO,
RemoteDeviceType.SET_TOP_BOX,
RemoteDeviceType.STREAMING_DEVICE,
RemoteDeviceType.LIGHT -> emptyList()
RemoteDeviceType.CUSTOM -> when (layoutStyle) {
PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false)
PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false)
@@ -0,0 +1,31 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class TestPresetInfraredCode(
private val gateway: InfraredGateway,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
deviceName: DeviceName,
presetId: String,
): DomainResult<Unit> {
val preset = OpenSourceIrCatalog.preset(presetId)
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Preset is not testable"))
val testButton = preset.buttons.firstOrNull { button ->
button.buttonId.contains("POWER") || button.buttonId == "ON" || button.buttonId == "OFF"
} ?: return DomainResult.Failure(DomainError.NoData(detail = "Preset has no test button"))
val code = testButton.defaultHexCode
?: return DomainResult.Failure(DomainError.NoData(detail = "Preset test code is unavailable"))
val scratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT
return when (val download = gateway.downloadCodes(profileId, listOf(InfraredCode(deviceName, scratchSlot, code)))) {
is DomainResult.Failure -> download
is DomainResult.Success -> gateway.sendKey(profileId, deviceName, scratchSlot)
}
}
}
@@ -10,6 +10,11 @@ enum class RemoteDeviceType {
TV,
AIR_CONDITIONER,
FAN,
PROJECTOR,
AUDIO,
SET_TOP_BOX,
STREAMING_DEVICE,
LIGHT,
CUSTOM,
}
@@ -70,7 +75,12 @@ data class VirtualRemote(
fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) {
RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV
RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT
RemoteDeviceType.FAN -> PanelLayoutStyle.GRID
RemoteDeviceType.FAN,
RemoteDeviceType.PROJECTOR,
RemoteDeviceType.AUDIO,
RemoteDeviceType.SET_TOP_BOX,
RemoteDeviceType.STREAMING_DEVICE,
RemoteDeviceType.LIGHT,
RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID
}
}
+58
View File
@@ -0,0 +1,58 @@
param(
[Parameter(Mandatory = $true)]
[string]$InputFile
)
$lines = Get-Content -LiteralPath $InputFile
$signals = @()
function Convert-Signal([string]$Protocol, [string]$Address, [string]$Command) {
$addressBytes = $Address -split '\s+' | Where-Object { $_ }
$commandBytes = $Command -split '\s+' | Where-Object { $_ }
if ($Protocol -eq 'NEC') {
if ($addressBytes.Count -lt 1 -or $commandBytes.Count -lt 1) { return $null }
$addressValue = [Convert]::ToInt32($addressBytes[0], 16)
$commandValue = [Convert]::ToInt32($commandBytes[0], 16)
return '0x{0:X2}{1:X2}{2:X2}{3:X2}' -f $addressValue, (255 - $addressValue), $commandValue, (255 - $commandValue)
}
if ($Protocol -eq 'NECext') {
if ($addressBytes.Count -lt 2 -or $commandBytes.Count -lt 2) { return $null }
return '0x{0}{1}{2}{3}' -f $addressBytes[0].ToUpper(), $addressBytes[1].ToUpper(), $commandBytes[0].ToUpper(), $commandBytes[1].ToUpper()
}
return $null
}
for ($index = 0; $index -lt $lines.Count; $index++) {
if ($lines[$index] -notmatch '^name:\s*(.+)$') { continue }
$name = $matches[1].Trim()
$type = ''
$protocol = ''
$address = ''
$command = ''
for ($lookahead = $index + 1; $lookahead -lt [Math]::Min($index + 8, $lines.Count); $lookahead++) {
if ($lines[$lookahead] -match '^name:' -or $lines[$lookahead] -eq '#') {
if ($lookahead -gt $index + 1) { break }
}
if ($lines[$lookahead] -match '^type:\s*(.+)$') { $type = $matches[1].Trim() }
if ($lines[$lookahead] -match '^protocol:\s*(.+)$') { $protocol = $matches[1].Trim() }
if ($lines[$lookahead] -match '^address:\s*(.+)$') { $address = $matches[1].Trim() }
if ($lines[$lookahead] -match '^command:\s*(.+)$') { $command = $matches[1].Trim() }
}
if ($type -ne 'parsed' -or $protocol -notin @('NEC', 'NECext')) { continue }
$hexCode = Convert-Signal $protocol $address $command
if ($null -ne $hexCode) {
$signals += [pscustomobject]@{
name = $name
protocol = $protocol
code = $hexCode
}
}
}
$signals | ConvertTo-Json -Depth 3