|
1 | | -use std::env::current_dir; |
2 | | -use std::fs; |
| 1 | +use std::path::Path; |
| 2 | +use std::vec::Vec; |
3 | 3 |
|
4 | | -use sysinfo::{Disk, Disks}; |
| 4 | +use futures::future::try_join_all; |
5 | 5 |
|
6 | | -use console::Style; |
7 | | -use indicatif::DecimalBytes; |
| 6 | +use anyhow::{Context, Error, Result, anyhow}; |
| 7 | + |
| 8 | +use clap::{Arg, ArgAction, Command, crate_version}; |
8 | 9 |
|
9 | 10 | use log::debug; |
10 | 11 |
|
11 | | -// Print disks list as a table |
12 | | -pub fn print_disks() { |
13 | | - println!( |
14 | | - "{0: ^20} | {1: ^30} | {2: ^6} | {3: ^9} | {4: ^10} | {5: ^5} ", |
15 | | - "Name", "Location", "Type", "Removable", "Avail.", "Empty" |
16 | | - ); |
17 | | - let red = Style::new().red(); |
18 | | - let green = Style::new().green(); |
19 | | - let disks = Disks::new_with_refreshed_list(); |
20 | | - for disk in &disks { |
21 | | - let disk_removable = if disk.is_removable() { |
22 | | - green.apply_to("Yes") |
23 | | - } else { |
24 | | - red.apply_to("No") |
25 | | - }; |
26 | | - let file_system_str = disk.file_system().to_string_lossy(); |
27 | | - let file_system = if file_system_str.eq_ignore_ascii_case("vfat") |
28 | | - || file_system_str.eq_ignore_ascii_case("fat32") |
29 | | - { |
30 | | - green.apply_to(file_system_str) |
31 | | - } else { |
32 | | - red.apply_to(file_system_str) |
33 | | - }; |
34 | | - |
35 | | - let empty = if let Ok(files) = fs::read_dir(disk.mount_point()) { |
36 | | - if files.count() == 0 { |
37 | | - green.apply_to("Yes") |
38 | | - } else { |
39 | | - red.apply_to("No") |
| 12 | +use reqwest::Client; |
| 13 | + |
| 14 | +use indicatif::{DecimalBytes, MultiProgress}; |
| 15 | + |
| 16 | +mod disk; |
| 17 | +mod download; |
| 18 | +mod interact; |
| 19 | +mod psa; |
| 20 | + |
| 21 | +#[tokio::main] |
| 22 | +async fn main() -> Result<(), Error> { |
| 23 | + env_logger::init(); |
| 24 | + |
| 25 | + let mut map_info = "Sets the map to check for update. Supported maps:".to_string(); |
| 26 | + for map in psa::MAPS { |
| 27 | + map_info = format!("{}\n - {}: {}", map_info, map.get_code(), map.get_name()); |
| 28 | + } |
| 29 | + |
| 30 | + let matches = Command::new("PSA firmware update.") |
| 31 | + .version(crate_version!()) |
| 32 | + .about("CLI alternative to Peugeot/Citroën/Opel/DS update applications for car infotainment system (NAC/RCC firmware and navigation maps), hopefully more robust. Supports for resume of downloads.") |
| 33 | + .arg(Arg::new("VIN") |
| 34 | + .help("Vehicle Identification Number (VIN) to check for update") |
| 35 | + .required(false) |
| 36 | + .index(1)) |
| 37 | + .arg(Arg::new("map") |
| 38 | + .help(map_info) |
| 39 | + .required(false) |
| 40 | + .long("map") |
| 41 | + .action(ArgAction::Set)) |
| 42 | + .arg(Arg::new("silent") |
| 43 | + .help("Sets silent (non-interactive) mode") |
| 44 | + .required(false) |
| 45 | + .long("silent") |
| 46 | + .action(ArgAction::SetTrue)) |
| 47 | + .arg(Arg::new("download") |
| 48 | + .help("Automatically proceed with download of updates. Previous downloads will be resumed.") |
| 49 | + .required(false) |
| 50 | + .long("download") |
| 51 | + .action(ArgAction::SetTrue)) |
| 52 | + .arg(Arg::new("extract") |
| 53 | + .help("Location where to extract update files. Should be the root of an empty FAT32 USB drive.") |
| 54 | + .required(false) |
| 55 | + .long("extract") |
| 56 | + .action(ArgAction::Set)) |
| 57 | + .arg(Arg::new("sequential-download") |
| 58 | + .help("Forces sequential download of updates. By default updates are downloaded concurrently.") |
| 59 | + .required(false) |
| 60 | + .long("sequential-download") |
| 61 | + .action(ArgAction::SetTrue)) |
| 62 | + .get_matches(); |
| 63 | + |
| 64 | + let interactive = !matches.get_flag("silent"); |
| 65 | + let vin = matches.get_one::<String>("VIN").map(|s| s.to_uppercase()); |
| 66 | + let vin_provided_as_arg = vin.is_some(); |
| 67 | + let map = matches.get_one::<String>("map").map(|s| s.as_str()); |
| 68 | + let download = matches.get_flag("download"); |
| 69 | + let sequential_download = matches.get_flag("sequential-download"); |
| 70 | + let extract_location = matches.get_one::<String>("extract").map(|s| s.as_str()); |
| 71 | + |
| 72 | + // Vin not provided on command line, asking interactively |
| 73 | + let vin = if !vin_provided_as_arg && interactive { |
| 74 | + interact::prompt("Please enter VIN").ok() |
| 75 | + } else { |
| 76 | + vin.map(|v| v.to_string()) |
| 77 | + }; |
| 78 | + if vin.is_none() { |
| 79 | + return Err(anyhow!("No VIN provided")); |
| 80 | + } |
| 81 | + let vin = vin.unwrap(); |
| 82 | + |
| 83 | + let client = Client::builder() |
| 84 | + // Dummy user agent to make cloudfront proxy happy when downloading firmware files |
| 85 | + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36") |
| 86 | + .build() |
| 87 | + .context("Failed to create HTTP client")?; |
| 88 | + let device_info = psa::request_device_information(&client, &vin).await?; |
| 89 | + let is_nac: bool = device_info |
| 90 | + .devices |
| 91 | + .map(|l| l.iter().any(|d| d.ecu_type.contains("NAC"))) |
| 92 | + == Some(true); |
| 93 | + |
| 94 | + // Maps not provided on command line, asking interactively for NAC |
| 95 | + let map = if map.is_none() && is_nac && interactive { |
| 96 | + interact::select_map()? |
| 97 | + } else { |
| 98 | + map |
| 99 | + }; |
| 100 | + |
| 101 | + let update_response = psa::request_available_updates(&client, &vin, map).await?; |
| 102 | + |
| 103 | + if update_response.software.is_none() { |
| 104 | + println!("No update found"); |
| 105 | + return Ok(()); |
| 106 | + } |
| 107 | + |
| 108 | + let mut selected_updates: Vec<psa::SoftwareUpdate> = Vec::new(); |
| 109 | + let mut total_update_size = 0_u64; |
| 110 | + |
| 111 | + let mut software_list: Vec<psa::Software> = update_response |
| 112 | + .software |
| 113 | + .expect("Expected at least a software in server response"); |
| 114 | + |
| 115 | + // For NAC, let's sort in reverse order of software type to display firmware (ovip) first, then map (map) |
| 116 | + software_list.sort_by(|u1, u2| u2.software_type.cmp(&u1.software_type)); |
| 117 | + |
| 118 | + for software in software_list { |
| 119 | + for update in &software.update { |
| 120 | + // An empty update can be sent by the server when there is no available update |
| 121 | + if !update.update_id.is_empty() { |
| 122 | + psa::print(&software, update); |
| 123 | + if download || (interactive && interact::confirm("Download update?")?) { |
| 124 | + selected_updates.push(update.clone()); |
| 125 | + let update_size = match update.update_size.parse() { |
| 126 | + Ok(size) => size, |
| 127 | + Err(_) => { |
| 128 | + debug!("Failed to parse update size: {}", update.update_size); |
| 129 | + 0 |
| 130 | + } |
| 131 | + }; |
| 132 | + total_update_size += update_size; |
| 133 | + } |
40 | 134 | } |
41 | | - } else { |
42 | | - red.apply_to("N/A") |
43 | | - }; |
44 | | - |
45 | | - println!( |
46 | | - "{0: <20} | {1: <30} | {2: <6} | {3: <9} | {4: >10} | {5: <5}", |
47 | | - disk.name().to_string_lossy(), |
48 | | - disk.mount_point().to_string_lossy(), |
49 | | - file_system, |
50 | | - disk_removable, |
51 | | - DecimalBytes(disk.available_space()).to_string(), |
52 | | - empty |
53 | | - ); |
| 135 | + } |
54 | 136 | } |
55 | | -} |
56 | 137 |
|
57 | | -// Available disk space in current directory |
58 | | -pub fn get_current_dir_available_space() -> Option<u64> { |
59 | | - let cwd_result = current_dir(); |
60 | | - if cwd_result.is_err() { |
61 | | - debug!( |
62 | | - "Failed to retrieve information about current working directory: {}", |
63 | | - cwd_result.err().unwrap() |
64 | | - ); |
65 | | - return None; |
| 138 | + if selected_updates.is_empty() { |
| 139 | + println!("No update selected for download"); |
| 140 | + return Ok(()); |
66 | 141 | } |
67 | | - let cwd = cwd_result.ok().unwrap(); |
68 | | - let mut cwd_disk: Option<&Disk> = None; |
69 | | - // Lookup disk whose mount point is parent of cwd |
70 | | - // In case there are multiple candidates, pick up the "nearest" parent of cwd |
71 | | - let disks = Disks::new_with_refreshed_list(); |
72 | | - for disk in &disks { |
73 | | - debug!("Disk {disk:?}"); |
74 | | - if cwd.starts_with(disk.mount_point()) |
75 | | - && (cwd_disk.is_none() |
76 | | - || disk |
77 | | - .mount_point() |
78 | | - .starts_with(cwd_disk.unwrap().mount_point())) |
79 | | - { |
80 | | - cwd_disk = Some(disk); |
| 142 | + |
| 143 | + // Check available disk size |
| 144 | + let disk_space = disk::get_current_dir_available_space(); |
| 145 | + if let Some(space) = disk_space |
| 146 | + && space < total_update_size |
| 147 | + { |
| 148 | + interact::warn(&format!( |
| 149 | + "Not enough space on disk to proceed with download. Available disk space in current directory: {}", |
| 150 | + DecimalBytes(space) |
| 151 | + )); |
| 152 | + if interactive && !(interact::confirm("Continue anyway?")?) { |
| 153 | + return Ok(()); |
81 | 154 | } |
82 | 155 | } |
83 | | - if cwd_disk.is_none() { |
84 | | - debug!( |
85 | | - "Failed to retrieve disk information for current working directory: {}", |
86 | | - cwd.to_string_lossy() |
87 | | - ); |
88 | | - return None; |
| 156 | + |
| 157 | + let multi_progress = MultiProgress::new(); |
| 158 | + |
| 159 | + let downloaded_updates: Vec<psa::DownloadedUpdate> = if sequential_download { |
| 160 | + // Download sequentially |
| 161 | + let mut result: Vec<psa::DownloadedUpdate> = Vec::new(); |
| 162 | + for update in selected_updates { |
| 163 | + result.push(psa::download_update(&client, &update, &multi_progress).await?); |
| 164 | + } |
| 165 | + result |
| 166 | + } else { |
| 167 | + // Download concurrently |
| 168 | + let downloads = selected_updates |
| 169 | + .iter() |
| 170 | + .map(|update| psa::download_update(&client, update, &multi_progress)); |
| 171 | + try_join_all(downloads).await? |
| 172 | + }; |
| 173 | + |
| 174 | + let mut extract_location = extract_location.map(str::to_string); |
| 175 | + if interactive && extract_location.is_none() { |
| 176 | + if !interact::confirm( |
| 177 | + "To proceed to extraction of update(s), please insert an empty USB disk formatted as FAT32. Continue?", |
| 178 | + )? { |
| 179 | + return Ok(()); |
| 180 | + } |
| 181 | + |
| 182 | + // Listing available disks for extraction |
| 183 | + // TODO check destination available space. |
| 184 | + disk::print_disks(); |
| 185 | + let location = interact::prompt( |
| 186 | + "Location where to extract the update files (IMPORTANT: Should be the root of an EMPTY USB device formatted as FAT32)", |
| 187 | + )?; |
| 188 | + if !location.is_empty() { |
| 189 | + extract_location = Some(location); |
| 190 | + } |
89 | 191 | } |
90 | | - debug!( |
91 | | - "Current working directory maps to disk {}", |
92 | | - cwd_disk.unwrap().name().to_string_lossy() |
93 | | - ); |
94 | | - Some(cwd_disk.unwrap().available_space()) |
| 192 | + |
| 193 | + match extract_location { |
| 194 | + Some(location) => { |
| 195 | + let destination_path = Path::new(&location); |
| 196 | + if !destination_path.is_dir() { |
| 197 | + return Err(anyhow!( |
| 198 | + "Destination does not exist or is not a directory: {}", |
| 199 | + destination_path.to_string_lossy() |
| 200 | + )); |
| 201 | + } |
| 202 | + for update in downloaded_updates { |
| 203 | + psa::extract_update(&update, destination_path) |
| 204 | + .context("Failed to extract update")?; |
| 205 | + } |
| 206 | + println!( |
| 207 | + "The update can be applied on the car infotainment system following vendor instructions." |
| 208 | + ); |
| 209 | + if is_nac { |
| 210 | + println!( |
| 211 | + "For example, for Peugeot NAC: https://web.archive.org/web/20220719220945/https://media-ct-ndp.peugeot.com/file/38/2/map-software-rcc-en.632382.pdf" |
| 212 | + ); |
| 213 | + } else { |
| 214 | + println!( |
| 215 | + "For example, for Peugeot RCC: https://web.archive.org/web/20230602131011/https://media-ct-ndp.peugeot.com/file/38/0/map-software-nac-en.632380.pdf" |
| 216 | + ); |
| 217 | + } |
| 218 | + } |
| 219 | + None => { |
| 220 | + println!("No location, skipping extraction"); |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + Ok(()) |
95 | 225 | } |
0 commit comments