From 450ee2b6d000edf13c121ce9bf00360f2dff190a Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 19:37:37 -0600 Subject: [PATCH 01/10] fix(sdcard): IsNonResultLine no longer false-positives on filenames starting with 'error' (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix, IsNonResultLine returned true for any line starting with the bare substring "ERROR" (case-insensitive). That made GetSdCardFilesAsync silently drop legit SD filenames whose basename starts with "error" — e.g. "error_log.csv", "Errors_summary.bin" — because the permissive classifier treated them as non-result error/status text. Tightened to require ERROR followed by one of: - nothing (the bare "ERROR" line, length 5) - `:` (the SCPI ERROR: prefix) - ` ` or `\t` (firmware "Error !!" / "Error " text) - `!` (firmware "Error!!") The "**ERROR" prefix path is unchanged. IsScpiErrorLine is also unchanged — it's already strict (requires ** or :). New test GetSdCardFilesAsync_FilenamesStartingWithErrorAreNotMisclassified canned three filenames into the LIST? response: error_log.csv, Errors_summary.bin, normal.bin. Asserts all three round-trip into the parsed file list (was: only normal.bin pre-fix). Mirrors the equivalent fix already merged in daqifi-python-core PR #102 (test_sdcard_typed_exceptions.py). 891 tests pass on net9.0 + net10.0 (was 890). --- .../Device/SdCard/SdCardOperationsTests.cs | 27 +++++++++++++++++++ .../Device/DaqifiStreamingDevice.cs | 23 +++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index e59b00e..08107b8 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -68,6 +68,33 @@ public async Task GetSdCardFilesAsync_ParsesResponseCorrectly() Assert.Null(files[1].CreatedDate); } + [Fact] + public async Task GetSdCardFilesAsync_FilenamesStartingWithErrorAreNotMisclassified() + { + // Regression for #190: IsNonResultLine's prior bare "ERROR" + // prefix check false-positived on legit SD filenames whose + // basename starts with "error" (e.g. "error_log.csv"). The + // permissive classifier dropped them from the parsed file + // list. Tightened to require ERROR followed by `:`/` `/`!`/ + // tab so ordinary filenames pass through. + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.CannedTextResponse = new List + { + "Daqifi/error_log.csv", + "Daqifi/Errors_summary.bin", + "Daqifi/normal.bin", + }; + device.Connect(); + + var files = await device.GetSdCardFilesAsync(); + + var names = files.Select(f => f.FileName).ToList(); + Assert.Contains("error_log.csv", names); + Assert.Contains("Errors_summary.bin", names); + Assert.Contains("normal.bin", names); + Assert.Equal(3, files.Count); + } + [Fact] public async Task GetSdCardFilesAsync_RestoresLanInterface() { diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 72238d7..5fbf529 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -749,12 +749,29 @@ private static bool IsScpiErrorLine(string line) // Permissive: any line that looks like a device error or status message, // including firmware text such as "Error !! ...". Used to recognize that // the parser would yield no result, without polluting LastScpiError with - // non-SCPI text. + // non-SCPI text. Closes #190 — bare "ERROR" prefix false-positives on + // legit SD filenames that happen to start with "error" (e.g. + // "error_log.csv"), which would mask the file from GetSdCardFilesAsync's + // returned list. Tightened to require ERROR followed by ":", " " (firmware + // pattern "Error !!"), or "*" (the "**ERROR" SCPI marker, also matched by + // IsScpiErrorLine). private static bool IsNonResultLine(string line) { var trimmed = line.TrimStart(); - return trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase) - || trimmed.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase); + if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) + return true; + // Must be followed by a non-letter so plain filenames like + // "error_log.csv" (which TrimStart leaves intact) don't match. + if (trimmed.Length >= 5 + && trimmed.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) + { + if (trimmed.Length == 5) + return true; + var next = trimmed[5]; + if (next == ':' || next == ' ' || next == '!' || next == '\t') + return true; + } + return false; } /// From c7d18d5a4479dce467581787513459e87ef7539d Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 20:09:41 -0600 Subject: [PATCH 02/10] Apply Qodo /agentic_review pass 1 on PR #195: 3 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (Parser still skips bare ERROR): IsNonResultLine in DaqifiStreamingDevice was tightened in pass 0 but SdCardFileListParser.ParseFileList ALSO had the same bare StartsWith("ERROR") check on the per-line classifier — and that classifier runs on the directory-stripped path, so bare filenames like "error_log.csv" emitted without the "Daqifi/" prefix would still slip through the LIST? throw-on-error gate but get dropped during file-list parsing. Tightened with the same prefix logic factored into IsErrorResponseLine. Bug 3 (IsNonResultLine comment mismatched): pass 0 comment said ERROR is tightened to be followed by ":", " " (firmware "Error !!"), or "*" (the "**ERROR" SCPI marker). Actual code: ":", " ", "!", tab, or end-of-line. Documentation drift; rewrote comment to enumerate the three cases (canonical "**ERROR" marker / "ERROR" exact / "ERROR" followed by :/space/!/tab) accurately. Requirement gap (Missing edge-case sdcard test): pass 0 test used "Daqifi/error_log.csv" path shapes that don't actually start with "error" after TrimStart — the prior bug never fired on those paths. Strengthened the regression test: - Added BARE filenames "error_log.csv", "Errors_summary.bin", "ERROR_archive.bin" (no Daqifi/ prefix) which DO trigger the prior bug. - New test GetSdCardFilesAsync_OnlyErrorPrefixedFilenames_AllSurvive for the all-error-only listing edge case explicitly called out by Qodo (no normal.bin sanity anchor). - New Theory GetSdCardFilesAsync_RealErrorLinesStillSkipped (6 parameterized cases) confirms the tightening didn't go too far — real **ERROR / ERROR: / Error !! / bare ERROR / "error\t..." lines are all still classified as non-result. 898 tests pass on net9.0 + net10.0 (was 891; added 7 net cases). --- .../Device/SdCard/SdCardOperationsTests.cs | 71 +++++++++++++++++-- .../Device/DaqifiStreamingDevice.cs | 8 ++- .../Device/SdCard/SdCardFileListParser.cs | 32 ++++++++- 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index 08107b8..0adaef1 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -71,17 +71,24 @@ public async Task GetSdCardFilesAsync_ParsesResponseCorrectly() [Fact] public async Task GetSdCardFilesAsync_FilenamesStartingWithErrorAreNotMisclassified() { - // Regression for #190: IsNonResultLine's prior bare "ERROR" - // prefix check false-positived on legit SD filenames whose - // basename starts with "error" (e.g. "error_log.csv"). The - // permissive classifier dropped them from the parsed file - // list. Tightened to require ERROR followed by `:`/` `/`!`/ - // tab so ordinary filenames pass through. + // Regression for #190 covering BOTH bug locations: + // - IsNonResultLine in DaqifiStreamingDevice (the LIST? response classifier) + // - SdCardFileListParser.ParseFileList (the per-line parser; bare "ERROR" check) + // Pre-fix, both used a bare "ERROR" StartsWith check that + // false-positived on legit SD filenames. Tightened to require + // ERROR followed by ":"/" "/"!"/tab/end-of-line. + // + // Cover both path shapes the firmware may emit: + // - prefixed: "Daqifi/error_log.csv" + // - bare: "error_log.csv" (no Daqifi/ prefix) var device = new TestableSdCardStreamingDevice("TestDevice"); device.CannedTextResponse = new List { "Daqifi/error_log.csv", "Daqifi/Errors_summary.bin", + "error_log.csv", + "Errors_summary.bin", + "ERROR_archive.bin", "Daqifi/normal.bin", }; device.Connect(); @@ -91,8 +98,60 @@ public async Task GetSdCardFilesAsync_FilenamesStartingWithErrorAreNotMisclassif var names = files.Select(f => f.FileName).ToList(); Assert.Contains("error_log.csv", names); Assert.Contains("Errors_summary.bin", names); + Assert.Contains("ERROR_archive.bin", names); Assert.Contains("normal.bin", names); + } + + [Fact] + public async Task GetSdCardFilesAsync_OnlyErrorPrefixedFilenames_AllSurvive() + { + // Edge case explicitly called out by Qodo on PR #195: a + // listing consisting SOLELY of error*-prefixed filenames + // (no normal.bin to act as a sanity anchor) must round-trip + // every entry. Pre-fix, the entire response would have parsed + // as zero files. + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.CannedTextResponse = new List + { + "error_log.csv", + "errors.bin", + "Erroneous_data.bin", + }; + device.Connect(); + + var files = await device.GetSdCardFilesAsync(); + Assert.Equal(3, files.Count); + var names = files.Select(f => f.FileName).ToList(); + Assert.Contains("error_log.csv", names); + Assert.Contains("errors.bin", names); + Assert.Contains("Erroneous_data.bin", names); + } + + [Theory] + [InlineData("**ERROR: -200, Execution error")] + [InlineData("**Error: bad")] + [InlineData("ERROR: -100, Bad command")] + [InlineData("Error !! Generic firmware status")] + [InlineData("ERROR")] + [InlineData("error\tsomething")] + public async Task GetSdCardFilesAsync_RealErrorLinesStillSkipped(string errorLine) + { + // Confirm the tightening didn't go too far — real error + // lines still classify as non-result and don't end up + // misinterpreted as filenames. + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.CannedTextResponse = new List + { + "Daqifi/normal.bin", + errorLine, + }; + device.Connect(); + + var files = await device.GetSdCardFilesAsync(); + + Assert.Single(files); + Assert.Equal("normal.bin", files[0].FileName); } [Fact] diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 5fbf529..919b010 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -752,9 +752,11 @@ private static bool IsScpiErrorLine(string line) // non-SCPI text. Closes #190 — bare "ERROR" prefix false-positives on // legit SD filenames that happen to start with "error" (e.g. // "error_log.csv"), which would mask the file from GetSdCardFilesAsync's - // returned list. Tightened to require ERROR followed by ":", " " (firmware - // pattern "Error !!"), or "*" (the "**ERROR" SCPI marker, also matched by - // IsScpiErrorLine). + // returned list. Tightened to require either: + // - the canonical "**ERROR" SCPI marker (also matched by IsScpiErrorLine), or + // - "ERROR" exactly (length-5 line), or + // - "ERROR" followed by ":" / " " / "!" / tab — covering both + // SCPI ("ERROR: -100, ...") and firmware ("Error !!", "Error ...") patterns. private static bool IsNonResultLine(string line) { var trimmed = line.TrimStart(); diff --git a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs index a9d5fea..0b11b02 100644 --- a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs +++ b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs @@ -40,9 +40,15 @@ public static IReadOnlyList ParseFileList(IEnumerable li var path = line.Trim(); - // Skip SCPI error responses: "**ERROR: -200, ..." or "ERROR: -200, ..." - if (path.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) + // Skip SCPI error responses: "**ERROR: -200, ..." or + // "ERROR: -200, ...". Bare "ERROR" prefix can't be used + // here because filenames like "error_log.csv" / + // "Errors_summary.bin" emitted without the Daqifi/ + // directory prefix would also match (closes #190 second + // location — IsNonResultLine in DaqifiStreamingDevice + // had the same bug). Require a non-letter follower so + // ordinary filenames pass through. + if (IsErrorResponseLine(path)) { continue; } @@ -108,5 +114,25 @@ public static IReadOnlyList ParseFileList(IEnumerable li return null; } + + // Mirrors DaqifiStreamingDevice.IsNonResultLine (the LIST? response + // classifier). Match `**ERROR` (the canonical SCPI marker) or + // `ERROR` followed by a non-letter so legit filenames whose basename + // starts with "error" / "Errors" pass through. + private static bool IsErrorResponseLine(string line) + { + if (line.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) + return true; + if (line.Length >= 5 + && line.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) + { + if (line.Length == 5) + return true; + var next = line[5]; + if (next == ':' || next == ' ' || next == '!' || next == '\t') + return true; + } + return false; + } } } From b9ccac263c5014c8047278e1f179b751c647828b Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 20:23:35 -0600 Subject: [PATCH 03/10] fix: Apply Qodo /agentic_review pass 2 on PR #195: align comments with code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo flagged that two comments described the bare-ERROR delimiter rule as "followed by a non-letter", but the actual helpers (IsErrorResponseLine in SdCardFileListParser and IsNonResultLine in DaqifiStreamingDevice) only treat ':', ' ', '!', '\t', and end of line as SCPI delimiters. Updating the comments to match prevents a future edit from broadening the check to "any non-letter" and reintroducing the #190 filename false-positive. Comment-only — no behavioral change. --- src/Daqifi.Core/Device/DaqifiStreamingDevice.cs | 5 +++-- src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 919b010..16b5b5a 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -762,8 +762,9 @@ private static bool IsNonResultLine(string line) var trimmed = line.TrimStart(); if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) return true; - // Must be followed by a non-letter so plain filenames like - // "error_log.csv" (which TrimStart leaves intact) don't match. + // Must be followed by an SCPI delimiter (':', ' ', '!', '\t') + // or end of line, so plain filenames like "error_log.csv" + // (which TrimStart leaves intact) don't match. if (trimmed.Length >= 5 && trimmed.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs index 0b11b02..3cf24d3 100644 --- a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs +++ b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs @@ -46,8 +46,9 @@ public static IReadOnlyList ParseFileList(IEnumerable li // "Errors_summary.bin" emitted without the Daqifi/ // directory prefix would also match (closes #190 second // location — IsNonResultLine in DaqifiStreamingDevice - // had the same bug). Require a non-letter follower so - // ordinary filenames pass through. + // had the same bug). Match `ERROR` only when followed + // by an SCPI delimiter (':', ' ', '!', '\t') or end of + // line so ordinary filenames pass through. if (IsErrorResponseLine(path)) { continue; @@ -117,8 +118,9 @@ public static IReadOnlyList ParseFileList(IEnumerable li // Mirrors DaqifiStreamingDevice.IsNonResultLine (the LIST? response // classifier). Match `**ERROR` (the canonical SCPI marker) or - // `ERROR` followed by a non-letter so legit filenames whose basename - // starts with "error" / "Errors" pass through. + // `ERROR` followed by an SCPI delimiter (':', ' ', '!', '\t') or + // end of line, so legit filenames whose basename starts with + // "error" / "Errors" pass through. private static bool IsErrorResponseLine(string line) { if (line.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) From 73cb29e9ebc9ba6e65bb2fe91894be986264290f Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 20:26:55 -0600 Subject: [PATCH 04/10] fix: Apply Qodo /improve pass 3 on PR #195: handle trailing CR in error detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo flagged that IsNonResultLine used TrimStart() while device responses typically end in CRLF. With TrimStart(), a bare "ERROR\r" leaves the '\r' as trimmed[5], failing the SCPI delimiter switch and silently classifying the error line as a regular result. Switched to Trim() to strip trailing CR/LF as well. IsErrorResponseLine in SdCardFileListParser gets the same defensive Trim() — current callers pre-trim the input, but mirroring the helper prevents future reuse from reintroducing the same blind spot. --- src/Daqifi.Core/Device/DaqifiStreamingDevice.cs | 5 ++++- .../Device/SdCard/SdCardFileListParser.cs | 15 ++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 16b5b5a..3911aac 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -759,7 +759,10 @@ private static bool IsScpiErrorLine(string line) // SCPI ("ERROR: -100, ...") and firmware ("Error !!", "Error ...") patterns. private static bool IsNonResultLine(string line) { - var trimmed = line.TrimStart(); + // Trim both ends — bare "ERROR\r" from a CRLF line ending would + // otherwise leave the '\r' as trimmed[5] and fall through the + // delimiter switch below. + var trimmed = line.Trim(); if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) return true; // Must be followed by an SCPI delimiter (':', ' ', '!', '\t') diff --git a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs index 3cf24d3..e5d9934 100644 --- a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs +++ b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs @@ -123,14 +123,19 @@ public static IReadOnlyList ParseFileList(IEnumerable li // "error" / "Errors" pass through. private static bool IsErrorResponseLine(string line) { - if (line.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) + // Defensive Trim — current callers already pre-trim, but a future + // reuse with raw CRLF input must still classify "ERROR\r" as an + // error line rather than letting the trailing '\r' disqualify it + // (mirrors IsNonResultLine in DaqifiStreamingDevice). + var trimmed = line.Trim(); + if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) return true; - if (line.Length >= 5 - && line.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) + if (trimmed.Length >= 5 + && trimmed.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) { - if (line.Length == 5) + if (trimmed.Length == 5) return true; - var next = line[5]; + var next = trimmed[5]; if (next == ':' || next == ' ' || next == '!' || next == '\t') return true; } From 89ed9038be146915e76e5e30392bdb693cf46db6 Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 20:33:31 -0600 Subject: [PATCH 05/10] fix: Apply Qodo /agentic_review pass 4 on PR #195: extract shared error-line helper Both Qodo surfaces (review + suggestions) flagged the same maintenance risk: IsNonResultLine in DaqifiStreamingDevice and IsErrorResponseLine in SdCardFileListParser implement the same predicate, so a future delimiter / trim refinement applied to one would silently miss the other and re-introduce the #190 filename false-positive. Promoted IsErrorResponseLine to internal so DaqifiStreamingDevice can call it; replaced IsNonResultLine's body with a one-liner delegation. The two SD-response classification paths now share a single source of truth for the rule. --- .../Device/DaqifiStreamingDevice.cs | 32 +++---------------- .../Device/SdCard/SdCardFileListParser.cs | 24 ++++++++------ 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 3911aac..f4b9672 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -749,35 +749,13 @@ private static bool IsScpiErrorLine(string line) // Permissive: any line that looks like a device error or status message, // including firmware text such as "Error !! ...". Used to recognize that // the parser would yield no result, without polluting LastScpiError with - // non-SCPI text. Closes #190 — bare "ERROR" prefix false-positives on - // legit SD filenames that happen to start with "error" (e.g. - // "error_log.csv"), which would mask the file from GetSdCardFilesAsync's - // returned list. Tightened to require either: - // - the canonical "**ERROR" SCPI marker (also matched by IsScpiErrorLine), or - // - "ERROR" exactly (length-5 line), or - // - "ERROR" followed by ":" / " " / "!" / tab — covering both - // SCPI ("ERROR: -100, ...") and firmware ("Error !!", "Error ...") patterns. + // non-SCPI text. Delegates to SdCardFileListParser.IsErrorResponseLine + // so the SD-response classification rule (closes #190 — filenames + // starting with "error_" must NOT match) stays in lockstep across + // both call sites. private static bool IsNonResultLine(string line) { - // Trim both ends — bare "ERROR\r" from a CRLF line ending would - // otherwise leave the '\r' as trimmed[5] and fall through the - // delimiter switch below. - var trimmed = line.Trim(); - if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) - return true; - // Must be followed by an SCPI delimiter (':', ' ', '!', '\t') - // or end of line, so plain filenames like "error_log.csv" - // (which TrimStart leaves intact) don't match. - if (trimmed.Length >= 5 - && trimmed.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) - { - if (trimmed.Length == 5) - return true; - var next = trimmed[5]; - if (next == ':' || next == ' ' || next == '!' || next == '\t') - return true; - } - return false; + return SdCardFileListParser.IsErrorResponseLine(line); } /// diff --git a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs index e5d9934..352cf0e 100644 --- a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs +++ b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs @@ -116,17 +116,21 @@ public static IReadOnlyList ParseFileList(IEnumerable li return null; } - // Mirrors DaqifiStreamingDevice.IsNonResultLine (the LIST? response - // classifier). Match `**ERROR` (the canonical SCPI marker) or - // `ERROR` followed by an SCPI delimiter (':', ' ', '!', '\t') or - // end of line, so legit filenames whose basename starts with - // "error" / "Errors" pass through. - private static bool IsErrorResponseLine(string line) + /// + /// Returns true if the line is a SCPI error response (**ERROR + /// canonical marker, or ERROR followed by a SCPI delimiter + /// ':', ' ', '!', '\t', or end of line). + /// Trims both ends so a bare "ERROR\r" from CRLF line endings + /// still classifies. Plain filenames whose basename starts with + /// error / Errors pass through unmatched (closes #190). + /// + /// + /// Shared with DaqifiStreamingDevice.IsNonResultLine so any + /// future delimiter / trim refinement stays consistent across both + /// SD-response classification paths. + /// + internal static bool IsErrorResponseLine(string line) { - // Defensive Trim — current callers already pre-trim, but a future - // reuse with raw CRLF input must still classify "ERROR\r" as an - // error line rather than letting the trailing '\r' disqualify it - // (mirrors IsNonResultLine in DaqifiStreamingDevice). var trimmed = line.Trim(); if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) return true; From 5af721832ebb6c60d4385124b859fcf0e65792a3 Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 20:36:40 -0600 Subject: [PATCH 06/10] fix: Apply Qodo /improve pass 5 on PR #195: unify **ERROR + ERROR delimiter rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo flagged that **ERROR matched on prefix alone while ERROR required a SCPI delimiter follower — a theoretical inconsistency: a filename beginning with **ERROR would be classified as a SCPI error even without the delimiter. FAT/exFAT reserves '*' so this can't actually happen in SD card listings, but consistency between the two prefix checks is cheap and removes the inconsistency for future readers / non-FAT consumers. Refactored to a shared MatchesErrorPrefix helper that both prefixes call with the same delimiter rule. --- .../Device/SdCard/SdCardFileListParser.cs | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs index 352cf0e..22f75ba 100644 --- a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs +++ b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs @@ -117,12 +117,13 @@ public static IReadOnlyList ParseFileList(IEnumerable li } /// - /// Returns true if the line is a SCPI error response (**ERROR - /// canonical marker, or ERROR followed by a SCPI delimiter - /// ':', ' ', '!', '\t', or end of line). - /// Trims both ends so a bare "ERROR\r" from CRLF line endings - /// still classifies. Plain filenames whose basename starts with - /// error / Errors pass through unmatched (closes #190). + /// Returns true if the line is a SCPI error response — either the + /// canonical **ERROR marker or a bare ERROR token, in + /// each case followed by a SCPI delimiter (:, space, !, + /// tab) or end of line. Trims both ends so a bare "ERROR\r" + /// from CRLF line endings still classifies. Plain filenames whose + /// basename starts with error / Errors pass through + /// unmatched (closes #190). /// /// /// Shared with DaqifiStreamingDevice.IsNonResultLine so any @@ -132,18 +133,19 @@ public static IReadOnlyList ParseFileList(IEnumerable li internal static bool IsErrorResponseLine(string line) { var trimmed = line.Trim(); - if (trimmed.StartsWith("**ERROR", StringComparison.OrdinalIgnoreCase)) + return MatchesErrorPrefix(trimmed, "**ERROR") + || MatchesErrorPrefix(trimmed, "ERROR"); + } + + private static bool MatchesErrorPrefix(string trimmed, string prefix) + { + if (trimmed.Length < prefix.Length + || !trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + if (trimmed.Length == prefix.Length) return true; - if (trimmed.Length >= 5 - && trimmed.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) - { - if (trimmed.Length == 5) - return true; - var next = trimmed[5]; - if (next == ':' || next == ' ' || next == '!' || next == '\t') - return true; - } - return false; + var next = trimmed[prefix.Length]; + return next == ':' || next == ' ' || next == '!' || next == '\t'; } } } From dfb3e2262baeea4b42b44e59185aca7e802ed154 Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Tue, 12 May 2026 13:08:15 -0600 Subject: [PATCH 07/10] Apply Qodo /agentic_review pass 6: tighten ! to !! + accurate doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 2 (error! filename misclassified): MatchesErrorPrefix treated a single '!' after "ERROR" as a delimiter, so legitimate filenames like "error!log.bin" were dropped from SD listings. Tightened to require '!!' (firmware always sends "Error !!" with space; the bang-only delimiter was purely defensive against a no-space "Error!!" variant — '!!' still catches that case while letting single-bang filenames pass.) Bug 6 (SCPI-only doc misleading): rewrote IsErrorResponseLine summary to accurately describe its dual role as a SCPI error detector AND a permissive non-result classifier for firmware status text — prevents future maintainers from "tightening" to SCPI-only and re-breaking SD listing classification. Tests: added Theory case for "Error!! No space" (still classifies), added Theory GetSdCardFilesAsync_FilenamesWithSingleBangSurvive (3 cases) for the regression. 216/216 pass. --- .../Device/SdCard/SdCardOperationsTests.cs | 31 +++++++++++++++++++ .../Device/SdCard/SdCardFileListParser.cs | 28 ++++++++++++----- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index 0adaef1..b456249 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -133,6 +133,7 @@ public async Task GetSdCardFilesAsync_OnlyErrorPrefixedFilenames_AllSurvive() [InlineData("**Error: bad")] [InlineData("ERROR: -100, Bad command")] [InlineData("Error !! Generic firmware status")] + [InlineData("Error!! No space firmware status")] [InlineData("ERROR")] [InlineData("error\tsomething")] public async Task GetSdCardFilesAsync_RealErrorLinesStillSkipped(string errorLine) @@ -154,6 +155,36 @@ public async Task GetSdCardFilesAsync_RealErrorLinesStillSkipped(string errorLin Assert.Equal("normal.bin", files[0].FileName); } + [Theory] + [InlineData("error!log.bin")] + [InlineData("Daqifi/error!log.bin")] + [InlineData("Erroneous!data.bin")] + public async Task GetSdCardFilesAsync_FilenamesWithSingleBangSurvive(string filename) + { + // Regression: a single '!' immediately after "error" is ambiguous + // (could be a filename like "error!log.bin"). The classifier must + // require '!!' to treat as an error/status line so legitimate + // filenames aren't dropped from listings. Filename validation + // already permits '!' in SD filenames. + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.CannedTextResponse = new List + { + "Daqifi/normal.bin", + filename, + }; + device.Connect(); + + var files = await device.GetSdCardFilesAsync(); + + var names = files.Select(f => f.FileName).ToList(); + Assert.Equal(2, names.Count); + Assert.Contains("normal.bin", names); + // filename may or may not have the Daqifi/ prefix stripped depending + // on the parser's path handling; just confirm it survived. + var expected = filename.StartsWith("Daqifi/") ? filename.Substring("Daqifi/".Length) : filename; + Assert.Contains(expected, names); + } + [Fact] public async Task GetSdCardFilesAsync_RestoresLanInterface() { diff --git a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs index 22f75ba..5638410 100644 --- a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs +++ b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs @@ -117,13 +117,18 @@ public static IReadOnlyList ParseFileList(IEnumerable li } /// - /// Returns true if the line is a SCPI error response — either the - /// canonical **ERROR marker or a bare ERROR token, in - /// each case followed by a SCPI delimiter (:, space, !, - /// tab) or end of line. Trims both ends so a bare "ERROR\r" - /// from CRLF line endings still classifies. Plain filenames whose - /// basename starts with error / Errors pass through - /// unmatched (closes #190). + /// Returns true if the line is a non-result error/status line that + /// SD listing parsers should drop. Matches both SCPI error responses + /// (canonical **ERROR marker, bare ERROR token followed + /// by a SCPI delimiter : / space / tab / end-of-line) and + /// firmware status text (Error !! ... with space, or the + /// no-space Error!! form). A double-! is required when + /// no other delimiter is present so legitimate filenames like + /// error!log.bin aren't dropped — single ! alone is + /// ambiguous between error-status and filename. Trims both ends so + /// a bare "ERROR\r" from CRLF line endings still classifies. + /// Plain filenames whose basename starts with error / + /// Errors pass through unmatched (closes #190). /// /// /// Shared with DaqifiStreamingDevice.IsNonResultLine so any @@ -145,7 +150,14 @@ private static bool MatchesErrorPrefix(string trimmed, string prefix) if (trimmed.Length == prefix.Length) return true; var next = trimmed[prefix.Length]; - return next == ':' || next == ' ' || next == '!' || next == '\t'; + if (next == ':' || next == ' ' || next == '\t') + return true; + // Single '!' is ambiguous (could be a filename like "error!log.bin"). + // Require '!!' so we still catch firmware "Error!!" status text but + // let plain filenames pass through. + return next == '!' + && trimmed.Length > prefix.Length + 1 + && trimmed[prefix.Length + 1] == '!'; } } } From 52a53ac34205908bb9289e2614574d83d145f35a Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Tue, 12 May 2026 13:21:05 -0600 Subject: [PATCH 08/10] Apply Qodo /agentic_review pass 7: case-insensitive prefix in test Mirror the production parser's case-insensitive Daqifi/ prefix strip (StringComparison.OrdinalIgnoreCase) in the FilenamesWithSingleBangSurvive theory so future test cases with "daqifi/" / "DAQIFI/" don't false-fail. --- .../Device/SdCard/SdCardOperationsTests.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index b456249..787327d 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -179,9 +179,14 @@ public async Task GetSdCardFilesAsync_FilenamesWithSingleBangSurvive(string file var names = files.Select(f => f.FileName).ToList(); Assert.Equal(2, names.Count); Assert.Contains("normal.bin", names); - // filename may or may not have the Daqifi/ prefix stripped depending - // on the parser's path handling; just confirm it survived. - var expected = filename.StartsWith("Daqifi/") ? filename.Substring("Daqifi/".Length) : filename; + // The parser strips the "Daqifi/" prefix case-insensitively + // (StringComparison.OrdinalIgnoreCase), so mirror that here — + // a case-sensitive StartsWith would falsely fail if a future + // test case used "daqifi/" or "DAQIFI/". + const string daqifiPrefix = "Daqifi/"; + var expected = filename.StartsWith(daqifiPrefix, StringComparison.OrdinalIgnoreCase) + ? filename.Substring(daqifiPrefix.Length) + : filename; Assert.Contains(expected, names); } From 96dd1cb17a4e3d97b6658154e4a12e2b10161e84 Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Tue, 12 May 2026 13:26:17 -0600 Subject: [PATCH 09/10] Apply Qodo /agentic_review pass 8: Path.GetFileName in test expected Mirror the production parser's full normalization (Daqifi/ strip THEN Path.GetFileName) when computing the expected filename in the FilenamesWithSingleBangSurvive theory. Stripping only the prefix would diverge from production on nested-path entries like "Daqifi/sub/file.bin". --- .../Device/SdCard/SdCardOperationsTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index 787327d..f94a0b3 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -179,14 +179,16 @@ public async Task GetSdCardFilesAsync_FilenamesWithSingleBangSurvive(string file var names = files.Select(f => f.FileName).ToList(); Assert.Equal(2, names.Count); Assert.Contains("normal.bin", names); - // The parser strips the "Daqifi/" prefix case-insensitively - // (StringComparison.OrdinalIgnoreCase), so mirror that here — - // a case-sensitive StartsWith would falsely fail if a future - // test case used "daqifi/" or "DAQIFI/". + // Mirror production normalization in SdCardFileListParser: + // 1. strip Daqifi/ prefix case-insensitively + // 2. apply Path.GetFileName so any nested path collapses to its basename + // Doing only step 1 would falsely fail on a future "Daqifi/sub/file.bin" + // test case, where production returns just "file.bin". const string daqifiPrefix = "Daqifi/"; var expected = filename.StartsWith(daqifiPrefix, StringComparison.OrdinalIgnoreCase) ? filename.Substring(daqifiPrefix.Length) : filename; + expected = Path.GetFileName(expected); Assert.Contains(expected, names); } From 4b018de0192a2617fd79de27ca5d5f6916c88c6d Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 13 May 2026 08:57:34 -0600 Subject: [PATCH 10/10] refactor: move error-line classifier to neutral ScpiResponseClassifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple DaqifiStreamingDevice from SdCardFileListParser by relocating the shared IsErrorResponseLine/MatchesErrorPrefix helpers into a neutral internal ScpiResponseClassifier class. Previously the general SCPI response classifier reached into the SD-card-specific module, coupling unrelated layers. Also fix an OS-dependent test expectation: the FilenamesWithSingleBangSurvive theory used Path.GetFileName, which treats '\\' as a separator on Windows but not on Linux/macOS. Replaced with an explicit LastIndexOf('/') split since the device protocol uses forward slashes. No behavior change. All 902 tests pass on net9.0 + net10.0. Verified on real device (Nq1, FW 3.4.4) via --sd-list — normal listings parse correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Device/SdCard/SdCardOperationsTests.cs | 16 +++-- .../Device/DaqifiStreamingDevice.cs | 9 ++- .../Device/ScpiResponseClassifier.cs | 53 +++++++++++++++++ .../Device/SdCard/SdCardFileListParser.cs | 59 ++----------------- 4 files changed, 72 insertions(+), 65 deletions(-) create mode 100644 src/Daqifi.Core/Device/ScpiResponseClassifier.cs diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index f94a0b3..1910019 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -179,16 +179,20 @@ public async Task GetSdCardFilesAsync_FilenamesWithSingleBangSurvive(string file var names = files.Select(f => f.FileName).ToList(); Assert.Equal(2, names.Count); Assert.Contains("normal.bin", names); - // Mirror production normalization in SdCardFileListParser: - // 1. strip Daqifi/ prefix case-insensitively - // 2. apply Path.GetFileName so any nested path collapses to its basename - // Doing only step 1 would falsely fail on a future "Daqifi/sub/file.bin" - // test case, where production returns just "file.bin". + // Mirror production normalization: strip the Daqifi/ prefix then keep + // the basename. Split on '/' explicitly (not Path.GetFileName) — the + // device protocol uses forward slashes, and Path.GetFileName treats + // '\\' as a separator on Windows but not on Linux/macOS, which would + // make this expectation OS-dependent if a future case used '\\'. const string daqifiPrefix = "Daqifi/"; var expected = filename.StartsWith(daqifiPrefix, StringComparison.OrdinalIgnoreCase) ? filename.Substring(daqifiPrefix.Length) : filename; - expected = Path.GetFileName(expected); + var lastSlash = expected.LastIndexOf('/'); + if (lastSlash >= 0) + { + expected = expected.Substring(lastSlash + 1); + } Assert.Contains(expected, names); } diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index f4b9672..5c7a362 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -749,13 +749,12 @@ private static bool IsScpiErrorLine(string line) // Permissive: any line that looks like a device error or status message, // including firmware text such as "Error !! ...". Used to recognize that // the parser would yield no result, without polluting LastScpiError with - // non-SCPI text. Delegates to SdCardFileListParser.IsErrorResponseLine - // so the SD-response classification rule (closes #190 — filenames - // starting with "error_" must NOT match) stays in lockstep across - // both call sites. + // non-SCPI text. Shared classifier so the SD-response rule (closes #190 + // — filenames starting with "error_" must NOT match) stays in lockstep + // across both call sites. private static bool IsNonResultLine(string line) { - return SdCardFileListParser.IsErrorResponseLine(line); + return ScpiResponseClassifier.IsErrorResponseLine(line); } /// diff --git a/src/Daqifi.Core/Device/ScpiResponseClassifier.cs b/src/Daqifi.Core/Device/ScpiResponseClassifier.cs new file mode 100644 index 0000000..af5d1f1 --- /dev/null +++ b/src/Daqifi.Core/Device/ScpiResponseClassifier.cs @@ -0,0 +1,53 @@ +using System; + +#nullable enable + +namespace Daqifi.Core.Device +{ + /// + /// Shared classification for SCPI text response lines. Used by both the + /// general streaming-device response handling and SD card listing parsing + /// so the error/status line rule stays consistent across call sites. + /// + internal static class ScpiResponseClassifier + { + /// + /// Returns true if the line is a non-result error/status line that + /// response parsers should drop. Matches both SCPI error responses + /// (canonical **ERROR marker, bare ERROR token followed + /// by a SCPI delimiter : / space / tab / end-of-line) and + /// firmware status text (Error !! ... with space, or the + /// no-space Error!! form). A double-! is required when + /// no other delimiter is present so legitimate filenames like + /// error!log.bin aren't dropped — single ! alone is + /// ambiguous between error-status and filename. Trims both ends so + /// a bare "ERROR\r" from CRLF line endings still classifies. + /// Plain filenames whose basename starts with error / + /// Errors pass through unmatched (closes #190). + /// + internal static bool IsErrorResponseLine(string line) + { + var trimmed = line.Trim(); + return MatchesErrorPrefix(trimmed, "**ERROR") + || MatchesErrorPrefix(trimmed, "ERROR"); + } + + private static bool MatchesErrorPrefix(string trimmed, string prefix) + { + if (trimmed.Length < prefix.Length + || !trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + if (trimmed.Length == prefix.Length) + return true; + var next = trimmed[prefix.Length]; + if (next == ':' || next == ' ' || next == '\t') + return true; + // Single '!' is ambiguous (could be a filename like "error!log.bin"). + // Require '!!' so we still catch firmware "Error!!" status text but + // let plain filenames pass through. + return next == '!' + && trimmed.Length > prefix.Length + 1 + && trimmed[prefix.Length + 1] == '!'; + } + } +} diff --git a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs index 5638410..06643e2 100644 --- a/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs +++ b/src/Daqifi.Core/Device/SdCard/SdCardFileListParser.cs @@ -40,16 +40,11 @@ public static IReadOnlyList ParseFileList(IEnumerable li var path = line.Trim(); - // Skip SCPI error responses: "**ERROR: -200, ..." or - // "ERROR: -200, ...". Bare "ERROR" prefix can't be used - // here because filenames like "error_log.csv" / - // "Errors_summary.bin" emitted without the Daqifi/ - // directory prefix would also match (closes #190 second - // location — IsNonResultLine in DaqifiStreamingDevice - // had the same bug). Match `ERROR` only when followed - // by an SCPI delimiter (':', ' ', '!', '\t') or end of - // line so ordinary filenames pass through. - if (IsErrorResponseLine(path)) + // Skip SCPI error responses ("**ERROR: -200, ...", "ERROR: -200, ...") + // and firmware status text ("Error !! ..."). Classification rule lives + // in ScpiResponseClassifier so it stays consistent with + // DaqifiStreamingDevice.IsNonResultLine (closes #190). + if (ScpiResponseClassifier.IsErrorResponseLine(path)) { continue; } @@ -115,49 +110,5 @@ public static IReadOnlyList ParseFileList(IEnumerable li return null; } - - /// - /// Returns true if the line is a non-result error/status line that - /// SD listing parsers should drop. Matches both SCPI error responses - /// (canonical **ERROR marker, bare ERROR token followed - /// by a SCPI delimiter : / space / tab / end-of-line) and - /// firmware status text (Error !! ... with space, or the - /// no-space Error!! form). A double-! is required when - /// no other delimiter is present so legitimate filenames like - /// error!log.bin aren't dropped — single ! alone is - /// ambiguous between error-status and filename. Trims both ends so - /// a bare "ERROR\r" from CRLF line endings still classifies. - /// Plain filenames whose basename starts with error / - /// Errors pass through unmatched (closes #190). - /// - /// - /// Shared with DaqifiStreamingDevice.IsNonResultLine so any - /// future delimiter / trim refinement stays consistent across both - /// SD-response classification paths. - /// - internal static bool IsErrorResponseLine(string line) - { - var trimmed = line.Trim(); - return MatchesErrorPrefix(trimmed, "**ERROR") - || MatchesErrorPrefix(trimmed, "ERROR"); - } - - private static bool MatchesErrorPrefix(string trimmed, string prefix) - { - if (trimmed.Length < prefix.Length - || !trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - return false; - if (trimmed.Length == prefix.Length) - return true; - var next = trimmed[prefix.Length]; - if (next == ':' || next == ' ' || next == '\t') - return true; - // Single '!' is ambiguous (could be a filename like "error!log.bin"). - // Require '!!' so we still catch firmware "Error!!" status text but - // let plain filenames pass through. - return next == '!' - && trimmed.Length > prefix.Length + 1 - && trimmed[prefix.Length + 1] == '!'; - } } }