Add configurable step size selector and motor velocity controls

F2 Control screen Manual mode now has a row of step-size buttons
(0.1°, 0.5°, 1°, 5°, 10°) that scale arrow key nudges, plus
AZ/EL velocity inputs with Apply button for runtime motor speed
tuning via firmware mv/ma commands.

Bridge: set_max_velocity(), set_max_acceleration() write methods.
Demo: mutable dynamics + console mv/ma write handling.
Tests: 8 Textual pilot tests covering button toggle, nudge math,
velocity population, and apply round-trip.
This commit is contained in:
Ryan Malloy 2026-02-14 20:05:12 -07:00
parent 5252d1d73c
commit ce24f7c478
5 changed files with 506 additions and 12 deletions

View File

@ -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] (°/).
"""
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:

View File

@ -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)

View File

@ -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:

View File

@ -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 ─────────────────────────────── */
* {

View File

@ -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")