Arduino OTA Firmware Update Using Flutter/Dart or C#

Arduino OTA (Over The Air) is commonly used to update the firmware of Arduino based home automation or IoT devices. Arduino provides a Python script in the file espota.py to upload firmware from the command line. Multi-platform applications that are built using Flutter or C# can include the option to update the device’s firmware using the class described below.

The full class can be found on Github.

Flutter/Dart Version:

//
// FirmwareUploader is a Flutter/Dart class that can be used to upload firmware from a mobile
// application to any device running Arduino OTA (Over The Air)
// Sample Usage:
// ByteData data = await DefaultAssetBundle.of(context).load('assets/firmware.bin');
// (firmware.bin should be added to the assets section of pubspec.yaml)
// var uploader = FirmwareUploader();
// await uploader.upload(OTAAddress, OTAPort, OTAPassword, data);
//
// This code is provided AS IS by Upwind Tecnologias Lda. and can be freely
// used for personal or commercial purposes                                   //                                                                       
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'dart:convert';

// internal enum to track upload _status
enum FirmwareUploaderStatus {
  connecting,
  authorizing,
  updating,
  waitingForResponse,
  finished,
}

class FirmwareUploader {
  String log = "";
  FirmwareUploaderStatus _status = FirmwareUploaderStatus.connecting;

  // Send/Receive timeout in seconds
  int timeout = 5;

//
// Upload new firmware to device, returns true is successful
// In case of failure, check the log member variable for errors
//
  Future<bool> upload(String deviceAddress, int devicePort, String password,
      ByteData firmware_) async {
    bool success = false;
    _status = FirmwareUploaderStatus.connecting;

    final Uint8List firmware = firmware_.buffer.asUint8List();

    // start a UDP socket to initiate communication with the device
    RawDatagramSocket.bind(InternetAddress.anyIPv4, 0)
        .then((RawDatagramSocket udpSocket) async {
      // UDP messages received from device are captured in the listen method
      udpSocket.listen((e) async {
        try {
          Datagram? datagram = udpSocket.receive();
          if (datagram != null) {
            String returnData = String.fromCharCodes(datagram.data);
            switch (_status) {
              case FirmwareUploaderStatus.connecting:
                if (returnData.startsWith("AUTH")) {
                  // Firware update is protected with a password

                  // ArduinoOTA requests the password in a very specific format
                  String nonce = returnData.split(' ')[1];
                  String passwordHash = _hashString(password);
                  String cnonce = _hashString(
                      "${firmware.length} $passwordHash $deviceAddress"); // this hash is not used so any random 32 character string will do
                  String result = _hashString("$passwordHash:$nonce:$cnonce");
                  String message = "200 $cnonce $result\n"; // 200 = AUTH

                  // send the hashed password and wait for OK or ERR return from device
                  _status = FirmwareUploaderStatus.authorizing;
                  udpSocket.send(message.codeUnits,
                      InternetAddress(deviceAddress), devicePort);
                } else {
                  // no authentication required, upload firmware
                  _status = FirmwareUploaderStatus.updating;
                  success = await _uploadData(udpSocket.port, firmware);
                  udpSocket.close();
                }
                break;

              case FirmwareUploaderStatus.authorizing:
                if (!returnData.startsWith("OK")) {
                  udpSocket.close();
                  throw (Exception("Authentication Failed"));
                } else {
                  // Autentication succeeded, start the upload
                  _status = FirmwareUploaderStatus.updating;
                  success = await _uploadData(udpSocket.port, firmware);
                  udpSocket.close();
                }
                break;

              default:
                break;
            }
          }
        } on Exception catch (e) {
          log += e.toString();
          success = false;
          _status = FirmwareUploaderStatus.finished;
        }
      });

      try {
        // the first command is U_FLASH (value 0) followed by the local port, the file length and and the file hash
        String hash;

        // get the MD5 hash of the stream
        var hashBytes = md5.convert(firmware).toString();

        hash = hashBytes.toLowerCase();

        String command = "0 ${udpSocket.port} ${firmware.length} $hash\n";
        udpSocket.send(
            command.codeUnits, InternetAddress(deviceAddress), devicePort);
      } on Exception catch (e) {
        log += e.toString();
        _status = FirmwareUploaderStatus.finished;
        udpSocket.close();
      }
    });

    // wait for the upload to finish before returning
    while (_status != FirmwareUploaderStatus.finished) {
      await Future.delayed(const Duration(seconds: 1));
    }

    return success;
  }

//
// Once the device has accepted the connection, we can start actually uploading the firmware
// Note that this method should be called within 1 second of the end of the upload method (i.e. no breakpoints in between)
// This is because ArduinoOTA will try to connect back to us within 1 sec
// The actual uploading of the data uses TCP on the same port as the UDP used to start the communication
//
  Future<bool> _uploadData(int localPort, Uint8List firmware) async {
    bool success = false;

    // start a TCP listener on the same port as the UDP listener and wait for device to connect back to us
    ServerSocket.bind(InternetAddress.anyIPv4, localPort)
        .then((ServerSocket listener) {
      listener.timeout(Duration(seconds: timeout));
      listener.listen((Socket sender) async {
        // the listen method will capture any messages sent back from the device
        try {
          sender.listen(
            (Uint8List data) {
              String response = String.fromCharCodes(data);
              switch (_status) {
                case FirmwareUploaderStatus.updating:
                  // ignore any returned data while updating
                  break;
                case FirmwareUploaderStatus.waitingForResponse:
                  // keep reading until we receive ERR or OK
                  if (!response.contains("ERR") && !response.contains("OK")) {
                    break;
                  }

                  success = response.contains("OK");
                  listener.close();
                  _status = FirmwareUploaderStatus.finished;
                  if (!success) {
                    log += "Firmware update failed with error: $response\n";
                  }
                  break;
                default:
                  break;
              }
            },
            onError: (error) {
              listener.close();
            },
            onDone: () {
              listener.close();
            },
          );

          // send firmware data to device in small blocks
          int blockSize = 1460;
          int length = firmware.length;
          int pos = 0;
          while (length > 0) {
            sender.add(firmware
                .getRange(pos, pos + blockSize)
                .toList(growable: false));
            await sender.flush();
            pos += blockSize;
            length -= blockSize;
            blockSize = min(blockSize, length);
          }
          _status = FirmwareUploaderStatus.waitingForResponse;
        } on Exception catch (e) {
          success = false;
          _status = FirmwareUploaderStatus.finished;
          listener.close();
          log += e.toString();
        }
      });
    });

    // wait for the upload to finish
    while (_status != FirmwareUploaderStatus.finished) {
      await Future.delayed(const Duration(seconds: 1));
    }
    return success;
  }

  String _hashString(String value) {
    String hash = "";
    // Generate hash value for input string
    var hashBytes = md5.convert(utf8.encode(value)).toString();

    // Arduino requires lower case with no separators
    hash = hashBytes.toLowerCase();
    return hash;
  }
}

C# Version:

// FirmwareUploader is a C# class that can be used to upload firmware from a .NET
// application to any device running Arduino OTA (Over The Air)
// Sample Usage:
string fileName = @"firmware.ino.bin";
string password = "[password]";
string IP = "[hostname]";
int Port = portnumber;
	
FileStream fs = new FileStream(fileName, FileMode.Open);

// create an Uploader object
Uploader uploader = new Uploader();
// update the device
uploader.FirmwareUpload(IP, Port, password, fs);
Console.Write(uploader.Log);

class Uploader
{
	public string Log;
	// Send/Receive timeout in seconds
	public int Timeout = 5;

	/// <summary>
	/// Update a device running the Arduino OTA (Over The Air) module with new firmware
	/// </summary>
	/// <param name="deviceAddress">IP address of the device that we are updating</param>
	/// <param name="devicePort">Port number on which ArduinoOTA is set to listen</param>
	/// <param name="password">ArduinoOTA password if requested</param>
	/// <param name="firmware">Stream containing the new firmware</param>
	/// <returns>true if upload was successful</returns>
	/// 
	public bool FirmwareUpload(string deviceAddress, int devicePort, string password, Stream firmware)
	{
		//
		// code adapted from Arduino's espota.py
		//
		bool success = false;
		UdpClient udpClient = new UdpClient();
		IPEndPoint RemoteIpEndPoint = new IPEndPoint(IPAddress.Any, 0);
		Log = "";

		try
		{
			string hash;

			// get the MD5 hash of the file
			using (var md5Hash = MD5.Create())
			{
				// Generate hash value(Byte Array) for input data
				var hashBytes = md5Hash.ComputeHash(firmware);

				// Convert hash byte array to string
				hash = BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower();
			}

			// We need the local port number to send it to the device in the first command
			if (udpClient.Client.LocalEndPoint == null)
			{
				udpClient.Connect(deviceAddress, devicePort);
				udpClient.Client.SendTimeout = Timeout * 1000;
				udpClient.Client.ReceiveTimeout = Timeout * 1000;
			}
			int localPport = ((IPEndPoint)udpClient.Client.LocalEndPoint).Port;

			// the first command is U_FLASH (value 0) followed by the local port, the file length and and the file hash
			String command = String.Format("0 {0} {1} {2}\n", localPport, firmware.Length, hash);

			Byte[] sendBytes = Encoding.ASCII.GetBytes(command);
			udpClient.Send(sendBytes, sendBytes.Length);

			// Blocks until a message is returned from the device
			Byte[] receiveBytes = udpClient.Receive(ref RemoteIpEndPoint);

			string returnData = Encoding.ASCII.GetString(receiveBytes);

			if (returnData.StartsWith("AUTH"))
			{
				// device is requesting the password in a very specific format
				string nonce = returnData.Split(' ')[1];
				string cnonce = Hash(String.Format("{0}{1}{2}", firmware.Length, hash, deviceAddress));      // any random 32 character string will do here
				string result = Hash(String.Format("{0}:{1}:{2}", Hash(password), nonce, cnonce));

				string message = String.Format("200 {0} {1}\n", cnonce, result);   // 200 = AUTH

				// send the hashed password and wait for OK or ERR return from device
				sendBytes = Encoding.ASCII.GetBytes(message);
				udpClient.Send(sendBytes, sendBytes.Length);
				receiveBytes = udpClient.Receive(ref RemoteIpEndPoint);
				returnData = Encoding.ASCII.GetString(receiveBytes);

				if (!returnData.StartsWith("OK"))
				{
					Exception e = new Exception("Authentication Failed");
				}
			}

			// send firmware data to device in small blocks
			const int block_size = 1460;

			// start a TCP listener on the same port as the UDP listener and wait for device to connect back to us
			TcpListener listener = new TcpListener(IPAddress.Any, localPport);
			listener.Server.ReceiveTimeout = listener.Server.SendTimeout = Timeout * 1000;
			listener.Start();
			TcpClient tcpClient = listener.AcceptTcpClient();
			tcpClient.SendBufferSize = block_size;

			// rewind file to beginning, the call to ComputeHash moves the file pointer
			firmware.Seek(0, SeekOrigin.Begin);

			byte[] buf = new byte[block_size];
			string ret = "";

			while (true)
			{
				int bytes_read = firmware.Read(buf, 0, block_size);
				if (bytes_read > 0)
				{
					tcpClient.GetStream().Write(buf, 0, bytes_read);
					int len = tcpClient.GetStream().Read(buf, 0, block_size);     // ignore any returned value
					ret = Encoding.ASCII.GetString(buf, 0, len);
				}

				if (bytes_read < block_size)        // end of file
					break;
			}

			// keep reading until we receive ERR or OK
			while (!ret.Contains("ERR") && !ret.Contains("OK"))
			{
				int len = tcpClient.GetStream().Read(buf, 0, block_size);     // ignore any returned value
				ret = Encoding.ASCII.GetString(buf, 0, len);
			}

			success = ret.Contains("OK");

			Log += "========== FLASH ==========\nFrom " + RemoteIpEndPoint.Address.ToString() + ":" + RemoteIpEndPoint.Port.ToString() + "\n";
			Log += ret;

			// close all connections
			tcpClient.Close();
			listener.Stop();
			udpClient.Close();
		}
		catch (Exception e)
		{
			Console.WriteLine(e.ToString());
		}

		return success;
	}

	static string Hash(string value)
	{
		string hash = "";

		using (var md5Hash = MD5.Create())
		{
			// Generate hash value(Byte Array) for input data
			var hashBytes = md5Hash.ComputeHash(Encoding.ASCII.GetBytes(value));

			// Convert hash byte array to string while removing the dashes
			hash = BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower();
		}

		return hash;
	}
}