Posted 21 June 2026
After getting OTA via Bluetooth to the onboard HC-05 going for the 2-wheel robot, I had an epiphany; For vision processing the 2-wheel robot uses an on-board Raspberry Pi5 with a Wi-Fi connection to my local network (and thence to my PC), so why use the HC-05 link at all? The Wi-Fi connection is much more robust than the BT/HC-05 link and is available throughout the house. The pi5 has a serial port, so in theory I could simply write a small python script to pipe characters back and forth between its Wi-Fi port and its serial port, just as the HC-05 does between its BT port and its serial port. How hard could it be?
Grok Code and I have been working on this issue for a while now and have gotten to the point where we can transfer the .HEX file from the pi5 to the Teensy once, but not multiple times. This indicates that the firmware update did not happen correctly. Also, we haven’t yet figured out how to automatically transfer the HEX file resulting from a Visual Studio compile to the pi5 so that it can be passed to the Teensy via the pi5’s serial port so we are bypassing this step by using SCP (or a copy/paste using VS Code) to create a duplicate of the HEX file on the pi5; then all the pi5 script has to do is pass lines from the local HEX file to the Teensy via serial.
Grok Code and I have been trying to troubleshoot this problem, and we don’t seem to be getting anywhere. Grok does not really know how to troubleshoot in an organized manner – it is more of a ‘random walk’ process. This post is intended to document my own troubleshooting efforts.
First, what is the basic problem? The basic problem is that multiple transfers of a HEX firmware file to the Teensy using the established BT/HC-05/Serial2 succeed, but the same process using the Wi-Fi/pi5/Serial1 link appears to succeed the first time but fails on the second attempt. Since the firmware HEX files in the two cases are identical, the problem must be somewhere in the pi5 script, either in the way lines are read from the local HEX file or in the way lines are transferred to the Teensy.
A basic assumption in the above is that the HEX file transferred to the Teensy via BT/HC-05 and the HEX file transferred to the Teensy via Wi-Fi/Pi5 are identical, so I decided to start there. Are they really identical?
- Compiled firmware on VS, copy/pasted (using VS Code) from “C:\Users\Frank\Documents\Arduino\Wifi_OTA_Demo\obj\x64\Debug\Wifi_OTA_Demo.hex” to “/home/pi/my_vision_robot/tests/Wifi_OTA_Demo/Wifi_OTA_Demo.hex”. Then I copy/pasted from the pi5 file to notepad++ and compared with the original – they matched perfectly.
- I modified FxUtil.cpp’s update_firmware() to add the line “out->println(line);” then updated Teensy firmware using USB connector to establish ‘known-good’ baseline. Then used pi5 script to transfer its local copy of the firmware to the Teensy, logging the transfer via VS serial monitor. The file as logged going into the Teensy and the source file on the pi5 also match perfectly. This pretty much eliminates a corrupted file transfer as the source of the problem.
- Then I performed the same procedure except using the BT/HC-05 channel instead of the Wifi/Pi5 channel.
Here’s the Wifi_OTA_Demo.ino file used to run the above tests:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
// Generated on: 2026-06-17 14:30:00 UTC // Wifi_OTA_Demo.ino - UART OTA Test via Pi5 Serial1 #include "FXUtil.h" extern "C" { #include "FlashTxx.h" } #define USING_HC05 //needed for compile-time switching char c; uint32_t buffer_addr, buffer_size; void setup() { Serial.begin(115200); // USB debug #ifdef USING_HC05 Serial2.begin(115200); // UART to HC05 Serial2.println("\n=== Wifi_OTA_Demo - UART OTA Ready ==="); Serial2.println("Send 'U' from TeraTerm on Serial2 to trigger OTA update"); #else Serial1.begin(115200); // UART to Pi5 //Serial1.println("\n=== Wifi_OTA_Demo - UART OTA Ready ==="); //Serial1.println("Send 'U' from Pi5 on Serial1 to trigger OTA update"); #endif pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_BUILTIN, HIGH); } void loop() { #ifdef USING_HC05 if (Serial2.available()) { c = Serial2.read(); Serial2.printf("Received %c on Serial2: '\n", c); if (c == 'U' || c == 'u') { Serial2.println("\nStart Program Update - Send new HEX file!"); digitalWrite(LED_BUILTIN, LOW); // visual feedback delay(500); //uint32_t buffer_addr, buffer_size; if (firmware_buffer_init(&buffer_addr, &buffer_size) == 0) { Serial2.println("Failed to init buffer"); digitalWrite(LED_BUILTIN, HIGH); return; } Serial2.println("Calling update_firmware() on Serial2..."); while (Serial2.available()) Serial2.read(); // clear buffer update_firmware(&Serial2, &Serial2, buffer_addr, buffer_size); } #else if (Serial1.available()) { c = Serial1.read(); Serial1.printf("Received %c on Serial1: '\n", c); Serial1.print(c); } if (c == 'U' || c == 'u') { Serial.println("\nStart Program Update - Send new HEX file!"); digitalWrite(LED_BUILTIN, LOW); // visual feedback delay(500); //uint32_t buffer_addr, buffer_size; if (firmware_buffer_init(&buffer_addr, &buffer_size) == 0) { Serial.println("Failed to init buffer"); digitalWrite(LED_BUILTIN, HIGH); return; } //} while (Serial1.available()) Serial1.read(); // clear buffer Serial.println("Calling update_firmware() on Serial1..."); //update_firmware(&Serial1, &Serial1, buffer_addr, buffer_size); update_firmware(&Serial1, &Serial, buffer_addr, buffer_size); //firmware_buffer_free(buffer_addr, buffer_size); //Serial.println("Firmware update call completed. About to REBOOT..."); //delay(1000); // Give time for the message to be sent #endif //firmware_buffer_free(buffer_addr, buffer_size); #ifdef USING_HC05 Serial2.println("after update_firmware"); firmware_buffer_free(buffer_addr, buffer_size); Serial2.println("Firmware update call completed. About to REBOOT..."); #else Serial.println("after update_firmware"); firmware_buffer_free(buffer_addr, buffer_size); Serial.println("Firmware update call completed. About to REBOOT..."); #endif delay(1000); // Give time for the message to be sent REBOOT; } } |
25 June 2026 Update:
Grok Code and finally managed to get the Teensy OTA update via Pi5/Serial1 working. Here is the final Python script on the Pi5:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
#!/usr/bin/env python3 """ Wifi_OTA_Demo.py - Final version for ongoing vision-enhanced robot project Last updated: 2026-06-24 """ import serial import time import sys import os from datetime import datetime def ota_upload(hex_file_path): if not os.path.exists(hex_file_path): print(f"Error: Hex file not found: {hex_file_path}") return False print(f"\n=== OTA Upload Started at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ===") print(f"File: {hex_file_path}\n") ser = serial.Serial(port='/dev/ttyAMA0', baudrate=115200, timeout=3) try: print("Sending 'U' trigger...") ser.write(b'U') time.sleep(1.0) print("Waiting for Teensy prompt...") start = time.time() while time.time() - start < 10: if ser.in_waiting: line = ser.readline().decode('utf-8', errors='replace').strip() print(f"Teensy: {line}") if "reading hex lines" in line.lower(): print("✓ Got prompt") break time.sleep(0.2) else: print("✗ Timed out waiting for prompt") return False # Send file print("Sending .hex file...") line_count = 0 with open(hex_file_path, 'r') as f: for line_count, line in enumerate(f, 1): ser.write(line.encode('utf-8')) if line_count % 200 == 0: # Fixed: was line_num time.sleep(0.005) print(f"Sent {line_count} lines. Sending EOF...") ser.write(b':00000001FF\r\n') ser.flush() time.sleep(1.0) # Line count confirmation print("Waiting for line count prompt...") start = time.time() while time.time() - start < 15: if ser.in_waiting: line = ser.readline().decode('utf-8', errors='replace').strip() print(f"Teensy: {line}") if "enter" in line.lower() and "flash" in line.lower(): parts = line.split() if len(parts) > 1 and parts[1].isdigit(): num = parts[1] print(f"Sending line count: {num}") ser.write((num + '\r\n').encode('utf-8')) ser.flush() break time.sleep(0.3) print("\n✅ OTA UPDATE SUCCESSFUL!") print(" Flash process started.") print(" Waiting for Teensy reboot and Serial1 re-initialization...\n") time.sleep(12) # Important for reliable reboot print("Upload process finished. You can now check Serial1 output.\n") return True except Exception as e: print(f"Error during OTA: {e}") return False finally: ser.close() if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python3 Wifi_OTA_Demo.py <full_path_to_.hex>") sys.exit(1) ota_upload(sys.argv[1]) |
And here is the Teensy sketch used for the test:
To verify that the OTA update code performed properly, I ran the update three times in a row. The first time replaces whatever sketch was on the Teensy with my test sketch (that also includes the code that supports further updates), and the second and third updates confirm that the first (and the second) updates contained the crucial update support code.
At this point the ‘Wifi_OTA_Demo’ project is pretty much finished. The only step remaining is to fully automate the process.
27 June 2026 Update:
Now I have the update process updated to the point where I can press ‘F5’ in Visual Studio in an OTA-equipped Teensy project and have the resulting .hex file automatically copied over to the pi5. Here are the relevant files:
board.txt:
Updated 06/27/26 Post-Build: Copy .hex to Pi5
|
1 2 |
recipe.hooks.deploy.preupload.1.pattern=python "C:\Users\Frank\Documents\Robot_Projects\Wifi_OTA\PostBuild_OTA.py" "{vm.runtime.build.final_output_path}" "{build.project_name}" "{build.project_path}" "{vm.runtime.build.intermediate_output_path}" recipe.hooks.deploy.preupload.1.use_shell_execute=true |
PostBuild_OTA.py:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
#!/usr/bin/env python3 """ Post-Build OTA Helper for Visual Micro """ import sys import os import subprocess from datetime import datetime # ============== CONFIGURATION ============== VERBOSE = False # Set to True only when debugging # =========================================== def main(): print("\n" + "="*60) print("Post-Build OTA Transfer to Pi5") print("="*60) if VERBOSE: print("\n=== Visual Micro Path Variables ===") print(f"build.project_path = {sys.argv[3] if len(sys.argv) > 3 else 'Not provided'}") print(f"vm.runtime.build.intermediate_output_path = {sys.argv[4] if len(sys.argv) > 4 else 'Not provided'}") print(f"vm.runtime.build.final_output_path = {sys.argv[1] if len(sys.argv) > 1 else 'Not provided'}") print("="*40) if len(sys.argv) < 4: print("ERROR: Not enough arguments provided.") input("Press Enter to close...") sys.exit(1) intermediate_path = sys.argv[4].rstrip('\\') project_name = sys.argv[2] if len(sys.argv) > 2 else "Unknown" #hex_path = f"{intermediate_path}\\Wifi_OTA_Demo.hex" hex_path = f"{intermediate_path}\\Wifi_OTA.hex" if VERBOSE: print("\n=== Derived File Paths ===") print(f"Project Name : {project_name}") print(f"Intermediate Dir : {intermediate_path}") print(f"Hex File Path : {hex_path}") print("="*40) print(f"Time : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if not os.path.exists(hex_path): print(f"ERROR: Hex file not found: {hex_path}") input("Press Enter to close...") sys.exit(1) print("\nCopying .hex to Pi5...") try: subprocess.run(["scp", hex_path, "pi@RobotPi5:~/my_vision_robot/firmware/latest.hex"], capture_output=True, text=True, check=True) print("SUCCESS: .hex file copied to Pi5.") except subprocess.CalledProcessError: print("\nERROR: Failed to copy .hex file to Pi5.") if VERBOSE: print("Check that the Pi5 is reachable and scp is set up.") sys.exit(1) except FileNotFoundError: print("\nERROR: 'scp' command not found. Is OpenSSH installed?") sys.exit(1) print(f"\nPost-build completed at {datetime.now().strftime('%H:%M:%S')}") print("Ready for next build.\n") input("Press Enter to close this window...") if __name__ == "__main__": main() |
The next step is to put these two steps (copy the .hex file to the pi5, and then launch the update code that xfers the .hex file to the Teensy) together into one seamless automated process.
08 August 2026 Update:
I recently got my 4-wheel robot going again and moved the Raspberry Pi5/OAKD-Lite over to it from the 2-wheel robot, and ported my WallE3_Git.ino program to it as well. Due to some changes made to file locations on both my PC and on the Pi5, some changes to the OTA-related files were necessary as well. Here are the updated files:
Board.txt (lives in the same folder as the project & .ino file):
|
1 2 |
recipe.hooks.deploy.preupload.1.pattern=python "C:\Users\Frank\Documents\Robot_Projects\my_vision_robot\software\Wifi_OTA\teensy\Wifi_OTA\PostBuild_OTA.py" "{vm.runtime.build.final_output_path}" "{build.project_name}" "{build.project_path}" "{vm.runtime.build.intermediate_output_path}" recipe.hooks.deploy.preupload.1.use_shell_execute=true |
C:\Users\Frank\Documents\Robot_Projects\my_vision_robot\software\Wifi_OTA\teensy\Wifi_OTA\PostBuild_OTA.py:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
#!/usr/bin/env python3 """ Post-Build OTA Helper for Visual Micro Copies the newly built .hex file to the Pi5 so the wifi-ota-watcher can flash it to the Teensy over serial. Standardized destination (Aug 2026): pi@RobotPi5:~/my_vision_robot/firmware/latest.hex """ import sys import os import subprocess from datetime import datetime # ============== CONFIGURATION ============== VERBOSE = False # Set to True only when debugging PI_USER_HOST = "pi@RobotPi5" PI_DEST = "~/my_vision_robot/firmware/latest.hex" # =========================================== def main(): print("\n" + "=" * 60) print("Post-Build OTA Transfer to Pi5") print("=" * 60) if VERBOSE: print("\n=== Visual Micro Path Variables ===") print(f"build.project_path = {sys.argv[3] if len(sys.argv) > 3 else 'Not provided'}") print(f"vm.runtime.build.intermediate_output_path = {sys.argv[4] if len(sys.argv) > 4 else 'Not provided'}") print(f"vm.runtime.build.final_output_path = {sys.argv[1] if len(sys.argv) > 1 else 'Not provided'}") print("=" * 40) if len(sys.argv) < 4: print("ERROR: Not enough arguments provided.") print("Expected: final_output_path project_name project_path intermediate_output_path") input("Press Enter to close...") sys.exit(1) intermediate_path = sys.argv[4].rstrip('\\') project_name = sys.argv[2] if len(sys.argv) > 2 else "Unknown" hex_path = f"{intermediate_path}\\{project_name}.hex" if VERBOSE: print("\n=== Derived File Paths ===") print(f"Project Name : {project_name}") print(f"Intermediate Dir : {intermediate_path}") print(f"Hex File Path : {hex_path}") print("=" * 40) print(f"Time : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if not os.path.exists(hex_path): print(f"ERROR: Hex file not found: {hex_path}") input("Press Enter to close...") sys.exit(1) print(f"\nSource : {hex_path}") print(f"Dest : {PI_USER_HOST}:{PI_DEST}") print("\nCopying .hex to Pi5...") try: subprocess.run( ["scp", hex_path, f"{PI_USER_HOST}:{PI_DEST}"], capture_output=True, text=True, check=True ) print("SUCCESS: .hex file copied to Pi5.") except subprocess.CalledProcessError as e: print("\nERROR: Failed to copy .hex file to Pi5.") if VERBOSE: print(e.stderr if e.stderr else str(e)) print("Check that the Pi5 is reachable and scp / SSH keys are set up.") sys.exit(1) except FileNotFoundError: print("\nERROR: 'scp' command not found. Is OpenSSH installed?") sys.exit(1) print(f"\nPost-build completed at {datetime.now().strftime('%H:%M:%S')}") print("Ready for next build.\n") input("Press Enter to close this window...") if __name__ == "__main__": main() |
C:\Users\Frank\Documents\Robot_Projects\my_vision_robot\software\Wifi_OTA\pi5\Wifi_OTA.py:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
#!/usr/bin/env python3 """ Wifi_OTA.py - Automatic OTA uploader for Teensy 4.1 Watches for latest.hex and triggers OTA via Serial1 Standardized path (Aug 2026): /home/pi/my_vision_robot/firmware/latest.hex """ import serial import time import os import sys from datetime import datetime import signal # Configuration HEX_FILE_PATH = "/home/pi/my_vision_robot/firmware/latest.hex" POLL_INTERVAL = 1.0 # seconds MIN_HEX_SIZE = 50000 # bytes MAX_AGE_SECONDS = 300 # 5 minutes SERIAL_PORT = "/dev/ttyAMA0" BAUD = 115200 def log(msg): timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"[{timestamp}] {msg}") def is_fresh_hex_file(path): if not os.path.exists(path): return False try: size = os.path.getsize(path) if size < MIN_HEX_SIZE: log(f"File too small ({size} bytes)") return False mtime = os.path.getmtime(path) age = time.time() - mtime if age > MAX_AGE_SECONDS: return False return True except Exception as e: log(f"Error checking file: {e}") return False def ota_upload(hex_file_path): if not os.path.exists(hex_file_path): log(f"Error: Hex file not found: {hex_file_path}") return False log(f"\n=== OTA Upload Started at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ===") log(f"File: {hex_file_path}\n") try: ser = serial.Serial(port=SERIAL_PORT, baudrate=BAUD, timeout=3) except serial.SerialException as e: log(f"Cannot open {SERIAL_PORT}: {e}") log("Is clearest_direction_node (or another process) holding the port?") log("Stop the vision node first, then the watcher will retry on the next cycle.") return False try: log("Sending 'U' trigger...") ser.write(b'U') time.sleep(1.0) log("Waiting for Teensy prompt...") start = time.time() while time.time() - start < 10: if ser.in_waiting: line = ser.readline().decode('utf-8', errors='replace').strip() log(f"Teensy: {line}") if "reading hex lines" in line.lower(): log("✓ Got prompt") break time.sleep(0.2) else: log("✗ Timed out waiting for prompt") return False # Send file log("Sending .hex file...") line_count = 0 with open(hex_file_path, 'r') as f: for line_count, line in enumerate(f, 1): ser.write(line.encode('utf-8')) if line_count % 200 == 0: time.sleep(0.005) log(f"Sent {line_count} lines. Sending EOF...") ser.write(b':00000001FF\r\n') ser.flush() time.sleep(1.0) # Line count confirmation log("Waiting for line count prompt...") start = time.time() while time.time() - start < 15: if ser.in_waiting: line = ser.readline().decode('utf-8', errors='replace').strip() log(f"Teensy: {line}") if "enter" in line.lower() and "flash" in line.lower(): parts = line.split() if len(parts) > 1 and parts[1].isdigit(): num = parts[1] log(f"Sending line count: {num}") ser.write((num + '\r\n').encode('utf-8')) ser.flush() break time.sleep(0.3) log("\n✅ OTA UPDATE SUCCESSFUL!") log(" Flash process started.") log(" Waiting for Teensy reboot...\n") time.sleep(12) log("Upload process finished.\n") return True except Exception as e: log(f"Error during OTA: {e}") return False finally: ser.close() def main(): log("Wifi_OTA watcher started") log(f"Watching: {HEX_FILE_PATH}") last_mtime = 0.0 if os.path.exists(HEX_FILE_PATH): last_mtime = os.path.getmtime(HEX_FILE_PATH) log(f"Initial latest.hex found (size: {os.path.getsize(HEX_FILE_PATH)} bytes)") while True: try: if is_fresh_hex_file(HEX_FILE_PATH): current_mtime = os.path.getmtime(HEX_FILE_PATH) if current_mtime > last_mtime + 0.5: # small tolerance log("=== New latest.hex detected! Starting OTA ===") last_mtime = current_mtime ota_upload(HEX_FILE_PATH) except Exception as e: log(f"Watcher error: {e}") time.sleep(POLL_INTERVAL) if __name__ == "__main__": signal.signal(signal.SIGTERM, lambda s, f: sys.exit(0)) main() |
These files are also saved on my Github site in the my_vision_robot repo.
These updates went very smoothly, all things considered. The only minor hitch in the get-along was getting the new .hex file copied over from my PC to the Pi5 via SCP without having to enter the Pi5 password. Here’s the relevant material from Grok:
The password prompt
That’s expected if SSH key authentication isn’t set up (or the key isn’t loaded). scp is falling back to password auth, which blocks a fully automatic F5 experience.
Fix (one-time setup on the PC):
- Make sure you have an SSH key pair:
Bash
# in PowerShell or Git Bash
ls ~/.ssh/id_rsa.pub
# or
ls ~/.ssh/id_ed25519.pub
- Copy the public key to the Pi (if not already done):
Bash
ssh-copy-id pi@RobotPi5
(or manually append the .pub contents to ~/.ssh/authorized_keys on the Pi)
- Test that it works without a password:
Bash
scp some_small_file pi@RobotPi5:~/my_vision_robot/firmware/test.txt
After that, the post-build scp should run silently. (this still failed):
No SSH key exists yet. Generate one (ed25519 is preferred):
In PowerShell:
PowerShell
ssh-keygen -t ed25519 -C “frank@vision-robot”
- Press Enter to accept the default file location (C:\Users\Frank\.ssh\id_ed25519)
- You can set a passphrase or leave it empty (empty = fully automatic, no prompts)
Then copy the public key to the Pi:
PowerShell
type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh pi@RobotPi5 “mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys”
(It will ask for the Pi password one last time.)
After that, test:
PowerShell
scp $env:USERPROFILE\.ssh\id_ed25519.pub pi@RobotPi5:~/test_key.txt
If that works without asking for a password, F5 will also be password-free.