use std::io::{Cursor, Write as _};
use serde_json::{json, Value};
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
use crate::app::NebulusApp;
pub(crate) struct SupportBundle {
pub(crate) filename: String,
pub(crate) bytes: Vec<u8>,
}
pub(crate) fn build(app: &NebulusApp) -> Result<SupportBundle, String> {
let build = crate::build_info::current();
let timestamp = web_time::SystemTime::now()
.duration_since(web_time::SystemTime::UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs());
let routes = app
.settings
.payload_routes
.iter()
.map(|route| {
json!({
"id": route.id,
"enabled": route.enabled,
"name": route.name,
"radio_port": route.radio_port,
"action": format!("{:?}", route.action),
"telemetry_protocol": format!("{:?}", route.telemetry_protocol),
"payload_type": route.payload_type,
"sample_rate": route.sample_rate,
"channels": route.channels,
"udp_host": route.udp_host,
"udp_port": route.udp_port,
})
})
.collect::<Vec<_>>();
let profiles = app
.settings
.profiles
.iter()
.map(|profile| {
json!({
"id": profile.id,
"name": profile.name,
"receiver_source": profile.receiver_source.label(),
"udp_bind_address": profile.udp_bind_address,
"udp_bind_port": profile.udp_bind_port,
"device_id": profile.device_id,
"diversity_device_ids": profile.diversity_device_ids,
"channel": profile.channel,
"channel_width_mhz": profile.channel_width_mhz,
"channel_offset": profile.channel_offset,
"link_id": format!("0x{:06x}", profile.link_id),
"minimum_epoch": profile.minimum_epoch,
"codec": profile.codec_preference.label(),
"rtp_reorder": profile.rtp_reorder,
"adaptive_link": profile.adaptive_link,
"tx_power": profile.tx_power,
"audio_volume": profile.audio_volume,
"transfer_size": profile.transfer_size,
"vpn": profile.vpn_enabled,
"vtx_control": profile.vtx_control_enabled,
"osd_profile_id": profile.osd_profile_id,
"routes": profile.payload_routes.len(),
"key_bytes": profile.key_bytes.len(),
"key_sha256": sha256_hex(&profile.key_bytes),
"telemetry_signing": profile.telemetry.mavlink_signing.label(),
"telemetry_signing_key_bytes": profile.telemetry.mavlink_signing_key.len(),
"telemetry_signing_key_sha256": sha256_hex(&profile.telemetry.mavlink_signing_key),
})
})
.collect::<Vec<_>>();
let osd_profiles = app
.settings
.osd_profiles
.iter()
.map(|profile| {
json!({
"id": profile.id,
"name": profile.name,
"indicators": profile.hud.items.len(),
"visible_indicators": profile
.hud
.items
.iter()
.filter(|item| item.visible)
.count(),
"scale_percent": profile.hud.scale_percent,
"background_opacity": profile.hud.background_opacity,
})
})
.collect::<Vec<_>>();
let scan_results = app
.scan_results
.iter()
.map(|result| {
json!({
"channel": result.channel,
"packets": result.packets,
"bytes": result.bytes,
"wfb_frames": result.wfb_frames,
"average_rssi_dbm": result.average_rssi_dbm,
"strongest_rssi_dbm": result.strongest_rssi_dbm,
"average_snr_db": result.average_snr_db,
"average_evm_db": result.average_evm_db,
"retune_us": result.retune_us,
"used_fast_retune": result.used_fast_retune,
"dwell_ms": result.dwell_ms,
})
})
.collect::<Vec<_>>();
let receiver_attempts = if app.receiver_attempts.is_empty() {
&app.receiver_infos
} else {
&app.receiver_attempts
};
let receivers = receiver_attempts
.iter()
.map(receiver_summary_json)
.collect::<Vec<_>>();
let discovered_devices = app
.devices
.iter()
.map(|device| {
json!({
"id": device.id,
"label": device.label,
"vendor_id": format!("{:04x}", device.vendor_id),
"product_id": format!("{:04x}", device.product_id),
"location": device.location,
})
})
.collect::<Vec<_>>();
let driver_init = json!({
"schema_version": 2,
"generated_unix_seconds": timestamp,
"attempts": receiver_attempts
.iter()
.map(receiver_driver_json)
.collect::<Vec<_>>(),
});
let stage_latencies = app
.diagnostics
.stages
.iter()
.map(|(name, values)| {
let summary = values.summary();
json!({
"stage": name,
"last_ms": summary.last,
"average_ms": summary.average,
"p95_ms": summary.p95,
"maximum_ms": summary.maximum,
"samples": summary.samples,
})
})
.collect::<Vec<_>>();
let route_metrics = app
.route_stats
.iter()
.map(|(id, stats)| {
json!({
"route_id": id,
"packets": stats.packets,
"bytes": stats.bytes,
"last_bytes": stats.last_bytes,
"errors": stats.errors,
})
})
.collect::<Vec<_>>();
let preflight = app
.preflight
.checks
.iter()
.map(|check| {
json!({
"name": check.name,
"severity": format!("{:?}", check.severity),
"detail": check.detail,
})
})
.collect::<Vec<_>>();
let telemetry_configuration = json!({
"stale_timeout_ms": app.settings.telemetry.stale_timeout_ms,
"mavlink_signing": app.settings.telemetry.mavlink_signing.label(),
"mavlink_signing_key_bytes": app.settings.telemetry.mavlink_signing_key.len(),
"mavlink_signing_key_sha256": sha256_hex(&app.settings.telemetry.mavlink_signing_key),
"mavlink_system_id": app.settings.telemetry.mavlink_system_id,
"mavlink_component_id": app.settings.telemetry.mavlink_component_id,
"msp_version": app.settings.telemetry.msp_version.label(),
"msp_direction": app.settings.telemetry.msp_direction.label(),
"crsf_address": app.settings.telemetry.crsf_address,
});
let telemetry = json!({
"protocol": app.telemetry.protocol.map(|protocol| protocol.label()),
"messages": app.telemetry.messages,
"fresh": app.telemetry.is_fresh(app.settings.telemetry.stale_timeout_ms),
"age_seconds": app.telemetry.age_seconds(),
"frame_age_seconds": app.telemetry.frame_age_seconds(),
"accepted_frames": app.telemetry.counters.accepted_frames,
"rejected_frames": app.telemetry.counters.rejected_frames,
"filtered_frames": app.telemetry.counters.filtered_frames,
"mavlink_version": app.telemetry.mavlink_version,
"mavlink_system_id": app.telemetry.mavlink_system_id,
"mavlink_component_id": app.telemetry.mavlink_component_id,
"mavlink_last_signed": app.telemetry.mavlink_last_signed,
"mavlink_signing_link_id": app.telemetry.mavlink_signing_link_id,
"mavlink_signed_frames": app.telemetry.counters.mavlink_signed_frames,
"mavlink_unsigned_frames": app.telemetry.counters.mavlink_unsigned_frames,
"mavlink_verified_frames": app.telemetry.counters.mavlink_verified_frames,
"mavlink_invalid_signatures": app.telemetry.counters.mavlink_invalid_signatures,
"mavlink_replay_drops": app.telemetry.counters.mavlink_replay_drops,
"mavlink_stale_timestamp_drops": app.telemetry.counters.mavlink_stale_timestamp_drops,
"mavlink_missing_key_drops": app.telemetry.counters.mavlink_missing_key_drops,
"armed": app.telemetry.armed,
"flight_mode": app.telemetry.flight_mode,
"battery_voltage_v": app.telemetry.battery_voltage_v,
"battery_current_a": app.telemetry.battery_current_a,
"battery_consumed_mah": app.telemetry.battery_consumed_mah,
"battery_remaining_pct": app.telemetry.battery_remaining_pct,
"gps_fix": app.telemetry.gps_fix,
"satellites": app.telemetry.satellites,
"altitude_m": app.telemetry.altitude_m,
"relative_altitude_m": app.telemetry.relative_altitude_m,
"ground_speed_mps": app.telemetry.ground_speed_mps,
"air_speed_mps": app.telemetry.air_speed_mps,
"vertical_speed_mps": app.telemetry.vertical_speed_mps,
"heading_deg": app.telemetry.heading_deg,
"home_distance_m": app.telemetry.home_distance_m,
"rc_link_quality_pct": app.telemetry.rc_link_quality_pct,
"position_available": app.telemetry.latitude_deg.is_some()
&& app.telemetry.longitude_deg.is_some(),
});
let adapter_metrics = app
.adapter_metrics
.iter()
.map(|adapter| {
json!({
"source_id": adapter.source_id,
"device_id": adapter.device_id,
"label": adapter.label,
"online": adapter.online,
"transfers": adapter.transfers,
"transfer_bytes": adapter.transfer_bytes,
"usb_errors": adapter.usb_errors,
"queue_drops": adapter.queue_drops,
"rssi": adapter.rssi,
"snr": adapter.snr,
"accepted": adapter.accepted,
"duplicates": adapter.duplicates,
"descriptor_kind": adapter.descriptor_kind,
"first_descriptor_sample_hex": adapter.first_descriptor_sample,
"first_transfer_len": adapter.first_transfer_len,
"first_transfer_latency_ms": adapter.first_transfer_latency_ms,
"first_transfer_sample_hex": adapter.first_transfer_sample,
"zero_length_transfers": adapter.zero_length_transfers,
"aggregate_descriptors": adapter.aggregate_descriptors,
"aggregate_trailing_events": adapter.aggregate_trailing_events,
"aggregate_trailing_bytes": adapter.aggregate_trailing_bytes,
"aggregate_trailing_nonzero_bytes": adapter.aggregate_trailing_nonzero_bytes,
"alignment_padding_bytes": adapter.alignment_padding_bytes,
"final_alignment_shortfall_bytes": adapter.final_alignment_shortfall_bytes,
"descriptor_too_short": adapter.descriptor_too_short,
"invalid_packet_length": adapter.invalid_packet_length,
"crc_packets": adapter.crc_packets,
"icv_packets": adapter.icv_packets,
"report_packets": adapter.report_packets,
"wifi_parse_errors": adapter.wifi_parse_errors,
"first_parse_error": adapter.first_parse_error,
"first_parse_error_sample_hex": adapter.first_parse_error_sample,
"usb_stalls": adapter.usb_stalls,
"usb_disconnects": adapter.usb_disconnects,
"usb_other_errors": adapter.usb_other_errors,
"last_usb_error": adapter.last_usb_error,
})
})
.collect::<Vec<_>>();
let vtx_settings = &app.settings.vtx;
let vtx_configuration = json!({
"control_enabled": app.settings.vtx_control_enabled,
"ssh_username": app.settings.vtx_ssh_username,
"ssh_password_configured": !app.settings.vtx_ssh_password.is_empty(),
"host_key_sha256": app.settings.vtx_host_key_sha256,
"mcs_index": vtx_settings.mcs_index,
"stbc": vtx_settings.stbc,
"ldpc": vtx_settings.ldpc,
"fec_k": vtx_settings.fec_k,
"fec_n": vtx_settings.fec_n,
"multi_link": vtx_settings.multi_link,
"mirror": vtx_settings.mirror,
"flip": vtx_settings.flip,
"contrast": vtx_settings.contrast,
"hue": vtx_settings.hue,
"saturation": vtx_settings.saturation,
"luminance": vtx_settings.luminance,
"resolution": vtx_settings.resolution,
"fps": vtx_settings.fps,
"bitrate_kbps": vtx_settings.bitrate_kbps,
"codec": vtx_settings.codec,
"gop_size": vtx_settings.gop_size,
"rate_control": vtx_settings.rate_control,
"simple_video_mode": vtx_settings.simple_video_mode,
"recording_enabled": vtx_settings.recording_enabled,
"recording_split_seconds": vtx_settings.recording_split_seconds,
"recording_max_usage": vtx_settings.recording_max_usage,
"exposure": vtx_settings.exposure,
"anti_flicker": vtx_settings.anti_flicker,
"sensor_config": vtx_settings.sensor_config,
"fpv_enabled": vtx_settings.fpv_enabled,
"noise_level": vtx_settings.noise_level,
"telemetry_serial": vtx_settings.telemetry_serial,
"telemetry_router": vtx_settings.telemetry_router,
"telemetry_osd_fps": vtx_settings.telemetry_osd_fps,
"telemetry_gs_rendering": vtx_settings.telemetry_gs_rendering,
"adaptive_service_enabled": vtx_settings.adaptive_service_enabled,
"adaptive_variable": vtx_settings.adaptive_variable,
"adaptive_value": vtx_settings.adaptive_value,
"tx_profiles": vtx_settings.tx_profiles,
});
let capture_stats = crate::logging::capture_stats();
let report = json!({
"schema_version": 2,
"generated_unix_seconds": timestamp,
"build": {
"version": build.version,
"commit": build.commit,
"tag": build.tag,
},
"application": {
"state": format!("{:?}", app.state),
"active_profile_id": app.settings.active_profile_id,
"active_osd_profile_id": app.settings.active_osd_profile_id,
"auto_recover": app.settings.auto_recover,
"recovery_attempt": app.recovery.attempt,
"last_recovery_error": sanitize(&app.recovery.last_error),
"recording_state": format!("{:?}", app.recording.state),
"receiver_key_source": sanitize(&app.key_name),
"receiver_key_error": app.key_error.as_deref().map(sanitize),
"mavlink_key_source": sanitize(&app.mavlink_key_name),
"mavlink_key_error": app.mavlink_key_error.as_deref().map(sanitize),
"preset_error": app.preset_error.as_deref().map(sanitize),
"channel_scan_error": app.scan_error.as_deref().map(sanitize),
},
"logging": {
"configured_verbosity": app.settings.diagnostic_verbosity.label(),
"visible_log_records": app.logs.len(),
"retained_session_records": app.support_logs.len(),
"session_records_evicted": app.support_logs_dropped,
"high_rate_trace_records_sampled_out": capture_stats.sampled_trace_records,
"global_capture_records_trimmed": capture_stats.trimmed_records,
"egui_feedback_records_excluded": capture_stats.egui_records_excluded,
"high_rate_sample_ratio": 128,
},
"environment": {
"platform": app.environment.platform,
"architecture": app.environment.architecture,
"runtime": app.environment.runtime,
"renderer": app.environment.renderer,
"logical_processors": app.environment.logical_processors,
"user_agent": app.environment.user_agent,
"decoder_backend": app.environment.decoder_backend,
"h264": app.environment.h264,
"h265": app.environment.h265,
"native_surfaces": app.environment.native_surfaces,
},
"receivers": receivers,
"discovered_supported_devices": discovered_devices,
"configuration": {
"receiver_source": app.settings.receiver_source.label(),
"udp_bind_address": app.settings.udp_bind_address,
"udp_bind_port": app.settings.udp_bind_port,
"device_id": app.settings.device_id,
"diversity_device_ids": app.settings.diversity_device_ids,
"channel": app.settings.channel,
"channel_width_mhz": app.settings.channel_width_mhz,
"channel_offset": app.settings.channel_offset,
"link_id": format!("0x{:06x}", app.settings.link_id),
"minimum_epoch": app.settings.minimum_epoch,
"codec_preference": app.settings.codec_preference.label(),
"rtp_reorder": app.settings.rtp_reorder,
"adaptive_link": app.settings.adaptive_link,
"tx_power": app.settings.tx_power,
"audio_volume": app.settings.audio_volume,
"transfer_size": app.settings.transfer_size,
"vpn_enabled": app.settings.vpn_enabled,
"key_bytes": app.settings.key_bytes.len(),
"key_is_default": app.settings.key_bytes == crate::settings::DEFAULT_KEY_BYTES,
"key_sha256": sha256_hex(&app.settings.key_bytes),
"telemetry": telemetry_configuration,
"routes": routes,
"profiles": profiles,
"osd_profiles": osd_profiles,
},
"metrics": {
"input_bytes": app.metrics.usb_bytes,
"input_events": app.metrics.usb_transfers,
"input_packets": app.metrics.wifi_packets,
"usb_bytes": app.metrics.usb_bytes,
"usb_transfers": app.metrics.usb_transfers,
"wifi_packets": app.metrics.wifi_packets,
"rtp_packets": app.metrics.rtp_packets,
"encoded_frames": app.metrics.encoded_frames,
"decoded_frames": app.metrics.decoded_frames,
"render_frames": app.metrics.render_frames,
"fec_total_packets": app.metrics.fec_total_packets,
"recovered_packets": app.metrics.recovered_packets,
"lost_packets": app.metrics.lost_packets,
"decoder_drops": app.metrics.decoder_drops,
"decoder_errors": app.metrics.decoder_errors,
"bitrate_bps": app.metrics.bitrate_bps,
"receive_fps": app.metrics.receive_fps,
"decode_fps": app.metrics.decode_fps,
"render_fps": app.metrics.render_fps,
"rssi": app.metrics.rssi,
"snr": app.metrics.snr,
"link_score": app.metrics.link_score,
"usb_latency_ms": app.metrics.usb_latency_ms,
"pipeline_latency_ms": app.metrics.pipeline_latency_ms,
"decode_latency_ms": app.metrics.decode_latency_ms,
"presentation_queue_latency_ms": app.metrics.presentation_queue_latency_ms,
"resolution": app.metrics.resolution,
"decoder": app.metrics.decoder_name,
},
"receive_milestones_seconds": app.diagnostics.milestones,
"pipeline_error_histogram": app.diagnostics.pipeline_errors,
"packet_counters": packet_counters_json(app.diagnostics.counters),
"rtp_status": rtp_status_json(app.diagnostics.rtp),
"rtp_reorder_status": rtp_reorder_json(app.diagnostics.reorder),
"diversity": {
"accepted": app.diagnostics.diversity.accepted,
"duplicates": app.diagnostics.diversity.duplicates,
"passthrough": app.diagnostics.diversity.passthrough,
"cached_packets": app.diagnostics.diversity.cached_packets,
"adapters": adapter_metrics,
},
"stage_latencies": stage_latencies,
"route_metrics": route_metrics,
"telemetry": telemetry,
"preflight": preflight,
"audio": {
"enabled": app.audio.enabled,
"supported": app.audio.supported,
"decoder": app.audio.decoder_name,
"packets": app.audio.packets,
"bytes": app.audio.bytes,
"decoded_frames": app.audio.decoded_frames,
"errors": app.audio.errors,
"queued_ms": app.audio.queued_ms,
},
"vpn": {
"active": app.vpn.active,
"interface_name": app.vpn.interface_name,
"downlink_packets": app.vpn.downlink_packets,
"downlink_bytes": app.vpn.downlink_bytes,
"uplink_packets": app.vpn.uplink_packets,
"uplink_bytes": app.vpn.uplink_bytes,
"errors": app.vpn.errors,
},
"vtx_control": {
"state": format!("{:?}", app.vtx_control.state),
"video_mode": app.vtx_control.video_mode,
"tunnel_packets_received": app.vtx_control.network.tunnel_packets_received,
"tunnel_bytes_received": app.vtx_control.network.tunnel_bytes_received,
"tunnel_packets_sent": app.vtx_control.network.tunnel_packets_sent,
"tunnel_bytes_sent": app.vtx_control.network.tunnel_bytes_sent,
"malformed_tunnel_packets": app.vtx_control.network.malformed_tunnel_packets,
"tcp_connections_opened": app.vtx_control.network.tcp_connections_opened,
"tcp_connection_failures": app.vtx_control.network.tcp_connection_failures,
"tcp_connections_active": app.vtx_control.network.tcp_connections_active,
"configuration": vtx_configuration,
},
"channel_scan": scan_results,
});
let report = serde_json::to_vec_pretty(&report)
.map_err(|error| format!("serialize support report failed: {error}"))?;
let driver_init = serde_json::to_vec_pretty(&driver_init)
.map_err(|error| format!("serialize driver initialization report failed: {error}"))?;
let visible_logs = format_logs(app.logs.iter());
let session_logs = format_logs(app.support_logs.iter());
let manifest = serde_json::to_vec_pretty(&json!({
"schema_version": 2,
"generated_unix_seconds": timestamp,
"files": {
"report.json": "Sanitized application, configuration, pipeline, media, and runtime state",
"driver_init.json": "Persistent probe, EFUSE, initialization-stage, register-trace, and post-init evidence",
"logs.txt": "Current filtered log-panel contents",
"session-logs.txt": "Latest bounded session log history before UI verbosity filtering",
"README.txt": "Bundle contents and privacy notes",
},
"redacted_or_excluded": [
"WFB key bytes",
"MAVLink signing key bytes",
"VTX SSH password and private credentials",
"absolute home-directory prefixes",
"recorded video and audio payloads",
],
"included_sensitive_hardware_evidence": [
"SHA-256 fingerprints (never bytes) of configured WFB and MAVLink keys",
"USB identifiers and device labels",
"EFUSE fingerprint and decoded board calibration summary",
"raw register values used during driver initialization",
"bounded USB and malformed-descriptor hex samples",
],
}))
.map_err(|error| format!("serialize support manifest failed: {error}"))?;
let bytes = archive(
&manifest,
&report,
&driver_init,
&visible_logs,
&session_logs,
)?;
Ok(SupportBundle {
filename: format!("nebulus-support-{timestamp}.zip"),
bytes,
})
}
fn receiver_summary_json(receiver: &crate::runtime::ReceiverInfo) -> Value {
let diagnostics = receiver.driver_diagnostics.as_ref();
json!({
"transport": format!("{:?}", receiver.transport),
"id": receiver.id,
"source_id": receiver.source_id,
"label": receiver.label,
"vendor_id": receiver.vendor_id.map(|id| format!("{id:04x}")),
"product_id": receiver.product_id.map(|id| format!("{id:04x}")),
"chip": receiver.chip,
"rf_paths": receiver.rf_paths,
"cut_version": receiver.cut_version,
"usb_speed": receiver.usb_speed,
"bulk_in_endpoint": receiver.bulk_in_endpoint.map(|endpoint| format!("0x{endpoint:02x}")),
"bulk_out_endpoint": receiver.bulk_out_endpoint.map(|endpoint| format!("0x{endpoint:02x}")),
"initialization": receiver.initialization,
"firmware_downloaded": receiver.firmware_downloaded,
"rx_descriptor": receiver.rx_descriptor,
"failure": receiver.failure.as_deref().map(sanitize),
"driver_diagnostics": diagnostics.map(|diagnostics| json!({
"schema_version": diagnostics.schema_version,
"completed": diagnostics.completed,
"duration_us": diagnostics.duration_us,
"stages": diagnostics.stages.len(),
"register_io": register_io_json(&diagnostics.register_io),
"register_trace_entries": diagnostics.register_trace.len(),
"register_trace_dropped": diagnostics.register_trace_dropped,
"error": diagnostics.error.as_deref().map(sanitize),
})),
})
}
fn receiver_driver_json(receiver: &crate::runtime::ReceiverInfo) -> Value {
json!({
"receiver": receiver_summary_json(receiver),
"driver": receiver
.driver_diagnostics
.as_ref()
.map(|diagnostics| driver_diagnostics_json(diagnostics)),
})
}
fn driver_diagnostics_json(diagnostics: &openipc_rtl88xx::DriverDiagnostics) -> Value {
json!({
"schema_version": diagnostics.schema_version,
"started_us": diagnostics.started_us,
"duration_us": diagnostics.duration_us,
"completed": diagnostics.completed,
"error": diagnostics.error.as_deref().map(sanitize),
"radio": {
"channel": diagnostics.channel,
"channel_width_mhz": diagnostics.channel_width_mhz,
"channel_offset": diagnostics.channel_offset,
},
"effective_options": diagnostics.effective_options,
"probe": diagnostics.probe.as_ref().map(|probe| json!({
"vendor_id": format!("{:04x}", probe.vendor_id),
"product_id": format!("{:04x}", probe.product_id),
"sys_cfg": format!("0x{:08x}", probe.sys_cfg),
"sys_cfg2_chip_id": format!("0x{:02x}", probe.sys_cfg2_chip_id),
"selected_family": probe.chip.family.name(),
"selected_cut_version": probe.chip.cut_version,
"selected_rf_type": format!("{:?}", probe.chip.rf_type),
"selected_rx_descriptor": format!("{:?}", probe.rx_descriptor),
})),
"efuse": diagnostics.efuse.as_ref().map(|efuse| json!({
"eeprom_id": format!("0x{:04x}", efuse.eeprom_id),
"autoload_valid": efuse.autoload_valid,
"logical_map_fingerprint": format!("0x{:016x}", efuse.map_fingerprint),
"programmed_bytes": efuse.programmed_bytes,
"rfe_type": efuse.rfe_type,
"board_type": format!("0x{:02x}", efuse.board_type),
"external_pa_2g": efuse.external_pa_2g,
"external_pa_5g": efuse.external_pa_5g,
"external_lna_2g": efuse.external_lna_2g,
"external_lna_5g": efuse.external_lna_5g,
"crystal_cap": efuse.crystal_cap,
"thermal_meter": efuse.thermal_meter,
"thermal_meter_paths": efuse.thermal_meter_paths,
"tx_power_source": if efuse.tx_power_defaults { "IC defaults" } else { "EFUSE" },
"mac_present": efuse.mac_present,
})),
"stages": diagnostics.stages.iter().map(|stage| json!({
"name": stage.name,
"started_us": stage.started_us,
"duration_us": stage.duration_us,
"success": stage.success,
"error": stage.error.as_deref().map(sanitize),
"register_io": register_io_json(&stage.register_io),
})).collect::<Vec<_>>(),
"register_io": register_io_json(&diagnostics.register_io),
"register_trace": diagnostics.register_trace.iter().map(|entry| json!({
"sequence": entry.sequence,
"offset_us": entry.offset_us,
"stage": entry.stage,
"operation": entry.operation,
"register": format!("0x{:04x}", entry.register),
"length": entry.bytes.len(),
"bytes_hex": hex_bytes(&entry.bytes),
"success": entry.success,
"error": entry.error.as_deref().map(sanitize),
})).collect::<Vec<_>>(),
"register_trace_dropped": diagnostics.register_trace_dropped,
"post_init_registers": diagnostics.post_init_registers.iter().map(|register| json!({
"name": register.name,
"address": format!("0x{:04x}", register.address),
"width": register.width,
"value": register.value,
"error": register.error.as_deref().map(sanitize),
})).collect::<Vec<_>>(),
})
}
fn register_io_json(io: &openipc_rtl88xx::RegisterIoDiagnostics) -> Value {
json!({
"reads": io.reads,
"writes": io.writes,
"read_bytes": io.read_bytes,
"write_bytes": io.write_bytes,
"failures": io.failures,
"ordered_fingerprint": format!("0x{:016x}", io.fingerprint),
})
}
fn packet_counters_json(counters: openipc_core::ReceiverBatchCounters) -> Value {
json!({
"realtek_packets": counters.packets,
"accepted_packets": counters.accepted_packets,
"wifi_frames": counters.wifi_frames,
"matching_channel_frames": counters.matched_frames,
"wifi_parse_dropped": counters.wifi_parse_dropped,
"dropped_packets": counters.dropped_packets,
"crc_dropped": counters.crc_dropped,
"icv_dropped": counters.icv_dropped,
"report_packets_dropped": counters.report_dropped,
"ignored_frames": counters.ignored_frames,
"wfb_sessions": counters.sessions,
"wfb_payloads": counters.wfb_payloads,
"rtp_packets": counters.rtp_packets,
"video_frames": counters.video_frames,
"raw_payload_count": counters.raw_payload_count,
"raw_payload_bytes": counters.raw_payload_bytes,
"route_errors": counters.route_errors,
})
}
fn rtp_status_json(status: openipc_core::RtpDepacketizerStatus) -> Value {
json!({
"packets": status.packets,
"frames_emitted": status.frames_emitted,
"config_wait_drops": status.config_wait_drops,
"keyframes_with_prepended_config": status.keyframes_with_prepended_config,
"parameter_sets_prepended": status.parameter_sets_prepended,
"fragment_sequence_gaps": status.fragment_sequence_gaps,
"damaged_frames_forwarded": status.damaged_frames_forwarded,
"damaged_frames_dropped": status.damaged_frames_dropped,
"fragment_overflows": status.fragment_overflows,
"unsupported_payloads": status.unsupported_payloads,
"malformed_packets": status.malformed_packets,
"last_payload_type": status.last_payload_type,
"last_sequence_number": status.last_sequence_number,
"last_timestamp": status.last_timestamp,
"last_codec": status.last_codec.map(|codec| format!("{codec:?}")),
"last_nal_type": status.last_nal_type,
"codec_config": {
"h264_sps": status.codec_config.h264_sps,
"h264_pps": status.codec_config.h264_pps,
"h265_vps": status.codec_config.h265_vps,
"h265_sps": status.codec_config.h265_sps,
"h265_pps": status.codec_config.h265_pps,
},
})
}
fn rtp_reorder_json(status: openipc_core::RtpReorderStatus) -> Value {
json!({
"buffered_packets": status.buffered_packets,
"reordered_packets": status.reordered_packets,
"late_packets": status.late_packets,
"forced_flushes": status.forced_flushes,
})
}
fn format_logs<'a>(entries: impl IntoIterator<Item = &'a crate::model::LogEntry>) -> String {
entries
.into_iter()
.map(|entry| {
format!(
"#{:<10} {:>10.3} {:<5} {:<32} {}",
entry.sequence,
entry.elapsed_seconds,
entry.level.label(),
entry.target,
sanitize(&entry.message)
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn hex_bytes(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut output = String::with_capacity(bytes.len().saturating_mul(2));
for byte in bytes {
let _ = write!(output, "{byte:02x}");
}
output
}
fn sha256_hex(bytes: &[u8]) -> Option<String> {
use sha2::{Digest as _, Sha256};
(!bytes.is_empty()).then(|| hex_bytes(&Sha256::digest(bytes)))
}
fn archive(
manifest: &[u8],
report: &[u8],
driver_init: &[u8],
logs: &str,
session_logs: &str,
) -> Result<Vec<u8>, String> {
let mut output = Cursor::new(Vec::new());
{
let mut zip = ZipWriter::new(&mut output);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file("manifest.json", options)
.map_err(|error| format!("create manifest entry failed: {error}"))?;
zip.write_all(manifest)
.map_err(|error| format!("write manifest entry failed: {error}"))?;
zip.start_file("report.json", options)
.map_err(|error| format!("create report entry failed: {error}"))?;
zip.write_all(report)
.map_err(|error| format!("write report entry failed: {error}"))?;
zip.start_file("driver_init.json", options)
.map_err(|error| format!("create driver initialization entry failed: {error}"))?;
zip.write_all(driver_init)
.map_err(|error| format!("write driver initialization entry failed: {error}"))?;
zip.start_file("logs.txt", options)
.map_err(|error| format!("create logs entry failed: {error}"))?;
zip.write_all(logs.as_bytes())
.map_err(|error| format!("write logs entry failed: {error}"))?;
zip.start_file("session-logs.txt", options)
.map_err(|error| format!("create session logs entry failed: {error}"))?;
zip.write_all(session_logs.as_bytes())
.map_err(|error| format!("write session logs entry failed: {error}"))?;
zip.start_file("README.txt", options)
.map_err(|error| format!("create bundle README failed: {error}"))?;
zip.write_all(
b"Nebulus support bundle\n\nStart with report.json and its receive_milestones_seconds field to find the first pipeline stage that did not occur. driver_init.json preserves every receiver attempt, raw probe decisions, decoded EFUSE state, timed initialization stages, a bounded ordered register trace with actual values, and post-init register snapshots. session-logs.txt contains the latest bounded session history; logs.txt is only the currently visible filtered log panel.\n\nSecret key material, VTX credentials, media payloads, and absolute home-directory prefixes are excluded. Hardware identifiers, EFUSE fingerprints, register values, and bounded packet samples are included because they are required to compare a failing adapter against a working Devourer/OpenIPC-WASM trace. See manifest.json for the complete disclosure.\n",
)
.map_err(|error| format!("write bundle README failed: {error}"))?;
zip.finish()
.map_err(|error| format!("finish support bundle failed: {error}"))?;
}
Ok(output.into_inner())
}
fn sanitize(message: &str) -> String {
#[cfg(not(target_arch = "wasm32"))]
if let Some(home) = std::env::var_os("HOME")
.and_then(|home| home.into_string().ok())
.filter(|home| !home.is_empty())
{
return message.replace(&home, "~");
}
message.to_owned()
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
pub(crate) fn save(bundle: SupportBundle) -> Result<String, String> {
let Some(path) = rfd::FileDialog::new()
.set_title("Save Nebulus support bundle")
.set_file_name(&bundle.filename)
.add_filter("ZIP archive", &["zip"])
.save_file()
else {
return Ok("Support bundle export cancelled".to_owned());
};
std::fs::write(&path, bundle.bytes)
.map_err(|error| format!("write {} failed: {error}", path.display()))?;
Ok(format!("Support bundle saved to {}", path.display()))
}
#[cfg(target_os = "android")]
pub(crate) fn save(bundle: SupportBundle) -> Result<String, String> {
crate::android::save_file(&bundle.filename, &bundle.bytes)?;
Ok("Android document picker opened for the support bundle".to_owned())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn save(bundle: SupportBundle) -> Result<String, String> {
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast as _;
let parts = js_sys::Array::new();
let bytes = js_sys::Uint8Array::from(bundle.bytes.as_slice());
parts.push(&bytes.buffer());
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/zip");
let blob = web_sys::Blob::new_with_buffer_source_sequence_and_options(&parts, &options)
.map_err(js_error)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_error)?;
let window = web_sys::window().ok_or_else(|| "browser window is unavailable".to_owned())?;
let document = window
.document()
.ok_or_else(|| "browser document is unavailable".to_owned())?;
let body = document
.body()
.ok_or_else(|| "browser document body is unavailable".to_owned())?;
let anchor = document
.create_element("a")
.map_err(js_error)?
.dyn_into::<web_sys::HtmlAnchorElement>()
.map_err(|_| "could not create support-bundle download link".to_owned())?;
anchor.set_href(&url);
anchor.set_download(&bundle.filename);
body.append_child(&anchor).map_err(js_error)?;
anchor.click();
let revoke_url = url.clone();
let cleanup_body = body;
let cleanup_anchor = anchor;
let revoke = Closure::once_into_js(move || {
let _ = cleanup_body.remove_child(&cleanup_anchor);
let _ = web_sys::Url::revoke_object_url(&revoke_url);
});
window
.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 1_000)
.map_err(js_error)?;
Ok(format!("Downloaded {}", bundle.filename))
}
#[cfg(target_arch = "wasm32")]
fn js_error(error: wasm_bindgen::JsValue) -> String {
error.as_string().unwrap_or_else(|| format!("{error:?}"))
}
#[cfg(test)]
mod tests {
use std::io::{Cursor, Read as _};
#[test]
fn archive_contains_structured_driver_and_session_evidence() {
let bytes = super::archive(
br#"{"schema_version":2}"#,
br#"{"version":"test"}"#,
br#"{"attempts":[]}"#,
"INFO visible",
"INFO session",
)
.expect("archive");
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("open zip");
assert_eq!(archive.len(), 6);
let mut report = String::new();
archive
.by_name("report.json")
.expect("report entry")
.read_to_string(&mut report)
.expect("read report");
assert_eq!(report, r#"{"version":"test"}"#);
assert!(archive.by_name("manifest.json").is_ok());
assert!(archive.by_name("driver_init.json").is_ok());
assert!(archive.by_name("logs.txt").is_ok());
assert!(archive.by_name("session-logs.txt").is_ok());
assert!(archive.by_name("README.txt").is_ok());
}
#[test]
fn sanitize_hides_home_directory_prefix() {
let message = super::sanitize("no absolute path required");
assert_eq!(message, "no absolute path required");
}
}