Save pre-V2 motor controls and UI checkpoint
This commit is contained in:
@@ -87,6 +87,8 @@ class MavlinkWorker:
|
||||
# At 20 Hz this produces five small steps from neutral to full command.
|
||||
self.ramp_seconds = 0.25
|
||||
self.gain = 0.20
|
||||
self.independent_motor_scaling = False
|
||||
self.motor_gains = {motor: 20 for motor in range(1, 13)}
|
||||
self.rate_hz = 20
|
||||
self.target_system = 0
|
||||
self.target_component = 0
|
||||
@@ -110,7 +112,7 @@ class MavlinkWorker:
|
||||
self.heavy_pending_pings = {}
|
||||
self.heavy_quality_latest = None
|
||||
self.telemetry_rates_hz = {
|
||||
"SYS_STATUS": 1, "SERVO_OUTPUT_RAW": 2,
|
||||
"SYS_STATUS": 1, "BATTERY_STATUS": 1, "SERVO_OUTPUT_RAW": 2,
|
||||
"RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
|
||||
}
|
||||
self.ping_sequence = 0
|
||||
@@ -128,6 +130,11 @@ class MavlinkWorker:
|
||||
self.param_retries = 0
|
||||
self.param_types = {}
|
||||
self.pending_sets = {}
|
||||
self.motor_test_lock = threading.Lock()
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.motor_test_acknowledged = False
|
||||
self.motor_test_last_motor = None
|
||||
self.log_lock = threading.Lock()
|
||||
self.log_file = None
|
||||
self.log_path = None
|
||||
@@ -200,8 +207,10 @@ class MavlinkWorker:
|
||||
# ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
|
||||
# 20 Hz neutral stream alive here only competes with ARM and
|
||||
# its acknowledgement on constrained links.
|
||||
motor_test_mode = self._tick_motor_test(now)
|
||||
if self.target_system and self.armed and now >= next_control:
|
||||
self._send_control()
|
||||
if not motor_test_mode:
|
||||
self._send_control()
|
||||
# The first disarm packet is preceded by a neutral command.
|
||||
# Until disarm is confirmed, keep sending neutral at a
|
||||
# lower rate instead of consuming 20 Hz on the weak link.
|
||||
@@ -245,6 +254,10 @@ class MavlinkWorker:
|
||||
self.next_arm_retry_at = 0.0
|
||||
self.arm_retry_deadline = 0.0
|
||||
self.arm_confirmation = 0
|
||||
with self.motor_test_lock:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.motor_test_last_motor = None
|
||||
self.stream_config.clear()
|
||||
self.pending_pings.clear()
|
||||
self.ping_results.clear()
|
||||
@@ -337,6 +350,13 @@ class MavlinkWorker:
|
||||
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
|
||||
self.armed = armed
|
||||
self.config_paused = armed
|
||||
if not armed:
|
||||
with self.motor_test_lock:
|
||||
was_testing = self.motor_test_mode
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
if was_testing:
|
||||
self.events.put(("motor_test_stopped", "Vehicle disarmed"))
|
||||
if self.pending_arm_state is not None and armed == self.pending_arm_state:
|
||||
self.pending_arm_state = None
|
||||
if not armed:
|
||||
@@ -361,6 +381,18 @@ class MavlinkWorker:
|
||||
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
|
||||
self.log("RX_STREAM_CONFIG_ACK", result_name)
|
||||
return
|
||||
if int(msg.command) == mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST:
|
||||
self.log("RX_MOTOR_TEST_ACK", result_name)
|
||||
if result_name in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
|
||||
if not self.motor_test_acknowledged:
|
||||
self.motor_test_acknowledged = True
|
||||
self.events.put(("motor_test_ack", result_name))
|
||||
else:
|
||||
with self.motor_test_lock:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.events.put(("motor_test_rejected", result_name))
|
||||
return
|
||||
if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
|
||||
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
|
||||
self.pending_arm_state = None
|
||||
@@ -375,8 +407,33 @@ class MavlinkWorker:
|
||||
self.events.put(("statustext", text))
|
||||
elif msg_type == "SYS_STATUS":
|
||||
voltage_mv = int(msg.voltage_battery)
|
||||
if 0 < voltage_mv < 65535:
|
||||
self.events.put(("battery", voltage_mv / 1000.0))
|
||||
current_ca = int(msg.current_battery)
|
||||
remaining = int(msg.battery_remaining)
|
||||
self.events.put(("battery", {
|
||||
"voltage": voltage_mv / 1000.0 if 0 < voltage_mv < 65535 else None,
|
||||
"current_a": current_ca / 100.0 if current_ca >= 0 else None,
|
||||
"remaining_percent": remaining if 0 <= remaining <= 100 else None,
|
||||
}))
|
||||
elif msg_type == "BATTERY_STATUS":
|
||||
voltages = [int(value) for value in getattr(msg, "voltages", [])]
|
||||
valid_voltages = [value for value in voltages if 0 < value < 65535]
|
||||
voltage = sum(valid_voltages) / 1000.0 if valid_voltages else None
|
||||
current_ca = int(msg.current_battery)
|
||||
consumed = int(msg.current_consumed)
|
||||
remaining = int(msg.battery_remaining)
|
||||
self.events.put(("battery", {
|
||||
"voltage": voltage,
|
||||
"current_a": current_ca / 100.0 if current_ca >= 0 else None,
|
||||
"consumed_mah": consumed if consumed >= 0 else None,
|
||||
"remaining_percent": remaining if 0 <= remaining <= 100 else None,
|
||||
}))
|
||||
elif msg_type == "SERVO_OUTPUT_RAW":
|
||||
base = int(getattr(msg, "port", 0)) * 8 + 1
|
||||
outputs = {}
|
||||
for index in range(1, 9):
|
||||
raw = int(getattr(msg, f"servo{index}_raw", 0))
|
||||
outputs[str(base + index - 1)] = raw if 0 < raw < 65535 else None
|
||||
self.events.put(("servo_output", outputs))
|
||||
elif msg_type == "PARAM_VALUE":
|
||||
param_id = msg.param_id
|
||||
if isinstance(param_id, bytes):
|
||||
@@ -479,6 +536,67 @@ class MavlinkWorker:
|
||||
"probe_supported": total > 0,
|
||||
}))
|
||||
|
||||
def _send_motor_test(self, motor, throttle_percent):
|
||||
if not self.connection or not self.target_system:
|
||||
raise RuntimeError("No autopilot heartbeat")
|
||||
self.log("TX_MOTOR_TEST", f"motor={motor} throttle={throttle_percent}%")
|
||||
self._send_mav(self.connection, "command_int_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_FRAME_GLOBAL,
|
||||
mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, 0, 0,
|
||||
float(motor), 0.0, float(throttle_percent), 1.0, 1, 0, 0,
|
||||
)
|
||||
|
||||
def start_motor_test(self, motor, throttle_percent):
|
||||
motor, throttle_percent = int(motor), int(throttle_percent)
|
||||
if not 1 <= motor <= 12 or not 0 <= throttle_percent <= 100:
|
||||
raise ValueError("Choose a motor and a test throttle from 0% to 100%")
|
||||
if not self.connection or not self.target_system:
|
||||
raise RuntimeError("Connect to the vehicle before testing a motor")
|
||||
if not self.armed or time.monotonic() - self.last_heartbeat > 2.0:
|
||||
raise RuntimeError("Arm the vehicle and confirm fresh telemetry before motor testing")
|
||||
with self.motor_test_lock:
|
||||
if self.motor_test_mode:
|
||||
raise RuntimeError("Finish the current motor test before starting another")
|
||||
self.motor_test_request = {"motor": motor, "throttle": throttle_percent, "next_at": 0.0}
|
||||
self.motor_test_last_motor = motor
|
||||
self.motor_test_mode = True
|
||||
self.motor_test_acknowledged = False
|
||||
self.set_axes(Axes())
|
||||
self.log("OPERATOR", f"held motor test for motor {motor} at {throttle_percent}%")
|
||||
return True
|
||||
|
||||
def stop_motor_test(self):
|
||||
with self.motor_test_lock:
|
||||
request = self.motor_test_request
|
||||
if request is not None:
|
||||
self.motor_test_last_motor = request["motor"]
|
||||
motor = self.motor_test_last_motor
|
||||
was_testing = self.motor_test_mode
|
||||
self.motor_test_request = None
|
||||
if was_testing and motor and self.connection and self.target_system:
|
||||
self._send_motor_test(motor, 0)
|
||||
return True
|
||||
|
||||
def _tick_motor_test(self, now):
|
||||
motor = throttle = None
|
||||
with self.motor_test_lock:
|
||||
request = self.motor_test_request
|
||||
active = self.motor_test_mode
|
||||
if request is not None and not self.armed:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
active = False
|
||||
self.events.put(("motor_test_stopped", "Vehicle disarmed"))
|
||||
elif request is not None and now >= request["next_at"]:
|
||||
request["next_at"] = now + 0.25
|
||||
motor, throttle = request["motor"], request["throttle"]
|
||||
else:
|
||||
motor = throttle = None
|
||||
if motor is not None:
|
||||
self._send_motor_test(motor, throttle)
|
||||
return active
|
||||
|
||||
def _tick_link_probe(self, now):
|
||||
if self.heavy_quality_test is not None:
|
||||
self._tick_heavy_quality(now)
|
||||
@@ -754,6 +872,8 @@ class MavlinkWorker:
|
||||
active = not (force_neutral or expired)
|
||||
target = self.axes if active else Axes()
|
||||
gain = self.gain
|
||||
independent_motor_scaling = self.independent_motor_scaling
|
||||
motor_gains = dict(self.motor_gains)
|
||||
if force_neutral:
|
||||
self.output_axes = Axes()
|
||||
else:
|
||||
@@ -776,10 +896,25 @@ class MavlinkWorker:
|
||||
)
|
||||
limited_forward = axes.forward / horizontal_mix_peak
|
||||
limited_yaw = axes.yaw / horizontal_mix_peak
|
||||
x = round(limited_forward * gain * 1000)
|
||||
y = round(axes.lateral * gain * 1000)
|
||||
r = round(limited_yaw * gain * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * gain * 500)
|
||||
if independent_motor_scaling:
|
||||
# The desktop controller's SimpleROV-3 profile maps motors 1/2
|
||||
# to surge/turn and motor 3 to vertical thrust. Convert the two
|
||||
# independently scaled horizontal demands back into MANUAL_CONTROL
|
||||
# axes so ArduSub's configured mixer remains in charge.
|
||||
motor_1 = (limited_forward + limited_yaw) * motor_gains.get(1, 100) / 100.0
|
||||
motor_2 = (limited_forward - limited_yaw) * motor_gains.get(2, 100) / 100.0
|
||||
limited_forward = (motor_1 + motor_2) / 2.0
|
||||
limited_yaw = (motor_1 - motor_2) / 2.0
|
||||
vertical_gain = motor_gains.get(3, 100) / 100.0
|
||||
x = round(limited_forward * 1000)
|
||||
y = round(axes.lateral * 1000)
|
||||
r = round(limited_yaw * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * vertical_gain * 500)
|
||||
else:
|
||||
x = round(limited_forward * gain * 1000)
|
||||
y = round(axes.lateral * gain * 1000)
|
||||
r = round(limited_yaw * gain * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * gain * 500)
|
||||
self.log(
|
||||
"TX_MANUAL_CONTROL",
|
||||
f"x={x} y={y} z={z} r={r} active={active} "
|
||||
@@ -852,6 +987,17 @@ class MavlinkWorker:
|
||||
manual,
|
||||
)
|
||||
|
||||
def set_motor_scaling(self, independent, gains):
|
||||
clean = {motor: max(0, min(100, int(gains.get(motor, 100)))) for motor in range(1, 13)}
|
||||
with self.lock:
|
||||
self.independent_motor_scaling = bool(independent)
|
||||
self.motor_gains = clean
|
||||
|
||||
def motor_output_scale(self, motor):
|
||||
with self.lock:
|
||||
return (self.motor_gains.get(int(motor), 100) / 100.0
|
||||
if self.independent_motor_scaling else self.gain)
|
||||
|
||||
def set_axes(self, axes: Axes, sequence=None):
|
||||
def conditioned(value):
|
||||
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
|
||||
|
||||
Reference in New Issue
Block a user