From 75cd022308fe6fb8911f9df5aba1d34eb2520cec Mon Sep 17 00:00:00 2001 From: Atomic Deploy Date: Fri, 2 Oct 2026 01:13:08 +0330 Subject: [PATCH 1/2] fix(tui): restore contextual reboot and strip controls --- Tools/Controller/internal/tui/interactions.go | 32 ++++++++- Tools/Controller/internal/tui/model.go | 11 ++- Tools/Controller/internal/tui/model_test.go | 67 ++++++++++++++++--- Tools/Controller/internal/tui/pages.go | 41 +++++++++++- Tools/Controller/internal/tui/preview.go | 2 +- Tools/Controller/internal/tui/table.go | 10 +-- 6 files changed, 142 insertions(+), 21 deletions(-) diff --git a/Tools/Controller/internal/tui/interactions.go b/Tools/Controller/internal/tui/interactions.go index f87493b5b..36f02b50c 100644 --- a/Tools/Controller/internal/tui/interactions.go +++ b/Tools/Controller/internal/tui/interactions.go @@ -281,6 +281,9 @@ func (model Model) handleKey(message tea.KeyMsg) (Model, tea.Cmd, bool) { case "ctrl+x": return model.closePort() case "ctrl+r": + if model.snapshot().Connected { + return model.dispatchLine("reset app") + } return model.dispatchLine("reset lines") case "~", "`": model.toggleTerminal() @@ -783,6 +786,19 @@ func (model Model) activateSelection() (Model, tea.Cmd, bool) { func (model Model) adjustSelection(delta int) (Model, tea.Cmd, bool) { switch model.page { case PageOutputs: + if row, ok := model.selectedOutputRow(); ok && row.Adjust != "" { + switch row.Adjust { + case "strip-count": + pixels, _ := model.stripConfiguration() + model.stripPixels = wrapInt(pixels-1, delta, native.StripMaximumPixels) + 1 + case "strip-fps": + _, fps := model.stripConfiguration() + model.stripFPS = wrapInt(fps-1, delta, 30) + 1 + case "strip-color": + model.stripColor = wrapInt(model.stripColorIndex(), delta, len(stripColorPresets)) + } + return model, nil, true + } if model.cursor >= 15 && model.cursor <= 26 { channel := model.cursor - 15 value := int(model.pwmValues[channel]) + delta*64 @@ -812,6 +828,9 @@ func (model Model) adjustSelection(delta int) (Model, tea.Cmd, bool) { } func (model Model) activateOutput() (Model, tea.Cmd, bool) { + if row, ok := model.selectedOutputRow(); ok && row.Action != "" { + return model.dispatchLine(row.Action) + } switch { case model.cursor >= 0 && model.cursor <= 7: return model.dispatchLine(fmt.Sprintf("relay %d toggle", model.cursor+1)) @@ -837,6 +856,14 @@ func (model Model) activateOutput() (Model, tea.Cmd, bool) { return model, nil, true } +func (model Model) selectedOutputRow() (controlTableRow, bool) { + rows := model.controlTableRows(model.snapshot(), 8) + if model.cursor < 0 || model.cursor >= len(rows) { + return controlTableRow{}, false + } + return rows[model.cursor], true +} + func outputPeripheralDescriptor(cursor int) (appconfig.PeripheralDescriptor, bool) { rows := make([]appconfig.PeripheralDescriptor, 0, 27) descriptors := appconfig.PeripheralDescriptors() @@ -1910,7 +1937,10 @@ func (model Model) handleActionBarClick(x int) (tea.Model, tea.Cmd) { case "close": updated, command, _ := model.closePort() return updated, command - case "reboot": + case "firmware-reboot": + updated, command, _ := model.dispatchLine("reset app") + return updated, command + case "hardware-reset": updated, command, _ := model.dispatchLine("reset lines") return updated, command case "refresh": diff --git a/Tools/Controller/internal/tui/model.go b/Tools/Controller/internal/tui/model.go index 9129c25f7..eaaaba8df 100644 --- a/Tools/Controller/internal/tui/model.go +++ b/Tools/Controller/internal/tui/model.go @@ -126,6 +126,9 @@ type Model struct { pwmDragValue uint16 pwmDragSet bool pwmPending bool + stripPixels int + stripFPS int + stripColor int lastPWMRefresh time.Time overrideIllumination func(context.Context, uint16) (uint16, error) portPicker bool @@ -2140,9 +2143,13 @@ func (model Model) actionBarItems(snapshot control.Snapshot) []actionBarItem { items = append(items, actionBarItem{label: "O Open", action: "open", style: buttonGoodStyle}) } rebootLabel := "^R HW Reset" - rebootAction := "reboot" + rebootAction := "hardware-reset" + if snapshot.Connected { + rebootLabel = "^R Reboot" + rebootAction = "firmware-reboot" + } if model.rebootPending { - rebootLabel = model.spinnerView() + " Resetting" + rebootLabel = model.spinnerView() + " Rebooting" rebootAction = "" } items = append(items, diff --git a/Tools/Controller/internal/tui/model_test.go b/Tools/Controller/internal/tui/model_test.go index 668e7184f..5adbf25f8 100644 --- a/Tools/Controller/internal/tui/model_test.go +++ b/Tools/Controller/internal/tui/model_test.go @@ -619,8 +619,10 @@ func TestUnavailablePeripheralsDoNotRenderInvalidDashboardValuesOrControls(t *te t.Fatalf("dashboard rendered unavailable peripheral %q:\n%s", unavailable, dashboard) } } - if rows := model.controlTableRows(snapshot, 16); len(rows) != 15 { - t.Fatalf("control table retained PCA/PWM rows: got %d rows, want 15", len(rows)) + for _, row := range model.controlTableRows(snapshot, 16) { + if strings.HasPrefix(row.Name, "CH ") || row.Name == "All user PWM" { + t.Fatalf("control table retained unavailable PCA/PWM row: %#v", row) + } } } @@ -1015,14 +1017,14 @@ func TestControlPageAndTerminalVisibilityFollowNavigationContract(t *testing.T) } } -func TestActionBarDefaultsToOnePortToggleAndShowsHardwareResetProgress(t *testing.T) { +func TestActionBarUsesFirmwareRebootWhenAuthenticatedAndHardwareResetOffline(t *testing.T) { model := readyModel(t, PageDashboard) plain := ansi.Strip(model.actionBar(model.snapshot())) if strings.Contains(plain, "O Open") || !strings.Contains(plain, "X Close") { t.Fatalf("connected default action bar did not use one Close toggle: %q", plain) } - if !strings.Contains(plain, "^R HW Reset") || strings.Contains(model.actionBar(model.snapshot()), buttonBadStyle.Render("^R HW Reset")) { - t.Fatal("hardware reset action is missing or permanently danger-colored") + if !strings.Contains(plain, "^R Reboot") || strings.Contains(plain, "HW Reset") { + t.Fatalf("authenticated action bar did not select firmware reboot: %q", plain) } model.uiValue.SeparatePortButtons = true @@ -1032,25 +1034,37 @@ func TestActionBarDefaultsToOnePortToggleAndShowsHardwareResetProgress(t *testin } model.uiValue.SeparatePortButtons = false - updated, command, _ := model.dispatchLine("reset lines") + updated, command, _ := model.dispatchLine("reset app") model = updated - if command == nil || !model.rebootPending || !strings.Contains(ansi.Strip(model.actionBar(model.snapshot())), "Resetting") { - t.Fatal("hardware reset did not enter visible in-transit state") + if command == nil || !model.rebootPending || !strings.Contains(ansi.Strip(model.actionBar(model.snapshot())), "Rebooting") { + t.Fatal("firmware reboot did not enter visible in-transit state") } updatedModel, _ := model.Update(command()) if updatedModel.(Model).rebootPending { t.Fatal("reboot progress did not clear after command completion") } + + model = updatedModel.(Model) + model.preview.Connected = false + plain = ansi.Strip(model.actionBar(model.snapshot())) + if !strings.Contains(plain, "^R HW Reset") || strings.Contains(plain, "^R Reboot") { + t.Fatalf("offline action bar did not retain recovery reset: %q", plain) + } } -func TestControlRUsesHardwareResetPath(t *testing.T) { +func TestControlRUsesContextualResetPath(t *testing.T) { model := readyModel(t, PageDashboard) updated, command, handled := model.handleKey(tea.KeyMsg{Type: tea.KeyCtrlR}) if !handled || command == nil { t.Fatal("Ctrl+R did not dispatch a reset command") } - if !updated.rebootPending || !strings.Contains(updated.notice, "DTR hardware reset") { - t.Fatalf("Ctrl+R did not select the DTR reset path: pending=%v notice=%q", updated.rebootPending, updated.notice) + if !updated.rebootPending || !strings.Contains(updated.notice, "Rebooting controller application") { + t.Fatalf("connected Ctrl+R did not select firmware reboot: pending=%v notice=%q", updated.rebootPending, updated.notice) + } + model.preview.Connected = false + updated, command, handled = model.handleKey(tea.KeyMsg{Type: tea.KeyCtrlR}) + if !handled || command == nil || !strings.Contains(updated.notice, "DTR hardware reset") { + t.Fatalf("offline Ctrl+R did not select DTR recovery: handled=%v command=%v notice=%q", handled, command, updated.notice) } } @@ -1735,6 +1749,37 @@ func TestControlTableUsesMappedGroupSeparatorsAndStableHeaders(t *testing.T) { } } +func TestControlTableExposesCapabilityGatedWS2811Surface(t *testing.T) { + model := readyModel(t, PageOutputs) + rows := model.controlTableRows(model.snapshot(), 8) + joined := "" + for _, row := range rows { + joined += row.Group + " " + row.Name + " " + row.Value + "\n" + } + for _, expected := range []string{"WS2811 STRIP", "Pixel count", "Rainbow", "Fill color", "Police", "White thunder", "Converging red", "Stop stream"} { + if !strings.Contains(joined, expected) { + t.Fatalf("WS2811 control surface missing %q:\n%s", expected, joined) + } + } + if plain := ansi.Strip(model.outputsPage(model.snapshot())); !strings.Contains(plain, "WS2811 available below") { + t.Fatalf("WS2811 availability is not discoverable in the initial viewport:\n%s", plain) + } + for index, row := range rows { + if row.Adjust == "strip-count" { + model.cursor = index + updated, _, _ := model.adjustSelection(-1) + if pixels, _ := updated.stripConfiguration(); pixels != native.StripMaximumPixels-1 { + t.Fatalf("strip count adjustment=%d", pixels) + } + break + } + } + model.preview.Hello.Capabilities &^= native.CapabilityAddressableLED + if rendered := ansi.Strip(model.outputsPage(model.snapshot())); strings.Contains(rendered, "WS2811 STRIP") { + t.Fatalf("strip controls ignored live capability gate:\n%s", rendered) + } +} + func TestControlTableSemanticColorsCanBeDisabled(t *testing.T) { priorProfile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.TrueColor) diff --git a/Tools/Controller/internal/tui/pages.go b/Tools/Controller/internal/tui/pages.go index 44e64ddd5..b1c8a56ca 100644 --- a/Tools/Controller/internal/tui/pages.go +++ b/Tools/Controller/internal/tui/pages.go @@ -320,9 +320,11 @@ func (model Model) outputsPage(snapshot control.Snapshot) string { columns := outputTableColumns(tableWidth) rows := model.controlTableRows(snapshot, max(8, columns[1].Width-7)) tableView := renderControlTable(tableWidth, tableBodyRows(model.contentHeight()), model.cursor, columns, rows, model.uiValue.ControlValueColors) - parts := []string{ - sectionHeader(model.width, "CONTROL", "↑/↓ select · ←/→ adjust · Home/End limits · Enter activate · F2 rename"), + detail := "↑/↓ select · ←/→ adjust · Home/End limits · Enter activate · F2 rename" + if snapshot.Connected && snapshot.Hello.Capabilities&native.CapabilityAddressableLED != 0 { + detail += " · WS2811 available below" } + parts := []string{sectionHeader(model.width, "CONTROL", detail)} if snapshot.Connected && snapshot.Hello.Capabilities&native.CapabilityRelayMotion != 0 && !snapshot.HaveStatus { parts = append(parts, warnStyle.Render(model.spinner.View()+" loading advertised relay and motion state…")) } @@ -407,9 +409,44 @@ func (model Model) controlTableRows(snapshot control.Snapshot, levelWidth int) [ }) rows = append(rows, controlTableRow{Name: "All user PWM", Value: "Set 0%", Tone: controlToneAction}) } + if snapshot.Connected && snapshot.Hello.Capabilities&native.CapabilityAddressableLED != 0 { + pixels, fps := model.stripConfiguration() + color := stripColorPresets[model.stripColorIndex()] + rows = append(rows, + controlTableRow{Group: "WS2811 STRIP", Name: "Pixel count", Value: fmt.Sprintf("%d · ←/→ adjust · Enter configure", pixels), Tone: controlToneLevel, Action: fmt.Sprintf("strip config %d", pixels), Adjust: "strip-count"}, + controlTableRow{Name: "Rainbow", Value: fmt.Sprintf("%d px @ %d FPS · Enter start", pixels, fps), Tone: controlToneAction, Action: fmt.Sprintf("strip rainbow %d %d", pixels, fps), Adjust: "strip-fps"}, + controlTableRow{Name: "Fill color", Value: fmt.Sprintf("#%02X%02X%02X · ←/→ color · Enter apply", color[0], color[1], color[2]), Tone: controlToneLevel, Action: fmt.Sprintf("strip fill %d %d %d", color[0], color[1], color[2]), Adjust: "strip-color"}, + controlTableRow{Name: "Police", Value: "Enter start", Tone: controlToneAction, Action: fmt.Sprintf("strip effect play police %d %d", pixels, fps)}, + controlTableRow{Name: "White thunder", Value: "Enter start", Tone: controlToneAction, Action: fmt.Sprintf("strip effect play white-thunder %d %d", pixels, fps)}, + controlTableRow{Name: "Converging red", Value: "Enter start", Tone: controlToneAction, Action: fmt.Sprintf("strip effect play converging-red %d %d", pixels, fps)}, + controlTableRow{Name: "Clear strip", Value: "Enter clear", Tone: controlToneAction, Action: "strip clear"}, + controlTableRow{Name: "Stop stream", Value: "Enter stop", Tone: controlToneAction, Action: "strip stop"}, + controlTableRow{Name: "Stream status", Value: "Enter inspect", Tone: controlToneAction, Action: "strip status"}, + ) + } return rows } +var stripColorPresets = [][3]byte{{255, 255, 255}, {255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {255, 128, 0}, {128, 0, 255}, {0, 255, 255}} + +func (model Model) stripConfiguration() (int, int) { + pixels, fps := model.stripPixels, model.stripFPS + if pixels < 1 || pixels > native.StripMaximumPixels { + pixels = native.StripMaximumPixels + } + if fps < 1 || fps > 30 { + fps = 30 + } + return pixels, fps +} + +func (model Model) stripColorIndex() int { + if model.stripColor < 0 || model.stripColor >= len(stripColorPresets) { + return 0 + } + return model.stripColor +} + type menuPageGeometry struct { frontPanelStart int frontPanelEnd int diff --git a/Tools/Controller/internal/tui/preview.go b/Tools/Controller/internal/tui/preview.go index ed3c41cbc..ae6f74db4 100644 --- a/Tools/Controller/internal/tui/preview.go +++ b/Tools/Controller/internal/tui/preview.go @@ -33,7 +33,7 @@ func RichPreviewSnapshot() control.Snapshot { native.CapabilityPersistentSettings | native.CapabilityMenuRemote | native.CapabilityBluetoothAudio | native.CapabilityRemoteKeys | native.CapabilityFrontPanelSnapshot | native.CapabilityMenuDirectory | native.CapabilityRFLearnReplace | native.CapabilityHostFrontPanel | native.CapabilityBuzzerBusy | native.CapabilityMenuLayout | native.CapabilityTimedMacroQueue | - native.CapabilityStatusEffects | native.CapabilityProgramState | native.CapabilityScheduledSegments, + native.CapabilityStatusEffects | native.CapabilityProgramState | native.CapabilityScheduledSegments | native.CapabilityAddressableLED, IdentitySchema: native.IdentitySchemaCompact, BuildHash: 0x5DF10D05, BuildTimestamp: 0x3501645C, BuildStamp: "260801123456", }, diff --git a/Tools/Controller/internal/tui/table.go b/Tools/Controller/internal/tui/table.go index 3d864fb2d..dcb83f5d1 100644 --- a/Tools/Controller/internal/tui/table.go +++ b/Tools/Controller/internal/tui/table.go @@ -25,10 +25,12 @@ const ( ) type controlTableRow struct { - Group string - Name string - Value string - Tone controlValueTone + Group string + Name string + Value string + Tone controlValueTone + Action string + Adjust string } type controlTableLine struct { From ab912f0fcdcb9e4f5aceed1c2ea5371835f75cd2 Mon Sep 17 00:00:00 2001 From: Atomic Deploy Date: Fri, 2 Oct 2026 01:25:26 +0330 Subject: [PATCH 2/2] fix(tui): harden contextual output routing --- Tools/Controller/internal/tui/interactions.go | 126 ++++++++---------- Tools/Controller/internal/tui/model.go | 17 ++- Tools/Controller/internal/tui/model_test.go | 68 +++++++++- Tools/Controller/internal/tui/pages.go | 95 +++++++------ Tools/Controller/internal/tui/table.go | 15 ++- 5 files changed, 204 insertions(+), 117 deletions(-) diff --git a/Tools/Controller/internal/tui/interactions.go b/Tools/Controller/internal/tui/interactions.go index 36f02b50c..535dbf0c8 100644 --- a/Tools/Controller/internal/tui/interactions.go +++ b/Tools/Controller/internal/tui/interactions.go @@ -281,9 +281,13 @@ func (model Model) handleKey(message tea.KeyMsg) (Model, tea.Cmd, bool) { case "ctrl+x": return model.closePort() case "ctrl+r": - if model.snapshot().Connected { + if firmwareRebootAvailable(model.snapshot()) { return model.dispatchLine("reset app") } + if snapshot := model.snapshot(); snapshot.ConnectionState == "close_failed" || snapshot.ConnectionPhase == "blocked" { + model.setNotice("Hardware reset unavailable until retained serial ownership is cleaned up") + return model, nil, true + } return model.dispatchLine("reset lines") case "~", "`": model.toggleTerminal() @@ -366,15 +370,19 @@ func (model Model) handleKey(message tea.KeyMsg) (Model, tea.Cmd, bool) { model.moveCursor(1) return model, nil, true case "home": - if inputEmpty && model.page == PageOutputs && model.cursor >= 15 && model.cursor <= 26 { - return model.setSelectedPWM(0) + if inputEmpty && model.page == PageOutputs { + if row, ok := model.selectedOutputRow(); ok && row.Kind == "pwm" { + return model.setPWMChannel(row.Index, 0) + } } if inputEmpty && model.page == PageMenus { return model.moveSelectedMenuToRank(0) } case "end": - if inputEmpty && model.page == PageOutputs && model.cursor >= 15 && model.cursor <= 26 { - return model.setSelectedPWM(4095) + if inputEmpty && model.page == PageOutputs { + if row, ok := model.selectedOutputRow(); ok && row.Kind == "pwm" { + return model.setPWMChannel(row.Index, 4095) + } } if inputEmpty && model.page == PageMenus { return model.moveSelectedMenuToRank(len(model.activeMenuPages()) - 1) @@ -481,8 +489,10 @@ func (model Model) dispatchLine(line string) (Model, tea.Cmd, bool) { if strings.EqualFold(line, "reset app") || strings.EqualFold(line, "reset lines") { model.rebootPending = true if strings.EqualFold(line, "reset lines") { + model.rebootKind = "hardware" model.setNotice("Pulsing DTR hardware reset…") } else { + model.rebootKind = "firmware" model.setNotice("Rebooting controller application…") } } @@ -799,8 +809,8 @@ func (model Model) adjustSelection(delta int) (Model, tea.Cmd, bool) { } return model, nil, true } - if model.cursor >= 15 && model.cursor <= 26 { - channel := model.cursor - 15 + if row, ok := model.selectedOutputRow(); ok && row.Kind == "pwm" { + channel := row.Index value := int(model.pwmValues[channel]) + delta*64 if value < 0 { value = 4095 @@ -808,7 +818,7 @@ func (model Model) adjustSelection(delta int) (Model, tea.Cmd, bool) { if value > 4095 { value = 0 } - return model.setSelectedPWM(uint16(value)) + return model.setPWMChannel(channel, uint16(value)) } case PageMenus: entry, ok := model.selectedMenuConfiguration() @@ -828,30 +838,19 @@ func (model Model) adjustSelection(delta int) (Model, tea.Cmd, bool) { } func (model Model) activateOutput() (Model, tea.Cmd, bool) { - if row, ok := model.selectedOutputRow(); ok && row.Action != "" { - return model.dispatchLine(row.Action) - } - switch { - case model.cursor >= 0 && model.cursor <= 7: - return model.dispatchLine(fmt.Sprintf("relay %d toggle", model.cursor+1)) - case model.cursor == 8: - return model.dispatchLine("relay off") - case model.cursor >= 9 && model.cursor <= 14: - commands := []string{ - "relay side left up", "relay side left stop", "relay side left down", - "relay side right up", "relay side right stop", "relay side right down", - } - return model.dispatchLine(commands[model.cursor-9]) - case model.cursor >= 15 && model.cursor <= 26: - value := model.pwmValues[model.cursor-15] - if value == 0 { - value = 2048 - } else { - value = 0 + if row, ok := model.selectedOutputRow(); ok { + if row.Action != "" { + return model.dispatchLine(row.Action) + } + if row.Kind == "pwm" { + value := model.pwmValues[row.Index] + if value == 0 { + value = 2048 + } else { + value = 0 + } + return model.setPWMChannel(row.Index, value) } - return model.setSelectedPWM(value) - case model.cursor == 27: - return model.dispatchLine("pwm off") } return model, nil, true } @@ -864,34 +863,6 @@ func (model Model) selectedOutputRow() (controlTableRow, bool) { return rows[model.cursor], true } -func outputPeripheralDescriptor(cursor int) (appconfig.PeripheralDescriptor, bool) { - rows := make([]appconfig.PeripheralDescriptor, 0, 27) - descriptors := appconfig.PeripheralDescriptors() - for _, descriptor := range descriptors { - if descriptor.Kind == "relay" { - rows = append(rows, descriptor) - } - } - rows = append(rows, appconfig.PeripheralDescriptor{}) // All relays. - for _, descriptor := range descriptors { - if descriptor.Kind == "motion" { - for range 3 { // Up, stop, and down share one presentation name. - rows = append(rows, descriptor) - } - } - } - for _, descriptor := range descriptors { - if descriptor.Control == "pwm-user" { - rows = append(rows, descriptor) - } - } - rows = append(rows, appconfig.PeripheralDescriptor{}) // All user PWM. - if cursor < 0 || cursor >= len(rows) || rows[cursor].Key == "" { - return appconfig.PeripheralDescriptor{}, false - } - return rows[cursor], true -} - func (model Model) peripheralName(key, fallback string) string { if name := strings.TrimSpace(model.uiValue.PeripheralNames[key]); name != "" { return name @@ -900,11 +871,21 @@ func (model Model) peripheralName(key, fallback string) string { } func (model Model) beginPeripheralRename() (Model, tea.Cmd, bool) { - descriptor, ok := outputPeripheralDescriptor(model.cursor) - if !ok { + row, ok := model.selectedOutputRow() + if !ok || row.PeripheralKey == "" { model.setNotice("Select a relay, motion side, or PWM channel before renaming") return model, nil, true } + var descriptor appconfig.PeripheralDescriptor + for _, candidate := range appconfig.PeripheralDescriptors() { + if candidate.Key == row.PeripheralKey { + descriptor, ok = candidate, true + break + } + } + if !ok { + return model, nil, true + } return model.beginPeripheralRenameDescriptor(descriptor) } @@ -1015,7 +996,14 @@ func (model Model) savePeripheralName(descriptor appconfig.PeripheralDescriptor, } func (model Model) setSelectedPWM(value uint16) (Model, tea.Cmd, bool) { - channel := model.cursor - 15 + row, ok := model.selectedOutputRow() + if !ok || row.Kind != "pwm" { + return model, nil, true + } + return model.setPWMChannel(row.Index, value) +} + +func (model Model) setPWMChannel(channel int, value uint16) (Model, tea.Cmd, bool) { if channel < 0 || channel > 11 { return model, nil, true } @@ -1861,7 +1849,7 @@ func (model Model) handleMouse(message tea.MouseMsg) (tea.Model, tea.Cmd) { return model, nil } if message.Action == tea.MouseActionMotion && message.Button == tea.MouseButtonLeft && model.pwmDragChannel >= 0 { - return model.setOutputPWMFromX(15+model.pwmDragChannel, message.X) + return model.setOutputPWMFromX(model.cursor, message.X) } } if message.Action == tea.MouseActionRelease && model.page == PageMenus && !model.portPicker { @@ -2007,14 +1995,14 @@ func (model Model) handleContentClick(row, x int) (tea.Model, tea.Cmd) { index, ok := controlTableLogicalAt(rows, tableBodyRows(model.contentHeight()), model.cursor, row-3) if ok { model.cursor = index - if index >= 15 && index <= 26 { + if selected := rows[index]; selected.Kind == "pwm" { tableStart := max(0, (model.width-tableWidth)/2) levelWidth := max(8, columns[1].Width-7) sliderStart := tableStart + 1 + columns[0].Width + 1 if x < sliderStart-1 || x > sliderStart+levelWidth { return model, nil } - model.pwmDragChannel = index - 15 + model.pwmDragChannel = selected.Index model.pwmDragSet = false return model.setOutputPWMFromX(index, x) } @@ -2114,9 +2102,11 @@ func (model Model) handleContentClick(row, x int) (tea.Model, tea.Cmd) { } func (model Model) setOutputPWMFromX(index, x int) (tea.Model, tea.Cmd) { - if index < 15 || index > 26 { + rows := model.controlTableRows(model.snapshot(), 8) + if index < 0 || index >= len(rows) || rows[index].Kind != "pwm" { return model, nil } + channel := rows[index].Index tableWidth := model.presentationTableWidth(118) columns := outputTableColumns(tableWidth) tableStart := max(0, (model.width-tableWidth)/2) @@ -2126,13 +2116,13 @@ func (model Model) setOutputPWMFromX(index, x int) (tea.Model, tea.Cmd) { percent = min(100, max(0, percent)) value := uint16(percent * 4095 / 100) model.cursor = index - model.pwmDragChannel = index - 15 + model.pwmDragChannel = channel if model.pwmDragSet && model.pwmDragValue == value { return model, nil } model.pwmDragValue = value model.pwmDragSet = true - updated, command, _ := model.setSelectedPWM(value) + updated, command, _ := model.setPWMChannel(channel, value) return updated, command } diff --git a/Tools/Controller/internal/tui/model.go b/Tools/Controller/internal/tui/model.go index eaaaba8df..878cc4051 100644 --- a/Tools/Controller/internal/tui/model.go +++ b/Tools/Controller/internal/tui/model.go @@ -76,6 +76,7 @@ type Model struct { connectRetryAt time.Time connectRetryDelay time.Duration rebootPending bool + rebootKind string statusPending bool uiConfig func() appconfig.UI saveUI func(appconfig.UI) error @@ -1028,6 +1029,7 @@ func (model Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if strings.EqualFold(strings.TrimSpace(message.line), "reset app") || strings.EqualFold(strings.TrimSpace(message.line), "reset lines") { model.rebootPending = false + model.rebootKind = "" } if errors.Is(message.err, shell.ErrExit) { @@ -1476,8 +1478,10 @@ func (model Model) applyAppAction(action hostui.AppAction) (Model, []tea.Cmd, bo strings.EqualFold(strings.TrimSpace(action.Value), "reset lines") { model.rebootPending = true if strings.EqualFold(strings.TrimSpace(action.Value), "reset lines") { + model.rebootKind = "hardware" model.setNotice("Pulsing DTR hardware reset…") } else { + model.rebootKind = "firmware" model.setNotice("Rebooting controller application…") } } @@ -2144,12 +2148,18 @@ func (model Model) actionBarItems(snapshot control.Snapshot) []actionBarItem { } rebootLabel := "^R HW Reset" rebootAction := "hardware-reset" - if snapshot.Connected { + if firmwareRebootAvailable(snapshot) { rebootLabel = "^R Reboot" rebootAction = "firmware-reboot" + } else if snapshot.ConnectionState == "close_failed" || snapshot.ConnectionPhase == "blocked" { + rebootLabel = "^R Close blocked" + rebootAction = "" } if model.rebootPending { rebootLabel = model.spinnerView() + " Rebooting" + if model.rebootKind == "hardware" { + rebootLabel = model.spinnerView() + " HW Resetting" + } rebootAction = "" } items = append(items, @@ -2166,6 +2176,11 @@ func (model Model) actionBarItems(snapshot control.Snapshot) []actionBarItem { return items } +func firmwareRebootAvailable(snapshot control.Snapshot) bool { + return snapshot.Connected && snapshot.Hello.IsPCController() && + snapshot.ConnectionState != "close_failed" && snapshot.ConnectionPhase != "blocked" +} + func (model Model) actionBar(snapshot control.Snapshot) string { items := model.actionBarItems(snapshot) buttons := make([]string, 0, len(items)) diff --git a/Tools/Controller/internal/tui/model_test.go b/Tools/Controller/internal/tui/model_test.go index 5adbf25f8..903450274 100644 --- a/Tools/Controller/internal/tui/model_test.go +++ b/Tools/Controller/internal/tui/model_test.go @@ -856,7 +856,7 @@ func TestHostedMenuPreviewAndLivePWMRemainBoardAuthoritative(t *testing.T) { model := New(control.New(control.Options{}), shell.New(10)) model.page, model.cursor = PageOutputs, 15 model.pwmValues[0] = 123 - updated, _, _ := model.setSelectedPWM(2048) + updated, _, _ := model.setPWMChannel(0, 2048) if updated.pwmValues[0] != 123 { t.Fatalf("live PWM changed optimistically to %d", updated.pwmValues[0]) } @@ -873,7 +873,7 @@ func TestHostedMenuPreviewAndLivePWMRemainBoardAuthoritative(t *testing.T) { } return 3068, nil } - updated, command, _ := model.setSelectedPWM(3072) + updated, command, _ := model.setPWMChannel(11, 3072) if command == nil { t.Fatal("semantic enclosure override did not return a command") } @@ -1050,6 +1050,17 @@ func TestActionBarUsesFirmwareRebootWhenAuthenticatedAndHardwareResetOffline(t * if !strings.Contains(plain, "^R HW Reset") || strings.Contains(plain, "^R Reboot") { t.Fatalf("offline action bar did not retain recovery reset: %q", plain) } + updated, _, _ = model.dispatchLine("reset lines") + if plain = ansi.Strip(updated.actionBar(updated.snapshot())); !strings.Contains(plain, "HW Resetting") { + t.Fatalf("hardware reset progress used an ambiguous label: %q", plain) + } + model.preview.Connected = true + model.preview.ConnectionState = "close_failed" + model.preview.ConnectionPhase = "blocked" + plain = ansi.Strip(model.actionBar(model.snapshot())) + if !strings.Contains(plain, "Close blocked") || strings.Contains(plain, "^R Reboot") { + t.Fatalf("cleanup-blocked connection advertised an unsafe reboot path: %q", plain) + } } func TestControlRUsesContextualResetPath(t *testing.T) { @@ -1066,6 +1077,13 @@ func TestControlRUsesContextualResetPath(t *testing.T) { if !handled || command == nil || !strings.Contains(updated.notice, "DTR hardware reset") { t.Fatalf("offline Ctrl+R did not select DTR recovery: handled=%v command=%v notice=%q", handled, command, updated.notice) } + model.preview.Connected = true + model.preview.ConnectionState = "close_failed" + model.preview.ConnectionPhase = "blocked" + updated, command, handled = model.handleKey(tea.KeyMsg{Type: tea.KeyCtrlR}) + if !handled || command != nil || !strings.Contains(updated.notice, "ownership") { + t.Fatalf("cleanup-blocked Ctrl+R was not safely disabled: command=%v notice=%q", command, updated.notice) + } } func TestF2PeripheralRenamePersistsInWatchedUIConfig(t *testing.T) { @@ -1780,6 +1798,52 @@ func TestControlTableExposesCapabilityGatedWS2811Surface(t *testing.T) { } } +func TestControlTableExposesWS2811WithoutRelayStatusAndAvoidsPWMCursorRouting(t *testing.T) { + model := readyModel(t, PageOutputs) + model.preview.Hello.Capabilities = native.CapabilityAddressableLED + model.preview.HaveStatus = false + rows := model.controlTableRows(model.snapshot(), 8) + if len(rows) == 0 || rows[0].Group != "WS2811 STRIP" { + t.Fatalf("addressable-only board did not expose strip controls: %#v", rows) + } + for index, row := range rows { + if row.Name == "Police" { + model.cursor = index + updated, command, _ := model.adjustSelection(1) + if command != nil || updated.pwmValues != model.pwmValues { + t.Fatalf("strip action row leaked into PWM adjustment: command=%v", command) + } + updated, command, _ = model.beginPeripheralRename() + if command != nil || updated.renameTarget != "" { + t.Fatal("strip action row leaked into peripheral rename") + } + return + } + } + t.Fatal("addressable-only controls omitted Police effect") +} + +func TestControlTableDisablesStripMutationsWhileMacroWorkspaceIsBusy(t *testing.T) { + model := readyModel(t, PageOutputs) + model.previewMacroRecording.Active = true + rows := model.controlTableRows(model.snapshot(), 8) + for _, row := range rows { + if row.Kind != "strip" { + continue + } + switch row.Name { + case "Stop stream", "Stream status": + if row.Action == "" { + t.Fatalf("host-only %q action was disabled", row.Name) + } + default: + if row.Action != "" || row.Adjust != "" || !strings.Contains(row.Value, "macro") { + t.Fatalf("busy strip mutation remained active: %#v", row) + } + } + } +} + func TestControlTableSemanticColorsCanBeDisabled(t *testing.T) { priorProfile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.TrueColor) diff --git a/Tools/Controller/internal/tui/pages.go b/Tools/Controller/internal/tui/pages.go index b1c8a56ca..bb1f15233 100644 --- a/Tools/Controller/internal/tui/pages.go +++ b/Tools/Controller/internal/tui/pages.go @@ -337,39 +337,41 @@ func (model Model) outputsPage(snapshot control.Snapshot) string { func (model Model) controlTableRows(snapshot control.Snapshot, levelWidth int) []controlTableRow { status := snapshot.Status rows := make([]controlTableRow, 0, 27) - if !snapshot.Connected || !snapshot.HaveStatus || snapshot.Hello.Capabilities&native.CapabilityRelayMotion == 0 { + if !snapshot.Connected { return rows } - for index := 0; index < 8; index++ { - key := fmt.Sprintf("relay.%d", index+1) - fallback, _ := appconfig.PeripheralDefaultName(key) - label := fmt.Sprintf("R%d · %s", index+1, model.peripheralName(key, fallback)) - on := status.ActiveRelays&(1<