# PiFlash::MediaWriter - write to Raspberry Pi SD card installation with scriptable customization
# by Ian Kluft

use strict;
use warnings;
use v5.14.0; # require 2011 or newer version of Perl
use PiFlash::State;
use PiFlash::Command;
use PiFlash::Inspector;
use PiFlash::Hook;

package PiFlash::MediaWriter;
$PiFlash::MediaWriter::VERSION = '0.4.1';
use autodie; # report errors instead of silently continuing ("die" actions are used as exceptions - caught & reported)
use Try::Tiny;
use File::Basename;
use File::Slurp qw(slurp);

# ABSTRACT: write to Raspberry Pi SD card installation with scriptable customization


# generate random hex digits
sub random_hex
{
	my $length = shift;
	my $hex = "";
	while ($length > 0) {
		my $chunk = ($length > 4) ? 4 : $length;
		$length -= $chunk;
		$hex .= sprintf "%0*x", $chunk, int(rand(16**$chunk));
	}
	return $hex;
}

# generate a random UUID
# 128 bits/32 hexadecimal digits, used to set a probably-unique UUID on an ext2/3/4 filesystem we created
sub random_uuid
{
	my $uuid;

	# start with our own contrived prefix for our UUIDs
	$uuid .= "314decaf-"; # "314" first digits of pi (as in RasPi), and "decaf" among few words from hex digits

	# next 4 digits are from lower 4 hex digits of current time (rolls over every 18 days)
	$uuid .= sprintf "%04x-", (time & 0xffff);

	# next 4 digits are the UUID format version (4 for random) and 3 random hex digits
	$uuid .= "4".random_hex(3)."-";

	# next 4 digits are a UUID variant digit and 3 random hex digits
	$uuid .= (sprintf "%x", 8+int(rand(4))).random_hex(3)."-";
	
	# conclude with 8 random hex digits
	$uuid .= random_hex(12);

	return $uuid;
}

# generate a random label string
# 11 characters, used to set a probably-unique label on a VFAT/ExFAT filesystem we created
sub random_label
{
	my $label = "RPI";
	for (my $i=0; $i<8; $i++) {
		my $num = int(rand(36));
		if ($num <= 9) {
			$label .= chr(ord('0')+$num);
		} else {
			$label .= chr(ord('A')+$num-10);
		}
	}
	return $label;
}

# reread partition table, with retries if necessary
sub reread_pt
{
	my $reason = shift;

	# re-read partition table, use multiple tries if necessary
	my $tries = 10;
	while (1) {
		try {
			PiFlash::Command::cmd("reread partition table for $reason", PiFlash::Command::prog("sudo"),
				PiFlash::Command::prog("blockdev"), "--rereadpt", PiFlash::State::output("path"));
		};

		# check for errors, retry if possible
		if ($@) {
			if (ref $@) {
				# reference means unrecognized error - rethrow the exception
				die $@;
			} elsif ($@ =~ /exited with value 1/) {
				# exit status 1 means retry
				$tries--;
				if ($tries > 0) {
					# wait a second and try again - sync may need to settle
					sleep 1;
					next;
				}
				# otherwise fail for repeated failed retries
				die $@;
			} else {
				# other unrecognized error - rethrow the exception
				die $@;
			}
		}

		# got through without an error - done
		last;
	}
}

# look up boot and root partition & filesystem info
# save data in PiFlash::State::output
sub get_sd_partitions
{
	my $output = PiFlash::State::output();
	(exists $output->{partitions}) and return;
	my @partitions = grep {/part\s*$/} PiFlash::Command::cmd2str("lsblk - find partitions",
		PiFlash::Command::prog("lsblk"), "--list", PiFlash::State::output("path"));

	if (@partitions) {
		for (my $i=0; $i<scalar @partitions; $i++) {
			$partitions[$i] =~ s/^([^\s]+)\s.*/$1/;
		}
		my $part_boot = $partitions[0];
		my $num_root = scalar @partitions;
		my $part_root = $partitions[$num_root-1];
		PiFlash::State::output("num_boot", 0);
		PiFlash::State::output("part_boot", $part_boot);
		PiFlash::State::output("fstype_boot", PiFlash::Inspector::get_fstype("/dev/$part_boot"));
		PiFlash::State::output("num_root", $num_root);
		PiFlash::State::output("part_root", $part_root);
		PiFlash::State::output("fstype_root", PiFlash::Inspector::get_fstype("/dev/$part_root"));
	}
	PiFlash::State::output("partitions", \@partitions);

	if (PiFlash::State::verbose()) {
		print "get_sd_partitions: ";
		for my $key (qw(num_boot part_boot fstype_boot num_root part_root fstype_root)) {
			print "$key=".(PiFlash::State::output($key) // "undef")." ";
		}
		print "\n";
	}
}

# flash the output device from the input file
sub flash_device
{
	# flash the device
	if (PiFlash::State::has_input("imgfile")) {
		# if we know an embedded image file name, use it in the start message
		say "flashing ".PiFlash::State::input("path")." / ".PiFlash::State::input("imgfile")." -> "
			.PiFlash::State::output("path");
	} else {
		# print a start message with source and destination
		say "flashing ".PiFlash::State::input("path")." -> ".PiFlash::State::output("path");
	}
	say "wait for it to finish - this takes a while, progress not always indicated";
	my $dd_args = "bs=4M oflag=sync status=progress";
	if (PiFlash::State::input("type") eq "img") {
		PiFlash::Command::cmd("dd flash", PiFlash::Command::prog("sudo")." ".PiFlash::Command::prog("dd")
			." if=\"".PiFlash::State::input("path")."\" of=\""
			.PiFlash::State::output("path")."\" $dd_args" );
	} elsif (PiFlash::State::input("type") eq "zip") {
		if (PiFlash::State::has_input("NOOBS")) {
			# format SD and copy NOOBS archive to it
			my $label = random_label();
			PiFlash::State::output("label", $label);
			my $fstype = PiFlash::State::system("primary_fs");
			if ($fstype ne "vfat") {
				PiFlash::State->error("NOOBS requires VFAT filesystem, not in this kernel - need to load a module?");
			}
			say "formatting $fstype filesystem for Raspberry Pi NOOBS system...";
			PiFlash::Command::cmd("write partition table", PiFlash::Command::prog("echo"), "type=c", "|",
				PiFlash::Command::prog("sudo"), PiFlash::Command::prog("sfdisk"), PiFlash::State::output("path"));
			my @partitions = grep {/part\s*$/} PiFlash::Command::cmd2str("lsblk - find partitions",
				PiFlash::Command::prog("lsblk"), "--list", PiFlash::State::output("path"));
			$partitions[0] =~ /^([^\s]+)\s/;
			my $partition = "/dev/".$1;
			PiFlash::Command::cmd("format sd card", PiFlash::Command::prog("sudo"),
				PiFlash::Command::prog("mkfs.$fstype"), "-n", $label, $partition);
			my $mntdir = PiFlash::State::system("media_dir")."/piflash/sdcard";
			PiFlash::Command::cmd("reread partition table for NOOBS", PiFlash::Command::prog("sudo"),
				PiFlash::Command::prog("blockdev"), "--rereadpt", PiFlash::State::output("path"));
			PiFlash::Command::cmd("create mount point", PiFlash::Command::prog("sudo"),
				PiFlash::Command::prog("mkdir"), "-p", $mntdir );
			PiFlash::Command::cmd("mount SD card", PiFlash::Command::prog("sudo"), PiFlash::Command::prog("mount"),
				"-t", $fstype, "LABEL=$label", $mntdir);
			PiFlash::Command::cmd("unzip NOOBS contents", PiFlash::Command::prog("sudo"),
				PiFlash::Command::prog("unzip"), "-d", $mntdir, PiFlash::State::input("path"));
			PiFlash::Command::cmd("unmount SD card", PiFlash::Command::prog("sudo"), PiFlash::Command::prog("umount"),
				$mntdir);
		} else {
			# flash zip archive to SD
			PiFlash::Command::cmd("unzip/dd flash", PiFlash::Command::prog("unzip")." -p \""
				.PiFlash::State::input("path")."\" \"".PiFlash::State::input("imgfile")."\" | "
				.PiFlash::Command::prog("sudo")." ".PiFlash::Command::prog("dd")." of=\""
				.PiFlash::State::output("path")."\" $dd_args");
		}
	} elsif (PiFlash::State::input("type") eq "gz") {
		# flash gzip-compressed image file to SD
		PiFlash::Command::cmd("gunzip/dd flash", PiFlash::Command::prog("gunzip")." --stdout \""
			.PiFlash::State::input("path")."\" | ".PiFlash::Command::prog("sudo")." ".PiFlash::Command::prog("dd")
			." of=\"".PiFlash::State::output("path")."\" $dd_args");
	} elsif (PiFlash::State::input("type") eq "xz") {
		# flash xz-compressed image file to SD
		PiFlash::Command::cmd("xz/dd flash", PiFlash::Command::prog("xz")." --decompress --stdout \""
			.PiFlash::State::input("path")."\" | ".PiFlash::Command::prog("sudo")." ".PiFlash::Command::prog("dd")
			." of=\"".PiFlash::State::output("path")."\" $dd_args");
	}
	say "- synchronizing buffers";
	PiFlash::Command::cmd("sync", PiFlash::Command::prog("sync"));
	reread_pt("post-sync"); # re-read partition table, use multiple tries if necessary
	get_sd_partitions();
	my @partitions = PiFlash::State::output("partitions");

	# check if there are any partitions before partition-dependent processing
	# protects from scenario (such as RISCOS) where whole-device filesystem has no partition table
	if (@partitions) {
		my $sd_name = basename(PiFlash::State::output("path"));
		my $num_boot = PiFlash::State::output("num_boot");
		my $part_boot = PiFlash::State::output("part_boot");
		my $num_root = PiFlash::State::output("num_root");
		my $part_root = PiFlash::State::output("part_root");
		my $fstype_root = PiFlash::State::output("fstype_root");

		# resize root filesystem if command-line flag is set
		# resize flag is silently ignored for NOOBS images because it will re-image and resize
		if (PiFlash::State::has_cli_opt("resize") and not PiFlash::State::has_input("NOOBS")) {
			say "- resizing the partition";

			if ((defined $fstype_root) and $fstype_root =~ /^ext[234]/ ) {
				# ext2/3/4 filesystem can be resized
				my @sfdisk_resize_input = ( ", +" );
				PiFlash::Command::cmd2str(\@sfdisk_resize_input, "resize partition",
					PiFlash::Command::prog("sudo"), PiFlash::Command::prog("sfdisk"), "--quiet", "--no-reread", "-N",
					$num_root, PiFlash::State::output("path"));
				say "- checking the filesystem";
				PiFlash::Command::cmd2str("filesystem check", PiFlash::Command::prog("sudo"),
					PiFlash::Command::prog("e2fsck"), "-fy", "/dev/$part_root");
				say "- resizing the filesystem";
				PiFlash::Command::cmd2str("resize filesystem", PiFlash::Command::prog("sudo"),
					PiFlash::Command::prog("resize2fs"), "/dev/$part_root");
			} else {
				warn "unrecognized filesystem type ".($fstype_root // "")." - resize not attempted";
			}
		}

		# check if any hooks are registered for filesystem access
		if (PiFlash::Hook::has("fs_mount")) {
			reread_pt("filesystem hooks"); # re-read partition table, use multiple tries if necessary
			get_sd_partitions();
			my @partitions = PiFlash::State::output("partitions");
			my $fstype_boot = PiFlash::State::output("fstype_boot");
			my $dev_boot = "/dev/".PiFlash::State::output("part_boot");
			my $fstype_root = PiFlash::State::output("fstype_root");
			my $dev_root = "/dev/".PiFlash::State::output("part_root");
			my $mntdir = PiFlash::State::system("media_dir")."/piflash/sdcard";
			my $mnt_boot = $mntdir."/boot";
			my $mnt_root = $mntdir."/root";
			PiFlash::Command::cmd("create mount point for boot fs", PiFlash::Command::prog("sudo"),
				PiFlash::Command::prog("mkdir"), "-p", $mnt_boot );
			PiFlash::Command::cmd("create mount point for root fs", PiFlash::Command::prog("sudo"),
				PiFlash::Command::prog("mkdir"), "-p", $mnt_root );
			PiFlash::Command::cmd("mount boot fs", PiFlash::Command::prog("sudo"), PiFlash::Command::prog("mount"),
				"-t", $fstype_boot, $dev_boot, $mnt_boot);
			PiFlash::Command::cmd("mount root fs", PiFlash::Command::prog("sudo"), PiFlash::Command::prog("mount"),
				"-t", $fstype_root, $dev_root, $mnt_root);
			PiFlash::Hook::fs_mount({boot => $mnt_boot, root => $mnt_root});
			PiFlash::Command::cmd("unmount root fs", PiFlash::Command::prog("sudo"), PiFlash::Command::prog("umount"),
				$mnt_root);
			PiFlash::Command::cmd("unmount boot fs", PiFlash::Command::prog("sudo"), PiFlash::Command::prog("umount"),
				$mnt_boot);
		}
	}

	# call hooks for optional post-install tweaks
	PiFlash::Hook::post_install();

	# report that it's done
	say "done - it is safe to remove the SD card";
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

PiFlash::MediaWriter - write to Raspberry Pi SD card installation with scriptable customization

=head1 VERSION

version 0.4.1

=head1 SYNOPSIS

 PiFlash::MediaWriter::flash_device();

=head1 DESCRIPTION

=head1 SEE ALSO

L<piflash>, L<PiFlash::Command>, L<PiFlash::Inspector>, L<PiFlash::State>

=head1 AUTHOR

Ian Kluft <cpan-dev@iankluft.com>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2017-2019 by Ian Kluft.

This is free software, licensed under:

  The Apache License, Version 2.0, January 2004

=cut
