This project was originally forked from kevinmcaleer/ota by Kevin McAleer.
Robust over‑the‑air (OTA) update system for MicroPython devices. The updater
can pull application bundles either from the latest GitHub release
(stable channel) or from the tip of a development branch (developer
channel). Every file is streamed through a Git style SHA1 verifier and
staged before an atomic swap with rollback support.
- Dual update channels – latest release or branch tip
- Works with public and private repositories (token optional)
- Manifest or manifestless operation using the Git tree
- Git blob SHA1 verification (optional SHA256 sidecar)
- Streamed downloads to a staging directory and atomic swap
- Rollback on failure and version tracking
- Minimal memory usage and concise logging
- Delta/differential updates:
- 60-95% bandwidth reduction for code changes
- 85-92% energy savings on updates
- Automatic preference for low-bandwidth/metered connections
- Transparent fallback to full download
- Multi-connectivity support:
- Intelligent fallback: WiFi → Cellular → LoRa
- 90%+ connectivity reliability for remote deployments
- Automatic cost estimation for metered connections
- Transport-aware delta and bandwidth optimization
- Headless operation support:
- Hardware watchdog timer for automatic recovery from hangs
- Status LED visual feedback for debugging without console
- Battery level monitoring for battery-powered devices
- Error state persistence for post-mortem debugging
- Enhanced reliability:
- Exponential backoff for network retries
- Pre-download validation to catch issues early
- Automatic resource adaptation based on available memory and signal strength
- Comprehensive error tracking and rollback safety
- Update scheduling & health monitoring:
- Rate limiting to prevent API quota exhaustion
- Time-based update windows (solar peak optimization)
- Canary rollouts for staged deployment
- Health-based update deferral
Get your first OTA update running in 5 minutes on a Raspberry Pi Pico W.
- Raspberry Pi Pico W with MicroPython v1.20+ installed
- GitHub account (free)
- WiFi network (2.4GHz)
- USB cable for initial setup
Fork this repository to your GitHub account so you can push firmware updates.
Create ota_config.json with your details:
{
"owner": "YOUR_GITHUB_USERNAME",
"repo": "ota",
"ssid": "YOUR_WIFI_SSID",
"password": "YOUR_WIFI_PASSWORD",
"channel": "developer",
"branch": "main",
"allow": ["README.md"],
"debug": true
}Replace YOUR_GITHUB_USERNAME, YOUR_WIFI_SSID, and YOUR_WIFI_PASSWORD with your actual values.
With Thonny:
- Connect to "MicroPython (Raspberry Pi Pico)" interpreter
- Drag and drop these files:
ota.py,main.py,ota_config.json
With mpremote:
mpremote cp ota.py :
mpremote cp main.py :
mpremote cp ota_config.json :In the REPL:
import main
main.main()Expected output:
Connecting to WiFi...
Connected: 192.168.1.100
Using developer channel: main
Downloading: README.md (15KB)
Update successful! Rebooting...
After reboot:
import os
print('README.md' in os.listdir('/')) # Should print: TrueSuccess! Your device can now receive OTA updates.
- See Usage for production deployment with manifest-based releases
- Enable Security with tokens and signed manifests
- Configure Headless Operation for remote devices
- Set up Delta Updates to reduce bandwidth by 60-95%
-
Copy
ota.pyandmain.pyto the device. -
Provide configuration in
ota_config.json(default),ota_config.yaml/ota_config.yml, orota_config.toml. The loader inspects the file extension to parse JSON, YAML or TOML. YAML parsing requires the optional PyYAML dependency; TOML uses Python's built‑intomllib(3.11+). Setchanneltostableto pull the latest GitHub release or todeveloperto use the tip ofbranch. -
For manifest based updates build a release that contains an asset named
manifest.json. For manifestless mode the client derives the file list directly from the Git tree at the chosen ref. Usemanifest_gen.pyon the development machine to create the manifest if desired:python manifest_gen.py --version v1.0.0 boot.py main.py lib/util.py
-
Upload the manifest and files as release assets. The updater can then fetch the latest release or a specific tag when
CONFIG['tag']is set.If you prefer to avoid attaching a manifest, omit
manifest.jsonfrom the release and the updater will derive file paths from the Git tree at the tag. Each file is verified against its Git blob SHA before being staged. Use theallowandignorelists in the configuration to control which files are downloaded. Entries may be exact file names likeota.pyor directory prefixes such aslib/;ignorerules use the same matching logic and take priority overallow.
-
Fork this repository on GitHub so the device can access
README.md. -
Create
ota_config.jsonon the Pico with values similar to:{ "owner": "YOUR_GITHUB_USERNAME", "repo": "ota", "ssid": "YOUR_WIFI_SSID", "password": "YOUR_WIFI_PASSWORD", "channel": "developer", "branch": "main", "token": "", "allow": ["README.md"], "ignore": [], "chunk": 512, "stage_dir": ".ota_stage", "backup_dir": ".ota_backup", "connect_timeout_sec": 20, "http_timeout_sec": 20, "retries": 3, "backoff_sec": 3, "reset_mode": "hard", "debug": false }Equivalent YAML:
owner: YOUR_GITHUB_USERNAME repo: ota ssid: YOUR_WIFI_SSID password: YOUR_WIFI_PASSWORD channel: developer branch: main allow: [README.md] ignore: [] chunk: 512 connect_timeout_sec: 20 http_timeout_sec: 20 retries: 3 backoff_sec: 3 reset_mode: hard debug: false
Equivalent TOML:
owner = "YOUR_GITHUB_USERNAME" repo = "ota" ssid = "YOUR_WIFI_SSID" password = "YOUR_WIFI_PASSWORD" channel = "developer" branch = "main" allow = ["README.md"] ignore = [] chunk = 512 connect_timeout_sec = 20 http_timeout_sec = 20 retries = 3 backoff_sec = 3 force = false reset_mode = "hard" debug = false
Set
debugtotrueto enable verbose logging for troubleshooting. Thereset_modefield controls how the device restarts after an update:hard(default) usesmachine.reset(),softattemptsmachine.soft_reset(), andnoneskips resetting.The configuration fields are:
owner(string, required) – GitHub username.repo(string, required) – repository name.ssid(string, required) – Wi‑Fi network name.password(string, required) – Wi‑Fi password.channel(string, required) –stablefor releases ordeveloperfor branch tip.branch(string, optional) – development branch when using thedeveloperchannel.token(string, optional) – GitHub API token; use an empty string ("") for public repositories.allow(list of strings, required) – whitelist of paths to update.ignore(list of strings, optional) – paths to skip during updates.chunk(integer, optional) – download buffer size in bytes.stage_dir(string, optional) – staging directory used during updates; defaults to.ota_stage.backup_dir(string, optional) – directory holding backups for rollback; defaults to.ota_backup.connect_timeout_sec/http_timeout_sec(integer, optional) – network timeout values. On MicroPython the two fields collapse into one effective timeout equal to the larger of the provided values.retries(integer, optional) – number of retry attempts.backoff_sec(integer, optional) – initial delay between retries in seconds.max_backoff_sec(integer, optional) – maximum delay cap for exponential backoff (default 60).force(boolean, optional) – set totrueto force an update even if the installed and remote versions match.reset_mode(string, optional) –hardfor a full reset (default),softfor a soft reset when supported, ornoneto disable automatic resets.debug(boolean, optional) – set totruefor verbose logging.
watchdog_timeout_ms(integer, optional) – hardware watchdog timeout in milliseconds (e.g., 8000 for 8 seconds). Enables automatic recovery from system hangs.status_led_pin(integer, optional) – GPIO pin number for status LED (e.g., 25 for Pico W onboard LED). Provides visual feedback:- 2 quick blinks: WiFi connection attempt
- Solid: Connected/processing
- Brief pulses: Downloading
- Quick blink: File completed
- 3 quick blinks: Update successful
- Long blink: Connection/update failed
battery_adc_pin(integer, optional) – ADC pin for battery voltage monitoring.battery_divider_ratio(float, optional) – voltage divider ratio if using one (default 1.0).battery_v_max(float, optional) – fully charged battery voltage (default 4.2 for LiPo).battery_v_min(float, optional) – empty battery voltage (default 3.0 for LiPo).min_battery_percent(integer, optional) – minimum battery percentage required to perform updates.
The updater applies allow and ignore rules to every file considered
for download. Each rule may be an exact file (e.g. main.py) or a
directory prefix (lib/ or lib). ignore entries take precedence
over allow. When allow is empty all files are permitted unless
ignored. Manifest files and delete instructions are subject to the same
checks, and paths containing .. or starting with / are rejected.
Booleans must use lowercase true or false without quotes.
-
Copy
ota.py,main.pyand the config file to the root of the Pico. -
Run the updater from the REPL:
import main main.main()
The client downloads
README.md, verifies its SHA1 and reboots into the updated filesystem. EditingREADME.mdon GitHub and rerunning will fetch the new revision.
The modules expose a MICROPYTHON flag based on sys.implementation.name
to detect when running under MicroPython and fall back to lightweight stubs on
CPython. The codebase has been verified on MicroPython v1.26.0 (2025-08-09)
running on a Raspberry Pi Pico W with an RP2040.
Run a quick smoke test to verify that the client can resolve the update target for each channel without applying changes:
python integration_test.pyFor comprehensive coverage, run the unit tests on a development machine with Python 3:
pytest- The GitHub token for private repositories should be stored in a small configuration file or passed at runtime. On MicroPython devices secrets are stored in plain text – protect physical access to the device.
- TLS certificate validation may be limited on some boards. When using
urequests, ensure the firmware supports HTTPS or provide a CA bundle if necessary.
For remote or battery-powered deployments without console access, the updater provides several monitoring and recovery features:
Enable hardware watchdog to automatically recover from system hangs:
{
"watchdog_timeout_ms": 8000
}The watchdog is fed during downloads and file operations. If the system hangs, the device will automatically reset after the timeout period.
Configure a status LED for visual debugging without console access:
{
"status_led_pin": 25
}LED Patterns:
- 2 quick blinks → WiFi connection starting
- Solid LED → Connected and processing
- Brief pulses → Actively downloading files
- Quick blink → File download completed
- 3 quick blinks → Update successful
- Long blink (500ms) → Connection or update failed
For battery-powered devices, configure battery monitoring to prevent updates when battery is low:
{
"battery_adc_pin": 26,
"battery_divider_ratio": 2.0,
"battery_v_max": 4.2,
"battery_v_min": 3.0,
"min_battery_percent": 20
}The updater will abort if battery level falls below min_battery_percent.
Failed updates write error details to ota_error.json for post-mortem debugging. This file persists across reboots and includes:
- Rollback failures and reasons
- Update validation errors
- Exception messages from failed operations
Network retries use exponential backoff to avoid overwhelming poor connections:
{
"retries": 5,
"backoff_sec": 3,
"max_backoff_sec": 60
}First retry waits 3s, then 6s, 12s, 24s, up to the 60s maximum. The system automatically adapts retry behavior based on WiFi signal strength (RSSI).
Reduce bandwidth usage by 60-95% with differential updates. Instead of downloading entire files, only the changes between versions are transmitted.
{
"enable_delta_updates": true
}- Generate deltas between versions using the provided tool:
python delta_gen.py --old v1.0.0 --new v1.1.0 --output .deltas/- Commit and push deltas to your repository:
git add .deltas/
git commit -m "Add deltas for v1.1.0"
git push- Device automatically attempts delta updates when enabled
- Falls back to full download if delta is unavailable or fails
- Delta preferred automatically for low-bandwidth or metered connections (cellular)
- Verifies output integrity using Git blob SHA1 hash
- 60-95% bandwidth reduction for typical code changes
- 85-92% energy savings on updates
- Essential for cellular deployments (automatic cost estimation)
- Zero configuration on device side
delta.py- Delta apply module (runs on device)delta_gen.py- Delta generation tool (runs on server).deltas/- Directory for storing delta files in repository
Intelligent fallback between WiFi, Cellular, and LoRa connections for maximum reliability in remote deployments.
{
"ssid": "wifi-ssid",
"password": "wifi-password",
"cellular_enabled": true,
"cellular_apn": "your.apn.com",
"cellular_uart": 1,
"cellular_tx_pin": 4,
"cellular_rx_pin": 5,
"cellular_baud": 115200,
"cellular_tech": "nbiot",
"cellular_cost_per_mb": 0.50
}{
"ssid": "wifi-ssid",
"password": "wifi-password",
"lora_enabled": true,
"lora_spi_pins": [18, 19, 16],
"lora_cs_pin": 17,
"lora_rst_pin": 20,
"lora_freq": 915000000
}- Automatically tries transports in priority order: WiFi → Cellular → LoRa
- Shows connected transport and signal strength in debug output
- Estimates update cost for metered connections (cellular)
- Automatically prefers delta updates for low-bandwidth/costly connections
- WiFi - High bandwidth, zero cost
- Cellular - Medium/high bandwidth, metered (NB-IoT, LTE-M, 2G/3G/4G)
- LoRa - Very low bandwidth, zero cost (metadata/triggers only)
Cellular Modems:
- SIM800/SIM800L (2G)
- SIM7000 (NB-IoT/LTE-M)
- SIM7600 (4G LTE)
- Any AT command-based modem
LoRa Modules:
- SX1276/SX1278
- RFM95/RFM96
- LoRaWAN gateways
- 90%+ connectivity reliability vs 60-70% WiFi-only
- Automatic failover when WiFi unavailable
- Cost optimization for cellular deployments
- Essential for remote deployments (weather stations, remote sensors, etc.)
Note: WiFi transport is fully implemented. Cellular and LoRa transports provide framework but require modem-specific implementation for production use. See connectivity.py for transport interface.
connectivity.py- Transport abstraction and ConnectivityManager
Intelligent update timing and health-based decisions for production IoT fleets. See update_scheduler.py for full documentation.
- Health tracking - Monitor crash counts and update history
- Rate limiting - Prevent API quota exhaustion
- Update windows - Time-based scheduling (e.g., solar peak hours)
- Canary rollouts - Staggered deployment using device ID hashing
- Stability checks - Delay updates after recent crashes
{
"update_scheduling": {
"min_update_interval_sec": 3600,
"update_window_start_hour": 10,
"update_window_end_hour": 15,
"power_source": "solar",
"min_battery_percent": 60,
"max_crashes_before_delay": 3,
"rollout_percent": 20
}
}After a successful update the device writes the new version to
version.json and issues machine.reset() to boot into the new code.
The manifest may include optional post_update and rollback hook
scripts for custom actions.
WiFi Connection Failed
Error: Timeout connecting to WiFi
Solutions:
- Verify SSID and password are correct (case-sensitive)
- Ensure 2.4GHz WiFi (Pico W doesn't support 5GHz)
- Move device closer to router
- Enable debug mode:
"debug": true
HTTP 401 Unauthorized
Error: GitHub API returned 401
Solutions:
- For private repos: Add GitHub Personal Access Token to config
- Create token at: https://github.com/settings/tokens (needs
reposcope) - Add to config:
"token": "ghp_xxxxxxxxxxxx"
HTTP 404 Not Found
Error: Resource not found
Solutions:
- Verify repository exists:
https://github.com/{owner}/{repo} - For stable channel: Ensure at least one release exists
- Check tag name matches exactly (including
vprefix)
Out of Memory
Error: memory allocation failed
Solutions:
- Reduce chunk size:
"chunk": 256 - Free memory before update:
import gc gc.collect() import main main.main()
- Remove unnecessary files from device
SHA Verification Failed
Error: SHA1/SHA256 mismatch
Solutions:
- Usually auto-retries (check
retriesconfig) - Check network stability (weak WiFi signal)
- Increase retries:
"retries": 5, "backoff_sec": 5
Insufficient Storage
Error: no space left on device
Solutions:
- Check free space:
import os stat = os.statvfs('/') free_kb = (stat[0] * stat[3]) / 1024 print(f"Free: {free_kb} KB")
- Delete old files/logs
- Exclude directories:
"ignore": ["data/", "images/"] - Note: Only exact paths or directory prefixes supported (no wildcards like
*.bmp)
Enable verbose logging to diagnose issues:
{
"debug": true
}This shows:
- WiFi connection details (SSID, RSSI, IP)
- GitHub API requests/responses
- File download progress
- Memory usage
- Detailed error messages
If using status_led_pin:
| Pattern | Meaning | Action |
|---|---|---|
| 2 quick blinks | WiFi connecting | Wait 10-30s |
| Solid ON | Connected/processing | Normal |
| Brief pulses | Downloading | Normal |
| 3 quick blinks | Update successful | Will reboot |
| Long blink (500ms) | Error | Check logs |
View errors:
import json
try:
with open('ota_error.json') as f:
print(json.load(f))
except OSError:
print("No errors logged")Check version:
try:
with open('version.json') as f:
print(json.load(f))
except OSError:
print("No version yet")Q: How often should devices check for updates? A: Production: once per hour. Development: every boot.
Q: Can I update just one file?
A: Yes, use "allow": ["main.py"]
Q: What happens if power is lost during update?
A: Automatic rollback from .ota_backup/ on next boot. No corruption.
Q: How much free space is needed? A: At least 2× update size + 50KB overhead.
Q: Do I need a manifest for stable channel? A: No, but strongly recommended for signature verification and better security.
Q: Can multiple devices update simultaneously? A: Yes. Without token: ~10-20 devices/hour. With token: ~1000-2000 devices/hour.
Q: The device reboots but old code still runs?
A: Enable hard reset: "reset_mode": "hard"
If still stuck:
- Enable
"debug": trueand capture full output - Check GitHub Issues: https://github.com/ajmcardle/ota/issues
- Provide:
- MicroPython version:
import sys; print(sys.implementation.version) - Board type (e.g., "Raspberry Pi Pico W")
- Config file (redact passwords/tokens)
- Full error output
- Expected vs actual behavior
- MicroPython version:
MIT License