From cafbe33ad9d9be077dd0d1871e59e0695e2269fb Mon Sep 17 00:00:00 2001 From: Justin Hammond Date: Fri, 18 Nov 2022 01:39:48 +0800 Subject: [PATCH] Initial Version --- .github/workflows/main.yml | 52 ++ .gitignore | 5 + CMakeLists.txt | 12 + Kconfig | 33 + README.md | 153 +++- examples/esp_ghota_example/CMakeLists.txt | 6 + .../esp_ghota_example/main/CMakeLists.txt | 2 + .../esp_ghota_example/main/idf_component.yml | 8 + examples/esp_ghota_example/main/main.c | 292 +++++++ examples/esp_ghota_example/partitions.csv | 8 + examples/esp_ghota_example/sdkconfig.defaults | 7 + examples/esp_ghota_example/spiffs/test.txt | 1 + idf_component.yml | 15 + include/esp_ghota.h | 157 ++++ include/semver.h | 105 +++ src/esp_ghota.c | 768 ++++++++++++++++++ src/lwjson.c | 710 ++++++++++++++++ src/lwjson.h | 335 ++++++++ src/lwjson_debug.c | 134 +++ src/lwjson_opt.h | 136 ++++ src/lwjson_stream.c | 436 ++++++++++ src/semver.c | 642 +++++++++++++++ 22 files changed, 4015 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 CMakeLists.txt create mode 100644 Kconfig create mode 100644 examples/esp_ghota_example/CMakeLists.txt create mode 100644 examples/esp_ghota_example/main/CMakeLists.txt create mode 100644 examples/esp_ghota_example/main/idf_component.yml create mode 100644 examples/esp_ghota_example/main/main.c create mode 100644 examples/esp_ghota_example/partitions.csv create mode 100644 examples/esp_ghota_example/sdkconfig.defaults create mode 100644 examples/esp_ghota_example/spiffs/test.txt create mode 100644 idf_component.yml create mode 100644 include/esp_ghota.h create mode 100644 include/semver.h create mode 100644 src/esp_ghota.c create mode 100644 src/lwjson.c create mode 100644 src/lwjson.h create mode 100644 src/lwjson_debug.c create mode 100644 src/lwjson_opt.h create mode 100644 src/lwjson_stream.c create mode 100644 src/semver.c diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..40a57ce --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,52 @@ +on: + push: + pull_request: + branches: [master] + +permissions: + contents: write +name: Build +jobs: + build: + strategy: + fail-fast: true + matrix: + targets: [esp32, esp32s3] + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + submodules: 'recursive' + - name: esp-idf build + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v4.4 + target: ${{ matrix.targets }} + path: 'examples/esp_ghota_example' + - name: Rename artifact + run: | + cp build/esp_ghota_example.bin esp_ghota_example-${{ matrix.targets }}.bin + cp build/storage.bin storage-${{ matrix.targets }}.bin + - name: Archive Firmware Files + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.targets }}-firmware + path: "*-${{ matrix.targets }}.bin" + + release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Download Firmware Files + uses: actions/download-artifact@v2 + with: + path: release + - name: Release Firmware + uses: ncipollo/release-action@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + artifacts: release/*/*.bin + generateReleaseNotes: true + allowUpdates: true + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index c6127b3..f0b962b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,8 @@ modules.order Module.symvers Mkfile.old dkms.conf + +#idf files +build/ +dependencies.lock +sdkconfig diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..035409a --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,12 @@ +set(priv_requires "log" "freertos" "esp_http_client" "esp-tls" "esp_https_ota" "app_update") +set(requires "esp_event") +set(srcs "src/esp_ghota.c" + "src/lwjson_debug.c" + "src/lwjson.c" + "src/lwjson_stream.c" + "src/semver.c") + +idf_component_register(SRCS "${srcs}" + INCLUDE_DIRS "include" + PRIV_REQUIRES ${priv_requires} + REQUIRES ${requires}) diff --git a/Kconfig b/Kconfig new file mode 100644 index 0000000..a7b62d3 --- /dev/null +++ b/Kconfig @@ -0,0 +1,33 @@ +menu "Github OTA Configuration" + + config MAX_FILENAME_LEN + int "The Max Size Of Filenames" + default 64 + help + The Maximum Size of Filenames stored at the Repository + + config MAX_URL_LEN + int "The Max Size Of URLs" + default 128 + help + The Maximum Size of URl's to download Firmware Files + + config GITHUB_HOSTNAME + string "The Hostname of the Github Repository" + default "api.github.com" + help + The Hostname of the Github API Server + + config GITHUB_OWNER + string "The Owner of the Github Repository" + default "Fishwaldo" + help + The Owner of the Github Repository + + config GITHUB_REPO + string "The Repository of the Github Repository" + default "esp_ghota" + help + The Repository of the Github Repository + +endmenu diff --git a/README.md b/README.md index a5efea9..dd067b6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,151 @@ -# esp_ghota -esp32 OTA Component to update firmware from Github Releases +# GITHUB OTA for ESP32 devices + +Automate your OTA and CI/CD pipeline with Github Actions to update your ESP32 devices in the field direct from github releases + +## Features +* Uses the esp_htps_ota library under the hood to update firmware images +* Can also update spiffs/littlefs/fatfs partitions +* Uses SemVer to compare versions and only update if a newer version is available +* Plays nicely with App rollback and anti-rollback features of the esp-idf bootloader +* Download firmware and partitiion images from the github release page directly +* Supports multiple devices with different firmware images +* Includes a sample Github Actions that builds and releases images when a new tag is pushed +* Updates can be triggered manually, or via a interval timer +* Uses a streaming JSON parser for to reduce memory usage (Github API responses can be huge) +* Supports Private Repositories (Github API token required*) +* Supports Github Enterprise +* Supports Github Personal Access Tokens to overcome Github API Ratelimits +* Sends progress of Updates via the esp_event_loop + +Note: +You should be careful with your GitHub PAT and putting it in the source code. I would suggest that you store the PAT in NVS, and the user enters it when running, as otherwise the PAT would be easily extractable from your firmware images. + +## Usage + +via the Espressif Component Registry: + +```bash +idf.py add-dependency Fishwaldo/ghota^1.0.0 +``` + +## Example +After Initilizing Network Access, Start a timer to periodically check for new releases: + +```c + ghota_config_t ghconfig = { + .filenamematch = "GithubOTA-esp32.bin", // Glob Pattern to match against the Firmware file + .storagenamematch = "storage-esp32.bin", // Glob Pattern to match against the storage firmware file + .storagepartitionname = "storage", // Update the storage partition + .updateInterval = 60, // Check for updates every 60 minuites + }; + ghota_client_handle_t *ghota_client = ghota_init(&ghconfig); + if (ghota_client == NULL) { + ESP_LOGE(TAG, "ghota_client_init failed"); + return; + } + esp_event_handler_register(GHOTA_EVENTS, ESP_EVENT_ANY_ID, &ghota_event_callback, ghota_client); // Register a handler to get updates on progress + ESP_ERROR_CHECK(ghota_start_update_timer(ghota_client)); // Start the timer to check for updates +``` + +Manually Checking for updates: + +```c + ghota_config_t ghconfig = { + .filenamematch = "GithubOTA-esp32.bin", + .storagenamematch = "storage-esp32.bin", + .storagepartitionname = "storage", + .updateInterval = 60, + }; + ghota_client_handle_t *ghota_client = ghota_init(&ghconfig); + if (ghota_client == NULL) { + ESP_LOGE(TAG, "ghota_client_init failed"); + return; + } + esp_event_handler_register(GHOTA_EVENTS, ESP_EVENT_ANY_ID, &ghota_event_callback, ghota_client); + ESP_ERROR_CHECK(ghota_check(ghota_client)); + + semver_t *cur = ghota_get_current_version(ghota_client); + if (cur) { + ESP_LOGI(TAG, "Current version: %d.%d.%d", cur->major, cur->minor, cur->patch); + semver_free(cur); + } + + semver_t *new = ghota_get_latest_version(ghota_client); + if (new) { + ESP_LOGI(TAG, "New version: %d.%d.%d", new->major, new->minor, new->patch); + semver_free(new); + } + ESP_ERROR_CHECK(ghota_update(ghota_client)); + ESP_ERROR_CHECK(ghota_free(ghota_client)); +``` + +## Configuration +The following configuration options are available: + + * config.filenamematch <- Glob pattern to match against the firmware file from the Github Releases page. + * config.storagenamematch <- Glob pattern to match against the storage file from the Github Releases page. + * config.storagepartitionname <- Name of the storage partition to update (as defined in partitions.csv) + * config.hostname <- Hostname of the Github API (default: api.github.com) + * config.orgname <- Name of the Github User or Organization + * config.reponame <- Name of the Github Repository + * config.updateInterval <- Interval in minutes to check for updates + +## Github Actions +The Github Actions included in this repository can be used to build and release firmware images to Github Releases. +This is a good way to automate your CI/CD pipeline, and update your devices in the field. +In this example, we build two variants of the Firmware - on for a ESP32 and one for a ESP32-S3 device +Using the filenamematch and storagenamematch config options, we can match against the correct firmware image for the device. + +```yaml +on: + push: + pull_request: + branches: [master] + +permissions: + contents: write +name: Build +jobs: + build: + strategy: + fail-fast: true + matrix: + targets: [esp32, esp32s3] + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + submodules: 'recursive' + - name: esp-idf build + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v4.4 + target: ${{ matrix.targets }} + - name: Rename artifact + run: | + cp build/GithubOTA.bin GithubOTA-${{ matrix.targets }}.bin + cp build/storage.bin storage-${{ matrix.targets }}.bin + - name: Archive Firmware Files + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.targets }}-firmware + path: "*-${{ matrix.targets }}.bin" + + release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Download Firmware Files + uses: actions/download-artifact@v2 + with: + path: release + - name: Release Firmware + uses: ncipollo/release-action@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + artifacts: release/*/*.bin + generateReleaseNotes: true + allowUpdates: true + token: ${{ secrets.GITHUB_TOKEN }} +``` \ No newline at end of file diff --git a/examples/esp_ghota_example/CMakeLists.txt b/examples/esp_ghota_example/CMakeLists.txt new file mode 100644 index 0000000..9b56c39 --- /dev/null +++ b/examples/esp_ghota_example/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16.0) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(esp_ghota_example) + + + diff --git a/examples/esp_ghota_example/main/CMakeLists.txt b/examples/esp_ghota_example/main/CMakeLists.txt new file mode 100644 index 0000000..379fac2 --- /dev/null +++ b/examples/esp_ghota_example/main/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRCS "main.c") +spiffs_create_partition_image(storage ../spiffs FLASH_IN_PROJECT) \ No newline at end of file diff --git a/examples/esp_ghota_example/main/idf_component.yml b/examples/esp_ghota_example/main/idf_component.yml new file mode 100644 index 0000000..88b98b7 --- /dev/null +++ b/examples/esp_ghota_example/main/idf_component.yml @@ -0,0 +1,8 @@ +## IDF Component Manager Manifest File +dependencies: + Fishwaldo/esp_ghota: + version: '*' + override_path: '../../..' + ## Required IDF version + idf: + version: ">=4.4.0" diff --git a/examples/esp_ghota_example/main/main.c b/examples/esp_ghota_example/main/main.c new file mode 100644 index 0000000..16fdd1a --- /dev/null +++ b/examples/esp_ghota_example/main/main.c @@ -0,0 +1,292 @@ +#include +#include +#include +#include +#include "freertos/event_groups.h" +#include "esp_system.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_log.h" +#include "nvs_flash.h" +#include "esp_spiffs.h" + +#include "lwip/err.h" +#include +#include +#include +#include + +#define EXAMPLE_ESP_WIFI_SSID "wifi" +#define EXAMPLE_ESP_WIFI_PASS "password" + + +/* FreeRTOS event group to signal when we are connected*/ +static EventGroupHandle_t s_wifi_event_group; + +/* The event group allows multiple bits for each event, but we only care about two events: + * - we are connected to the AP with an IP + * - we failed to connect after the maximum amount of retries */ +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 + +static int s_retry_num = 0; + +static const char* TAG = "main"; + +static void event_handler(void* arg, esp_event_base_t event_base, + int32_t event_id, void* event_data) +{ + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + if (s_retry_num < 10) { + esp_wifi_connect(); + s_retry_num++; + ESP_LOGI(TAG, "retry to connect to the AP"); + } else { + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + ESP_LOGI(TAG,"connect to the AP fail"); + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data; + ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_num = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +void wifi_init_sta(void) +{ + s_wifi_event_group = xEventGroupCreate(); + + ESP_ERROR_CHECK(esp_netif_init()); + + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_create_default_wifi_sta(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + esp_event_handler_instance_t instance_any_id; + esp_event_handler_instance_t instance_got_ip; + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, + ESP_EVENT_ANY_ID, + &event_handler, + NULL, + &instance_any_id)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, + IP_EVENT_STA_GOT_IP, + &event_handler, + NULL, + &instance_got_ip)); + + wifi_config_t wifi_config = { + .sta = { + .ssid = EXAMPLE_ESP_WIFI_SSID, + .password = EXAMPLE_ESP_WIFI_PASS, + .threshold.authmode = WIFI_AUTH_WPA2_PSK, + }, + }; + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA) ); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config) ); + ESP_ERROR_CHECK(esp_wifi_start() ); + + ESP_LOGI(TAG, "wifi_init_sta finished."); + + /* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or connection failed for the maximum + * number of re-tries (WIFI_FAIL_BIT). The bits are set by event_handler() (see above) */ + EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, + WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, + pdFALSE, + pdFALSE, + portMAX_DELAY); + + /* xEventGroupWaitBits() returns the bits before the call returned, hence we can test which event actually + * happened. */ + if (bits & WIFI_CONNECTED_BIT) { + ESP_LOGI(TAG, "connected to ap SSID:%s password:%s", + EXAMPLE_ESP_WIFI_SSID, EXAMPLE_ESP_WIFI_PASS); + } else if (bits & WIFI_FAIL_BIT) { + ESP_LOGI(TAG, "Failed to connect to SSID:%s, password:%s", + EXAMPLE_ESP_WIFI_SSID, EXAMPLE_ESP_WIFI_PASS); + } else { + ESP_LOGE(TAG, "UNEXPECTED EVENT"); + } +} + +static void read_from_spiffs(void) +{ + ESP_LOGI(TAG, "Reading hello.txt"); + + // Open for reading hello.txt + FILE* f = fopen("/spiffs/test.txt", "r"); + if (f == NULL) { + ESP_LOGE(TAG, "Failed to open test.txt"); + return; + } + + char buf[64]; + memset(buf, 0, sizeof(buf)); + fread(buf, 1, sizeof(buf), f); + fclose(f); + + // Display the read contents from the file + ESP_LOGI(TAG, "Read from test.txt: %s", buf); +} + +void mount_spiffs() { + esp_vfs_spiffs_conf_t conf = { + .base_path = "/spiffs", + .partition_label = "storage", + .max_files = 5, + .format_if_mount_failed = false + }; + + // Use settings defined above to initialize and mount SPIFFS filesystem. + // Note: esp_vfs_spiffs_register is an all-in-one convenience function. + esp_err_t ret = esp_vfs_spiffs_register(&conf); + + if (ret != ESP_OK) { + if (ret == ESP_FAIL) { + ESP_LOGE(TAG, "Failed to mount or format filesystem"); + } else if (ret == ESP_ERR_NOT_FOUND) { + ESP_LOGE(TAG, "Failed to find SPIFFS partition"); + } else { + ESP_LOGE(TAG, "Failed to initialize SPIFFS (%s)", esp_err_to_name(ret)); + } + return; + } + + size_t total = 0, used = 0; + ret = esp_spiffs_info("storage", &total, &used); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to get SPIFFS partition information (%s)", esp_err_to_name(ret)); + } else { + ESP_LOGI(TAG, "Partition size: total: %d, used: %d", total, used); + } + read_from_spiffs(); +} + +void unmount_spiffs() { + esp_vfs_spiffs_unregister("storage"); +} + + +static void ghota_event_callback(void* handler_args, esp_event_base_t base, int32_t id, void* event_data) { + ghota_client_handle_t *client = (ghota_client_handle_t *)handler_args; + ESP_LOGI(TAG, "Got Update Callback: %s", ghota_get_event_str(id)); + if (id == GHOTA_EVENT_START_STORAGE_UPDATE) { + ESP_LOGI(TAG, "Starting storage update"); + /* if we are updating the SPIFF storage we should unmount it */ + unmount_spiffs(); + } else if (id == GHOTA_EVENT_FINISH_STORAGE_UPDATE) { + ESP_LOGI(TAG, "Ending storage update"); + /* after updating we can remount, but typically the device will reboot shortly after recieving this event. */ + mount_spiffs(); + } else if (id == GHOTA_EVENT_FIRMWARE_UPDATE_PROGRESS) { + /* display some progress with the firmware update */ + ESP_LOGI(TAG, "Firmware Update Progress: %d%%", *((int*) event_data)); + } else if (id == GHOTA_EVENT_STORAGE_UPDATE_PROGRESS) { + /* display some progress with the spiffs partition update */ + ESP_LOGI(TAG, "Storage Update Progress: %d%%", *((int*) event_data)); + } + (void)client; + return; +} +void app_main() { + ESP_LOGI(TAG, "Starting"); + + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + + ESP_LOGI(TAG, "ESP_WIFI_MODE_STA"); + wifi_init_sta(); + + /* initialize our ghota config */ + ghota_config_t ghconfig = { + .filenamematch = "esp_ghota-esp32.bin", + .storagenamematch = "storage-esp32.bin", + .storagepartitionname = "storage", + /* 1 minute as a example, but in production you should pick something larger (remember, Github has ratelimites on the API! )*/ + .updateInterval = 1, + }; + /* initialize ghota. */ + ghota_client_handle_t *ghota_client = ghota_init(&ghconfig); + if (ghota_client == NULL) { + ESP_LOGE(TAG, "ghota_client_init failed"); + return; + } + /* register for events relating to the update progress */ + esp_event_handler_register(GHOTA_EVENTS, ESP_EVENT_ANY_ID, &ghota_event_callback, ghota_client); + +#define DO_BACKGROUND_UPDATE 1 +#define DO_FOREGROUND_UPDATE 0 +#define DO_MANUAL_CHECK_UPDATE 0 + +#ifdef DO_BACKGROUND_UPDATE + /* for private repositories or to get more API calls than anonymouse, set a github username and PAT token + * see https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token + * for more information on how to create a PAT token. + * + * Be carefull, as the PAT token will be stored in your firmware etc and can be used to access your github account. + */ + //ESP_ERROR_CHECK(ghota_set_auth(ghota_client, "", "")); + + /* start a timer that will automatically check for updates based on the interval specified above */ + ESP_ERROR_CHECK(ghota_start_update_timer(ghota_client)); + +#elif DO_FORGROUND_UPDATE + /* or do a check/update now + * This runs in a new task under freeRTOS, so you can do other things while it is running. + */ + ESP_ERROR_CHECK(ghota_start_update_task(ghota_client)); + +#elif DO_MANUAL_CHECK_UPDATE + /* Alternatively you can do manual checks + * but note, you probably have to increase the Stack size for the task this runs on + */ + + /* Query the Github Release API for the latest release */ + ESP_ERROR_CHECK(ghota_check(ghota_client)); + + /* get the semver version of the currently running firmware */ + semver_t *cur = ghota_get_current_version(ghota_client); + if (cur) { + ESP_LOGI(TAG, "Current version: %d.%d.%d", cur->major, cur->minor, cur->patch); + semver_free(cur); + + + /* get the version of the latest release on Github */ + semver_t *new = ghota_get_latest_version(ghota_client); + if (new) { + ESP_LOGI(TAG, "New version: %d.%d.%d", new->major, new->minor, new->patch); + semver_free(new); + } + + /* do some comparisions */ + if (semver_gt(new, cur) == 1) { + ESP_LOGI(TAG, "New version is greater than current version"); + } else if (semver_eq(new, cur) == 1) { + ESP_LOGI(TAG, "New version is equal to current version"); + } else { + ESP_LOGI(TAG, "New version is less than current version"); + } + + /* assuming we have a new version, then do a actual update */ + ESP_ERROR_CHECK(ghota_update(ghota_client)); + /* if there was a new version installed, the esp will reboot after installation and will not reach this code */ + +#endif + + while (1) { + vTaskDelay(5000 / portTICK_PERIOD_MS); + ESP_LOGI(TAG, "This is where we do other things. Memory Dump Below to see the memory usage"); + ESP_LOGI(TAG, "Memory: Free %dKiB Low: %dKiB\n", (int)xPortGetFreeHeapSize()/1024, (int)xPortGetMinimumEverFreeHeapSize()/1024); + } + +} \ No newline at end of file diff --git a/examples/esp_ghota_example/partitions.csv b/examples/esp_ghota_example/partitions.csv new file mode 100644 index 0000000..fec65df --- /dev/null +++ b/examples/esp_ghota_example/partitions.csv @@ -0,0 +1,8 @@ +# Name, Type, SubType, Offset, Size, Flags +# Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +otadata, data, ota, , 0x2000 +ota_0, app, ota_0, , 1M, +ota_1, app, ota_1, , 1M +storage, data, spiffs, , 0xF0000, \ No newline at end of file diff --git a/examples/esp_ghota_example/sdkconfig.defaults b/examples/esp_ghota_example/sdkconfig.defaults new file mode 100644 index 0000000..aee78f3 --- /dev/null +++ b/examples/esp_ghota_example/sdkconfig.defaults @@ -0,0 +1,7 @@ +CONFIG_GITHUB_OWNER="Fishwaldo" +CONFIG_GITHUB_REPO="esp_ghota" +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 diff --git a/examples/esp_ghota_example/spiffs/test.txt b/examples/esp_ghota_example/spiffs/test.txt new file mode 100644 index 0000000..aae0040 --- /dev/null +++ b/examples/esp_ghota_example/spiffs/test.txt @@ -0,0 +1 @@ +Hello From Spiffs! diff --git a/idf_component.yml b/idf_component.yml new file mode 100644 index 0000000..17143da --- /dev/null +++ b/idf_component.yml @@ -0,0 +1,15 @@ +# Version of the component [Required, if not passed during upload] +version: "1.0.0" +# Short description for the project [Recommended] +description: esp32 OTA Component to update firmware from Github Releases +# Github repo or a home [Recommended] +url: https://github.com/Fishwaldo/esp_ghota + +# List of dependencies [Optional] +# All dependencies of the component should be published in the same registry. +#dependencies: + # Default namespace is `espressif` + # Declaring dependency as `cool_component` is the same as `espressif/cool_component` + #cool_component: ">1.0.0" + #some_ns/some_component: + # version: "~1.2.7" \ No newline at end of file diff --git a/include/esp_ghota.h b/include/esp_ghota.h new file mode 100644 index 0000000..b2d2881 --- /dev/null +++ b/include/esp_ghota.h @@ -0,0 +1,157 @@ +#ifndef GITHUB_OTA_H +#define GITHUB_OTA_H + +#include +#include +#include "semver.h" + +#ifdef __cplusplus +extern "C" { +#endif + +ESP_EVENT_DECLARE_BASE(GHOTA_EVENTS); + +/** + * @brief Github OTA events + * These events are posted to the event loop to track progress of the OTA process + */ +typedef enum +{ + GHOTA_EVENT_START_CHECK = 0x01, /*!< Github OTA check started */ + GHOTA_EVENT_UPDATE_AVAILABLE = 0x02, /*!< Github OTA update available */ + GHOTA_EVENT_NOUPDATE_AVAILABLE = 0x04, /*!< Github OTA no update available */ + GHOTA_EVENT_START_UPDATE = 0x08, /*!< Github OTA update started */ + GHOTA_EVENT_FINISH_UPDATE = 0x10, /*!< Github OTA update finished */ + GHOTA_EVENT_UPDATE_FAILED = 0x20, /*!< Github OTA update failed */ + GHOTA_EVENT_START_STORAGE_UPDATE = 0x40, /*!< Github OTA storage update started. If the storage is mounted, you should unmount it when getting this call */ + GHOTA_EVENT_FINISH_STORAGE_UPDATE = 0x80, /*!< Github OTA storage update finished. You can mount the new storage after getting this call if needed */ + GHOTA_EVENT_STORAGE_UPDATE_FAILED = 0x100, /*!< Github OTA storage update failed */ + GHOTA_EVENT_FIRMWARE_UPDATE_PROGRESS = 0x200, /*!< Github OTA firmware update progress */ + GHOTA_EVENT_STORAGE_UPDATE_PROGRESS = 0x400, /*!< Github OTA storage update progress */ + GHOTA_EVENT_PENDING_REBOOT = 0x800, /*!< Github OTA pending reboot */ +} ghota_event_e; + +/** + * @brief Github OTA Configuration + */ +typedef struct ghota_config_t { + char filenamematch[CONFIG_MAX_FILENAME_LEN]; /**< Filename to match against on Github indicating this is a firmware file */ + char storagenamematch[CONFIG_MAX_FILENAME_LEN]; /**< Filename to match against on Github indicating this is a storage file */ + char storagepartitionname[17]; /**< Name of the storage partition to update */ + char *hostname; /**< Hostname of the Github server. Defaults to api.github.com*/ + char *orgname; /**< Name of the Github organization */ + char *reponame; /**< Name of the Github repository */ + uint32_t updateInterval; /**< Interval in Minutes to check for updates if using the ghota_start_update_timer function */ +} ghota_config_t; + +typedef struct ghota_client_handle_t ghota_client_handle_t; + +/** + * @brief Initialize the github ota client + * + * + * @param config [in] Configuration for the github ota client + * @return ghota_client_handle_t* handle to pass to all subsequent calls. If it returns NULL, there is a error in your config + */ +ghota_client_handle_t *ghota_init(ghota_config_t *config); + +/** + * @brief Set the Username and Password to access private repositories or get more API calls + * + * Anonymus API calls are limited to 60 per hour. If you want to get more calls, you need to set a username and password. + * Be aware that this will be stored in the flash and can be read by anyone with access to the flash. + * The password should be a Github Personal Access Token and for good security you should limit what it can do + * + * @param handle the handle returned by ghota_init + * @param username the username to authenticate with + * @param password this Github Personal Access Token + * @return esp_err_t ESP_OK if all is good, ESP_FAIL if there is an error + */ +esp_err_t ghota_set_auth(ghota_client_handle_t *handle, const char *username, const char *password); +/** + * @brief Free the ghota client handle and all resources + * + * @param handle the Handle + * @return esp_err_t if there was a error + */ +esp_err_t ghota_free(ghota_client_handle_t *handle); + +/** + * @brief Perform a check for updates + * + * This will just check if there is a available update on Github releases with download resources that match your configuration + * for firmware and storage files. If it returns ESP_OK, you can call ghota_get_latest_version to get the version of the latest release + * + * @param handle the ghota_client_handle_t handle + * @return esp_err_t ESP_OK if there is a update available, ESP_FAIL if there is no update available or an error + */ +esp_err_t ghota_check(ghota_client_handle_t *handle); + +/** + * @brief Downloads and writes the latest firmware and storage partition (if available) + * + * You should only call this after calling ghota_check and ensuring that there is a update available. + * + * @param handle the ghota_client_handle_t handle + * @return esp_err_t ESP_FAIL if there is a error. If the Update is successful, it will not return, but reboot the device + */ +esp_err_t ghota_update(ghota_client_handle_t *handle); + +/** + * @brief Get the currently running version of the firmware + * + * This will return the version of the firmware currently running on your device. + * consult semver.h for functions to compare versions + * + * @param handle the ghota_client_handle_t handle + * @return semver_t the version of the latest release + */ + +semver_t *ghota_get_current_version(ghota_client_handle_t *handle); + +/** + * @brief Get the version of the latest release on Github. Only valid after calling ghota_check + * + * @param handle the ghota_client_handle_t handle + * @return semver_t* the version of the latest release on Github + */ +semver_t *ghota_get_latest_version(ghota_client_handle_t *handle); + +/** + * @brief Start a new Task that will check for updates and update if available + * + * This is equivalent to calling ghota_check and ghota_update if there is a new update available. + * If no update is available, it will not update the device. + * + * Progress can be monitored by registering for the GHOTA_EVENTS events on the Global Event Loop + * + * @param handle ghota_client_handle_t handle + * @return esp_err_t ESP_OK if the task was started, ESP_FAIL if there was an error + */ +esp_err_t ghota_start_update_task(ghota_client_handle_t *handle); + +/** + * @brief Install a Timer to automatically check for new updates and update if available + * + * Install a timer that will check for new updates every updateInterval seconds and update if available. + * + * @param handle ghota_client_handle_t handle + * @return esp_err_t ESP_OK if no error, otherwise ESP_FAIL + */ + +esp_err_t ghota_start_update_timer(ghota_client_handle_t *handle); + +/** + * @brief convience function to return a string representation of events emited by this library + * + * @param event the eventid passed to the event handler + * @return char* a string representing the event + */ +char *ghota_get_event_str(ghota_event_e event); + + +#ifdef __cplusplus +} +#endif + +#endif \ No newline at end of file diff --git a/include/semver.h b/include/semver.h new file mode 100644 index 0000000..1b48670 --- /dev/null +++ b/include/semver.h @@ -0,0 +1,105 @@ +/* + * semver.h + * + * Copyright (c) 2015-2017 Tomas Aparicio + * MIT licensed + */ + +#ifndef __SEMVER_H +#define __SEMVER_H + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef SEMVER_VERSION +#define SEMVER_VERSION "0.2.0" +#endif + +/** + * semver_t struct + */ + +typedef struct semver_version_s { + int major; + int minor; + int patch; + char * metadata; + char * prerelease; +} semver_t; + +/** + * Set prototypes + */ + +int +semver_satisfies (semver_t x, semver_t y, const char *op); + +int +semver_satisfies_caret (semver_t x, semver_t y); + +int +semver_satisfies_patch (semver_t x, semver_t y); + +int +semver_compare (semver_t x, semver_t y); + +int +semver_compare_version (semver_t x, semver_t y); + +int +semver_compare_prerelease (semver_t x, semver_t y); + +int +semver_gt (semver_t x, semver_t y); + +int +semver_gte (semver_t x, semver_t y); + +int +semver_lt (semver_t x, semver_t y); + +int +semver_lte (semver_t x, semver_t y); + +int +semver_eq (semver_t x, semver_t y); + +int +semver_neq (semver_t x, semver_t y); + +int +semver_parse (const char *str, semver_t *ver); + +int +semver_parse_version (const char *str, semver_t *ver); + +void +semver_render (semver_t *x, char *dest); + +int +semver_numeric (semver_t *x); + +void +semver_bump (semver_t *x); + +void +semver_bump_minor (semver_t *x); + +void +semver_bump_patch (semver_t *x); + +void +semver_free (semver_t *x); + +int +semver_is_valid (const char *s); + +int +semver_clean (char *s); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/esp_ghota.c b/src/esp_ghota.c new file mode 100644 index 0000000..8582ded --- /dev/null +++ b/src/esp_ghota.c @@ -0,0 +1,768 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "esp_ghota.h" +#include "lwjson.h" + +static const char *TAG = "GHOTA"; + +ESP_EVENT_DEFINE_BASE(GHOTA_EVENTS); + +typedef struct ghota_client_handle_t { + ghota_config_t config; + char *username; + char *token; + struct { + char tag_name[CONFIG_MAX_FILENAME_LEN]; + char name[CONFIG_MAX_FILENAME_LEN]; + char url[CONFIG_MAX_URL_LEN]; + char storageurl[CONFIG_MAX_URL_LEN]; + uint8_t flags; + } result; + struct { + char name[CONFIG_MAX_FILENAME_LEN]; + char url[CONFIG_MAX_URL_LEN]; + } scratch; + semver_t current_version; + semver_t latest_version; + uint32_t countdown; + TaskHandle_t task_handle; + const esp_partition_t *storage_partition; +} ghota_client_handle_t; + + +enum release_flags +{ + GHOTA_RELEASE_GOT_TAG = 0x01, + GHOTA_RELEASE_GOT_FNAME = 0x02, + GHOTA_RELEASE_GOT_URL = 0x04, + GHOTA_RELEASE_GOT_STORAGE = 0x08, + GHOTA_RELEASE_VALID_ASSET = 0x10, +} release_flags; + +SemaphoreHandle_t ghota_lock = NULL; + + +static void SetFlag(ghota_client_handle_t *handle, enum release_flags flag) +{ + handle->result.flags |= flag; +} +static bool GetFlag(ghota_client_handle_t *handle, enum release_flags flag) +{ + return handle->result.flags & flag; +} + +static void ClearFlag(ghota_client_handle_t *handle, enum release_flags flag) +{ + handle->result.flags &= ~flag; +} + +ghota_client_handle_t *ghota_init(ghota_config_t *newconfig) { + if (!ghota_lock) { + ghota_lock = xSemaphoreCreateMutex(); + } + if (xSemaphoreTake(ghota_lock, pdMS_TO_TICKS(1000)) != pdPASS) { + ESP_LOGE(TAG, "Failed to take lock"); + return NULL; + } + ghota_client_handle_t *handle = malloc(sizeof(ghota_client_handle_t)); + if (handle == NULL) { + ESP_LOGE(TAG, "Failed to allocate memory for client handle"); + xSemaphoreGive(ghota_lock); + return NULL; + } + bzero(handle, sizeof(ghota_client_handle_t)); + strncpy(handle->config.filenamematch, newconfig->filenamematch, CONFIG_MAX_FILENAME_LEN); + strncpy(handle->config.storagenamematch, newconfig->storagenamematch, CONFIG_MAX_FILENAME_LEN); + strncpy(handle->config.storagepartitionname, newconfig->storagepartitionname, 17); + if (newconfig->hostname == NULL) + asprintf(&handle->config.hostname, CONFIG_GITHUB_HOSTNAME); + else + asprintf(&handle->config.hostname, newconfig->hostname); + + if (newconfig->orgname == NULL) + asprintf(&handle->config.orgname, CONFIG_GITHUB_OWNER); + else + asprintf(&handle->config.orgname, newconfig->orgname); + + if (newconfig->reponame == NULL) + asprintf(&handle->config.reponame, CONFIG_GITHUB_REPO); + else + asprintf(&handle->config.reponame, newconfig->reponame); + + const esp_app_desc_t *app_desc = esp_ota_get_app_description(); + if (semver_parse(app_desc->version, &handle->current_version)) { + ESP_LOGE(TAG, "Failed to parse current version"); + ghota_free(handle); + xSemaphoreGive(ghota_lock); + return NULL; + } + handle->result.flags = 0; + +// if (newconfig->updateInterval < 60) { +// ESP_LOGE(TAG, "Update interval must be at least 60 Minutes"); +// newconfig->updateInterval = 60; +// } + handle->config.updateInterval = newconfig->updateInterval; + + handle->task_handle = NULL; + xSemaphoreGive(ghota_lock); + + return handle; +} + +esp_err_t ghota_free(ghota_client_handle_t *handle) { + if (xSemaphoreTake(ghota_lock, pdMS_TO_TICKS(1000)) != pdPASS) { + ESP_LOGE(TAG, "Failed to take lock"); + return ESP_FAIL; + } + free(handle->config.hostname); + free(handle->config.orgname); + free(handle->config.reponame); + if (handle->username) + free(handle->username); + if (handle->token) + free(handle->token); + semver_free(&handle->current_version); + semver_free(&handle->latest_version); + xSemaphoreGive(ghota_lock); + return ESP_OK; +} + +esp_err_t ghota_set_auth(ghota_client_handle_t *handle, const char *username, const char *password) { + if (xSemaphoreTake(ghota_lock, pdMS_TO_TICKS(1000)) != pdPASS) { + ESP_LOGE(TAG, "Failed to take lock"); + return ESP_FAIL; + } + asprintf(&handle->username, "%s", username); + asprintf(&handle->token, "%s", password); + xSemaphoreGive(ghota_lock); + return ESP_OK; +} + + +static void lwjson_callback(lwjson_stream_parser_t *jsp, lwjson_stream_type_t type) +{ + if (jsp->udata == NULL) { + ESP_LOGE(TAG, "No user data for callback"); + return; + } + ghota_client_handle_t *handle = (ghota_client_handle_t *)jsp->udata; +#ifdef DEBUG + ESP_LOGI(TAG, "Lwjson Called: %d %d %d %d", jsp->stack_pos, jsp->stack[jsp->stack_pos - 1].type, type, handle->result.flags); + if (jsp->stack[jsp->stack_pos - 1].type == LWJSON_STREAM_TYPE_KEY) + { /* We need key to be before */ + ESP_LOGI(TAG, "Key: %s", jsp->stack[jsp->stack_pos - 1].meta.name); + } +#endif + /* Get a value corresponsing to "tag_name" key */ + if (!GetFlag(handle, GHOTA_RELEASE_GOT_TAG)) { + if (jsp->stack_pos >= 2 /* Number of stack entries must be high */ + && jsp->stack[0].type == LWJSON_STREAM_TYPE_OBJECT /* First must be object */ + && jsp->stack[1].type == LWJSON_STREAM_TYPE_KEY /* We need key to be before */ + && strcasecmp(jsp->stack[1].meta.name, "tag_name") == 0) + { + ESP_LOGD(TAG, "Got '%s' with value '%s'", jsp->stack[1].meta.name, jsp->data.str.buff); + strncpy(handle->result.tag_name, jsp->data.str.buff, CONFIG_MAX_FILENAME_LEN); + SetFlag(handle, GHOTA_RELEASE_GOT_TAG); + } + } + if (!GetFlag(handle, GHOTA_RELEASE_VALID_ASSET) || !GetFlag(handle, GHOTA_RELEASE_GOT_STORAGE)) { + if (jsp->stack_pos == 5 + && jsp->stack[0].type == LWJSON_STREAM_TYPE_OBJECT + && jsp->stack[1].type == LWJSON_STREAM_TYPE_KEY + && strcasecmp(jsp->stack[1].meta.name, "assets") == 0 + && jsp->stack[2].type == LWJSON_STREAM_TYPE_ARRAY + && jsp->stack[3].type == LWJSON_STREAM_TYPE_OBJECT + && jsp->stack[4].type == LWJSON_STREAM_TYPE_KEY) + { + ESP_LOGD(TAG, "Assets Got key '%s' with value '%s'", jsp->stack[jsp->stack_pos - 1].meta.name, jsp->data.str.buff); + if (strcasecmp(jsp->stack[4].meta.name, "name") == 0) + { + strncpy(handle->scratch.name, jsp->data.str.buff, CONFIG_MAX_FILENAME_LEN); + SetFlag(handle, GHOTA_RELEASE_GOT_FNAME); + ESP_LOGD(TAG, "Got Filename for Asset: %s", handle->scratch.name); + } + if (strcasecmp(jsp->stack[4].meta.name, "url") == 0) + { + strncpy(handle->scratch.url, jsp->data.str.buff, CONFIG_MAX_URL_LEN); + SetFlag(handle, GHOTA_RELEASE_GOT_URL); + ESP_LOGD(TAG, "Got URL for Asset: %s", handle->scratch.url); + } + /* Now test if we got both name an download url */ + if (GetFlag(handle, GHOTA_RELEASE_GOT_FNAME) && GetFlag(handle, GHOTA_RELEASE_GOT_URL)) { + ESP_LOGD(TAG, "Testing Firmware filenames %s -> %s - Matching Filename against %s and %s", handle->scratch.name, handle->scratch.url, handle->config.filenamematch, handle->config.storagenamematch); + /* see if the filename matches */ + if (!GetFlag(handle, GHOTA_RELEASE_VALID_ASSET) && fnmatch(handle->config.filenamematch, handle->scratch.name, 0) == 0) { + strncpy(handle->result.name, handle->scratch.name, CONFIG_MAX_FILENAME_LEN); + strncpy(handle->result.url, handle->scratch.url, CONFIG_MAX_URL_LEN); + ESP_LOGD(TAG, "Valid Firmware Found: %s - %s", handle->result.name, handle->result.url); + SetFlag(handle, GHOTA_RELEASE_VALID_ASSET); + } else if (!GetFlag(handle, GHOTA_RELEASE_GOT_STORAGE) && fnmatch(handle->config.storagenamematch, handle->scratch.name, 0) == 0) { + strncpy(handle->result.storageurl, handle->scratch.url, CONFIG_MAX_URL_LEN); + ESP_LOGD(TAG, "Valid Storage Asset Found: %s - %s", handle->scratch.name, handle->result.storageurl); + SetFlag(handle, GHOTA_RELEASE_GOT_STORAGE); + } else { + ESP_LOGD(TAG, "Invalid Asset Found: %s", handle->scratch.name); + ClearFlag(handle, GHOTA_RELEASE_GOT_FNAME); + ClearFlag(handle, GHOTA_RELEASE_GOT_URL); + } + } + } + } +} + +static esp_err_t _http_event_handler(esp_http_client_event_t *evt) +{ + lwjsonr_t res; + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wswitch" + switch (evt->event_id) { + case HTTP_EVENT_ON_HEADER: + if (strncasecmp(evt->header_key, "x-ratelimit-remaining", strlen("x-ratelimit-remaining")) == 0) { + int limit = atoi(evt->header_value); + ESP_LOGD(TAG, "Github API Rate Limit Remaining: %d", limit); + if (limit < 10) { + ESP_LOGW(TAG, "Github API Rate Limit Remaining is low: %d", limit); + } + } + break; + case HTTP_EVENT_ON_DATA: + if (!esp_http_client_is_chunked_response(evt->client)) + { + char *buf = evt->data; + for (int i = 0; i < evt->data_len; i++) + { + res = lwjson_stream_parse((lwjson_stream_parser_t *)evt->user_data, *buf); + if (!(res == lwjsonOK || res == lwjsonSTREAMDONE || res == lwjsonSTREAMINPROG)) + { + ESP_LOGE(TAG, "Lwjson Error: %d", res); + } + buf++; + } + } + break; + case HTTP_EVENT_DISCONNECTED: { + int mbedtls_err = 0; + esp_err_t err = esp_tls_get_and_clear_last_error(evt->data, &mbedtls_err, NULL); + if (err != 0) + { + ESP_LOGE(TAG, "Last esp error code: 0x%x", err); + ESP_LOGE(TAG, "Last mbedtls failure: 0x%x", mbedtls_err); + } + break; + } + } + #pragma GCC diagnostic pop + return ESP_OK; +} + +esp_err_t ghota_check(ghota_client_handle_t *handle) +{ + if (xSemaphoreTake(ghota_lock, pdMS_TO_TICKS(1000)) != pdPASS) { + ESP_LOGE(TAG, "Failed to get lock"); + return ESP_FAIL; + } + ESP_LOGI(TAG, "Checking for new release"); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_START_CHECK, handle, sizeof(ghota_client_handle_t *), portMAX_DELAY)); + lwjson_stream_parser_t stream_parser; + lwjsonr_t res; + + res = lwjson_stream_init(&stream_parser, lwjson_callback); + if (res != lwjsonOK) + { + ESP_LOGE(TAG, "Failed to initialize JSON parser: %d", res); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_NOUPDATE_AVAILABLE, handle, sizeof(ghota_client_handle_t *), portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + stream_parser.udata = (void*)handle; + + char url[CONFIG_MAX_URL_LEN]; + snprintf(url, CONFIG_MAX_URL_LEN, "https://%s/repos/%s/%s/releases/latest", handle->config.hostname, handle->config.orgname, handle->config.reponame); + + esp_http_client_config_t httpconfig = { + .url = url, + .crt_bundle_attach = esp_crt_bundle_attach, + .event_handler = _http_event_handler, + .user_data = &stream_parser, + }; + if (handle->username) { + ESP_LOGD(TAG, "Using Authenticated Request to %s", url); + httpconfig.username = handle->username; + httpconfig.password = handle->token; + httpconfig.auth_type = HTTP_AUTH_TYPE_BASIC; + } + ESP_LOGI(TAG, "Searching for Firmware from %s", url); + + esp_http_client_handle_t client = esp_http_client_init(&httpconfig); + + esp_err_t err = esp_http_client_perform(client); + if (err == ESP_OK) + { + ESP_LOGD(TAG, "HTTP GET Status = %d, content_length = %d", + esp_http_client_get_status_code(client), + esp_http_client_get_content_length(client)); + } + else + { + ESP_LOGE(TAG, "HTTP GET request failed: %s", esp_err_to_name(err)); + esp_http_client_cleanup(client); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_NOUPDATE_AVAILABLE, handle, sizeof(ghota_client_handle_t *), portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + + } + if (esp_http_client_get_status_code(client) == 200) + { + if (GetFlag(handle, GHOTA_RELEASE_VALID_ASSET)) + { + if (semver_parse(handle->result.tag_name, &handle->latest_version)) { + ESP_LOGE(TAG, "Failed to parse new version"); + esp_http_client_cleanup(client); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_NOUPDATE_AVAILABLE, handle, sizeof(ghota_client_handle_t *), portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + ESP_LOGI(TAG, "Current Version %d.%d.%d", handle->current_version.major, handle->current_version.minor, handle->current_version.patch); + ESP_LOGI(TAG, "New Version %d.%d.%d", handle->latest_version.major, handle->latest_version.minor, handle->latest_version.patch); + ESP_LOGI(TAG, "Asset: %s", handle->result.name); + ESP_LOGI(TAG, "Firmware URL: %s", handle->result.url); + if (strlen(handle->result.storageurl)) { + ESP_LOGI(TAG, "Storage URL: %s", handle->result.storageurl); + } + } else { + ESP_LOGI(TAG, "Asset: No Valid Firmware Assets Found"); + esp_http_client_cleanup(client); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_NOUPDATE_AVAILABLE, handle, sizeof(ghota_client_handle_t *), portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + } + else + { + ESP_LOGW(TAG, "Github Release API Returned: %d", esp_http_client_get_status_code(client)); + esp_http_client_cleanup(client); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_NOUPDATE_AVAILABLE, handle, sizeof(ghota_client_handle_t *), portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + + esp_http_client_cleanup(client); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_UPDATE_AVAILABLE, handle, sizeof(ghota_client_handle_t *), portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_OK; +} + +static esp_err_t validate_image_header(esp_app_desc_t *new_app_info) +{ + if (new_app_info == NULL) { + return ESP_ERR_INVALID_ARG; + } + ESP_LOGI(TAG, "New Firmware Details:"); + ESP_LOGI(TAG, "Project name: %s", new_app_info->project_name); + ESP_LOGI(TAG, "Firmware version: %s", new_app_info->version); + ESP_LOGI(TAG, "Compiled time: %s %s", new_app_info->date, new_app_info->time); + ESP_LOGI(TAG, "ESP-IDF: %s", new_app_info->idf_ver); + ESP_LOGI(TAG, "SHA256:"); + ESP_LOG_BUFFER_HEX(TAG, new_app_info->app_elf_sha256, sizeof(new_app_info->app_elf_sha256)); + + const esp_partition_t *running = esp_ota_get_running_partition(); + ESP_LOGD(TAG, "Current partition %s type %d subtype %d (offset 0x%08x)", + running->label, running->type, running->subtype, running->address); + const esp_partition_t *update = esp_ota_get_next_update_partition(NULL); + ESP_LOGD(TAG, "Update partition %s type %d subtype %d (offset 0x%08x)", + update->label, update->type, update->subtype, update->address); + +#ifdef CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK + /** + * Secure version check from firmware image header prevents subsequent download and flash write of + * entire firmware image. However this is optional because it is also taken care in API + * esp_https_ota_finish at the end of OTA update procedure. + */ + const uint32_t hw_sec_version = esp_efuse_read_secure_version(); + if (new_app_info->secure_version < hw_sec_version) { + ESP_LOGW(TAG, "New firmware security version is less than eFuse programmed, %d < %d", new_app_info->secure_version, hw_sec_version); + return ESP_FAIL; + } +#endif + + return ESP_OK; +} + +static esp_err_t http_client_set_header_cb(esp_http_client_handle_t http_client) +{ + return esp_http_client_set_header(http_client, "Accept", "application/octet-stream"); +} + +esp_err_t _http_event_storage_handler(esp_http_client_event_t *evt) { + static int output_pos; + static int last_progress; + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wswitch" + switch (evt->event_id) { + case HTTP_EVENT_ON_CONNECTED: { + output_pos = 0; + last_progress = 0; + /* Erase the Partition */ + break; + } + case HTTP_EVENT_ON_DATA: + if (!esp_http_client_is_chunked_response(evt->client)) { + ghota_client_handle_t *handle = (ghota_client_handle_t *)evt->user_data; + if (output_pos == 0) { + ESP_LOGD(TAG, "Erasing Partition"); + ESP_ERROR_CHECK(esp_partition_erase_range(handle->storage_partition, 0, handle->storage_partition->size)); + ESP_LOGD(TAG, "Erasing Complete"); + } + + ESP_ERROR_CHECK(esp_partition_write(handle->storage_partition, output_pos, evt->data, evt->data_len)); + output_pos += evt->data_len; + int progress = 100 * ((float)output_pos / (float)handle->storage_partition->size); + if ((progress % 5 == 0) && (progress != last_progress)) { + ESP_LOGV(TAG, "Storage Firmware Update Progress: %d%%", progress); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_STORAGE_UPDATE_PROGRESS, &progress, sizeof(progress), portMAX_DELAY)); + last_progress = progress; + } + } + break; + case HTTP_EVENT_DISCONNECTED: { + int mbedtls_err = 0; + esp_err_t err = esp_tls_get_and_clear_last_error(evt->data, &mbedtls_err, NULL); + if (err != 0) + { + ESP_LOGE(TAG, "Last esp error code: 0x%x", err); + ESP_LOGE(TAG, "Last mbedtls failure: 0x%x", mbedtls_err); + } + break; + } + } + #pragma GCC diagnostic pop + return ESP_OK; +} + +esp_err_t ghota_storage_update(ghota_client_handle_t *handle) { + if (xSemaphoreTake(ghota_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + ESP_LOGE(TAG, "Failed to take lock"); + return ESP_FAIL; + } + if (handle == NULL) { + ESP_LOGE(TAG, "Invalid Handle"); + xSemaphoreGive(ghota_lock); + return ESP_ERR_INVALID_ARG; + } + if (!strlen(handle->result.storageurl)) { + ESP_LOGE(TAG, "No Storage URL"); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + if (!strlen(handle->config.storagepartitionname)) { + ESP_LOGE(TAG, "No Storage Partition Name"); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + handle->storage_partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, handle->config.storagepartitionname); + if (handle->storage_partition == NULL) { + ESP_LOGE(TAG, "Storage Partition Not Found"); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + ESP_LOGD(TAG, "Storage Partition %s - Type %x Subtype %x Found at %x - size %d", handle->storage_partition->label, handle->storage_partition->type, handle->storage_partition->subtype, handle->storage_partition->address, handle->storage_partition->size); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_START_STORAGE_UPDATE, NULL, 0, portMAX_DELAY)); + /* give time for the system to react, such as unmounting the filesystems etc */ + vTaskDelay(pdMS_TO_TICKS(1000)); + + esp_http_client_config_t config = { + .url = handle->result.storageurl, + .event_handler = _http_event_storage_handler, + .crt_bundle_attach = esp_crt_bundle_attach, + .user_data = handle, + .buffer_size_tx = 2048, + + }; + if (handle->username) { + ESP_LOGD(TAG, "Using Authenticated Request to %s", config.url); + config.username = handle->username; + config.password = handle->token; + config.auth_type = HTTP_AUTH_TYPE_BASIC; + } + esp_http_client_handle_t client = esp_http_client_init(&config); + ESP_ERROR_CHECK(esp_http_client_set_header(client, "Accept", "application/octet-stream")); + esp_err_t err = esp_http_client_perform(client); + if (err == ESP_OK) { + ESP_LOGD(TAG, "HTTP GET Status = %d, content_length = %d", + esp_http_client_get_status_code(client), + esp_http_client_get_content_length(client)); + uint8_t sha256[32] = { 0 }; + ESP_ERROR_CHECK(esp_partition_get_sha256(handle->storage_partition, sha256)); + ESP_LOG_BUFFER_HEX("New Storage Partition SHA256:", sha256, sizeof(sha256)); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_FINISH_STORAGE_UPDATE, NULL, 0, portMAX_DELAY)); + } else { + ESP_LOGE(TAG, "HTTP GET request failed: %s", esp_err_to_name(err)); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_STORAGE_UPDATE_FAILED, NULL, 0, portMAX_DELAY)); + + } + esp_http_client_cleanup(client); + xSemaphoreGive(ghota_lock); + return ESP_OK; +} + + +esp_err_t ghota_update(ghota_client_handle_t *handle) { + esp_err_t ota_finish_err = ESP_OK; + if (xSemaphoreTake(ghota_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + ESP_LOGE(TAG, "Failed to take lock"); + return ESP_FAIL; + } + ESP_LOGI(TAG, "Scheduled Check for Firmware Update Starting"); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_START_UPDATE, NULL, 0, portMAX_DELAY)); + if (!GetFlag(handle, GHOTA_RELEASE_VALID_ASSET)) { + ESP_LOGE(TAG, "No Valid Release Asset Found"); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_UPDATE_FAILED, NULL, 0, portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + int cmp = semver_compare_version(handle->latest_version, handle->current_version); + if ( cmp != 1) { + ESP_LOGE(TAG, "Current Version is equal or newer than new release"); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_UPDATE_FAILED, NULL, 0, portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_OK; + } + esp_http_client_config_t httpconfig = { + .url = handle->result.url, + .crt_bundle_attach = esp_crt_bundle_attach, + .keep_alive_enable = true, + .buffer_size_tx = 4096, + }; + + if (handle->username) { + ESP_LOGD(TAG, "Using Authenticated Request to %s", httpconfig.url); + httpconfig.username = handle->username; + httpconfig.password = handle->token; + httpconfig.auth_type = HTTP_AUTH_TYPE_BASIC; + } + + esp_https_ota_config_t ota_config = { + .http_config = &httpconfig, + .http_client_init_cb = http_client_set_header_cb, + }; + + esp_https_ota_handle_t https_ota_handle = NULL; + esp_err_t err = esp_https_ota_begin(&ota_config, &https_ota_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ESP HTTPS OTA Begin failed: %d", err); + goto ota_end; + } + + esp_app_desc_t app_desc; + err = esp_https_ota_get_img_desc(https_ota_handle, &app_desc); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_https_ota_read_img_desc failed: %d", err); + goto ota_end; + } + err = validate_image_header(&app_desc); + if (err != ESP_OK) { + ESP_LOGE(TAG, "image header verification failed: %d", err); + goto ota_end; + } + int last_progress = -1; + while (1) { + err = esp_https_ota_perform(https_ota_handle); + if (err != ESP_ERR_HTTPS_OTA_IN_PROGRESS) { + break; + } + int32_t dl = esp_https_ota_get_image_len_read(https_ota_handle); + int32_t size = esp_https_ota_get_image_size(https_ota_handle); + int progress = 100 * ((float)dl / (float)size); + if ((progress % 5 == 0) && (progress != last_progress)) { + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_FIRMWARE_UPDATE_PROGRESS, &progress, sizeof(progress), portMAX_DELAY)); + ESP_LOGV(TAG, "Firmware Update Progress: %d%%", progress); + last_progress = progress; + } + } + + if (esp_https_ota_is_complete_data_received(https_ota_handle) != true) { + // the OTA image was not completely received and user can customise the response to this situation. + ESP_LOGE(TAG, "Complete data was not received."); + } else { + ota_finish_err = esp_https_ota_finish(https_ota_handle); + if ((err == ESP_OK) && (ota_finish_err == ESP_OK)) { + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_FINISH_UPDATE, NULL, 0, portMAX_DELAY)); + if (strlen(handle->result.storageurl)) { + xSemaphoreGive(ghota_lock); + if (ghota_storage_update(handle) == ESP_OK) { + ESP_LOGI(TAG, "Storage Update Successful"); + } else { + ESP_LOGE(TAG, "Storage Update Failed"); + } + } else { + xSemaphoreGive(ghota_lock); + } + ESP_LOGI(TAG, "ESP_HTTPS_OTA upgrade successful. Rebooting ..."); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_PENDING_REBOOT, NULL, 0, portMAX_DELAY)); + vTaskDelay(1000 / portTICK_PERIOD_MS); + esp_restart(); + return ESP_OK; + } else { + if (ota_finish_err == ESP_ERR_OTA_VALIDATE_FAILED) { + ESP_LOGE(TAG, "Image validation failed, image is corrupted"); + } + ESP_LOGE(TAG, "ESP_HTTPS_OTA upgrade failed 0x%x", ota_finish_err); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_UPDATE_FAILED, NULL, 0, portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; + } + } + +ota_end: + esp_https_ota_abort(https_ota_handle); + ESP_LOGE(TAG, "ESP_HTTPS_OTA upgrade failed"); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_UPDATE_FAILED, NULL, 0, portMAX_DELAY)); + xSemaphoreGive(ghota_lock); + return ESP_FAIL; +} + +semver_t *ghota_get_current_version(ghota_client_handle_t *handle) { + if (!handle) { + return NULL; + } + semver_t *cur = malloc(sizeof(semver_t)); + memcpy(cur, &handle->current_version, sizeof(semver_t)); + return cur; +} + +semver_t *ghota_get_latest_version(ghota_client_handle_t *handle) { + if (!handle) { + return NULL; + } + if (!GetFlag(handle, GHOTA_RELEASE_VALID_ASSET)) { + return NULL; + } + semver_t *new = malloc(sizeof(semver_t)); + memcpy(new, &handle->latest_version, sizeof(semver_t)); + return new; +} + +static void ghota_task(void *pvParameters) { + ghota_client_handle_t *handle = (ghota_client_handle_t *)pvParameters; + ESP_LOGI(TAG, "Firmware Update Task Starting"); + if (handle) { + if (ghota_check(handle) == ESP_OK) { + if (semver_gt(handle->latest_version, handle->current_version) == 1) { + ESP_LOGI(TAG, "New Version Available"); + ghota_update(handle); + } else { + ESP_LOGI(TAG, "No New Version Available"); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_NOUPDATE_AVAILABLE, NULL, 0, portMAX_DELAY)); + } + } else { + ESP_LOGI(TAG, "No Update Available"); + ESP_ERROR_CHECK(esp_event_post(GHOTA_EVENTS, GHOTA_EVENT_NOUPDATE_AVAILABLE, NULL, 0, portMAX_DELAY)); + } + } + ESP_LOGI(TAG, "Firmware Update Task Finished"); + vTaskDelete(handle->task_handle); + vTaskDelay(pdMS_TO_TICKS(1000)); + handle->task_handle = NULL; +} + +esp_err_t ghota_start_update_task(ghota_client_handle_t *handle) { + if (!handle) { + return ESP_FAIL; + } + eTaskState state = eInvalid; + TaskHandle_t tmp = xTaskGetHandle("ghota_task"); + if (tmp) { + state = eTaskGetState(tmp); + } + if (state == eDeleted || state == eInvalid) { + ESP_LOGD(TAG, "Starting Task to Check for Updates"); + if (xTaskCreate(ghota_task, "ghota_task", 6144, handle, 5, &handle->task_handle) != pdPASS) { + ESP_LOGW(TAG, "Failed to Start ghota_task"); + return ESP_FAIL; + } + } else { + ESP_LOGW(TAG, "ghota_task Already Running"); + return ESP_FAIL; + } + return ESP_OK; +} + +static void ghota_timer_callback(TimerHandle_t xTimer) { + ghota_client_handle_t *handle = (ghota_client_handle_t *)pvTimerGetTimerID(xTimer); + if (handle) { + handle->countdown--; + if (handle->countdown == 0) { + handle->countdown = handle->config.updateInterval; + ghota_start_update_task(handle); + } + } +} + +esp_err_t ghota_start_update_timer(ghota_client_handle_t *handle) { + if (!handle) { + ESP_LOGE(TAG, "Failed to initialize GHOTA Client"); + return ESP_FAIL; + } + handle->countdown = handle->config.updateInterval; + + /* run timer every minute */ + uint64_t ticks = pdMS_TO_TICKS(1000) * 60; + TimerHandle_t timer = xTimerCreate("ghota_timer", ticks, pdTRUE, (void *)handle, ghota_timer_callback); + if ( timer == NULL) { + ESP_LOGE(TAG, "Failed to create timer"); + return ESP_FAIL; + } else { + if (xTimerStart(timer, 0) != pdPASS) { + ESP_LOGE(TAG, "Failed to start timer"); + return ESP_FAIL; + } else { + ESP_LOGI(TAG, "Started Update Timer for %d Minutes", handle->config.updateInterval); + } + } + return ESP_OK; +} + +char *ghota_get_event_str(ghota_event_e event) { + switch (event) { + case GHOTA_EVENT_START_CHECK: + return "GHOTA_EVENT_START_CHECK"; + case GHOTA_EVENT_UPDATE_AVAILABLE: + return "GHOTA_EVENT_UPDATE_AVAILABLE"; + case GHOTA_EVENT_NOUPDATE_AVAILABLE: + return "GHOTA_EVENT_NOUPDATE_AVAILABLE"; + case GHOTA_EVENT_START_UPDATE: + return "GHOTA_EVENT_START_UPDATE"; + case GHOTA_EVENT_FINISH_UPDATE: + return "GHOTA_EVENT_FINISH_UPDATE"; + case GHOTA_EVENT_UPDATE_FAILED: + return "GHOTA_EVENT_UPDATE_FAILED"; + case GHOTA_EVENT_START_STORAGE_UPDATE: + return "GHOTA_EVENT_START_STORAGE_UPDATE"; + case GHOTA_EVENT_FINISH_STORAGE_UPDATE: + return "GHOTA_EVENT_FINISH_STORAGE_UPDATE"; + case GHOTA_EVENT_STORAGE_UPDATE_FAILED: + return "GHOTA_EVENT_STORAGE_UPDATE_FAILED"; + case GHOTA_EVENT_FIRMWARE_UPDATE_PROGRESS: + return "GHOTA_EVENT_FIRMWARE_UPDATE_PROGRESS"; + case GHOTA_EVENT_STORAGE_UPDATE_PROGRESS: + return "GHOTA_EVENT_STORAGE_UPDATE_PROGRESS"; + case GHOTA_EVENT_PENDING_REBOOT: + return "GHOTA_EVENT_PENDING_REBOOT"; + } + return "Unknown Event"; +} \ No newline at end of file diff --git a/src/lwjson.c b/src/lwjson.c new file mode 100644 index 0000000..36ea14b --- /dev/null +++ b/src/lwjson.c @@ -0,0 +1,710 @@ +/** + * \file lwjson.c + * \brief Lightweight JSON format parser + */ + +/* + * Copyright (c) 2022 Tilen MAJERLE + * + * 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. + * + * This file is part of LwJSON - Lightweight JSON format parser. + * + * Author: Tilen MAJERLE + * Version: v1.5.0 + */ +#include +#include "lwjson.h" + +/** + * \brief Internal string object + */ +typedef struct { + const char* start; /*!< Original pointer to beginning of JSON object */ + size_t len; /*!< Total length of input json string */ + const char* p; /*!< Current char pointer */ +} lwjson_int_str_t; + +/** + * \brief Allocate new token for JSON block + * \param[in] lw: LwJSON instance + * \return Pointer to new token + */ +static lwjson_token_t* +prv_alloc_token(lwjson_t* lw) { + if (lw->next_free_token_pos < lw->tokens_len) { + memset(&lw->tokens[lw->next_free_token_pos], 0x00, sizeof(*lw->tokens)); + return &lw->tokens[lw->next_free_token_pos++]; + } + return NULL; +} + +/** + * \brief Skip all characters that are considered *blank* as per RFC4627 + * \param[in,out] pobj: Pointer to text that is modified on success + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +static lwjsonr_t +prv_skip_blank(lwjson_int_str_t* pobj) { + while (pobj->p != NULL && *pobj->p != '\0' && (size_t)(pobj->p - pobj->start) < pobj->len) { + if (*pobj->p == ' ' || *pobj->p == '\t' || *pobj->p == '\r' || *pobj->p == '\n' || *pobj->p == '\f') { + ++pobj->p; +#if LWJSON_CFG_COMMENTS + /* Check for comments and remove them */ + } else if (*pobj->p == '/') { + ++pobj->p; + if (pobj->p != NULL && *pobj->p == '*') { + ++pobj->p; + while (pobj->p != NULL && *pobj->p != '\0' && (size_t)(pobj->p - pobj->start) < pobj->len) { + if (*pobj->p == '*') { + ++pobj->p; + if (*pobj->p == '/') { + ++pobj->p; + break; + } + } + ++pobj->p; + } + } +#endif /* LWJSON_CFG_COMMENTS */ + } else { + break; + } + } + if (pobj->p != NULL && *pobj->p != '\0' && (size_t)(pobj->p - pobj->start) < pobj->len) { + return lwjsonOK; + } + return lwjsonERRJSON; +} + +/** + * \brief Parse JSON string that must start end end with double quotes `"` character + * It just parses length of characters and does not perform any decode operation + * \param[in,out] pobj: Pointer to text that is modified on success + * \param[out] pout: Pointer to pointer to string that is set where string starts + * \param[out] poutlen: Length of string in units of characters is stored here + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +static lwjsonr_t +prv_parse_string(lwjson_int_str_t* pobj, const char** pout, size_t* poutlen) { + lwjsonr_t res; + size_t len = 0; + + if ((res = prv_skip_blank(pobj)) != lwjsonOK) { + return res; + } + if (*pobj->p++ != '"') { + return lwjsonERRJSON; + } + *pout = pobj->p; + /* Parse string but take care of escape characters */ + for (;; ++pobj->p, ++len) { + if (pobj->p == NULL || *pobj->p == '\0' || (size_t)(pobj->p - pobj->start) >= pobj->len) { + return lwjsonERRJSON; + } + /* Check special characters */ + if (*pobj->p == '\\') { + ++pobj->p; + ++len; + switch (*pobj->p) { + case '"': + case '\\': + case '/': + case 'b': + case 'f': + case 'n': + case 'r': + case 't': + break; + case 'u': + ++pobj->p; + for (size_t i = 0; i < 4; ++i, ++len) { + if (!((*pobj->p >= '0' && *pobj->p <= '9') || (*pobj->p >= 'a' && *pobj->p <= 'f') + || (*pobj->p >= 'A' && *pobj->p <= 'F'))) { + return lwjsonERRJSON; + } + if (i < 3) { + ++pobj->p; + } + } + break; + default: + return lwjsonERRJSON; + } + } else if (*pobj->p == '"') { + ++pobj->p; + break; + } + } + *poutlen = len; + return res; +} + +/** + * \brief Parse property name that must comply with JSON string format as in RFC4627 + * Property string must be followed by colon character ":" + * \param[in,out] pobj: Pointer to text that is modified on success + * \param[out] t: Token instance to write property name to + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +static lwjsonr_t +prv_parse_property_name(lwjson_int_str_t* pobj, lwjson_token_t* t) { + lwjsonr_t res; + + /* Parse property string first */ + if ((res = prv_parse_string(pobj, &t->token_name, &t->token_name_len)) != lwjsonOK) { + return res; + } + /* Skip any spaces */ + if ((res = prv_skip_blank(pobj)) != lwjsonOK) { + return res; + } + /* Must continue with colon */ + if (*pobj->p++ != ':') { + return lwjsonERRJSON; + } + /* Skip any spaces */ + if ((res = prv_skip_blank(pobj)) != lwjsonOK) { + return res; + } + return lwjsonOK; +} + +/** + * \brief Parse number as described in RFC4627 + * \param[in,out] pobj: Pointer to text that is modified on success + * \param[out] tout: Pointer to output number format + * \param[out] fout: Pointer to output real-type variable. Used if type is REAL. + * \param[out] iout: Pointer to output int-type variable. Used if type is INT. + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +static lwjsonr_t +prv_parse_number(lwjson_int_str_t* pobj, lwjson_type_t* tout, lwjson_real_t* fout, lwjson_int_t* iout) { + lwjsonr_t res; + uint8_t is_minus; + lwjson_real_t num; + lwjson_type_t type = LWJSON_TYPE_NUM_INT; + + if ((res = prv_skip_blank(pobj)) != lwjsonOK) { + return res; + } + if (*pobj->p == '\0' || (size_t)(pobj->p - pobj->start) >= pobj->len) { + return lwjsonERRJSON; + } + is_minus = *pobj->p == '-' ? (++pobj->p, 1) : 0; + if (*pobj->p == '\0' /* Invalid string */ + || *pobj->p < '0' || *pobj->p > '9' /* Character outside number range */ + || (*pobj->p == '0' + && (pobj->p[1] < '0' && pobj->p[1] > '9'))) { /* Number starts with 0 but not followed by dot */ + return lwjsonERRJSON; + } + + /* Parse number */ + for (num = 0; *pobj->p >= '0' && *pobj->p <= '9'; ++pobj->p) { + num = num * 10 + (*pobj->p - '0'); + } + if (pobj->p != NULL && *pobj->p == '.') { /* Number has exponent */ + lwjson_real_t exp, dec_num; + + type = LWJSON_TYPE_NUM_REAL; /* Format is real */ + ++pobj->p; /* Ignore comma character */ + if (*pobj->p < '0' || *pobj->p > '9') { /* Must be followed by number characters */ + return lwjsonERRJSON; + } + /* Get number after decimal point */ + for (exp = 1, dec_num = 0; *pobj->p >= '0' && *pobj->p <= '9'; ++pobj->p, exp *= 10) { + dec_num = dec_num * 10 + (*pobj->p - '0'); + } + num += dec_num / exp; /* Add decimal part to number */ + } + if (pobj->p != NULL && (*pobj->p == 'e' || *pobj->p == 'E')) { /* Engineering mode */ + uint8_t is_minus_exp; + int exp_cnt; + + type = LWJSON_TYPE_NUM_REAL; /* Format is real */ + ++pobj->p; /* Ignore enginnering sing part */ + is_minus_exp = *pobj->p == '-' ? (++pobj->p, 1) : 0; /* Check if negative */ + if (*pobj->p == '+') { /* Optional '+' is possible too */ + ++pobj->p; + } + if (*pobj->p < '0' || *pobj->p > '9') { /* Must be followed by number characters */ + return lwjsonERRJSON; + } + + /* Parse exponent number */ + for (exp_cnt = 0; *pobj->p >= '0' && *pobj->p <= '9'; ++pobj->p) { + exp_cnt = exp_cnt * 10 + (*pobj->p - '0'); + } + /* Calculate new value for exponent 10^exponent */ + if (is_minus_exp) { + for (; exp_cnt > 0; num /= 10, --exp_cnt) {} + } else { + for (; exp_cnt > 0; num *= 10, --exp_cnt) {} + } + } + if (is_minus) { + num = -num; + } + + /* Write output values */ + if (tout != NULL) { + *tout = type; + } + if (type == LWJSON_TYPE_NUM_INT) { + *iout = (lwjson_int_t)num; + } else { + *fout = num; + } + return lwjsonOK; +} + +/** + * \brief Create path segment from input path for search operation + * \param[in,out] p: Pointer to pointer to input path. Pointer is modified + * \param[out] opath: Pointer to pointer to write path segment + * \param[out] olen: Pointer to variable to write length of segment + * \param[out] is_last: Pointer to write if this is last segment + * \return `1` on success, `0` otherwise + */ +static uint8_t +prv_create_path_segment(const char** p, const char** opath, size_t* olen, uint8_t* is_last) { + const char* s = *p; + + *is_last = 0; + *opath = NULL; + *olen = 0; + + /* Check input path */ + if (s == NULL || *s == '\0') { + *is_last = 1; + return 0; + } + + /* + * Path must be one of: + * - literal text + * - "#" followed by dot "." + */ + if (*s == '#') { + *opath = s; + for (*olen = 0;; ++s, ++(*olen)) { + if (*s == '.') { + ++s; + break; + } else if (*s == '\0') { + if (*olen == 1) { + return 0; + } else { + break; + } + } + } + *p = s; + } else { + *opath = s; + for (*olen = 0; *s != '\0' && *s != '.'; ++(*olen), ++s) {} + *p = s + 1; + } + if (*s == '\0') { + *is_last = 1; + } + return 1; +} + +/** + * \brief Input recursive function for find operation + * \param[in] parent: Parent token of type \ref LWJSON_TYPE_ARRAY or LWJSON_TYPE_OBJECT + * \param[in] path: Path to search for starting this token further + * \return Found token on success, `NULL` otherwise + */ +static const lwjson_token_t* +prv_find(const lwjson_token_t* parent, const char* path) { + const char* segment; + size_t segment_len; + uint8_t is_last, result; + + /* Get path segments */ + if ((result = prv_create_path_segment(&path, &segment, &segment_len, &is_last)) != 0) { + /* Check if detected an array request */ + if (*segment == '#') { + /* Parent must be array */ + if (parent->type != LWJSON_TYPE_ARRAY) { + return NULL; + } + + /* Check if index requested */ + if (segment_len > 1) { + const lwjson_token_t* t; + size_t index = 0; + + /* Parse number */ + for (size_t i = 1; i < segment_len; ++i) { + if (segment[i] < '0' || segment[i] > '9') { + return NULL; + } else { + index = index * 10 + (segment[i] - '0'); + } + } + + /* Start from beginning */ + for (t = parent->u.first_child; t != NULL && index > 0; t = t->next, --index) {} + if (t != NULL) { + if (is_last) { + return t; + } else { + return prv_find(t, path); + } + } + return NULL; + } + + /* Scan all indexes and get first match */ + for (const lwjson_token_t *tmp_t, *t = parent->u.first_child; t != NULL; t = t->next) { + if ((tmp_t = prv_find(t, path)) != NULL) { + return tmp_t; + } + } + } else { + if (parent->type != LWJSON_TYPE_OBJECT) { + return NULL; + } + for (const lwjson_token_t* t = parent->u.first_child; t != NULL; t = t->next) { + if (t->token_name_len == segment_len && !strncmp(t->token_name, segment, segment_len)) { + const lwjson_token_t* tmp_t; + if (is_last) { + return t; + } + if ((tmp_t = prv_find(t, path)) != NULL) { + return tmp_t; + } + } + } + } + } + return NULL; +} + +/** + * \brief Check for character after opening bracket of array or object + * \param[in,out] pobj: JSON string + * \param[in] t: Token to check for type + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +static inline lwjsonr_t +prv_check_valid_char_after_open_bracket(lwjson_int_str_t* pobj, lwjson_token_t* t) { + lwjsonr_t res; + + /* Check next character after object open */ + if ((res = prv_skip_blank(pobj)) != lwjsonOK) { + return res; + } + if (*pobj->p == '\0' || (t->type == LWJSON_TYPE_OBJECT && (*pobj->p != '"' && *pobj->p != '}')) + || (t->type == LWJSON_TYPE_ARRAY + && (*pobj->p != '"' && *pobj->p != ']' && *pobj->p != '[' && *pobj->p != '{' && *pobj->p != '-' + && (*pobj->p < '0' || *pobj->p > '9') && *pobj->p != 't' && *pobj->p != 'n' && *pobj->p != 'f'))) { + res = lwjsonERRJSON; + } + return res; +} + +/** + * \brief Setup LwJSON instance for parsing JSON strings + * \param[in,out] lw: LwJSON instance + * \param[in] tokens: Pointer to array of tokens used for parsing + * \param[in] tokens_len: Number of tokens + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +lwjsonr_t +lwjson_init(lwjson_t* lw, lwjson_token_t* tokens, size_t tokens_len) { + memset(lw, 0x00, sizeof(*lw)); + memset(tokens, 0x00, sizeof(*tokens) * tokens_len); + lw->tokens = tokens; + lw->tokens_len = tokens_len; + lw->first_token.type = LWJSON_TYPE_OBJECT; + return lwjsonOK; +} + +/** + * \brief Parse JSON data with length parameter + * JSON format must be complete and must comply with RFC4627 + * \param[in,out] lw: LwJSON instance + * \param[in] json_data: JSON string to parse + * \param[in] jsonÄŒlen: JSON data length + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +lwjsonr_t +lwjson_parse_ex(lwjson_t* lw, const void* json_data, size_t json_len) { + lwjsonr_t res = lwjsonOK; + lwjson_token_t *t, *to; + lwjson_int_str_t pobj = {.start = json_data, .len = json_len, .p = json_data}; + + /* Check input parameters */ + if (lw == NULL || json_data == NULL || json_len == 0) { + res = lwjsonERRPAR; + goto ret; + } + + /* set first token */ + to = &lw->first_token; + + /* values from very beginning */ + lw->flags.parsed = 0; + lw->next_free_token_pos = 0; + memset(to, 0x00, sizeof(*to)); + + /* First parse */ + if ((res = prv_skip_blank(&pobj)) != lwjsonOK) { + goto ret; + } + if (*pobj.p == '{') { + to->type = LWJSON_TYPE_OBJECT; + } else if (*pobj.p == '[') { + to->type = LWJSON_TYPE_ARRAY; + } else { + res = lwjsonERRJSON; + goto ret; + } + ++pobj.p; + if ((res = prv_check_valid_char_after_open_bracket(&pobj, to)) != lwjsonOK) { + goto ret; + } + + /* Process all characters as indicated by input user */ + while (pobj.p != NULL && *pobj.p != '\0' && (size_t)(pobj.p - pobj.start) < pobj.len) { + /* Filter out blanks */ + if ((res = prv_skip_blank(&pobj)) != lwjsonOK) { + goto ret; + } + if (*pobj.p == ',') { + ++pobj.p; + continue; + } + + /* Check if end of object or array*/ + if (*pobj.p == (to->type == LWJSON_TYPE_OBJECT ? '}' : ']')) { + lwjson_token_t* parent = to->next; + to->next = NULL; + ++pobj.p; + + /* End of string if to == NULL (no parent), check if properly terminated */ + if ((to = parent) == NULL) { + prv_skip_blank(&pobj); + res = (pobj.p == NULL || *pobj.p == '\0' || (size_t)(pobj.p - pobj.start) == pobj.len) ? lwjsonOK + : lwjsonERR; + goto ret; + } + continue; + } + + /* Allocate new token */ + if ((t = prv_alloc_token(lw)) == NULL) { + res = lwjsonERRMEM; + goto ret; + } + + /* If object type is not array, first thing is property that starts with quotes */ + if (to->type != LWJSON_TYPE_ARRAY) { + if (*pobj.p != '"') { + res = lwjsonERRJSON; + goto ret; + } + if ((res = prv_parse_property_name(&pobj, t)) != lwjsonOK) { + goto ret; + } + } + + /* Add element to linked list */ + if (to->u.first_child == NULL) { + to->u.first_child = t; + } else { + lwjson_token_t* c; + for (c = to->u.first_child; c->next != NULL; c = c->next) {} + c->next = t; + } + + /* Check next character to process */ + switch (*pobj.p) { + case '{': + case '[': + t->type = *pobj.p == '{' ? LWJSON_TYPE_OBJECT : LWJSON_TYPE_ARRAY; + ++pobj.p; + if ((res = prv_check_valid_char_after_open_bracket(&pobj, t)) != lwjsonOK) { + goto ret; + } + t->next = to; /* Temporary saved as parent object */ + to = t; + break; + case '"': + if ((res = prv_parse_string(&pobj, &t->u.str.token_value, &t->u.str.token_value_len)) == lwjsonOK) { + t->type = LWJSON_TYPE_STRING; + } else { + goto ret; + } + break; + case 't': + /* RFC4627 is lower-case only */ + if (strncmp(pobj.p, "true", 4) == 0) { + t->type = LWJSON_TYPE_TRUE; + pobj.p += 4; + } else { + res = lwjsonERRJSON; + goto ret; + } + break; + case 'f': + /* RFC4627 is lower-case only */ + if (strncmp(pobj.p, "false", 5) == 0) { + t->type = LWJSON_TYPE_FALSE; + pobj.p += 5; + } else { + res = lwjsonERRJSON; + goto ret; + } + break; + case 'n': + /* RFC4627 is lower-case only */ + if (strncmp(pobj.p, "null", 4) == 0) { + t->type = LWJSON_TYPE_NULL; + pobj.p += 4; + } else { + res = lwjsonERRJSON; + goto ret; + } + break; + default: + if (*pobj.p == '-' || (*pobj.p >= '0' && *pobj.p <= '9')) { + if (prv_parse_number(&pobj, &t->type, &t->u.num_real, &t->u.num_int) != lwjsonOK) { + res = lwjsonERRJSON; + goto ret; + } + } else { + res = lwjsonERRJSON; + goto ret; + } + break; + } + + /* Below code is used to check characters after valid tokens */ + if (t->type == LWJSON_TYPE_ARRAY || t->type == LWJSON_TYPE_OBJECT) { + continue; + } + + /* + * Check what are values after the token value + * + * As per RFC4627, every token value may have one or more + * blank characters, followed by one of below options: + * - Comma separator for next token + * - End of array indication + * - End of object indication + */ + if ((res = prv_skip_blank(&pobj)) != lwjsonOK) { + goto ret; + } + /* Check if valid string is availabe after */ + if (pobj.p == NULL || *pobj.p == '\0' || (*pobj.p != ',' && *pobj.p != ']' && *pobj.p != '}')) { + res = lwjsonERRJSON; + goto ret; + } else if (*pobj.p == ',') { /* Check to advance to next token immediatey */ + ++pobj.p; + } + } + if (to != &lw->first_token || (to != NULL && to->next != NULL)) { + res = lwjsonERRJSON; + to = NULL; + } + if (to != NULL) { + if (to->type != LWJSON_TYPE_ARRAY && to->type != LWJSON_TYPE_OBJECT) { + res = lwjsonERRJSON; + } + to->token_name = NULL; + to->token_name_len = 0; + } +ret: + if (res == lwjsonOK) { + lw->flags.parsed = 1; + } + return res; +} + +/** + * \brief Parse input JSON format + * JSON format must be complete and must comply with RFC4627 + * \param[in,out] lw: LwJSON instance + * \param[in] json_str: JSON string to parse + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +lwjsonr_t +lwjson_parse(lwjson_t* lw, const char* json_str) { + return lwjson_parse_ex(lw, json_str, strlen(json_str)); +} + +/** + * \brief Free token instances (specially used in case of dynamic memory allocation) + * \param[in,out] lw: LwJSON instance + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +lwjsonr_t +lwjson_free(lwjson_t* lw) { + memset(lw->tokens, 0x00, sizeof(*lw->tokens) * lw->tokens_len); + lw->flags.parsed = 0; + return lwjsonOK; +} + +/** + * \brief Find first match in the given path for JSON entry + * JSON must be valid and parsed with \ref lwjson_parse function + * \param[in] lw: JSON instance with parsed JSON string + * \param[in] path: Path with dot-separated entries to search for the JSON key to return + * \return Pointer to found token on success, `NULL` if token cannot be found + */ +const lwjson_token_t* +lwjson_find(lwjson_t* lw, const char* path) { + if (lw == NULL || !lw->flags.parsed || path == NULL) { + return NULL; + } + return prv_find(lwjson_get_first_token(lw), path); +} + +/** + * \brief Find first match in the given path for JSON path + * JSON must be valid and parsed with \ref lwjson_parse function + * + * \param[in] lw: JSON instance with parsed JSON string + * \param[in] token: Root token to start search at. + * Token must be type \ref LWJSON_TYPE_OBJECT or \ref LWJSON_TYPE_ARRAY. + * Set to `NULL` to use root token of LwJSON object + * \param[in] path: path with dot-separated entries to search for JSON key + * \return Pointer to found token on success, `NULL` if token cannot be found + */ +const lwjson_token_t* +lwjson_find_ex(lwjson_t* lw, const lwjson_token_t* token, const char* path) { + if (lw == NULL || !lw->flags.parsed || path == NULL) { + return NULL; + } + if (token == NULL) { + token = lwjson_get_first_token(lw); + } + if (token == NULL || (token->type != LWJSON_TYPE_ARRAY && token->type != LWJSON_TYPE_OBJECT)) { + return NULL; + } + return prv_find(token, path); +} diff --git a/src/lwjson.h b/src/lwjson.h new file mode 100644 index 0000000..ea85950 --- /dev/null +++ b/src/lwjson.h @@ -0,0 +1,335 @@ +/** + * \file lwjson.h + * \brief LwJSON - Lightweight JSON format parser + */ + +/* + * Copyright (c) 2022 Tilen MAJERLE + * + * 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. + * + * This file is part of LwJSON - Lightweight JSON format parser. + * + * Author: Tilen MAJERLE + * Version: v1.5.0 + */ +#ifndef LWJSON_HDR_H +#define LWJSON_HDR_H + +#include +#include +#include "lwjson_opt.h" + +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ + +/** + * \defgroup LWJSON Lightweight JSON format parser + * \brief LwJSON - Lightweight JSON format parser + * \{ + */ + +/** + * \brief Get size of statically allocated array + * \param[in] x: Object to get array size of + * \return Number of elements in array + */ +#define LWJSON_ARRAYSIZE(x) (sizeof(x) / sizeof((x)[0])) + +/** + * \brief List of supported JSON types + */ +typedef enum { + LWJSON_TYPE_STRING, /*!< String/Text format. Everything that has beginning and ending quote character */ + LWJSON_TYPE_NUM_INT, /*!< Number type for integer */ + LWJSON_TYPE_NUM_REAL, /*!< Number type for real number */ + LWJSON_TYPE_OBJECT, /*!< Object data type */ + LWJSON_TYPE_ARRAY, /*!< Array data type */ + LWJSON_TYPE_TRUE, /*!< True boolean value */ + LWJSON_TYPE_FALSE, /*!< False boolean value */ + LWJSON_TYPE_NULL, /*!< Null value */ +} lwjson_type_t; + +/** + * \brief Real data type + */ +typedef LWJSON_CFG_REAL_TYPE lwjson_real_t; + +/** + * \brief Integer data type + */ +typedef LWJSON_CFG_INT_TYPE lwjson_int_t; + +/** + * \brief JSON token + */ +typedef struct lwjson_token { + struct lwjson_token* next; /*!< Next token on a list */ + lwjson_type_t type; /*!< Token type */ + const char* token_name; /*!< Token name (if exists) */ + size_t token_name_len; /*!< Length of token name (this is needed to support const input strings to parse) */ + + union { + struct { + const char* token_value; /*!< Pointer to the beginning of the string */ + size_t + token_value_len; /*!< Length of token value (this is needed to support const input strings to parse) */ + } str; /*!< String data */ + + lwjson_real_t num_real; /*!< Real number format */ + lwjson_int_t num_int; /*!< Int number format */ + struct lwjson_token* first_child; /*!< First children object for object or array type */ + } u; /*!< Union with different data types */ +} lwjson_token_t; + +/** + * \brief JSON result enumeration + */ +typedef enum { + lwjsonOK = 0x00, /*!< Function returns successfully */ + lwjsonERR, /*!< Generic error message */ + lwjsonERRJSON, /*!< Error JSON format */ + lwjsonERRMEM, /*!< Memory error */ + lwjsonERRPAR, /*!< Parameter error */ + + lwjsonSTREAMWAITFIRSTCHAR, /*!< Streaming parser did not yet receive first valid character + indicating start of JSON sequence */ + lwjsonSTREAMDONE, /*!< Streaming parser is done, + closing character matched the stream opening one */ + lwjsonSTREAMINPROG, /*!< Stream parsing is still in progress */ +} + +lwjsonr_t; + +/** + * \brief LwJSON instance + */ +typedef struct { + lwjson_token_t* tokens; /*!< Pointer to array of tokens */ + size_t tokens_len; /*!< Size of all tokens */ + size_t next_free_token_pos; /*!< Position of next free token instance */ + lwjson_token_t first_token; /*!< First token on a list */ + + struct { + uint8_t parsed : 1; /*!< Flag indicating JSON parsing has finished successfully */ + } flags; /*!< List of flags */ +} lwjson_t; + +lwjsonr_t lwjson_init(lwjson_t* lw, lwjson_token_t* tokens, size_t tokens_len); +lwjsonr_t lwjson_parse_ex(lwjson_t* lw, const void* json_data, size_t len); +lwjsonr_t lwjson_parse(lwjson_t* lw, const char* json_str); +const lwjson_token_t* lwjson_find(lwjson_t* lw, const char* path); +const lwjson_token_t* lwjson_find_ex(lwjson_t* lw, const lwjson_token_t* token, const char* path); +lwjsonr_t lwjson_free(lwjson_t* lw); + +void lwjson_print_token(const lwjson_token_t* token); +void lwjson_print_json(const lwjson_t* lw); + +/** + * \brief Object type for streaming parser + */ +typedef enum { + LWJSON_STREAM_TYPE_NONE, /*!< No entry - not used */ + LWJSON_STREAM_TYPE_OBJECT, /*!< Object indication */ + LWJSON_STREAM_TYPE_OBJECT_END, /*!< Object end indication */ + LWJSON_STREAM_TYPE_ARRAY, /*!< Array indication */ + LWJSON_STREAM_TYPE_ARRAY_END, /*!< Array end indication */ + LWJSON_STREAM_TYPE_KEY, /*!< Key string */ + LWJSON_STREAM_TYPE_STRING, /*!< Strin type */ + LWJSON_STREAM_TYPE_TRUE, /*!< True primitive */ + LWJSON_STREAM_TYPE_FALSE, /*!< False primitive */ + LWJSON_STREAM_TYPE_NULL, /*!< Null primitive */ + LWJSON_STREAM_TYPE_NUMBER, /*!< Generic number */ +} lwjson_stream_type_t; + +/** + * \brief Stream parsing stack object + */ +typedef struct { + lwjson_stream_type_t type; /*!< Streaming type - current value */ + + union { + char name[LWJSON_CFG_STREAM_KEY_MAX_LEN + + 1]; /*!< Last known key name, used only for \ref LWJSON_STREAM_TYPE_KEY type */ + uint16_t index; /*!< Current index when type is an array */ + } meta; /*!< Meta information */ +} lwjson_stream_stack_t; + +typedef enum { + LWJSON_STREAM_STATE_WAITINGFIRSTCHAR = 0x00, /*!< State to wait for very first opening character */ + LWJSON_STREAM_STATE_PARSING, /*!< In parsing of the first char state - detecting next character state */ + LWJSON_STREAM_STATE_PARSING_STRING, /*!< Parse string primitive */ + LWJSON_STREAM_STATE_PARSING_PRIMITIVE, /*!< Parse any primitive that is non-string, either "true", "false", "null" or a number */ +} lwjson_stream_state_t; + +/* Forward declaration */ +struct lwjson_stream_parser; + +/** + * \brief Callback function for various events + * + */ +typedef void (*lwjson_stream_parser_callback_fn)(struct lwjson_stream_parser* jsp, lwjson_stream_type_t type); + +/** + * \brief LwJSON streaming structure + */ +typedef struct lwjson_stream_parser { + lwjson_stream_stack_t + stack[LWJSON_CFG_STREAM_STACK_SIZE]; /*!< Stack used for parsing. TODO: Add conditional compilation flag */ + size_t stack_pos; /*!< Current stack position */ + + lwjson_stream_state_t parse_state; /*!< Parser state */ + + lwjson_stream_parser_callback_fn evt_fn; /*!< Event function for user */ + + /* State */ + union { + struct { + char buff[LWJSON_CFG_STREAM_STRING_MAX_LEN + + 1]; /*!< Buffer to write temporary data. TODO: Size to be variable with define */ + size_t buff_pos; /*!< Buffer position for next write (length of bytes in buffer) */ + size_t buff_total_pos; /*!< Total buffer position used up to now (in several data chunks) */ + uint8_t is_last; /*!< Status indicates if this is the last part of the string */ + } str; /*!< String structure. It is only used for keys and string objects. + Use primitive part for all other options */ + + struct { + char buff[LWJSON_CFG_STREAM_PRIMITIVE_MAX_LEN + 1]; /*!< Temporary write buffer */ + size_t buff_pos; /*!< Buffer position for next write */ + } prim; /*!< Primitive object. Used for all types, except key or string */ + + /* Todo: Add other types */ + } data; /*!< Data union used to parse various */ + + char prev_c; /*!< History of characters */ + void *udata; /*!< User data */ +} lwjson_stream_parser_t; + +lwjsonr_t lwjson_stream_init(lwjson_stream_parser_t* jsp, lwjson_stream_parser_callback_fn evt_fn); +lwjsonr_t lwjson_stream_reset(lwjson_stream_parser_t* jsp); +lwjsonr_t lwjson_stream_parse(lwjson_stream_parser_t* jsp, char c); + +/** + * \brief Get number of tokens used to parse JSON + * \param[in] lw: Pointer to LwJSON instance + * \return Number of tokens used to parse JSON + */ +#define lwjson_get_tokens_used(lw) (((lw) != NULL) ? ((lw)->next_free_token_pos + 1) : 0) + +/** + * \brief Get very first token of LwJSON instance + * \param[in] lw: Pointer to LwJSON instance + * \return Pointer to first token + */ +#define lwjson_get_first_token(lw) (((lw) != NULL) ? (&(lw)->first_token) : NULL) + +/** + * \brief Get token value for \ref LWJSON_TYPE_NUM_INT type + * \param[in] token: token with integer type + * \return Int number if type is integer, `0` otherwise + */ +#define lwjson_get_val_int(token) \ + ((lwjson_int_t)(((token) != NULL && (token)->type == LWJSON_TYPE_NUM_INT) ? (token)->u.num_int : 0)) + +/** + * \brief Get token value for \ref LWJSON_TYPE_NUM_REAL type + * \param[in] token: token with real type + * \return Real numbeer if type is real, `0` otherwise + */ +#define lwjson_get_val_real(token) \ + ((lwjson_real_t)(((token) != NULL && (token)->type == LWJSON_TYPE_NUM_REAL) ? (token)->u.num_real : 0)) + +/** + * \brief Get first child token for \ref LWJSON_TYPE_OBJECT or \ref LWJSON_TYPE_ARRAY types + * \param[in] token: token with integer type + * \return Pointer to first child or `NULL` if parent token is not object or array + */ +#define lwjson_get_first_child(token) \ + (const void*)(((token) != NULL && ((token)->type == LWJSON_TYPE_OBJECT || (token)->type == LWJSON_TYPE_ARRAY)) \ + ? (token)->u.first_child \ + : NULL) + +/** + * \brief Get string value from JSON token + * \param[in] token: Token with string type + * \param[out] str_len: Pointer to variable holding length of string. + * Set to `NULL` if not used + * \return Pointer to string or `NULL` if invalid token type + */ +static inline const char* +lwjson_get_val_string(const lwjson_token_t* token, size_t* str_len) { + if (token != NULL && token->type == LWJSON_TYPE_STRING) { + if (str_len != NULL) { + *str_len = token->u.str.token_value_len; + } + return token->u.str.token_value; + } + return NULL; +} + +/** + * \brief Get length of string for \ref LWJSON_TYPE_STRING token type + * \param[in] token: token with string type + * \return Length of string in units of bytes + */ +#define lwjson_get_val_string_length(token) \ + ((size_t)(((token) != NULL && (token)->type == LWJSON_TYPE_STRING) ? (token)->u.str.token_value_len : 0)) + +/** + * \brief Compare string token with user input string for a case-sensitive match + * \param[in] token: Token with string type + * \param[in] str: NULL-terminated string to compare + * \return `1` if equal, `0` otherwise + */ +static inline uint8_t +lwjson_string_compare(const lwjson_token_t* token, const char* str) { + if (token != NULL && token->type == LWJSON_TYPE_STRING) { + return strncmp(token->u.str.token_value, str, token->u.str.token_value_len) == 0; + } + return 0; +} + +/** + * \brief Compare string token with user input string for a case-sensitive match + * \param[in] token: Token with string type + * \param[in] str: NULL-terminated string to compare + * \param[in] len: Length of the string in bytes + * \return `1` if equal, `0` otherwise + */ +static inline uint8_t +lwjson_string_compare_n(const lwjson_token_t* token, const char* str, size_t len) { + if (token != NULL && token->type == LWJSON_TYPE_STRING && len <= token->u.str.token_value_len) { + return strncmp(token->u.str.token_value, str, len) == 0; + } + return 0; +} + +/** + * \} + */ + +#ifdef __cplusplus +} +#endif /* __cplusplus */ + +#endif /* LWJSON_HDR_H */ diff --git a/src/lwjson_debug.c b/src/lwjson_debug.c new file mode 100644 index 0000000..a9b77f2 --- /dev/null +++ b/src/lwjson_debug.c @@ -0,0 +1,134 @@ +/** + * \file lwjson_debug.c + * \brief Debug and print function for tokens + */ + +/* + * Copyright (c) 2022 Tilen MAJERLE + * + * 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. + * + * This file is part of LwJSON - Lightweight JSON format parser. + * + * Author: Tilen MAJERLE + * Version: v1.5.0 + */ +#include +#include +#include "lwjson.h" + +/** + * \brief Token print instance + */ +typedef struct { + size_t indent; /*!< Indent level for token print */ +} lwjson_token_print_t; + +/** + * \brief Print token value + * \param[in] p: Token print instance + * \param[in] token: Token to print + */ +static void +prv_print_token(lwjson_token_print_t* p, const lwjson_token_t* token) { +#define print_indent() printf("%.*s", (int)((p->indent)), "\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t"); + + if (token == NULL) { + return; + } + + /* Check if token has a name */ + print_indent(); + if (token->token_name != NULL) { + printf("\"%.*s\":", (int)token->token_name_len, token->token_name); + } + + /* Print different types */ + switch (token->type) { + case LWJSON_TYPE_OBJECT: + case LWJSON_TYPE_ARRAY: { + printf("%c", token->type == LWJSON_TYPE_OBJECT ? '{' : '['); + if (token->u.first_child != NULL) { + printf("\n"); + ++p->indent; + for (const lwjson_token_t* t = lwjson_get_first_child(token); t != NULL; t = t->next) { + prv_print_token(p, t); + } + --p->indent; + print_indent(); + } + printf("%c", token->type == LWJSON_TYPE_OBJECT ? '}' : ']'); + break; + } + case LWJSON_TYPE_STRING: { + printf("\"%.*s\"", (int)lwjson_get_val_string_length(token), lwjson_get_val_string(token, NULL)); + break; + } + case LWJSON_TYPE_NUM_INT: { + printf("%lld", (long long)lwjson_get_val_int(token)); + break; + } + case LWJSON_TYPE_NUM_REAL: { + printf("%f", (double)lwjson_get_val_real(token)); + break; + } + case LWJSON_TYPE_TRUE: { + printf("true"); + break; + } + case LWJSON_TYPE_FALSE: { + printf("false"); + break; + } + case LWJSON_TYPE_NULL: { + printf("NULL"); + break; + } + default: + break; + } + if (token->next != NULL) { + printf(","); + } + printf("\n"); +} + +/** + * \brief Prints and outputs token data to the stream output + * \note This function is not re-entrant + * \param[in] token: Token to print + */ +void +lwjson_print_token(const lwjson_token_t* token) { + lwjson_token_print_t p = {0}; + prv_print_token(&p, token); +} + +/** + * \brief Prints and outputs full parsed LwJSON instance + * \note This function is not re-entrant + * \param[in] lw: LwJSON instance to print + */ +void +lwjson_print_json(const lwjson_t* lw) { + lwjson_token_print_t p = {0}; + prv_print_token(&p, lwjson_get_first_token(lw)); +} diff --git a/src/lwjson_opt.h b/src/lwjson_opt.h new file mode 100644 index 0000000..8c6ff0b --- /dev/null +++ b/src/lwjson_opt.h @@ -0,0 +1,136 @@ +/** + * \file lwjson_opt.h + * \brief LwJSON options + */ + +/* + * Copyright (c) 2022 Tilen MAJERLE + * + * 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. + * + * This file is part of LwJSON - Lightweight JSON format parser. + * + * Author: Tilen MAJERLE + * Version: v1.5.0 + */ +#ifndef LWJSON_HDR_OPT_H +#define LWJSON_HDR_OPT_H + +//#define LWJSON_DEV 1 +/* Uncomment to ignore user options (or set macro in compiler flags) */ +#define LWJSON_IGNORE_USER_OPTS + +/* Include application options */ +#ifndef LWJSON_IGNORE_USER_OPTS +#include "lwjson_opts.h" +#endif /* LWJSON_IGNORE_USER_OPTS */ + +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ + +/** + * \defgroup LWJSON_OPT Configuration + * \brief LwJSON options + * \{ + */ + +/** + * \brief Real data type used to parse numbers with floating point number + * \note Data type must be signed, normally `float` or `double` + * + * This is used for numbers in \ref LWJSON_TYPE_NUM_REAL token data type. + */ +#ifndef LWJSON_CFG_REAL_TYPE +#define LWJSON_CFG_REAL_TYPE float +#endif + +/** + * \brief Integer type used to parse numbers + * \note Data type must be signed integer + * + * This is used for numbers in \ref LWJSON_TYPE_NUM_INT token data type. + */ +#ifndef LWJSON_CFG_INT_TYPE +#define LWJSON_CFG_INT_TYPE long long +#endif + +/** + * \brief Enables `1` or disables `0` support for inline comments + * + * Default set to `0` to be JSON compliant + */ +#ifndef LWJSON_CFG_COMMENTS +#define LWJSON_CFG_COMMENTS 0 +#endif + +/** + * \defgroup LWJSON_OPT_STREAM JSON stream + * \brief JSON streaming confiuration + * \{ + */ + +/** + * \brief Max length of token key (object key name) to be available for stack storage + * + */ +#ifndef LWJSON_CFG_STREAM_KEY_MAX_LEN +#define LWJSON_CFG_STREAM_KEY_MAX_LEN 32 +#endif + +/** + * \brief Max stack size (depth) in units of \ref lwjson_stream_stack_t structure + * + */ +#ifndef LWJSON_CFG_STREAM_STACK_SIZE +#define LWJSON_CFG_STREAM_STACK_SIZE 16 +#endif + +/** + * \brief Max size of string for single parsing in units of bytes + * + */ +#ifndef LWJSON_CFG_STREAM_STRING_MAX_LEN +#define LWJSON_CFG_STREAM_STRING_MAX_LEN 256 +#endif + +/** + * \brief Max number of bytes used to parse primitive. + * + * Primitives are all numbers and logical values (null, true, false) + */ +#ifndef LWJSON_CFG_STREAM_PRIMITIVE_MAX_LEN +#define LWJSON_CFG_STREAM_PRIMITIVE_MAX_LEN 32 +#endif + +/** + * \} + */ + +/** + * \} + */ + +#ifdef __cplusplus +} +#endif /* __cplusplus */ + +#endif /* LWJSON_HDR_OPT_H */ diff --git a/src/lwjson_stream.c b/src/lwjson_stream.c new file mode 100644 index 0000000..c14451e --- /dev/null +++ b/src/lwjson_stream.c @@ -0,0 +1,436 @@ +/** + * \file lwjson_stream.c + * \brief Lightweight JSON format parser + */ + +/* + * Copyright (c) 2022 Tilen MAJERLE + * + * 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. + * + * This file is part of LwJSON - Lightweight JSON format parser. + * + * Author: Tilen MAJERLE + * Version: v1.5.0 + */ +#include +#include "lwjson.h" + +#if defined(LWJSON_DEV) +#include +#define DEBUG_STRING_PREFIX_SPACES \ + " " +#define LWJSON_DEBUG(jsp, ...) \ + do { \ + if ((jsp) != NULL) { \ + printf("%.*s", (int)(4 * (jsp)->stack_pos), DEBUG_STRING_PREFIX_SPACES); \ + } \ + printf(__VA_ARGS__); \ + } while (0) + +/* Strings for debug */ +static const char* type_strings[] = { + [LWJSON_STREAM_TYPE_NONE] = "none", + [LWJSON_STREAM_TYPE_OBJECT] = "object", + [LWJSON_STREAM_TYPE_OBJECT_END] = "object_end", + [LWJSON_STREAM_TYPE_ARRAY] = "array", + [LWJSON_STREAM_TYPE_ARRAY_END] = "array_end", + [LWJSON_STREAM_TYPE_KEY] = "key", + [LWJSON_STREAM_TYPE_STRING] = "string", + [LWJSON_STREAM_TYPE_TRUE] = "true", + [LWJSON_STREAM_TYPE_FALSE] = "false", + [LWJSON_STREAM_TYPE_NULL] = "null", + [LWJSON_STREAM_TYPE_NUMBER] = "number", +}; +#else +#define LWJSON_DEBUG(jsp, ...) +#endif /* defined(LWJSON_DEV) */ + +/** + * \brief Sends an event to user for further processing + * + */ +#define SEND_EVT(jsp, type) \ + if ((jsp) != NULL && (jsp)->evt_fn != NULL) { \ + (jsp)->evt_fn((jsp), (type)); \ + } + +/** + * \brief Check if character is a space character (with extended chars) + * \param[in] c: Character to check + * \return `1` if considered extended space, `0` otherwise + */ +#define prv_is_space_char_ext(c) ((c) == ' ' || (c) == '\t' || (c) == '\r' || (c) == '\n' || (c) == '\f') + +/** + * \brief Push "parent" state to the artificial stack + * \param jsp: JSON stream parser instance + * \param type: Stream type to be pushed on stack + * \return `1` on success, `0` otherwise + */ +static uint8_t +prv_stack_push(lwjson_stream_parser_t* jsp, lwjson_stream_type_t type) { + if (jsp->stack_pos < LWJSON_ARRAYSIZE(jsp->stack)) { + jsp->stack[jsp->stack_pos].type = type; + jsp->stack[jsp->stack_pos].meta.index = 0; + LWJSON_DEBUG(jsp, "Pushed to stack: %s\r\n", type_strings[type]); + jsp->stack_pos++; + return 1; + } + return 0; +} + +/** + * \brief Pop value from stack (remove it) and return its value + * \param jsp: JSON stream parser instance + * \return Member of \ref lwjson_stream_type_t enumeration + */ +static lwjson_stream_type_t +prv_stack_pop(lwjson_stream_parser_t* jsp) { + if (jsp->stack_pos > 0) { + lwjson_stream_type_t t = jsp->stack[--jsp->stack_pos].type; + jsp->stack[jsp->stack_pos].type = LWJSON_STREAM_TYPE_NONE; + LWJSON_DEBUG(jsp, "Popped from stack: %s\r\n", type_strings[t]); + + /* Take care of array to indicate number of entries */ + if (jsp->stack_pos > 0 && jsp->stack[jsp->stack_pos - 1].type == LWJSON_STREAM_TYPE_ARRAY) { + jsp->stack[jsp->stack_pos - 1].meta.index++; + } + return t; + } + return LWJSON_STREAM_TYPE_NONE; +} + +/** + * \brief Get top type value currently on the stack + * \param jsp: JSON stream parser instance + * \return Member of \ref lwjson_stream_type_t enumeration + */ +static lwjson_stream_type_t +prv_stack_get_top(lwjson_stream_parser_t* jsp) { + if (jsp->stack_pos > 0) { + return jsp->stack[jsp->stack_pos - 1].type; + } + return LWJSON_STREAM_TYPE_NONE; +} + +/** + * \brief Initialize LwJSON stream object before parsing takes place + * \param[in,out] jsp: Stream JSON structure + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +lwjsonr_t +lwjson_stream_init(lwjson_stream_parser_t* jsp, lwjson_stream_parser_callback_fn evt_fn) { + memset(jsp, 0x00, sizeof(*jsp)); + jsp->parse_state = LWJSON_STREAM_STATE_WAITINGFIRSTCHAR; + jsp->evt_fn = evt_fn; + return lwjsonOK; +} + +/** + * \brief Reset LwJSON stream structure + * + * \param jsp: LwJSON stream parser + * \return \ref lwjsonOK on success, member of \ref lwjsonr_t otherwise + */ +lwjsonr_t +lwjson_stream_reset(lwjson_stream_parser_t* jsp) { + jsp->parse_state = LWJSON_STREAM_STATE_WAITINGFIRSTCHAR; + jsp->stack_pos = 0; + return lwjsonOK; +} + +/** + * \brief Parse JSON string in streaming mode + * \param[in,out] jsp: Stream JSON structure + * \param[in] c: Character to parse + * \return \ref lwjsonOK if parsing is in progress and no hard error detected + * \ref lwjsonSTREAMDONE when valid JSON was detected and stack level reached back `0` level + */ +lwjsonr_t +lwjson_stream_parse(lwjson_stream_parser_t* jsp, char c) { + /* Get first character first */ + if (jsp->parse_state == LWJSON_STREAM_STATE_WAITINGFIRSTCHAR && c != '{' && c != '[') { + return lwjsonSTREAMDONE; + } + +start_over: + /* + * Determine what to do from parsing state + */ + switch (jsp->parse_state) { + + /* + * Waiting for very first valid characters, + * that is used to indicate start of JSON stream + */ + case LWJSON_STREAM_STATE_WAITINGFIRSTCHAR: + case LWJSON_STREAM_STATE_PARSING: { + /* Determine start of object or an array */ + if (c == '{' || c == '[') { + /* Reset stack pointer if this character came from waiting for first character */ + if (jsp->parse_state == LWJSON_STREAM_STATE_WAITINGFIRSTCHAR) { + jsp->stack_pos = 0; + } + if (!prv_stack_push(jsp, c == '{' ? LWJSON_STREAM_TYPE_OBJECT : LWJSON_STREAM_TYPE_ARRAY)) { + LWJSON_DEBUG(jsp, "Cannot push object/array to stack\r\n"); + return lwjsonERRMEM; + } + jsp->parse_state = LWJSON_STREAM_STATE_PARSING; + SEND_EVT(jsp, c == '{' ? LWJSON_STREAM_TYPE_OBJECT : LWJSON_STREAM_TYPE_ARRAY); + + /* Determine end of object or an array */ + } else if (c == '}' || c == ']') { + lwjson_stream_type_t t = prv_stack_get_top(jsp); + + /* + * If it is a key last entry on closing area, + * it is an error - an example: {"key":} + */ + if (t == LWJSON_STREAM_TYPE_KEY) { + LWJSON_DEBUG(jsp, "ERROR - key should not be followed by ] without value for a key\r\n"); + return lwjsonERRJSON; + } + + /* + * Check if closing character matches stack value + * Avoid cases like: {"key":"value"] or ["v1", "v2", "v3"} + */ + if ((c == '}' && t != LWJSON_STREAM_TYPE_OBJECT) || (c == ']' && t != LWJSON_STREAM_TYPE_ARRAY)) { + LWJSON_DEBUG(jsp, "ERROR - closing character '%c' does not match stack element \"%s\"\r\n", c, + type_strings[t]); + return lwjsonERRJSON; + } + + /* Now remove the array or object from stack */ + if (prv_stack_pop(jsp) == LWJSON_STREAM_TYPE_NONE) { + return lwjsonERRJSON; + } + + /* + * Check if above is a key type + * and remove it too as we finished with processing of potential case. + * + * {"key":{"abc":1}} - remove "key" part + */ + if (prv_stack_get_top(jsp) == LWJSON_STREAM_TYPE_KEY) { + prv_stack_pop(jsp); + } + SEND_EVT(jsp, c == '}' ? LWJSON_STREAM_TYPE_OBJECT_END : LWJSON_STREAM_TYPE_ARRAY_END); + + /* If that is the end of JSON */ + if (jsp->stack_pos == 0) { + return lwjsonSTREAMDONE; + } + + /* Determine start of string - can be key or regular string (in array or after key) */ + } else if (c == '"') { +#if defined(LWJSON_DEV) + lwjson_stream_type_t t = prv_stack_get_top(jsp); + if (t == LWJSON_STREAM_TYPE_OBJECT) { + LWJSON_DEBUG(jsp, "Start of string parsing - expected key name in an object\r\n"); + } else if (t == LWJSON_STREAM_TYPE_KEY) { + LWJSON_DEBUG(jsp, + "Start of string parsing - string value associated to previous key in an object\r\n"); + } else if (t == LWJSON_STREAM_TYPE_ARRAY) { + LWJSON_DEBUG(jsp, "Start of string parsing - string entry in an array\r\n"); + } +#endif /* defined(LWJSON_DEV) */ + jsp->parse_state = LWJSON_STREAM_STATE_PARSING_STRING; + memset(&jsp->data.str, 0x00, sizeof(jsp->data.str)); + + /* Check for end of key character */ + } else if (c == ':') { + lwjson_stream_type_t t = prv_stack_get_top(jsp); + + /* + * Color can only be followed by key on the stack + * + * It is clear JSON error if this is not the case + */ + if (t != LWJSON_STREAM_TYPE_KEY) { + LWJSON_DEBUG(jsp, "Error - wrong ':' character\r\n"); + return lwjsonERRJSON; + } + /* Check if this is start of number or "true", "false" or "null" */ + } else if (c == '-' || (c >= '0' && c <= '9') || c == 't' || c == 'f' || c == 'n') { + LWJSON_DEBUG(jsp, "Start of primitive parsing parsing - %s, First char: %c\r\n", + (c == '-' || (c >= '0' && c <= '9')) ? "number" : "true,false,null", c); + jsp->parse_state = LWJSON_STREAM_STATE_PARSING_PRIMITIVE; + memset(&jsp->data.prim, 0x00, sizeof(jsp->data.prim)); + jsp->data.prim.buff[jsp->data.prim.buff_pos++] = c; + } + break; + } + + /* + * Parse any type of string in a sequence + * + * It is used for key or string in an object or an array + */ + case LWJSON_STREAM_STATE_PARSING_STRING: { + lwjson_stream_type_t t = prv_stack_get_top(jsp); + + /* + * Quote character may trigger end of string, + * or if backslasled before - it is part of string + * + * TODO: Handle backslash + */ + if (c == '"' && jsp->prev_c != '\\') { +#if defined(LWJSON_DEV) + if (t == LWJSON_STREAM_TYPE_OBJECT) { + LWJSON_DEBUG(jsp, "End of string parsing - object key name: \"%s\"\r\n", jsp->data.str.buff); + } else if (t == LWJSON_STREAM_TYPE_KEY) { + LWJSON_DEBUG( + jsp, "End of string parsing - string value associated to previous key in an object: \"%s\"\r\n", + jsp->data.str.buff); + } else if (t == LWJSON_STREAM_TYPE_ARRAY) { + LWJSON_DEBUG(jsp, "End of string parsing - an array string entry: \"%s\"\r\n", jsp->data.str.buff); + } +#endif /* defined(LWJSON_DEV) */ + + /* Set is_last to 1 as this is the last part of this string token */ + jsp->data.str.is_last = 1; + + /* + * When top of stack is object - string is treated as a key + * When top of stack is a key - string is a value for a key - notify user and pop the value for key + * When top of stack is an array - string is one type - notify user and don't do anything + */ + if (t == LWJSON_STREAM_TYPE_OBJECT) { + SEND_EVT(jsp, LWJSON_STREAM_TYPE_KEY); + if (prv_stack_push(jsp, LWJSON_STREAM_TYPE_KEY)) { + size_t len = jsp->data.str.buff_pos; + if (len > (sizeof(jsp->stack[0].meta.name) - 1)) { + len = sizeof(jsp->stack[0].meta.name) - 1; + } + memcpy(jsp->stack[jsp->stack_pos - 1].meta.name, jsp->data.str.buff, len); + jsp->stack[jsp->stack_pos - 1].meta.name[len] = '\0'; + } else { + LWJSON_DEBUG(jsp, "Cannot push key to stack\r\n"); + return lwjsonERRMEM; + } + } else if (t == LWJSON_STREAM_TYPE_KEY) { + SEND_EVT(jsp, LWJSON_STREAM_TYPE_STRING); + prv_stack_pop(jsp); + /* Next character to wait for is either space or comma or end of object */ + } else if (t == LWJSON_STREAM_TYPE_ARRAY) { + SEND_EVT(jsp, LWJSON_STREAM_TYPE_STRING); + jsp->stack[jsp->stack_pos - 1].meta.index++; + } + jsp->parse_state = LWJSON_STREAM_STATE_PARSING; + } else { + /* TODO: Check other backslash elements */ + jsp->data.str.buff[jsp->data.str.buff_pos++] = c; + jsp->data.str.buff_total_pos++; + + /* Handle buffer "overflow" */ + if (jsp->data.str.buff_pos >= (LWJSON_CFG_STREAM_STRING_MAX_LEN - 1)) { + jsp->data.str.buff[jsp->data.str.buff_pos] = '\0'; + + /* + * - For array or key types - following one is always string + * - For object type - character is key + */ + SEND_EVT(jsp, (t == LWJSON_STREAM_TYPE_KEY || t == LWJSON_STREAM_TYPE_ARRAY) + ? LWJSON_STREAM_TYPE_STRING + : LWJSON_STREAM_TYPE_KEY); + jsp->data.str.buff_pos = 0; + } + } + break; + } + + /* + * Parse any type of primitive that is not a string. + * + * true, false, null or any number primitive + */ + case LWJSON_STREAM_STATE_PARSING_PRIMITIVE: { + /* Any character except space, comma, or end of array/object are valid */ + if (!prv_is_space_char_ext(c) && c != ',' && c != ']' && c != '}') { + if (jsp->data.prim.buff_pos < sizeof(jsp->data.prim.buff) - 1) { + jsp->data.prim.buff[jsp->data.prim.buff_pos++] = c; + } + } else { + lwjson_stream_type_t t = prv_stack_get_top(jsp); + +#if defined(LWJSON_DEV) + if (t == LWJSON_STREAM_TYPE_OBJECT) { + /* TODO: Handle error - primitive cannot be just after object */ + } else if (t == LWJSON_STREAM_TYPE_KEY) { + LWJSON_DEBUG( + jsp, + "End of primitive parsing - string value associated to previous key in an object: \"%s\"\r\n", + jsp->data.prim.buff); + } else if (t == LWJSON_STREAM_TYPE_ARRAY) { + LWJSON_DEBUG(jsp, "End of primitive parsing - an array string entry: \"%s\"\r\n", + jsp->data.prim.buff); + } +#endif /* defined(LWJSON_DEV) */ + + /* + * This is the end of primitive parsing + * + * It is assumed that buffer for primitive can handle at least + * true, false, null or all number characters (that being real or int number) + */ + if (jsp->data.prim.buff_pos == 4 && strncmp(jsp->data.prim.buff, "true", 4) == 0) { + LWJSON_DEBUG(jsp, "Primitive parsed as %s\r\n", "true"); + SEND_EVT(jsp, LWJSON_STREAM_TYPE_TRUE); + } else if (jsp->data.prim.buff_pos == 4 && strncmp(jsp->data.prim.buff, "null", 4) == 0) { + LWJSON_DEBUG(jsp, "Primitive parsed as %s\r\n", "null"); + SEND_EVT(jsp, LWJSON_STREAM_TYPE_NULL); + } else if (jsp->data.prim.buff_pos == 5 && strncmp(jsp->data.prim.buff, "false", 5) == 0) { + LWJSON_DEBUG(jsp, "Primitive parsed as %s\r\n", "false"); + SEND_EVT(jsp, LWJSON_STREAM_TYPE_FALSE); + } else if (jsp->data.prim.buff[0] == '-' + || (jsp->data.prim.buff[0] >= '0' && jsp->data.prim.buff[0] <= '9')) { + LWJSON_DEBUG(jsp, "Primitive parsed - number\r\n"); + SEND_EVT(jsp, LWJSON_STREAM_TYPE_NUMBER); + } else { + LWJSON_DEBUG(jsp, "Invalid primitive type. Got: %s\r\n", jsp->data.prim.buff); + } + if (t == LWJSON_STREAM_TYPE_KEY) { + prv_stack_pop(jsp); + } else if (t == LWJSON_STREAM_TYPE_ARRAY) { + jsp->stack[jsp->stack_pos - 1].meta.index++; + } + + /* + * Received character is not part of the primitive and must be processed again + * + * Set state to default state and start from beginning + */ + jsp->parse_state = LWJSON_STREAM_STATE_PARSING; + goto start_over; + } + break; + } + + /* TODO: Add other case statements */ + default: + break; + } + jsp->prev_c = c; /* Save current c as previous for next round */ + return lwjsonSTREAMINPROG; +} diff --git a/src/semver.c b/src/semver.c new file mode 100644 index 0000000..d2c19d4 --- /dev/null +++ b/src/semver.c @@ -0,0 +1,642 @@ +/* + * semver.c + * + * Copyright (c) 2015-2017 Tomas Aparicio + * MIT licensed + */ + +#include +#include +#include +#include "semver.h" + +#define SLICE_SIZE 50 +#define DELIMITER "." +#define PR_DELIMITER "-" +#define MT_DELIMITER "+" +#define NUMBERS "0123456789" +#define ALPHA "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +#define DELIMITERS DELIMITER PR_DELIMITER MT_DELIMITER +#define VALID_CHARS NUMBERS ALPHA DELIMITERS + +static const size_t MAX_SIZE = sizeof(char) * 255; +static const int MAX_SAFE_INT = (unsigned int) -1 >> 1; + +/** + * Define comparison operators, storing the + * ASCII code per each symbol in hexadecimal notation. + */ + +enum operators { + SYMBOL_GT = 0x3e, + SYMBOL_LT = 0x3c, + SYMBOL_EQ = 0x3d, + SYMBOL_TF = 0x7e, + SYMBOL_CF = 0x5e +}; + +/** + * Private helpers + */ + +/* + * Remove [begin:len-begin] from str by moving len data from begin+len to begin. + * If len is negative cut out to the end of the string. + */ +static int +strcut (char *str, int begin, int len) { + size_t l; + l = strlen(str); + + if((int)l < 0 || (int)l > MAX_SAFE_INT) return -1; + + if (len < 0) len = l - begin + 1; + if (begin + len > (int)l) len = l - begin; + memmove(str + begin, str + begin + len, l - len + 1 - begin); + + return len; +} + +static int +contains (const char c, const char *matrix, size_t len) { + size_t x; + for (x = 0; x < len; x++) + if ((char) matrix[x] == c) return 1; + return 0; +} + +static int +has_valid_chars (const char *str, const char *matrix) { + size_t i, len, mlen; + len = strlen(str); + mlen = strlen(matrix); + + for (i = 0; i < len; i++) + if (contains(str[i], matrix, mlen) == 0) + return 0; + + return 1; +} + +static int +binary_comparison (int x, int y) { + if (x == y) return 0; + if (x > y) return 1; + return -1; +} + +static int +parse_int (const char *s) { + int valid, num; + valid = has_valid_chars(s, NUMBERS); + if (valid == 0) return -1; + + num = strtol(s, NULL, 10); + if (num > MAX_SAFE_INT) return -1; + + return num; +} + +/* + * Return a string allocated on the heap with the content from sep to end and + * terminate buf at sep. + */ +static char * +parse_slice (char *buf, char sep) { + char *pr, *part; + int plen; + + /* Find separator in buf */ + pr = strchr(buf, sep); + if (pr == NULL) return NULL; + /* Length from separator to end of buf */ + plen = strlen(pr); + + /* Copy from buf into new string */ + part = (char*)calloc(plen + 1, sizeof(*part)); + if (part == NULL) return NULL; + memcpy(part, pr + 1, plen); + /* Null terminate new string */ + part[plen] = '\0'; + + /* Terminate buf where separator was */ + *pr = '\0'; + + return part; +} + +/** + * Parses a string as semver expression. + * + * Returns: + * + * `0` - Parsed successfully + * `-1` - In case of error + */ +int +semver_parse (const char *str, semver_t *ver) { + int valid, res; + size_t len; + char *buf; + bzero(ver, sizeof(semver_t)); + valid = semver_is_valid(str); + if (!valid) return -1; + if (*str == 'v') str++; + + len = strlen(str); + buf = (char*)calloc(len + 1, sizeof(*buf)); + if (buf == NULL) return -1; + strcpy(buf, str); + + ver->metadata = parse_slice(buf, MT_DELIMITER[0]); + ver->prerelease = parse_slice(buf, PR_DELIMITER[0]); + + res = semver_parse_version(buf, ver); +#if DEBUG > 0 + printf("[debug] semver.c %s (%s) = %d.%d.%d - %d\n", str, buf, ver->major, ver->minor, ver->patch, res); + if (ver->prerelease) printf("[debug] semver.c prerelease: %s\n", ver->prerelease); + if (ver->metadata) printf("[debug] semver.c metadata: %s\n", ver->metadata); + //printf("[debug] semver.c %s = %d.%d.%d, %s %s\n", str, ver->major, ver->minor, ver->patch, ver->prerelease, ver->metadata); +#endif + free(buf); + return res; +} + +/** + * Parses a given string as semver expression. + * + * Returns: + * + * `0` - Parsed successfully + * `-1` - Parse error or invalid + */ + +int +semver_parse_version (const char *str, semver_t *ver) { + size_t len; + int index, value; + char *slice, *next, *endptr; + slice = (char *) str; + index = 0; + + while (slice != NULL && index++ < 4) { + next = strchr(slice, DELIMITER[0]); + if (next == NULL) + len = strlen(slice); + else + len = next - slice; + if (len > SLICE_SIZE) return -1; + + /* Cast to integer and store */ + value = strtol(slice, &endptr, 10); + if (endptr != next && *endptr != '\0') return -1; + + switch (index) { + case 1: ver->major = value; break; + case 2: ver->minor = value; break; + case 3: ver->patch = value; break; + } + + /* Continue with the next slice */ + if (next == NULL) + slice = NULL; + else + slice = next + 1; + } + + return 0; +} + +static int +compare_prerelease (char *x, char *y) { + char *lastx, *lasty, *xptr, *yptr, *endptr; + int xlen, ylen, xisnum, yisnum, xnum, ynum; + int xn, yn, min, res; + if (x == NULL && y == NULL) return 0; + if (y == NULL && x) return -1; + if (x == NULL && y) return 1; + + lastx = x; + lasty = y; + xlen = strlen(x); + ylen = strlen(y); + + while (1) { + if ((xptr = strchr(lastx, DELIMITER[0])) == NULL) + xptr = x + xlen; + if ((yptr = strchr(lasty, DELIMITER[0])) == NULL) + yptr = y + ylen; + + xnum = strtol(lastx, &endptr, 10); + xisnum = endptr == xptr ? 1 : 0; + ynum = strtol(lasty, &endptr, 10); + yisnum = endptr == yptr ? 1 : 0; + + if (xisnum && !yisnum) return -1; + if (!xisnum && yisnum) return 1; + + if (xisnum && yisnum) { + /* Numerical comparison */ + if (xnum != ynum) return xnum < ynum ? -1 : 1; + } else { + /* String comparison */ + xn = xptr - lastx; + yn = yptr - lasty; + min = xn < yn ? xn : yn; + if ((res = strncmp(lastx, lasty, min))) return res < 0 ? -1 : 1; + if (xn != yn) return xn < yn ? -1 : 1; + } + + lastx = xptr + 1; + lasty = yptr + 1; + if (lastx == x + xlen + 1 && lasty == y + ylen + 1) break; + if (lastx == x + xlen + 1) return -1; + if (lasty == y + ylen + 1) return 1; + } + + return 0; +} + +int +semver_compare_prerelease (semver_t x, semver_t y) { + return compare_prerelease(x.prerelease, y.prerelease); +} + +/** + * Performs a major, minor and patch binary comparison (x, y). + * This function is mostly used internally + * + * Returns: + * + * `0` - If versiona are equal + * `1` - If x is higher than y + * `-1` - If x is lower than y + */ + +int +semver_compare_version (semver_t x, semver_t y) { + int res; + + if ((res = binary_comparison(x.major, y.major)) == 0) { + if ((res = binary_comparison(x.minor, y.minor)) == 0) { + return binary_comparison(x.patch, y.patch); + } + } + + return res; +} + +/** + * Compare two semantic versions (x, y). + * + * Returns: + * - `1` if x is higher than y + * - `0` if x is equal to y + * - `-1` if x is lower than y + */ + +int +semver_compare (semver_t x, semver_t y) { + int res; + + if ((res = semver_compare_version(x, y)) == 0) { + return semver_compare_prerelease(x, y); + } + + return res; +} + +/** + * Performs a `greater than` comparison + */ + +int +semver_gt (semver_t x, semver_t y) { + return semver_compare(x, y) == 1; +} + +/** + * Performs a `lower than` comparison + */ + +int +semver_lt (semver_t x, semver_t y) { + return semver_compare(x, y) == -1; +} + +/** + * Performs a `equality` comparison + */ + +int +semver_eq (semver_t x, semver_t y) { + return semver_compare(x, y) == 0; +} + +/** + * Performs a `non equal to` comparison + */ + +int +semver_neq (semver_t x, semver_t y) { + return semver_compare(x, y) != 0; +} + +/** + * Performs a `greater than or equal` comparison + */ + +int +semver_gte (semver_t x, semver_t y) { + return semver_compare(x, y) >= 0; +} + +/** + * Performs a `lower than or equal` comparison + */ + +int +semver_lte (semver_t x, semver_t y) { + return semver_compare(x, y) <= 0; +} + +/** + * Checks if version `x` can be satisfied by `y` + * performing a comparison with caret operator. + * + * See: https://docs.npmjs.com/misc/semver#caret-ranges-1-2-3-0-2-5-0-0-4 + * + * Returns: + * + * `1` - Can be satisfied + * `0` - Cannot be satisfied + */ + +int +semver_satisfies_caret (semver_t x, semver_t y) { + /* Major versions must always match. */ + if (x.major == y.major) { + /* If major version is 0, minor versions must match */ + if (x.major == 0) { + /* If minor version is 0, patch must match */ + if (x.minor == 0){ + return (x.minor == y.minor) && (x.patch == y.patch); + } + /* If minor version is not 0, patch must be >= */ + else if (x.minor == y.minor){ + return x.patch >= y.patch; + } + else{ + return 0; + } + } + else if (x.minor > y.minor){ + return 1; + } + else if (x.minor == y.minor) + { + return x.patch >= y.patch; + } + else { + return 0; + } + } + return 0; +} + +/** + * Checks if version `x` can be satisfied by `y` + * performing a comparison with tilde operator. + * + * See: https://docs.npmjs.com/misc/semver#tilde-ranges-1-2-3-1-2-1 + * + * Returns: + * + * `1` - Can be satisfied + * `0` - Cannot be satisfied + */ + +int +semver_satisfies_patch (semver_t x, semver_t y) { + return x.major == y.major + && x.minor == y.minor; +} + +/** + * Checks if both versions can be satisfied + * based on the given comparison operator. + * + * Allowed operators: + * + * - `=` - Equality + * - `>=` - Higher or equal to + * - `<=` - Lower or equal to + * - `<` - Lower than + * - `>` - Higher than + * - `^` - Caret comparison (see https://docs.npmjs.com/misc/semver#caret-ranges-1-2-3-0-2-5-0-0-4) + * - `~` - Tilde comparison (see https://docs.npmjs.com/misc/semver#tilde-ranges-1-2-3-1-2-1) + * + * Returns: + * + * `1` - Can be satisfied + * `0` - Cannot be satisfied + */ + +int +semver_satisfies (semver_t x, semver_t y, const char *op) { + int first, second; + /* Extract the comparison operator */ + first = op[0]; + second = op[1]; + + /* Caret operator */ + if (first == SYMBOL_CF) + return semver_satisfies_caret(x, y); + + /* Tilde operator */ + if (first == SYMBOL_TF) + return semver_satisfies_patch(x, y); + + /* Strict equality */ + if (first == SYMBOL_EQ) + return semver_eq(x, y); + + /* Greater than or equal comparison */ + if (first == SYMBOL_GT) { + if (second == SYMBOL_EQ) { + return semver_gte(x, y); + } + return semver_gt(x, y); + } + + /* Lower than or equal comparison */ + if (first == SYMBOL_LT) { + if (second == SYMBOL_EQ) { + return semver_lte(x, y); + } + return semver_lt(x, y); + } + + return 0; +} + +/** + * Free heep allocated memory of a given semver. + * This is just a convenient function that you + * should call when you're done. + */ + +void +semver_free (semver_t *x) { + if (x->metadata) { + free(x->metadata); + x->metadata = NULL; + } + if (x->prerelease) { + free(x->prerelease); + x->prerelease = NULL; + } +} + +/** + * Renders + */ + +static void +concat_num (char * str, int x, char * sep) { + char buf[SLICE_SIZE] = {0}; + if (sep == NULL) sprintf(buf, "%d", x); + else sprintf(buf, "%s%d", sep, x); + strcat(str, buf); +} + +static void +concat_char (char * str, char * x, char * sep) { + char buf[SLICE_SIZE] = {0}; + sprintf(buf, "%s%s", sep, x); + strcat(str, buf); +} + +/** + * Render a given semver as string + */ + +void +semver_render (semver_t *x, char *dest) { + concat_num(dest, x->major, NULL); + concat_num(dest, x->minor, DELIMITER); + concat_num(dest, x->patch, DELIMITER); + if (x->prerelease) concat_char(dest, x->prerelease, PR_DELIMITER); + if (x->metadata) concat_char(dest, x->metadata, MT_DELIMITER); +} + +/** + * Version bump helpers + */ + +void +semver_bump (semver_t *x) { + x->major++; +} + +void +semver_bump_minor (semver_t *x) { + x->minor++; +} + +void +semver_bump_patch (semver_t *x) { + x->patch++; +} + +/** + * Helpers + */ + +static int +has_valid_length (const char *s) { + return strlen(s) <= MAX_SIZE; +} + +/** + * Checks if a given semver string is valid + * + * Returns: + * + * `1` - Valid expression + * `0` - Invalid + */ + +int +semver_is_valid (const char *s) { + return has_valid_length(s) + && has_valid_chars(s, VALID_CHARS); +} + +/** + * Removes non-valid characters in the given string. + * + * Returns: + * + * `0` - Valid + * `-1` - Invalid input + */ + +int +semver_clean (char *s) { + size_t i, len, mlen; + int res; + if (has_valid_length(s) == 0) return -1; + + len = strlen(s); + mlen = strlen(VALID_CHARS); + + for (i = 0; i < len; i++) { + if (contains(s[i], VALID_CHARS, mlen) == 0) { + res = strcut(s, i, 1); + if(res == -1) return -1; + --len; --i; + } + } + + return 0; +} + +static int +char_to_int (const char * str) { + int buf; + size_t i,len, mlen; + buf = 0; + len = strlen(str); + mlen = strlen(VALID_CHARS); + + for (i = 0; i < len; i++) + if (contains(str[i], VALID_CHARS, mlen)) + buf += (int) str[i]; + + return buf; +} + +/** + * Render a given semver as numeric value. + * Useful for ordering and filtering. + */ + +int +semver_numeric (semver_t *x) { + int num; + char buf[SLICE_SIZE * 3]; + memset(&buf, 0, SLICE_SIZE * 3); + + if (x->major) concat_num(buf, x->major, NULL); + if (x->major || x->minor) concat_num(buf, x->minor, NULL); + if (x->major || x->minor || x->patch) concat_num(buf, x->patch, NULL); + + num = parse_int(buf); + if(num == -1) return -1; + + if (x->prerelease) num += char_to_int(x->prerelease); + if (x->metadata) num += char_to_int(x->metadata); + + return num; +}