From 4e05a65944e80f4dc428eb8d3f222940933156b4 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:47:14 +0000 Subject: [PATCH 1/9] Plan value explanations for drives and accessories the inventory Mac lacked --- PLAN.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..073dcbd --- /dev/null +++ b/PLAN.md @@ -0,0 +1,51 @@ +# Plan: explain the values of drives and accessories the inventory Mac didn't have + +`docs/value-inventory.md` comes from one Apple silicon Mac with no connected +Bluetooth accessories, no Serial ATA drive, no disc drive, and no card in its +card reader. Every limited-set field that Mac reported now has a value rule, +but these sections only get the general data-type text, even though they are +common on other Macs: + +- **Bluetooth accessories**: the accessory type, battery levels, and the + services an accessory or the controller supports. Most Macs have a paired + keyboard, mouse, trackpad, or headphones. +- **Serial ATA**: every Intel iMac, Mac mini, and older MacBook reports its + drive here, along with the same volume fields the Storage section has. +- **Card readers**: a card in the slot is reported with the same drive and + volume fields. +- **Disc burning**: Macs with a built-in or USB optical drive. + +Spellings come from published output: the macOS samples in + (`resources/macos/system_profiler`, +the XML form, whose keys and values are what `-json` reports), and code that +reads `system_profiler SPBluetoothDataType -json`, such as the Toothpick +extension in . A value no source shows +is marked unconfirmed, and anything else is shown as not yet explained. + +The list of values that need a scan was in the previous `PLAN.md`, which was +removed, so `docs/value-explanations.md` points at a file that no longer +exists. This branch moves that list into the docs. + +## Steps + +1. [x] Write this plan. +2. [ ] Apply the drive and volume rules (SMART, partition map, file system, + writable, free space, partition content, removable and detachable) to Serial + ATA drives and to cards in a card reader, and explain `Windows_FAT_32`. +3. [ ] Serial ATA: medium type, physical interconnect, negotiated and port + link speed, and Native Command Queuing. +4. [ ] Disc burning: support level, media in the drive, DVD reading, + interconnect, writable CD and DVD formats, and burn strategies. +5. [ ] Bluetooth accessories: accessory type, battery levels (main, left, + right, case), and supported services for accessories and the controller. +6. [ ] Docs: list every new value in `docs/value-explanations.md` with its + source and spelling, mark the spellings the published samples confirm, and + move the list of values that need a scan into that file. +7. [ ] Remove this plan. + +## Verification + +There is no Xcode here, so each pushed commit is built and tested by the CI +`build-and-test` job. Each step adds its values to `ValueCatalogTests`, which +checks that every listed value has every part of an explanation, plus tests for +the values that change the status. From 05a083c41d808f6ae8aa4b6c3f9b7bf46552a7a0 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:48:17 +0000 Subject: [PATCH 2/9] Explain Serial ATA drives and memory cards like other drives Serial ATA drives and cards in a card reader report the same drive and volume fields as NVMe drives and the Storage section, but only got the general section text. SMART status, partition map, file system, writable, partition content, and removable and detachable media are now explained there too, and Windows_FAT_32, the partition type of a FAT32 memory card, is new. The spellings come from the published macOS samples in glpi-agent. Free space stays on the Storage section, so a shortage isn't reported twice for the same volume. --- PLAN.md | 2 +- .../Values/PowerAndStorageValueRules.swift | 18 ++++-- .../SettingsAndSoftwareValueRules.swift | 15 ++++- .../ValueCatalogTests.swift | 64 ++++++++++++++++++- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/PLAN.md b/PLAN.md index 073dcbd..a7c61a8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -29,7 +29,7 @@ exists. This branch moves that list into the docs. ## Steps 1. [x] Write this plan. -2. [ ] Apply the drive and volume rules (SMART, partition map, file system, +2. [x] Apply the drive and volume rules (SMART, partition map, file system, writable, free space, partition content, removable and detachable) to Serial ATA drives and to cards in a card reader, and explain `Windows_FAT_32`. 3. [ ] Serial ATA: medium type, physical interconnect, negotiated and port diff --git a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift index dbd9d15..83082df 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift @@ -560,15 +560,21 @@ private func scheduledPowerEventExplanation(_ value: String) -> ValueExplanation // system facts come from the Disk Utility User Guide // (https://support.apple.com/guide/disk-utility/dsku19ed921c) and the Signed System // Volume from https://support.apple.com/guide/security/secd698747c9. +// +// Serial ATA drives and cards in a card reader report the same drive and volume fields. +// The macOS samples in https://github.com/glpi-project/glpi-agent +// (resources/macos/system_profiler) show a Serial ATA drive as Verified with +// guid_partition_map_type and Journaled HFS+ and MS-DOS FAT32 volumes, and an SD card as +// Not Supported with master_boot_record_partition_map_type and an MS-DOS FAT32 volume. let lowFreeSpaceFraction: Double = 0.10 let storageValueRules: [ValueRule] = [ - ValueRule(.storage, .nvme, .serialATA, field: "smart_status") { context in + ValueRule(.storage, .nvme, .serialATA, .cardReader, field: "smart_status") { context in smartStatusExplanation(context.reportedValue) }, - ValueRule(.storage, field: "writable") { context in + ValueRule(.storage, .serialATA, .cardReader, field: "writable") { context in switch decodeBooleanLike(context.reportedValue) { case true?: return .normal( @@ -679,11 +685,11 @@ let storageValueRules: [ValueRule] = [ } }, - ValueRule(.storage, field: "file_system") { context in + ValueRule(.storage, .serialATA, .cardReader, field: "file_system") { context in fileSystemExplanation(context.reportedValue) }, - ValueRule(.storage, .nvme, field: "partition_map_type") { context in + ValueRule(.storage, .nvme, .serialATA, .cardReader, field: "partition_map_type") { context in partitionMapExplanation(context.reportedValue) } ] @@ -847,7 +853,7 @@ let storageConnectionValueRules: [ValueRule] = [ } }, - ValueRule(.nvme, field: "removable_media") { context in + ValueRule(.nvme, .serialATA, .cardReader, field: "removable_media") { context in switch decodeBooleanLike(context.reportedValue) { case true?: .info( @@ -870,7 +876,7 @@ let storageConnectionValueRules: [ValueRule] = [ } }, - ValueRule(.nvme, field: "detachable_drive") { context in + ValueRule(.nvme, .serialATA, .cardReader, field: "detachable_drive") { context in switch decodeBooleanLike(context.reportedValue) { case true?: .info( diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift index db3a63a..ccae524 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift @@ -371,7 +371,10 @@ private func accessibilityFeatureRules(_ features: [(field: String, whenOn: Stri // Sources: spnvme_trim_support and spsata_trim_support are keys in Apple's SPNVMeReporter // and SPSerialATAReporter strings; Yes is seen in docs/value-inventory.md. The iocontent // values Apple_APFS, Apple_APFS_ISC, and Apple_APFS_Recovery are seen in the inventory; -// the other partition types are the names `diskutil list` shows, and are unconfirmed in +// EFI, Apple_HFS, Apple_Boot, and Apple_CoreStorage (Serial ATA volumes) and +// Windows_FAT_32 (a card in a card reader) appear in the macOS samples in +// https://github.com/glpi-project/glpi-agent (resources/macos/system_profiler). The other +// partition types are the names `diskutil list` shows, and are unconfirmed in // system_profiler output. The Apple silicon containers are described in Apple Platform // Security ("Boot process for a Mac with Apple silicon"). @@ -384,7 +387,7 @@ let nvmeValueRules: [ValueRule] = [ trimExplanation(context.reportedValue) }, - ValueRule(.nvme, field: "iocontent") { context in + ValueRule(.nvme, .serialATA, .cardReader, field: "iocontent") { context in partitionContentExplanation(context.reportedValue) } ] @@ -470,6 +473,14 @@ private func partitionContentExplanation(_ value: String) -> ValueExplanation? { action: "Nothing to do.", confidence: .observed ) + case "Windows_FAT_32": + .info( + "A FAT32 partition, the format most memory cards and older USB drives come with.", + detail: "The partition type marks a FAT32 volume on a disk that uses the older Master Boot Record layout.", + why: "Cameras, Windows PCs, and Macs can all read and write it, but it can't hold a file of 4 GB or more.", + action: "Nothing to do. Eject the card or drive before removing it.", + confidence: .observed + ) case "Microsoft Basic Data": .info( "A partition formatted for Windows or for sharing, such as exFAT, FAT32, or NTFS.", diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index 93e4609..84313d9 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -51,7 +51,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples + networkValueSamples + softwareHistoryAndFirewallValueSamples + wifiValueSamples + powerValueSamples + storageValueSamples + startupAndOverviewValueSamples - + hardwareValueSamples + settingsValueSamples + + hardwareValueSamples + settingsValueSamples + driveAndCardValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -343,6 +343,32 @@ private let storageValueSamples: [ValueSample] = { return samples }() +/// Serial ATA drives and cards in a card reader report the same drive and volume +/// fields as NVMe drives and the Storage section. The values are the ones in the +/// published macOS samples listed in docs/value-explanations.md. +private let driveAndCardValueSamples: [ValueSample] = { + let drive: [String] = ["_items", "[]"] + let volume: [String] = drive + ["volumes", "[]"] + var samples: [ValueSample] = [] + + for dataType in [SystemProfilerDataType.serialATA, .cardReader] { + samples += ["Verified", "Failing", "Not Supported"].map { ValueSample(dataType, drive + ["smart_status"], $0) } + samples += ["guid_partition_map_type", "master_boot_record_partition_map_type"].map { + ValueSample(dataType, drive + ["partition_map_type"], $0) + } + for field in ["removable_media", "detachable_drive"] { + samples += ["yes", "no"].map { ValueSample(dataType, drive + [field], $0) } + } + samples += ["Journaled HFS+", "MS-DOS FAT32", "ExFAT", "APFS"].map { ValueSample(dataType, volume + ["file_system"], $0) } + samples += ["yes", "no"].map { ValueSample(dataType, volume + ["writable"], $0) } + samples += ["Apple_APFS", "EFI", "Apple_HFS", "Apple_Boot", "Apple_CoreStorage", "Windows_FAT_32"].map { + ValueSample(dataType, volume + ["iocontent"], $0) + } + } + + return samples +}() + private let startupAndOverviewValueSamples: [ValueSample] = { var samples: [ValueSample] = [ "Full Security", "Reduced Security", "Permissive Security", "Medium Security", "No Security" @@ -702,3 +728,39 @@ struct ValueCatalogTests { #expect(processorFamily(inHardwareItems: [.object(["_name": .string("hardware_overview")])]) == nil) } } + +/// Values from drives, cards, disc drives, and Bluetooth accessories that the +/// inventory Mac didn't have. +struct DriveAndAccessoryValueTests { + // MARK: - Serial ATA drives and cards + + @Test + func aMemoryCardIsExplainedLikeAnyOtherDrive() throws { + let card: [String] = ["_items", "[]"] + let volume: [String] = card + ["volumes", "[]"] + + let smart = try #require(valueExplanation(dataType: .cardReader, path: card + ["smart_status"], scalar: .string("Not Supported"))) + let content = try #require(valueExplanation(dataType: .cardReader, path: volume + ["iocontent"], scalar: .string("Windows_FAT_32"))) + let format = try #require(valueExplanation(dataType: .cardReader, path: volume + ["file_system"], scalar: .string("MS-DOS FAT32"))) + + #expect(smart.status == .informational) + #expect(content.summary.contains("FAT32")) + #expect(format.summary.contains("4 GB")) + #expect(valueExplanation(dataType: .cardReader, path: card + ["removable_media"], scalar: .string("yes"))?.summary.contains("memory card") == true) + } + + @Test + func aFailingSerialATADriveIsWorthALook() { + let drive: [String] = ["_items", "[]"] + + #expect(valueExplanation(dataType: .serialATA, path: drive + ["smart_status"], scalar: .string("Failing"))?.status == .worthReviewing) + #expect(valueExplanation(dataType: .serialATA, path: drive + ["smart_status"], scalar: .string("Verified"))?.status == .normal) + } + + @Test + func anUnknownPartitionTypeOnASerialATADriveIsNotGuessed() { + let volume: [String] = ["_items", "[]", "volumes", "[]"] + + #expect(valueExplanation(dataType: .serialATA, path: volume + ["iocontent"], scalar: .string("Linux_Swap"))?.status == .unknown) + } +} From a039e76ceef11dbd0420234b1b748c025077ffe4 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:50:10 +0000 Subject: [PATCH 3/9] Explain Serial ATA medium, connection, link speed, and NCQ values Covers the drive's medium type, the controller's physical interconnect, the port and negotiated SATA link speeds, Native Command Queuing, and the PCI Express link speed and width Apple's SSD controller reports. A link slower than its port is explained as information, with what it means for an SSD. PCI as the interconnect is marked as an inference, with its reasons. --- PLAN.md | 10 +- .../Values/PowerAndStorageValueRules.swift | 205 ++++++++++++++++++ .../Values/ValueExplanation.swift | 2 +- .../ValueCatalogTests.swift | 35 +++ 4 files changed, 247 insertions(+), 5 deletions(-) diff --git a/PLAN.md b/PLAN.md index a7c61a8..1b18b2b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -30,10 +30,12 @@ exists. This branch moves that list into the docs. 1. [x] Write this plan. 2. [x] Apply the drive and volume rules (SMART, partition map, file system, - writable, free space, partition content, removable and detachable) to Serial - ATA drives and to cards in a card reader, and explain `Windows_FAT_32`. -3. [ ] Serial ATA: medium type, physical interconnect, negotiated and port - link speed, and Native Command Queuing. + writable, partition content, removable and detachable) to Serial ATA drives + and to cards in a card reader, and explain `Windows_FAT_32`. Free space + stays on the Storage section, so a shortage isn't reported twice. +3. [x] Serial ATA: medium type, physical interconnect, negotiated and port + link speed, Native Command Queuing, and the PCI Express link of Apple's SSD + controller. 4. [ ] Disc burning: support level, media in the drive, DVD reading, interconnect, writable CD and DVD formats, and burn strategies. 5. [ ] Bluetooth accessories: accessory type, battery levels (main, left, diff --git a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift index 83082df..ef1189a 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift @@ -900,6 +900,211 @@ let storageConnectionValueRules: [ValueRule] = [ } ] +// MARK: - Serial ATA + +// Sources: the macOS samples in https://github.com/glpi-project/glpi-agent +// (resources/macos/system_profiler) show spsata_medium_type (Rotational, Solid State), +// spsata_physical_interconnect (SATA, PCI), spsata_negotiatedlinkspeed and +// spsata_portspeed (3 Gigabit), spsata_ncq (Yes, No), and, for Apple's SSD controller, +// spsata_linkspeed (5.0 GT/s) and spsata_linkwidth (x2). 1.5 and 6 Gigabit, the other +// SATA generations, and the other PCI Express rates are unconfirmed in system_profiler +// output. Speeds follow the SATA-IO and PCI-SIG specifications. + +let serialATAValueRules: [ValueRule] = [ + ValueRule(.serialATA, field: "spsata_medium_type") { context in + switch context.reportedValue.lowercased() { + case "solid state": + .info( + "A solid-state drive (flash storage, no moving parts).", + detail: "The drive stores data on flash memory chips.", + why: "SSDs are much faster than hard drives, and they have no moving parts to wear out.", + action: "Nothing to do.", + confidence: .observed + ) + case "rotational": + .info( + "A spinning hard drive.", + detail: "The drive stores data on spinning magnetic disks, as the internal drives of many older iMacs and Mac minis do.", + why: "Hard drives are much slower than SSDs, and their moving parts wear out over time.", + action: "Nothing to do. Keep backups current, especially as the drive ages.", + confidence: .observed + ) + default: + nil + } + }, + + ValueRule(.serialATA, field: "spsata_physical_interconnect") { context in + switch context.reportedValue.uppercased() { + case "SATA": + .info( + "The controller connects its drives over Serial ATA (SATA).", + detail: "SATA is the connection the internal hard drives, SSDs, and optical drives of Intel Macs use.", + why: "SATA tops out at 6 Gb/s, slower than the PCI Express and NVMe storage in newer Macs.", + action: "Nothing to do.", + confidence: .observed + ) + case "PCI": + .info( + "The controller connects over PCI Express, as the built-in flash storage of some Intel Macs does.", + detail: "The SSD uses the same commands as a SATA drive (AHCI), but it's wired to the Mac over PCI Express instead of a SATA cable.", + why: "That's why a fast SSD appears under Serial ATA. It isn't limited to SATA speeds.", + action: "Nothing to do.", + confidence: .likely(reasons: [ + "In a published sample, Apple's SSD Controller reports PCI here, together with a PCI Express link speed and width.", + "Its port description names AHCI, the standard interface for SATA controllers." + ]) + ) + default: + nil + } + }, + + ValueRule(.serialATA, field: "spsata_portspeed") { context in + sataLinkSpeed(context.reportedValue).map { speed in + .info( + "This port supports up to \(speed.label) (\(speed.generation)).", + detail: "It's the fastest speed the port can run. The drive decides whether it runs that fast.", + why: "A drive can't be faster than its port. Hard drives rarely need more than 3 Gb/s, but SSDs can use 6 Gb/s.", + action: "Nothing to do.", + confidence: .observed + ) + } + }, + + ValueRule(.serialATA, field: "spsata_negotiatedlinkspeed") { context in + guard let negotiated = sataLinkSpeed(context.reportedValue) else { + return nil + } + + if let port = context.sibling("spsata_portspeed").flatMap(sataLinkSpeed), negotiated.gigabits < port.gigabits { + return .info( + "The link runs at \(negotiated.label), slower than the \(port.label) the port supports.", + detail: "The drive and port agreed on the slower speed. Older drives and optical drives often support only that speed, and a worn cable can force it too.", + why: "An SSD on a slower link can't reach its full speed. Hard drives and optical drives are rarely held back by it.", + action: "Nothing to do for a hard drive or optical drive. For an SSD, check that it supports \(port.label).", + confidence: .observed + ) + } + + return .normal( + "The link runs at \(negotiated.label) (\(negotiated.generation)).", + detail: "This is the speed the drive and port agreed on when the drive connected.", + why: "It's the most the connection can carry. The drive itself may be slower.", + action: "Nothing to do.", + confidence: .observed + ) + }, + + ValueRule(.serialATA, field: "spsata_ncq") { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .normal( + "The drive supports Native Command Queuing (NCQ).", + detail: "The drive can accept several requests at once and reorder them to finish sooner.", + why: "It helps the drive keep up when apps read and write many small files.", + action: "Nothing to do.", + confidence: .observed + ) + case false?: + .info( + "The drive doesn't use Native Command Queuing (NCQ).", + detail: "The drive handles one request at a time. That's usual for optical drives, some older drives, and virtual machine disks.", + why: "Busy workloads can be a little slower without it. It doesn't mean anything is wrong.", + action: "Nothing to do.", + confidence: .observed + ) + case nil: + nil + } + }, + + ValueRule(.serialATA, field: "spsata_linkspeed") { context in + pciExpressGeneration(context.reportedValue).map { generation in + .info( + "The controller's PCI Express link runs at \(context.reportedValue) per lane (\(generation)).", + detail: "This is the speed of each lane between the storage controller and the Mac.", + why: "Together with the number of lanes, it sets the most the storage can transfer.", + action: "Nothing to do.", + confidence: .observed + ) + } + }, + + ValueRule(.serialATA, field: "spsata_linkwidth") { context in + pciExpressLaneCount(context.reportedValue).map { lanes in + .info( + lanes == 1 + ? "The controller uses one PCI Express lane." + : "The controller uses \(lanes) PCI Express lanes.", + detail: "Each lane carries data separately, so more lanes allow faster transfers.", + why: "Together with the link speed, it sets the most the storage can transfer.", + action: "Nothing to do.", + confidence: .observed + ) + } + } +] + +private struct SATALinkSpeed { + let gigabits: Double + let label: String + let generation: String +} + +/// Reads speeds such as `3 Gigabit`. Some locales write `1,5 Gigabit`. +private func sataLinkSpeed(_ value: String) -> SATALinkSpeed? { + let parts: [Substring] = value.split(separator: " ") + + guard parts.count == 2, + parts[1].lowercased() == "gigabit", + let gigabits = Double(parts[0].replacingOccurrences(of: ",", with: ".")) else { + return nil + } + + switch gigabits { + case 1.5: + return SATALinkSpeed(gigabits: gigabits, label: "1.5 Gb/s", generation: "SATA I") + case 3: + return SATALinkSpeed(gigabits: gigabits, label: "3 Gb/s", generation: "SATA II") + case 6: + return SATALinkSpeed(gigabits: gigabits, label: "6 Gb/s", generation: "SATA III") + default: + return nil + } +} + +/// Names the PCI Express generation of a per-lane rate such as `5.0 GT/s`. +func pciExpressGeneration(_ value: String) -> String? { + let parts: [Substring] = value.split(separator: " ") + + guard parts.count == 2, + parts[1] == "GT/s", + let rate = Double(parts[0].replacingOccurrences(of: ",", with: ".")) else { + return nil + } + + switch rate { + case 2.5: return "PCI Express 1" + case 5: return "PCI Express 2" + case 8: return "PCI Express 3" + case 16: return "PCI Express 4" + case 32: return "PCI Express 5" + default: return nil + } +} + +/// Reads a lane count such as `x2`. +func pciExpressLaneCount(_ value: String) -> Int? { + let trimmed: String = value.trimmingCharacters(in: .whitespaces).lowercased() + + guard trimmed.hasPrefix("x"), let lanes = Int(trimmed.dropFirst()), [1, 2, 4, 8, 16].contains(lanes) else { + return nil + } + + return lanes +} + /// Explains the connection a storage device reports, such as `Apple Fabric` or `USB`. func storageProtocolExplanation(_ value: String) -> ValueExplanation? { let external: String = "External drives should be ejected before they're unplugged." diff --git a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift index 5bc7f8f..38e19bd 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift @@ -326,7 +326,7 @@ private let valueRuleIndex: [ValueRuleKey: [ValueRule]] = { + displayValueRules + audioValueRules + thunderboltValueRules + legacySoftwareValueRules + syncServicesValueRules + syncServicesSummaryValueRules + internationalValueRules + accessibilityValueRules + nvmeValueRules + configurationProfileValueRules + printerValueRules + extensionValueRules - + vendorIdentifierValueRules + thirdWaveValueRules + + vendorIdentifierValueRules + thirdWaveValueRules + serialATAValueRules var index: [ValueRuleKey: [ValueRule]] = [:] for rule in rules { diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index 84313d9..9a3866c 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -366,6 +366,16 @@ private let driveAndCardValueSamples: [ValueSample] = { } } + let port: [String: ProfileValue] = ["spsata_portspeed": .string("6 Gigabit")] + samples += ["Rotational", "Solid State"].map { ValueSample(.serialATA, drive + ["spsata_medium_type"], $0) } + samples += ["Yes", "No"].map { ValueSample(.serialATA, drive + ["spsata_ncq"], $0) } + samples += ["SATA", "PCI"].map { ValueSample(.serialATA, ["spsata_physical_interconnect"], $0) } + samples += ["1.5 Gigabit", "3 Gigabit", "6 Gigabit", "1,5 Gigabit"].map { ValueSample(.serialATA, ["spsata_portspeed"], $0) } + samples += ["3 Gigabit", "6 Gigabit"].map { ValueSample(.serialATA, ["spsata_negotiatedlinkspeed"], $0, siblings: port) } + samples.append(ValueSample(.serialATA, ["spsata_negotiatedlinkspeed"], "3 Gigabit")) + samples += ["2.5 GT/s", "5.0 GT/s", "8.0 GT/s"].map { ValueSample(.serialATA, ["spsata_linkspeed"], $0) } + samples += ["x1", "x2", "x4"].map { ValueSample(.serialATA, ["spsata_linkwidth"], $0) } + return samples }() @@ -757,6 +767,31 @@ struct DriveAndAccessoryValueTests { #expect(valueExplanation(dataType: .serialATA, path: drive + ["smart_status"], scalar: .string("Verified"))?.status == .normal) } + @Test + func aSATALinkSlowerThanItsPortIsPointedOut() throws { + func explain(_ negotiated: String, port: String?) -> ValueExplanation? { + let siblings: [String: ProfileValue] = port.map { ["spsata_portspeed": ProfileValue.string($0)] } ?? [:] + return valueExplanation(dataType: .serialATA, path: ["spsata_negotiatedlinkspeed"], scalar: .string(negotiated), siblings: siblings) + } + + let slower = try #require(explain("3 Gigabit", port: "6 Gigabit")) + #expect(slower.status == .informational) + #expect(slower.summary.contains("slower")) + #expect(explain("3 Gigabit", port: "3 Gigabit")?.status == .normal) + #expect(explain("6 Gigabit", port: nil)?.summary.contains("SATA III") == true) + #expect(explain("12 Gigabit", port: nil)?.status == .unknown) + } + + @Test + func applesPCIExpressSSDControllerIsMarkedAsAnInference() throws { + let pci = try #require(valueExplanation(dataType: .serialATA, path: ["spsata_physical_interconnect"], scalar: .string("PCI"))) + + #expect(pci.confidence?.reasons.isEmpty == false) + #expect(valueExplanation(dataType: .serialATA, path: ["spsata_linkspeed"], scalar: .string("5.0 GT/s"))?.summary.contains("PCI Express 2") == true) + #expect(valueExplanation(dataType: .serialATA, path: ["spsata_linkwidth"], scalar: .string("x2"))?.summary.contains("2 PCI Express lanes") == true) + #expect(valueExplanation(dataType: .serialATA, path: ["spsata_linkwidth"], scalar: .string("wide"))?.status == .unknown) + } + @Test func anUnknownPartitionTypeOnASerialATADriveIsNotGuessed() { let volume: [String] = ["_items", "[]", "volumes", "[]"] From b5039df0f4312e72defeb0a0b35913b2f27f9517 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:51:36 +0000 Subject: [PATCH 4/9] Explain disc drive support, media, connection, and write formats Covers how fully macOS supports burning with the drive, an empty drive, DVD reading, the drive's connection, the CD and DVD formats it can write, and its burn strategies. Format and strategy lists are spelled out, and a list with an unfamiliar entry is shown as not yet explained rather than partly explained. The support levels and connections other than the published ones are Disc Recording constants whose spelling in system_profiler output is unconfirmed. Adds englishList so lists in explanations read the same in every system language. --- PLAN.md | 2 +- .../Values/DisplayAndMediaValueRules.swift | 213 ++++++++++++++++++ .../Values/ValueExplanation.swift | 17 +- .../ValueCatalogTests.swift | 42 +++- 4 files changed, 271 insertions(+), 3 deletions(-) diff --git a/PLAN.md b/PLAN.md index 1b18b2b..2350152 100644 --- a/PLAN.md +++ b/PLAN.md @@ -36,7 +36,7 @@ exists. This branch moves that list into the docs. 3. [x] Serial ATA: medium type, physical interconnect, negotiated and port link speed, Native Command Queuing, and the PCI Express link of Apple's SSD controller. -4. [ ] Disc burning: support level, media in the drive, DVD reading, +4. [x] Disc burning: support level, media in the drive, DVD reading, interconnect, writable CD and DVD formats, and burn strategies. 5. [ ] Bluetooth accessories: accessory type, battery levels (main, left, right, case), and supported services for accessories and the controller. diff --git a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift index a940d28..e1cc7bc 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift @@ -886,3 +886,216 @@ private func cardReaderLinkExplanation(_ value: String) -> ValueExplanation? { ]) ) } + +// MARK: - Disc burning + +// Sources: the macOS samples in https://github.com/glpi-project/glpi-agent +// (resources/macos/system_profiler) show burn_support (DRDeviceSupportLevelAppleShipping), +// device_media (media_none), device_readdvd (yes), interconnect (ATAPI), device_cdwrite +// (-R, -RW), device_dvdwrite (-R, -R DL, -RW, +R, +R DL, +RW), and device_strategies +// (CD-TAO, CD-SAO, CD-Raw, DVD-DAO). The other support levels and interconnects are +// constants of Apple's Disc Recording framework (DRDeviceSupportLevel… and +// DRDevicePhysicalInterconnect…), listed in +// https://developer.apple.com/library/archive/releasenotes/General/APIDiffsMacOSX10_10_3/modules/DiscRecording.html, +// and their exact spelling in system_profiler output is unconfirmed. + +let discBurningValueRules: [ValueRule] = [ + ValueRule(.discBurning, field: "burn_support") { context in + discSupportLevelExplanation(context.reportedValue) + }, + + ValueRule(.discBurning, field: "device_media") { context in + switch context.reportedValue { + case "media_none": + .info( + "There was no disc in the drive when the scan ran.", + detail: "The drive is empty.", + why: "Details about a disc appear here only while one is inserted.", + action: "Nothing to do.", + confidence: .observed + ) + default: + nil + } + }, + + ValueRule(.discBurning, field: "device_readdvd") { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "The drive can read DVDs as well as CDs.", + detail: "It can play and copy from DVD discs.", + why: "A CD-only drive can't read DVDs at all.", + action: "Nothing to do.", + confidence: .observed + ) + case false?: + .info( + "The drive can't read DVDs, only CDs.", + detail: "It's a CD-only drive.", + why: "DVD discs won't work in it.", + action: "Nothing to do. Use a DVD drive for DVDs.", + confidence: .observed + ) + case nil: + nil + } + }, + + ValueRule(.discBurning, field: "interconnect") { context in + discInterconnectExplanation(context.reportedValue) + }, + + ValueRule(.discBurning, field: "device_cdwrite") { context in + discWriteFormats(context.reportedValue, disc: "CD").map { formats in + .info( + "The drive can write \(formats).", + detail: "R discs can be written once. RW discs can be erased and written again.", + why: "It tells you which blank CDs to buy for this drive.", + action: "Nothing to do.", + confidence: .observed + ) + } + }, + + ValueRule(.discBurning, field: "device_dvdwrite") { context in + discWriteFormats(context.reportedValue, disc: "DVD").map { formats in + .info( + "The drive can write \(formats).", + detail: "R discs can be written once and RW discs erased and written again. DL means dual-layer, which holds about twice as much. The minus and plus formats are two competing standards.", + why: "It tells you which blank DVDs to buy for this drive.", + action: "Nothing to do.", + confidence: .observed + ) + } + }, + + ValueRule(.discBurning, field: "device_strategies") { context in + discBurnStrategies(context.reportedValue).map { strategies in + .info( + "Ways the drive can write a disc: \(strategies).", + detail: "Burning apps pick one of these. Writing a whole disc in one pass makes audio CDs without gaps between tracks.", + why: "It only matters if a burning app asks you to choose how to write.", + action: "Nothing to do.", + confidence: .observed + ) + } + } +] + +private func discSupportLevelExplanation(_ value: String) -> ValueExplanation? { + let why: String = "It decides whether the Finder and Music can burn discs with this drive." + + return switch value { + case "DRDeviceSupportLevelAppleShipping": + .normal( + "A drive Apple shipped in its Macs, so macOS fully supports burning with it.", + detail: "Apple's Disc Recording framework recognizes it as a drive Apple shipped.", + why: why, + action: "Nothing to do.", + confidence: .documented + ) + case "DRDeviceSupportLevelAppleSupported": + .normal( + "macOS supports burning with this drive.", + detail: "Apple's Disc Recording framework supports the drive, though Apple didn't ship it in a Mac.", + why: why, + action: "Nothing to do.", + confidence: .documented + ) + case "DRDeviceSupportLevelVendorSupported": + .info( + "Burning with this drive is supported by software from the drive's maker.", + detail: "Apple's Disc Recording framework uses support the drive's maker provides.", + why: why, + action: "Nothing to do. If burning fails, check the drive maker's site for a macOS update.", + confidence: .documented + ) + case "DRDeviceSupportLevelUnsupported": + .info( + "macOS doesn't support burning with this drive. It may still read discs.", + detail: "Apple's Disc Recording framework recognizes the drive but doesn't support writing with it.", + why: why, + action: "If you need to burn discs, use a drive macOS supports or the drive maker's software.", + confidence: .documented + ) + case "DRDeviceSupportLevelNone": + .info( + "The drive can't burn discs on this Mac.", + detail: "Apple's Disc Recording framework has no burning support for it, often because it's a read-only drive.", + why: why, + action: "Nothing to do unless you need to burn discs.", + confidence: .documented + ) + default: + nil + } +} + +private func discInterconnectExplanation(_ value: String) -> ValueExplanation? { + let summary: String + let detail: String + + switch value.uppercased() { + case "ATAPI": + summary = "A drive built into the Mac, connected over ATAPI." + detail = "ATAPI is how internal optical drives connect, over an ATA or SATA link." + case "USB": + summary = "An external drive connected over USB, such as Apple's USB SuperDrive." + detail = "The drive is connected by a USB cable." + case "FIREWIRE": + summary = "An external drive connected over FireWire." + detail = "FireWire was common on Macs before Thunderbolt." + case "SCSI": + summary = "A drive connected over SCSI, an older connection." + detail = "SCSI drives are rare on current Macs and usually need an adapter." + default: + return nil + } + + return .info( + summary, + detail: detail, + why: "It shows whether the drive is inside the Mac or plugged in, which helps if the drive stops appearing.", + action: "Nothing to do. If an external drive doesn't appear, connect it directly to the Mac.", + confidence: .documented + ) +} + +/// Turns a format list such as `-R, -R DL, -RW, +R` into words, or nil when a format is unfamiliar. +private func discWriteFormats(_ value: String, disc: String) -> String? { + let known: Set = ["-R", "-RW", "+R", "+RW", "-R DL", "+R DL", "-RAM", "+RW DL"] + let formats: [String] = value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + guard !formats.isEmpty, formats.allSatisfy({ known.contains($0) }) else { + return nil + } + + return englishList(formats.map { "\(disc)\($0)" }) +} + +/// Names each burn strategy in a list such as `CD-TAO, CD-SAO, CD-Raw, DVD-DAO`, or nil +/// when one is unfamiliar. +private func discBurnStrategies(_ value: String) -> String? { + let names: [String: String] = [ + "CD-TAO": "CD track at once", + "CD-SAO": "CD session at once", + "CD-Raw": "CD raw mode", + "DVD-DAO": "DVD disc at once", + "BD-DAO": "Blu-ray disc at once" + ] + let strategies: [String] = value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + let named: [String] = strategies.compactMap { names[$0] } + + guard !strategies.isEmpty, named.count == strategies.count else { + return nil + } + + return englishList(named) +} diff --git a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift index 38e19bd..1005e38 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift @@ -326,7 +326,7 @@ private let valueRuleIndex: [ValueRuleKey: [ValueRule]] = { + displayValueRules + audioValueRules + thunderboltValueRules + legacySoftwareValueRules + syncServicesValueRules + syncServicesSummaryValueRules + internationalValueRules + accessibilityValueRules + nvmeValueRules + configurationProfileValueRules + printerValueRules + extensionValueRules - + vendorIdentifierValueRules + thirdWaveValueRules + serialATAValueRules + + vendorIdentifierValueRules + thirdWaveValueRules + serialATAValueRules + discBurningValueRules var index: [ValueRuleKey: [ValueRule]] = [:] for rule in rules { @@ -434,6 +434,21 @@ func leadingInteger(_ value: String) -> Int? { return Int(digits) } +/// Joins items as an English list: “a”, “a and b”, or “a, b, and c”. Explanations are +/// written in English, so the list doesn't follow the system language. +func englishList(_ items: [String]) -> String { + switch items.count { + case 0: + return "" + case 1: + return items[0] + case 2: + return "\(items[0]) and \(items[1])" + default: + return items.dropLast().joined(separator: ", ") + ", and " + (items.last ?? "") + } +} + func formattedByteCount(_ bytes: Int64) -> String { ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) } diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index 9a3866c..9cd0bfb 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -51,7 +51,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples + networkValueSamples + softwareHistoryAndFirewallValueSamples + wifiValueSamples + powerValueSamples + storageValueSamples + startupAndOverviewValueSamples - + hardwareValueSamples + settingsValueSamples + driveAndCardValueSamples + + hardwareValueSamples + settingsValueSamples + driveAndCardValueSamples + discDriveValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -379,6 +379,21 @@ private let driveAndCardValueSamples: [ValueSample] = { return samples }() +private let discDriveValueSamples: [ValueSample] = { + var samples: [ValueSample] = [ + "DRDeviceSupportLevelAppleShipping", "DRDeviceSupportLevelAppleSupported", "DRDeviceSupportLevelVendorSupported", + "DRDeviceSupportLevelUnsupported", "DRDeviceSupportLevelNone" + ].map { ValueSample(.discBurning, ["burn_support"], $0) } + + samples.append(ValueSample(.discBurning, ["device_media"], "media_none")) + samples += ["yes", "no"].map { ValueSample(.discBurning, ["device_readdvd"], $0) } + samples += ["ATAPI", "USB", "FireWire", "SCSI"].map { ValueSample(.discBurning, ["interconnect"], $0) } + samples += ["-R, -RW", "-R"].map { ValueSample(.discBurning, ["device_cdwrite"], $0) } + samples += ["-R, -R DL, -RW, +R, +R DL, +RW", "-R, -RAM"].map { ValueSample(.discBurning, ["device_dvdwrite"], $0) } + samples += ["CD-TAO, CD-SAO, CD-Raw, DVD-DAO", "CD-TAO"].map { ValueSample(.discBurning, ["device_strategies"], $0) } + return samples +}() + private let startupAndOverviewValueSamples: [ValueSample] = { var samples: [ValueSample] = [ "Full Security", "Reduced Security", "Permissive Security", "Medium Security", "No Security" @@ -792,6 +807,31 @@ struct DriveAndAccessoryValueTests { #expect(valueExplanation(dataType: .serialATA, path: ["spsata_linkwidth"], scalar: .string("wide"))?.status == .unknown) } + // MARK: - Disc drives + + @Test + func discFormatListsAreSpelledOut() throws { + let dvd = try #require(valueExplanation(dataType: .discBurning, path: ["device_dvdwrite"], scalar: .string("-R, -R DL, +RW"))) + let cd = try #require(valueExplanation(dataType: .discBurning, path: ["device_cdwrite"], scalar: .string("-R, -RW"))) + + #expect(dvd.summary == "The drive can write DVD-R, DVD-R DL, and DVD+RW.") + #expect(cd.summary == "The drive can write CD-R and CD-RW.") + #expect(valueExplanation(dataType: .discBurning, path: ["device_dvdwrite"], scalar: .string("-R, +HD"))?.status == .unknown) + #expect(valueExplanation(dataType: .discBurning, path: ["device_strategies"], scalar: .string("CD-TAO, HD-DAO"))?.status == .unknown) + } + + @Test + func aDriveMacOSCantBurnWithIsInformation() { + func status(_ value: String) -> ValueStatus? { + valueExplanation(dataType: .discBurning, path: ["burn_support"], scalar: .string(value))?.status + } + + #expect(status("DRDeviceSupportLevelAppleShipping") == .normal) + #expect(status("DRDeviceSupportLevelUnsupported") == .informational) + #expect(status("DRDeviceSupportLevelSomethingElse") == .unknown) + #expect(valueExplanation(dataType: .discBurning, path: ["device_media"], scalar: .string("media_cdr"))?.status == .unknown) + } + @Test func anUnknownPartitionTypeOnASerialATADriveIsNotGuessed() { let volume: [String] = ["_items", "[]", "volumes", "[]"] From 41f2a0335b6190b0c6483daf7f5ae403def127f2 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:53:14 +0000 Subject: [PATCH 5/9] Explain Bluetooth accessory types, battery levels, and services Most Macs have a paired keyboard, mouse, trackpad, or headphones, but the inventory Mac had none connected, so these values had no explanation. Covers the accessory type, the battery level of the accessory, each earbud, and the charging case (nearly empty is worth a look, low is information), and the services an accessory or this Mac's Bluetooth supports. A service the app doesn't know is named as such, and AACP is marked as an inference because Apple doesn't document it. Adds short field explanations for the battery and service fields. --- PLAN.md | 2 +- .../Explanations/NetworkExplanations.swift | 16 ++ .../Values/NetworkValueRules.swift | 187 ++++++++++++++++++ .../Values/ValueExplanation.swift | 1 + .../ValueCatalogTests.swift | 60 ++++++ 5 files changed, 265 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 2350152..0efa1cf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -38,7 +38,7 @@ exists. This branch moves that list into the docs. controller. 4. [x] Disc burning: support level, media in the drive, DVD reading, interconnect, writable CD and DVD formats, and burn strategies. -5. [ ] Bluetooth accessories: accessory type, battery levels (main, left, +5. [x] Bluetooth accessories: accessory type, battery levels (main, left, right, case), and supported services for accessories and the controller. 6. [ ] Docs: list every new value in `docs/value-explanations.md` with its source and spelling, mark the spellings the published samples confirm, and diff --git a/SystemProfilerExplorer/Core/Explanations/NetworkExplanations.swift b/SystemProfilerExplorer/Core/Explanations/NetworkExplanations.swift index aec1df9..cb189e6 100644 --- a/SystemProfilerExplorer/Core/Explanations/NetworkExplanations.swift +++ b/SystemProfilerExplorer/Core/Explanations/NetworkExplanations.swift @@ -306,6 +306,22 @@ func bluetoothExplanation(path: [String], reportedValue: String) -> FieldExplana interpretation: "A recorded address shows the accessory was paired at some point. It doesn't show when it last connected, and privacy features can make the address it uses over the air differ.", privacy: "This address can persistently identify the accessory and link it to its owner. Redact it from public reports." ) + case "device_batteryLevelMain", "device_batteryLevelLeft", "device_batteryLevelRight", "device_batteryLevelCase": + FieldExplanation( + title: "Accessory Battery", + meaning: "This is the battery level a connected accessory reported, for the accessory itself or for one earbud or the charging case.", + significance: "It shows which part needs charging before it stops working.", + interpretation: "The level is a snapshot from when the scan ran. Accessories that aren't connected don't report one.", + privacy: nil + ) + case "device_services": + FieldExplanation( + title: "Accessory Services", + meaning: "These are the Bluetooth services (profiles) the accessory supports, such as stereo audio or keyboard input.", + significance: "They show what the accessory can do with this Mac.", + interpretation: "A listed service is supported, not necessarily in use.", + privacy: nil + ) case "device_caseVersion": FieldExplanation( title: "Charging Case Firmware", diff --git a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift index f8fd729..7a2aa1c 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift @@ -1126,6 +1126,193 @@ private func bluetoothVendorExplanation(_ value: String) -> ValueExplanation? { return vendorExplanation(value, kind: .bluetooth, reportedName: reportedName) } +// MARK: - Bluetooth accessories + +// Sources: system_profiler lists accessories under device_connected and +// device_not_connected, each with device_minorType, and connected ones with +// device_batteryLevelMain, _Left, _Right, and _Case as percentages such as "85%". Code +// that reads `system_profiler SPBluetoothDataType -json` shows these keys and the types +// Keyboard, Mouse, Headphones, Speaker, and Gamepad (the Toothpick extension in +// https://github.com/raycast/extensions, extensions/toothpick/src/core/devices) and +// Headset (https://github.com/yigegongjiang/jj-ice); Trackpad appears in older +// system_profiler output read by https://github.com/matryer/xbar-plugins +// (System/Battery/trackpad-system_profiler.1m.rb). A published accessory reports +// device_services as "0x400000 < BLE >" (https://github.com/raycast/extensions/issues/5860). +// The other service names are standard Bluetooth profile abbreviations; AACP is Apple's +// own and isn't documented. + +let bluetoothAccessoryValueRules: [ValueRule] = [ + ValueRule(.bluetooth, field: "device_minorType") { context in + bluetoothAccessoryTypeExplanation(context.reportedValue) + } +] + bluetoothBatteryParts.map { part -> ValueRule in + ValueRule(.bluetooth, field: part.field) { context in + bluetoothBatteryExplanation(context.reportedValue, part: part.name) + } +} + [ + ValueRule(.bluetooth, field: "device_services") { context in + bluetoothServicesExplanation(context.reportedValue, owner: "This accessory supports") + }, + + ValueRule(.bluetooth, field: "controller_supportedServices") { context in + bluetoothServicesExplanation(context.reportedValue, owner: "This Mac's Bluetooth supports") + } +] + +private let bluetoothBatteryParts: [(field: String, name: String)] = [ + ("device_batteryLevelMain", "The accessory's battery"), + ("device_batteryLevelLeft", "The left earbud's battery"), + ("device_batteryLevelRight", "The right earbud's battery"), + ("device_batteryLevelCase", "The charging case's battery") +] + +private let lowAccessoryBatteryPercent: Int = 20 + +private func bluetoothAccessoryTypeExplanation(_ value: String) -> ValueExplanation? { + let summary: String + let why: String + + switch value.lowercased() { + case "headphones": + summary = "Headphones or earbuds, such as AirPods." + why = "They can play this Mac's sound, and those with a microphone can be used for calls." + case "headset": + summary = "A headset with a microphone, for calls and audio." + why = "It can play this Mac's sound and be used as its microphone." + case "keyboard": + summary = "A keyboard." + why = "It can type on this Mac whenever it's connected." + case "mouse": + summary = "A mouse." + why = "It can move the pointer and click on this Mac whenever it's connected." + case "trackpad": + summary = "A trackpad, such as a Magic Trackpad." + why = "It can move the pointer and click on this Mac whenever it's connected." + case "speaker": + summary = "A speaker." + why = "It can play this Mac's sound." + case "gamepad": + summary = "A game controller." + why = "Games and apps that support controllers can use it." + default: + return nil + } + + return .info( + summary, + detail: "This is the kind of device the accessory says it is when it pairs.", + why: why, + action: "Nothing to do. If you don't recognize the accessory, remove it in System Settings › Bluetooth.", + confidence: .observed + ) +} + +private func bluetoothBatteryExplanation(_ value: String, part: String) -> ValueExplanation? { + let trimmed: String = value.trimmingCharacters(in: .whitespaces) + + guard trimmed.hasSuffix("%"), + let percent = leadingInteger(trimmed), + (0...100).contains(percent) else { + return nil + } + + let detail: String = "This is the level the accessory reported while it was connected to this Mac." + let why: String = "When the battery runs out, the accessory stops working until it's charged." + + if percent <= lowBatteryPercent { + return .review( + "\(part) is at \(percent)%, nearly empty.", + detail: detail, + why: why, + action: "Charge it soon.", + confidence: .observed + ) + } + + if percent <= lowAccessoryBatteryPercent { + return .info( + "\(part) is low, at \(percent)%.", + detail: detail, + why: why, + action: "Charge it when it's convenient.", + confidence: .observed + ) + } + + return .normal( + "\(part) is at \(percent)%.", + detail: detail, + why: why, + action: "Nothing to do.", + confidence: .observed + ) +} + +private let bluetoothServiceNames: [String: String] = [ + "A2DP": "stereo audio (A2DP)", + "AVRCP": "play and volume controls (AVRCP)", + "HFP": "hands-free calls (HFP)", + "HSP": "headset audio (HSP)", + "HID": "keyboards, mice, and game controllers (HID)", + "BRAILLE": "braille displays", + "GATT": "Bluetooth Low Energy data such as battery level (GATT)", + "BLE": "Bluetooth Low Energy (BLE)", + "SERIALPORT": "serial connections (Serial Port)", + "SERIAL": "serial connections (Serial Port)", + "PAN": "network sharing (PAN)", + "AACP": "Apple accessory features (AACP)" +] + +/// Explains a service list such as `0x400000 < BLE >`. Services the app doesn't know are +/// named as such, and a list with none it knows is left unexplained. +private func bluetoothServicesExplanation(_ value: String, owner: String) -> ValueExplanation? { + guard let open = value.firstIndex(of: "<"), + let close = value.lastIndex(of: ">"), + open < close else { + return nil + } + + let tokens: [String] = value[value.index(after: open)..", "0x980019 < HFP AVRCP A2DP AACP GATT >"].map { + ValueSample(.bluetooth, accessory + ["device_services"], $0) + } + samples.append(ValueSample( + .bluetooth, + ["controller_properties", "controller_supportedServices"], + "0x382039 < HFP AVRCP A2DP HID Braille AACP GATT SerialPort >" + )) + return samples +}() + private let startupAndOverviewValueSamples: [ValueSample] = { var samples: [ValueSample] = [ "Full Security", "Reduced Security", "Permissive Security", "Medium Security", "No Security" @@ -832,6 +854,44 @@ struct DriveAndAccessoryValueTests { #expect(valueExplanation(dataType: .discBurning, path: ["device_media"], scalar: .string("media_cdr"))?.status == .unknown) } + // MARK: - Bluetooth accessories + + @Test + func aNearlyEmptyAccessoryBatteryIsWorthALook() { + func explain(_ field: String, _ value: String) -> ValueExplanation? { + valueExplanation(dataType: .bluetooth, path: ["device_connected", "[]", "Example Earbuds", field], scalar: .string(value)) + } + + #expect(explain("device_batteryLevelLeft", "8%")?.status == .worthReviewing) + #expect(explain("device_batteryLevelLeft", "8%")?.summary.contains("left earbud") == true) + #expect(explain("device_batteryLevelCase", "18%")?.status == .informational) + #expect(explain("device_batteryLevelMain", "85%")?.status == .normal) + #expect(explain("device_batteryLevelMain", "85")?.status == .unknown) + #expect(explain("device_batteryLevelMain", "140%")?.status == .unknown) + } + + @Test + func accessoryTypesAreNotGuessed() { + let path: [String] = ["device_not_connected", "[]", "Example Accessory", "device_minorType"] + + #expect(valueExplanation(dataType: .bluetooth, path: path, scalar: .string("Keyboard"))?.summary == "A keyboard.") + #expect(valueExplanation(dataType: .bluetooth, path: path, scalar: .string("Toaster"))?.status == .unknown) + } + + @Test + func serviceListsNameWhatTheAppDoesntRecognize() throws { + let path: [String] = ["device_connected", "[]", "Example Accessory", "device_services"] + let mixed = try #require(valueExplanation(dataType: .bluetooth, path: path, scalar: .string("0x1 < A2DP XYZ >"))) + let apple = try #require(valueExplanation(dataType: .bluetooth, path: path, scalar: .string("0x2 < AACP GATT >"))) + + #expect(mixed.summary == "This accessory supports stereo audio (A2DP).") + #expect(mixed.detail?.contains("XYZ") == true) + #expect(mixed.confidence == .observed) + #expect(apple.confidence?.reasons.isEmpty == false) + #expect(valueExplanation(dataType: .bluetooth, path: path, scalar: .string("0x3 < XYZ >"))?.status == .unknown) + #expect(valueExplanation(dataType: .bluetooth, path: path, scalar: .string("none"))?.status == .unknown) + } + @Test func anUnknownPartitionTypeOnASerialATADriveIsNotGuessed() { let volume: [String] = ["_items", "[]", "volumes", "[]"] From 9e765672c8fff5f64dce25320f44d97b92027f1e Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:54:14 +0000 Subject: [PATCH 6/9] List the new values and move the scan list into the docs docs/value-explanations.md now lists the Serial ATA, disc burning, and Bluetooth accessory values with their sources and spellings, and marks the storage spellings the published samples confirm (MS-DOS FAT32, Not Supported, the MBR partition map, and the EFI, Apple_HFS, Apple_Boot, and Apple_CoreStorage partition types). The list of values that need a scan on a real Mac lived in PLAN.md, which was removed, so the docs pointed at a missing file. The list is now the last section of docs/value-explanations.md, updated with this branch's unconfirmed values. --- PLAN.md | 2 +- docs/value-explanations.md | 171 +++++++++++++++++++++++++++++++++++-- docs/value-inventory.md | 2 + 3 files changed, 165 insertions(+), 10 deletions(-) diff --git a/PLAN.md b/PLAN.md index 0efa1cf..2882e29 100644 --- a/PLAN.md +++ b/PLAN.md @@ -40,7 +40,7 @@ exists. This branch moves that list into the docs. interconnect, writable CD and DVD formats, and burn strategies. 5. [x] Bluetooth accessories: accessory type, battery levels (main, left, right, case), and supported services for accessories and the controller. -6. [ ] Docs: list every new value in `docs/value-explanations.md` with its +6. [x] Docs: list every new value in `docs/value-explanations.md` with its source and spelling, mark the spellings the published samples confirm, and move the list of values that need a scan into that file. 7. [ ] Remove this plan. diff --git a/docs/value-explanations.md b/docs/value-explanations.md index 5a56354..4394d06 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -31,10 +31,15 @@ current one); the table says so. `Apple_System_Profiler.lg` and `SP*Reporter.lg`). These glossaries come from an older macOS release, so a key there is a spelling Apple used, not proof that current macOS still reports it. -- **published**: in system_profiler output published online. +- **published**: in system_profiler output published online, or in code that + reads `system_profiler -json`. The macOS samples in + (`resources/macos/system_profiler`) + are XML output from older macOS releases; XML and JSON use the same keys and + values. - **unconfirmed**: no source shows this spelling. It's matched so the value is - explained if it appears, and it's listed under "Values that need a scan" in - `PLAN.md`. + explained if it appears, and it's listed under + [Values that need a scan](#values-that-need-a-scan-on-a-real-mac) at the end of + this file. ## Applications and frameworks @@ -268,9 +273,10 @@ modes: the `pmset` man page. ## Storage and NVMe -Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's +Sources: the values seen in `docs/value-inventory.md`, the keys in Apple's `SPStorageReporter`, `SPNVMeReporter`, `SPSerialATAReporter` and `SPSupport` -strings. File systems: "File system formats available in Disk Utility on Mac" +strings, and the glpi-agent samples, which show a Serial ATA drive with Journaled +HFS+ and MS-DOS FAT32 volumes and an SD card in a card reader. File systems: "File system formats available in Disk Utility on Mac" (). The sealed system volume: "Signed system volume security" (). @@ -279,14 +285,16 @@ volume: "Signed system volume security" |---|---|---|---|---| | `smart_status` | `Verified` | Normal | Apple | seen | | `smart_status` | `Failing` | Worth a look | Apple | Apple key | -| `smart_status` | `Not Supported` | Info | Apple | Apple key | +| `smart_status` | `Not Supported` | Info | Apple | Apple key, published | | `medium_type` | `ssd` | Info | Apple | seen | | `medium_type` | `rotational` | Info | Apple | Apple key | | `file_system` | `APFS` | Normal | Apple | seen | | `file_system` | `Journaled HFS+`, `Case-sensitive Journaled HFS+` | Info | Apple | Apple key | -| `file_system` | `ExFAT`, `MS-DOS FAT32`, `NTFS` | Info | Apple | unconfirmed (matched by name) | +| `file_system` | `MS-DOS FAT32` | Info | Apple | published | +| `file_system` | `ExFAT`, `NTFS` | Info | Apple | unconfirmed (matched by name) | | `partition_map_type` | `guid_partition_map_type` | Normal | Apple | seen | -| `partition_map_type` | `master_boot_record_partition_map_type`, `apple_partition_map_type` | Info | Apple | Apple key | +| `partition_map_type` | `master_boot_record_partition_map_type` | Info | Apple | Apple key, published | +| `partition_map_type` | `apple_partition_map_type` | Info | Apple | Apple key | | `partition_map_type` | `unknown_partition_map_type` | Info | Inferred | seen | | `writable` | `yes`; `no` on the system volume, a disk image, or another volume | Normal, Info | Apple | seen | | `free_space_in_bytes` | a byte count (under 10% free is Worth a look) | Normal, Info, Worth a look | Standard | seen | @@ -298,7 +306,62 @@ volume: "Signed system volume security" | `removable_media`, `detachable_drive` | `yes`, `no` | Info | Apple | seen (`no`) | | `spnvme_trim_support`, `spsata_trim_support` | `Yes`, `No` | Normal, Info | Apple | seen (`Yes`), Apple key (field) | | `iocontent` | `Apple_APFS`, `Apple_APFS_ISC`, `Apple_APFS_Recovery` | Info | Apple | seen | -| `iocontent` | `EFI`, `Apple_HFS`, `Apple_Boot`, `Apple_CoreStorage`, `Microsoft Basic Data` | Info | Standard | unconfirmed (names `diskutil list` shows) | +| `iocontent` | `EFI`, `Apple_HFS`, `Apple_Boot`, `Apple_CoreStorage` | Info | Standard | published (Serial ATA volumes) | +| `iocontent` | `Windows_FAT_32` | Info | Standard | published (a card in a card reader) | +| `iocontent` | `Microsoft Basic Data` | Info | Standard | unconfirmed (the name `diskutil list` shows) | + +Serial ATA drives and cards in a card reader report the same drive and volume +fields, and get the same explanations: `smart_status`, `partition_map_type`, +`removable_media`, `detachable_drive`, and each volume's `file_system`, +`writable`, and `iocontent`. Free space is explained only in the Storage +section, which lists the same volumes, so a shortage isn't reported twice. + +## Serial ATA + +Sources: the glpi-agent samples (an Intel SATA controller, Apple's SSD +controller, and a virtual machine). SATA generations follow the SATA-IO +specifications and PCI Express rates the PCI-SIG specifications. + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `spsata_medium_type` | `Rotational`, `Solid State` | Info | Standard | published | +| `spsata_physical_interconnect` | `SATA` | Info | Standard | published | +| `spsata_physical_interconnect` | `PCI` (Apple's SSD controller) | Info | Inferred | published | +| `spsata_portspeed` | `1.5 Gigabit`, `3 Gigabit`, `6 Gigabit` | Info | Standard | published (`3 Gigabit`); others unconfirmed | +| `spsata_negotiatedlinkspeed` | the same speeds; slower than `spsata_portspeed` is Info | Normal, Info | Standard | published (`3 Gigabit`); others unconfirmed | +| `spsata_ncq` | `Yes`, `No` | Normal, Info | Standard | published | +| `spsata_linkspeed` | `2.5`, `5.0`, `8.0`, `16.0`, `32.0 GT/s` | Info | Standard | published (`5.0 GT/s`); others unconfirmed | +| `spsata_linkwidth` | `x1`, `x2`, `x4`, `x8`, `x16` | Info | Standard | published (`x2`); others unconfirmed | + +Speeds written with a decimal comma, such as `1,5 Gigabit`, are read too, since +the samples show sizes written that way in some languages. `spsata_power_off` +and `spsata_async_notify` (both `No` in the samples) have no rule, because no +source says what they report. + +## Disc burning + +Sources: the glpi-agent samples, and the support levels and interconnects of +Apple's Disc Recording framework (`DRDeviceSupportLevel…` and +`DRDevicePhysicalInterconnect…`, listed in +). + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `burn_support` | `DRDeviceSupportLevelAppleShipping` | Normal | Apple | published | +| `burn_support` | `DRDeviceSupportLevelAppleSupported` | Normal | Apple | Apple constant | +| `burn_support` | `DRDeviceSupportLevelVendorSupported`, `DRDeviceSupportLevelUnsupported`, `DRDeviceSupportLevelNone` | Info | Apple | Apple constant | +| `device_media` | `media_none` | Info | Standard | published | +| `device_readdvd` | `yes`, `no` | Info | Standard | published (`yes`) | +| `interconnect` | `ATAPI` | Info | Apple | published | +| `interconnect` | `USB`, `FireWire`, `SCSI` | Info | Apple | Apple constant | +| `device_cdwrite`, `device_dvdwrite` | lists of `-R`, `-RW`, `+R`, `+RW`, `-R DL`, `+R DL` | Info | Standard | published | +| `device_cdwrite`, `device_dvdwrite` | lists that include `-RAM` or `+RW DL` | Info | Standard | unconfirmed | +| `device_strategies` | lists of `CD-TAO`, `CD-SAO`, `CD-Raw`, `DVD-DAO` | Info | Apple | published | +| `device_strategies` | lists that include `BD-DAO` | Info | Apple | unconfirmed (Apple constant) | + +A list with an entry the app doesn't know is shown as not yet explained, rather +than partly explained. `device_media` values other than `media_none` (a disc in +the drive) are not explained yet. ## Startup security, software overview, and hardware @@ -380,6 +443,33 @@ Bluetooth visibility: . | `spethernet_max_link_speed` | `ethernet_speed_N` | Info | Apple | seen | | `spethernet_usb_device_speed` | `low_speed` … `super_speed_plus_by_2` | Info | Apple | seen (`high_speed`); others unconfirmed | +## Bluetooth accessories + +Sources: the inventory Mac had no connected accessories, so these spellings come +from code that reads `system_profiler SPBluetoothDataType -json`: the Toothpick +extension in +(`extensions/toothpick/src/core/devices`), which reads `device_minorType` and the +four battery levels; , which reads +`Headphones` and `Headset`; and a published accessory in + (`Mouse`, `0x400000 < BLE >`). +`Trackpad` is from older text output read by + +(`System/Battery/trackpad-system_profiler.1m.rb`). Service names are standard +Bluetooth profile abbreviations. + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `device_minorType` | `Headphones`, `Headset`, `Keyboard`, `Mouse`, `Speaker`, `Gamepad` | Info | Standard | published | +| `device_minorType` | `Trackpad` | Info | Standard | published (older text output) | +| `device_batteryLevelMain`, `_Left`, `_Right`, `_Case` | a percentage such as `85%`: over 20% Normal, 11–20% Info, 10% or less Worth a look | Normal, Info, Worth a look | Standard | published (keys and `%` format) | +| `device_services`, `controller_supportedServices` | `< BLE >` | Info | Standard | published | +| `device_services`, `controller_supportedServices` | lists of `A2DP`, `AVRCP`, `HFP`, `HID`, `GATT`, `Braille`, `SerialPort`, `Serial`, `HSP`, `PAN` | Info | Standard | unconfirmed in this form | +| `device_services`, `controller_supportedServices` | lists that include `AACP` | Info | Inferred | unconfirmed | + +A service list names any service the app doesn't know, and a list with none it +knows is shown as not yet explained. The accessory types other than these are +not explained yet. + ## Settings and profiles Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's @@ -419,3 +509,66 @@ CUPS printer states. | `description` (sync services) | other `…_log_description` names | Info | Inferred | unconfirmed | | `se_in_restricted_mode` | `No`, `Yes` | Normal, Info | Inferred | seen (`No`), Apple key | | `se_prod_signed` | `Yes`, `No` | Normal, Info | Inferred | Apple key (withheld in the inventory) | + +## Values that need a scan on a real Mac + +Each of these is explained, but no public source confirms the exact spelling +current macOS reports, or the inventory withheld the field's values. A scan with +the matching hardware or setting would confirm them (or show a spelling to add). +Until then, a different spelling is shown as "not yet explained". + +**Fields whose value format is unknown** (no rule, or the rule may never match): + +- `contrast` (Accessibility): no rule; its values were withheld. +- `ibridge_extra_boot_policies` (Apple Bridge): no rule; its values were withheld. +- `UserVisible` (scheduled power events): no rule; its values were withheld. +- `ibridge_external_boot` (Macs with the T2 Security Chip): Apple's strings name + `External Drive`, `Network`, `Internal`, `Disallowed`, and `BootCamp`, but which + field reports which isn't clear without a scan of a T2 Mac, so it has no rule. +- `link_status_key` (Thunderbolt): the rule matches Apple's `trained_link_status` + family, but current macOS may report a number such as `0x2`. +- `printersharing`, `scanner`, `shared`, `default`, and `status` (Printers): + needs a Mac with a printer set up. +- `spsata_power_off` and `spsata_async_notify` (Serial ATA): no rule; no source + says what they report. + +**Spellings to confirm, by section:** + +- Applications: `arch_ppc`, `ios_app_store`; and whether current macOS still + reports the older Apple keys `arch_i32`, `arch_i32_i64`, `app_store`. +- Extensions: whether current macOS still reports `spext_runtime_environment`, + `spext_obtained_from`, and `spext_notarized`. +- Network: `PPP (PPPoE)`, `PPP (L2TP)`, `PPP (PPTP)`; Ethernet media speeds + other than `100baseTX` and `1000baseT`; `spethernet_pcie`, + `spethernet_builtin`; USB link speeds other than `high_speed` (needs a USB + Ethernet adapter on a faster port). +- Firewall: `spfirewall_globalstate_off` (turn the firewall off and scan). +- Wi-Fi: `spairport_status_disconnected`, `_not_associated`; security modes + `wpa3_enterprise`, `wpa2_wpa3_enterprise`, `owe`, `wpa_personal_mixed`; + locale `MKK` (needs networks of those kinds nearby, or a Mac in Japan). +- Battery: `Normal`, `Service Recommended` and the other System Settings + names (needs a notebook whose battery isn't `Good`). +- Storage: file systems `ExFAT` and `NTFS`; protocols `USB`, `Thunderbolt`, + `SATA`, `PCI-Express`, `NVMe`, `Secure Digital`; partition type + `Microsoft Basic Data` (needs external drives formatted each way). +- Serial ATA: link speeds other than `3 Gigabit`, PCI Express rates other than + `5.0 GT/s`, and whether current macOS reports the same keys as the older + samples (needs an Intel Mac with a SATA drive). +- Disc burning: the support levels other than `AppleShipping`, interconnects + `USB`, `FireWire`, and `SCSI`, the `-RAM` and `+RW DL` formats, `BD-DAO`, and + `device_media` with a disc inserted (needs a Mac with an optical drive, such + as a USB SuperDrive). +- Bluetooth accessories: the service list spellings other than `BLE`, accessory + types other than the published ones, and whether `Trackpad` is still the type + current macOS reports (needs a scan with a trackpad, headphones, and a game + controller connected). +- Startup security: `Reduced Security`, `Permissive Security`; `Disabled` for + the `ibridge_sb_*` protections; `Custom Configuration`; `Yes` for + `ibridge_sb_other_kext` and the MDM fields (needs a Mac with a lowered + security policy). +- Displays and audio: `spdisplays_external`, `spdisplays_pcie`; audio + transports `bluetoothle` and `aggregate`; Thunderbolt 5 speeds. +- Bluetooth controller: transports `USB` and `UART` (older Macs). +- Settings: zoom styles `zoom_picture_in_picture` and `zoom_pip`; profile + states `verified`, `invalid`, `unverified` and MDM install sources; managed + preference states `often` and `once`; other sync log names. diff --git a/docs/value-inventory.md b/docs/value-inventory.md index 30f4f2a..7cd8d80 100644 --- a/docs/value-inventory.md +++ b/docs/value-inventory.md @@ -24,6 +24,8 @@ Among the fields this inventory classes as enumeration or boolean-like, these ha - **Enumerations whose values the inventory withheld and whose format no public source shows**: `contrast` (Accessibility), `ibridge_extra_boot_policies` (Apple Bridge), and `UserVisible` (scheduled power events). `link_status_key` (Thunderbolt) and `printersharing` and `scanner` (Printers) now have rules for the spellings Apple's strings or the usual yes/no forms use, but the format this Mac reports still needs a sample; an unmatched value is shown as not yet explained. - **Audio device names and manufacturers** (`coreaudio_input_source`, `coreaudio_output_source`, `coreaudio_device_manufacturer`), which are free text. +Sections this Mac had no data for, or no connected devices in, now have value rules too, with spellings from published output: Serial ATA drives, cards in a card reader, disc drives, and connected Bluetooth accessories (type, battery levels, and services). `docs/value-explanations.md` lists them, and its last section lists the values that still need a scan on a real Mac. + ## Wi-Fi (`SPAirPortDataType`) | field | class | values seen | distinct | explained | value-aware | example values | From 5ec204f61edfb51fcc51d5c8c622f8a01223a7c6 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:54:27 +0000 Subject: [PATCH 7/9] Check every value the docs list for the new sections in the value catalog --- SystemProfilerExplorerTests/ValueCatalogTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index e06d8f3..b014c0b 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -374,8 +374,8 @@ private let driveAndCardValueSamples: [ValueSample] = { samples += ["1.5 Gigabit", "3 Gigabit", "6 Gigabit", "1,5 Gigabit"].map { ValueSample(.serialATA, ["spsata_portspeed"], $0) } samples += ["3 Gigabit", "6 Gigabit"].map { ValueSample(.serialATA, ["spsata_negotiatedlinkspeed"], $0, siblings: port) } samples.append(ValueSample(.serialATA, ["spsata_negotiatedlinkspeed"], "3 Gigabit")) - samples += ["2.5 GT/s", "5.0 GT/s", "8.0 GT/s"].map { ValueSample(.serialATA, ["spsata_linkspeed"], $0) } - samples += ["x1", "x2", "x4"].map { ValueSample(.serialATA, ["spsata_linkwidth"], $0) } + samples += ["2.5 GT/s", "5.0 GT/s", "8.0 GT/s", "16.0 GT/s", "32.0 GT/s"].map { ValueSample(.serialATA, ["spsata_linkspeed"], $0) } + samples += ["x1", "x2", "x4", "x8", "x16"].map { ValueSample(.serialATA, ["spsata_linkwidth"], $0) } return samples }() @@ -390,8 +390,8 @@ private let discDriveValueSamples: [ValueSample] = { samples += ["yes", "no"].map { ValueSample(.discBurning, ["device_readdvd"], $0) } samples += ["ATAPI", "USB", "FireWire", "SCSI"].map { ValueSample(.discBurning, ["interconnect"], $0) } samples += ["-R, -RW", "-R"].map { ValueSample(.discBurning, ["device_cdwrite"], $0) } - samples += ["-R, -R DL, -RW, +R, +R DL, +RW", "-R, -RAM"].map { ValueSample(.discBurning, ["device_dvdwrite"], $0) } - samples += ["CD-TAO, CD-SAO, CD-Raw, DVD-DAO", "CD-TAO"].map { ValueSample(.discBurning, ["device_strategies"], $0) } + samples += ["-R, -R DL, -RW, +R, +R DL, +RW", "-R, -RAM", "+RW DL"].map { ValueSample(.discBurning, ["device_dvdwrite"], $0) } + samples += ["CD-TAO, CD-SAO, CD-Raw, DVD-DAO", "CD-TAO", "BD-DAO"].map { ValueSample(.discBurning, ["device_strategies"], $0) } return samples }() @@ -405,7 +405,7 @@ private let bluetoothAccessoryValueSamples: [ValueSample] = { samples += ["100%", "15%", "5%", "0%"].map { ValueSample(.bluetooth, accessory + [field], $0) } } - samples += ["0x400000 < BLE >", "0x980019 < HFP AVRCP A2DP AACP GATT >"].map { + samples += ["0x400000 < BLE >", "0x980019 < HFP AVRCP A2DP AACP GATT >", "0x1 < HSP PAN Serial >"].map { ValueSample(.bluetooth, accessory + ["device_services"], $0) } samples.append(ValueSample( From 39b79db1d43a1610da5bbc9910e456d98194c6f6 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:57:33 +0000 Subject: [PATCH 8/9] Remove the working plan The plan tracked this branch's steps and doesn't belong in the project. The list of values that need a scan now lives in docs/value-explanations.md. --- PLAN.md | 53 ----------------------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 2882e29..0000000 --- a/PLAN.md +++ /dev/null @@ -1,53 +0,0 @@ -# Plan: explain the values of drives and accessories the inventory Mac didn't have - -`docs/value-inventory.md` comes from one Apple silicon Mac with no connected -Bluetooth accessories, no Serial ATA drive, no disc drive, and no card in its -card reader. Every limited-set field that Mac reported now has a value rule, -but these sections only get the general data-type text, even though they are -common on other Macs: - -- **Bluetooth accessories**: the accessory type, battery levels, and the - services an accessory or the controller supports. Most Macs have a paired - keyboard, mouse, trackpad, or headphones. -- **Serial ATA**: every Intel iMac, Mac mini, and older MacBook reports its - drive here, along with the same volume fields the Storage section has. -- **Card readers**: a card in the slot is reported with the same drive and - volume fields. -- **Disc burning**: Macs with a built-in or USB optical drive. - -Spellings come from published output: the macOS samples in - (`resources/macos/system_profiler`, -the XML form, whose keys and values are what `-json` reports), and code that -reads `system_profiler SPBluetoothDataType -json`, such as the Toothpick -extension in . A value no source shows -is marked unconfirmed, and anything else is shown as not yet explained. - -The list of values that need a scan was in the previous `PLAN.md`, which was -removed, so `docs/value-explanations.md` points at a file that no longer -exists. This branch moves that list into the docs. - -## Steps - -1. [x] Write this plan. -2. [x] Apply the drive and volume rules (SMART, partition map, file system, - writable, partition content, removable and detachable) to Serial ATA drives - and to cards in a card reader, and explain `Windows_FAT_32`. Free space - stays on the Storage section, so a shortage isn't reported twice. -3. [x] Serial ATA: medium type, physical interconnect, negotiated and port - link speed, Native Command Queuing, and the PCI Express link of Apple's SSD - controller. -4. [x] Disc burning: support level, media in the drive, DVD reading, - interconnect, writable CD and DVD formats, and burn strategies. -5. [x] Bluetooth accessories: accessory type, battery levels (main, left, - right, case), and supported services for accessories and the controller. -6. [x] Docs: list every new value in `docs/value-explanations.md` with its - source and spelling, mark the spellings the published samples confirm, and - move the list of values that need a scan into that file. -7. [ ] Remove this plan. - -## Verification - -There is no Xcode here, so each pushed commit is built and tested by the CI -`build-and-test` job. Each step adds its values to `ValueCatalogTests`, which -checks that every listed value has every part of an explanation, plus tests for -the values that change the status. From 7be57ec046cc83fa56791638b0241e9edb370bbc Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:37:15 -0700 Subject: [PATCH 9/9] Harden drive and accessory value parsing Reject malformed Bluetooth percentages, service masks and PCI lane counts. Keep CD and DVD format interpretation medium-specific and preserve unsupported raw values without inference. Validate import/search/export preservation and value boundaries. Native macOS build/test passed with the optional private fixture unavailable; synthetic accessory and optical cases do not establish live hardware coverage. --- .../Values/DisplayAndMediaValueRules.swift | 70 +++++---- .../Values/NetworkValueRules.swift | 31 +++- .../Values/PowerAndStorageValueRules.swift | 5 +- .../ValueCatalogTests.swift | 141 +++++++++++++++++- docs/value-explanations.md | 42 +++++- 5 files changed, 244 insertions(+), 45 deletions(-) diff --git a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift index e1cc7bc..0d04368 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift @@ -947,10 +947,10 @@ let discBurningValueRules: [ValueRule] = [ }, ValueRule(.discBurning, field: "device_cdwrite") { context in - discWriteFormats(context.reportedValue, disc: "CD").map { formats in + discWriteFormats(context.reportedValue, names: cdWriteFormatNames).map { formats in .info( "The drive can write \(formats).", - detail: "R discs can be written once. RW discs can be erased and written again.", + detail: "R discs can be written once. RW discs can be erased and written again. These are reported capabilities, not a successful burn or a check of an inserted disc.", why: "It tells you which blank CDs to buy for this drive.", action: "Nothing to do.", confidence: .observed @@ -959,10 +959,10 @@ let discBurningValueRules: [ValueRule] = [ }, ValueRule(.discBurning, field: "device_dvdwrite") { context in - discWriteFormats(context.reportedValue, disc: "DVD").map { formats in + discWriteFormats(context.reportedValue, names: dvdWriteFormatNames).map { formats in .info( "The drive can write \(formats).", - detail: "R discs can be written once and RW discs erased and written again. DL means dual-layer, which holds about twice as much. The minus and plus formats are two competing standards.", + detail: "R discs can be written once; RW and RAM discs can be written again. DL means dual-layer, which holds about twice as much. The minus and plus formats are two competing standards. These are reported capabilities, not a successful burn or a check of an inserted disc.", why: "It tells you which blank DVDs to buy for this drive.", action: "Nothing to do.", confidence: .observed @@ -984,45 +984,45 @@ let discBurningValueRules: [ValueRule] = [ ] private func discSupportLevelExplanation(_ value: String) -> ValueExplanation? { - let why: String = "It decides whether the Finder and Music can burn discs with this drive." + let why: String = "It describes Apple's Disc Recording framework support. A successful burn also depends on the drive, blank media, and burning software." return switch value { case "DRDeviceSupportLevelAppleShipping": .normal( - "A drive Apple shipped in its Macs, so macOS fully supports burning with it.", - detail: "Apple's Disc Recording framework recognizes it as a drive Apple shipped.", + "This drive type shipped in an Apple Mac.", + detail: "Apple's Disc Recording framework identifies the drive as a type shipping in an Apple machine. This doesn't establish where this particular drive came from.", why: why, action: "Nothing to do.", confidence: .documented ) case "DRDeviceSupportLevelAppleSupported": .normal( - "macOS supports burning with this drive.", - detail: "Apple's Disc Recording framework supports the drive, though Apple didn't ship it in a Mac.", + "Apple tested this drive type for Disc Recording support.", + detail: "This support level means Apple tested the drive for use with its Disc Recording framework. It doesn't guarantee that every disc will burn successfully.", why: why, action: "Nothing to do.", confidence: .documented ) case "DRDeviceSupportLevelVendorSupported": .info( - "Burning with this drive is supported by software from the drive's maker.", - detail: "Apple's Disc Recording framework uses support the drive's maker provides.", + "A third party tested this drive type for Disc Recording support.", + detail: "This support level records third-party testing. It doesn't identify the tester or imply that software from the drive's maker is installed.", why: why, action: "Nothing to do. If burning fails, check the drive maker's site for a macOS update.", confidence: .documented ) case "DRDeviceSupportLevelUnsupported": .info( - "macOS doesn't support burning with this drive. It may still read discs.", - detail: "Apple's Disc Recording framework recognizes the drive but doesn't support writing with it.", + "macOS will still try to burn with this drive despite its unsupported status.", + detail: "Apple's Disc Recording engine attempts to use the drive even though it lacks declared support. A successful burn is not confirmed by this label.", why: why, action: "If you need to burn discs, use a drive macOS supports or the drive maker's software.", confidence: .documented ) case "DRDeviceSupportLevelNone": .info( - "The drive can't burn discs on this Mac.", - detail: "Apple's Disc Recording framework has no burning support for it, often because it's a read-only drive.", + "Apple's Disc Recording framework has no support for this drive.", + detail: "The drive cannot be used by that engine. This label alone doesn't establish whether the hardware is read-only or whether other software can write with it.", why: why, action: "Nothing to do unless you need to burn discs.", confidence: .documented @@ -1038,13 +1038,13 @@ private func discInterconnectExplanation(_ value: String) -> ValueExplanation? { switch value.uppercased() { case "ATAPI": - summary = "A drive built into the Mac, connected over ATAPI." - detail = "ATAPI is how internal optical drives connect, over an ATA or SATA link." + summary = "A drive connected over ATAPI." + detail = "ATAPI is an interface for devices such as optical drives, over an ATA or SATA link. It doesn't by itself establish the drive's physical location." case "USB": - summary = "An external drive connected over USB, such as Apple's USB SuperDrive." - detail = "The drive is connected by a USB cable." + summary = "A drive connected over USB." + detail = "USB is the reported interface. This field alone doesn't establish whether the drive is built in or external." case "FIREWIRE": - summary = "An external drive connected over FireWire." + summary = "A drive connected over FireWire." detail = "FireWire was common on Macs before Thunderbolt." case "SCSI": summary = "A drive connected over SCSI, an older connection." @@ -1056,25 +1056,36 @@ private func discInterconnectExplanation(_ value: String) -> ValueExplanation? { return .info( summary, detail: detail, - why: "It shows whether the drive is inside the Mac or plugged in, which helps if the drive stops appearing.", + why: "It identifies the reported connection interface, which helps choose where to investigate if the drive stops appearing. Physical location is a separate property.", action: "Nothing to do. If an external drive doesn't appear, connect it directly to the Mac.", confidence: .documented ) } -/// Turns a format list such as `-R, -R DL, -RW, +R` into words, or nil when a format is unfamiliar. -private func discWriteFormats(_ value: String, disc: String) -> String? { - let known: Set = ["-R", "-RW", "+R", "+RW", "-R DL", "+R DL", "-RAM", "+RW DL"] +private let cdWriteFormatNames: [String: String] = [ + "-R": "CD-R", "-RW": "CD-RW" +] + +// Apple Disc Recording write-capability constants define these DVD media types. +// The suffixes also appear in SPDiscBurningReporter's format strings. +private let dvdWriteFormatNames: [String: String] = [ + "-R": "DVD-R", "-RW": "DVD-RW", "+R": "DVD+R", "+RW": "DVD+RW", + "-R DL": "DVD-R DL", "-RW DL": "DVD-RW DL", "+R DL": "DVD+R DL", + "-RAM": "DVD-RAM", "+RW DL": "DVD+RW DL" +] + +/// Names a complete format list; unfamiliar or empty entries leave the entire value unexplained. +private func discWriteFormats(_ value: String, names: [String: String]) -> String? { let formats: [String] = value - .split(separator: ",") + .split(separator: ",", omittingEmptySubsequences: false) .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } + let named: [String] = formats.compactMap { names[$0] } - guard !formats.isEmpty, formats.allSatisfy({ known.contains($0) }) else { + guard !formats.isEmpty, named.count == formats.count else { return nil } - return englishList(formats.map { "\(disc)\($0)" }) + return englishList(named) } /// Names each burn strategy in a list such as `CD-TAO, CD-SAO, CD-Raw, DVD-DAO`, or nil @@ -1088,9 +1099,8 @@ private func discBurnStrategies(_ value: String) -> String? { "BD-DAO": "Blu-ray disc at once" ] let strategies: [String] = value - .split(separator: ",") + .split(separator: ",", omittingEmptySubsequences: false) .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } let named: [String] = strategies.compactMap { names[$0] } guard !strategies.isEmpty, named.count == strategies.count else { diff --git a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift index 7a2aa1c..db1e129 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift @@ -1209,14 +1209,18 @@ private func bluetoothAccessoryTypeExplanation(_ value: String) -> ValueExplanat private func bluetoothBatteryExplanation(_ value: String, part: String) -> ValueExplanation? { let trimmed: String = value.trimmingCharacters(in: .whitespaces) + // Percent formatters can insert a space or nonbreaking space before the percent sign. + let digits: String = trimmed.dropLast().trimmingCharacters(in: .whitespaces) guard trimmed.hasSuffix("%"), - let percent = leadingInteger(trimmed), + !digits.isEmpty, + digits.allSatisfy({ ("0"..."9").contains($0) }), + let percent: Int = Int(digits), (0...100).contains(percent) else { return nil } - let detail: String = "This is the level the accessory reported while it was connected to this Mac." + let detail: String = "This is the accessory's reported battery level, not a measurement made by the app. It doesn't establish whether the accessory is connected or charging now." let why: String = "When the battery runs out, the accessory stops working until it's charged." if percent <= lowBatteryPercent { @@ -1266,14 +1270,27 @@ private let bluetoothServiceNames: [String: String] = [ /// Explains a service list such as `0x400000 < BLE >`. Services the app doesn't know are /// named as such, and a list with none it knows is left unexplained. private func bluetoothServicesExplanation(_ value: String, owner: String) -> ValueExplanation? { - guard let open = value.firstIndex(of: "<"), - let close = value.lastIndex(of: ">"), + let trimmed: String = value.trimmingCharacters(in: .whitespacesAndNewlines) + + guard trimmed.hasSuffix(">"), + let open = trimmed.firstIndex(of: "<"), + let close = trimmed.lastIndex(of: ">"), open < close else { return nil } - let tokens: [String] = value[value.index(after: open)..") else { + return nil + } + + let tokens: [String] = body + .split(whereSeparator: \.isWhitespace) .map(String.init) var known: [String] = [] var unknown: [String] = [] @@ -1292,7 +1309,7 @@ private func bluetoothServicesExplanation(_ value: String, owner: String) -> Val return nil } - var detail: String = "Each service is a Bluetooth profile, a standard way of doing one job." + var detail: String = "The list names Bluetooth capabilities, including profiles, protocols, and connection types." if !unknown.isEmpty { detail += " The app doesn't recognize \(englishList(unknown)), so it isn't described here." diff --git a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift index ef1189a..7c891d9 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift @@ -1097,8 +1097,11 @@ func pciExpressGeneration(_ value: String) -> String? { /// Reads a lane count such as `x2`. func pciExpressLaneCount(_ value: String) -> Int? { let trimmed: String = value.trimmingCharacters(in: .whitespaces).lowercased() + let digits: Substring = trimmed.dropFirst() - guard trimmed.hasPrefix("x"), let lanes = Int(trimmed.dropFirst()), [1, 2, 4, 8, 16].contains(lanes) else { + guard trimmed.hasPrefix("x"), !digits.isEmpty, + digits.allSatisfy({ ("0"..."9").contains($0) }), + let lanes: Int = Int(digits), [1, 2, 4, 8, 16].contains(lanes) else { return nil } diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index b014c0b..cb04914 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -390,7 +390,7 @@ private let discDriveValueSamples: [ValueSample] = { samples += ["yes", "no"].map { ValueSample(.discBurning, ["device_readdvd"], $0) } samples += ["ATAPI", "USB", "FireWire", "SCSI"].map { ValueSample(.discBurning, ["interconnect"], $0) } samples += ["-R, -RW", "-R"].map { ValueSample(.discBurning, ["device_cdwrite"], $0) } - samples += ["-R, -R DL, -RW, +R, +R DL, +RW", "-R, -RAM", "+RW DL"].map { ValueSample(.discBurning, ["device_dvdwrite"], $0) } + samples += ["-R, -R DL, -RW, +R, +R DL, +RW", "-R, -RAM", "+RW DL", "-RW DL"].map { ValueSample(.discBurning, ["device_dvdwrite"], $0) } samples += ["CD-TAO, CD-SAO, CD-Raw, DVD-DAO", "CD-TAO", "BD-DAO"].map { ValueSample(.discBurning, ["device_strategies"], $0) } return samples }() @@ -779,6 +779,29 @@ struct ValueCatalogTests { /// Values from drives, cards, disc drives, and Bluetooth accessories that the /// inventory Mac didn't have. struct DriveAndAccessoryValueTests { + @Test + func importedMalformedValuesRemainSearchableAndRoundTripUnchanged() throws { + let data: Data = Data(#"{"SPBluetoothDataType":[{"device_batteryLevelMain":"85garbage%","device_batteryLevelLeft":"10%"}],"SPDiscBurningDataType":[{"device_cdwrite":"-R, +RW","device_dvdwrite":"-R, -RW"}]}"#.utf8) + let report: SystemProfilerReport = try SystemProfilerParser().parseImportedReport( + data, + importedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let index: ReportPresentationIndex = try makeReportPresentationIndex(report) + let malformedBattery: ReportQueryResult = try index.queryResult(for: FindingQuery(text: "85garbage%", filter: .all)) + let unsupportedCD: ReportQueryResult = try index.queryResult(for: FindingQuery(text: "-R, +RW", filter: .all)) + let roundTrip: ReportExportEnvelope = try decodeReportExport(encodeReportExport(makeFullReportExport(report))) + + #expect(malformedBattery.findingCount == 1) + #expect(unsupportedCD.findingCount == 1) + #expect(index.worthReviewingFindingCount == 1) + #expect(index.worthReviewingItems.first?.sourcePath.hasSuffix("device_batteryLevelLeft") == true) + #expect(roundTrip.report.sections == report.sections) + let bluetooth: SystemProfilerSection = try #require(roundTrip.report.sections.first { $0.dataType == .bluetooth }) + #expect(bluetooth.items == [.object([ + "device_batteryLevelMain": .string("85garbage%"), "device_batteryLevelLeft": .string("10%") + ])]) + } + // MARK: - Serial ATA drives and cards @Test @@ -842,6 +865,65 @@ struct DriveAndAccessoryValueTests { #expect(valueExplanation(dataType: .discBurning, path: ["device_strategies"], scalar: .string("CD-TAO, HD-DAO"))?.status == .unknown) } + @Test(arguments: ["+R", "+RW", "-R DL", "-RW DL", "+R DL", "-RAM", "+RW DL", "-R, +RW"]) + func dvdFormatsAreNotExplainedAsCDFormats(_ value: String) throws { + let explanation = try #require(valueExplanation(dataType: .discBurning, path: ["device_cdwrite"], scalar: .string(value))) + + #expect(explanation.status == .unknown) + #expect(explanation.confidence == nil) + #expect(explanation == .unexplained(value)) + } + + @Test(arguments: ["", ",", "-R,", ",-R", "-R,, -RW", "-R, , -RW", "-R garbage", "-R, +HD"]) + func malformedDiscFormatListsStayUnexplained(_ value: String) throws { + for field: String in ["device_cdwrite", "device_dvdwrite"] { + let explanation = try #require(valueExplanation(dataType: .discBurning, path: [field], scalar: .string(value))) + + #expect(explanation.status == .unknown) + #expect(explanation.confidence == nil) + #expect(explanation == .unexplained(value)) + } + } + + @Test + func discFormatWhitespaceAndDVDOnlyFormatsAreRecognized() throws { + let cd = try #require(valueExplanation(dataType: .discBurning, path: ["device_cdwrite"], scalar: .string(" -R , -RW "))) + let dvd = try #require(valueExplanation(dataType: .discBurning, path: ["device_dvdwrite"], scalar: .string("-R, -RW, +R, +RW, -R DL, -RW DL, +R DL, -RAM, +RW DL"))) + + #expect(cd.summary == "The drive can write CD-R and CD-RW.") + #expect(cd.confidence == .observed) + #expect(dvd.summary == "The drive can write DVD-R, DVD-RW, DVD+R, DVD+RW, DVD-R DL, DVD-RW DL, DVD+R DL, DVD-RAM, and DVD+RW DL.") + #expect(dvd.confidence == .observed) + } + + @Test(arguments: [",CD-TAO", "CD-TAO,", "CD-TAO,,DVD-DAO", "CD-TAO, ,DVD-DAO"]) + func incompleteDiscStrategyListsStayUnexplained(_ value: String) throws { + let explanation = try #require(valueExplanation(dataType: .discBurning, path: ["device_strategies"], scalar: .string(value))) + + #expect(explanation == .unexplained(value)) + } + + @Test + func discSupportLevelsDoNotPromiseSuccessfulBurns() throws { + let unsupported = try #require(valueExplanation(dataType: .discBurning, path: ["burn_support"], scalar: .string("DRDeviceSupportLevelUnsupported"))) + let none = try #require(valueExplanation(dataType: .discBurning, path: ["burn_support"], scalar: .string("DRDeviceSupportLevelNone"))) + let vendor = try #require(valueExplanation(dataType: .discBurning, path: ["burn_support"], scalar: .string("DRDeviceSupportLevelVendorSupported"))) + + #expect(unsupported.summary.contains("will still try")) + #expect(unsupported.confidence == .documented) + #expect(none.detail?.contains("doesn't establish whether the hardware is read-only") == true) + #expect(vendor.summary.contains("third party tested")) + } + + @Test(arguments: ["ATAPI", "USB", "FireWire", "SCSI"]) + func discInterconnectDoesNotEstablishPhysicalLocation(_ value: String) throws { + let explanation = try #require(valueExplanation(dataType: .discBurning, path: ["interconnect"], scalar: .string(value))) + + #expect(explanation.confidence == .documented) + #expect(explanation.summary.hasPrefix("A drive connected over")) + #expect(explanation.significance?.contains("Physical location is a separate property") == true) + } + @Test func aDriveMacOSCantBurnWithIsInformation() { func status(_ value: String) -> ValueStatus? { @@ -870,6 +952,35 @@ struct DriveAndAccessoryValueTests { #expect(explain("device_batteryLevelMain", "140%")?.status == .unknown) } + @Test(arguments: [ + ("0%", ValueStatus.worthReviewing), ("10%", .worthReviewing), + ("11%", .informational), ("20%", .informational), + ("21%", .normal), ("100%", .normal), (" 85% ", .normal), + ("85 %", .normal), ("85\u{00A0}%", .normal) + ]) + func accessoryBatteryBoundariesAreRecognized(_ value: String, _ expected: ValueStatus) throws { + for field: String in ["device_batteryLevelMain", "device_batteryLevelLeft", "device_batteryLevelRight", "device_batteryLevelCase"] { + let explanation = try #require(valueExplanation(dataType: .bluetooth, path: [field], scalar: .string(value))) + + #expect(explanation.status == expected) + #expect(explanation.confidence == .observed) + } + } + + @Test(arguments: [ + "85garbage%", "85.5%", "8 5%", "85%%", "85%garbage", "85", "%", "", + "+85%", "-0%", "-1%", "101%", "999999999999999999999999%", "85%", "85\n%" + ]) + func malformedAccessoryBatteryTextStaysUnexplained(_ value: String) throws { + for field: String in ["device_batteryLevelMain", "device_batteryLevelLeft", "device_batteryLevelRight", "device_batteryLevelCase"] { + let explanation = try #require(valueExplanation(dataType: .bluetooth, path: [field], scalar: .string(value))) + + #expect(explanation.status == .unknown) + #expect(explanation.confidence == nil) + #expect(explanation == .unexplained(value)) + } + } + @Test func accessoryTypesAreNotGuessed() { let path: [String] = ["device_not_connected", "[]", "Example Accessory", "device_minorType"] @@ -892,6 +1003,34 @@ struct DriveAndAccessoryValueTests { #expect(valueExplanation(dataType: .bluetooth, path: path, scalar: .string("none"))?.status == .unknown) } + @Test(arguments: [ + "junk < A2DP > junk", "0x1 < A2DP > junk", "0x < A2DP >", "0xG < A2DP >", + "0xa\u{200d} < A2DP >", "0xa\u{FE0F} < A2DP >", "0x1 < < A2DP >", "0x1 < A2DP > >" + ]) + func malformedServiceListsStayUnexplained(_ value: String) throws { + let explanation = try #require(valueExplanation(dataType: .bluetooth, path: ["device_services"], scalar: .string(value))) + + #expect(explanation == .unexplained(value)) + } + + @Test(arguments: ["< A2DP >", " 0X1 < A2DP\tXYZ > ", "0x1 < A2DP\nXYZ >"]) + func serviceListsAllowWhitespaceWithoutDiscardingUnknownTokens(_ value: String) throws { + let explanation = try #require(valueExplanation(dataType: .bluetooth, path: ["device_services"], scalar: .string(value))) + + #expect(explanation.status == .informational) + #expect(explanation.summary.contains("stereo audio (A2DP)")) + if value.contains("XYZ") { + #expect(explanation.detail?.contains("XYZ") == true) + } + } + + @Test(arguments: ["x+2", "x-2", "x2junk", "x2.0", "x", "x0", "x3", "x2"]) + func malformedOrUnsupportedPCILaneCountsStayUnexplained(_ value: String) throws { + let explanation = try #require(valueExplanation(dataType: .serialATA, path: ["spsata_linkwidth"], scalar: .string(value))) + + #expect(explanation == .unexplained(value)) + } + @Test func anUnknownPartitionTypeOnASerialATADriveIsNotGuessed() { let volume: [String] = ["_items", "[]", "volumes", "[]"] diff --git a/docs/value-explanations.md b/docs/value-explanations.md index 4394d06..725ca9a 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -334,7 +334,9 @@ specifications and PCI Express rates the PCI-SIG specifications. | `spsata_linkwidth` | `x1`, `x2`, `x4`, `x8`, `x16` | Info | Standard | published (`x2`); others unconfirmed | Speeds written with a decimal comma, such as `1,5 Gigabit`, are read too, since -the samples show sizes written that way in some languages. `spsata_power_off` +the samples show sizes written that way in some languages. Lane counts require +`x` followed by a complete unsigned ASCII integer in the supported set; signed +or malformed suffixes stay unexplained. `spsata_power_off` and `spsata_async_notify` (both `No` in the samples) have no rule, because no source says what they report. @@ -344,6 +346,10 @@ Sources: the glpi-agent samples, and the support levels and interconnects of Apple's Disc Recording framework (`DRDeviceSupportLevel…` and `DRDevicePhysicalInterconnect…`, listed in ). +Apple SDK `DRDevice.h` and `DRCoreDevice.h` define support levels, physical +interfaces, and medium-specific write capabilities. The installed +`SPDiscBurningReporter` also contains the DVD format suffixes below; that +confirms spellings, not a live drive's capabilities. | field | value | status | source | spelling | |---|---|---|---|---| @@ -354,14 +360,24 @@ Apple's Disc Recording framework (`DRDeviceSupportLevel…` and | `device_readdvd` | `yes`, `no` | Info | Standard | published (`yes`) | | `interconnect` | `ATAPI` | Info | Apple | published | | `interconnect` | `USB`, `FireWire`, `SCSI` | Info | Apple | Apple constant | -| `device_cdwrite`, `device_dvdwrite` | lists of `-R`, `-RW`, `+R`, `+RW`, `-R DL`, `+R DL` | Info | Standard | published | -| `device_cdwrite`, `device_dvdwrite` | lists that include `-RAM` or `+RW DL` | Info | Standard | unconfirmed | +| `device_cdwrite` | lists of `-R`, `-RW` only | Info | Standard | published | +| `device_dvdwrite` | lists of `-R`, `-RW`, `+R`, `+RW`, `-R DL`, `+R DL` | Info | Standard | published | +| `device_dvdwrite` | lists that include `-RW DL`, `-RAM`, or `+RW DL` | Info | Standard | Apple capability constants and installed reporter strings; not live-tested | | `device_strategies` | lists of `CD-TAO`, `CD-SAO`, `CD-Raw`, `DVD-DAO` | Info | Apple | published | | `device_strategies` | lists that include `BD-DAO` | Info | Apple | unconfirmed (Apple constant) | -A list with an entry the app doesn't know is shown as not yet explained, rather -than partly explained. `device_media` values other than `media_none` (a disc in -the drive) are not explained yet. +Format and strategy lists with an unknown or empty entry are shown as not yet +explained, rather than partly explained. DVD-only formats in `device_cdwrite` +remain unexplained. Raw values are unchanged. A listed format is a reported +capability, not proof of a successful burn or compatible inserted media. +`device_media` values other than `media_none` (a disc in the drive) are not +explained yet. + +Support labels follow Apple's framework contract: AppleSupported means tested +by Apple; VendorSupported means tested by a third party; Unsupported still lets +the engine try the drive; None means no support from that engine. They don't +prove a successful burn, maker-provided software, or read-only hardware. +Interconnect values identify an interface, not internal/external location. ## Startup security, software overview, and hardware @@ -470,6 +486,20 @@ A service list names any service the app doesn't know, and a list with none it knows is shown as not yet explained. The accessory types other than these are not explained yet. +Battery levels require a complete unsigned ASCII integer from 0 through 100 +followed by `%`. Surrounding whitespace and a space/nonbreaking space before +`%` are accepted for percent-formatter compatibility. Decimal, signed, +out-of-range, or partially numeric text such as `85garbage%` stays unexplained; +the raw value is retained. The 10% and 20% boundaries are app review thresholds, +not vendor health diagnoses, and a reported level doesn't prove a current +connection or charging state. + +Service lists require a complete `<…>` structure, optionally preceded by a +hexadecimal `0x…` value. Extra surrounding text or nested brackets remain +unexplained. Whitespace-separated unfamiliar names stay visible; the app does +not infer token meanings from the bitmask. BLE and GATT are capabilities, not +both profiles, and listing a capability doesn't prove active use. + ## Settings and profiles Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's