From 64266b1ff83683fd4f2198f9cce34f08afe4acbe Mon Sep 17 00:00:00 2001 From: suchmememanyskill <38142618+suchmememanyskill@users.noreply.github.com> Date: Sat, 10 Feb 2024 00:37:25 +0100 Subject: [PATCH] Implement OTA updates --- CYD-Klipper/src/conf/global_config.h | 1 + CYD-Klipper/src/lib/ESP32OTAPull.h | 230 +++++++++++++++++++ CYD-Klipper/src/main.cpp | 7 + CYD-Klipper/src/ui/ota_setup.cpp | 103 +++++++++ CYD-Klipper/src/ui/ota_setup.h | 8 + CYD-Klipper/src/ui/panels/settings_panel.cpp | 40 +++- ci.py | 19 ++ 7 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 CYD-Klipper/src/lib/ESP32OTAPull.h create mode 100644 CYD-Klipper/src/ui/ota_setup.cpp create mode 100644 CYD-Klipper/src/ui/ota_setup.h diff --git a/CYD-Klipper/src/conf/global_config.h b/CYD-Klipper/src/conf/global_config.h index 922b14e..bc1b508 100644 --- a/CYD-Klipper/src/conf/global_config.h +++ b/CYD-Klipper/src/conf/global_config.h @@ -20,6 +20,7 @@ typedef struct _GLOBAL_CONFIG { bool invertColors : 1; bool rotateScreen : 1; bool onDuringPrint : 1; + bool autoOtaUpdate : 1; }; }; float screenCalXOffset; diff --git a/CYD-Klipper/src/lib/ESP32OTAPull.h b/CYD-Klipper/src/lib/ESP32OTAPull.h new file mode 100644 index 0000000..f6ca39a --- /dev/null +++ b/CYD-Klipper/src/lib/ESP32OTAPull.h @@ -0,0 +1,230 @@ +/* +ESP32-OTA-Pull - a library for doing "pull" based OTA ("Over The Air") firmware +updates, where the image updates are posted on the web. + +MIT License + +Copyright (c) 2022-3 Mikal Hart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#pragma once +#include +#include +#include +#include + +class ESP32OTAPull +{ +public: + enum ActionType { DONT_DO_UPDATE, UPDATE_BUT_NO_BOOT, UPDATE_AND_BOOT }; + + // Return codes from CheckForOTAUpdate + enum ErrorCode { UPDATE_AVAILABLE = -3, NO_UPDATE_PROFILE_FOUND = -2, NO_UPDATE_AVAILABLE = -1, UPDATE_OK = 0, HTTP_FAILED = 1, WRITE_ERROR = 2, JSON_PROBLEM = 3, OTA_UPDATE_FAIL = 4 }; + +private: + void (*Callback)(int offset, int totallength) = NULL; + ActionType Action = UPDATE_AND_BOOT; + String Board = ARDUINO_BOARD; + String Device = ""; + String Config = ""; + String CVersion = ""; + bool DowngradesAllowed = false; + + int DownloadJson(const char* URL, String& payload) + { + HTTPClient http; + http.begin(URL); + + // Send HTTP GET request + int httpResponseCode = http.GET(); + + if (httpResponseCode == 200) + { + payload = http.getString(); + } + + // Free resources + http.end(); + return httpResponseCode; + } + + int DoOTAUpdate(const char* URL, ActionType Action) + { + HTTPClient http; + http.begin(URL); + + // Send HTTP GET request + int httpResponseCode = http.GET(); + + if (httpResponseCode == 200) + { + int totalLength = http.getSize(); + + // this is required to start firmware update process + if (!Update.begin(UPDATE_SIZE_UNKNOWN)) + return OTA_UPDATE_FAIL; + + // create buffer for read + uint8_t buff[1280] = { 0 }; + + // get tcp stream + WiFiClient* stream = http.getStreamPtr(); + + // read all data from server + int offset = 0; + while (http.connected() && offset < totalLength) + { + size_t sizeAvail = stream->available(); + if (sizeAvail > 0) + { + size_t bytes_to_read = min(sizeAvail, sizeof(buff)); + size_t bytes_read = stream->readBytes(buff, bytes_to_read); + size_t bytes_written = Update.write(buff, bytes_read); + if (bytes_read != bytes_written) + { + // Serial.printf("Unexpected error in OTA: %d %d %d\n", bytes_to_read, bytes_read, bytes_written); + break; + } + offset += bytes_written; + if (Callback != NULL) + Callback(offset, totalLength); + } + } + + if (offset == totalLength) + { + Update.end(true); + delay(1000); + + // Restart ESP32 to see changes + if (Action == UPDATE_BUT_NO_BOOT) + return UPDATE_OK; + ESP.restart(); + } + return WRITE_ERROR; + } + + http.end(); + return httpResponseCode; + } + +public: + /// @brief Return the version string of the binary, as reported by the JSON + /// @return The firmware version + String GetVersion() + { + return CVersion; + } + + /// @brief Override the default "Device" id (MAC Address) + /// @param device A string identifying the particular device (instance) (typically e.g., a MAC address) + /// @return The current ESP32OTAPull object for chaining + ESP32OTAPull &OverrideDevice(const char *device) + { + Device = device; + return *this; + } + + /// @brief Override the default "Board" value of ARDUINO_BOARD + /// @param board A string identifying the board (class) being targeted + /// @return The current ESP32OTAPull object for chaining + ESP32OTAPull &OverrideBoard(const char *board) + { + Board = board; + return *this; + } + + /// @brief Specify a configuration string that must match any "Config" in JSON + /// @param config An arbitrary string showing the current configuration + /// @return The current ESP32OTAPull object for chaining + ESP32OTAPull &SetConfig(const char *config) + { + Config = config; + return *this; + } + + /// @brief Specify whether downgrades (posted version is lower) are allowed + /// @param allow_downgrades true if downgrades are allowed + /// @return The current ESP32OTAPull object for chaining + ESP32OTAPull &AllowDowngrades(bool allow_downgrades) + { + DowngradesAllowed = allow_downgrades; + return *this; + } + + /// @brief Specify a callback function to monitor update progress + /// @param callback Pointer to a function that is called repeatedly during update + /// @return The current ESP32OTAPull object for chaining + ESP32OTAPull &SetCallback(void (*callback)(int offset, int totallength)) + { + Callback = callback; + return *this; + } + + /// @brief The main entry point for OTA Update + /// @param JSON_URL The URL for the JSON filter file + /// @param CurrentVersion The version # of the current (i.e. to be replaced) sketch + /// @param ActionType The action to be performed. May be any of DONT_DO_UPDATE, UPDATE_BUT_NO_BOOT, UPDATE_AND_BOOT (default) + /// @return ErrorCode or HTTP failure code (see enum above) + int CheckForOTAUpdate(const char* JSON_URL, const char *CurrentVersion, ActionType Action = UPDATE_AND_BOOT) + { + CurrentVersion = CurrentVersion == NULL ? "" : CurrentVersion; + + // Downloading OTA Json... + String Payload; + int httpResponseCode = DownloadJson(JSON_URL, Payload); + if (httpResponseCode != 200) + return httpResponseCode > 0 ? httpResponseCode : HTTP_FAILED; + + // Deserialize the JSON file downloaded from user's site + JsonDocument doc; + DeserializationError deserialization = deserializeJson(doc, Payload.c_str()); + if (deserialization != DeserializationError::Ok) + return JSON_PROBLEM; + + String DeviceName = Device.isEmpty() ? WiFi.macAddress() : Device; + String BoardName = Board.isEmpty() ? ARDUINO_BOARD : Board; + String ConfigName = Config.isEmpty() ? "" : Config; + bool foundProfile = false; + + // Step through the configurations looking for a match + for (auto config : doc["Configurations"].as()) + { + String CBoard = config["Board"].isNull() ? "" : (const char *)config["Board"]; + String CDevice = config["Device"].isNull() ? "" : (const char *)config["Device"]; + CVersion = config["Version"].isNull() ? "" : (const char *)config["Version"]; + String CConfig = config["Config"].isNull() ? "" : (const char *)config["Config"]; + //Serial.printf("Checking %s %s %s %s\n", CBoard.c_str(), CDevice.c_str(), CVersion.c_str(), CConfig.c_str()); + //Serial.printf("Against %s %s %s %s\n", BoardName.c_str(), DeviceName.c_str(), CurrentVersion, ConfigName.c_str()); + if ((CBoard.isEmpty() || CBoard == BoardName) && + (CDevice.isEmpty() || CDevice == DeviceName) && + (CConfig.isEmpty() || CConfig == ConfigName)) + { + if (CVersion.isEmpty() || CVersion > String(CurrentVersion) || + (DowngradesAllowed && CVersion != String(CurrentVersion))) + return Action == DONT_DO_UPDATE ? UPDATE_AVAILABLE : DoOTAUpdate(config["URL"], Action); + foundProfile = true; + } + } + return foundProfile ? NO_UPDATE_AVAILABLE : NO_UPDATE_PROFILE_FOUND; + } +}; + diff --git a/CYD-Klipper/src/main.cpp b/CYD-Klipper/src/main.cpp index 75225d2..e728bbe 100644 --- a/CYD-Klipper/src/main.cpp +++ b/CYD-Klipper/src/main.cpp @@ -8,6 +8,7 @@ #include "ui/nav_buttons.h" #include #include "core/lv_setup.h" +#include "ui/ota_setup.h" void setup() { Serial.begin(115200); @@ -18,6 +19,7 @@ void setup() { Serial.println("Screen init done"); wifi_init(); + ota_init(); ip_init(); data_setup(); @@ -31,4 +33,9 @@ void loop(){ data_loop(); lv_timer_handler(); lv_task_handler(); + + if (is_ready_for_ota_update()) + { + ota_do_update(); + } } \ No newline at end of file diff --git a/CYD-Klipper/src/ui/ota_setup.cpp b/CYD-Klipper/src/ui/ota_setup.cpp new file mode 100644 index 0000000..9888324 --- /dev/null +++ b/CYD-Klipper/src/ui/ota_setup.cpp @@ -0,0 +1,103 @@ +#include "../lib/ESP32OTAPull.h" +#include "lvgl.h" +#include "ui_utils.h" +#include "../core/lv_setup.h" +#include "../core/data_setup.h" +#include "../conf/global_config.h" +#include "ota_setup.h" + +//const char *ota_url = "https://gist.githubusercontent.com/suchmememanyskill/ece418fe199e155340de6c224a0badf2/raw/0d6762d68bc807cbecc71e40d55b76692397a7b3/update.json"; // Test url +const char *ota_url = "https://suchmememanyskill.github.io/CYD-Klipper/OTA.json"; // Prod url +ESP32OTAPull ota_pull; +static bool update_available; +static bool ready_for_ota_update = false; + +String ota_new_version_name() +{ + return ota_pull.GetVersion(); +} + +bool ota_has_update() +{ + return update_available; +} + +static int last_callback_time = 0; +lv_obj_t *percentage_bar; +lv_obj_t *update_label; +void do_update_callback(int offset, int totallength) +{ + int now = millis(); + if (now - last_callback_time < 1000) + { + return; + } + + last_callback_time = now; + + float percentage = (float)offset / (float)totallength; // 0 -> 1 + lv_bar_set_value(percentage_bar, percentage * 100, LV_ANIM_OFF); + lv_label_set_text_fmt(update_label, "%d/%d bytes", offset, totallength); + + lv_refr_now(NULL); + lv_timer_handler(); + lv_task_handler(); +} + +void ota_do_update(bool variant_automatic) +{ + Serial.println("Starting OTA Update"); + lv_obj_clean(lv_scr_act()); + + lv_obj_t *panel = lv_create_empty_panel(lv_scr_act()); + lv_obj_set_size(panel, CYD_SCREEN_WIDTH_PX, CYD_SCREEN_HEIGHT_PX); + lv_obj_align(panel, LV_ALIGN_TOP_LEFT, 0, 0); + lv_layout_flex_column(panel, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *label = lv_label_create_ex(panel); + lv_label_set_text(label, "Updating OTA..."); + + percentage_bar = lv_bar_create(panel); + lv_obj_set_size(percentage_bar, CYD_SCREEN_WIDTH_PX - CYD_SCREEN_GAP_PX * 3, CYD_SCREEN_MIN_BUTTON_HEIGHT_PX * 0.75f); + + update_label = lv_label_create_ex(panel); + lv_label_set_text(update_label, "0/0"); + + if (!variant_automatic) { + Serial.println("Freezing Background Tasks"); + screen_timer_wake(); + screen_timer_stop(); + freeze_request_thread(); + } + + lv_refr_now(NULL); + lv_timer_handler(); + lv_task_handler(); + + ota_pull.SetCallback(do_update_callback); + ota_pull.CheckForOTAUpdate(ota_url, REPO_VERSION, ESP32OTAPull::ActionType::UPDATE_AND_BOOT); +} + +void ota_init() +{ + //ota_pull.AllowDowngrades(true); + int result = ota_pull.CheckForOTAUpdate(ota_url, REPO_VERSION, ESP32OTAPull::ActionType::DONT_DO_UPDATE); + Serial.printf("OTA Update Result: %d\n", result); + update_available = result == ESP32OTAPull::UPDATE_AVAILABLE; + + if (global_config.autoOtaUpdate && update_available) + { + ota_do_update(true); + } +} + +void set_ready_for_ota_update() +{ + ready_for_ota_update = true; +} + +bool is_ready_for_ota_update() +{ + return ready_for_ota_update; +} + diff --git a/CYD-Klipper/src/ui/ota_setup.h b/CYD-Klipper/src/ui/ota_setup.h new file mode 100644 index 0000000..7186d64 --- /dev/null +++ b/CYD-Klipper/src/ui/ota_setup.h @@ -0,0 +1,8 @@ +#pragma once + +String ota_new_version_name(); +bool ota_has_update(); +void ota_do_update(bool variant_automatic = false); +void ota_init(); +void set_ready_for_ota_update(); +bool is_ready_for_ota_update(); \ No newline at end of file diff --git a/CYD-Klipper/src/ui/panels/settings_panel.cpp b/CYD-Klipper/src/ui/panels/settings_panel.cpp index 1486ff1..8839eb9 100644 --- a/CYD-Klipper/src/ui/panels/settings_panel.cpp +++ b/CYD-Klipper/src/ui/panels/settings_panel.cpp @@ -6,6 +6,7 @@ #include "../ui_utils.h" #include #include "../../core/lv_setup.h" +#include "../ota_setup.h" #ifndef REPO_VERSION #define REPO_VERSION "Unknown" @@ -87,6 +88,17 @@ static void on_during_print_switch(lv_event_t* e){ WriteGlobalConfig(); } +static void btn_ota_do_update(lv_event_t * e){ + set_ready_for_ota_update(); +} + +static void auto_ota_update_switch(lv_event_t* e){ + auto state = lv_obj_get_state(lv_event_get_target(e)); + bool checked = (state & LV_STATE_CHECKED == LV_STATE_CHECKED); + global_config.autoOtaUpdate = checked; + WriteGlobalConfig(); +} + const static lv_point_t line_points[] = { {0, 0}, {(short int)((CYD_SCREEN_PANEL_WIDTH_PX - CYD_SCREEN_GAP_PX * 2) * 0.85f), 0} }; void create_settings_widget(const char* label_text, lv_obj_t* object, lv_obj_t* root_panel, bool set_height = true){ @@ -211,7 +223,33 @@ void settings_panel_init(lv_obj_t* panel){ #endif label = lv_label_create_ex(panel); - lv_label_set_text(label, REPO_VERSION " "); + lv_label_set_text(label, REPO_VERSION " "); create_settings_widget("Version", label, panel, false); + + if (ota_has_update()){ + btn = lv_btn_create(panel); + lv_obj_add_event_cb(btn, btn_ota_do_update, LV_EVENT_CLICKED, NULL); + + label = lv_label_create_ex(btn); + lv_label_set_text_fmt(label, "Update to %s", ota_new_version_name().c_str()); + lv_obj_center(label); + + create_settings_widget("Device", btn, panel); + } + else { + label = lv_label_create_ex(panel); + lv_label_set_text(label, ARDUINO_BOARD " "); + + create_settings_widget("Device", label, panel, false); + } + + toggle = lv_switch_create(panel); + lv_obj_set_width(toggle, CYD_SCREEN_MIN_BUTTON_WIDTH_PX * 2); + lv_obj_add_event_cb(toggle, auto_ota_update_switch, LV_EVENT_VALUE_CHANGED, NULL); + + if (global_config.autoOtaUpdate) + lv_obj_add_state(toggle, LV_STATE_CHECKED); + + create_settings_widget("Auto Update", toggle, panel); } \ No newline at end of file diff --git a/ci.py b/ci.py index be97402..f22f181 100644 --- a/ci.py +++ b/ci.py @@ -32,6 +32,20 @@ def get_manifest(base_path : str, device_name : str): ] } +def extract_commit() -> str: + git_describe_output = subprocess.run(["git", "describe", "--tags"], stdout=subprocess.PIPE, text=True, check=True).stdout.strip() + return git_describe_output.split("-")[0] + +repo_version = extract_commit() +configurations = [] + +def add_configuration(board : str): + configurations.append({ + "Board": board, + "Version": repo_version, + "URL": f"https://suchmememanyskill.github.io/CYD-Klipper/out/{board}/firmware.bin" + }) + if os.path.exists("out"): shutil.rmtree("out") @@ -54,5 +68,10 @@ for port in CYD_PORTS: with open(f"./_site/{port}.json", "w") as f: json.dump(get_manifest(port_path, port), f) + add_configuration(port) + os.chdir(BASE_DIR) shutil.copytree("./out", "./_site/out") + +with open("./_site/OTA.json", "w") as f: + json.dump({"Configurations": configurations}, f)