mod bundle;
mod link;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use serialport::{SerialPortInfo, SerialPortType};
use link::{Link, BASE_BAUD, DEFAULT_BAUD};
const EXIT_INTERRUPTED: u8 = 130;
const HAT_EEPROM_ADDRESS: &str = "0x50";
const DEFAULT_PAGE_SIZE: u32 = 32;
const HAT_HEADER_LEN: u32 = 12;
const HAT_SIGNATURE: &[u8; 4] = b"R-Pi";
const HAT_EEPLEN_AT: usize = 8;
const HAT_MAX_IMAGE: u32 = 0x1_0000;
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[arg(short, long, global = true, value_name = "DEVICE")]
device: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Boot {
image: PathBuf,
#[arg(long, value_parser = parse_u32)]
load_addr: u32,
#[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_BAUD)]
baud: u32,
},
MemWrite {
#[arg(value_parser = parse_u32)]
addr: u32,
file: PathBuf,
#[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_BAUD)]
baud: u32,
},
Exec {
#[arg(value_parser = parse_u32)]
addr: u32,
#[arg(long)]
terminal: bool,
},
SdList {
#[arg(default_value = "/")]
path: String,
},
SdRead {
remote: String,
local: PathBuf,
#[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_BAUD)]
baud: u32,
},
SdWrite {
local: PathBuf,
remote: String,
#[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_BAUD)]
baud: u32,
},
SdDelete {
remote: String,
},
SdMkdir {
remote: String,
},
EepromRead {
local: PathBuf,
#[arg(long, value_parser = parse_u32)]
length: Option<u32>,
#[arg(long, value_parser = parse_i2c_address, default_value = HAT_EEPROM_ADDRESS)]
address: u8,
#[arg(long, value_parser = parse_u32, default_value_t = 0)]
offset: u32,
#[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_BAUD)]
baud: u32,
},
EepromWrite {
local: PathBuf,
#[arg(long, value_parser = parse_i2c_address, default_value = HAT_EEPROM_ADDRESS)]
address: u8,
#[arg(long, value_parser = parse_u32, default_value_t = 0)]
offset: u32,
#[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_PAGE_SIZE)]
page_size: u32,
#[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_BAUD)]
baud: u32,
},
Bundle {
#[arg(default_value = "bundle.toml")]
manifest: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
sdcard: Option<PathBuf>,
#[arg(long, value_name = "URL")]
upload: Option<String>,
},
Terminal,
Completions {
#[arg(value_enum, default_value_t = Shell::Bash)]
shell: Shell,
},
List {
#[arg(long)]
all: bool,
},
}
impl Command {
fn needs_handshake(&self) -> bool {
!matches!(
self,
Command::Terminal
| Command::List { .. }
| Command::Bundle { .. }
| Command::Completions { .. }
)
}
}
fn parse_u32(s: &str) -> Result<u32, String> {
let text = s.trim().replace('_', "");
let (digits, radix) = match text.get(..2).map(str::to_ascii_lowercase).as_deref() {
Some("0x") => (&text[2..], 16),
Some("0b") => (&text[2..], 2),
Some("0o") => (&text[2..], 8),
_ => (&text[..], 10),
};
u32::from_str_radix(digits, radix).map_err(|e| format!("{s:?} is not a number: {e}"))
}
fn parse_i2c_address(s: &str) -> Result<u8, String> {
let value = parse_u32(s)?;
u8::try_from(value)
.ok()
.filter(|&address| address <= 0x7f)
.ok_or_else(|| format!("{s:?} is not a 7-bit I2C address (0x00-0x7f)"))
}
fn read_file(path: &Path) -> Result<Vec<u8>> {
fs::read(path).with_context(|| format!("reading {}", path.display()))
}
fn describe(port_type: &SerialPortType) -> String {
match port_type {
SerialPortType::UsbPort(info) => {
let mut parts = Vec::new();
let name = [info.manufacturer.as_deref(), info.product.as_deref()]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
if !name.is_empty() {
parts.push(name);
}
parts.push(format!("{:04x}:{:04x}", info.vid, info.pid));
if let Some(serial) = &info.serial_number {
parts.push(format!("serial {serial}"));
}
parts.join(" ")
}
SerialPortType::PciPort => "PCI serial".into(),
SerialPortType::BluetoothPort => "Bluetooth serial".into(),
_ => "unknown".into(),
}
}
fn repair_hyphenated_bin_name(name: &str, script: String) -> String {
if !name.contains('-') {
return script;
}
script.replace(&name.replace('-', "__subcmd__"), &name.replace('-', "__"))
}
const BASH_DEVICE_WRAPPER: &str = r#"
# Serial ports for --device, from the binary that knows them. Everything
# else falls through to the generated completion above.
@PREFIX@_ports() {
@BIN@ list 2>/dev/null | awk 'NF {print $1}'
}
@PREFIX@_complete() {
case "$3" in
-d|--device)
COMPREPLY=( $(compgen -W "$(@PREFIX@_ports)" -- "$2") )
return 0
;;
esac
@FN@ "$@"
}
complete -F @PREFIX@_complete -o bashdefault -o default @BIN@
"#;
fn write_completions(shell: Shell) -> Result<()> {
let mut command = Cli::command();
let name = command.get_name().to_string();
let mut buffer = Vec::new();
clap_complete::generate(shell, &mut command, &name, &mut buffer);
let mut script = String::from_utf8(buffer).context("completion script is not UTF-8")?;
if shell == Shell::Bash {
script = repair_hyphenated_bin_name(&name, script);
let generated = format!("_{}", name.replace('-', "__"));
if script.contains(&format!("{generated}()")) {
script.push_str(
&BASH_DEVICE_WRAPPER
.replace("@PREFIX@", &format!("_{}_device", name.replace('-', "_")))
.replace("@FN@", &generated)
.replace("@BIN@", &name),
);
}
}
print!("{script}");
Ok(())
}
fn list_ports(all: bool) -> Result<()> {
let mut ports = serialport::available_ports().context("enumerating serial ports")?;
if !all {
ports.retain(|port| matches!(port.port_type, SerialPortType::UsbPort(_)));
}
ports.sort_by(|a, b| {
let rank = |port: &SerialPortInfo| !matches!(port.port_type, SerialPortType::UsbPort(_));
(rank(a), a.port_name.clone()).cmp(&(rank(b), b.port_name.clone()))
});
if ports.is_empty() {
eprintln!(
"{}",
if all {
"No serial ports found."
} else {
"No USB serial ports found; pass --all to list every port."
}
);
return Ok(());
}
let width = ports
.iter()
.map(|port| port.port_name.len())
.max()
.unwrap_or(0);
for port in &ports {
println!(
"{:width$} {}",
port.port_name,
describe(&port.port_type),
width = width
);
}
Ok(())
}
fn hat_image_length(link: &mut Link, address: u8, offset: u32) -> Result<u32> {
let header = link.eeprom_read(address, offset, HAT_HEADER_LEN)?;
if !header.starts_with(HAT_SIGNATURE) {
return Err(anyhow!(
"0x{address:02x} does not hold a HAT image (no \"R-Pi\" signature at offset \
{offset}); pass --length to read it anyway"
));
}
let eeplen = u32::from_le_bytes(
header[HAT_EEPLEN_AT..HAT_EEPLEN_AT + 4]
.try_into()
.expect("the header is 12 bytes, so this slice is 4"),
);
if !(HAT_HEADER_LEN..=HAT_MAX_IMAGE).contains(&eeplen) {
return Err(anyhow!(
"the HAT header claims an image length of {eeplen} bytes, which is not \
plausible; pass --length to read it anyway"
));
}
Ok(eeplen)
}
fn main() -> ExitCode {
let cli = Cli::parse();
let interrupted = Arc::new(AtomicBool::new(false));
match run(cli, Arc::clone(&interrupted)) {
Ok(()) if interrupted.load(Ordering::SeqCst) => ExitCode::from(EXIT_INTERRUPTED),
Ok(()) => ExitCode::SUCCESS,
Err(_) if interrupted.load(Ordering::SeqCst) => {
eprintln!("\nInterrupted.");
ExitCode::from(EXIT_INTERRUPTED)
}
Err(e) => {
eprintln!("Error: {e:#}");
ExitCode::FAILURE
}
}
}
fn run(cli: Cli, interrupted: Arc<AtomicBool>) -> Result<()> {
if let Command::List { all } = &cli.command {
return list_ports(*all);
}
if let Command::Completions { shell } = &cli.command {
return write_completions(*shell);
}
if let Command::Bundle {
manifest,
output,
sdcard,
upload,
} = &cli.command
{
return bundle::run(
manifest,
output.clone(),
sdcard.as_deref(),
upload.as_deref(),
);
}
let device = cli.device.as_deref().ok_or_else(|| {
anyhow!("no serial device given; pass --device (e.g. --device /dev/ttyUSB0)")
})?;
let flag = Arc::clone(&interrupted);
if let Err(e) = ctrlc::set_handler(move || flag.store(true, Ordering::SeqCst)) {
eprintln!("Warning: could not install the Ctrl-C handler: {e}");
}
let mut link = Link::open(device, interrupted)?;
if cli.command.needs_handshake() {
link.handshake()?;
}
match cli.command {
Command::Boot {
image,
load_addr,
baud,
} => {
let data = read_file(&image)?;
link.negotiate_baud(baud)?;
link.mem_write(load_addr, &data)?;
link.negotiate_baud(BASE_BAUD)?;
link.exec(load_addr)?;
eprintln!("Jumped to {load_addr:#x}.");
link.terminal()?;
}
Command::MemWrite { addr, file, baud } => {
let data = read_file(&file)?;
link.negotiate_baud(baud)?;
link.mem_write(addr, &data)?;
link.negotiate_baud(BASE_BAUD)?;
eprintln!("Wrote {} bytes to {addr:#x}.", data.len());
}
Command::Exec { addr, terminal } => {
link.exec(addr)?;
eprintln!("Jumped to {addr:#x}.");
if terminal {
link.terminal()?;
}
}
Command::SdList { path } => {
for entry in link.sd_list(&path)? {
let marker = if entry.is_dir { "/" } else { "" };
println!("{:>12} {}{marker}", entry.size, entry.name);
}
}
Command::SdRead {
remote,
local,
baud,
} => {
link.negotiate_baud(baud)?;
let data = link.sd_read(&remote)?;
link.negotiate_baud(BASE_BAUD)?;
fs::write(&local, &data).with_context(|| format!("writing {}", local.display()))?;
eprintln!(
"Read {} bytes from {remote} -> {}",
data.len(),
local.display()
);
}
Command::SdWrite {
local,
remote,
baud,
} => {
let data = read_file(&local)?;
link.negotiate_baud(baud)?;
link.sd_write(&remote, &data)?;
link.negotiate_baud(BASE_BAUD)?;
}
Command::SdDelete { remote } => {
link.sd_delete(&remote)?;
eprintln!("Deleted {remote}");
}
Command::SdMkdir { remote } => {
link.sd_mkdir(&remote)?;
eprintln!("Created directory {remote}");
}
Command::EepromRead {
local,
length,
address,
offset,
baud,
} => {
link.negotiate_baud(baud)?;
let length = match length {
Some(length) => length,
None => hat_image_length(&mut link, address, offset)?,
};
let data = link.eeprom_read(address, offset, length)?;
link.negotiate_baud(BASE_BAUD)?;
fs::write(&local, &data).with_context(|| format!("writing {}", local.display()))?;
eprintln!(
"Read {} bytes from 0x{address:02x} -> {}",
data.len(),
local.display()
);
}
Command::EepromWrite {
local,
address,
offset,
page_size,
baud,
} => {
let data = read_file(&local)?;
if data.is_empty() {
return Err(anyhow!("{} is empty", local.display()));
}
if offset == 0 && !data.starts_with(HAT_SIGNATURE) {
eprintln!(
"Warning: {} does not start with the HAT signature \"R-Pi\"; \
writing it anyway.",
local.display()
);
}
link.negotiate_baud(baud)?;
link.eeprom_write(address, offset, page_size, &data)?;
link.negotiate_baud(BASE_BAUD)?;
eprintln!(
"Programmed and verified {} bytes at offset {offset} of 0x{address:02x}.",
data.len()
);
}
Command::Terminal => link.terminal()?,
Command::Bundle { .. } => unreachable!(),
Command::List { .. } => unreachable!(),
Command::Completions { .. } => unreachable!(),
}
Ok(())
}