#!/usr/bin/env python3

# Copyright (C) 2019 Otto-von-Guericke-Universität Magdeburg
#
# This file is subject to the terms and conditions of the GNU Lesser
# General Public License v2.1. See the file LICENSE in the top level
# directory for more details.
#
# @author   Maximilian Deubel <maximilian.deubel@ovgu.de>

# Black Magic Probe helper script
# This script can detect connected Black Magic Probes and can be used as a flashloader and much more

import argparse
import os
import re
import sys

import humanize
import serial.tools.list_ports
from progressbar import Bar, Percentage, ProgressBar
from pygdbmi.gdbcontroller import GdbController
import distutils.spawn

parser = argparse.ArgumentParser(description='Black Magic Tool helper script.')
parser.add_argument('--jtag', action='store_true', help='use JTAG transport')
parser.add_argument('--swd', action='store_true', help='use SWD transport (default)')
parser.add_argument('--connect-srst', action='store_true', help='reset target while connecting')
parser.add_argument('--tpwr', action='store_true', help='enable target power')
parser.add_argument('--serial', help='choose specific probe by serial number')
parser.add_argument('--port', help='choose specific probe by port')
parser.add_argument('--attach', help='choose specific target by number', default='1')
parser.add_argument('--gdb-path', help='path to GDB', default='gdb-multiarch')
parser.add_argument('--term-cmd', help='serial terminal command',
                    default='picocom --nolock --imap lfcrlf --baud 115200 %s')
parser.add_argument('action', help='choose a task to perform', nargs='?',
                    choices=['list', 'flash', 'erase', 'debug', 'term', 'reset'],
                    default='list')
parser.add_argument('file', help='file to load to target (hex or elf)', nargs='?')

TIMEOUT = 100  # seconds


# find a suitable gdb executable, falling back to defaults if needed
def find_suitable_gdb(gdb_path):
    if distutils.spawn.find_executable(gdb_path):
        return gdb_path
    else:
        for p in ['arm-none-eabi-gdb', 'gdb-multiarch']:
            p = distutils.spawn.find_executable(p)
            if p:
                print("GDB EXECUTABLE NOT FOUND! FALLING BACK TO %s" % p, file=sys.stderr)
                return p
    print("CANNOT LOCATE SUITABLE GDB EXECUTABLE!", file=sys.stderr)
    sys.exit(-1)


# find all connected BMPs and store both GDB and UART interfaces
def detect_probes():
    gdb_ports = []
    uart_ports = []
    for p in serial.tools.list_ports.comports():
        if p.vid == 0x1D50 and p.pid in {0x6018, 0x6017}:
            if re.fullmatch(r'COM\d\d', p.device):
                p.device = '//./' + p.device
            if 'GDB' in str(p.interface) \
                    or re.fullmatch(r'/dev/cu\.usbmodem([A-F0-9]*)1', p.device) \
                    or p.location[-1] == '0' and os.name == 'nt':
                gdb_ports.append(p)
            else:
                uart_ports.append(p)
    return gdb_ports, uart_ports


# search device with specific serial number <snr> in a list of ports <ports>
def search_serial(snr, ports):
    for port in ports:
        if snr in port.serial_number:
            return port.device


# parse GDB output for targets
def detect_targets(gdbmi, res):
    targets = []
    while True:
        for msg in res:
            if msg['type'] == 'target':
                m = re.fullmatch(pattern=r"\s*(\d)+\s*(.*)\\n", string=msg['payload'])
                if m:
                    targets.append(m.group(2))
            elif msg['type'] == 'result':
                assert msg['message'] == 'done', str(msg)
                return targets

        res = gdbmi.get_gdb_response(timeout_sec=TIMEOUT)


def gdb_write_and_wait_for_result(gdbmi, cmd, description, expected_result='done'):
    res = gdbmi.write(cmd, timeout_sec=TIMEOUT)
    while True:
        for msg in res:
            if msg['type'] == 'result':
                if msg['message'] == expected_result:
                    print(description, "successful.")
                    return True
                else:
                    print(description, "failed.", file=sys.stderr)
                    return False
        res = gdbmi.get_gdb_response(timeout_sec=TIMEOUT)


def parse_download_msg(msg):
    m = re.fullmatch(
        pattern=r"\+download,"
                r"\{(?:section=\"(.*?)\")?,?(?:section-sent=\"(.*?)\")?,?"
                r"(?:section-size=\"(.*?)\")?,?(?:total-sent=\"(.*?)\")?,?"
                r"(?:total-size=\"(.*?)\")?,?\}",
        string=msg['payload'])
    if m:
        section_name = m.group(1)
        section_sent = int(m.group(2)) if m.group(2) else None
        section_size = int(m.group(3)) if m.group(3) else None
        total_sent = int(m.group(4)) if m.group(4) else None
        total_size = int(m.group(5)) if m.group(5) else None
        return section_name, section_sent, section_size, total_sent, total_size


def download_to_flash(gdbmi):
    res = gdbmi.write('-target-download', timeout_sec=TIMEOUT)
    first = True  # whether this is the first status message
    current_sec = None  # name of current section
    pbar = ProgressBar()
    while True:
        for msg in res:
            if msg['type'] == 'result':
                assert msg['message'] == 'done', "download failed: %s" % str(msg)
                if pbar.start_time:
                    pbar.finish()
                print("downloading finished")
                return
            elif msg['type'] == 'output':
                section_name, section_sent, section_size, total_sent, total_size = parse_download_msg(msg)
                if section_name:
                    if first:
                        first = False
                        print("downloading... total size: %s"
                              % humanize.naturalsize(total_size, gnu=True))
                    if section_name != current_sec:
                        if pbar.start_time:
                            pbar.finish()
                        current_sec = section_name
                        print("downloading section [%s] (%s)" % (
                            section_name, humanize.naturalsize(section_size, gnu=True)))
                        pbar = ProgressBar(widgets=[Percentage(), Bar()], maxval=section_size).start()
                    if section_sent:
                        pbar.update(section_sent)
        res = gdbmi.get_gdb_response(timeout_sec=TIMEOUT)


def check_flash(gdbmi):
    res = gdbmi.write('compare-sections', timeout_sec=TIMEOUT)
    while True:
        for msg in res:
            if msg['type'] == 'result':
                assert msg['message'] == 'done', "checking failed: %s" % str(msg)
                print("checking successful")
                return
            elif msg['type'] == 'console':
                assert 'matched' in msg['payload'] and 'MIS-MATCHED' not in msg['payload'], \
                    "checking failed: %s" % str(msg)
        res = gdbmi.get_gdb_response(timeout_sec=TIMEOUT)


def choose_bmp_port(gdb_ports):
    print("found following Black Magic GDB servers:")
    for i, s in enumerate(gdb_ports):
        print("\t[%s]" % s.device, end=' ')
        if len(s.serial_number) > 1:
            print("Serial:", s.serial_number, end=' ')
        if i == 0:
            print("<- default", end=' ')
        print('')
    port = gdb_ports[0].device
    if args.port:
        port = args.port
    elif args.serial:
        port = search_serial(args.serial, gdb_ports)
        assert port, "no BMP with this serial found"
    print('connecting to [%s]...' % port)
    return port


# terminal mode, opens TTY program
def term_mode(uart_ports):
    port = uart_ports[0].device
    if args.port:
        port = args.port
    elif args.serial:
        port = search_serial(args.serial, uart_ports)
        assert port, "no BMP with this serial found"
    os.system(args.term_cmd % port)
    sys.exit(0)


# debug mode, opens GDB shell with options
def debug_mode(port):
    gdb_args = ['-ex \'target extended-remote %s\'' % port]
    if args.tpwr:
        gdb_args.append('-ex \'monitor tpwr enable\'')
    if args.connect_srst:
        gdb_args.append('-ex \'monitor connect_srst enable\'')
    if args.jtag:
        gdb_args.append('-ex \'monitor jtag_scan\'')
    else:
        gdb_args.append('-ex \'monitor swdp_scan\'')
    gdb_args.append('-ex \'attach %s\'' % args.attach)
    os.system(" ".join(['\"' + args.gdb_path + '\"'] + gdb_args + [args.file]))


def connect_to_target(port):
    # open GDB in machine interface mode
    try:
        # try old API first
        gdbmi = GdbController(gdb_path=args.gdb_path, gdb_args=["--nx", "--quiet", "--interpreter=mi2", args.file])
    except TypeError:
        # and then new API
        gdbmi = GdbController(command=[args.gdb_path, "--nx", "--quiet", "--interpreter=mi2", args.file])
    assert gdb_write_and_wait_for_result(gdbmi, '-target-select extended-remote %s' % port, 'connecting',
                                         expected_result='connected')
    # set options
    if args.connect_srst:
        gdbmi.write('monitor connect_srst enable', timeout_sec=TIMEOUT)
    if args.tpwr:
        gdbmi.write('monitor tpwr enable', timeout_sec=TIMEOUT)
    # scan for targets
    if not args.jtag:
        print("scanning using SWD...")
        res = gdbmi.write('monitor swdp_scan', timeout_sec=TIMEOUT)
    else:
        print("scanning using JTAG...")
        res = gdbmi.write('monitor jtag_scan', timeout_sec=TIMEOUT)
    targets = detect_targets(gdbmi, res)
    assert len(targets) > 0, "no targets found"
    print("found following targets:")
    for t in targets:
        print("\t%s" % t)
    print("")
    return gdbmi


if __name__ == '__main__':
    args = parser.parse_args()
    assert not (args.swd and args.jtag), "you may only choose one protocol"
    assert not (args.serial and args.port), "you may only specify the probe by port or by serial"
    g, u = detect_probes()
    assert len(g) > 0, "no Black Magic Probes found 😔"

    if args.action == 'term':
        term_mode(u)
    else:
        port = choose_bmp_port(g)

        args.file = args.file if args.file else ''
        args.gdb_path = find_suitable_gdb(args.gdb_path)

        if args.action == 'debug':
            debug_mode(port)
            sys.exit(0)

        gdbmi = connect_to_target(port)

        if args.action == 'list':
            sys.exit(0)

        assert gdb_write_and_wait_for_result(gdbmi, '-target-attach %s' % args.attach, 'attaching to target')

        # reset mode: reset device using reset pin
        if args.action == 'reset':
            assert gdb_write_and_wait_for_result(gdbmi, 'monitor hard_srst', 'resetting target')
            sys.exit(0)
        # erase mode
        elif args.action == 'erase':
            print('erasing...')
            assert gdb_write_and_wait_for_result(gdbmi, '-target-flash-erase', 'erasing target')
            sys.exit(0)
        # flashloader mode: flash, check and restart
        elif args.action == 'flash':
            download_to_flash(gdbmi)
            check_flash(gdbmi)

            # kill and reset
            assert gdb_write_and_wait_for_result(gdbmi, 'kill', 'killing')
