unleashed-firmware/scripts/fwflash.py

499 lines
16 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
import logging
import os
import re
import socket
import subprocess
import time
import typing
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from flipper.app import App
from serial.tools.list_ports_common import ListPortInfo
# When adding an interface, also add it to SWD_TRANSPORT in fbt/ufbt options
class Programmer(ABC):
root_logger = logging.getLogger("Programmer")
@abstractmethod
def flash(self, file_path: str, do_verify: bool) -> bool:
pass
@abstractmethod
def probe(self) -> bool:
pass
@abstractmethod
def get_name(self) -> str:
pass
@abstractmethod
def set_serial(self, serial: str):
pass
@classmethod
def _spawn_and_await(cls, process_params, show_progress: bool = False):
cls.root_logger.debug(f"Launching: {' '.join(process_params)}")
process = subprocess.Popen(
process_params,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if show_progress:
while process.poll() is None:
time.sleep(0.25)
print(".", end="", flush=True)
print()
else:
process.wait()
return process
@dataclass
class OpenOCDInterface:
name: str
config_file: str
serial_cmd: str
additional_args: typing.Optional[list[str]] = field(default_factory=list)
class OpenOCDProgrammer(Programmer):
def __init__(self, interface: OpenOCDInterface):
self.interface = interface
self.logger = self.root_logger.getChild("OpenOCD")
self.serial: typing.Optional[str] = None
def _add_file(self, params: list[str], file: str):
params += ["-f", file]
def _add_command(self, params: list[str], command: str):
params += ["-c", command]
def _add_serial(self, params: list[str], serial: str):
self._add_command(params, f"{self.interface.serial_cmd} {serial}")
def set_serial(self, serial: str):
self.serial = serial
def flash(self, file_path: str, do_verify: bool) -> bool:
if os.altsep:
file_path = file_path.replace(os.sep, os.altsep)
openocd_launch_params = ["openocd"]
self._add_file(openocd_launch_params, self.interface.config_file)
if self.serial:
self._add_serial(openocd_launch_params, self.serial)
if self.interface.additional_args:
for additional_arg in self.interface.additional_args:
self._add_command(openocd_launch_params, additional_arg)
self._add_file(openocd_launch_params, "target/stm32wbx.cfg")
self._add_command(openocd_launch_params, "init")
program_params = [
"program",
f'"{file_path}"',
"verify" if do_verify else "",
"reset",
"exit",
"0x8000000" if file_path.endswith(".bin") else "",
]
self._add_command(openocd_launch_params, " ".join(program_params))
# join the list of parameters into a string, but add quote if there are spaces
openocd_launch_params_string = " ".join(
[f'"{p}"' if " " in p else p for p in openocd_launch_params]
)
self.logger.debug(f"Launching: {openocd_launch_params_string}")
process = self._spawn_and_await(openocd_launch_params, True)
success = process.returncode == 0
if not success:
self.logger.error("OpenOCD failed to flash")
if process.stdout:
self.logger.error(process.stdout.read().decode("utf-8").strip())
return success
def probe(self) -> bool:
openocd_launch_params = ["openocd"]
self._add_file(openocd_launch_params, self.interface.config_file)
if self.serial:
self._add_serial(openocd_launch_params, self.serial)
if self.interface.additional_args:
for additional_arg in self.interface.additional_args:
self._add_command(openocd_launch_params, additional_arg)
self._add_file(openocd_launch_params, "target/stm32wbx.cfg")
self._add_command(openocd_launch_params, "init")
self._add_command(openocd_launch_params, "exit")
process = self._spawn_and_await(openocd_launch_params)
success = process.returncode == 0
output = process.stdout.read().decode("utf-8").strip() if process.stdout else ""
self.logger.debug(output)
# Find target voltage using regex
if match := re.search(r"Target voltage: (\d+\.\d+)", output):
voltage = float(match.group(1))
if not success:
if voltage < 1:
self.logger.warning(
f"Found {self.get_name()}, but device is not connected"
)
else:
self.logger.warning(
f"Device is connected, but {self.get_name()} failed to attach. Is System>Debug enabled?"
)
if "cannot read IDR" in output:
self.logger.warning(
f"Found {self.get_name()}, but failed to attach. Is device connected and is System>Debug enabled?"
)
success = False
return success
def get_name(self) -> str:
return self.interface.name
def blackmagic_find_serial(serial: str):
import serial.tools.list_ports as list_ports
if serial and os.name == "nt":
if not serial.startswith("\\\\.\\"):
serial = f"\\\\.\\{serial}"
# idk why, but python thinks that list_ports.grep returns tuple[str, str, str]
ports: list[ListPortInfo] = list(list_ports.grep("blackmagic")) # type: ignore
if len(ports) == 0:
return None
elif len(ports) > 2:
if serial:
ports = list(
filter(
lambda p: p.serial_number == serial
or p.name == serial
or p.device == serial,
ports,
)
)
if len(ports) == 0:
return None
if len(ports) > 2:
raise Exception("More than one Blackmagic probe found")
# If you're getting any issues with auto lookup, uncomment this
# print("\n".join([f"{p.device} {vars(p)}" for p in ports]))
port = sorted(ports, key=lambda p: f"{p.location}_{p.name}")[0]
if serial:
if (
serial != port.serial_number
and serial != port.name
and serial != port.device
):
return None
if os.name == "nt":
port.device = f"\\\\.\\{port.device}"
return port.device
def _resolve_hostname(hostname):
try:
return socket.gethostbyname(hostname)
except socket.gaierror:
return None
def blackmagic_find_networked(serial: str):
if not serial or serial == "auto":
serial = "blackmagic.local"
# remove the tcp: prefix if it's there
if serial.startswith("tcp:"):
serial = serial[4:]
# remove the port if it's there
if ":" in serial:
serial = serial.split(":")[0]
if not (probe := _resolve_hostname(serial)):
return None
return f"tcp:{probe}:2345"
class BlackmagicProgrammer(Programmer):
def __init__(
self,
port_resolver, # typing.Callable[typing.Union[str, None], typing.Optional[str]]
name: str,
):
self.port_resolver = port_resolver
self.name = name
self.logger = self.root_logger.getChild(f"Blackmagic{name}")
self.port: typing.Optional[str] = None
def _add_command(self, params: list[str], command: str):
params.append("-ex")
params.append(command)
def _valid_ip(self, address):
try:
socket.inet_aton(address)
return True
except Exception:
return False
def set_serial(self, serial: str):
if self._valid_ip(serial):
self.port = f"{serial}:2345"
elif ip := _resolve_hostname(serial):
self.port = f"{ip}:2345"
else:
self.port = serial
def _get_gdb_core_params(self) -> list[str]:
gdb_launch_params = ["arm-none-eabi-gdb"]
self._add_command(gdb_launch_params, f"target extended-remote {self.port}")
self._add_command(gdb_launch_params, "set pagination off")
self._add_command(gdb_launch_params, "set confirm off")
self._add_command(gdb_launch_params, "monitor swdp_scan")
return gdb_launch_params
def flash(self, file_path: str, do_verify: bool) -> bool:
if not self.port:
if not self.probe():
return False
# We can convert .bin to .elf with objcopy:
# arm-none-eabi-objcopy -I binary -O elf32-littlearm --change-section-address=.data=0x8000000 -B arm -S app.bin app.elf
# But I choose to use the .elf file directly because we are flashing our own firmware and it always has an elf predecessor.
if file_path.endswith(".bin"):
file_path = file_path[:-4] + ".elf"
if not os.path.exists(file_path):
self.logger.error(
f"Sorry, but Blackmagic can't flash .bin file, and {file_path} doesn't exist"
)
return False
# arm-none-eabi-gdb build/f7-firmware-D/firmware.bin
# -ex 'set pagination off'
# -ex 'target extended-remote /dev/cu.usbmodem21201'
# -ex 'set confirm off'
# -ex 'monitor swdp_scan'
# -ex 'attach 1'
# -ex 'set mem inaccessible-by-default off'
# -ex 'load'
# -ex 'compare-sections'
# -ex 'quit'
gdb_launch_params = self._get_gdb_core_params()
self._add_command(gdb_launch_params, "attach 1")
self._add_command(gdb_launch_params, "set mem inaccessible-by-default off")
self._add_command(gdb_launch_params, "load")
if do_verify:
self._add_command(gdb_launch_params, "compare-sections")
self._add_command(gdb_launch_params, "quit")
gdb_launch_params.append(file_path)
process = self._spawn_and_await(gdb_launch_params, True)
if not process.stdout:
return False
output = process.stdout.read().decode("utf-8").strip()
flashed = (
"Loading section .text," in output
and "MIS-MATCHED!" not in output
and "target image does not match the loaded file" not in output
)
if not flashed:
self.logger.error("Blackmagic failed to flash")
self.logger.error(output)
return flashed
def probe(self) -> bool:
if not (port := self.port_resolver(self.port)):
return False
self.port = port
gdb_launch_params = self._get_gdb_core_params()
self._add_command(gdb_launch_params, "quit")
process = self._spawn_and_await(gdb_launch_params)
if not process.stdout or process.returncode != 0:
return False
output = process.stdout.read().decode("utf-8").strip()
if "SW-DP scan failed!" in output:
self.logger.warning(
f"Found {self.get_name()} at {self.port}, but failed to attach. Is device connected and is System>Debug enabled?"
)
return False
return True
def get_name(self) -> str:
return self.name
####################
local_flash_interfaces: list[Programmer] = [
OpenOCDProgrammer(
OpenOCDInterface(
"cmsis-dap",
"interface/cmsis-dap.cfg",
"cmsis_dap_serial",
["transport select swd"],
),
),
OpenOCDProgrammer(
OpenOCDInterface(
"stlink",
"interface/stlink.cfg",
"hla_serial",
["transport select hla_swd"],
),
),
BlackmagicProgrammer(blackmagic_find_serial, "blackmagic_usb"),
]
network_flash_interfaces: list[Programmer] = [
BlackmagicProgrammer(blackmagic_find_networked, "blackmagic_wifi")
]
all_flash_interfaces = [*local_flash_interfaces, *network_flash_interfaces]
####################
class Main(App):
AUTO_INTERFACE = "auto"
def init(self):
Programmer.root_logger = self.logger
self.parser.add_argument(
"filename",
type=str,
help="File to flash",
)
self.parser.add_argument(
"--verify",
"-v",
action="store_true",
help="Verify flash after programming",
default=False,
)
self.parser.add_argument(
"--interface",
choices=(
self.AUTO_INTERFACE,
*[i.get_name() for i in all_flash_interfaces],
),
type=str,
default=self.AUTO_INTERFACE,
help="Interface to use",
)
self.parser.add_argument(
"--serial",
type=str,
default=self.AUTO_INTERFACE,
help="Serial number or port of the programmer",
)
self.parser.set_defaults(func=self.flash)
def _search_interface(self, interface_list: list[Programmer]) -> list[Programmer]:
found_programmers = []
for p in interface_list:
name = p.get_name()
if (serial := self.args.serial) != self.AUTO_INTERFACE:
p.set_serial(serial)
self.logger.debug(f"Trying {name} with {serial}")
else:
self.logger.debug(f"Trying {name}")
if p.probe():
self.logger.debug(f"Found {name}")
found_programmers.append(p)
else:
self.logger.debug(f"Failed to probe {name}")
return found_programmers
def flash(self):
start_time = time.time()
file_path = os.path.abspath(self.args.filename)
if not os.path.exists(file_path):
self.logger.error(f"Binary file not found: {file_path}")
return 1
if self.args.interface != self.AUTO_INTERFACE:
available_interfaces = list(
filter(
lambda p: p.get_name() == self.args.interface,
all_flash_interfaces,
)
)
else:
self.logger.info("Probing for local interfaces...")
available_interfaces = self._search_interface(local_flash_interfaces)
if not available_interfaces:
# Probe network blackmagic
self.logger.info("Probing for network interfaces...")
available_interfaces = self._search_interface(network_flash_interfaces)
if not available_interfaces:
self.logger.error("No available interfaces")
return 1
elif len(available_interfaces) > 1:
self.logger.error("Multiple interfaces found:")
self.logger.error(
f"Please specify '--interface={[i.get_name() for i in available_interfaces]}'"
)
return 1
interface = available_interfaces.pop(0)
if self.args.serial != self.AUTO_INTERFACE:
interface.set_serial(self.args.serial)
self.logger.info(f"Using {interface.get_name()} with {self.args.serial}")
else:
self.logger.info(f"Using {interface.get_name()}")
self.logger.info(f"Flashing {file_path}")
if not interface.flash(file_path, self.args.verify):
self.logger.error(f"Failed to flash via {interface.get_name()}")
return 1
flash_time = time.time() - start_time
self.logger.info(f"Flashed successfully in {flash_time:.2f}s")
if file_path.endswith(".bin"):
bin_size = os.path.getsize(file_path)
self.logger.info(
f"Effective speed: {bin_size / flash_time / 1024:.2f} KiB/s"
)
return 0
if __name__ == "__main__":
Main()()