#!/usr/bin/perl -w

# these are bundled with perl.
use JSON;
use Getopt::Std;

# so is this one, but becase it's optional, proceed without it if
# it's missing.
eval { require Digest::SHA; Digest::SHA->import('sha256_hex'); $sha_ok = 1 };

$VERSION="0.0.1";

$RELEASE_URL = "https://api.github.com/repos/FujiNetWIFI/fujinet-firmware/releases";

sub slurpfile {
	my $file = shift;
	open my $f, "<$file" or die "$file: $!\n";
	my $old = $/;
	undef $/;
	my $content = <$f>;
	$/ = $old;
	close $f;
	return $content;
}

# all the programs we want to check, will exit if called with
# no arguments. so this is simpler than expected.
sub checkpath {
	my $exe = shift;
	my $result = system("$exe </dev/null 1>/dev/null 2>&1");
	$result >>= 8;
	if($result < 0 || $result > 2) {
		warn "can't find $exe on PATH.\n";
		return 0;
	}
	warn "found $exe on PATH\n";
	return 1;
}

sub check_paths {
	# the -L is needed to make curl follow redirects.
	my $curlcmd = "curl -L -o";

	# wget follows redirects by default.
	my $wgetcmd = "wget -nv -O";

	if($opts{c}) {
		if(checkpath("curl")) {
			$dlcmd = $curlcmd;
		} else {
			die_path("curl");
		}
	} elsif(checkpath("wget")) {
		$dlcmd = $wgetcmd;
	} elsif(checkpath("curl")) {
		$dlcmd = $curlcmd;
	} else {
		die_path("curl or wget");
	}

	if(!checkpath("unzip")) {
		die_path("unzip");
	}

	if(!checkpath("pio")) {
		if(!$opts{n}) {
			warn "  disabling monitoring.\n";
			$opts{n} = 1;
		}
		if(!$opts{p}) {
			die "  can't autodetect FujiNet, install pio or use -p option.\n";
		}
	}
}

sub prepend_sudo {
	my @ret;
	for my $cmd (@_) {
		$cmd = "sudo " . $cmd if $opts{s};
		push @ret, $cmd;
	}
	return @ret;
}

sub check_digest {
	if(!$sha_ok) {
		warn "can't verify digest because Digest::SHA module is missing.\n";
		return;
	}

	my $file = shift;
	my $cksum = shift;

	if(!$cksum) {
		warn "No digest provided for this file.\n";
		return;
	}

	my($algo, $digest) = split /:/, $cksum;

	if($algo ne 'sha256') {
		warn "don't know how to check $algo checksums, sorry.\n";
		return;
	}

	my $content = slurpfile($file);
	my $res = sha256_hex($content);
	if($digest eq $res) {
		print "\n$algo digest verified.\n";
	} else {
		print "\n$algo digest does not match.\n";
		print "Use file anyway [y/N]? ";
		chomp(my $yn = <>);
		if($yn !~ /y/i) {
			print "Delete it? [Y/n]? ";
			chomp($yn = <>);
			if($yn !~ /n/i) {
				unlink $file;
			}
			exit(1);
		}
	}
}

sub download_firmware {
	my $url = shift;
	my $cksum = shift;
	my $file = $url;
	$file =~ s,.*/,,;

	if(-e $file) {
		print "$file already exists, download anyway [y/N]? ";
		chomp(my $c = <>);
		if($c !~ /^y/i) {
			check_digest($file, $cksum);
			return $file;
		}
	}

	system("$dlcmd $file $url");
	# TODO: check for errors from wget/curl? we already check the file thoroughly...

	if(! -e $file) {
		die "failed to download $url!\n";
	}

	# make sure it looks OK.
	open my $f, "<$file" or die "can't read $file: $!\n";
	read($f, my $bytes, 2) or die "can't read $file: $!\n";
	close $f;
	if($bytes ne 'PK') {
		die "$file is not a .zip file!\n";
	}

	check_digest($file, $cksum);

	return $file;
}

sub show_choices {
	if(!$opts{s}) {
		print "\nFujiNet platform [$sys]? ";
		my $p = <>;
		chomp $p;
		$sys = uc $p if length($p);
	}

	print "\nChecking for $sys releases.\n\n";
	my $tmpfile = "releases.$$.json";
	unlink $tmpfile;
	system("$dlcmd releases.$$.json $RELEASE_URL");
	if(! -e $tmpfile) {
		die "failed to download from $RELEASE_URL\n";
	}

	my $json = slurpfile($tmpfile);

	my $j = decode_json($json);
	die "$tmpfile: JSON decoding failed, huh?\n" unless $j;

	unlink $tmpfile;


	my $num = 1;
	my @choices;
	my %checksums;

	print "\nChoose release:\n";

	for(@{$j}) {
		for(@{$_->{assets}}) {
			my $url = $_->{browser_download_url};
			my $d = $_->{digest};
			next unless $url =~ /\/fujinet-[A-Z].*\.zip$/;
			if($sys ne "ALL") {
				next unless $url =~ /-$sys-.*\.zip$/i;
				next if $sys eq "ATARI" && $url =~ /-8mb/;
			}
			$checksums{$url} = $d;
			my $file = $url;
			$file =~ s,.*/,,;
			$choices[$num] = $url;
			print "[$num] $file\n";
			$num++;
		}
	}

	if(!@choices) {
		die "No firmware releases for $sys!\n";
	}

	print "-> ";
	chomp(my $c = <>);
	die "invalid choice.\n" unless $choices[$c];

	my $url = $choices[$c];
	my $cksum = $checksums{$url};
	return($url, $cksum);
}

sub get_esptool_cmds {
	my $file = shift;
	if(! -e $file) {
		die "$file doesn't exist.\n";
	}
	if(-d $file) {
		$dir = $file;
		print "Using extracted dir $dir\n";
	} else {
		print "Extracting $file\n";
		$dir = $file;
		$dir =~ s,\.zip$,,;
		$dir =~ s,.*/,,;
		system("rm -rf $dir");
		mkdir $dir;
		if(system("unzip $file -d $dir") != 0) {
			die "failed to extract $file\n";
		}
	}
	chdir($dir) or die "$dir: $!\n";

	my $json = slurpfile("release.json");
	my $j = decode_json($json) or die "$!";

	for(@{$j->{files}}) {
		my $f = $_->{filename};
		die "$f referenced in release.json, but doesn't exist.\n"
			unless -e $f;
		push @commands,
			  "esptool.py --port $port --baud $baud " .
				  "write_flash $_->{offset} $f";
	}

	if($mon) {
		push @commands, "pio device monitor -b $baud -p $port";
	}

	@commands = prepend_sudo(@commands);

	die "no filenames in release.json\n" unless @commands;

	return @commands;
}

sub confirm {
	print "\nAbout to run these commands to flash the firmware:\n";
	print "   $_\n" for @commands;
	print "\nAre you sure you want to proceed[y/N]? ";
	chomp(my $resp = <>);
	return $resp =~ /^y/i;
}

sub port_menu {
	my $choice = 1;
	my @p;
	print "\nFound multiple devices.\n";
	for(@_) {
		print "[$choice] " . $_ . "\n";
		$p[$choice++] = $_;
	}
	print "-> ";
	chomp($choice = <>);
	my $port = $p[$choice];
	die "Invalid choice\n" unless $port;
	return $port;
}

# TODO: be a little more specific. Right now it just assumes if pio
# doesn't call the hardware "n/a" that it's really a FujiNet.
# Both my FujiNets are VID:PID=10C4:EA60, CP2102N USB to UART Bridge Controller,
# but I don't know if *all* FujiNets will identify as this.
sub detect_fujinet {
	my @cmd = ("pio --no-ansi device list");
	@cmd = prepend_sudo(@cmd);
	open my $fh, '-|', $cmd[0] or die "pio failed\n";

	print "\nDetecting FujiNet(s)...\n";

	my $tty;
	my @res;
	my $vidpid;
	while(<$fh>) {
		chomp;
		if(m,(/dev/\S+),) {
			$tty = $1;
			next;
		}
		if(m,^Hardware ID: USB (VID:PID=\S+),) {
			$vidpid = $1;
			push @res, $tty;
			next;
		}
		if(!m,n/a, && m,^Description: (\S+),) {
			print "$tty: $vidpid $1\n";
			next;
		}
	}
	close $fh;

	if(!@res) {
		print "\npio couldn't find a FujiNet, are you sure it's plugged in?\n";
		print "If you're sure you know what you're doing, re-run with -p <port> option.\n";
		exit(1);
	} elsif(@res == 1) {
		$port = $res[0];
		print "\nFound $port, using.\n";
	} else {
		$port = port_menu(@res);
		print "\nOK, using $port\n";
	}

	return $port;
}

sub interact {
	mkdir($homedir);
	chdir($homedir) or die "$homedir: $!\n";

	check_paths();

	$port = detect_fujinet() unless $port;
	if(! -w $port) {
		print "\nCurrent user has no write permission for $port! Try -s option.\n";
	}
	($url, $cksum) = show_choices();
	$file = download_firmware($url, $cksum);

	@commands = get_esptool_cmds($file);

	if(confirm()) {
		return $file;
	} else {
		print "Aborted.\n";
		exit 0;
	}
}

sub flash {
	for(@commands) {
		print "===> $_\n";
		system($_);
	}
}

### main()
if(@ARGV) {
	for($ARGV[0]) {
		if(/^--?(?:\?|h)/) {
			exec "perldoc $0";
			exit 0;
		} elsif(/^--?man/) {
			exec "pod2man --stderr -s6 -cUrchlaysStuff -r$VERSION -u $0";
			exit 0;
		} elsif(/^--?version/) {
			print "$VERSION\n";
			exit 0;
		}
	}
}

getopts('st:b:p:nc', \%opts) or die "bad option\n";

if(@ARGV) {
	$opts{f} = shift @ARGV;
	if(@ARGV) {
		die "only one file/directory is supported.\n";
	}
}

# TODO: option to override this?
$homedir = "$ENV{HOME}/.fujinet-updater";

$baud = $opts{b} || 460800;
$port = $opts{p};
$sys  = uc($opts{t} || "atari");
$mon  = !$opts{n};

if($opts{f}) {
	$file = $opts{f};
	$port = $opts{p} || "/dev/ttyUSB0";
	if($file !~ /^\//) {
		my $p = `pwd`;
		chomp($p);
		$file = $p . '/' . $file;
	}
	if(-d $file) {
		chdir($file) or die "$file: $!\n";
	} else {
		mkdir($homedir);
		chdir($homedir) or die "$homedir: $!\n";
	}
	@commands = get_esptool_cmds($file);
} else {
	interact();
}

flash();

=pod

=head1 NAME

fujinet-updater - download and flash the firmware on a FujiNet device.

=head1 SYNOPSIS

B<fujinet-updater> [B<-s>] [B<-t> I<board-type>] [B<-b> I<baud>] [B<-p> I<serial-port>] [B<-n>] <I<zipfile-or-directory>>

=head1 DESCRIPTION

B<fujinet-updater> is a command-line tool which will flash the
firmware on a FujiNet device. It will download the firmware from
the fujinet-firmware GitHub releases page with B<wget> or B<curl>,
and uses B<esptool.py> to upload the firmware to the device and
(if installed) B<pio> to monitor the FujiNet's log messages when it
reboots after being flashed.

Normally, B<fujinet-updater> runs interactively. It attempts to
autodetect the FujiNet's USB serial device, and prompts for the
target system. Then it searches for firmware releases for the target
system, and presents a menu from which you can choose the version to
flash. After downloading and unzipping the firmware, you will be shown
the exact B<esptool.py> commands that will be executed, and offered a
last chance to abort before flashing the device.

For non-interactive use, you can give the path to an
already-downloaded (and possibly already-extracted) firmware zip file
or directory.

=head1 OPTIONS

=over 4

=item B<--help>

Displays the documentation you're reading now, using the B<perldoc>(1)
command.

=item B<--man>

Prints this documentation in man page (troff/nroff source) format.

=item B<--version>

Prints the B<fujinet-updated> version and exits.

=item B<-s>

Run B<pio> and B<esptool.py> commands with B<sudo>(8). If you haven't
already authenticated with sudo in the current session, you'll be
asked for your password when the first command is run.

It's better to avoid this option, if possible. Example: on Slackware
Linux, the B</dev/ttyUSB*> devices are owned by user I<root>, group
I<dialout>, and the group has read/write permission. So, you can add
your user to the I<dialout> group (using e.g. B<usermod>(8)), and
you'll be able to access the devices without using B<sudo>. Note
that if you add your user to a new group, you generally have to
log out and back in for the new group to be usable.

=item B<-c>

Use B<curl>, if both B<curl> and B<wget> are installed. Normally,
B<wget> is the default. There's no particular reason to prefer
B<curl> or B<wget> (both work fine).

=item B<-t> I<board-type>

Which system (platform) your FujiNet board is designed for. This
is case-insensitive. Default is I<atari>. Other choices include
I<atari-8mb>, I<adam>, I<apple>, I<coco>, etc. Use B<-t> I<all> to
see all platforms. In interactive mode, you are prompted for the
platform, and this just sets the default.

B<NOTE>: Only I<atari> has been tested!

=item B<-b> I<baud>

Baud (or actually, bits-per-second) rate for the flashing process. Defaults
to I<460800>. Normally you should not need to change this.

=item B<-p> I<serial-port>

Serial port device for the FujiNet. In interactive mode (no filename
argument), the default is to auto-detect (using B<pio device
list>). In non-interactive mode, defaults to I</dev/ttyUSB0>.

=item B<-n>

Do not run B<pio device monitor> to monitor the debug output from the
FujiNet when it reboots after being flashed. Normally you would want
to see this output.

=item I<zipfile-or-directory>

Use a specific firmware that's already been downloaded, rather than
prompting for a file to download. With this option, B<fujinet-updater>
does not prompt the user for anything, I<including> the final
confirmation prompt before flashing the device. It also doesn't
autodetect the FujiNet (use the B<-p> option, if the port isn't
I</dev/ttyUSB0>).

If the argument is a directory, it will be used as-is. If it's a zip
file, it will be extracted to a temporary directory. Either way, the
zip file or directory B<must> contain the B<release.json> file that
lists the firmware files and their offsets, and all files mentioned
there B<must> be present.

When using a local file, no network activity is done (so this can also
be considered "offline" mode).

For I<totally> non-interactive mode, you'll want to combine this
with B<-n> (to disable monitoring).

=back

=head1 RATIONALE

Why does this need to exist? There's already the official
fujinet-flasher, and the fujinet_firmware_uploader.py script in the
fujinet-firmware source repo...

The official fujinet-flasher is a Python3 GUI program. Python3 is
still a fast-moving target, and its developers don't care about
supporting older versions of Python3 (or older Linux glibc versions,
for their binary releases). Plus, it's graphical. Some of us prefer
command-line tools, especially ones that can be run non-interactively.

But, fujinet_firmware_uploader.py is a command line tool, isn't
it? Yes, but as shipped, it's broken: hardcoded weird place where
it looks for the esptool.py executable, and it only flashes 2 of
the files in the release zip file (no idea why, but this leaves the
FujiNet in an unusable state). Also, it always downloads I<all> the
firmware .zip files it finds, and extracts its files to the current
directory, which can be annoying... and if you need to use B<sudo>
to read/write to the serial port, root access is also used to download the
files. B<fujinet-updater> doesn't do anything as root except actually
communicate with the serial port.

B<fujinet-updater> can be used non-interactively (e.g. from a Makefile
or other script), which is not true of either of the existing tools.
It also is written in Perl, and doesn't use any external Perl modules
that don't ship with Perl itself (so, no dependency hell).

The disadvantage is, B<fujinet-updater> calls external binaries:
B<wget>(1) or B<curl>(1), B<esptool.py>, and B<pio>. It also pretty
much assumes a UNIX/POSIX like environment, so it might be a PITA to
get working on Windows.

=head1 FILES

B<~/.fujinet-updater/> - all downloaded zip files, and their extracted
contents, are saved here.

=head1 AUTHOR

B<fujinet-updater> was written by B. Watson <urchlay@slackware.uk> and
released under the WTFPL: Do WTF you want with this.

=cut
