├── .clang-format ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── cpplint.yml │ └── dependabot.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── data └── .keep ├── docs ├── _config.yml ├── index.md ├── safeboot-ota.jpeg └── safeboot-ssid.jpeg ├── embed └── website.html ├── examples ├── App │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── partitions-4MB-safeboot.csv │ ├── platformio.ini │ └── src │ │ └── main.cpp └── App_ESPConnect_OTA │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── partitions-4MB-safeboot.csv │ ├── platformio.ini │ └── src │ └── main.cpp ├── lib └── ArduinoOTA │ ├── library.properties │ └── src │ ├── ArduinoOTA.cpp │ └── ArduinoOTA.h ├── partitions.csv ├── platformio.ini ├── src └── main.cpp ├── test ├── .gitignore ├── LICENSE ├── README.md ├── partitions-4MB-safeboot.csv ├── platformio.ini └── src │ └── main.cpp └── tools ├── factory.py ├── safeboot.py ├── safeboot_size_check.py ├── version.py └── website.py /.clang-format: -------------------------------------------------------------------------------- 1 | Language: Cpp 2 | BasedOnStyle: LLVM 3 | 4 | AccessModifierOffset: -2 5 | AlignConsecutiveMacros: true 6 | AllowAllArgumentsOnNextLine: false 7 | AllowAllParametersOfDeclarationOnNextLine: false 8 | AllowShortIfStatementsOnASingleLine: false 9 | AllowShortLambdasOnASingleLine: Inline 10 | BinPackArguments: false 11 | ColumnLimit: 0 12 | ContinuationIndentWidth: 2 13 | FixNamespaceComments: false 14 | IndentAccessModifiers: true 15 | IndentCaseLabels: true 16 | IndentPPDirectives: BeforeHash 17 | IndentWidth: 2 18 | NamespaceIndentation: All 19 | PointerAlignment: Left 20 | ReferenceAlignment: Left 21 | TabWidth: 2 22 | UseTab: Never 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mathieucarbou 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: https://paypal.me/mathieucarboufr 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every week 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: Build 4 | 5 | on: 6 | workflow_dispatch: 7 | push: 8 | tags: ["v*"] 9 | branches: ["main"] 10 | paths-ignore: ["docs/**", "**/*.md"] 11 | pull_request: 12 | paths-ignore: ["docs/**", "**/*.md"] 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | build: 20 | name: Build safeboot-${{ matrix.board }}.bin 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | board: 26 | [ 27 | "denky_d4", 28 | "esp32-c3-devkitc-02", 29 | "esp32-c6-devkitc-1", 30 | "esp32-gateway", 31 | "esp32-poe-iso", 32 | "esp32-poe", 33 | "esp32-s2-saola-1", 34 | "esp32-s3-devkitc-1", 35 | # "esp32-solo1", 36 | "esp32dev", 37 | "esp32s3box", 38 | "lilygo-t-eth-lite-s3", 39 | "lolin_s2_mini", 40 | "tinypico", 41 | "wemos_d1_uno32", 42 | "wipy3", 43 | "wt32-eth01", 44 | ] 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | 49 | - name: Get Tags 50 | run: git fetch --force --tags origin 51 | 52 | - name: Cache PlatformIO 53 | uses: actions/cache@v4 54 | with: 55 | key: ${{ runner.os }}-pio 56 | path: | 57 | ~/.cache/pip 58 | ~/.platformio 59 | 60 | - name: Python 61 | uses: actions/setup-python@v5 62 | with: 63 | python-version: "3.x" 64 | 65 | - name: Install npm 66 | uses: actions/setup-node@v4 67 | with: 68 | node-version: '20.x' 69 | 70 | - name: Install html-minifier-terser 71 | run: npm install -g html-minifier-terser 72 | 73 | - name: Build 74 | run: | 75 | python -m pip install --upgrade pip 76 | pip install --upgrade platformio 77 | REF_NAME="${{ github.ref_name }}" pio run -e ${{ matrix.board }} 78 | cp .pio/build/${{ matrix.board }}/firmware.bin ./safeboot-${{ matrix.board }}.bin 79 | 80 | - name: Upload 81 | uses: actions/upload-artifact@v4 82 | with: 83 | name: ${{ matrix.board }} 84 | if-no-files-found: error 85 | path: | 86 | *.bin 87 | 88 | test: 89 | name: Test safeboot-${{ matrix.board }}.bin 90 | runs-on: ubuntu-latest 91 | needs: [build] 92 | strategy: 93 | fail-fast: false 94 | matrix: 95 | board: 96 | [ 97 | "denky_d4", 98 | "esp32-c3-devkitc-02", 99 | "esp32-c6-devkitc-1", 100 | "esp32-gateway", 101 | "esp32-poe-iso", 102 | "esp32-poe", 103 | "esp32-s2-saola-1", 104 | "esp32-s3-devkitc-1", 105 | # "esp32-solo1", 106 | "esp32dev", 107 | "esp32s3box", 108 | "lolin_s2_mini", 109 | "tinypico", 110 | "wemos_d1_uno32", 111 | "wipy3", 112 | "wt32-eth01", 113 | ] 114 | steps: 115 | - name: Checkout 116 | uses: actions/checkout@v4 117 | 118 | - name: Get Tags 119 | run: git fetch --force --tags origin 120 | 121 | - name: Cache PlatformIO 122 | uses: actions/cache@v4 123 | with: 124 | key: ${{ runner.os }}-pio 125 | path: | 126 | ~/.cache/pip 127 | ~/.platformio 128 | 129 | - name: Python 130 | uses: actions/setup-python@v5 131 | with: 132 | python-version: "3.x" 133 | 134 | - name: Download 135 | uses: actions/download-artifact@v4 136 | with: 137 | path: artifacts/ 138 | 139 | - name: Test 140 | run: | 141 | python -m pip install --upgrade pip 142 | pip install --upgrade platformio 143 | SAFEBOOT_IMAGE=../artifacts/${{ matrix.board }}/safeboot-${{ matrix.board }}.bin BOARD=${{ matrix.board }} pio run -d test -e ci 144 | 145 | release: 146 | name: Release 147 | if: ${{ github.repository_owner == 'mathieucarbou' && github.event_name != 'pull_request' && startsWith(github.ref, 'refs/tags/v') }} 148 | runs-on: ubuntu-latest 149 | needs: [test] 150 | permissions: 151 | contents: write 152 | steps: 153 | - name: Checkout 154 | uses: actions/checkout@v4 155 | 156 | - name: Download 157 | uses: actions/download-artifact@v4 158 | with: 159 | path: artifacts/ 160 | 161 | - name: Move 162 | run: | 163 | ls -R artifacts 164 | find artifacts -name '*.bin' -exec mv {} artifacts/ \; 165 | 166 | - name: Release 167 | uses: softprops/action-gh-release@v2 168 | with: 169 | files: | 170 | artifacts/*.bin 171 | tools/factory.py 172 | tools/safeboot.py 173 | -------------------------------------------------------------------------------- /.github/workflows/cpplint.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: Cpplint 4 | 5 | on: 6 | workflow_dispatch: 7 | schedule: 8 | - cron: "0 5 * * 1" 9 | push: 10 | tags: ["v*"] 11 | branches: ["*"] 12 | paths: ["**/*.h", "**/*.cpp"] 13 | pull_request: 14 | paths: ["**/*.h", "**/*.cpp"] 15 | 16 | jobs: 17 | cpplint: 18 | name: cpplint 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Cache 25 | uses: actions/cache@v4 26 | with: 27 | key: ${{ runner.os }}-cpplint 28 | path: ~/.cache/pip 29 | 30 | - name: Pyhton 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: "3.x" 34 | 35 | - name: cpplint 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install --upgrade cpplint 39 | cpplint \ 40 | --repository=. \ 41 | --recursive \ 42 | --filter=-whitespace/line_length,-whitespace/braces,-whitespace/comments,-runtime/indentation_namespace,-whitespace/indent,-readability/braces,-whitespace/newline,-readability/todo,-build/c++11,-runtime/references,-legal/copyright \ 43 | --exclude=lib \ 44 | include \ 45 | src 46 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: PlatformIO Dependabot 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # Runs every day at 01:00 7 | - cron: "0 1 * * *" 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | name: PlatformIO Dependabot 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: run PlatformIO Dependabot 17 | uses: peterus/platformio_dependabot@v1 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .lh 3 | /.pio 4 | /.vscode 5 | /logs 6 | 7 | /sdkconfig.* 8 | /dependencies.lock 9 | /managed_components 10 | /.dummy 11 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-python-3.11 2 | USER gitpod 3 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - command: pip install --upgrade pip && pip install -U platformio && platformio run 3 | 4 | image: 5 | file: .gitpod.Dockerfile 6 | 7 | vscode: 8 | extensions: 9 | - shardulm94.trailing-spaces 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | https://sidweb.nl/cms3/en/contact. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 121 | 122 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 123 | enforcement ladder](https://github.com/mozilla/diversity). 124 | 125 | [homepage]: https://www.contributor-covenant.org 126 | 127 | For answers to common questions about this code of conduct, see the FAQ at 128 | https://www.contributor-covenant.org/faq. Translations are available at 129 | https://www.contributor-covenant.org/translations. 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Mathieu Carbou 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MycilaSafeBoot 2 | 3 | [![Latest Release](https://img.shields.io/github/release/mathieucarbou/MycilaSafeBoot.svg)](https://GitHub.com/mathieucarbou/MycilaSafeBoot/releases/) 4 | [![Download](https://img.shields.io/badge/Download-safeboot-green.svg)](https://github.com/mathieucarbou/MycilaSafeBoot/releases) 5 | 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md) 8 | 9 | [![Build](https://github.com/mathieucarbou/MycilaSafeBoot/actions/workflows/build.yml/badge.svg)](https://github.com/mathieucarbou/MycilaSafeBoot/actions/workflows/build.yml) 10 | [![GitHub latest commit](https://badgen.net/github/last-commit/mathieucarbou/MycilaSafeBoot)](https://GitHub.com/mathieucarbou/MycilaSafeBoot/commit/) 11 | [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/mathieucarbou/MycilaSafeBoot) 12 | 13 | MycilaSafeBoot is a Web OTA recovery partition for ESP32 / Arduino. 14 | 15 | It allows to have only one application partition to use the maximum available flash size. 16 | 17 | The idea is not new: [Tasmota also uses a SafeBoot partition](https://tasmota.github.io/docs/Safeboot/). 18 | 19 | 20 | 21 | - [Overview](#overview) 22 | - [How it works](#how-it-works) 23 | - [How to integrate the SafeBoot in your project](#how-to-integrate-the-safeboot-in-your-project) 24 | - [How to build the SafeBoot firmware image](#how-to-build-the-safeboot-firmware-image) 25 | - [SafeBoot Example](#safeboot-example) 26 | - [How to reboot in SafeBoot mode from the app](#how-to-reboot-in-safeboot-mode-from-the-app) 27 | - [Configuration options to manage build size](#configuration-options-to-manage-build-size) 28 | - [Options matrix](#options-matrix) 29 | - [Default board options](#default-board-options) 30 | - [How to OTA update firmware from PlatformIO](#how-to-ota-update-firmware-from-platformio) 31 | 32 | ![](https://private-user-images.githubusercontent.com/61346/426535795-7eda5f6e-7900-4380-921f-8e54fb2b2e2c.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDM5NjQ4MjIsIm5iZiI6MTc0Mzk2NDUyMiwicGF0aCI6Ii82MTM0Ni80MjY1MzU3OTUtN2VkYTVmNmUtNzkwMC00MzgwLTkyMWYtOGU1NGZiMmIyZTJjLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA0MDYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNDA2VDE4MzUyMlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTdjN2Y5YmQ2YmU0YjAyOTEzNzdiMzc4YTExNzM4NjgwNWI5MzRkMjc4MWJjZDZlNWMwNTExYmIxMDMyYTVjM2MmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.uJq7XczPOdOlZTsLLgYw6IeRHdbwrk1n8y7YOnuMVGY) 33 | 34 | ## Overview 35 | 36 | Usually, a normal partition table when supporting OTA updates on a 4MB ESP32 looks like this: 37 | 38 | ``` 39 | # Name, Type, SubType, Offset, Size, Flags 40 | nvs, data, nvs, 0x9000, 0x5000, 41 | otadata, data, ota, 0xE000, 0x2000, 42 | app0, app, ota_0, 0x10000, 0x1F0000, 43 | app1, app, ota_1, 0x200000, 0x1F0000, 44 | spiffs, data, spiffs, 0x3F0000, 0x10000, 45 | ``` 46 | 47 | which can also be written as: 48 | 49 | ``` 50 | # Name ,Type ,SubType ,Offset ,Size ,Flags 51 | nvs ,data ,nvs ,36K ,20K , 52 | otadata ,data ,ota ,56K ,8K , 53 | app0 ,app ,ota_0 ,64K ,1984K , 54 | app1 ,app ,ota_1 ,2048K ,1984K , 55 | spiffs ,data ,spiffs ,4032K ,64K , 56 | ``` 57 | 58 | Because of the need to have 2 partitions with the same size, the firmware is then limited to only 2MB in this case when the ESP has 4MB flash. 59 | 2MB is left unused (the OTA process will switch to the updated partition once completed). 60 | 61 | **A SafeBoot partition is a small bootable recovery partition allowing you to flash the firmware.** 62 | Consequently, the firmware can take all the remaining space on the flash. 63 | 64 | **The SafeBoot partition is 655360 bytes only.** 65 | 66 | **Example for 4MB partition** with a SafeBoot partition and an application size of 3MB: 67 | 68 | ``` 69 | # Name, Type, SubType, Offset, Size, Flags 70 | nvs, data, nvs, 0x9000, 0x5000, 71 | otadata, data, ota, 0xE000, 0x2000, 72 | safeboot, app, factory, 0x10000, 0xA0000, 73 | app, app, ota_0, 0xB0000, 0x330000, 74 | spiffs, data, spiffs, 0x3E0000, 0x10000, 75 | coredump, data, coredump, 0x3F0000, 0x10000, 76 | ``` 77 | 78 | which can also be written as: 79 | 80 | ``` 81 | # Name ,Type ,SubType ,Offset ,Size ,Flags 82 | nvs ,data ,nvs ,36K ,20K , 83 | otadata ,data ,ota ,56K ,8K , 84 | safeboot ,app ,factory ,64K ,640K , 85 | app ,app ,ota_0 ,704K ,3264K , 86 | spiffs ,data ,spiffs ,3968K ,64K , 87 | coredump ,data ,coredump ,4032K ,64K , 88 | ``` 89 | 90 | **Example for 8Mb partition** with a SafeBoot partition and an application size of 7MB: 91 | 92 | ``` 93 | # Name, Type, SubType, Offset, Size, Flags 94 | nvs, data, nvs, 0x9000, 0x5000, 95 | otadata, data, ota, 0xE000, 0x2000, 96 | safeboot, app, factory, 0x10000, 0xA0000, 97 | app, app, ota_0, 0xB0000, 0x730000, 98 | spiffs data, spiffs, 0x7E0000, 0x10000, 99 | coredump, data, coredump, 0x7F0000, 0x10000, 100 | ``` 101 | 102 | which can also be written as: 103 | 104 | ``` 105 | # Name ,Type ,SubType ,Offset ,Size ,Flags 106 | nvs ,data ,nvs ,36K ,20K , 107 | otadata ,data ,ota ,56K ,8K , 108 | safeboot ,app ,factory ,64K ,640K , 109 | app ,app ,ota_0 ,704K ,7312K , 110 | spiffs ,data ,spiffs ,8128K ,64K , 111 | coredump ,data ,coredump ,8192K ,64K , 112 | ``` 113 | 114 | The SafeBoot partition is also automatically booted when the firmware is missing. 115 | 116 | ## How it works 117 | 118 | 1. When a user wants to update the app firmware, we have to tell the app to reboot in recovery mode. 119 | 120 | 2. Once booted in recovery mode, an Access Point is created with the SSID `SafeBoot`. 121 | 122 | [![](https://mathieu.carbou.me/MycilaSafeBoot/safeboot-ssid.jpeg)](https://mathieu.carbou.me/MycilaSafeBoot/safeboot-ssid.jpeg) 123 | 124 | 3. Connect to the Access Point. 125 | 126 | 4. Now, you can flash the new firmware, either with `ArduinoOTA` or from the web page by going to `http://192.168.4.1` 127 | 128 | 5. After the flash is successful, the ESP will reboot in the new firmware. 129 | 130 | SafeBoot partition also supports [MycilaESPConnect](https://github.com/mathieucarbou/MycilaESPConnect), which means if your application saves some network settings (WiFi SSID, Ethernet or WiFi static IP, etc), they will be reused. 131 | 132 | ## How to integrate the SafeBoot in your project 133 | 134 | In the PIO file, some settings are added to specify the partition table and the SafeBoot location and the script to generate the factory image. 135 | 136 | ```ini 137 | extra_scripts = post:factory.py 138 | board_build.partitions = partitions-4MB-safeboot.csv 139 | board_build.app_partition_name = app 140 | custom_safeboot_url = https://github.com/mathieucarbou/MycilaSafeBoot/releases/download/v3.2.6/safeboot-esp32dev.bin 141 | ``` 142 | 143 | It is also possible to point to a folder if you download the SafeBoot project locally: 144 | 145 | ```ini 146 | custom_safeboot_dir = ../../tools/SafeBoot 147 | ``` 148 | 149 | It is also possible to point to a pre-downloaded safeoot image: 150 | 151 | ```ini 152 | custom_safeboot_file = safeboot.bin 153 | ``` 154 | 155 | You can find in the [Project Releases](https://github.com/mathieucarbou/MycilaSafeBoot/releases) the list of available SafeBoot images, with the Python script to add to your build. 156 | 157 | ## How to build the SafeBoot firmware image 158 | 159 | Go inside `tools/SafeBoot` and run: 160 | 161 | ```bash 162 | > pio run -e esp32dev 163 | ``` 164 | 165 | If your board does not exist, you can specify it like this: 166 | 167 | ```bash 168 | > SAFEBOOT_BOARD=my-board pio run -e safeboot 169 | ``` 170 | 171 | `SAFEBOOT_BOARD` is the environment variable to specify the board to build the SafeBoot firmware for. 172 | 173 | At the end you should see these lines: 174 | 175 | ``` 176 | Firmware size valid: 619744 <= 655360 177 | SafeBoot firmware created: /Users/mat/Data/Workspace/me/MycilaSafeBoot/.pio/build/dev/safeboot.bin 178 | ``` 179 | 180 | ## SafeBoot Example 181 | 182 | Go inside `examples/App` and execute: 183 | 184 | ```bash 185 | > pio run 186 | ``` 187 | 188 | You should see at the end of the build something like: 189 | 190 | ``` 191 | Generating factory image for serial flashing 192 | Downloading SafeBoot image from https://github.com/mathieucarbou/MycilaSafeBoot/releases/download/v3.2.6/safeboot-esp32dev.bin 193 | Offset | File 194 | - 0x1000 | /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/bootloader.bin 195 | - 0x8000 | /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/partitions.bin 196 | - 0xe000 | /Users/mat/.platformio/packages/framework-arduinoespressif32@src-17df1753722b7b9e1913598420d4e038/tools/partitions/boot_app0.bin 197 | - 0x10000 | /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/safeboot.bin 198 | - 0xb0000 | /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/firmware.bin 199 | 200 | [...] 201 | 202 | Wrote 0x1451a0 bytes to file /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/firmware.factory.bin, ready to flash to offset 0x0 203 | Factory image generated: /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/firmware.factory.bin 204 | ``` 205 | 206 | the `factory.py` script generates a complete factory image named `firmware.factory.bin` with all this content. 207 | 208 | It can be downloaded from [https://github.com/mathieucarbou/MycilaSafeBoot/releases](https://github.com/mathieucarbou/MycilaSafeBoot/releases). 209 | 210 | Flash this factory image on an ESP32: 211 | 212 | ```bash 213 | esptool.py write_flash 0x0 .pio/build/esp32dev/firmware.factory.bin 214 | ``` 215 | 216 | Restart the ESP. 217 | The app loads, shows a button to restart in SafeBoot mode. 218 | After clicking on it, the ESP will reboot into SafeBoot mode. 219 | From there, you can access the web page to flash a new firmware, even from another application. 220 | 221 | ## How to reboot in SafeBoot mode from the app 222 | 223 | You can use [MycilaSystem](https://github.com/mathieucarbou/MycilaSystem): 224 | 225 | ```cpp 226 | #include 227 | 228 | espConnect.saveConfiguration(); // if you want to save ESPConnect settings for network 229 | Mycila::System::restartFactory("safeboot"); 230 | ``` 231 | 232 | or this custom code: 233 | 234 | ```cpp 235 | #include 236 | #include 237 | 238 | const esp_partition_t* partition = esp_partition_find_first(esp_partition_type_t::ESP_PARTITION_TYPE_APP, esp_partition_subtype_t::ESP_PARTITION_SUBTYPE_APP_FACTORY, partitionName); 239 | if (partition) { 240 | esp_ota_set_boot_partition(partition); 241 | esp_restart(); 242 | return true; 243 | } else { 244 | ESP_LOGE("SafeBoot", "SafeBoot partition not found"); 245 | return false; 246 | } 247 | ``` 248 | 249 | ## Configuration options to manage build size 250 | 251 | Squezing everything into the SafeBoot partition (655360 bytes only) is a tight fit especially on ethernet enabled boards. 252 | 253 | Disabling the logging capabilities saves about 12 kbytes in the final build. Just comment out `MYCILA_SAFEBOOT_LOGGING` in `platformio.ini`. 254 | 255 | ```ini 256 | ; -D MYCILA_SAFEBOOT_LOGGING 257 | ``` 258 | 259 | Disabling mDNS saves about 24 kbytes. Enable both [...]\_NO_DNS options in `platformio.ini` to reduce the build size: 260 | 261 | ```ini 262 | -D ESPCONNECT_NO_MDNS 263 | -D MYCILA_SAFEBOOT_NO_MDNS 264 | ``` 265 | 266 | ### Options matrix 267 | 268 | | Board | mDNS: on, logger: on | mDNS: on, logger: off | mDNS: off, logger: off | 269 | | -------------------- | -------------------- | --------------------- | ---------------------- | 270 | | denky_d4 | NOT SUPPORTED | OK | OK | 271 | | esp32-c3-devkitc-02 | OK | OK | OK | 272 | | esp32-c6-devkitc-1 | NOT SUPPORTED | NOT SUPPORTED | OK | 273 | | esp32-gateway | NOT SUPPORTED | OK | OK | 274 | | esp32-poe | NOT SUPPORTED | NOT SUPPORTED | OK | 275 | | esp32-poe-iso | NOT SUPPORTED | NOT SUPPORTED | OK | 276 | | esp32-s2-saola-1 | OK | OK | OK | 277 | | esp32-s3-devkitc-1 | OK | OK | OK | 278 | | esp32-solo1 | OK | OK | OK | 279 | | esp32dev | OK | OK | OK | 280 | | esp32s3box | OK | OK | OK | 281 | | lilygo-t-eth-lite-s3 | OK | OK | OK | 282 | | lolin_s2_mini | OK | OK | OK | 283 | | tinypico | NOT SUPPORTED | OK | OK | 284 | | wemos_d1_uno32 | OK | OK | OK | 285 | | wipy3 | NOT SUPPORTED | OK | OK | 286 | | wt32-eth01 | NOT SUPPORTED | NOT SUPPORTED | OK | 287 | 288 | ## Default board options 289 | 290 | | Board | mDNS | Logging | Ethernet | 291 | | :------------------- | :--: | :-----: | :------: | 292 | | denky_d4 | ✅ | ❌ | ❌ | 293 | | esp32-c3-devkitc-02 | ✅ | ✅ | ❌ | 294 | | esp32-c6-devkitc-1 | ❌ | ❌ | ❌ | 295 | | esp32-gateway | ✅ | ❌ | ✅ | 296 | | esp32-poe | ❌ | ❌ | ✅ | 297 | | esp32-poe-iso | ❌ | ❌ | ✅ | 298 | | esp32-s2-saola-1 | ✅ | ✅ | ❌ | 299 | | esp32-s3-devkitc-1 | ✅ | ✅ | ❌ | 300 | | esp32-solo1 | ✅ | ✅ | ❌ | 301 | | esp32dev | ✅ | ✅ | ❌ | 302 | | esp32s3box | ✅ | ✅ | ❌ | 303 | | lilygo-t-eth-lite-s3 | ✅ | ✅ | ✅ | 304 | | lolin_s2_mini | ✅ | ✅ | ❌ | 305 | | tinypico | ✅ | ❌ | ❌ | 306 | | wemos_d1_uno32 | ✅ | ✅ | ❌ | 307 | | wipy3 | ✅ | ❌ | ❌ | 308 | | wt32-eth01 | ❌ | ❌ | ✅ | 309 | 310 | ## How to OTA update firmware from PlatformIO 311 | 312 | First make sure you created an HTTP endpoint that can be called to restart the app in SafeBoot mode. 313 | See [How to reboot in SafeBoot mode from the app](#how-to-reboot-in-safeboot-mode-from-the-app). 314 | 315 | Then add to your PlatformIO `platformio.ini` file: 316 | 317 | ```ini 318 | board_build.partitions = partitions-4MB-safeboot.csv 319 | upload_protocol = espota 320 | ; set OTA upload port to the ip-address when not using mDNS 321 | upload_port = 192.168.125.99 322 | ; when mDNS is enabled, just point the upload to the hostname 323 | ; upload_port = MyAwesomeApp.local 324 | custom_safeboot_restart_path = /api/system/safeboot 325 | extra_scripts = 326 | tools/safeboot.py 327 | ``` 328 | 329 | The `safeboot.py` script can be downloaded from the release page: [https://github.com/mathieucarbou/MycilaSafeBoot/releases](https://github.com/mathieucarbou/MycilaSafeBoot/releases). The partition table `partitions-4MB-safeboot.csv` is found in the [example folder](https://github.com/mathieucarbou/MycilaSafeBoot/tree/main/examples/App_ESPConnect_OTA). 330 | 331 | - `upload_protocol = espota` tells PlatformIO to use Arduino OTA to upload the firmware 332 | - `upload_port` is the IP address or mDNS name of the ESP32 333 | - `custom_safeboot_restart_path` is the path to call to restart the app in SafeBoot mode 334 | 335 | Once done, just run a `pio run -t upload` or `pio run -t uploadfs` for example and you will see the app automatically restarting in SafeBoot mode, then upload will be achieved, then the ESP will be restarted with your new app. 336 | 337 | See `examples/App_ESPConnect_OTA` for an example. 338 | -------------------------------------------------------------------------------- /data/.keep: -------------------------------------------------------------------------------- 1 | This folder is left empty to be able to generate an FS image with: pio run -e esp32dev -t buildfs -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # bundle exec jekyll serve --host=0.0.0.0 2 | 3 | title: MycilaSafeBoot 4 | description: MycilaSafeBoot is a Web OTA recovery partition for ESP32 / Arduino 5 | remote_theme: pages-themes/cayman@v0.2.0 6 | plugins: 7 | - jekyll-remote-theme 8 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # MycilaSafeBoot 2 | 3 | [![Latest Release](https://img.shields.io/github/release/mathieucarbou/MycilaSafeBoot.svg)](https://GitHub.com/mathieucarbou/MycilaSafeBoot/releases/) 4 | [![Download](https://img.shields.io/badge/Download-safeboot-green.svg)](https://github.com/mathieucarbou/MycilaSafeBoot/releases) 5 | 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md) 8 | 9 | [![Build](https://github.com/mathieucarbou/MycilaSafeBoot/actions/workflows/build.yml/badge.svg)](https://github.com/mathieucarbou/MycilaSafeBoot/actions/workflows/build.yml) 10 | [![GitHub latest commit](https://badgen.net/github/last-commit/mathieucarbou/MycilaSafeBoot)](https://GitHub.com/mathieucarbou/MycilaSafeBoot/commit/) 11 | [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/mathieucarbou/MycilaSafeBoot) 12 | 13 | MycilaSafeBoot is a Web OTA recovery partition for ESP32 / Arduino. 14 | 15 | It allows to have only one application partition to use the maximum available flash size. 16 | 17 | The idea is not new: [Tasmota also uses a SafeBoot partition](https://tasmota.github.io/docs/Safeboot/). 18 | 19 | 20 | 21 | - [Overview](#overview) 22 | - [How it works](#how-it-works) 23 | - [How to integrate the SafeBoot in your project](#how-to-integrate-the-safeboot-in-your-project) 24 | - [How to build the SafeBoot firmware image](#how-to-build-the-safeboot-firmware-image) 25 | - [SafeBoot Example](#safeboot-example) 26 | - [How to reboot in SafeBoot mode from the app](#how-to-reboot-in-safeboot-mode-from-the-app) 27 | - [Configuration options to manage build size](#configuration-options-to-manage-build-size) 28 | - [Options matrix](#options-matrix) 29 | - [Default board options](#default-board-options) 30 | - [How to OTA update firmware from PlatformIO](#how-to-ota-update-firmware-from-platformio) 31 | 32 | ![](https://private-user-images.githubusercontent.com/61346/426535795-7eda5f6e-7900-4380-921f-8e54fb2b2e2c.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDM5NjQ4MjIsIm5iZiI6MTc0Mzk2NDUyMiwicGF0aCI6Ii82MTM0Ni80MjY1MzU3OTUtN2VkYTVmNmUtNzkwMC00MzgwLTkyMWYtOGU1NGZiMmIyZTJjLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA0MDYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNDA2VDE4MzUyMlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTdjN2Y5YmQ2YmU0YjAyOTEzNzdiMzc4YTExNzM4NjgwNWI5MzRkMjc4MWJjZDZlNWMwNTExYmIxMDMyYTVjM2MmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.uJq7XczPOdOlZTsLLgYw6IeRHdbwrk1n8y7YOnuMVGY) 33 | 34 | ## Overview 35 | 36 | Usually, a normal partition table when supporting OTA updates on a 4MB ESP32 looks like this: 37 | 38 | ``` 39 | # Name, Type, SubType, Offset, Size, Flags 40 | nvs, data, nvs, 0x9000, 0x5000, 41 | otadata, data, ota, 0xE000, 0x2000, 42 | app0, app, ota_0, 0x10000, 0x1F0000, 43 | app1, app, ota_1, 0x200000, 0x1F0000, 44 | spiffs, data, spiffs, 0x3F0000, 0x10000, 45 | ``` 46 | 47 | which can also be written as: 48 | 49 | ``` 50 | # Name ,Type ,SubType ,Offset ,Size ,Flags 51 | nvs ,data ,nvs ,36K ,20K , 52 | otadata ,data ,ota ,56K ,8K , 53 | app0 ,app ,ota_0 ,64K ,1984K , 54 | app1 ,app ,ota_1 ,2048K ,1984K , 55 | spiffs ,data ,spiffs ,4032K ,64K , 56 | ``` 57 | 58 | Because of the need to have 2 partitions with the same size, the firmware is then limited to only 2MB in this case when the ESP has 4MB flash. 59 | 2MB is left unused (the OTA process will switch to the updated partition once completed). 60 | 61 | **A SafeBoot partition is a small bootable recovery partition allowing you to flash the firmware.** 62 | Consequently, the firmware can take all the remaining space on the flash. 63 | 64 | **The SafeBoot partition is 655360 bytes only.** 65 | 66 | **Example for 4MB partition** with a SafeBoot partition and an application size of 3MB: 67 | 68 | ``` 69 | # Name, Type, SubType, Offset, Size, Flags 70 | nvs, data, nvs, 0x9000, 0x5000, 71 | otadata, data, ota, 0xE000, 0x2000, 72 | safeboot, app, factory, 0x10000, 0xA0000, 73 | app, app, ota_0, 0xB0000, 0x330000, 74 | spiffs, data, spiffs, 0x3E0000, 0x10000, 75 | coredump, data, coredump, 0x3F0000, 0x10000, 76 | ``` 77 | 78 | which can also be written as: 79 | 80 | ``` 81 | # Name ,Type ,SubType ,Offset ,Size ,Flags 82 | nvs ,data ,nvs ,36K ,20K , 83 | otadata ,data ,ota ,56K ,8K , 84 | safeboot ,app ,factory ,64K ,640K , 85 | app ,app ,ota_0 ,704K ,3264K , 86 | spiffs ,data ,spiffs ,3968K ,64K , 87 | coredump ,data ,coredump ,4032K ,64K , 88 | ``` 89 | 90 | **Example for 8Mb partition** with a SafeBoot partition and an application size of 7MB: 91 | 92 | ``` 93 | # Name, Type, SubType, Offset, Size, Flags 94 | nvs, data, nvs, 0x9000, 0x5000, 95 | otadata, data, ota, 0xE000, 0x2000, 96 | safeboot, app, factory, 0x10000, 0xA0000, 97 | app, app, ota_0, 0xB0000, 0x730000, 98 | spiffs data, spiffs, 0x7E0000, 0x10000, 99 | coredump, data, coredump, 0x7F0000, 0x10000, 100 | ``` 101 | 102 | which can also be written as: 103 | 104 | ``` 105 | # Name ,Type ,SubType ,Offset ,Size ,Flags 106 | nvs ,data ,nvs ,36K ,20K , 107 | otadata ,data ,ota ,56K ,8K , 108 | safeboot ,app ,factory ,64K ,640K , 109 | app ,app ,ota_0 ,704K ,7312K , 110 | spiffs ,data ,spiffs ,8128K ,64K , 111 | coredump ,data ,coredump ,8192K ,64K , 112 | ``` 113 | 114 | The SafeBoot partition is also automatically booted when the firmware is missing. 115 | 116 | ## How it works 117 | 118 | 1. When a user wants to update the app firmware, we have to tell the app to reboot in recovery mode. 119 | 120 | 2. Once booted in recovery mode, an Access Point is created with the SSID `SafeBoot`. 121 | 122 | [![](https://mathieu.carbou.me/MycilaSafeBoot/safeboot-ssid.jpeg)](https://mathieu.carbou.me/MycilaSafeBoot/safeboot-ssid.jpeg) 123 | 124 | 3. Connect to the Access Point. 125 | 126 | 4. Now, you can flash the new firmware, either with `ArduinoOTA` or from the web page by going to `http://192.168.4.1` 127 | 128 | 5. After the flash is successful, the ESP will reboot in the new firmware. 129 | 130 | SafeBoot partition also supports [MycilaESPConnect](https://github.com/mathieucarbou/MycilaESPConnect), which means if your application saves some network settings (WiFi SSID, Ethernet or WiFi static IP, etc), they will be reused. 131 | 132 | ## How to integrate the SafeBoot in your project 133 | 134 | In the PIO file, some settings are added to specify the partition table and the SafeBoot location and the script to generate the factory image. 135 | 136 | ```ini 137 | extra_scripts = post:factory.py 138 | board_build.partitions = partitions-4MB-safeboot.csv 139 | board_build.app_partition_name = app 140 | custom_safeboot_url = https://github.com/mathieucarbou/MycilaSafeBoot/releases/download/v3.2.6/safeboot-esp32dev.bin 141 | ``` 142 | 143 | It is also possible to point to a folder if you download the SafeBoot project locally: 144 | 145 | ```ini 146 | custom_safeboot_dir = ../../tools/SafeBoot 147 | ``` 148 | 149 | It is also possible to point to a pre-downloaded safeoot image: 150 | 151 | ```ini 152 | custom_safeboot_file = safeboot.bin 153 | ``` 154 | 155 | You can find in the [Project Releases](https://github.com/mathieucarbou/MycilaSafeBoot/releases) the list of available SafeBoot images, with the Python script to add to your build. 156 | 157 | ## How to build the SafeBoot firmware image 158 | 159 | Go inside `tools/SafeBoot` and run: 160 | 161 | ```bash 162 | > pio run -e esp32dev 163 | ``` 164 | 165 | If your board does not exist, you can specify it like this: 166 | 167 | ```bash 168 | > SAFEBOOT_BOARD=my-board pio run -e safeboot 169 | ``` 170 | 171 | `SAFEBOOT_BOARD` is the environment variable to specify the board to build the SafeBoot firmware for. 172 | 173 | At the end you should see these lines: 174 | 175 | ``` 176 | Firmware size valid: 619744 <= 655360 177 | SafeBoot firmware created: /Users/mat/Data/Workspace/me/MycilaSafeBoot/.pio/build/dev/safeboot.bin 178 | ``` 179 | 180 | ## SafeBoot Example 181 | 182 | Go inside `examples/App` and execute: 183 | 184 | ```bash 185 | > pio run 186 | ``` 187 | 188 | You should see at the end of the build something like: 189 | 190 | ``` 191 | Generating factory image for serial flashing 192 | Downloading SafeBoot image from https://github.com/mathieucarbou/MycilaSafeBoot/releases/download/v3.2.6/safeboot-esp32dev.bin 193 | Offset | File 194 | - 0x1000 | /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/bootloader.bin 195 | - 0x8000 | /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/partitions.bin 196 | - 0xe000 | /Users/mat/.platformio/packages/framework-arduinoespressif32@src-17df1753722b7b9e1913598420d4e038/tools/partitions/boot_app0.bin 197 | - 0x10000 | /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/safeboot.bin 198 | - 0xb0000 | /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/firmware.bin 199 | 200 | [...] 201 | 202 | Wrote 0x1451a0 bytes to file /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/firmware.factory.bin, ready to flash to offset 0x0 203 | Factory image generated: /Users/mat/Data/Workspace/me/MycilaSafeBoot/examples/App/.pio/build/esp32dev/firmware.factory.bin 204 | ``` 205 | 206 | the `factory.py` script generates a complete factory image named `firmware.factory.bin` with all this content. 207 | 208 | It can be downloaded from [https://github.com/mathieucarbou/MycilaSafeBoot/releases](https://github.com/mathieucarbou/MycilaSafeBoot/releases). 209 | 210 | Flash this factory image on an ESP32: 211 | 212 | ```bash 213 | esptool.py write_flash 0x0 .pio/build/esp32dev/firmware.factory.bin 214 | ``` 215 | 216 | Restart the ESP. 217 | The app loads, shows a button to restart in SafeBoot mode. 218 | After clicking on it, the ESP will reboot into SafeBoot mode. 219 | From there, you can access the web page to flash a new firmware, even from another application. 220 | 221 | ## How to reboot in SafeBoot mode from the app 222 | 223 | You can use [MycilaSystem](https://github.com/mathieucarbou/MycilaSystem): 224 | 225 | ```cpp 226 | #include 227 | 228 | espConnect.saveConfiguration(); // if you want to save ESPConnect settings for network 229 | Mycila::System::restartFactory("safeboot"); 230 | ``` 231 | 232 | or this custom code: 233 | 234 | ```cpp 235 | #include 236 | #include 237 | 238 | const esp_partition_t* partition = esp_partition_find_first(esp_partition_type_t::ESP_PARTITION_TYPE_APP, esp_partition_subtype_t::ESP_PARTITION_SUBTYPE_APP_FACTORY, partitionName); 239 | if (partition) { 240 | esp_ota_set_boot_partition(partition); 241 | esp_restart(); 242 | return true; 243 | } else { 244 | ESP_LOGE("SafeBoot", "SafeBoot partition not found"); 245 | return false; 246 | } 247 | ``` 248 | 249 | ## Configuration options to manage build size 250 | 251 | Squezing everything into the SafeBoot partition (655360 bytes only) is a tight fit especially on ethernet enabled boards. 252 | 253 | Disabling the logging capabilities saves about 12 kbytes in the final build. Just comment out `MYCILA_SAFEBOOT_LOGGING` in `platformio.ini`. 254 | 255 | ```ini 256 | ; -D MYCILA_SAFEBOOT_LOGGING 257 | ``` 258 | 259 | Disabling mDNS saves about 24 kbytes. Enable both [...]\_NO_DNS options in `platformio.ini` to reduce the build size: 260 | 261 | ```ini 262 | -D ESPCONNECT_NO_MDNS 263 | -D MYCILA_SAFEBOOT_NO_MDNS 264 | ``` 265 | 266 | ### Options matrix 267 | 268 | | Board | mDNS: on, logger: on | mDNS: on, logger: off | mDNS: off, logger: off | 269 | | -------------------- | -------------------- | --------------------- | ---------------------- | 270 | | denky_d4 | NOT SUPPORTED | OK | OK | 271 | | esp32-c3-devkitc-02 | OK | OK | OK | 272 | | esp32-c6-devkitc-1 | NOT SUPPORTED | NOT SUPPORTED | OK | 273 | | esp32-gateway | NOT SUPPORTED | OK | OK | 274 | | esp32-poe | NOT SUPPORTED | NOT SUPPORTED | OK | 275 | | esp32-poe-iso | NOT SUPPORTED | NOT SUPPORTED | OK | 276 | | esp32-s2-saola-1 | OK | OK | OK | 277 | | esp32-s3-devkitc-1 | OK | OK | OK | 278 | | esp32-solo1 | OK | OK | OK | 279 | | esp32dev | OK | OK | OK | 280 | | esp32s3box | OK | OK | OK | 281 | | lilygo-t-eth-lite-s3 | OK | OK | OK | 282 | | lolin_s2_mini | OK | OK | OK | 283 | | tinypico | NOT SUPPORTED | OK | OK | 284 | | wemos_d1_uno32 | OK | OK | OK | 285 | | wipy3 | NOT SUPPORTED | OK | OK | 286 | | wt32-eth01 | NOT SUPPORTED | NOT SUPPORTED | OK | 287 | 288 | ## Default board options 289 | 290 | | Board | mDNS | Logging | Ethernet | 291 | | :------------------- | :--: | :-----: | :------: | 292 | | denky_d4 | ✅ | ❌ | ❌ | 293 | | esp32-c3-devkitc-02 | ✅ | ✅ | ❌ | 294 | | esp32-c6-devkitc-1 | ❌ | ❌ | ❌ | 295 | | esp32-gateway | ✅ | ❌ | ✅ | 296 | | esp32-poe | ❌ | ❌ | ✅ | 297 | | esp32-poe-iso | ❌ | ❌ | ✅ | 298 | | esp32-s2-saola-1 | ✅ | ✅ | ❌ | 299 | | esp32-s3-devkitc-1 | ✅ | ✅ | ❌ | 300 | | esp32-solo1 | ✅ | ✅ | ❌ | 301 | | esp32dev | ✅ | ✅ | ❌ | 302 | | esp32s3box | ✅ | ✅ | ❌ | 303 | | lilygo-t-eth-lite-s3 | ✅ | ✅ | ✅ | 304 | | lolin_s2_mini | ✅ | ✅ | ❌ | 305 | | tinypico | ✅ | ❌ | ❌ | 306 | | wemos_d1_uno32 | ✅ | ✅ | ❌ | 307 | | wipy3 | ✅ | ❌ | ❌ | 308 | | wt32-eth01 | ❌ | ❌ | ✅ | 309 | 310 | ## How to OTA update firmware from PlatformIO 311 | 312 | First make sure you created an HTTP endpoint that can be called to restart the app in SafeBoot mode. 313 | See [How to reboot in SafeBoot mode from the app](#how-to-reboot-in-safeboot-mode-from-the-app). 314 | 315 | Then add to your PlatformIO `platformio.ini` file: 316 | 317 | ```ini 318 | board_build.partitions = partitions-4MB-safeboot.csv 319 | upload_protocol = espota 320 | ; set OTA upload port to the ip-address when not using mDNS 321 | upload_port = 192.168.125.99 322 | ; when mDNS is enabled, just point the upload to the hostname 323 | ; upload_port = MyAwesomeApp.local 324 | custom_safeboot_restart_path = /api/system/safeboot 325 | extra_scripts = 326 | tools/safeboot.py 327 | ``` 328 | 329 | The `safeboot.py` script can be downloaded from the release page: [https://github.com/mathieucarbou/MycilaSafeBoot/releases](https://github.com/mathieucarbou/MycilaSafeBoot/releases). The partition table `partitions-4MB-safeboot.csv` is found in the [example folder](https://github.com/mathieucarbou/MycilaSafeBoot/tree/main/examples/App_ESPConnect_OTA). 330 | 331 | - `upload_protocol = espota` tells PlatformIO to use Arduino OTA to upload the firmware 332 | - `upload_port` is the IP address or mDNS name of the ESP32 333 | - `custom_safeboot_restart_path` is the path to call to restart the app in SafeBoot mode 334 | 335 | Once done, just run a `pio run -t upload` or `pio run -t uploadfs` for example and you will see the app automatically restarting in SafeBoot mode, then upload will be achieved, then the ESP will be restarted with your new app. 336 | 337 | See `examples/App_ESPConnect_OTA` for an example. 338 | -------------------------------------------------------------------------------- /docs/safeboot-ota.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieucarbou/MycilaSafeBoot/fd4dda1bc3042581a8f550e6b57e71a2d6e57f98/docs/safeboot-ota.jpeg -------------------------------------------------------------------------------- /docs/safeboot-ssid.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieucarbou/MycilaSafeBoot/fd4dda1bc3042581a8f550e6b57e71a2d6e57f98/docs/safeboot-ssid.jpeg -------------------------------------------------------------------------------- /embed/website.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SafeBoot 7 | 289 | 290 | 291 | 292 |
293 |

SafeBoot Firmware Update

294 | 296 |
297 | Upload a firmware/file system image here, or use Over-the-Air (OTA) 298 | update in PlatformIO with Arduino OTA on port 3232. 299 |
300 |
301 |
302 | 303 |
304 |

Firmware

305 |
306 | 311 | 314 |
315 |

File System

316 |
317 | 318 |
319 | 329 | 332 | 333 |

Drag and drop here

334 |
or
click to select (.bin) file
335 |
336 | 337 |
338 |
Uploading file:
339 |
340 |
341 | 347 |
348 |
0 %
349 |
350 |
351 | 352 |
353 | 365 | 368 | 369 | 370 | 382 | 385 | 386 | 387 |
388 |
389 |
390 | 391 |
OK!
392 |
393 | 394 |
395 |
396 |
397 |
398 |
Chip:
399 |
Unknown
400 |
401 |
402 |
SafeBoot-Version:
403 |
Unknown
404 |
405 |
406 | 407 |
408 |
409 | 667 | 668 | 669 | -------------------------------------------------------------------------------- /examples/App/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.pio 3 | /.vscode 4 | /logs 5 | -------------------------------------------------------------------------------- /examples/App/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Mathieu Carbou 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 | -------------------------------------------------------------------------------- /examples/App/README.md: -------------------------------------------------------------------------------- 1 | # SafeBoot Example 2 | 3 | Please refer to the SafeBoot tool documentation 4 | -------------------------------------------------------------------------------- /examples/App/partitions-4MB-safeboot.csv: -------------------------------------------------------------------------------- 1 | # Name ,Type ,SubType ,Offset ,Size ,Flags 2 | nvs ,data ,nvs ,36K ,20K , 3 | otadata ,data ,ota ,56K ,8K , 4 | safeboot ,app ,factory ,64K ,640K , 5 | app ,app ,ota_0 ,704K ,3264K , 6 | spiff ,data ,spiffs ,3968K ,64K , 7 | coredump ,data ,coredump ,4032K ,64K , 8 | -------------------------------------------------------------------------------- /examples/App/platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; https://docs.platformio.org/page/projectconf.html 10 | 11 | [platformio] 12 | name = MyAwesomeApp 13 | default_envs = esp32dev 14 | 15 | [env] 16 | platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip 17 | framework = arduino 18 | monitor_filters = esp32_exception_decoder, log2file 19 | monitor_speed = 115200 20 | upload_protocol = esptool 21 | ; upload_protocol = espota 22 | ; upload_port = 192.168.4.1 23 | lib_compat_mode = strict 24 | lib_ldf_mode = chain 25 | lib_deps = 26 | ESP32Async/AsyncTCP @ 3.4.4 27 | ESP32Async/ESPAsyncWebServer @ 3.7.7 28 | mathieucarbou/MycilaSystem @ 4.1.0 29 | build_flags = 30 | -Wall -Wextra 31 | -std=c++17 32 | -std=gnu++17 33 | build_unflags = 34 | -std=gnu++11 35 | extra_scripts = post:../../tools/factory.py 36 | board_build.partitions = partitions-4MB-safeboot.csv 37 | board_build.app_partition_name = app 38 | # custom_safeboot_dir = ../.. 39 | ; custom_safeboot_file = safeboot-esp32dev.bin 40 | custom_safeboot_url = https://github.com/mathieucarbou/MycilaSafeBoot/releases/download/v3.2.6/safeboot-esp32dev.bin 41 | 42 | ; -------------------------------------------------------------------- 43 | ; ENVIRONMENTs 44 | ; -------------------------------------------------------------------- 45 | 46 | [env:esp32dev] 47 | board = esp32dev 48 | -------------------------------------------------------------------------------- /examples/App/src/main.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | /* 3 | * Copyright (C) 2023-2025 Mathieu Carbou 4 | */ 5 | #include 6 | #include 7 | #include 8 | 9 | AsyncWebServer webServer(80); 10 | 11 | String getEspID() { 12 | uint32_t chipId = 0; 13 | for (int i = 0; i < 17; i += 8) { 14 | chipId |= ((ESP.getEfuseMac() >> (40 - i)) & 0xff) << i; 15 | } 16 | String espId = String(chipId, HEX); 17 | espId.toUpperCase(); 18 | return espId; 19 | } 20 | 21 | void setup() { 22 | WiFi.mode(WIFI_AP); 23 | WiFi.softAP(String("MyAwesomeApp-") + getEspID()); 24 | 25 | webServer.on("/", HTTP_GET, [](AsyncWebServerRequest* request) { 26 | request->send(200, "text/html", "
"); 27 | }); 28 | 29 | webServer.on("/safeboot", HTTP_POST, [](AsyncWebServerRequest* request) { 30 | request->send(200, "text/plain", "Restarting in SafeBoot mode... Look for an Access Point named: SafeBoot-" + getEspID()); 31 | Mycila::System::restartFactory("safeboot"); 32 | }); 33 | 34 | webServer.begin(); 35 | } 36 | 37 | void loop() { 38 | delay(100); 39 | } 40 | -------------------------------------------------------------------------------- /examples/App_ESPConnect_OTA/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.pio 3 | /.vscode 4 | /logs 5 | -------------------------------------------------------------------------------- /examples/App_ESPConnect_OTA/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Mathieu Carbou 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 | -------------------------------------------------------------------------------- /examples/App_ESPConnect_OTA/README.md: -------------------------------------------------------------------------------- 1 | # SafeBoot Example 2 | 3 | Please refer to the SafeBoot tool documentation 4 | -------------------------------------------------------------------------------- /examples/App_ESPConnect_OTA/partitions-4MB-safeboot.csv: -------------------------------------------------------------------------------- 1 | # Name ,Type ,SubType ,Offset ,Size ,Flags 2 | nvs ,data ,nvs ,36K ,20K , 3 | otadata ,data ,ota ,56K ,8K , 4 | safeboot ,app ,factory ,64K ,640K , 5 | app ,app ,ota_0 ,704K ,3264K , 6 | spiff ,data ,spiffs ,3968K ,64K , 7 | coredump ,data ,coredump ,4032K ,64K , 8 | -------------------------------------------------------------------------------- /examples/App_ESPConnect_OTA/platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; https://docs.platformio.org/page/projectconf.html 10 | 11 | [platformio] 12 | name = MyAwesomeApp 13 | default_envs = esp32dev, lolin_s2_mini 14 | 15 | [env] 16 | platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip 17 | framework = arduino 18 | monitor_filters = esp32_exception_decoder, log2file 19 | monitor_speed = 115200 20 | upload_protocol = esptool 21 | ; After initial flashing of the [..].factory.bin, espota can be used for uploading the app 22 | ; upload_protocol = espota 23 | ; upload_port = MyAwesomeApp.local 24 | lib_compat_mode = strict 25 | lib_ldf_mode = chain 26 | lib_deps = 27 | ESP32Async/AsyncTCP @ 3.4.4 28 | ESP32Async/ESPAsyncWebServer @ 3.7.7 29 | mathieucarbou/MycilaESPConnect @ 10.2.2 30 | mathieucarbou/MycilaSystem @ 4.1.0 31 | build_flags = 32 | -D APP_NAME=\"MyAwesomeApp\" 33 | -Wall -Wextra 34 | -std=c++17 35 | -std=gnu++17 36 | build_unflags = 37 | -std=gnu++11 38 | board_build.partitions = partitions-4MB-safeboot.csv 39 | board_build.app_partition_name = app 40 | 41 | ; -------------------------------------------------------------------- 42 | ; ENVIRONMENTs 43 | ; -------------------------------------------------------------------- 44 | 45 | ; environment without OTA 46 | [env:esp32dev] 47 | board = esp32dev 48 | extra_scripts = post:../../tools/factory.py 49 | custom_safeboot_url = https://github.com/mathieucarbou/MycilaSafeBoot/releases/download/v3.2.6/safeboot-esp32dev.bin 50 | ; custom_safeboot_dir = ../.. 51 | ; custom_safeboot_file = safeboot-esp32dev.bin 52 | ; custom_safeboot_restart_path = /api/system/safeboot 53 | 54 | ; After initial flashing of the [..].factory.bin, espota can be used for uploading the app 55 | [env:esp32dev-ota] 56 | board = esp32dev 57 | upload_protocol = espota 58 | extra_scripts = ../../tools/safeboot.py 59 | 60 | ; environment without OTA 61 | [env:lolin_s2_mini] 62 | board = lolin_s2_mini 63 | extra_scripts = post:../../tools/factory.py 64 | custom_safeboot_url = https://github.com/mathieucarbou/MycilaSafeBoot/releases/download/v3.2.6/safeboot-lolin_s2_mini.bin 65 | ; custom_safeboot_dir = ../.. 66 | ; custom_safeboot_file = safeboot-esp32dev.bin 67 | ; custom_safeboot_restart_path = /api/system/safeboot 68 | 69 | ; After initial flashing of the [..].factory.bin, espota can be used for uploading the app 70 | [env:lolin_s2_mini-ota] 71 | board = lolin_s2_mini 72 | upload_protocol = espota 73 | ; enter mdns-name of the target here 74 | upload_port = MyAwesomeApp.local 75 | extra_scripts = ../../tools/safeboot.py 76 | -------------------------------------------------------------------------------- /examples/App_ESPConnect_OTA/src/main.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | /* 3 | * Copyright (C) 2023-2025 Mathieu Carbou 4 | */ 5 | #include 6 | #include 7 | 8 | AsyncWebServer webServer(80); 9 | Mycila::ESPConnect espConnect(webServer); 10 | 11 | static String webpage = 12 | R"(

MyAwsomeApp

13 |

Built on ${D} at ${T}


14 |
15 | 16 |
)"; 17 | 18 | void setup() { 19 | Serial.begin(115200); 20 | #if ARDUINO_USB_CDC_ON_BOOT 21 | Serial.setTxTimeoutMs(0); 22 | delay(100); 23 | #else 24 | while (!Serial) 25 | yield(); 26 | #endif 27 | 28 | // update the webpage with the compile date and time 29 | webpage.replace("${D}", __DATE__); 30 | webpage.replace("${T}", __TIME__); 31 | 32 | // reboot esp into SafeBoot 33 | webServer.on("/safeboot", HTTP_POST, [](AsyncWebServerRequest* request) { 34 | Serial.println("Restarting in SafeBoot mode..."); 35 | request->send(200, "text/html", "Restarting in SafeBoot mode..."); 36 | Mycila::System::restartFactory("safeboot", 250); 37 | }); 38 | 39 | // network state listener is required here in async mode 40 | espConnect.listen([](__unused Mycila::ESPConnect::State previous, Mycila::ESPConnect::State state) { 41 | switch (state) { 42 | case Mycila::ESPConnect::State::NETWORK_CONNECTED: 43 | case Mycila::ESPConnect::State::AP_STARTED: 44 | // serve your home page here 45 | webServer.on("/", HTTP_GET, [&](AsyncWebServerRequest* request) { 46 | request->send(200, "text/html", webpage.c_str()); 47 | }) 48 | .setFilter([](__unused AsyncWebServerRequest* request) { return espConnect.getState() != Mycila::ESPConnect::State::PORTAL_STARTED; }); 49 | webServer.begin(); 50 | break; 51 | 52 | case Mycila::ESPConnect::State::NETWORK_DISCONNECTED: 53 | webServer.end(); 54 | break; 55 | 56 | default: 57 | break; 58 | } 59 | }); 60 | 61 | espConnect.setAutoRestart(true); 62 | espConnect.setBlocking(false); 63 | 64 | Serial.println("====> Trying to connect to saved WiFi or will start portal in the background..."); 65 | 66 | espConnect.begin(APP_NAME, APP_NAME); 67 | 68 | Serial.println("====> setup() completed..."); 69 | } 70 | 71 | void loop() { 72 | espConnect.loop(); 73 | } 74 | -------------------------------------------------------------------------------- /lib/ArduinoOTA/library.properties: -------------------------------------------------------------------------------- 1 | name=ArduinoOTA 2 | version=3.1.3 3 | author=Ivan Grokhotkov and Hristo Gochkov 4 | maintainer=Hristo Gochkov 5 | sentence=Enables Over The Air upgrades, via wifi and espota.py UDP request/TCP download. 6 | paragraph=With this library you can enable your sketch to be upgraded over network. Includes mdns announces to get discovered by the arduino IDE. 7 | category=Communication 8 | url= 9 | architectures=esp32 10 | -------------------------------------------------------------------------------- /lib/ArduinoOTA/src/ArduinoOTA.cpp: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Espressif Systems (Shanghai) PTE LTD 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #ifndef LWIP_OPEN_SRC 16 | #define LWIP_OPEN_SRC 17 | #endif 18 | #include "ArduinoOTA.h" 19 | #include "NetworkClient.h" 20 | #ifndef MYCILA_SAFEBOOT_NO_MDNS 21 | #include "ESPmDNS.h" 22 | #endif 23 | #include "Update.h" 24 | 25 | // #define OTA_DEBUG Serial 26 | 27 | ArduinoOTAClass::ArduinoOTAClass() : _initialized(false), _state(OTA_IDLE), _size(0), _cmd(0) {} 28 | 29 | ArduinoOTAClass::~ArduinoOTAClass() { 30 | end(); 31 | } 32 | 33 | ArduinoOTAClass& ArduinoOTAClass::setHostname(const char* hostname) { 34 | _hostname = hostname; 35 | return *this; 36 | } 37 | 38 | void ArduinoOTAClass::begin() { 39 | if (_initialized) { 40 | return; 41 | } 42 | 43 | if (!_udp_ota.begin(3232)) { 44 | return; 45 | } 46 | #ifndef MYCILA_SAFEBOOT_NO_MDNS 47 | #ifdef CONFIG_MDNS_MAX_INTERFACES 48 | MDNS.begin(_hostname); 49 | MDNS.enableArduino(3232, false); 50 | #endif 51 | #endif 52 | _initialized = true; 53 | _state = OTA_IDLE; 54 | } 55 | 56 | int ArduinoOTAClass::parseInt() { 57 | char data[INT_BUFFER_SIZE]; 58 | uint8_t index = 0; 59 | char value; 60 | while (_udp_ota.peek() == ' ') { 61 | _udp_ota.read(); 62 | } 63 | while (index < INT_BUFFER_SIZE - 1) { 64 | value = _udp_ota.peek(); 65 | if (value < '0' || value > '9') { 66 | data[index++] = '\0'; 67 | return atoi(data); 68 | } 69 | data[index++] = _udp_ota.read(); 70 | } 71 | return 0; 72 | } 73 | 74 | String ArduinoOTAClass::readStringUntil(char end) { 75 | String res = ""; 76 | int value; 77 | while (true) { 78 | value = _udp_ota.read(); 79 | if (value <= 0 || value == end) { 80 | return res; 81 | } 82 | res += (char)value; 83 | } 84 | return res; 85 | } 86 | 87 | void ArduinoOTAClass::_onRx() { 88 | if (_state == OTA_IDLE) { 89 | int cmd = parseInt(); 90 | if (cmd != U_FLASH && cmd != U_SPIFFS) { 91 | return; 92 | } 93 | _cmd = cmd; 94 | _ota_port = parseInt(); 95 | _size = parseInt(); 96 | _udp_ota.read(); 97 | _md5 = readStringUntil('\n'); 98 | _md5.trim(); 99 | if (_md5.length() != 32) { 100 | return; 101 | } 102 | 103 | _udp_ota.beginPacket(_udp_ota.remoteIP(), _udp_ota.remotePort()); 104 | _udp_ota.print("OK"); 105 | _udp_ota.endPacket(); 106 | _ota_ip = _udp_ota.remoteIP(); 107 | _state = OTA_RUNUPDATE; 108 | } 109 | } 110 | 111 | void ArduinoOTAClass::_runUpdate() { 112 | if (!Update.begin(_size, _cmd, -1, LOW, nullptr)) { 113 | _state = OTA_IDLE; 114 | return; 115 | } 116 | Update.setMD5(_md5.c_str()); 117 | NetworkClient client; 118 | if (!client.connect(_ota_ip, _ota_port)) { 119 | _state = OTA_IDLE; 120 | } 121 | 122 | uint32_t written = 0, total = 0, tried = 0; 123 | 124 | while (!Update.isFinished() && client.connected()) { 125 | size_t waited = 1000; 126 | size_t available = client.available(); 127 | while (!available && waited) { 128 | delay(1); 129 | waited -= 1; 130 | available = client.available(); 131 | } 132 | if (!waited) { 133 | if (written && tried++ < 3) { 134 | if (!client.printf("%lu", written)) { 135 | _state = OTA_IDLE; 136 | break; 137 | } 138 | continue; 139 | } 140 | _state = OTA_IDLE; 141 | Update.abort(); 142 | return; 143 | } 144 | if (!available) { 145 | _state = OTA_IDLE; 146 | break; 147 | } 148 | tried = 0; 149 | static uint8_t buf[1460]; 150 | if (available > 1460) { 151 | available = 1460; 152 | } 153 | size_t r = client.read(buf, available); 154 | if (r != available) { 155 | if ((int32_t)r < 0) { 156 | delay(1); 157 | continue; // let's not try to write 4 gigabytes when client.read returns -1 158 | } 159 | } 160 | 161 | written = Update.write(buf, r); 162 | if (written > 0) { 163 | client.printf("%lu", written); 164 | total += written; 165 | } 166 | } 167 | 168 | if (Update.end()) { 169 | client.print("OK"); 170 | client.stop(); 171 | delay(10); 172 | // let serial/network finish tasks that might be given in _end_callback 173 | delay(100); 174 | ESP.restart(); 175 | } else { 176 | Update.printError(client); 177 | client.stop(); 178 | delay(10); 179 | _state = OTA_IDLE; 180 | } 181 | } 182 | 183 | void ArduinoOTAClass::end() { 184 | _initialized = false; 185 | _udp_ota.stop(); 186 | #ifndef MYCILA_SAFEBOOT_NO_MDNS 187 | #ifdef CONFIG_MDNS_MAX_INTERFACES 188 | MDNS.end(); 189 | #endif 190 | #endif 191 | _state = OTA_IDLE; 192 | } 193 | 194 | void ArduinoOTAClass::handle() { 195 | if (!_initialized) { 196 | return; 197 | } 198 | if (_state == OTA_RUNUPDATE) { 199 | _runUpdate(); 200 | _state = OTA_IDLE; 201 | } 202 | if (_udp_ota.parsePacket()) { 203 | _onRx(); 204 | } 205 | _udp_ota.clear(); // always clear, even zero length packets must be cleared. 206 | } 207 | 208 | int ArduinoOTAClass::getCommand() { 209 | return _cmd; 210 | } 211 | 212 | #if !defined(NO_GLOBAL_INSTANCES) && !defined(NO_GLOBAL_ARDUINOOTA) 213 | ArduinoOTAClass ArduinoOTA; 214 | #endif 215 | -------------------------------------------------------------------------------- /lib/ArduinoOTA/src/ArduinoOTA.h: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Espressif Systems (Shanghai) PTE LTD 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #ifndef __ARDUINO_OTA_H 16 | #define __ARDUINO_OTA_H 17 | 18 | #include "Network.h" 19 | #include "Update.h" 20 | 21 | #define INT_BUFFER_SIZE 16 22 | 23 | typedef enum { 24 | OTA_IDLE, 25 | OTA_RUNUPDATE 26 | } ota_state_t; 27 | 28 | typedef enum { 29 | OTA_AUTH_ERROR, 30 | OTA_BEGIN_ERROR, 31 | OTA_CONNECT_ERROR, 32 | OTA_RECEIVE_ERROR, 33 | OTA_END_ERROR 34 | } ota_error_t; 35 | 36 | class ArduinoOTAClass { 37 | public: 38 | ArduinoOTAClass(); 39 | ~ArduinoOTAClass(); 40 | 41 | // Sets the device hostname. Default esp32-xxxxxx 42 | ArduinoOTAClass& setHostname(const char* hostname); 43 | 44 | // Starts the ArduinoOTA service 45 | void begin(); 46 | 47 | // Ends the ArduinoOTA service 48 | void end(); 49 | 50 | // Call this in loop() to run the service 51 | void handle(); 52 | 53 | // Gets update command type after OTA has started. Either U_FLASH or U_SPIFFS 54 | int getCommand(); 55 | 56 | void setTimeout(int timeoutInMillis); 57 | 58 | private: 59 | const char* _hostname; 60 | String _nonce; 61 | NetworkUDP _udp_ota; 62 | bool _initialized; 63 | ota_state_t _state; 64 | int _size; 65 | int _cmd; 66 | int _ota_port; 67 | IPAddress _ota_ip; 68 | String _md5; 69 | 70 | void _runUpdate(void); 71 | void _onRx(void); 72 | int parseInt(void); 73 | String readStringUntil(char end); 74 | }; 75 | 76 | #if !defined(NO_GLOBAL_INSTANCES) && !defined(NO_GLOBAL_ARDUINOOTA) 77 | extern ArduinoOTAClass ArduinoOTA; 78 | #endif 79 | 80 | #endif /* __ARDUINO_OTA_H */ 81 | -------------------------------------------------------------------------------- /partitions.csv: -------------------------------------------------------------------------------- 1 | # Name ,Type ,SubType ,Offset ,Size ,Flags 2 | nvs ,data ,nvs ,36K ,20K , 3 | otadata ,data ,ota ,56K ,8K , 4 | safeboot ,app ,ota_0 ,64K ,640K , 5 | app ,app ,ota_1 ,704K ,640K , 6 | fs ,data ,spiffs ,1344K ,640K , 7 | -------------------------------------------------------------------------------- /platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; https://docs.platformio.org/page/projectconf.html 10 | 11 | [platformio] 12 | name = SafeBoot 13 | default_envs = denky_d4, esp32-c3-devkitc-02, esp32-c6-devkitc-1, esp32-gateway, esp32-poe-iso, esp32-poe, esp32-s2-saola-1, esp32-s3-devkitc-1, esp32-solo1, esp32dev, esp32s3box, lilygo-t-eth-lite-s3, lolin_s2_mini, tinypico, wemos_d1_uno32, wipy3, wt32-eth01 14 | 15 | [env] 16 | framework = arduino 17 | platform = https://github.com/tasmota/platform-espressif32/releases/download/2025.03.30/platform-espressif32.zip 18 | monitor_filters = esp32_exception_decoder, log2file 19 | monitor_speed = 115200 20 | ; upload_protocol = esptool 21 | ; upload_protocol = espota 22 | ; upload_port = 192.168.4.1 23 | lib_compat_mode = strict 24 | lib_ldf_mode = chain 25 | lib_deps = 26 | ESPmDNS 27 | Update 28 | WebServer 29 | mathieucarbou/MycilaESPConnect @ 10.2.2 30 | ; https://github.com/mathieucarbou/MycilaESPConnect 31 | lib_ignore = 32 | ArduinoJson 33 | AsyncTCP 34 | ESPAsyncWebServer 35 | build_flags = 36 | ; ESPConnect 37 | -D ESPCONNECT_NO_CAPTIVE_PORTAL 38 | -D ESPCONNECT_NO_STD_STRING 39 | -D ESPCONNECT_NO_LOGGING 40 | ; Arduino 41 | -D HTTPCLIENT_NOSECURE 42 | -D UPDATE_NOCRYPT 43 | ; ------------------------------ 44 | ; mDNS ON by default 45 | ; -D ESPCONNECT_NO_MDNS 46 | ; -D MYCILA_SAFEBOOT_NO_MDNS 47 | ; ------------------------------ 48 | ; Logging OFF by default 49 | ; -D MYCILA_SAFEBOOT_LOGGING 50 | ; ------------------------------ 51 | ; C++ 52 | -Wall -Wextra 53 | -std=c++17 54 | -std=gnu++17 55 | -Os 56 | -flto=auto 57 | build_unflags = 58 | -std=gnu++11 59 | -fno-lto 60 | build_type = release 61 | board_build.partitions = partitions.csv 62 | extra_scripts = 63 | pre:tools/version.py 64 | pre:tools/website.py 65 | post:tools/safeboot_size_check.py 66 | board_build.embed_files = 67 | .pio/embed/website.html.gz 68 | 69 | ; -------------------------------------------------------------------- 70 | ; ENVIRONMENTs 71 | ; -------------------------------------------------------------------- 72 | 73 | [env:safeboot] 74 | board = ${sysenv.SAFEBOOT_BOARD} 75 | 76 | [env:denky_d4] 77 | board = denky_d4 78 | 79 | [env:esp32-c3-devkitc-02] 80 | board = esp32-c3-devkitc-02 81 | build_flags = 82 | ${env.build_flags} 83 | -D MYCILA_SAFEBOOT_LOGGING 84 | 85 | [env:esp32-c6-devkitc-1] 86 | board = esp32-c6-devkitc-1 87 | build_flags = 88 | ${env.build_flags} 89 | -D ESPCONNECT_NO_MDNS 90 | -D MYCILA_SAFEBOOT_NO_MDNS 91 | 92 | [env:esp32-gateway] 93 | board = esp32-gateway 94 | build_flags = 95 | ${env.build_flags} 96 | -D ESPCONNECT_ETH_RESET_ON_START 97 | -D ESPCONNECT_ETH_SUPPORT 98 | 99 | [env:esp32-poe-iso] 100 | board = esp32-poe-iso 101 | build_flags = 102 | ${env.build_flags} 103 | -D ESPCONNECT_NO_MDNS 104 | -D MYCILA_SAFEBOOT_NO_MDNS 105 | -D ESPCONNECT_ETH_RESET_ON_START 106 | -D ESPCONNECT_ETH_SUPPORT 107 | 108 | [env:esp32-poe] 109 | board = esp32-poe 110 | build_flags = 111 | ${env.build_flags} 112 | -D ESPCONNECT_NO_MDNS 113 | -D MYCILA_SAFEBOOT_NO_MDNS 114 | -D ESPCONNECT_ETH_RESET_ON_START 115 | -D ESPCONNECT_ETH_SUPPORT 116 | 117 | [env:esp32-s2-saola-1] 118 | board = esp32-s2-saola-1 119 | build_flags = 120 | ${env.build_flags} 121 | -D MYCILA_SAFEBOOT_LOGGING 122 | 123 | [env:esp32-s3-devkitc-1] 124 | board = esp32-s3-devkitc-1 125 | build_flags = 126 | ${env.build_flags} 127 | -D MYCILA_SAFEBOOT_LOGGING 128 | 129 | [env:esp32-solo1] 130 | board = esp32-solo1 131 | build_flags = 132 | ${env.build_flags} 133 | -D MYCILA_SAFEBOOT_LOGGING 134 | 135 | [env:esp32dev] 136 | board = esp32dev 137 | build_flags = 138 | ${env.build_flags} 139 | -D MYCILA_SAFEBOOT_LOGGING 140 | 141 | [env:esp32s3box] 142 | board = esp32s3box 143 | build_flags = 144 | ${env.build_flags} 145 | -D MYCILA_SAFEBOOT_LOGGING 146 | 147 | [env:lilygo-t-eth-lite-s3] 148 | board = esp32s3box 149 | build_flags = 150 | ${env.build_flags} 151 | -D MYCILA_SAFEBOOT_LOGGING 152 | -D ESPCONNECT_ETH_SUPPORT 153 | -D ETH_PHY_ADDR=1 154 | -D ETH_PHY_CS=9 155 | -D ETH_PHY_IRQ=13 156 | -D ETH_PHY_RST=14 157 | -D ETH_PHY_SPI_MISO=11 158 | -D ETH_PHY_SPI_MOSI=12 159 | -D ETH_PHY_SPI_SCK=10 160 | -D ETH_PHY_TYPE=ETH_PHY_W5500 161 | 162 | [env:lolin_s2_mini] 163 | board = lolin_s2_mini 164 | build_flags = 165 | ${env.build_flags} 166 | -D MYCILA_SAFEBOOT_LOGGING 167 | 168 | [env:tinypico] 169 | board = tinypico 170 | 171 | [env:wemos_d1_uno32] 172 | board = wemos_d1_uno32 173 | build_flags = 174 | ${env.build_flags} 175 | -D MYCILA_SAFEBOOT_LOGGING 176 | 177 | [env:wipy3] 178 | board = wipy3 179 | 180 | [env:wt32-eth01] 181 | board = wt32-eth01 182 | build_flags = 183 | ${env.build_flags} 184 | -D ESPCONNECT_NO_MDNS 185 | -D MYCILA_SAFEBOOT_NO_MDNS 186 | -D ESPCONNECT_ETH_RESET_ON_START 187 | -D ESPCONNECT_ETH_SUPPORT 188 | -D ETH_PHY_ADDR=1 189 | -D ETH_PHY_POWER=16 190 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | /* 3 | * Copyright (C) 2023-2025 Mathieu Carbou 4 | */ 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | #include 13 | #include 14 | 15 | #ifndef MYCILA_SAFEBOOT_NO_MDNS 16 | #include 17 | #endif 18 | 19 | #ifdef MYCILA_SAFEBOOT_LOGGING 20 | #define LOG(format, ...) Serial.printf(format, ##__VA_ARGS__) 21 | #else 22 | #define LOG(format, ...) 23 | #endif 24 | 25 | extern const char* __COMPILED_APP_VERSION__; 26 | extern const uint8_t update_html_start[] asm("_binary__pio_embed_website_html_gz_start"); 27 | extern const uint8_t update_html_end[] asm("_binary__pio_embed_website_html_gz_end"); 28 | static const char* successResponse = "Update Success! Rebooting..."; 29 | static const char* cancelResponse = "Rebooting..."; 30 | 31 | static WebServer webServer(80); 32 | static Mycila::ESPConnect espConnect; 33 | static Mycila::ESPConnect::Config espConnectConfig; 34 | static StreamString updaterError; 35 | 36 | static String getChipIDStr() { 37 | uint32_t chipId = 0; 38 | for (int i = 0; i < 17; i += 8) { 39 | chipId |= ((ESP.getEfuseMac() >> (40 - i)) & 0xff) << i; 40 | } 41 | String espId = String(chipId, HEX); 42 | espId.toUpperCase(); 43 | return espId; 44 | } 45 | 46 | static void start_web_server() { 47 | webServer.onNotFound([]() { 48 | webServer.sendHeader("Location", "/"); 49 | webServer.send(302, "text/plain", ""); 50 | }); 51 | 52 | webServer.on("/chipspecs", HTTP_GET, [&]() { 53 | String chipSpecs = ESP.getChipModel(); 54 | chipSpecs += " (" + String(ESP.getFlashChipSize() >> 20) + " MB)"; 55 | webServer.send(200, "text/plain", chipSpecs.c_str()); 56 | }); 57 | 58 | webServer.on("/sbversion", HTTP_GET, [&]() { 59 | webServer.send(200, "text/plain", __COMPILED_APP_VERSION__); 60 | }); 61 | 62 | webServer.on( 63 | "/cancel", 64 | HTTP_POST, 65 | [&]() { 66 | webServer.send(200, "text/plain", cancelResponse); 67 | webServer.client().stop(); 68 | delay(1000); 69 | ESP.restart(); 70 | }, 71 | [&]() { 72 | }); 73 | 74 | webServer.on("/", HTTP_GET, [&]() { 75 | webServer.sendHeader("Content-Encoding", "gzip"); 76 | webServer.send_P(200, "text/html", reinterpret_cast(update_html_start), update_html_end - update_html_start); 77 | }); 78 | 79 | webServer.on( 80 | "/", 81 | HTTP_POST, 82 | [&]() { 83 | if (Update.hasError()) { 84 | webServer.send(500, "text/plain", "Update error: " + updaterError); 85 | } else { 86 | webServer.client().setNoDelay(true); 87 | webServer.send(200, "text/plain", successResponse); 88 | webServer.client().stop(); 89 | delay(1000); 90 | ESP.restart(); 91 | } }, 92 | [&]() { 93 | // handler for the file upload, gets the sketch bytes, and writes 94 | // them through the Update object 95 | HTTPUpload& upload = webServer.upload(); 96 | 97 | if (upload.status == UPLOAD_FILE_START) { 98 | updaterError.clear(); 99 | int otaMode = webServer.hasArg("mode") && webServer.arg("mode") == "1" ? U_SPIFFS : U_FLASH; 100 | LOG("Mode: %d\n", otaMode); 101 | if (!Update.begin(UPDATE_SIZE_UNKNOWN, otaMode)) { // start with max available size 102 | Update.printError(updaterError); 103 | } 104 | } else if (upload.status == UPLOAD_FILE_WRITE && !updaterError.length()) { 105 | if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { 106 | Update.printError(updaterError); 107 | } 108 | } else if (upload.status == UPLOAD_FILE_END && !updaterError.length()) { 109 | if (!Update.end(true)) { // true to set the size to the current progress 110 | Update.printError(updaterError); 111 | } 112 | } else if (upload.status == UPLOAD_FILE_ABORTED) { 113 | Update.end(); 114 | } 115 | }); 116 | 117 | webServer.begin(); 118 | 119 | #ifndef MYCILA_SAFEBOOT_NO_MDNS 120 | MDNS.addService("http", "tcp", 80); 121 | #endif 122 | 123 | LOG("Web Server started\n"); 124 | } 125 | 126 | static void set_next_partition_to_boot() { 127 | const esp_partition_t* partition = esp_partition_find_first(esp_partition_type_t::ESP_PARTITION_TYPE_APP, esp_partition_subtype_t::ESP_PARTITION_SUBTYPE_APP_OTA_0, nullptr); 128 | if (partition) { 129 | esp_ota_set_boot_partition(partition); 130 | } 131 | } 132 | 133 | static void start_network_manager() { 134 | // load ESPConnect configuration 135 | espConnect.loadConfiguration(espConnectConfig); 136 | espConnect.setBlocking(true); 137 | espConnect.setAutoRestart(false); 138 | 139 | // reuse a potentially set hostname from main app, or set a default one 140 | if (!espConnectConfig.hostname.length()) { 141 | espConnectConfig.hostname = "SafeBoot-" + getChipIDStr(); 142 | } 143 | 144 | // If the passed config is to be in AP mode, or has a SSID that's fine. 145 | // If the passed config is empty, we need to check if the board supports ETH. 146 | // - For boards relying only on Wifi, if a SSID is not set and AP is not set (config empty), then we need to go to AP mode. 147 | // - For boards supporting Ethernet, we do not know if an Ethernet cable is plugged, so we cannot directly start AP mode, because the config might be no AP mode and no SSID. 148 | // So we will start, wait for connect timeout (20 seconds), to get DHCP address from ETH, and if failed, we start in AP mode 149 | if (!espConnectConfig.apMode && !espConnectConfig.wifiSSID.length()) { 150 | #ifdef ESPCONNECT_ETH_SUPPORT 151 | espConnect.setCaptivePortalTimeout(20); 152 | #else 153 | espConnectConfig.apMode = true; 154 | #endif 155 | } 156 | 157 | espConnect.listen([](Mycila::ESPConnect::State previous, Mycila::ESPConnect::State state) { 158 | if (state == Mycila::ESPConnect::State::NETWORK_TIMEOUT) { 159 | LOG("Connect timeout! Starting AP mode...\n"); 160 | // if ETH DHCP times out, we start AP mode 161 | espConnectConfig.apMode = true; 162 | espConnect.setConfig(espConnectConfig); 163 | } 164 | }); 165 | 166 | // show config 167 | LOG("Hostname: %s\n", espConnectConfig.hostname.c_str()); 168 | if (espConnectConfig.apMode) { 169 | LOG("AP: %s\n", espConnectConfig.hostname.c_str()); 170 | } else if (espConnectConfig.wifiSSID.length()) { 171 | LOG("SSID: %s\n", espConnectConfig.wifiSSID.c_str()); 172 | LOG("BSSID: %s\n", espConnectConfig.wifiBSSID.c_str()); 173 | } 174 | 175 | // connect... 176 | espConnect.begin(espConnectConfig.hostname.c_str(), "", espConnectConfig); 177 | LOG("IP: %s\n", espConnect.getIPAddress().toString().c_str()); 178 | } 179 | 180 | static void start_mdns() { 181 | #ifndef MYCILA_SAFEBOOT_NO_MDNS 182 | MDNS.begin(espConnectConfig.hostname.c_str()); 183 | LOG("mDNS started\n"); 184 | #endif 185 | } 186 | 187 | static void start_arduino_ota() { 188 | ArduinoOTA.setHostname(espConnectConfig.hostname.c_str()); 189 | ArduinoOTA.begin(); 190 | LOG("OTA Server started on port 3232\n"); 191 | } 192 | 193 | void setup() { 194 | #ifdef MYCILA_SAFEBOOT_LOGGING 195 | Serial.begin(115200); 196 | #if ARDUINO_USB_CDC_ON_BOOT 197 | Serial.setTxTimeoutMs(0); 198 | delay(100); 199 | #else 200 | while (!Serial) 201 | yield(); 202 | #endif 203 | #endif 204 | 205 | LOG("Version: %s\n", __COMPILED_APP_VERSION__); 206 | set_next_partition_to_boot(); 207 | start_network_manager(); 208 | start_mdns(); 209 | start_web_server(); 210 | start_arduino_ota(); 211 | } 212 | 213 | void loop() { 214 | webServer.handleClient(); 215 | ArduinoOTA.handle(); 216 | } 217 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.pio 3 | /.vscode 4 | /logs 5 | -------------------------------------------------------------------------------- /test/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Mathieu Carbou 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 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # SafeBoot Example 2 | 3 | Please refer to the SafeBoot tool documentation 4 | -------------------------------------------------------------------------------- /test/partitions-4MB-safeboot.csv: -------------------------------------------------------------------------------- 1 | # Name ,Type ,SubType ,Offset ,Size ,Flags 2 | nvs ,data ,nvs ,36K ,20K , 3 | otadata ,data ,ota ,56K ,8K , 4 | safeboot ,app ,factory ,64K ,640K , 5 | app ,app ,ota_0 ,704K ,3264K , 6 | spiff ,data ,spiffs ,3968K ,64K , 7 | coredump ,data ,coredump ,4032K ,64K , 8 | -------------------------------------------------------------------------------- /test/platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; https://docs.platformio.org/page/projectconf.html 10 | 11 | [platformio] 12 | name = MyAwesomeApp 13 | default_envs = esp32dev 14 | 15 | [env] 16 | platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip 17 | framework = arduino 18 | monitor_filters = esp32_exception_decoder, log2file 19 | monitor_speed = 115200 20 | upload_protocol = esptool 21 | ; upload_protocol = espota 22 | ; upload_port = 192.168.4.1 23 | lib_compat_mode = strict 24 | lib_ldf_mode = chain 25 | lib_deps = 26 | ESP32Async/AsyncTCP @ 3.4.4 27 | ESP32Async/ESPAsyncWebServer @ 3.7.7 28 | mathieucarbou/MycilaSystem @ 4.1.0 29 | build_flags = 30 | -Wall -Wextra 31 | -std=c++17 32 | -std=gnu++17 33 | build_unflags = 34 | -std=gnu++11 35 | extra_scripts = post:../tools/factory.py 36 | board_build.partitions = partitions-4MB-safeboot.csv 37 | board_build.app_partition_name = app 38 | 39 | ; -------------------------------------------------------------------- 40 | ; ENVIRONMENTs 41 | ; -------------------------------------------------------------------- 42 | 43 | [env:esp32dev] 44 | board = esp32dev 45 | custom_safeboot_dir = .. 46 | 47 | [env:esp32-solo1] 48 | board = esp32-solo1 49 | custom_safeboot_dir = .. 50 | 51 | [env:ci] 52 | board = ${sysenv.BOARD} 53 | custom_safeboot_file = ${sysenv.SAFEBOOT_IMAGE} 54 | custom_component_remove = espressif/esp_hosted 55 | espressif/esp_wifi_remote 56 | espressif/esp-dsp 57 | espressif/network_provisioning 58 | espressif/esp-zboss-lib 59 | espressif/esp-zigbee-lib 60 | espressif/esp_rainmaker 61 | espressif/rmaker_common 62 | espressif/esp_insights 63 | espressif/esp_diag_data_store 64 | espressif/esp_diagnostics 65 | espressif/cbor 66 | espressif/qrcode 67 | espressif/esp-sr 68 | espressif/libsodium 69 | espressif/esp-modbus 70 | chmorgan/esp-libhelix-mp3 71 | espressif/esp32-camera 72 | -------------------------------------------------------------------------------- /test/src/main.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | /* 3 | * Copyright (C) 2023-2025 Mathieu Carbou 4 | */ 5 | #include 6 | #include 7 | #include 8 | 9 | AsyncWebServer webServer(80); 10 | 11 | String getEspID() { 12 | uint32_t chipId = 0; 13 | for (int i = 0; i < 17; i += 8) { 14 | chipId |= ((ESP.getEfuseMac() >> (40 - i)) & 0xff) << i; 15 | } 16 | String espId = String(chipId, HEX); 17 | espId.toUpperCase(); 18 | return espId; 19 | } 20 | 21 | void setup() { 22 | WiFi.mode(WIFI_AP); 23 | WiFi.softAP(String("MyAwesomeApp-") + getEspID()); 24 | 25 | webServer.on("/", HTTP_GET, [](AsyncWebServerRequest* request) { 26 | request->send(200, "text/html", "
"); 27 | }); 28 | 29 | webServer.on("/safeboot", HTTP_POST, [](AsyncWebServerRequest* request) { 30 | request->send(200, "text/plain", "Restarting in SafeBoot mode... Look for an Access Point named: SafeBoot-" + getEspID()); 31 | Mycila::System::restartFactory("safeboot"); 32 | }); 33 | 34 | webServer.begin(); 35 | } 36 | 37 | void loop() { 38 | delay(100); 39 | } 40 | -------------------------------------------------------------------------------- /tools/factory.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | # 3 | # Copyright (C) 2023-2025 Mathieu Carbou 4 | # 5 | Import("env") 6 | 7 | import sys 8 | import os 9 | import requests 10 | from os.path import join, getsize 11 | 12 | sys.path.append(join(env.PioPlatform().get_package_dir("tool-esptoolpy"))) 13 | import esptool 14 | 15 | # print(env.Dump()) 16 | 17 | quiet = False 18 | 19 | 20 | def status(msg): 21 | """Print status message to stderr""" 22 | if not quiet: 23 | critical(msg) 24 | 25 | 26 | def critical(msg): 27 | """Print critical message to stderr""" 28 | sys.stderr.write("factory.py: ") 29 | sys.stderr.write(msg) 30 | sys.stderr.write("\n") 31 | 32 | 33 | def generateFactooryImage(source, target, env): 34 | status("Generating factory image for serial flashing") 35 | 36 | app_offset = 0x10000 37 | app_image = env.subst("$BUILD_DIR/${PROGNAME}.bin") 38 | 39 | # Set fs_offset = 0 to disable LittleFS image generation 40 | # Set fs_offset to the correct offset from the partition to generate a LittleFS image 41 | fs_offset = 0 42 | fs_image = env.subst("$BUILD_DIR/littlefs.bin") 43 | 44 | safeboot_offset = 0x10000 45 | safeboot_image = env.GetProjectOption("custom_safeboot_file", "") 46 | 47 | if safeboot_image == "": 48 | safeboot_project = env.GetProjectOption("custom_safeboot_dir", "") 49 | if safeboot_project != "": 50 | status( 51 | "Building SafeBoot image for board %s from %s" 52 | % (env.get("BOARD"), safeboot_project) 53 | ) 54 | if not os.path.isdir(safeboot_project): 55 | raise Exception("SafeBoot project not found: %s" % safeboot_project) 56 | env.Execute( 57 | "SAFEBOOT_BOARD=%s pio run -e safeboot -d %s" % (env.get("BOARD"), safeboot_project) 58 | ) 59 | safeboot_image = join(safeboot_project, ".pio/build/safeboot/firmware.bin") 60 | if not os.path.isfile(safeboot_image): 61 | raise Exception("SafeBoot image not found: %s" % safeboot_image) 62 | 63 | if safeboot_image == "": 64 | safeboot_url = env.GetProjectOption("custom_safeboot_url", "") 65 | if safeboot_url != "": 66 | safeboot_image = env.subst("$BUILD_DIR/safeboot.bin") 67 | if not os.path.isfile(safeboot_image): 68 | status( 69 | "Downloading SafeBoot image from %s to %s" 70 | % (safeboot_url, safeboot_image) 71 | ) 72 | response = requests.get(safeboot_url) 73 | if response.status_code != 200: 74 | raise Exception("Download error: %d" % response.status_code) 75 | with open(safeboot_image, "wb") as file: 76 | file.write(response.content) 77 | 78 | if fs_offset != 0: 79 | status("Building File System image") 80 | env.Execute("pio run -t buildfs -e %s" % env["PIOENV"]) 81 | 82 | factory_image = env.subst("$BUILD_DIR/${PROGNAME}.factory.bin") 83 | 84 | sections = env.subst(env.get("FLASH_EXTRA_IMAGES")) 85 | chip = env.get("BOARD_MCU") 86 | flash_size = env.BoardConfig().get("upload.flash_size") 87 | flash_freq = env.BoardConfig().get("build.f_flash", "40m") 88 | flash_freq = flash_freq.replace("000000L", "m") 89 | flash_mode = env.BoardConfig().get("build.flash_mode", "dio") 90 | memory_type = env.BoardConfig().get("build.arduino.memory_type", "qio_qspi") 91 | 92 | if flash_mode == "qio" or flash_mode == "qout": 93 | flash_mode = "dio" 94 | 95 | if memory_type == "opi_opi" or memory_type == "opi_qspi": 96 | flash_mode = "dout" 97 | 98 | cmd = [ 99 | "--chip", 100 | chip, 101 | "merge_bin", 102 | "-o", 103 | factory_image, 104 | "--flash_mode", 105 | flash_mode, 106 | "--flash_freq", 107 | flash_freq, 108 | "--flash_size", 109 | flash_size, 110 | ] 111 | 112 | # platformio estimates the amount of flash used to store the firmware. this 113 | # estimate is not accurate. we perform a final check on the firmware bin 114 | # size by comparing it against the respective partition size. 115 | max_size = env.BoardConfig().get("upload.maximum_size", 1) 116 | fw_size = getsize(env.subst("$BUILD_DIR/${PROGNAME}.bin")) 117 | if fw_size > max_size: 118 | raise Exception("Firmware binary too large: %d > %d" % (fw_size, max_size)) 119 | 120 | status(" Offset | File") 121 | for section in sections: 122 | sect_adr, sect_file = section.split(" ", 1) 123 | status(f" - {sect_adr} | {sect_file}") 124 | cmd += [sect_adr, sect_file] 125 | 126 | if safeboot_image != "": 127 | if os.path.isfile(safeboot_image): 128 | app_offset = 0xB0000 129 | status(f" - {hex(safeboot_offset)} | {safeboot_image}") 130 | cmd += [hex(safeboot_offset), safeboot_image] 131 | else: 132 | raise Exception("SafeBoot image not found: %s" % safeboot_image) 133 | 134 | status(f" - {hex(app_offset)} | {app_image}") 135 | cmd += [hex(app_offset), app_image] 136 | 137 | if fs_image != 0 and os.path.isfile(fs_image): 138 | status(f" - {hex(fs_offset)} | {fs_image}") 139 | cmd += [hex(fs_offset), fs_image] 140 | 141 | status("Using esptool.py arguments: %s" % " ".join(cmd)) 142 | 143 | esptool.main(cmd) 144 | 145 | status("Factory image generated! You can flash it with:\n> esptool.py write_flash 0x0 %s" % factory_image) 146 | 147 | 148 | env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", generateFactooryImage) 149 | -------------------------------------------------------------------------------- /tools/safeboot.py: -------------------------------------------------------------------------------- 1 | Import("env") 2 | import sys 3 | import urllib.request 4 | 5 | quiet = False 6 | 7 | 8 | def status(msg): 9 | """Print status message to stderr""" 10 | if not quiet: 11 | critical(msg) 12 | 13 | 14 | def critical(msg): 15 | """Print critical message to stderr""" 16 | sys.stderr.write("safeboot.py: ") 17 | sys.stderr.write(msg) 18 | sys.stderr.write("\n") 19 | 20 | 21 | # open "/safeboot" on target to restart in SafeBoot-mode 22 | def safeboot(source, target, env): 23 | upload_protocol = env.GetProjectOption("upload_protocol") 24 | upload_port = env.GetProjectOption("upload_port") 25 | if upload_protocol != "espota": 26 | critical("Wrong upload protocol (%s)" % upload_protocol) 27 | raise Exception("Wrong upload protocol!") 28 | else: 29 | status("Trying to activate SafeBoot on: %s" % upload_port) 30 | safeboot_path = env.GetProjectOption("custom_safeboot_restart_path", "/safeboot") 31 | req = urllib.request.Request("http://" + upload_port + safeboot_path, method="POST") 32 | try: 33 | urllib.request.urlopen(req) 34 | except urllib.error.URLError as e: 35 | critical(e) 36 | # Raise exception when SafeBoot cannot be activated 37 | pass 38 | 39 | status("Activated SafeBoot on: %s" % upload_port) 40 | 41 | 42 | env.AddPreAction("upload", safeboot) 43 | env.AddPreAction("uploadfs", safeboot) 44 | env.AddPreAction("uploadfsota", safeboot) 45 | -------------------------------------------------------------------------------- /tools/safeboot_size_check.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | # 3 | # Copyright (C) 2023-2025 Mathieu Carbou 4 | # 5 | Import("env") 6 | import os 7 | import sys 8 | 9 | quiet = False 10 | 11 | 12 | def status(msg): 13 | """Print status message to stderr""" 14 | if not quiet: 15 | critical(msg) 16 | 17 | 18 | def critical(msg): 19 | """Print critical message to stderr""" 20 | sys.stderr.write("safeboot.py: ") 21 | sys.stderr.write(msg) 22 | sys.stderr.write("\n") 23 | 24 | 25 | def safeboot(source, target, env): 26 | # platformio estimates the amount of flash used to store the firmware. this 27 | # estimate is not accurate. we perform a final check on the firmware bin 28 | # size by comparing it against the respective partition size. 29 | max_size = env.BoardConfig().get("upload.maximum_size", 1) 30 | fw_size = os.path.getsize(env.subst("$BUILD_DIR/${PROGNAME}.bin")) 31 | if fw_size > max_size: 32 | raise Exception("firmware binary too large: %d > %d" % (fw_size, max_size)) 33 | 34 | status("Firmware size valid: %d <= %d" % (fw_size, max_size)) 35 | 36 | status("SafeBoot firmware created: %s" % env.subst("$BUILD_DIR/${PROGNAME}.bin")) 37 | 38 | 39 | env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", safeboot) 40 | -------------------------------------------------------------------------------- /tools/version.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import re 4 | import sys 5 | from datetime import datetime, timezone 6 | 7 | Import("env") 8 | 9 | 10 | def do_main(): 11 | # hash 12 | ret = subprocess.run(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE, text=True, check=False) # Uses any tags 13 | full_hash = ret.stdout.strip() 14 | short_hash = full_hash[:7] 15 | 16 | # branch 17 | ref_name = os.environ.get("REF_NAME") 18 | if ref_name: 19 | branch = ref_name 20 | else: 21 | ret = subprocess.run( 22 | ["git", "symbolic-ref", "--short", "HEAD"], 23 | stdout=subprocess.PIPE, 24 | text=True, 25 | check=False, 26 | ) # retrieve branch name 27 | branch = ret.stdout.strip() 28 | branch = branch.replace("/", "") 29 | branch = branch.replace("-", "") 30 | branch = branch.replace("_", "") 31 | 32 | if branch == "": 33 | raise Exception("No branch name found") 34 | 35 | # is_tag ? 36 | tagPattern = re.compile("^v[0-9]+.[0-9]+.[0-9]+([_-][a-zA-Z0-9]+)?$") 37 | is_tag = branch.startswith("v") and len(branch) >= 6 and tagPattern.match(branch) 38 | 39 | version = branch 40 | if not is_tag: 41 | version += "_" + short_hash 42 | 43 | # local modifications ? 44 | has_local_modifications = False 45 | if not ref_name: 46 | # Check if the source has been modified since the last commit 47 | ret = subprocess.run( 48 | ["git", "diff-index", "--quiet", "HEAD", "--"], 49 | stdout=subprocess.PIPE, 50 | text=True, 51 | check=False, 52 | ) 53 | has_local_modifications = ret.returncode != 0 54 | 55 | if has_local_modifications: 56 | version += "_modified" 57 | 58 | # version = "v2.40.2-rc1" 59 | constantFile = os.path.join(env.subst("$BUILD_DIR"), "__compiled_constants.c") 60 | with open(constantFile, "w") as f: 61 | f.write( 62 | f'const char* __COMPILED_APP_VERSION__ = "{version[1:] if tagPattern.match(version) else version}";\n' 63 | f'const char* __COMPILED_BUILD_BRANCH__ = "{branch}";\n' 64 | f'const char* __COMPILED_BUILD_HASH__ = "{short_hash}";\n' 65 | f'const char* __COMPILED_BUILD_NAME__ = "{env["PIOENV"]}";\n' 66 | f'const char* __COMPILED_BUILD_TIMESTAMP__ = "{datetime.now(timezone.utc).isoformat()}";\n' 67 | f'const char* __COMPILED_BUILD_BOARD__ = "{env.get("BOARD")}";\n' 68 | ) 69 | sys.stderr.write(f"version.py: APP_VERSION: {version[1:] if tagPattern.match(version) else version}\n") 70 | sys.stderr.write(f"version.py: BUILD_BRANCH: {branch}\n") 71 | sys.stderr.write(f"version.py: BUILD_HASH: {short_hash}\n") 72 | sys.stderr.write(f"version.py: BUILD_NAME: {env['PIOENV']}\n") 73 | sys.stderr.write(f"version.py: BUILD_TIMESTAMP: {datetime.now(timezone.utc).isoformat()}\n") 74 | sys.stderr.write(f"version.py: BUILD_BOARD: {env.get('BOARD')}\n") 75 | 76 | env.AppendUnique(PIOBUILDFILES=[constantFile]) 77 | 78 | 79 | do_main() 80 | -------------------------------------------------------------------------------- /tools/website.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import os 3 | import sys 4 | import subprocess 5 | 6 | Import("env") 7 | 8 | os.makedirs(".pio/embed", exist_ok=True) 9 | 10 | for filename in ["website.html"]: 11 | skip = False 12 | # comment out next two lines to always rebuild 13 | if os.path.isfile(".pio/embed/" + filename + ".gz"): 14 | skip = True 15 | 16 | if skip: 17 | sys.stderr.write(f"website.py: {filename}.gz already available\n") 18 | continue 19 | 20 | # use html-minifier-terser to reduce size of html/js/css 21 | # you need to install html-minifier-terser first: 22 | # npm install html-minifier-terser -g 23 | # see: https://github.com/terser/html-minifier-terser 24 | subprocess.run( 25 | [ 26 | "html-minifier-terser", 27 | "--remove-comments", 28 | "--minify-css", 29 | "true", 30 | "--minify-js", 31 | '{"compress":{"drop_console":true},"mangle":{"toplevel":true},"nameCache":{}}', 32 | "--case-sensitive", 33 | "--sort-attributes", 34 | "--sort-class-name", 35 | "--remove-tag-whitespace", 36 | "--collapse-whitespace", 37 | "--conservative-collapse", 38 | f"embed/{filename}", 39 | "-o", 40 | f".pio/embed/{filename}", 41 | ] 42 | ) 43 | 44 | # gzip the file 45 | with open(".pio/embed/" + filename, "rb") as inputFile: 46 | with gzip.open(".pio/embed/" + filename + ".gz", "wb") as outputFile: 47 | sys.stderr.write( 48 | f"website.py: gzip '.pio/embed/{filename}' to '.pio/embed/{filename}.gz'\n" 49 | ) 50 | outputFile.writelines(inputFile) 51 | 52 | # Delete temporary minified html 53 | os.remove(".pio/embed/" + filename) 54 | --------------------------------------------------------------------------------