diff --git a/tui/src/birdcage_tui/bridge.py b/tui/src/birdcage_tui/bridge.py index 6998403..f922632 100644 --- a/tui/src/birdcage_tui/bridge.py +++ b/tui/src/birdcage_tui/bridge.py @@ -315,6 +315,21 @@ class SerialBridge: return result + def set_max_velocity(self, motor_id: int, deg_per_sec: float) -> None: + """Set max velocity for a motor axis (°/s). Firmware: MOT> mv [motor] [vel].""" + with self._lock: + self._ensure_menu(Menu.MOT) + self._send(f"mv {motor_id} {deg_per_sec:.1f}") + + def set_max_acceleration(self, motor_id: int, accel: float) -> None: + """Set max acceleration for a motor axis. + + Firmware: MOT> ma [motor] [accel] (°/s²). + """ + with self._lock: + self._ensure_menu(Menu.MOT) + self._send(f"ma {motor_id} {accel:.1f}") + def get_motor_life(self) -> str: """Read motor lifetime / usage statistics.""" with self._lock: diff --git a/tui/src/birdcage_tui/demo.py b/tui/src/birdcage_tui/demo.py index eb56673..6ed45c6 100644 --- a/tui/src/birdcage_tui/demo.py +++ b/tui/src/birdcage_tui/demo.py @@ -206,6 +206,12 @@ class DemoDevice: self._target_el = 45.0 self._last_move_time = time.monotonic() + # Motor dynamics (mutable in demo for velocity/accel controls). + self._az_max_vel = 65.0 + self._el_max_vel = 45.0 + self._az_accel = 400.0 + self._el_accel = 400.0 + # Submenu tracking for console simulation. self._menu = _DemoMenu.ROOT @@ -320,12 +326,24 @@ class DemoDevice: def get_motor_dynamics(self) -> dict[str, float]: return { - "az_max_vel": 65.0, - "el_max_vel": 45.0, - "az_accel": 400.0, - "el_accel": 400.0, + "az_max_vel": self._az_max_vel, + "el_max_vel": self._el_max_vel, + "az_accel": self._az_accel, + "el_accel": self._el_accel, } + def set_max_velocity(self, motor_id: int, deg_per_sec: float) -> None: + if motor_id == 0: + self._az_max_vel = deg_per_sec + elif motor_id == 1: + self._el_max_vel = deg_per_sec + + def set_max_acceleration(self, motor_id: int, accel: float) -> None: + if motor_id == 0: + self._az_accel = accel + elif motor_id == 1: + self._el_accel = accel + def get_motor_life(self) -> str: return _MOTOR_LIFE @@ -574,10 +592,24 @@ class DemoDevice: self.home_motor(motor_id) return f"Homing motor {motor_id}\nMOT>" return "Invalid parameters\nMOT>" - if cmd == "mv": - return "Max Vel [0] = 65.0 Max Vel [1] = 45.0\nMOT>" - if cmd == "ma": - return "Accel[0] = 400.0 Accel[1] = 400.0\nMOT>" + if cmd == "mv" or cmd.startswith("mv "): + parts = cmd.split() + if len(parts) >= 3: + self.set_max_velocity(int(parts[1]), float(parts[2])) + return f"Max Vel [{parts[1]}] = {float(parts[2]):.1f}\nMOT>" + return ( + f"Max Vel [0] = {self._az_max_vel:.1f}" + f" Max Vel [1] = {self._el_max_vel:.1f}\nMOT>" + ) + if cmd == "ma" or cmd.startswith("ma "): + parts = cmd.split() + if len(parts) >= 3: + self.set_max_acceleration(int(parts[1]), float(parts[2])) + return f"Accel[{parts[1]}] = {float(parts[2]):.1f}\nMOT>" + return ( + f"Accel[0] = {self._az_accel:.1f}" + f" Accel[1] = {self._el_accel:.1f}\nMOT>" + ) if cmd == "p": self._update_position() az_steps = int(self._az * 40000 / 360) diff --git a/tui/src/birdcage_tui/screens/control.py b/tui/src/birdcage_tui/screens/control.py index c8e32e5..c5a42c9 100644 --- a/tui/src/birdcage_tui/screens/control.py +++ b/tui/src/birdcage_tui/screens/control.py @@ -40,6 +40,9 @@ class ControlScreen(Container): Binding("r", "release_motors", "Release", show=False), ] + # Available step sizes for the nudge selector (degrees). + STEP_SIZES: list[float] = [0.1, 0.5, 1.0, 5.0, 10.0] + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._device: object = None @@ -48,6 +51,7 @@ class ControlScreen(Container): self._poll_worker: Worker | None = None self._last_az = 180.0 self._last_el = 45.0 + self._step_size: float = 1.0 # ------------------------------------------------------------------ # Compose @@ -99,6 +103,27 @@ class ControlScreen(Container): type="number", ) yield Button("Move", id="btn-ctrl-move", variant="primary") + with Horizontal(classes="velocity-controls"): + yield Static("AZ Vel ", classes="label") + yield Input( + placeholder="65.0", + id="ctrl-az-vel", + type="number", + ) + yield Static(" EL Vel ", classes="label") + yield Input( + placeholder="45.0", + id="ctrl-el-vel", + type="number", + ) + yield Button("Apply", id="btn-ctrl-apply-vel") + with Horizontal(classes="step-size-bar"): + yield Static("Step ", classes="label") + for size in self.STEP_SIZES: + label = f"{size}°" if size >= 1 else f"{size:.1f}°" + btn_id = f"btn-step-{str(size).replace('.', '_')}" + classes = "step-btn selected" if size == 1.0 else "step-btn" + yield Button(label, id=btn_id, classes=classes) with Horizontal(classes="bottom-controls"): yield Button("Home AZ", id="btn-ctrl-home-az") yield Button("Home EL", id="btn-ctrl-home-el") @@ -124,6 +149,7 @@ class ControlScreen(Container): """Store the device reference and start the data poll.""" self._device = device self._start_data_poll() + self._load_motor_dynamics() def on_show(self) -> None: """Resume polling when this screen becomes visible.""" @@ -195,6 +221,28 @@ class ControlScreen(Container): self._polling = True self._poll_worker = self._do_data_poll() + @work(thread=True, exclusive=True, group="load-dynamics") + def _load_motor_dynamics(self) -> None: + """Fetch current velocity values and populate the input fields.""" + if self._device is None: + return + try: + dynamics = self._device.get_motor_dynamics() + self.app.call_from_thread( + self._populate_velocity_inputs, + dynamics["az_max_vel"], + dynamics["el_max_vel"], + ) + except Exception: + log.debug("Failed to load motor dynamics", exc_info=True) + + def _populate_velocity_inputs(self, az_vel: float, el_vel: float) -> None: + try: + self.query_one("#ctrl-az-vel", Input).value = f"{az_vel:.1f}" + self.query_one("#ctrl-el-vel", Input).value = f"{el_vel:.1f}" + except Exception: + pass + @work(thread=True, exclusive=True, group="control-data-poll") def _do_data_poll(self) -> None: """Poll step positions and torque at ~2 Hz while active.""" @@ -268,6 +316,10 @@ class ControlScreen(Container): self._handle_home(1) elif button_id == "btn-ctrl-engage": self._handle_engage_toggle() + elif button_id.startswith("btn-step-"): + self._handle_step_size(event.button) + elif button_id == "btn-ctrl-apply-vel": + self._handle_apply_velocity() def _handle_move(self) -> None: """Read AZ/EL inputs and issue a move command.""" @@ -319,6 +371,54 @@ class ControlScreen(Container): self._update_engaged(True) self.app.notify("Motors engaged") + def _handle_step_size(self, pressed_btn: Button) -> None: + """Update step size and highlight the selected button.""" + # Parse the step size from the button ID: "btn-step-1_0" → 1.0 + raw = (pressed_btn.id or "").replace("btn-step-", "").replace("_", ".") + try: + self._step_size = float(raw) + except ValueError: + return + + # Update visual selection on all step buttons. + for btn in self.query(".step-btn"): + btn.remove_class("selected") + pressed_btn.add_class("selected") + + def _handle_apply_velocity(self) -> None: + """Apply velocity settings from the input fields.""" + if self._device is None: + self.app.notify("No device connected", severity="warning") + return + + az_vel_input = self.query_one("#ctrl-az-vel", Input) + el_vel_input = self.query_one("#ctrl-el-vel", Input) + + az_val = az_vel_input.value.strip() + el_val = el_vel_input.value.strip() + + if not az_val and not el_val: + self.app.notify("Enter a velocity value", severity="warning") + return + + if az_val: + try: + az_vel = float(az_val) + self._run_motor_command(self._device.set_max_velocity, 0, az_vel) + except ValueError: + self.app.notify("Invalid AZ velocity", severity="warning") + return + + if el_val: + try: + el_vel = float(el_val) + self._run_motor_command(self._device.set_max_velocity, 1, el_vel) + except ValueError: + self.app.notify("Invalid EL velocity", severity="warning") + return + + self.app.notify("Velocity updated", severity="information") + # ------------------------------------------------------------------ # Preset handlers # ------------------------------------------------------------------ @@ -396,17 +496,17 @@ class ControlScreen(Container): return False def action_nudge_az(self, delta: int) -> None: - """Nudge azimuth by delta degrees.""" + """Nudge azimuth by delta * step_size degrees.""" if not self._is_manual_active() or self._device is None: return - new_az = self._last_az + delta + new_az = self._last_az + delta * self._step_size self._run_motor_command(self._device.move_motor, 0, new_az) def action_nudge_el(self, delta: int) -> None: - """Nudge elevation by delta degrees.""" + """Nudge elevation by delta * step_size degrees.""" if not self._is_manual_active() or self._device is None: return - new_el = self._last_el + delta + new_el = self._last_el + delta * self._step_size self._run_motor_command(self._device.move_motor, 1, new_el) def action_home_both(self) -> None: diff --git a/tui/src/birdcage_tui/theme.tcss b/tui/src/birdcage_tui/theme.tcss index 3f32a6a..5019110 100644 --- a/tui/src/birdcage_tui/theme.tcss +++ b/tui/src/birdcage_tui/theme.tcss @@ -644,6 +644,70 @@ ProgressBar PercentageStatus { text-style: bold; } +/* ── Step Size Selector ───────────────────────────── */ + +.step-size-bar { + layout: horizontal; + height: 3; + padding: 0 1; + dock: bottom; + background: #0e1420; + border-top: solid #1a2a38; +} + +.step-size-bar .label { + width: auto; + padding: 1 1 0 0; +} + +.step-btn { + min-width: 8; + height: 3; + margin: 0 0 0 0; + background: #121c2a; + color: #7090a8; + border: round #1a3050; + text-align: center; +} + +.step-btn:hover { + background: #1a2a40; + color: #00d4aa; + border: round #00d4aa; +} + +.step-btn.selected { + background: #0a3a3a; + color: #00d4aa; + border: round #00d4aa; + text-style: bold; +} + +/* ── Velocity Controls ───────────────────────────── */ + +.velocity-controls { + layout: horizontal; + height: 3; + padding: 0 1; + dock: bottom; + background: #0e1420; + border-top: solid #1a2a38; +} + +.velocity-controls .label { + width: auto; + padding: 1 1 0 0; +} + +.velocity-controls Input { + width: 10; + margin-right: 1; +} + +.velocity-controls Button { + margin-left: 1; +} + /* ── Scrollbar Styling ─────────────────────────────── */ * { diff --git a/tui/tests/test_step_size_velocity.py b/tui/tests/test_step_size_velocity.py new file mode 100644 index 0000000..057f27e --- /dev/null +++ b/tui/tests/test_step_size_velocity.py @@ -0,0 +1,283 @@ +"""Test step size selector and motor velocity controls on the F2 Control screen. + +Uses Textual's run_test() and pilot to drive the TUI in demo mode. +Verifies step size button toggling, nudge multiplication, velocity +input population, and velocity apply round-trip through DemoDevice. + +NOTE: pilot.click() on dock:bottom widgets can miss due to headless +coordinate mapping. We use post_message(Button.Pressed(...)) for reliable +button activation in CI, and pilot.press() for keyboard interaction. +""" + +import asyncio + +import pytest +from textual.widgets import Button, Input + +from birdcage_tui.app import BirdcageApp +from birdcage_tui.screens.control import ControlScreen + + +def _press_button(btn: Button) -> None: + """Programmatically press a Button by posting its Pressed message. + + Bypasses pilot.click() coordinate issues with docked widgets. + """ + btn.post_message(Button.Pressed(btn)) + + +@pytest.mark.asyncio +async def test_step_size_default_is_one(): + """Default step size should be 1.0 degree.""" + app = BirdcageApp() + app.demo_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("f2") + await pilot.pause() + + control = app.query_one("#control", ControlScreen) + assert control._step_size == 1.0 + + # The 1° button should have the "selected" class. + btn_1 = app.query_one("#btn-step-1_0", Button) + assert "selected" in btn_1.classes + + +@pytest.mark.asyncio +async def test_step_size_button_switches(): + """Pressing a step-size button should update _step_size and highlight.""" + app = BirdcageApp() + app.demo_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("f2") + await pilot.pause() + + control = app.query_one("#control", ControlScreen) + + # Press the 0.1° button. + btn_01 = app.query_one("#btn-step-0_1", Button) + _press_button(btn_01) + await pilot.pause() + + assert control._step_size == 0.1 + assert "selected" in btn_01.classes + + # The old 1° button should no longer be selected. + btn_1 = app.query_one("#btn-step-1_0", Button) + assert "selected" not in btn_1.classes + + # Press the 10° button. + btn_10 = app.query_one("#btn-step-10_0", Button) + _press_button(btn_10) + await pilot.pause() + + assert control._step_size == 10.0 + assert "selected" in btn_10.classes + assert "selected" not in btn_01.classes + + +@pytest.mark.asyncio +async def test_step_size_cycles_all(): + """Pressing each step button in sequence should set the correct value.""" + app = BirdcageApp() + app.demo_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("f2") + await pilot.pause() + + control = app.query_one("#control", ControlScreen) + expected = [ + ("btn-step-0_1", 0.1), + ("btn-step-0_5", 0.5), + ("btn-step-1_0", 1.0), + ("btn-step-5_0", 5.0), + ("btn-step-10_0", 10.0), + ] + + for btn_id, expected_size in expected: + btn = app.query_one(f"#{btn_id}", Button) + _press_button(btn) + await pilot.pause() + + assert control._step_size == expected_size, ( + f"After pressing #{btn_id}, step_size should be " + f"{expected_size}, got {control._step_size}" + ) + assert "selected" in btn.classes + + # All other buttons should NOT be selected. + for other_id, _ in expected: + if other_id != btn_id: + other = app.query_one(f"#{other_id}", Button) + assert "selected" not in other.classes, ( + f"#{other_id} should not be selected when #{btn_id} is active" + ) + + +@pytest.mark.asyncio +async def test_nudge_uses_step_size(): + """Arrow key nudge should multiply delta by the selected step size.""" + app = BirdcageApp() + app.demo_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("f2") + await pilot.pause() + + control = app.query_one("#control", ControlScreen) + + # Wait for device to be set and position poll to run. + await asyncio.sleep(1.0) + + # Record starting position. + start_az = control._last_az + + # Set step size to 5 degrees. + btn_5 = app.query_one("#btn-step-5_0", Button) + _press_button(btn_5) + await pilot.pause() + + assert control._step_size == 5.0 + + # Focus the control screen for key bindings. + control.focus() + await pilot.pause() + + # Press right arrow to nudge AZ +1 * 5.0 = +5.0. + await pilot.press("right") + await asyncio.sleep(0.5) + + # The demo device target should have moved by ~5 degrees. + target_az = app.device._target_az + expected = start_az + 5.0 + assert abs(target_az - expected) < 0.1, ( + f"Expected target AZ ~{expected}, got {target_az}" + ) + + +@pytest.mark.asyncio +async def test_nudge_fine_step(): + """0.1 deg step size should produce sub-degree nudges.""" + app = BirdcageApp() + app.demo_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("f2") + await pilot.pause() + + control = app.query_one("#control", ControlScreen) + await asyncio.sleep(1.0) + + start_el = control._last_el + + # Select 0.1° step size. + btn_01 = app.query_one("#btn-step-0_1", Button) + _press_button(btn_01) + await pilot.pause() + + control.focus() + await pilot.pause() + + # Press up arrow to nudge EL +1 * 0.1 = +0.1. + await pilot.press("up") + await asyncio.sleep(0.5) + + target_el = app.device._target_el + expected = start_el + 0.1 + assert abs(target_el - expected) < 0.05, ( + f"Expected target EL ~{expected:.1f}, got {target_el:.2f}" + ) + + +@pytest.mark.asyncio +async def test_velocity_inputs_populated(): + """Velocity inputs should auto-populate from device motor dynamics.""" + app = BirdcageApp() + app.demo_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("f2") + await pilot.pause() + + # Wait for the _load_motor_dynamics worker to complete. + await asyncio.sleep(1.0) + + az_vel_input = app.query_one("#ctrl-az-vel", Input) + el_vel_input = app.query_one("#ctrl-el-vel", Input) + + assert az_vel_input.value == "65.0", ( + f"AZ velocity should be '65.0', got {az_vel_input.value!r}" + ) + assert el_vel_input.value == "45.0", ( + f"EL velocity should be '45.0', got {el_vel_input.value!r}" + ) + + +@pytest.mark.asyncio +async def test_velocity_apply(): + """Clicking Apply should update the demo device motor velocities.""" + app = BirdcageApp() + app.demo_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("f2") + await pilot.pause() + await asyncio.sleep(1.0) + + # Set new velocity values directly on the Input widgets. + az_vel_input = app.query_one("#ctrl-az-vel", Input) + el_vel_input = app.query_one("#ctrl-el-vel", Input) + az_vel_input.value = "55.0" + el_vel_input.value = "30.0" + await pilot.pause() + + # Press Apply via message posting. + apply_btn = app.query_one("#btn-ctrl-apply-vel", Button) + _press_button(apply_btn) + + # Wait for the worker thread to run the commands. + await asyncio.sleep(1.0) + + # Verify the demo device stored the new velocities. + dynamics = app.device.get_motor_dynamics() + assert dynamics["az_max_vel"] == 55.0, ( + f"AZ velocity should be 55.0, got {dynamics['az_max_vel']}" + ) + assert dynamics["el_max_vel"] == 30.0, ( + f"EL velocity should be 30.0, got {dynamics['el_max_vel']}" + ) + + +@pytest.mark.asyncio +async def test_all_step_buttons_exist(): + """All five step size buttons should render in Manual mode.""" + app = BirdcageApp() + app.demo_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("f2") + await pilot.pause() + + expected_ids = [ + "btn-step-0_1", + "btn-step-0_5", + "btn-step-1_0", + "btn-step-5_0", + "btn-step-10_0", + ] + for btn_id in expected_ids: + btn = app.query_one(f"#{btn_id}", Button) + assert btn is not None, f"Button #{btn_id} should exist" + + app.save_screenshot("/tmp/birdcage_step_size_test.svg")