defmodule Mix.Tasks.Firmware do
@shortdoc "Build a firmware bundle"
@moduledoc """
Build a firmware image for the selected target platform.
This task builds the project, combines the generated OTP release with
a Nerves system image, and creates a `.fw` file that may be written
to an SDCard or sent to a device.
## Command line options
* `--verbose` - produce detailed output about release assembly
* `--output` - (Optional) The path to the .fw file used to write the patch
firmware. Defaults to `Nerves.Env.firmware_path/1`
## Environment variables
* `NERVES_SYSTEM` - may be set to a local directory to specify the Nerves
system image that is used
* `NERVES_TOOLCHAIN` - may be set to a local directory to specify the
Nerves toolchain (C/C++ crosscompiler) that is used
"""
use Mix.Task
import Mix.Nerves.Utils
alias Mix.Nerves.Preflight
@switches [verbose: :boolean, output: :string]
@impl Mix.Task
def run(args) do
Preflight.check!()
debug_info("Nerves Firmware Assembler")
{opts, _, _} = OptionParser.parse(args, switches: @switches)
system_path = check_nerves_system_is_set!()
_ = check_nerves_toolchain_is_set!()
# By this point, paths have already been loaded.
# We just want to ensure any custom systems are compiled
# via the precompile checks
Mix.Task.run("nerves.precompile", ["--no-loadpaths"])
Mix.Task.run("compile", [])
Mix.Nerves.IO.shell_info("Building OTP Release...")
build_release()
config = Mix.Project.config()
fw_out = opts[:output] || Nerves.Env.firmware_path(config)
build_firmware(config, system_path, fw_out)
end
@doc false
@spec result({Collectable.t(), exit_status :: non_neg_integer()}) :: :ok
def result({_, 0}) do
Mix.shell().info("""
Firmware built successfully! 🎉
Now you may install it to a MicroSD card using `mix burn` or upload it
to a device with `mix upload` or `mix firmware.gen.script`+`./upload.sh`.
""")
end
def result({result, _}) do
Mix.raise("""
Nerves encountered an error. #{inspect(result)}
""")
end
defp build_release() do
Mix.Task.run("release", [])
end
defp build_firmware(config, system_path, fw_out) do
otp_app = config[:app]
compiler_check()
firmware_config = Application.get_env(:nerves, :firmware)
mksquashfs_flags(firmware_config[:mksquashfs_flags])
rootfs_priorities =
Nerves.Env.package(:nerves_system_br)
|> rootfs_priorities()
rel2fw_path = Path.join(system_path, "scripts/rel2fw.sh")
cmd = "bash"
args = [rel2fw_path]
if firmware_config[:rootfs_additions] do
Mix.shell().error(
"The :rootfs_additions configuration option has been deprecated. Please use :rootfs_overlay instead."
)
end
build_rootfs_overlay = Path.join([Mix.Project.build_path(), "nerves", "rootfs_overlay"])
File.mkdir_p!(build_rootfs_overlay)
write_erlinit_config(build_rootfs_overlay)
project_rootfs_overlay =
case firmware_config[:rootfs_overlay] || firmware_config[:rootfs_additions] do
nil ->
[]
overlays when is_list(overlays) ->
overlays
overlay ->
[Path.expand(overlay)]
end
rootfs_overlays =
[build_rootfs_overlay | project_rootfs_overlay]
|> Enum.map(&["-a", &1])
|> List.flatten()
fwup_conf =
case firmware_config[:fwup_conf] do
nil -> []
fwup_conf -> ["-c", Path.join(File.cwd!(), fwup_conf)]
end
fw = ["-f", fw_out]
release_path = Path.join(Mix.Project.build_path(), "rel/#{otp_app}")
output = [release_path]
args = args ++ fwup_conf ++ rootfs_overlays ++ fw ++ rootfs_priorities ++ output
env = standard_fwup_variables(config)
set_provisioning(firmware_config[:provisioning])
config
|> Nerves.Env.images_path()
|> File.mkdir_p!()
shell(cmd, args, env: env)
|> result
end
defp standard_fwup_variables(config) do
# Assuming the fwup.conf file respects these variable like the official
# systems do, this will set the .fw metadata to what's in the mix.exs.
[
{"NERVES_FW_VERSION", config[:version]},
{"NERVES_FW_PRODUCT", config[:name] || to_string(config[:app])},
{"NERVES_FW_DESCRIPTION", config[:description]},
{"NERVES_FW_AUTHOR", config[:author]}
]
end
# Need to check min version for nerves_system_br to check if passing the
# rootfs priorities option is supported. This was added in the 1.7.1 release
# https://github.com/nerves-project/nerves_system_br/releases/tag/v1.7.1
defp rootfs_priorities(%Nerves.Package{app: :nerves_system_br, version: vsn}) do
case Version.compare(vsn, "1.7.1") do
r when r in [:gt, :eq] ->
rootfs_priorities_file =
Path.join([Mix.Project.build_path(), "nerves", "rootfs.priorities"])
if File.exists?(rootfs_priorities_file) do
["-p", rootfs_priorities_file]
else
[]
end
_ ->
[]
end
end
defp rootfs_priorities(_), do: []
defp compiler_check() do
{:ok, otpc} = erlang_compiler_version()
{:ok, elixirc} = elixir_compiler_version()
if otpc.major != elixirc.major do
Mix.raise("""
Elixir was compiled by a different version of the Erlang/OTP compiler
than is being used now. This may not work.
Erlang compiler used for Elixir: #{elixirc.major}.#{elixirc.minor}.#{elixirc.patch}
Current Erlang compiler: #{otpc.major}.#{otpc.minor}.#{otpc.patch}
Please use a version of Elixir that was compiled using the same major
version.
For example:
If your target is running OTP 25, you should use a version of Elixir
that was compiled using OTP 25.
If you're using asdf to manage Elixir versions, run:
asdf install elixir #{System.version()}-otp-#{System.otp_release()}
asdf global elixir #{System.version()}-otp-#{System.otp_release()}
""")
end
end
defp erlang_compiler_version() do
Application.spec(:compiler, :vsn)
|> to_string()
|> parse_otp_version()
end
defp elixir_compiler_version() do
{:file, path} = :code.is_loaded(Kernel)
{:ok, {_, [compile_info: compile_info]}} = :beam_lib.chunks(path, [:compile_info])
{:ok, vsn} = Keyword.fetch(compile_info, :version)
vsn
|> to_string()
|> parse_otp_version()
end
defp write_erlinit_config(build_overlay) do
with user_opts <- Application.get_env(:nerves, :erlinit, []),
{:ok, system_config_file} <- Nerves.Erlinit.system_config_file(Nerves.Env.system()),
{:ok, system_config_file} <- File.read(system_config_file),
system_opts <- Nerves.Erlinit.decode_config(system_config_file),
erlinit_opts <- Nerves.Erlinit.merge_opts(system_opts, user_opts),
erlinit_config <- Nerves.Erlinit.encode_config(erlinit_opts) do
erlinit_config_file = Path.join(build_overlay, "etc/erlinit.config")
Path.dirname(erlinit_config_file)
|> File.mkdir_p!()
header = erlinit_config_header(user_opts)
File.write!(erlinit_config_file, header <> erlinit_config)
else
{:error, :no_config} ->
Nerves.Utils.Shell.warn("There was no system erlinit.config found")
:ok
e ->
Nerves.Utils.Shell.warn("Error constructing erlinit.config: #{inspect(e)}")
:ok
end
end
@doc false
@spec erlinit_config_header(Keyword.t()) :: String.t()
def erlinit_config_header(opts) do
"""
# Generated from rootfs_overlay/etc/erlinit.config
""" <>
if opts != [] do
"""
# with overrides from the application config
"""
else
"""
"""
end
end
defp mksquashfs_flags(nil), do: :noop
defp mksquashfs_flags(flags) do
System.put_env("NERVES_MKSQUASHFS_FLAGS", Enum.join(flags, " "))
end
end