Skip to content

Commit 2bcdcd9

Browse files
committed
feat(bundle): fold debug capture into support bundle (2026.6.16.2-23BD)
Switch live Picos to debug input during bundle capture, harvest packet diagnostics, and restore the original persona afterward. Keep lower-level diagnostic commands out of normal help and support guidance. Treat RP2350 UF2 flash resets like cold boots when saved credentials exist.
1 parent 09fd3cc commit 2bcdcd9

15 files changed

Lines changed: 354 additions & 159 deletions

bridge/src/cmd_bundle/manifest.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::{config, logfile};
88

99
use super::collect::BUNDLE_LOG_FILES_PER_PREFIX;
1010

11-
pub(super) const BUNDLE_SCHEMA_VERSION: u8 = 19;
11+
pub(super) const BUNDLE_SCHEMA_VERSION: u8 = 20;
1212

1313
#[derive(Clone, Debug, Serialize)]
1414
pub(super) struct ManifestPicoCapture {
@@ -175,6 +175,8 @@ pub(super) async fn build_manifest(
175175
"BOOTSEL drives are inventoried when present.",
176176
"Offline Pico boards are represented from the local diagnostic cache and saved config when available.",
177177
"Debug input mode uses the XInput USB shape and logs raw USB IN/OUT packet samples for adapter reverse engineering.",
178+
"For each live Pico, bundle attempts an automatic debug input capture cycle: switch to debug mode, wait for USB polling, harvest GET_LOG, then restore the original persona.",
179+
"If the automatic debug input cycle cannot complete, bundle-capture.txt records the failed step, duration, and reason.",
178180
"While debug input mode is streaming, the bridge periodically drains the Pico diag ring into retained host packet logs so later bundles can include them.",
179181
"When bundle finds a live Pico already in debug input mode, it performs a bundle-time GET_LOG harvest and records the harvest health in that Pico's usb-packets.txt.",
180182
"Retained debug packet logs include per-harvest health records for GET_LOG duration, chunks, lost bytes, packet counts, and failures.",

bridge/src/cmd_bundle/mod.rs

Lines changed: 244 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ use zip::write::SimpleFileOptions;
2727
use zip::ZipWriter;
2828

2929
use crate::{
30-
cmd_run, cmd_usb_diag, config, debug_packets, journal, pico_cache, pico_state, protocol,
30+
cmd_persona, cmd_run, cmd_usb_diag, config, debug_packets, journal, pico_cache, pico_mode,
31+
pico_state, protocol,
3132
};
3233

3334
use collect::{bundle_log_prefix, collect_crash_file_names, collect_setup_transcript_names};
@@ -50,6 +51,9 @@ use usb_packet_summary::{
5051
};
5152

5253
const BUNDLE_DEBUG_PACKET_HARVEST_TIMEOUT: Duration = Duration::from_secs(2);
54+
const BUNDLE_DEBUG_PERSONA_WAIT: Duration = Duration::from_secs(60);
55+
const BUNDLE_DEBUG_USB_SETTLE: Duration = Duration::from_secs(3);
56+
const BUNDLE_RESTORE_PERSONA_WAIT: Duration = Duration::from_secs(60);
5357

5458
/// Structured result of a bundle build. Returned by `build_bundle` so
5559
/// callers get a typed answer without
@@ -107,6 +111,12 @@ struct RetainedDebugPacketLog {
107111
text: String,
108112
}
109113

114+
#[derive(Clone, Debug)]
115+
struct BundleUsbPacketCapture {
116+
text: String,
117+
debug_target: Option<cmd_run::PicoTarget>,
118+
}
119+
110120
#[derive(Default)]
111121
struct CaptureLog {
112122
lines: Vec<String>,
@@ -763,7 +773,7 @@ async fn capture_usb_diag_text() -> UsbDiagBundle {
763773
Suggested next step:\n\
764774
- Make sure the Pico is powered, has joined Wi-Fi, and is still plugged into the console adapter.\n\
765775
- Run `couchlink.exe bundle` again immediately after the failure.\n\
766-
- If the Pico is on Wi-Fi but broadcast discovery is blocked, run `couchlink.exe doctor` once so the last-known IP is saved.\n\n\
776+
- If the Pico is on Wi-Fi but broadcast discovery is blocked, choose `Enter Pico IP manually` from the guided menu once, then run bundle again.\n\n\
767777
Diagnostic details:\n\
768778
error={e:#}\n"
769779
),
@@ -1065,8 +1075,15 @@ async fn capture_one_pico(
10651075
pico_state_status = target_pico_state_status;
10661076
pico_diag_status = target_pico_diag_status;
10671077
pico_diag_text = target_pico_diag_text;
1068-
usb_packets_text =
1078+
let packet_capture =
10691079
bundle_usb_packets_for_target(&seed.uid, target, &pico_diag_text, capture_log).await;
1080+
if let Some(debug_target) = packet_capture.debug_target.as_ref() {
1081+
snapshot =
1082+
pico_cache::PicoStateSnapshot::from_target("bundle-debug-capture", debug_target)
1083+
.with_outcome("bundle: automatic debug input capture");
1084+
pico_cache::record(snapshot.clone());
1085+
}
1086+
usb_packets_text = packet_capture.text;
10701087
usb_diag_status = target_usb_diag_status;
10711088
usb_diag_text = target_usb_diag_text;
10721089
state_json_from_snapshot(&snapshot)
@@ -1154,11 +1171,153 @@ async fn bundle_usb_packets_for_target(
11541171
target: &cmd_run::PicoTarget,
11551172
fallback_diag_text: &str,
11561173
capture_log: &mut CaptureLog,
1157-
) -> String {
1158-
if target.persona != protocol::Persona::Debug {
1159-
return usb_packets_text_from_diag(uid, fallback_diag_text);
1174+
) -> BundleUsbPacketCapture {
1175+
if target.persona == protocol::Persona::Debug {
1176+
return BundleUsbPacketCapture {
1177+
text: harvest_debug_packets_for_target(uid, target, fallback_diag_text, capture_log)
1178+
.await,
1179+
debug_target: Some(target.clone()),
1180+
};
1181+
}
1182+
1183+
let original_persona = target.persona;
1184+
let started = Instant::now();
1185+
let mut note = String::new();
1186+
match pico_mode::request_set_persona(target, protocol::Persona::Debug).await {
1187+
Ok(()) => {
1188+
capture_log.record(
1189+
format!("per_pico.{uid}.debug_cycle_request"),
1190+
started,
1191+
"sent",
1192+
1,
1193+
format!("from={} to=debug", original_persona.label()),
1194+
);
1195+
}
1196+
Err(e) => {
1197+
capture_log.record(
1198+
format!("per_pico.{uid}.debug_cycle_request"),
1199+
started,
1200+
"error",
1201+
0,
1202+
format!("{e:#}"),
1203+
);
1204+
let mut text = usb_packets_text_from_diag(uid, fallback_diag_text);
1205+
let _ = writeln!(
1206+
text,
1207+
"# bundle-debug-cycle status=request_failed from={} error={}",
1208+
original_persona.label(),
1209+
sanitize_log_field(&format!("{e:#}"))
1210+
);
1211+
return BundleUsbPacketCapture {
1212+
text,
1213+
debug_target: None,
1214+
};
1215+
}
1216+
}
1217+
1218+
let started = Instant::now();
1219+
let matched = match cmd_persona::wait_for_persona(
1220+
&[target.info.unique_id_short],
1221+
protocol::Persona::Debug,
1222+
BUNDLE_DEBUG_PERSONA_WAIT,
1223+
)
1224+
.await
1225+
{
1226+
Ok(matched) => matched,
1227+
Err(e) => {
1228+
capture_log.record(
1229+
format!("per_pico.{uid}.debug_cycle_wait"),
1230+
started,
1231+
"error",
1232+
0,
1233+
format!("{e:#}"),
1234+
);
1235+
let mut text = usb_packets_text_from_diag(uid, fallback_diag_text);
1236+
let _ = writeln!(
1237+
text,
1238+
"# bundle-debug-cycle status=wait_failed from={} error={}",
1239+
original_persona.label(),
1240+
sanitize_log_field(&format!("{e:#}"))
1241+
);
1242+
return BundleUsbPacketCapture {
1243+
text,
1244+
debug_target: None,
1245+
};
1246+
}
1247+
};
1248+
let observed = format_observed_personas(&matched);
1249+
let debug_target = matched
1250+
.iter()
1251+
.find(|pico| pico.persona == protocol::Persona::Debug)
1252+
.cloned();
1253+
capture_log.record(
1254+
format!("per_pico.{uid}.debug_cycle_wait"),
1255+
started,
1256+
if debug_target.is_some() {
1257+
"confirmed"
1258+
} else {
1259+
"not_confirmed"
1260+
},
1261+
matched.len(),
1262+
format!("observed={observed}"),
1263+
);
1264+
1265+
let Some(debug_target) = debug_target else {
1266+
if let Some(current) = matched.first() {
1267+
if current.persona != original_persona {
1268+
restore_persona_after_bundle(uid, current, original_persona, capture_log).await;
1269+
}
1270+
}
1271+
let mut text = usb_packets_text_from_diag(uid, fallback_diag_text);
1272+
let _ = writeln!(
1273+
text,
1274+
"# bundle-debug-cycle status=not_confirmed from={} observed={}",
1275+
original_persona.label(),
1276+
observed
1277+
);
1278+
return BundleUsbPacketCapture {
1279+
text,
1280+
debug_target: None,
1281+
};
1282+
};
1283+
1284+
capture_log.record_duration(
1285+
format!("per_pico.{uid}.debug_usb_settle"),
1286+
BUNDLE_DEBUG_USB_SETTLE.as_millis() as u64,
1287+
"sleep",
1288+
0,
1289+
"allow USB host to enumerate and poll debug input persona",
1290+
);
1291+
tokio::time::sleep(BUNDLE_DEBUG_USB_SETTLE).await;
1292+
1293+
let text =
1294+
harvest_debug_packets_for_target(uid, &debug_target, fallback_diag_text, capture_log).await;
1295+
1296+
if original_persona != protocol::Persona::Debug {
1297+
restore_persona_after_bundle(uid, &debug_target, original_persona, capture_log).await;
1298+
let _ = writeln!(
1299+
note,
1300+
"# bundle-debug-cycle restore_requested persona={}",
1301+
original_persona.label()
1302+
);
11601303
}
11611304

1305+
let mut text = text;
1306+
if !note.is_empty() {
1307+
text.push_str(&note);
1308+
}
1309+
BundleUsbPacketCapture {
1310+
text,
1311+
debug_target: Some(debug_target),
1312+
}
1313+
}
1314+
1315+
async fn harvest_debug_packets_for_target(
1316+
uid: &str,
1317+
target: &cmd_run::PicoTarget,
1318+
fallback_diag_text: &str,
1319+
capture_log: &mut CaptureLog,
1320+
) -> String {
11621321
let started = Instant::now();
11631322
match debug_packets::capture_run_diag_log(target.peer, BUNDLE_DEBUG_PACKET_HARVEST_TIMEOUT)
11641323
.await
@@ -1201,6 +1360,82 @@ async fn bundle_usb_packets_for_target(
12011360
}
12021361
}
12031362

1363+
async fn restore_persona_after_bundle(
1364+
uid: &str,
1365+
target: &cmd_run::PicoTarget,
1366+
persona: protocol::Persona,
1367+
capture_log: &mut CaptureLog,
1368+
) {
1369+
let started = Instant::now();
1370+
match pico_mode::request_set_persona(target, persona).await {
1371+
Ok(()) => capture_log.record(
1372+
format!("per_pico.{uid}.restore_persona_request"),
1373+
started,
1374+
"sent",
1375+
1,
1376+
format!("persona={}", persona.label()),
1377+
),
1378+
Err(e) => {
1379+
capture_log.record(
1380+
format!("per_pico.{uid}.restore_persona_request"),
1381+
started,
1382+
"error",
1383+
0,
1384+
format!("{e:#}"),
1385+
);
1386+
return;
1387+
}
1388+
}
1389+
1390+
let started = Instant::now();
1391+
match cmd_persona::wait_for_persona(
1392+
&[target.info.unique_id_short],
1393+
persona,
1394+
BUNDLE_RESTORE_PERSONA_WAIT,
1395+
)
1396+
.await
1397+
{
1398+
Ok(matched) => {
1399+
let restored = matched.iter().find(|pico| pico.persona == persona);
1400+
capture_log.record(
1401+
format!("per_pico.{uid}.restore_persona_wait"),
1402+
started,
1403+
if restored.is_some() {
1404+
"confirmed"
1405+
} else {
1406+
"not_confirmed"
1407+
},
1408+
matched.len(),
1409+
format!("observed={}", format_observed_personas(&matched)),
1410+
);
1411+
if let Some(restored) = restored {
1412+
pico_cache::record(
1413+
pico_cache::PicoStateSnapshot::from_target("bundle-restore", restored)
1414+
.with_outcome(format!("restored_{}", persona.label())),
1415+
);
1416+
}
1417+
}
1418+
Err(e) => capture_log.record(
1419+
format!("per_pico.{uid}.restore_persona_wait"),
1420+
started,
1421+
"error",
1422+
0,
1423+
format!("{e:#}"),
1424+
),
1425+
}
1426+
}
1427+
1428+
fn format_observed_personas(targets: &[cmd_run::PicoTarget]) -> String {
1429+
if targets.is_empty() {
1430+
return "none".to_string();
1431+
}
1432+
targets
1433+
.iter()
1434+
.map(|target| format!("{}:{}", target.uid_hex(), target.persona.label()))
1435+
.collect::<Vec<_>>()
1436+
.join(",")
1437+
}
1438+
12041439
fn saved_picos_from_config(cfg: &config::Config) -> Vec<config::PicoIdentity> {
12051440
let mut out = Vec::new();
12061441
let mut seen = BTreeSet::new();
@@ -1664,13 +1899,13 @@ fn debug_capture_verdict_text(
16641899
out.push_str("next_steps=\n");
16651900
if summary.aggregate.packet_lines == 0 {
16661901
out.push_str(
1667-
"- Switch the target Pico to debug input mode, reproduce adapter traffic, then run bundle again.\n",
1902+
"- Run bundle again with the Pico powered and connected to the adapter; bundle switches to debug input mode and restores the original mode automatically.\n",
16681903
);
16691904
out.push_str(
1670-
"- Keep the bridge stream running while reproducing the issue so retained debug packet harvests can write debug-packets/*.log.\n",
1905+
"- If bundle-capture.txt shows debug_cycle_request, debug_cycle_wait, debug_packet_harvest, or GET_LOG failures, use that row's reason as the failure point.\n",
16711906
);
16721907
out.push_str(
1673-
"- If harvest_statuses shows error, check bundle-capture.txt and debug-packets/*.log for GET_LOG failures and timing.\n",
1908+
"- If retained debug-packets/*.log files are present, include the whole bundle; those files contain prior stream-time harvest evidence.\n",
16741909
);
16751910
} else {
16761911
out.push_str(

bridge/src/cmd_bundle/pico_diag.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,9 @@ impl DiagOutcome {
223223
&[
224224
"Run `couchlink setup` to provision a Pico (flash + Wi-Fi).",
225225
"Or, if a Pico is already running on your LAN, run \
226-
`couchlink doctor` -- if discovery succeeds it will be \
227-
recorded in config and the next bundle can probe it.",
226+
`couchlink bundle` from the bridge PC and choose \
227+
`Enter Pico IP manually` from the menu if broadcast \
228+
discovery is blocked.",
228229
],
229230
&[],
230231
),
@@ -288,7 +289,7 @@ impl DiagOutcome {
288289
`couchlink setup`.",
289290
"If you have multiple network adapters, make sure the bridge \
290291
is allowed through Windows Firewall on the active profile. \
291-
`couchlink doctor` will surface a firewall mismatch.",
292+
`couchlink bundle` includes the firewall and network snapshot.",
292293
],
293294
&[("error", reason)],
294295
),

bridge/src/cmd_configure_wifi.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ async fn handle_running_picos(mut picos: Vec<cmd_run::PicoTarget>) -> Result<Opt
148148
.context(
149149
"the Pico did not reappear as setup-mode USB. If this firmware is older, \
150150
update it with the newest ZIP first; otherwise unplug/replug the Pico and \
151-
run `couchlink doctor`.",
151+
run `couchlink bundle`.",
152152
)
153153
}
154154
_ => Ok(None),
@@ -193,8 +193,10 @@ async fn print_discovered_pico_ips(before: BTreeSet<u32>) -> Result<()> {
193193
let now = Instant::now();
194194
if now >= deadline {
195195
println!("No Pico replied yet.");
196-
println!("Run `couchlink test discover --all` after the Pico has joined Wi-Fi.");
197-
println!("If your router shows its IP, run `couchlink test discover --ip <ip>`.");
196+
println!("If your router shows its IP, choose `Enter Pico IP manually` from the menu.");
197+
println!(
198+
"If it still fails, run `couchlink bundle` and attach the zip to a bug report."
199+
);
198200
return Ok(());
199201
}
200202
if now >= next_beat {

bridge/src/cmd_doctor.rs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,6 @@ pub async fn run() -> Result<()> {
4646
Ok(())
4747
}
4848

49-
pub async fn run_interactive() -> Result<()> {
50-
let summary = run_checks().await?;
51-
if !summary.setup_complete && summary.fails == 0 {
52-
println!("(note: setup is not complete yet; use `Update Pico firmware`, then `Set up or change Wi-Fi`.)");
53-
}
54-
if summary.fails > 0 {
55-
println!();
56-
println!("Health check found a blocking problem.");
57-
println!("Use `Create support bundle` if you need to send the details.");
58-
} else if summary.warns > 0 {
59-
println!();
60-
println!("Health check finished with warnings. Warnings do not always block streaming.");
61-
}
62-
Ok(())
63-
}
64-
6549
async fn run_checks() -> Result<DoctorSummary> {
6650
tracing::info!("doctor: starting, bridge v{}", env!("CARGO_PKG_VERSION"));
6751
println!("couchlink doctor v{}", env!("CARGO_PKG_VERSION"));

0 commit comments

Comments
 (0)