diff --git a/CHANGELIST.md b/CHANGELIST.md index 616c1a2ca..6d1649405 100644 --- a/CHANGELIST.md +++ b/CHANGELIST.md @@ -8,6 +8,9 @@ - Be more verbose about program path resolution - Use updated version of ResolvePath from IO - Make program support sets more obvious +- Detect and list USB floppy drives on Linux (gmipf) +- Fix media type dropdown not updating dumping parameters (gmipf) +- Fix media type sticking when switching drives (gmipf) ### 3.8.2 (2026-07-01) diff --git a/MPF.Avalonia/Windows/MainWindow.axaml b/MPF.Avalonia/Windows/MainWindow.axaml index b617e5707..76645ce5c 100644 --- a/MPF.Avalonia/Windows/MainWindow.axaml +++ b/MPF.Avalonia/Windows/MainWindow.axaml @@ -122,7 +122,7 @@ diff --git a/MPF.Frontend.Test/DriveTests.cs b/MPF.Frontend.Test/DriveTests.cs index 3d673583b..b64e2d32a 100644 --- a/MPF.Frontend.Test/DriveTests.cs +++ b/MPF.Frontend.Test/DriveTests.cs @@ -107,6 +107,107 @@ public void EnumerateUnixOpticalBlockPaths_OnlyMatchesSrFollowedByDigits() #endregion + #region EnumerateUnixFloppyBlockPaths + + [Fact] + public void EnumerateUnixFloppyBlockPaths_NullOrEmpty_ReturnsEmpty() + { + Assert.Empty(Drive.EnumerateUnixFloppyBlockPaths(null!)); + Assert.Empty(Drive.EnumerateUnixFloppyBlockPaths(string.Empty)); + } + + [Fact] + public void EnumerateUnixFloppyBlockPaths_MissingDirectory_ReturnsEmpty() + { + string missing = Path.Combine(Path.GetTempPath(), "mpf-test-missing-" + Guid.NewGuid().ToString("N")); + Assert.Empty(Drive.EnumerateUnixFloppyBlockPaths(missing)); + } + + [Fact] + public void EnumerateUnixFloppyBlockPaths_OnlyMatchesFdFollowedByDigits() + { + string root = Path.Combine(Path.GetTempPath(), "mpf-test-dev-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + File.WriteAllText(Path.Combine(root, "fd0"), string.Empty); + File.WriteAllText(Path.Combine(root, "fd1"), string.Empty); + File.WriteAllText(Path.Combine(root, "fd7"), string.Empty); + File.WriteAllText(Path.Combine(root, "fd0u1440"), string.Empty); // format-specific node, skipped + File.WriteAllText(Path.Combine(root, "fda"), string.Empty); // name not all-digits, skipped + File.WriteAllText(Path.Combine(root, "fd"), string.Empty); // no trailing digits, skipped + File.WriteAllText(Path.Combine(root, "sdb"), string.Empty); // unrelated device, skipped + + var actual = Drive.EnumerateUnixFloppyBlockPaths(root); + + var actualNames = new List(); + foreach (var p in actual) + { + actualNames.Add(Path.GetFileName(p)); + } + actualNames.Sort(StringComparer.Ordinal); + + Assert.Equal(new[] { "fd0", "fd1", "fd7" }, actualNames); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + #endregion + + #region EnumerateUnixUsbFloppyBlockPaths + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_NullOrEmpty_ReturnsEmpty() + { + Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths(null!, "/dev")); + Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths(string.Empty, "/dev")); + Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths("/sys/block", null!)); + Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths("/sys/block", string.Empty)); + } + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_MissingDirectory_ReturnsEmpty() + { + string missing = Path.Combine(Path.GetTempPath(), "mpf-test-missing-" + Guid.NewGuid().ToString("N")); + Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths(missing, "/dev")); + } + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_OnlyMatchesRemovableFloppySizedDisks() + { + string sysfs = Path.Combine(Path.GetTempPath(), "mpf-test-sysblock-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(sysfs); + try + { + WriteSysBlockDevice(sysfs, "sdh", removable: "1", sizeSectors: "2880"); // USB floppy, 1.44 MB, matched + WriteSysBlockDevice(sysfs, "sdi", removable: "1\n", sizeSectors: "1440\n"); // USB floppy, 720 KB (trailing newline), matched + WriteSysBlockDevice(sysfs, "sda", removable: "0", sizeSectors: "2880"); // fixed disk of floppy size, not removable, skipped + WriteSysBlockDevice(sysfs, "sdb", removable: "1", sizeSectors: "30310400"); // USB flash drive, not a floppy size, skipped + WriteSysBlockDevice(sysfs, "sdc", removable: "1", sizeSectors: "0"); // empty floppy drive (no media), skipped + WriteSysBlockDevice(sysfs, "nvme0n1", removable: "1", sizeSectors: "2880"); // not an sd* device, not scanned + + var actual = Drive.EnumerateUnixUsbFloppyBlockPaths(sysfs, "/dev"); + + var actualNames = new List(); + foreach (var p in actual) + { + actualNames.Add(Path.GetFileName(p)); + } + actualNames.Sort(StringComparer.Ordinal); + + Assert.Equal(new[] { "sdh", "sdi" }, actualNames); + } + finally + { + Directory.Delete(sysfs, recursive: true); + } + } + + #endregion + #region EnumerateUnixOpticalGenericPaths [Fact] diff --git a/MPF.Frontend/Drive.Linux.cs b/MPF.Frontend/Drive.Linux.cs index 7b53de3b3..3b772b5f2 100644 --- a/MPF.Frontend/Drive.Linux.cs +++ b/MPF.Frontend/Drive.Linux.cs @@ -11,15 +11,15 @@ public partial class Drive #region Fields /// - /// sysfs block-device name prefixes that are not user-facing dump targets: loopback - /// images, ramdisks, device-mapper and software RAID virtual devices, floppy and - /// network block devices, ZFS volumes, and optical drives (surfaced separately via - /// the optical enumerator). + /// sysfs block-device name prefixes that the fixed-drive enumerator does not surface: + /// loopback images, ramdisks, device-mapper and software RAID virtual devices, network + /// block devices, ZFS volumes, and the floppy and optical drives (each surfaced + /// separately by its own enumerator rather than as a fixed or removable disk). /// private static readonly string[] _excludedUnixBlockPrefixes = [ "dm-", // device-mapper (LVM/LUKS, etc.) virtual devices - "fd", // floppy disk + "fd", // floppy drive (surfaced separately by the floppy enumerator) "loop", // loopback-mounted image "md", // software RAID (mdadm) "nbd", // network block device @@ -29,6 +29,21 @@ public partial class Drive "zram", // compressed ramdisk ]; + /// + /// Floppy media sizes in bytes for the standard 5.25" and 3.5" PC formats + /// (360 KB, 720 KB, 1.2 MB, 1.44 MB, 2.88 MB). A removable SCSI disk reporting one of + /// these exact capacities is a USB floppy with media inserted; no other removable + /// storage uses these sizes, so this doubles as the floppy identity check. + /// + private static readonly HashSet _unixFloppyMediaSizes = new HashSet + { + 368640, // 360 KB (5.25" DD) + 737280, // 720 KB (3.5" DD) + 1228800, // 1.2 MB (5.25" HD) + 1474560, // 1.44 MB (3.5" HD) + 2949120, // 2.88 MB (3.5" ED) + }; + #endregion #region Linux Helpers @@ -96,6 +111,71 @@ private static Drive[] AppendUnixOpticalDrives(Drive[] drives) return combined; } + /// + /// Append Linux floppy drives that DriveInfo did not surface. Two kinds are covered: + /// legacy on-board floppy nodes (/dev/fd0, /dev/fd1, ...) and USB floppy drives, which + /// the kernel exposes as ordinary SCSI disks (/dev/sd*). Neither is ever a mount point, + /// so DriveInfo does not list them; they are enumerated directly so users can dump disks + /// without mounting them first. Like optical drives, floppy drives are surfaced + /// regardless of the fixed-drive toggle, mirroring how Windows tags floppy media + /// (Win32_LogicalDisk.MediaType) in MarkWindowsFloppyDrives. + /// + /// Drives already discovered via DriveInfo + /// The drive array, extended with any floppy drives not already present + private static Drive[] AppendUnixFloppyDrives(Drive[] drives) + { + // Defensive: this reads Linux /dev paths, so never run it off-Unix + if (Environment.OSVersion.Platform != PlatformID.Unix) + return drives; + + var existingNames = new HashSet(); + foreach (var d in drives) + { + if (d?.Name is not null) + existingNames.Add(d.Name); + } + + // Legacy /dev/fd* nodes plus USB floppy drives exposed as SCSI disks (/dev/sd*) + var devicePaths = new List(); + devicePaths.AddRange(EnumerateUnixFloppyBlockPaths("/dev")); + devicePaths.AddRange(EnumerateUnixUsbFloppyBlockPaths("/sys/block", "/dev")); + + var extra = new List(); + foreach (var devicePath in devicePaths) + { + // Skip paths already surfaced by DriveInfo or an earlier enumerator + if (!existingNames.Add(devicePath)) + continue; + + try + { + var d = Create(Frontend.InternalDriveType.Floppy, devicePath); + if (d != null) + { + // A Linux floppy /dev node is never a mount point, so DriveInfo always + // reports it as not-ready. Mark it active like optical drives; whether a + // disk is actually inserted is left to the dumping program rather than + // probing the device here, since floppy media state is not reliably + // exposed through sysfs. + d.MarkedActive = true; + extra.Add(d); + } + } + catch + { + // Skip devices that can't be opened + } + } + + if (extra.Count == 0) + return drives; + + var combined = new Drive[drives.Length + extra.Count]; + Array.Copy(drives, combined, drives.Length); + extra.CopyTo(combined, drives.Length); + return combined; + } + /// /// Enumerate Linux optical block devices under a directory by matching /// the kernel convention "sr" followed by one or more digits (sr0, sr1, ...). @@ -127,6 +207,82 @@ internal static List EnumerateUnixOpticalBlockPaths(string devRoot) return result; } + /// + /// Enumerate Linux floppy block devices under a directory by matching the kernel + /// convention "fd" followed by one or more digits (fd0, fd1, ...). This deliberately + /// rejects the "/dev/fd" symlink (no trailing digits) and format-specific nodes such + /// as "fd0u1440" (embedded non-digit characters), which are not whole-device targets. + /// + /// Root directory to scan (typically "/dev") + /// Device paths, or an empty list when the directory is unreadable + internal static List EnumerateUnixFloppyBlockPaths(string devRoot) + { + var result = new List(); + if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) + return result; + + string[] candidates; + try + { + candidates = Directory.GetFiles(devRoot, "fd*"); + } + catch + { + return result; + } + + foreach (var path in candidates) + { + if (HasDeviceIndexSuffix(Path.GetFileName(path), "fd")) + result.Add(path); + } + + return result; + } + + /// + /// Enumerate USB floppy drives, which the kernel exposes as ordinary SCSI disks + /// (/dev/sd*) rather than /dev/fd* nodes. A disk is treated as a floppy when it reports + /// itself removable and its media is one of the standard floppy sizes. This mirrors how + /// Windows identifies a floppy from the inserted medium (Win32_LogicalDisk.MediaType) + /// and, like that path, only recognizes the drive while floppy media is present. + /// + /// sysfs block directory (typically "/sys/block") + /// Root directory device nodes live under (typically "/dev") + /// Device paths, or an empty list when the directory is unreadable + internal static List EnumerateUnixUsbFloppyBlockPaths(string sysBlockRoot, string devRoot) + { + var result = new List(); + if (string.IsNullOrEmpty(sysBlockRoot) || string.IsNullOrEmpty(devRoot)) + return result; + if (!Directory.Exists(sysBlockRoot)) + return result; + + string[] entries; + try + { + entries = Directory.GetFileSystemEntries(sysBlockRoot, "sd*"); + } + catch + { + return result; + } + + foreach (var entry in entries) + { + // A floppy reports itself removable and its media is a standard floppy size; + // both come from world-readable sysfs, so no elevated privileges are needed. + if (!ReadUnixRemovableFlag(entry)) + continue; + if (!_unixFloppyMediaSizes.Contains(ReadUnixBlockDeviceSize(entry))) + continue; + + result.Add(Path.Combine(devRoot, Path.GetFileName(entry))); + } + + return result; + } + /// /// Enumerate Linux optical drives via their generic SCSI nodes (/dev/sg*). /// Unlike the block nodes, "sg" is not optical-specific, so each candidate is diff --git a/MPF.Frontend/Drive.cs b/MPF.Frontend/Drive.cs index 2abc9b93a..840b15c0b 100644 --- a/MPF.Frontend/Drive.cs +++ b/MPF.Frontend/Drive.cs @@ -322,9 +322,18 @@ private static List GetDriveList(bool ignoreFixedDrives) } else if (isUnix) { + // Optical drives are removable dump targets, so surface them regardless of the + // fixed-drive toggle. Floppy drives are hidden unless the user opts to show + // fixed drives, matching Windows (where floppies enumerate as removable media + // that is only queried alongside fixed drives) and because the default program, + // Redumper, does not support them. Floppy runs before the fixed enumerator so a + // USB floppy is tagged Floppy and then skipped there. drives = AppendUnixOpticalDrives(drives); if (!ignoreFixedDrives) + { + drives = AppendUnixFloppyDrives(drives); drives = AppendUnixFixedDrives(drives); + } } return [.. drives]; diff --git a/MPF.Frontend/ViewModels/MainViewModel.cs b/MPF.Frontend/ViewModels/MainViewModel.cs index 9f666fb43..fc1993360 100644 --- a/MPF.Frontend/ViewModels/MainViewModel.cs +++ b/MPF.Frontend/ViewModels/MainViewModel.cs @@ -1217,6 +1217,12 @@ private void CacheCurrentMediaType() if (CurrentDrive is null) return; + // Forget the type detected for any previously selected drive; only a fresh + // successful detection below sets a new one. Without this reset a type like + // FloppyDisk would stick when switching to a drive whose media cannot be read + // (e.g. an empty optical drive), overriding the correct system default. + _detectedPhysicalMediaType = null; + // Get reasonable default values based on the current system var mediaTypes = CurrentSystem.MediaTypes(); PhysicalMediaType? defaultPhysicalMediaType = mediaTypes.Count > 0 ? mediaTypes[0] : PhysicalMediaType.CDROM; diff --git a/MPF.UI/Windows/MainWindow.xaml b/MPF.UI/Windows/MainWindow.xaml index e74bccc3c..e0def587f 100644 --- a/MPF.UI/Windows/MainWindow.xaml +++ b/MPF.UI/Windows/MainWindow.xaml @@ -209,7 +209,7 @@