diff --git a/Drivers/Holybro.inf b/Drivers/Holybro.inf index b1a9d82d4f..e30c965b37 100644 --- a/Drivers/Holybro.inf +++ b/Drivers/Holybro.inf @@ -87,14 +87,18 @@ ServiceBinary=%12%\%DRIVERFILENAME%.sys [DeviceList] %DESCRIPTION47%=DriverInstall, USB\VID_3162&PID_0047 %DESCRIPTION49%=DriverInstall, USB\VID_3162&PID_0049 -%DESCRIPTION4B%=DriverInstall, USB\VID_3162&PID_004B&MI_00 -%DESCRIPTION4BSL%=DriverInstall, USB\VID_3162&PID_004B&MI_02 +%DESCRIPTION4B%=DriverInstall, USB\VID_3162&PID_004B&MI_00 +%DESCRIPTION4BSL%=DriverInstall, USB\VID_3162&PID_004B&MI_02 +%DESCRIPTION53%=DriverInstall, USB\VID_3162&PID_0053&MI_00 +%DESCRIPTION53SL%=DriverInstall, USB\VID_3162&PID_0053&MI_02 [DeviceList.NTamd64] %DESCRIPTION47%=DriverInstall, USB\VID_3162&PID_0047 %DESCRIPTION49%=DriverInstall, USB\VID_3162&PID_0049 -%DESCRIPTION4B%=DriverInstall, USB\VID_3162&PID_004B&MI_00 -%DESCRIPTION4BSL%=DriverInstall, USB\VID_3162&PID_004B&MI_02 +%DESCRIPTION4B%=DriverInstall, USB\VID_3162&PID_004B&MI_00 +%DESCRIPTION4BSL%=DriverInstall, USB\VID_3162&PID_004B&MI_02 +%DESCRIPTION53%=DriverInstall, USB\VID_3162&PID_0053&MI_00 +%DESCRIPTION53SL%=DriverInstall, USB\VID_3162&PID_0053&MI_02 ;------------------------------------------------------------------------------ ; String Definitions @@ -108,6 +112,8 @@ MFGNAME="Holybro" INSTDISK="Holybro Installer" DESCRIPTION47="Pixhawk4" DESCRIPTION49="Pixhawk4-mini" -DESCRIPTION4B="Durandal" -DESCRIPTION4BSL="Durandal SLCAN" -SERVICE="USB RS-232 Emulation Driver" +DESCRIPTION4B="Durandal" +DESCRIPTION4BSL="Durandal SLCAN" +SERVICE="USB RS-232 Emulation Driver" +DESCRIPTION53="Pixhawk6C-MAVLink" +DESCRIPTION53SL="Pixhawk6C-SLCAN" diff --git a/ExtLibs/ArduPilot/Mavlink/MAVLinkInterface.cs b/ExtLibs/ArduPilot/Mavlink/MAVLinkInterface.cs index 6ce2fe3683..d70a05c90c 100644 --- a/ExtLibs/ArduPilot/Mavlink/MAVLinkInterface.cs +++ b/ExtLibs/ArduPilot/Mavlink/MAVLinkInterface.cs @@ -1699,8 +1699,9 @@ public async Task setParamAsync(byte sysid, byte compid, string paramname, Array.Resize(ref temp, 16); req.param_id = temp.ToByteArray(); - if ((MAVlist[sysid, compid].cs.capabilities & (uint) MAV_PROTOCOL_CAPABILITY.PARAM_FLOAT) > 0 || - MAVlist[sysid, compid].apname == MAV_AUTOPILOT.ARDUPILOTMEGA) + if (!UsesBytewiseParameterEncoding( + MAVlist[sysid, compid].cs.capabilities, + MAVlist[sysid, compid].apname)) { req.param_value = new MAVLinkParam(paramname, value, (MAV_PARAM_TYPE.REAL32)).float_value; } @@ -2090,8 +2091,9 @@ public async Task getParamListAsync(byte sysid, byte compid) //Console.WriteLine(DateTime.Now.Millisecond + " gp2a "); // item uses float based param system - if ((MAVlist[sysid, compid].cs.capabilities & (uint) MAV_PROTOCOL_CAPABILITY.PARAM_FLOAT) > 0 || - MAVlist[sysid, compid].apname == MAV_AUTOPILOT.ARDUPILOTMEGA) + if (!UsesBytewiseParameterEncoding( + MAVlist[sysid, compid].cs.capabilities, + MAVlist[sysid, compid].apname)) { var offset = Marshal.OffsetOf(typeof(mavlink_param_value_t), "param_value"); newparamlist[paramID] = new MAVLinkParam(paramID, BitConverter.GetBytes(par.param_value), @@ -2319,9 +2321,28 @@ public float GetParam(string name = "", short index = -1, bool requireresponce = public float GetParam(byte sysid, byte compid, string name = "", short index = -1, bool requireresponce = true) { return GetParamAsync(sysid, compid, name, index, requireresponce).AwaitSync(); - } - - /// + } + + internal static bool UsesBytewiseParameterEncoding( + uint capabilities, MAV_AUTOPILOT autopilot) + { + // The current flags are authoritative. In particular, a peripheral may identify its + // firmware as ArduPilot while still using the byte-wise protocol required to preserve + // UINT32/INT32 bits in the float-shaped PARAM_VALUE and PARAM_SET fields. + if ((capabilities & (uint) MAV_PROTOCOL_CAPABILITY.PARAM_ENCODE_BYTEWISE) != 0) + return true; + if ((capabilities & (uint) MAV_PROTOCOL_CAPABILITY.PARAM_ENCODE_C_CAST) != 0) + return false; +#pragma warning disable CS0612 // Legacy devices advertised PARAM_FLOAT before the encoding flags. + if ((capabilities & (uint) MAV_PROTOCOL_CAPABILITY.PARAM_FLOAT) != 0) + return false; +#pragma warning restore CS0612 + + // ArduPilot historically used C-cast encoding without advertising a capability. + return autopilot != MAV_AUTOPILOT.ARDUPILOTMEGA; + } + + /// /// Get param by either index or name /// /// diff --git a/GCSViews/FlightPlannerView.axaml b/GCSViews/FlightPlannerView.axaml index ab10b758b7..92d7a7938d 100644 --- a/GCSViews/FlightPlannerView.axaml +++ b/GCSViews/FlightPlannerView.axaml @@ -19,7 +19,8 @@ - + ApplyDockingLayout(!_actionDockBottom, persist: true); + internal static bool BlocksClosedMissionCommandKey( + bool isDropDownOpen, Key key, KeyModifiers modifiers) => + !isDropDownOpen && modifiers == KeyModifiers.None + && key is Key.Up or Key.Down or Key.PageUp or Key.PageDown or Key.Home or Key.End; + + private void OnMissionCommandKeyDown(object? sender, KeyEventArgs e) { + if (sender is ComboBox combo + && BlocksClosedMissionCommandKey(combo.IsDropDownOpen, e.Key, e.KeyModifiers)) { + e.Handled = true; + } + } + + private void OnMissionCommandPointerWheelChanged(object? sender, PointerWheelEventArgs e) { + if (sender is ComboBox { IsDropDownOpen: false }) { + e.Handled = true; + } + } + internal void ApplyDockingLayout(bool actionBottom, bool persist) { _actionDockBottom = actionBottom; diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/DataFlashMetadataTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/DataFlashMetadataTests.cs index c1fd1b8060..3f2a25386a 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/DataFlashMetadataTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/DataFlashMetadataTests.cs @@ -20,4 +20,29 @@ public void Parameter_export_is_sorted_and_uses_mission_planner_format() { File.Delete(path); } } + + [Fact] + public void Parameter_history_keeps_every_change_and_computes_final_values() { + string path = Path.Combine(Path.GetTempPath(), $"mp_param_history_{Guid.NewGuid():N}.log"); + try { + File.WriteAllLines(path, [ + "FMT, 128, 89, FMT, BBnNZ, Type,Length,Name,Format,Columns", + "FMT, 130, 40, PARM, QNff, TimeUS,Name,Value,Default", + "PARM, 1000000, ATC_STR_RAT_FF, 0.5, 0.2", + "PARM, 2000000, ARMING_CHECK, 1, 1", + "PARM, 3000000, ATC_STR_RAT_FF, 0.8, 0.2", + ]); + + DataFlashParameterHistory history = DataFlashLog.ReadParameterHistory(path); + + Assert.Equal(3, history.Changes.Count); + Assert.Equal(new[] { 1d, 2d, 3d }, + history.Changes.Select(change => change.TimeSeconds)); + Assert.Equal("0.8", history.FinalValues.Single( + parameter => parameter.Name == "ATC_STR_RAT_FF").Value); + Assert.Equal(2, history.FinalValues.Count); + } finally { + File.Delete(path); + } + } } diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/DroneCanParameterFileTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/DroneCanParameterFileTests.cs index 1a22903f39..9466b62298 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/DroneCanParameterFileTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/DroneCanParameterFileTests.cs @@ -57,4 +57,23 @@ public void DroneCan_value_conversion_preserves_string_type_and_rejects_bad_numb Assert.False(ConfigDroneCanViewModel.TryConvertParameterValue( numericParameter, "not-a-number", out _)); } + + [Theory] + [InlineData("-1", false)] + [InlineData("0", true)] + [InlineData("5.5", true)] + [InlineData("10", true)] + [InlineData("11", false)] + public void DroneCan_numeric_values_respect_reported_node_limits( + string text, bool expected) { + var parameter = new DroneCanParam { + Name = "RATE", + IsString = false, + Min = "0", + Max = "10", + }; + + Assert.Equal(expected, + ConfigDroneCanViewModel.TryConvertParameterValue(parameter, text, out _)); + } } diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/ExternalAdsbReceiverTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/ExternalAdsbReceiverTests.cs index e7a98758bd..227f57a4da 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/ExternalAdsbReceiverTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/ExternalAdsbReceiverTests.cs @@ -56,6 +56,30 @@ public void Sbs_decoder_merges_callsign_and_velocity_before_emitting_position() Assert.Equal(0x7700, plane.Squawk); } + [Fact] + public void Sbs_decoder_keeps_recent_nonempty_squawk_and_expires_it() { + DateTime now = new(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc); + var decoder = new ExternalAdsbDecoder(() => now); + + Assert.False(decoder.TryDecodeLine(Sbs("6", "ABC123", fields => { + fields[17] = "7700"; + }), out _)); + Assert.True(decoder.TryDecodeLine(Sbs("3", "ABC123", fields => { + fields[11] = "10000"; + fields[14] = "34.5"; + fields[15] = "33.25"; + }), out var recent)); + Assert.Equal(0x7700, recent.Squawk); + + now = now.AddSeconds(31); + Assert.True(decoder.TryDecodeLine(Sbs("3", "ABC123", fields => { + fields[11] = "10000"; + fields[14] = "34.5"; + fields[15] = "33.25"; + }), out var expired)); + Assert.Equal(0, expired.Squawk); + } + [Fact] public void Mode_s_decoder_accepts_valid_cpr_pair_in_avr_and_beast_framing() { const string even = "*8D75804B580FF2CF7E9BA6F701D0;"; diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/FlightDataSafetyAndTabTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/FlightDataSafetyAndTabTests.cs index 4a5406a78b..99c8eff33f 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/FlightDataSafetyAndTabTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/FlightDataSafetyAndTabTests.cs @@ -27,9 +27,9 @@ public void Destructive_action_prompts_explain_the_consequence() { FlightDataViewModel.ActionConfirmationText("Terminate_Flight")); Assert.Contains("permanently erased", FlightDataViewModel.ActionConfirmationText("Format_SD_Card")); - Assert.Contains("Disable automatic parachute release", + Assert.Contains("cannot be undone", FlightDataViewModel.ActionConfirmationText("Do_Parachute")); - Assert.Equal(MAVLink.PARACHUTE_ACTION.PARACHUTE_DISABLE, + Assert.Equal(MAVLink.PARACHUTE_ACTION.PARACHUTE_RELEASE, FlightDataViewModel.ParachuteCommandAction); } diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/MavlinkParameterEncodingTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/MavlinkParameterEncodingTests.cs new file mode 100644 index 0000000000..b8dd8d883d --- /dev/null +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/MavlinkParameterEncodingTests.cs @@ -0,0 +1,27 @@ +namespace MissionPlanner.Tests; + +public sealed class MavlinkParameterEncodingTests { + [Fact] + public void Explicit_bytewise_capability_overrides_firmware_identity() { + uint bytewise = (uint)MAVLink.MAV_PROTOCOL_CAPABILITY.PARAM_ENCODE_BYTEWISE; + + Assert.True(MAVLinkInterface.UsesBytewiseParameterEncoding( + bytewise, MAVLink.MAV_AUTOPILOT.ARDUPILOTMEGA)); + Assert.False(MAVLinkInterface.UsesBytewiseParameterEncoding( + (uint)MAVLink.MAV_PROTOCOL_CAPABILITY.PARAM_ENCODE_C_CAST, + MAVLink.MAV_AUTOPILOT.INVALID)); + } + + [Fact] + public void Bytewise_uint32_round_trip_preserves_all_bits() { + const uint expected = 60180513; + var encoded = new MAVLink.MAVLinkParam( + "DEVICE_CODE", expected, MAVLink.MAV_PARAM_TYPE.UINT32); + var decoded = new MAVLink.MAVLinkParam( + "DEVICE_CODE", BitConverter.GetBytes(encoded.float_value), + MAVLink.MAV_PARAM_TYPE.UINT32, MAVLink.MAV_PARAM_TYPE.UINT32); + + Assert.Equal(expected, decoded.Value); + Assert.NotEqual(expected, (double)(float)expected); + } +} diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/NvModemTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/NvModemTests.cs index 53f00ceef2..9ec71da7f9 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/NvModemTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/NvModemTests.cs @@ -301,6 +301,63 @@ public void Supports_every_current_gtu_identity_mode_without_id_ranges() { item.Label.Contains("NV5 88:222", StringComparison.Ordinal)); } + [Theory] + [InlineData((byte)0, (byte)4)] + [InlineData((byte)1, (byte)1)] + public void Nv5_unlocked_receiver_displays_current_channel_signal_only( + byte modulation, byte radioChip) { + var transport = new FakeTransport(); + using var viewModel = new NvModemViewModel(transport, () => DateTime.UtcNow, + startTimer: false); + var source = new NvModemLink(new MAVLinkInterface(), "shared UDP"); + + viewModel.HandlePacket(source, Packet(NvModemMessageIds.Nv5LinkStatus, + new Nv5LinkStatusMessage { + SampleMs = 1000, + Channel = 1, + RadioChip = radioChip, + Role = 0, + Modulation = modulation, + Flags = 0xfb, + PacketRssiDbmX10 = -395, + PacketSnrDbX10 = 112, + ChannelRssiDbmX10 = -970, + }, 5, 68)); + + Assert.Contains("L no", viewModel.RadioStatuses[0].Link, StringComparison.Ordinal); + Assert.Contains("R -97.0", viewModel.RadioStatuses[0].Link, StringComparison.Ordinal); + Assert.Contains("S —", viewModel.RadioStatuses[0].Link, StringComparison.Ordinal); + Assert.DoesNotContain("-39.5", viewModel.RadioStatuses[0].Link, StringComparison.Ordinal); + } + + [Fact] + public void Nv5_locked_receiver_prefers_packet_signal_and_allows_channel_fallback() { + var transport = new FakeTransport(); + using var viewModel = new NvModemViewModel(transport, () => DateTime.UtcNow, + startTimer: false); + var source = new NvModemLink(new MAVLinkInterface(), "shared UDP"); + var status = new Nv5LinkStatusMessage { + SampleMs = 1000, + Channel = 1, + RadioChip = 0, + Role = 0, + Flags = 1 << 2, + PacketRssiDbmX10 = -395, + PacketSnrDbX10 = 112, + ChannelRssiDbmX10 = -970, + }; + + viewModel.HandlePacket(source, + Packet(NvModemMessageIds.Nv5LinkStatus, status, 5, 68)); + Assert.Contains("R -39.5", viewModel.RadioStatuses[0].Link, StringComparison.Ordinal); + Assert.Contains("S 11.2", viewModel.RadioStatuses[0].Link, StringComparison.Ordinal); + + status.PacketRssiDbmX10 = short.MinValue; + viewModel.HandlePacket(source, + Packet(NvModemMessageIds.Nv5LinkStatus, status, 5, 68)); + Assert.Contains("R -97.0", viewModel.RadioStatuses[0].Link, StringComparison.Ordinal); + } + [Fact] public void Invalid_passport_and_unscoped_nv4_parameter_do_not_create_devices() { var transport = new FakeTransport(); diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/PackagingAuthoringTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/PackagingAuthoringTests.cs index c7f2d46420..60ded1ebda 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/PackagingAuthoringTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/PackagingAuthoringTests.cs @@ -27,6 +27,25 @@ public void Make_package_targets_select_their_platform_rid() { StringComparison.Ordinal); } + [Fact] + public void Windows_driver_catalog_recognizes_both_Pixhawk6C_interfaces() { + string inf = File.ReadAllText(Path.Combine(FindRepoRoot(), "Drivers", "Holybro.inf")); + + Assert.Equal(2, Count(inf, @"USB\VID_3162&PID_0053&MI_00")); + Assert.Equal(2, Count(inf, @"USB\VID_3162&PID_0053&MI_02")); + Assert.Contains("DESCRIPTION53=\"Pixhawk6C-MAVLink\"", inf, StringComparison.Ordinal); + Assert.Contains("DESCRIPTION53SL=\"Pixhawk6C-SLCAN\"", inf, StringComparison.Ordinal); + } + + private static int Count(string text, string value) { + int count = 0; + for (int index = 0; (index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0; + index += value.Length) { + count++; + } + return count; + } + private static void AssertPayload( XElement feature, XNamespace wix, string id, string expectedName) { XElement file = Assert.Single(feature.Descendants(wix + "File"), diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/PlannerPortParityTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/PlannerPortParityTests.cs index be064a74f3..bf9948306e 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/PlannerPortParityTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/PlannerPortParityTests.cs @@ -602,8 +602,12 @@ public void Loiter_turns_radius_respects_copter_panorama_semantics( } [Fact] - public void Global_loiter_radius_is_not_exposed_for_copter() { + public void Global_waypoint_and_loiter_radii_are_not_exposed_for_copter_or_rover() { + Assert.False(FlightPlannerViewModel.SupportsGlobalWaypointRadius(Firmwares.ArduCopter2)); + Assert.False(FlightPlannerViewModel.SupportsGlobalWaypointRadius(Firmwares.ArduRover)); + Assert.True(FlightPlannerViewModel.SupportsGlobalWaypointRadius(Firmwares.ArduPlane)); Assert.False(FlightPlannerViewModel.SupportsGlobalLoiterRadius(Firmwares.ArduCopter2)); + Assert.False(FlightPlannerViewModel.SupportsGlobalLoiterRadius(Firmwares.ArduRover)); Assert.True(FlightPlannerViewModel.SupportsGlobalLoiterRadius(Firmwares.ArduPlane)); } diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/RawParamsImportTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/RawParamsImportTests.cs index 5fcbb8d0e1..fbf30cf851 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/RawParamsImportTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/RawParamsImportTests.cs @@ -57,6 +57,47 @@ public void Compare_is_read_only_until_selected_rows_are_explicitly_staged() { Assert.Equal("500", second.ValueText); } + [Fact] + public void Compare_ignores_real32_rounding_noise_but_keeps_integer_changes_exact() { + var real = new ParamRow("ATC_RAT_PIT_P", 0.135000005, 0.135, "", "", "", + double.MinValue, double.MaxValue, MAVLink.MAV_PARAM_TYPE.REAL32); + var integer = new ParamRow("DEVICE_CODE", 60180513, null, "", "", "", + double.MinValue, double.MaxValue, MAVLink.MAV_PARAM_TYPE.UINT32); + + var comparison = RawParamsViewModel.BuildComparison( + new[] { real, integer }, + new Dictionary { + ["ATC_RAT_PIT_P"] = 0.135, + ["DEVICE_CODE"] = 60180512, + }); + + var changed = Assert.Single(comparison); + Assert.Equal("DEVICE_CODE", changed.Name); + Assert.False(real.IsDirty); + Assert.False(real.IsNonDefault); + } + + [Fact] + public void Compare_reports_parameters_missing_from_either_side_without_staging_them() { + var currentOnly = Row("CURRENT_ONLY", 7); + var changed = Row("CHANGED", 1); + + var comparison = RawParamsViewModel.BuildComparison( + new[] { currentOnly, changed }, + new Dictionary { ["FILE_ONLY"] = 9, ["CHANGED"] = 2 }); + + Assert.Equal(new[] { "CHANGED", "CURRENT_ONLY", "FILE_ONLY" }, + comparison.Select(row => row.Name)); + Assert.Equal("Not found", comparison.Single(row => row.Name == "CURRENT_ONLY").FileText); + Assert.Equal("Not found", comparison.Single(row => row.Name == "FILE_ONLY").CurrentText); + Assert.False(comparison.Single(row => row.Name == "CURRENT_ONLY").Use); + Assert.False(comparison.Single(row => row.Name == "FILE_ONLY").Use); + Assert.Equal(1, RawParamsViewModel.StageSelectedComparison( + new[] { currentOnly, changed }, comparison)); + Assert.Equal("2", changed.ValueText); + Assert.Equal("7", currentOnly.ValueText); + } + [Fact] public void Compare_never_exposes_or_stages_protected_parameters() { var normal = Row("RTL_ALT", 1000); diff --git a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/WpRowTests.cs b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/WpRowTests.cs index c1bea470b8..2236a98126 100644 --- a/MissionPlannerTests/Avalonia/MissionPlanner.Tests/WpRowTests.cs +++ b/MissionPlannerTests/Avalonia/MissionPlanner.Tests/WpRowTests.cs @@ -1,5 +1,7 @@ using MissionPlanner.Utilities; using MissionPlanner.ViewModels; +using MissionPlanner.Views; +using Avalonia.Input; namespace MissionPlanner.Tests; @@ -43,6 +45,20 @@ public void CommandName_setter_updates_command() { Assert.Equal((ushort)MAVLink.MAV_CMD.LAND, row.Command); } + [Theory] + [InlineData(Key.Down, KeyModifiers.None, true)] + [InlineData(Key.Up, KeyModifiers.None, true)] + [InlineData(Key.PageDown, KeyModifiers.None, true)] + [InlineData(Key.Home, KeyModifiers.None, true)] + [InlineData(Key.Enter, KeyModifiers.None, false)] + [InlineData(Key.Down, KeyModifiers.Alt, false)] + public void Closed_command_editor_blocks_implicit_selection_changes( + Key key, KeyModifiers modifiers, bool expected) { + Assert.Equal(expected, + FlightPlannerView.BlocksClosedMissionCommandKey(false, key, modifiers)); + Assert.False(FlightPlannerView.BlocksClosedMissionCommandKey(true, key, modifiers)); + } + [Fact] public void FrameName_maps_both_ways_and_round_trips() { var row = new WpRow { FrameName = "Terrain" }; diff --git a/Porting/STATUS.md b/Porting/STATUS.md index 43321189bc..2fb55fb63f 100644 --- a/Porting/STATUS.md +++ b/Porting/STATUS.md @@ -15,8 +15,11 @@ Updated: **2026-08-24**. rollback reference in Git history. - Upstream safety/reliability PR #13 is merged at `b24238dbc`; its complete Linux, Windows and macOS package matrix and CodeQL run passed, and release `v1.3.83-20260824.b24238db` contains all - 19 expected DEB/TAR/MSI/ZIP/DMG, signed update-manifest and checksum assets. The next upstream - issue audit is isolated on `fix/upstream-issues-round-2` until its own CI and review complete. + 19 expected DEB/TAR/MSI/ZIP/DMG, signed update-manifest and checksum assets. Round 2 was merged + through PR #14 at master checkpoint `0266a2878`; its master package run `32764653113` and CodeQL + run `32764653058` passed with zero open alerts. Round 3 remains isolated on + `fix/upstream-issues-round-3` until its own CI and review complete; no new tag or release may be + created until every round-3 check has passed on `master`. - The root `MissionPlanner.csproj` is now the net10 Avalonia application with assembly and product identity `MissionPlanner`. It builds one main `MissionPlanner.dll` and has no source, build or runtime dependency on an `external/MissionPlanner` tree. @@ -194,15 +197,51 @@ Updated: **2026-08-24**. projects report no known vulnerable direct or transitive NuGet packages. Cross-platform package and CodeQL gates remain required on the draft PR before merge. +## Upstream issue audit round 3 + +- Eleven atomic changes on `fix/upstream-issues-round-3` address confirmed reports and narrowly + applicable upstream PRs. #3535 now sends the irreversible `PARACHUTE_RELEASE` action instead of + disabling the parachute. #2923 hides Plane-only waypoint and loiter radii for both Copter and + Rover and prevents those pages from writing the hidden parameters. #3358 prevents a closed + mission-command selector from changing commands through navigation keys or the mouse wheel. +- Parameter handling now preserves protocol and file precision. #3284/#2842 comparisons use + `MAV_PARAM_TYPE`: integer values compare exactly, while REAL32 values tolerate only C + `FLT_EPSILON`; explicit compare dialogs also expose parameters missing from either side without + allowing a missing row to be staged. #2884 honours explicit bytewise parameter encoding ahead + of firmware-family heuristics, so values such as `UINT32` 60180513 survive both PARAM_SET and + parameter-list decoding exactly. The applicable validation from upstream PR #2644 rejects + DroneCAN numeric values outside the node-reported range while leaving string parameters intact. +- #3366 retains the latest non-empty SBS transponder squawk for a bounded 30-second interval. + #2758 changes DataFlash parameter browsing from a final-value dictionary to the complete + chronological `PARM` history while retaining final-value `.param` export. The functional part of + upstream PR #3735 adds Pixhawk 6C Windows USB MAVLink and SLCAN interfaces to both driver + architectures, with packaging authoring coverage. +- #2363 needs no port change: survey speed is already a `double` edited in 0.1 m/s increments and + emitted unchanged as `DO_CHANGE_SPEED`. The pre/post concurrent batch allocation described by + #2986 is also already represented by separate ISBH instance slots `0/1/3/4`, with an additional + bounds guard absent from the legacy viewer; the issue's two old Dropbox links now return HTML + rather than the logs, so no unverified spectral rewrite was made. +- A release-time GTU comparison found newer clean checkpoint `f196ea689`. Its NV5 signal semantics + are now mirrored: an unlocked receiver displays current channel RSSI and suppresses stale packet + RSSI/SNR; a locked receiver prefers packet RSSI and safely falls back to channel RSSI. SX127x and + LR11xx cases plus locked fallback are covered by regression tests. +- Round-3 local verification: Release solution build **0 warnings / 0 errors**, **1400/1400** + tests, all six migration/inventory checks pass (708/708 pinned source paths, 1623 native-manifest + rows and **0 blockers**), and all 28 active projects report no known vulnerable direct or + transitive NuGet package. Clean `linux-x64` TAR/DEB packages build, `lintian` emits no diagnostic, + payload assertions pass, and the extracted DEB reaches the normal Avalonia event loop during a + 12-second Xvfb smoke test. Native Windows and macOS packaging plus CodeQL remain mandatory CI + gates before merge and release. + ## GTU synchronization checkpoint - NV modem behavior was last compared with `/home/alex/src/AgroSky/GTU` at clean local and fetched - `origin/master` `6c2a4b04f03fa4e693d8e6adc2b39b734e817856`. The only `NV5Settings` - source/header/UI/test change after `98e98833` is committed GTU refinement - `77af510a47f8cbe7ea02fcc047019b07fb2c0c26`: selected-radio key targeting remains independent - of `DIVERSITY`, and **Revert selected** restores one staged parameter locally without sending - MAVLink. Both behaviors and their regression tests are ported. `REFRESH_SETTING` remains a - typed `UINT32`; NV5 key words remain signed `INT32` values preserving the same raw bytes. + `origin/master` `f196ea689ba0f30eb3b16a76bbf8041ee21a23de`. GTU refinement + `77af510a47f8cbe7ea02fcc047019b07fb2c0c26` keeps selected-radio key targeting independent of + `DIVERSITY`, and **Revert selected** restores one staged parameter locally without sending + MAVLink. Both behaviors and their regression tests are ported. Checkpoint `f196ea689` additionally + supplies current unlocked-channel RSSI semantics, now ported with matching tests. `REFRESH_SETTING` + remains a typed `UINT32`; NV5 key words remain signed `INT32` values preserving the same raw bytes. - Before each later NV modem change and before a release, recheck both committed and uncommitted GTU changes with `git status`, then compare every newer change to `hermes-gui/include/nv5settings.h`, `hermes-gui/src/nv5settings.cpp` and `hermes-gui/test/testnv5settings.cpp`. Update this commit and @@ -249,16 +288,18 @@ Updated: **2026-08-24**. ## Immediate next step -Build clean local Linux DEB/TAR artifacts, push `fix/upstream-issues-round-2`, and run its complete -CI/package and CodeQL gates in a draft PR. Merge only after those checks pass, then tag the merge -commit and verify the full 19-asset release. The remaining acceptance work still requires +Push `fix/upstream-issues-round-3` and run its complete CI/package and CodeQL gates in a PR. Merge +only after Linux, Windows, both macOS architectures and CodeQL pass; then repeat those gates on the +actual master merge commit. Only after the master checks and zero-alert API audit pass may the +master commit be tagged and its full 19-asset release verified. The remaining hardware acceptance +work still requires representative physical NV4/NV5 hardware: repeat UDP/TCP/UART switching, disconnect and key-programming checks, and recheck GTU `NV5Settings` changes newer than clean checkpoint -`6c2a4b04` before declaring hardware acceptance complete. +`f196ea689` before declaring hardware acceptance complete. ## Acceptance baseline -- At least 1379 port tests retained and passing. +- At least 1400 port tests retained and passing. - Clean Release build has zero errors and zero warnings. - `linux-x64`, `win-x64`, `osx-x64`, and `osx-arm64` publish gates pass. - Linux `.deb`/portable archive, Windows ZIP/MSI and both macOS ZIP/DMG pairs build and pass their diff --git a/Services/DataFlashLog.cs b/Services/DataFlashLog.cs index d6ee70f60e..63c24bf235 100644 --- a/Services/DataFlashLog.cs +++ b/Services/DataFlashLog.cs @@ -13,6 +13,15 @@ namespace MissionPlanner.Services; public sealed record DataFlashParameter(string Name, string Value, string DefaultValue); +public sealed record DataFlashParameterChange( + double TimeSeconds, string Name, string Value, string DefaultValue) { + public string TimeText => TimeSeconds.ToString("0.000", CultureInfo.InvariantCulture); +} + +public sealed record DataFlashParameterHistory( + IReadOnlyList Changes, + IReadOnlyList FinalValues); + public sealed record DataFlashMessage(double TimeSeconds, string Message) { public string TimeText => TimeSeconds.ToString("0.000", CultureInfo.InvariantCulture); } @@ -91,6 +100,11 @@ public static void ConvertBinToLog(string binPath, string outTextLogPath) { } public static IReadOnlyList ReadParameters(string path) { + return ReadParameterHistory(path).FinalValues; + } + + public static DataFlashParameterHistory ReadParameterHistory(string path) { + var changes = new List(); var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); using var log = new DFLogBuffer(path); foreach (var item in log.GetEnumeratorType("PARM")) { @@ -99,10 +113,15 @@ public static IReadOnlyList ReadParameters(string path) { if (name.Length == 0 || value.Length == 0) { continue; } - parameters[name] = new DataFlashParameter( - name, value, item["Default"]?.Trim() ?? ""); + string defaultValue = item["Default"]?.Trim() ?? ""; + changes.Add(new DataFlashParameterChange( + item.timems / 1000.0, name, value, defaultValue)); + parameters[name] = new DataFlashParameter(name, value, defaultValue); } - return parameters.Values.OrderBy(value => value.Name, StringComparer.OrdinalIgnoreCase).ToList(); + return new DataFlashParameterHistory( + changes, + parameters.Values.OrderBy( + value => value.Name, StringComparer.OrdinalIgnoreCase).ToList()); } public static IReadOnlyList ReadMessages(string path) { diff --git a/Services/ExternalAdsbReceiver.cs b/Services/ExternalAdsbReceiver.cs index 743058748a..69feed3c4d 100644 --- a/Services/ExternalAdsbReceiver.cs +++ b/Services/ExternalAdsbReceiver.cs @@ -411,12 +411,19 @@ private sealed class SbsState { internal string CallSign = ""; internal double Heading; internal double Speed; + internal ushort Squawk; + internal DateTime SquawkAtUtc = DateTime.MinValue; } + private static readonly TimeSpan _squawkLifetime = TimeSpan.FromSeconds(30); private static readonly object _modeSLock = new(); private static readonly FieldInfo? _groundSpeed = typeof(adsb.Plane).GetField("ground_speed", BindingFlags.Instance | BindingFlags.NonPublic); private readonly Dictionary _sbs = new(StringComparer.OrdinalIgnoreCase); + private readonly Func _utcNow; + + internal ExternalAdsbDecoder(Func? utcNow = null) => + _utcNow = utcNow ?? (() => DateTime.UtcNow); internal bool TryDecodeLine(string line, out adsb.PointLatLngAltHdg plane) { plane = null!; @@ -458,6 +465,13 @@ private bool TryDecodeSbs(string line, out adsb.PointLatLngAltHdg plane) { state = new SbsState(); _sbs[id] = state; } + DateTime now = _utcNow(); + if (fields.Length > 17 && !string.IsNullOrWhiteSpace(fields[17]) + && ushort.TryParse(fields[17], NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out ushort reportedSquawk)) { + state.Squawk = reportedSquawk; + state.SquawkAtUtc = now; + } switch (fields[1]) { case "1": case "5": @@ -487,10 +501,12 @@ private bool TryDecodeSbs(string line, out adsb.PointLatLngAltHdg plane) { CultureInfo.InvariantCulture, out double altitudeFeet); double.TryParse(fields[16], NumberStyles.Float, CultureInfo.InvariantCulture, out double verticalFeetPerMinute); - ushort.TryParse(fields[17], NumberStyles.HexNumber, CultureInfo.InvariantCulture, - out ushort squawk); + TimeSpan squawkAge = now - state.SquawkAtUtc; + ushort squawk = squawkAge >= TimeSpan.Zero && squawkAge <= _squawkLifetime + ? state.Squawk + : (ushort)0; plane = new adsb.PointLatLngAltHdg(lat, lng, altitudeFeet * 0.3048, - (float)state.Heading, state.Speed, id, DateTime.UtcNow) { + (float)state.Heading, state.Speed, id, now) { CallSign = state.CallSign, VerticalSpeed = verticalFeetPerMinute / 1.968, Squawk = squawk, diff --git a/ViewModels/FlightDataViewModel.cs b/ViewModels/FlightDataViewModel.cs index 28aa0db2d1..12edbf2612 100644 --- a/ViewModels/FlightDataViewModel.cs +++ b/ViewModels/FlightDataViewModel.cs @@ -2729,12 +2729,12 @@ await Services.Dialogs.Alert( "Format_SD_Card" => "Format the vehicle SD card now? All logs and other data on that card will be permanently erased.", "Do_Parachute" => - "Disable automatic parachute release? Manual parachute release remains available on the vehicle.", + "Release the parachute now and stop the motors? This action cannot be undone.", _ => $"Send the vehicle action {action}?", }; internal static MAVLink.PARACHUTE_ACTION ParachuteCommandAction => - MAVLink.PARACHUTE_ACTION.PARACHUTE_DISABLE; + MAVLink.PARACHUTE_ACTION.PARACHUTE_RELEASE; [Obsolete] private bool RunAction(string a) { diff --git a/ViewModels/FlightPlannerViewModel.cs b/ViewModels/FlightPlannerViewModel.cs index ad6ead7d05..b4e5c927f4 100644 --- a/ViewModels/FlightPlannerViewModel.cs +++ b/ViewModels/FlightPlannerViewModel.cs @@ -65,6 +65,7 @@ private void OnDisplayViewChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(RefreshDisplayView); private void OnConnectionChanged() => Dispatcher.UIThread.Post(() => { + OnPropertyChanged(nameof(ShowWpRadius)); OnPropertyChanged(nameof(ShowLoiterRadius)); OnPropertyChanged(nameof(VehicleFirmware)); }); @@ -716,10 +717,15 @@ public double AltWarnDisplay { public Firmwares VehicleFirmware => _comPort.MAV.cs.firmware; + public bool ShowWpRadius => SupportsGlobalWaypointRadius(VehicleFirmware); + public bool ShowLoiterRadius => SupportsGlobalLoiterRadius(VehicleFirmware); + internal static bool SupportsGlobalWaypointRadius(Firmwares firmware) => + firmware is not (Firmwares.ArduCopter2 or Firmwares.ArduRover); + internal static bool SupportsGlobalLoiterRadius(Firmwares firmware) => - firmware != Firmwares.ArduCopter2; + firmware is not (Firmwares.ArduCopter2 or Firmwares.ArduRover); [ObservableProperty] private bool _verifyHeight; @@ -1476,8 +1482,10 @@ void Set(string name, double value) { _comPort.setParam(name, (float)(value / CurrentState.multiplierdist)); } } - Set("WP_RADIUS", WpRadius); - Set("WP_RADIUS_M", WpRadius); + if (SupportsGlobalWaypointRadius(VehicleFirmware)) { + Set("WP_RADIUS", WpRadius); + Set("WP_RADIUS_M", WpRadius); + } if (SupportsGlobalLoiterRadius(VehicleFirmware)) { Set("WP_LOITER_RAD", LoiterRadius); Set("LOITER_RAD", LoiterRadius); diff --git a/ViewModels/GCSViews/ConfigurationView/ConfigDroneCanViewModel.cs b/ViewModels/GCSViews/ConfigurationView/ConfigDroneCanViewModel.cs index 1cbeaf7821..72b88613ed 100644 --- a/ViewModels/GCSViews/ConfigurationView/ConfigDroneCanViewModel.cs +++ b/ViewModels/GCSViews/ConfigurationView/ConfigDroneCanViewModel.cs @@ -992,6 +992,16 @@ internal static bool TryConvertParameterValue( } if (double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out double number)) { + if (double.TryParse(parameter.Min, NumberStyles.Any, CultureInfo.InvariantCulture, + out double minimum) && number < minimum) { + value = 0d; + return false; + } + if (double.TryParse(parameter.Max, NumberStyles.Any, CultureInfo.InvariantCulture, + out double maximum) && number > maximum) { + value = 0d; + return false; + } value = number; return true; } diff --git a/ViewModels/LogBrowseViewModel.cs b/ViewModels/LogBrowseViewModel.cs index 8304967809..67e4b1438e 100644 --- a/ViewModels/LogBrowseViewModel.cs +++ b/ViewModels/LogBrowseViewModel.cs @@ -224,6 +224,11 @@ public IReadOnlyList> ResolvePresetAlternatives(GraphP public IReadOnlyList ReadParameters() => CurrentPath == null ? [] : DataFlashLog.ReadParameters(CurrentPath); + public DataFlashParameterHistory ReadParameterHistory() => + CurrentPath == null + ? new DataFlashParameterHistory([], []) + : DataFlashLog.ReadParameterHistory(CurrentPath); + public IReadOnlyList ReadMessages() => CurrentPath == null ? [] : DataFlashLog.ReadMessages(CurrentPath); diff --git a/ViewModels/RawParamsViewModel.cs b/ViewModels/RawParamsViewModel.cs index c71e50a467..b7d7ec143e 100644 --- a/ViewModels/RawParamsViewModel.cs +++ b/ViewModels/RawParamsViewModel.cs @@ -335,7 +335,7 @@ public IReadOnlyList CompareParamFile(string path) { return []; } - var rows = BuildComparison(_all, fileParams); + var rows = BuildComparison(_all, fileParams, includeMissing: true); if (rows.Count == 0) { _ = Services.Dialogs.Alert("Compare parameters", $"{System.IO.Path.GetFileName(path)}: no differing matched parameters."); @@ -411,7 +411,7 @@ private async Task LoadFrameDefaultsAsync(bool forceRefresh) { Status = "Vehicle changed while the profile was loading; the old result was discarded."; return null; } - var comparison = CompareParamFile(path); + var comparison = BuildComparison(_all, ParamFile.loadParamFile(path), includeMissing: false); Status = comparison.Count == 0 ? $"{selected.Name}: no differing matched parameters." : $"{selected.Name}: choose which of {comparison.Count} differences to stage."; @@ -490,7 +490,7 @@ internal static ParamFileStageResult StageImportedValues( protectedCount++; continue; } - if (importedValue != row.CurrentValue) { + if (!ParameterValuesEqual(importedValue, row.CurrentValue, row.ParameterType)) { differing++; row.ValueText = importedValue.ToString(CultureInfo.InvariantCulture); } @@ -499,13 +499,26 @@ internal static ParamFileStageResult StageImportedValues( } internal static IReadOnlyList BuildComparison( - IEnumerable current, IReadOnlyDictionary imported) { + IEnumerable current, IReadOnlyDictionary imported, + bool includeMissing = true) { var rows = new List(); - foreach (var row in current) { - if (!IsProtectedFileParameter(row.Name) - && imported.TryGetValue(row.Name, out double importedValue) - && importedValue != row.CurrentValue) { - rows.Add(new ParamComparisonRow(row.Name, row.CurrentValue, importedValue)); + var currentByName = current + .Where(row => !IsProtectedFileParameter(row.Name)) + .ToDictionary(row => row.Name, StringComparer.OrdinalIgnoreCase); + foreach (var row in currentByName.Values) { + if (imported.TryGetValue(row.Name, out double importedValue)) { + if (!ParameterValuesEqual(importedValue, row.CurrentValue, row.ParameterType)) { + rows.Add(new ParamComparisonRow(row.Name, row.CurrentValue, importedValue)); + } + } else if (includeMissing) { + rows.Add(new ParamComparisonRow(row.Name, row.CurrentValue, null)); + } + } + if (includeMissing) { + foreach (var item in imported) { + if (!IsProtectedFileParameter(item.Key) && !currentByName.ContainsKey(item.Key)) { + rows.Add(new ParamComparisonRow(item.Key, null, item.Value)); + } } } return rows.OrderBy(row => row.Name, StringComparer.OrdinalIgnoreCase).ToList(); @@ -513,8 +526,9 @@ internal static IReadOnlyList BuildComparison( internal static int StageSelectedComparison( IEnumerable current, IEnumerable comparison) { - var selected = comparison.Where(row => row.Use) - .ToDictionary(row => row.Name, row => row.FileValue, StringComparer.OrdinalIgnoreCase); + var selected = comparison.Where(row => row.Use && row.FileValue.HasValue) + .ToDictionary( + row => row.Name, row => row.FileValue!.Value, StringComparer.OrdinalIgnoreCase); int staged = 0; foreach (var row in current) { if (!IsProtectedFileParameter(row.Name) @@ -544,6 +558,28 @@ internal static bool TryStageFrameDefaultComparison( internal static bool IsProtectedFileParameter(string name) => ProtectedFileParameters.Contains(name); + internal static bool ParameterValuesEqual( + double left, double right, MAVLink.MAV_PARAM_TYPE? parameterType = null) { + if (double.IsNaN(left) || double.IsNaN(right)) { + return double.IsNaN(left) && double.IsNaN(right); + } + if (left == right) { + return true; + } + if (parameterType is MAVLink.MAV_PARAM_TYPE.UINT8 or MAVLink.MAV_PARAM_TYPE.INT8 + or MAVLink.MAV_PARAM_TYPE.UINT16 or MAVLink.MAV_PARAM_TYPE.INT16 + or MAVLink.MAV_PARAM_TYPE.UINT32 or MAVLink.MAV_PARAM_TYPE.INT32 + or MAVLink.MAV_PARAM_TYPE.UINT64 or MAVLink.MAV_PARAM_TYPE.INT64) { + return false; + } + + // .NET's float.Epsilon is the smallest subnormal value, not C's FLT_EPSILON. + // Classic MAVLink parameters travel as REAL32, so values that differ by no more than one + // machine epsilon are the same setting and must not be offered as a dangerous file change. + const double floatMachineEpsilon = 1.1920928955078125e-7; + return Math.Abs(left - right) <= floatMachineEpsilon; + } + public void PersistFavs() { var favs = _all.Where(r => r.Fav).Select(r => r.Name).ToList(); Settings.Instance.SetList("fav_params", favs); @@ -554,7 +590,8 @@ private void LoadFromMav() { var favs = Settings.Instance.GetList("fav_params").ToHashSet(); var snapshot = _comPort.MAV.param.ToArray(); - var rows = snapshot.Select(p => BuildRow(p.Name, p.Value, p.default_value, fw, favs)).ToList(); + var rows = snapshot.Select(p => + BuildRow(p.Name, p.Value, p.default_value, fw, favs, p.TypeAP)).ToList(); LoadFrom(rows); } @@ -674,7 +711,9 @@ public void Dispose() { CancelSafely(cancellation); } - private ParamRow BuildRow(string name, double value, double? def, string fw, HashSet favs) { + private ParamRow BuildRow( + string name, double value, double? def, string fw, HashSet favs, + MAVLink.MAV_PARAM_TYPE? parameterType = null) { string units = Meta(name, ParameterMetaDataConstants.Units, fw); string range = Meta(name, ParameterMetaDataConstants.Range, fw); string values = Meta(name, ParameterMetaDataConstants.Values, fw); @@ -689,7 +728,9 @@ private ParamRow BuildRow(string name, double value, double? def, string fw, Has } - return new ParamRow(name, value, def, units, opts, desc, min, max) { Fav = favs.Contains(name) }; + return new ParamRow(name, value, def, units, opts, desc, min, max, parameterType) { + Fav = favs.Contains(name), + }; } private ParamRow MakeRow(string name, double value, double? def) { @@ -805,21 +846,30 @@ public override string ToString() { } public partial class ParamComparisonRow : ObservableObject, IParameterComparisonRow { - public ParamComparisonRow(string name, double currentValue, double fileValue) { + public ParamComparisonRow(string name, double? currentValue, double? fileValue) { Name = name; CurrentValue = currentValue; FileValue = fileValue; + _use = CanApply; } public string Name { get; } - public double CurrentValue { get; } - public double FileValue { get; } - public string CurrentText => CurrentValue.ToString(CultureInfo.InvariantCulture); - public string FileText => FileValue.ToString(CultureInfo.InvariantCulture); + public double? CurrentValue { get; } + public double? FileValue { get; } + public bool CanApply => CurrentValue.HasValue && FileValue.HasValue; + public string CurrentText => CurrentValue.HasValue + ? CurrentValue.Value.ToString(CultureInfo.InvariantCulture) + : "Not found"; + public string FileText => FileValue.HasValue + ? FileValue.Value.ToString(CultureInfo.InvariantCulture) + : "Not found"; public string ProposedText => FileText; - [ObservableProperty] - private bool _use = true; + private bool _use; + public bool Use { + get => _use; + set => SetProperty(ref _use, CanApply && value); + } } public partial class ParamRow : ObservableObject { @@ -831,7 +881,8 @@ public ParamRow( string options, string description, double min, - double max + double max, + MAVLink.MAV_PARAM_TYPE? parameterType = null ) { Name = name; _currentValue = current; @@ -842,6 +893,7 @@ double max Description = description; Min = min; Max = max; + ParameterType = parameterType; var us = name.IndexOf('_'); Prefix = us > 0 ? name.Substring(0, us) : name; } @@ -853,6 +905,7 @@ double max public string Description { get; } public double Min { get; } public double Max { get; } + public MAVLink.MAV_PARAM_TYPE? ParameterType { get; } public double? DefaultValue { get; } public string DefaultText => @@ -870,13 +923,15 @@ double max public bool IsDirty { get { if (double.TryParse(ValueText, NumberStyles.Any, CultureInfo.InvariantCulture, out var v)) { - return v != CurrentValue; + return !RawParamsViewModel.ParameterValuesEqual(v, CurrentValue, ParameterType); } return ValueText != CurrentValue.ToString(CultureInfo.InvariantCulture); } } - public bool IsNonDefault => DefaultValue.HasValue && DefaultValue.Value != CurrentValue; + public bool IsNonDefault => DefaultValue.HasValue + && !RawParamsViewModel.ParameterValuesEqual( + DefaultValue.Value, CurrentValue, ParameterType); partial void OnValueTextChanged(string value) => OnPropertyChanged(nameof(IsDirty)); diff --git a/ViewModels/Setup/NvModemViewModel.cs b/ViewModels/Setup/NvModemViewModel.cs index 3eabe80624..b27ded80dc 100644 --- a/ViewModels/Setup/NvModemViewModel.cs +++ b/ViewModels/Setup/NvModemViewModel.cs @@ -1928,13 +1928,14 @@ private void UpdateRadioRows(NvModemDeviceState device) { var row = RadioStatuses[channel - 1]; bool receives = status.Role != 1; - bool rssiValid = receives && (status.Flags & (1 << 6)) != 0 - && status.PacketRssiDbmX10 != short.MinValue; - bool snrValid = receives && (status.Flags & (1 << 7)) != 0 - && status.PacketSnrDbX10 != short.MinValue; bool locked = receives && (status.Flags & (1 << 2)) != 0; - string rssi = receives ? rssiValid ? $"{status.PacketRssiDbmX10 / 10.0:F1}" : "—" : "n/a"; - string snr = receives ? snrValid ? $"{status.PacketSnrDbX10 / 10.0:F1}" : "—" : "n/a"; + short? currentRssi = receives ? CurrentNv5Rssi(status, locked) : null; + short? currentSnr = receives && locked && status.PacketSnrDbX10 != short.MinValue + ? status.PacketSnrDbX10 : null; + string rssi = receives ? currentRssi is { } rssiValue + ? $"{rssiValue / 10.0:F1}" : "—" : "n/a"; + string snr = receives ? currentSnr is { } snrValue + ? $"{snrValue / 10.0:F1}" : "—" : "n/a"; string quality = receives ? $"{status.LinkQuality}%" : "n/a"; double sample = Math.Max(1, status.SampleMs); double txKbps = status.TxRadioBytes * 8 / sample; @@ -1958,6 +1959,18 @@ private void UpdateRadioRows(NvModemDeviceState device) { } } + private static short? CurrentNv5Rssi(Nv5LinkStatusMessage status, bool locked) { + if (!locked) { + return status.ChannelRssiDbmX10 == short.MinValue + ? null : status.ChannelRssiDbmX10; + } + if (status.PacketRssiDbmX10 != short.MinValue) { + return status.PacketRssiDbmX10; + } + return status.ChannelRssiDbmX10 == short.MinValue + ? null : status.ChannelRssiDbmX10; + } + private void ResetRadioRows() { foreach (NvRadioStatusRow row in RadioStatuses) { row.Identity = "—"; row.Mode = "—"; row.Link = "—"; row.Traffic = "—"; diff --git a/Views/LogBrowseView.axaml.cs b/Views/LogBrowseView.axaml.cs index f103e4db5b..4b4a03a905 100644 --- a/Views/LogBrowseView.axaml.cs +++ b/Views/LogBrowseView.axaml.cs @@ -291,10 +291,11 @@ private async void OnParameters(object? sender, RoutedEventArgs e) { vm.Busy = true; vm.Status = "Reading log parameters…"; try { - var parameters = await Task.Run(vm.ReadParameters); - LogMetadataWindow.ShowParameters(owner, parameters, + DataFlashParameterHistory history = await Task.Run(vm.ReadParameterHistory); + LogMetadataWindow.ShowParameters(owner, history, Path.GetFileNameWithoutExtension(path) + ".param"); - vm.Status = $"PARM: {parameters.Count} final parameter value(s)."; + vm.Status = $"PARM: {history.Changes.Count} change record(s), " + + $"{history.FinalValues.Count} final parameter value(s)."; } catch (Exception ex) { vm.Status = "Parameter read failed: " + ex.Message; } finally { diff --git a/Views/LogMetadataWindow.cs b/Views/LogMetadataWindow.cs index cb7a369cc5..61e2766ee6 100644 --- a/Views/LogMetadataWindow.cs +++ b/Views/LogMetadataWindow.cs @@ -37,11 +37,12 @@ internal static void ShowMessages(Window owner, IReadOnlyList } internal static void ShowParameters( - Window owner, IReadOnlyList parameters, string suggestedName) { - var table = Table(parameters); - table.Columns.Add(TextColumn("Parameter", nameof(DataFlashParameter.Name), null)); - table.Columns.Add(TextColumn("Value", nameof(DataFlashParameter.Value), 180)); - table.Columns.Add(TextColumn("Default", nameof(DataFlashParameter.DefaultValue), 180)); + Window owner, DataFlashParameterHistory history, string suggestedName) { + var table = Table(history.Changes); + table.Columns.Add(TextColumn("Time (s)", nameof(DataFlashParameterChange.TimeText), 100)); + table.Columns.Add(TextColumn("Parameter", nameof(DataFlashParameterChange.Name), null)); + table.Columns.Add(TextColumn("Value", nameof(DataFlashParameterChange.Value), 160)); + table.Columns.Add(TextColumn("Default", nameof(DataFlashParameterChange.DefaultValue), 160)); var save = new Button { Content = "Save .param…" }; var close = new Button { Content = "Close" }; var buttons = new StackPanel { @@ -50,7 +51,8 @@ internal static void ShowParameters( HorizontalAlignment = HorizontalAlignment.Right, Children = { save, close }, }; - var window = new LogMetadataWindow($"Log Parameters ({parameters.Count})", table, buttons); + var window = new LogMetadataWindow( + $"Log Parameter Changes ({history.Changes.Count})", table, buttons); close.Click += (_, _) => window.Close(); save.Click += async (_, _) => { var file = await window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { @@ -62,7 +64,7 @@ internal static void ShowParameters( ], }); if (file?.TryGetLocalPath() is { } path) { - DataFlashLog.ExportParameters(parameters, path); + DataFlashLog.ExportParameters(history.FinalValues, path); } }; window.Show(owner);