├── firmware.bin ├── run.bat ├── discover.py ├── README.md ├── LICENSE └── ota.py /firmware.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbiego/BLE_OTA_Python/HEAD/firmware.bin -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | python ota.py "40:F5:20:4A:45:B7" "firmware.bin" 3 | pause -------------------------------------------------------------------------------- /discover.py: -------------------------------------------------------------------------------- 1 | 2 | import asyncio 3 | from bleak import BleakScanner 4 | 5 | async def run(): 6 | devices = await BleakScanner.discover() 7 | for d in devices: 8 | print(d) 9 | 10 | loop = asyncio.get_event_loop() 11 | loop.run_until_complete(run()) 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BLE_OTA_Python 2 | A script for performing OTA update over BLE on ESP32 3 | 4 | ## Requirements 5 | - [`Bleak`](https://github.com/hbldh/bleak) 6 | `$ pip install bleak` 7 | 8 | 9 | ## Usage 10 | `python ota.py "01:23:45:67:89:ab" "firmware.bin"` 11 | 12 | you can create a batch file on windows 13 | 14 | ``` 15 | @echo off 16 | python ota.py "40:F5:20:4A:45:B7" "firmware.bin" 17 | pause 18 | ``` 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Felix Biego 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ota.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | MIT License 4 | 5 | Copyright (c) 2021 Felix Biego 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | """ 25 | 26 | from __future__ import print_function 27 | import os.path 28 | from os import path 29 | import asyncio 30 | import platform 31 | import math 32 | import sys 33 | import re 34 | 35 | from bleak import BleakClient, BleakScanner 36 | from bleak.exc import BleakError 37 | 38 | header = """##################################################################### 39 | ------------------------BLE OTA update--------------------- 40 | Arduino code @ https://github.com/fbiego/ESP32_BLE_OTA_Arduino 41 | #####################################################################""" 42 | 43 | UART_SERVICE_UUID = "fb1e4001-54ae-4a28-9f74-dfccb248601d" 44 | UART_RX_CHAR_UUID = "fb1e4002-54ae-4a28-9f74-dfccb248601d" 45 | UART_TX_CHAR_UUID = "fb1e4003-54ae-4a28-9f74-dfccb248601d" 46 | 47 | PART = 16000 48 | MTU = 500 49 | 50 | end = True 51 | clt = None 52 | fileBytes = None 53 | total = 0 54 | 55 | def get_bytes_from_file(filename): 56 | print("Reading from: ", filename) 57 | return open(filename, "rb").read() 58 | 59 | async def start_ota(ble_address: str, file_name: str): 60 | device = await BleakScanner.find_device_by_address(ble_address, timeout=20.0) 61 | disconnected_event = asyncio.Event() 62 | 63 | def handle_disconnect(_: BleakClient): 64 | global disconnect 65 | disconnect = False 66 | print(": Device disconnected") 67 | disconnected_event.set() 68 | 69 | async def handle_rx(_: int, data: bytearray): 70 | if (data[0] == 0xAA): 71 | print("Transfer mode:", data[1]) 72 | printProgressBar(0, total, prefix = 'Progress:', suffix = 'Complete', length = 50) 73 | if data[1] == 1: 74 | for x in range(0, fileParts): 75 | await send_part(x, fileBytes, clt) 76 | printProgressBar(x + 1, total, prefix = 'Progress:', suffix = 'Complete', length = 50) 77 | else: 78 | await send_part(0, fileBytes, clt) 79 | 80 | if (data[0] == 0xF1): 81 | nxt = int.from_bytes(bytearray([data[1], data[2]]), "big") 82 | await send_part(nxt, fileBytes, clt) 83 | printProgressBar(nxt + 1, total, prefix = 'Progress:', suffix = 'Complete', length = 50) 84 | if (data[0] == 0xF2): 85 | ins = 'Installing firmware' 86 | #print("Installing firmware") 87 | if (data[0] == 0x0F): 88 | result = bytearray([]) 89 | for s in range(1, len(data)): 90 | result.append(data[s]) 91 | print("OTA result: ", str(result, 'utf-8')) 92 | global end 93 | end = False 94 | #print("received:", data) 95 | 96 | def printProgressBar (iteration, total, prefix = '', suffix = '', decimals = 1, length = 100, fill = '█', printEnd = "\r"): 97 | """ 98 | Call in a loop to create terminal progress bar 99 | @params: 100 | iteration - Required : current iteration (Int) 101 | total - Required : total iterations (Int) 102 | prefix - Optional : prefix string (Str) 103 | suffix - Optional : suffix string (Str) 104 | decimals - Optional : positive number of decimals in percent complete (Int) 105 | length - Optional : character length of bar (Int) 106 | fill - Optional : bar fill character (Str) 107 | printEnd - Optional : end character (e.g. "\r", "\r\n") (Str) 108 | """ 109 | percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) 110 | filledLength = int(length * iteration // total) 111 | bar = fill * filledLength + '-' * (length - filledLength) 112 | print(f'\r{prefix} |{bar}| {percent}% {suffix}', end = printEnd) 113 | # Print New Line on Complete 114 | if iteration == total: 115 | print() 116 | 117 | async def send_part(position: int, data: bytearray, client: BleakClient): 118 | start = (position * PART) 119 | end = (position + 1) * PART 120 | if len(data) < end: 121 | end = len(data) 122 | parts = (end - start) / MTU 123 | for i in range(0, int(parts)): 124 | toSend = bytearray([0xFB, i]) 125 | for y in range(0, MTU): 126 | toSend.append(data[(position*PART)+(MTU * i) + y]) 127 | await send_data(client, toSend, False) 128 | if (end - start)%MTU != 0: 129 | rem = (end - start)%MTU 130 | toSend = bytearray([0xFB, int(parts)]) 131 | for y in range(0, rem): 132 | toSend.append(data[(position*PART)+(MTU * int(parts)) + y]) 133 | await send_data(client, toSend, False) 134 | update = bytearray([0xFC, int((end - start)/256), int((end - start) % 256), int(position/256), int(position % 256) ]) 135 | await send_data(client, update, True) 136 | 137 | async def send_data(client: BleakClient, data: bytearray, response: bool): 138 | await client.write_gatt_char(UART_RX_CHAR_UUID, data, response) 139 | 140 | if not device: 141 | print("-----------Failed--------------") 142 | print(f"Device with address {ble_address} could not be found.") 143 | return 144 | #raise BleakError(f"A device with address {ble_address} could not be found.") 145 | async with BleakClient(device, disconnected_callback=handle_disconnect) as client: 146 | await client.start_notify(UART_TX_CHAR_UUID, handle_rx) 147 | await asyncio.sleep(1.0) 148 | 149 | await send_data(client, bytearray([0xFD]), False) 150 | 151 | global fileBytes 152 | fileBytes = get_bytes_from_file(file_name) 153 | global clt 154 | clt = client 155 | fileParts = math.ceil(len(fileBytes) / PART) 156 | fileLen = len(fileBytes) 157 | fileSize = bytearray([0xFE, fileLen >> 24 & 0xFF, fileLen >> 16 & 0xFF, fileLen >> 8 & 0xFF, fileLen & 0xFF]) 158 | await send_data(client, fileSize, False) 159 | global total 160 | total = fileParts 161 | otaInfo = bytearray([0xFF, int(fileParts/256), int(fileParts%256), int(MTU / 256), int(MTU%256) ]) 162 | await send_data(client, otaInfo, False) 163 | 164 | while end: 165 | await asyncio.sleep(1.0) 166 | print("Waiting for disconnect... ", end="") 167 | await disconnected_event.wait() 168 | print("-----------Complete--------------") 169 | 170 | """ 171 | ble_address = ( 172 | "24:6F:28:AE:F6:B6" 173 | if platform.system() != "Darwin" 174 | else "B9EA5233-37EF-4DD6-87A8-2A875E821C46" 175 | ) 176 | """ 177 | 178 | def isValidAddress(str): 179 | 180 | # Regex to check valid 181 | # MAC address 182 | regex = ("^([0-9A-Fa-f]{2}[:-])" + 183 | "{5}([0-9A-Fa-f]{2})|" + 184 | "([0-9a-fA-F]{4}\\." + 185 | "[0-9a-fA-F]{4}\\." + 186 | "[0-9a-fA-F]{4}){17}$") 187 | regex2 = "^[{]?[0-9a-fA-F]{8}" + "-([0-9a-fA-F]{4}-)" + "{3}[0-9a-fA-F]{12}[}]?$" 188 | 189 | # Compile the ReGex 190 | p = re.compile(regex) 191 | q = re.compile(regex2) 192 | 193 | # If the string is empty 194 | # return false 195 | if (str == None): 196 | return False 197 | 198 | # Return if the string 199 | # matched the ReGex 200 | if(re.search(p, str) and len(str) == 17): 201 | return True 202 | else: 203 | if (re.search(q, str) and len(str) == 36): 204 | return True 205 | else: 206 | return False 207 | 208 | 209 | if __name__ == "__main__": 210 | print(header) 211 | if (len(sys.argv) > 2): 212 | print("Trying to start OTA update") 213 | if isValidAddress(sys.argv[1]) and path.exists(sys.argv[2]): 214 | asyncio.run(start_ota(sys.argv[1], sys.argv[2])) 215 | else: 216 | if not isValidAddress(sys.argv[1]): 217 | print("Invalid Address: ", sys.argv[1]) 218 | if not path.exists(sys.argv[2]): 219 | print("File not found: ", sys.argv[2]) 220 | else: 221 | print("Specify the device address and firmware file") 222 | print(">python ota.py \"01:23:45:67:89:ab\" \"firmware.bin\"") 223 | --------------------------------------------------------------------------------