Skip to content

Automate Deployments

You can download the following files from github.com/chriscummings/remote-arduino-deployment

On GitHub: example.env

.env
RPI_IP='192.168.0.42'
RPI_PORT=22
RPI_USER='YOUR_USER'
RPI_PASS='YOUR_PASS'

On GitHub: constants.py

constants.py
import os

# Shared constants
# ==============================================================================
# These constants are used by both deploy.py and flash.py

# User directory on RPi
RPI_USER_DIR: str = '/home/YOUR_USER'

# Project directory on RPi
PROJECT_DIR: str = '/home/YOUR_USER/demeter'

# Path to arduino-cli on RPi
RPI_ARDUINO_CLI: str = os.path.join(RPI_USER_DIR, 'bin/arduino-cli')

# Deploy script constants
# ==============================================================================
# These constants are only used by deploy.py

# RPi directory for source files
SKETCH_DESTINATION_DIRECTORY: str = PROJECT_DIR

# Arduino libaries to download to RPi
SKETCH_LIBS: list[str] = [
    'WatchDog',
    # etc..
]

# Sketch source files.
# Feel free to change this to a glob command or something similar so long as it
# return a list or strings. However, be sure to include constants.py or migrate
# the constants over some how.
SKETCH_FILES: list[str] = [
    'contants.py' # Copy this constants file to RPi for ease of updates
    'my_sketch.ino',
    'my_sketch.h',
    # etc...
]

# Path flash.py on RPi
RPI_FLASH_SCRIPT: str = os.path.join(RPI_USER_DIR, 'flash.py')

# Local (deploy) SSH log file
LOCAL_SSH_LOG: str = 'ssh.log'

# RPi-side-only flash script constants
# ==============================================================================
# These constants are only used byt flash.py

# Arduino-specific params for arduino-cli.
# See: https://avrdude.readthedocs.io/en/latest/2-Command_Line_Options.html
ARDUINO_CORE = 'arduino:avr:uno'
AVRDUDE_PROGRAMMER_ID = 'arduino'
AVRDUDE_PART_NO = 'm328p'

# Default output for arduino-cli compiled .hex files on RPi
COMPILED_SKETCH_PATH = '/tmp/arduino/sketches/'

# Path to avrdude on RPi
RPI_AVRDUDE = os.path.join(
    RPI_USER_DIR,
    '.arduino15/packages/arduino/tools/avrdude/6.3.0-arduino17/bin/avrdude')

# Path to avrdude.conf on RPi
AVRDUDE_CONF = os.path.join(RPI_USER_DIR, 'avrdude.conf')

# RPi GPIO pin used to pull Arduino's reset pin
GPIO_RESET_PIN = 4

# Duration to 'hold' Arduino's reset
ARDUINO_RESET_INTERVAL = 1

Getting Code on to the Pi

On GitHub: deploy.py

deploy.py
import os
from dotenv import load_dotenv
import paramiko
import constants


conn: paramiko.client.SSHClient

def setup_connection() -> paramiko.client.SSHClient:
    '''Configures SSH connection'''
    paramiko.util.log_to_file(constants.LOCAL_SSH_LOG)
    ssh: paramiko.client.SSHClient = paramiko.client.SSHClient()
    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    ssh.connect(
        os.getenv('RPI_IP'),
        port=int(os.getenv('RPI_PORT', '')),
        username=os.getenv('RPI_USER'),
        password=os.getenv('RPI_PASS'))
    return ssh

def run_rpi_cmd(cmd: str) -> str:
    '''Executes cmd on RPi'''
    stdin, stdout, stderr = conn.exec_command(cmd)
    stdout.channel.set_combine_stderr(True)
    output = '\n'.join(stdout.readlines()) # Waits for command to finish
    return output

def teardown_connection() -> None:
    '''Cleans up open SSH connections'''
    if conn:
        conn.close()

def transfer_src() -> None:
    '''Copies source code to RPi'''
    # Setup SFTP conn
    transport: paramiko.transport.Transport = paramiko.Transport(
        os.getenv('RPI_IP'),
        int(os.getenv('RPI_PORT', '')))
    transport.connect(None,
        os.getenv('RPI_USER'),
        os.getenv('RPI_PASS'))
    sftp: paramiko.sftp_client.SFTPClient = paramiko.SFTPClient.from_transport(transport)
    try:
        # Ensure directory
        run_rpi_cmd(f'mkdir {constants.SKETCH_DESTINATION_DIRECTORY}')
        # Copy files
        for sketch_file in constants.SKETCH_FILES:
            sftp.put(
                os.path.abspath(sketch_file),
                os.path.join(constants.SKETCH_DESTINATION_DIRECTORY, sketch_file))
    except Exception as err:
        print(err) # TODO: log
    finally:
        if sftp: sftp.close()
        if transport: transport.close()

def fetch_sketch_libs() -> None:
    '''Downloads sketch libraries to RPi'''
    output = run_rpi_cmd(f'{constants.RPI_ARDUINO_CLI} lib list')
    for lib in constants.SKETCH_LIBS:
            print(f'Checking for required sketch library {lib}')
            if lib not in output:
                print(f'Installing {lib}')
                output = run_rpi_cmd(f'{constants.RPI_ARDUINO_CLI} lib install "{lib}"')
                print(output)

def compile_and_flash() -> None:
    '''Compiles sketch on RPi and flashes Arduino'''
    output = run_rpi_cmd(f'python3 {constants.RPI_FLASH_SCRIPT} {constants.SKETCH_DESTINATION_DIRECTORY}')
    print(output)

def deploy() -> None:
    conn = setup_connection()
    try:
        transfer_src()
        fetch_sketch_libs()
        compile_and_flash()
    except Exception as err:
        print(err)
    finally:
        teardown_connection()

if __name__ == '__main__':
    load_dotenv()
    deploy()

Compiling and Flashing Firmware

On GitHub: flash.py

flash.py
import os
import sys
import time
import subprocess
from glob import glob
import RPi.GPIO as GPIO
import contants


def configure_gpio_reset_pin() -> None:
    '''Sets up RPi GPIO pin for Arduino reset'''
    GPIO.setwarnings(False)
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(contants.GPIO_RESET_PIN, GPIO.OUT)

def delete_sketches() -> None:
    '''Delete old compiled sketches'''
    subprocess.run(['rm', '-rf', os.path.join(contants.COMPILED_SKETCH_PATH, '*')])

def get_ino_path() -> str:
    '''Returns first .ino found. Assumes only one .ino!'''
    # First param is src folder
    target_dir: str = sys.argv[1]
    ino_filepath = glob(os.path.join(target_dir, '*.ino'))[0]
    if not ino_filepath: raise Exception(f'No .ino file found in {target_dir}')
    return ino_filepath

def compile(ino_filepath: str) -> None:
    '''Runs arduino-cli compilation'''
    subprocess.run([
        contants.RPI_ARDUINO_CLI,
        'compile',
        '-b', contants.ARDUINO_CORE,
        ino_filepath])

def get_compiled_sketch_path() -> str:
    '''Returns first compiled .hex found.'''
    # Assumes previous copilations have been deleted!
    path: str = ''
    for filepath in glob(os.path.join(contants.COMPILED_SKETCH_PATH, '*', '*')):
            root, ext = os.path.splitext(filepath)
            if ext == '.hex' and 'bootloader' not in root:
                    path = filepath
                    break
    if path == '': raise Exception(f'No .hex found in {contants.COMPILED_SKETCH_PATH}')
    return path

def reset_arduino() -> None:
    '''Hard resets Arduino'''
    GPIO.output(contants.GPIO_RESET_PIN, GPIO.LOW)
    time.sleep(contants.ARDUINO_RESET_INTERVAL)
    GPIO.output(contants.GPIO_RESET_PIN, GPIO.HIGH)

def flash(hex_file: str) -> None:
    '''Runs AVRDUDE cmd to flash Arduino'''
    subprocess.run([
        contants.RPI_AVRDUDE,
        '-c', contants.AVRDUDE_PROGRAMMER_ID,
        '-p', contants.AVRDUDE_PART_NO,
        '-C', contants.AVRDUDE_CONF,
        '-U', f'flash:w:{hex_file}'])

def run():
    configure_gpio_reset_pin()
    delete_sketches()
    compile(get_ino_path())
    flash(get_compiled_sketch_path())
    reset_arduino()

if __name__ == "__main__":
    run()