Golioth includes excellent Over-the-Air (OTA) firmware update functionality for your IoT fleet. The OTA service is supported by the firmware update sample code available in the Golioth Firmware SDK, which makes it very easy to enable safe and secure update for your main controller from day one.
As your project grows you may need OTA support for more than one binary image. Perhaps you have different “downstream” controllers on your board, or want to send machine learning, AI model, or image assets to your devices alongside the firmware updates themselves. Good news, Golioth natively supports multiple image OTA. Today we’ll walk through the ins and outs of multi-image OTA!
While this post focuses on Zephyr RTOS, the same concepts and approaches are applicable to all other supported platforms. As you read through the post, you may want to cross-reference the OTA device API docs and the Golioth Firmware SDK Doxygen page for OTA. A practical example of this process is also available from Marcin Niestrój’s post about downloading graphics to an IoT device over OTA using Golioth.
Golioth OTA: Packages, Cohorts, and Deployments
There are three core parts to Golioth’s Over-the-Air update system: Packages, Cohorts, and Deployments. Full detail of these concepts are available on the Golioth OTA Updates service docs, but here’s the gist you need to understand to continue reading this post
- Packages: a package is a single component that may be passed to your IoT fleet. Packages have names (eg:
main
orml_model
), version numbers (1.2.5
), and an artifact (the binary file itself). You are responsible for choosing package names; what you choose must match between the cloud and the compiled device firmware. - Cohorts: a cohort is a grouping of embedded devices in your fleet. Cohorts determine which devices will receive a deployed OTA update.
- Deployments: a deployment is an OTA update pushed to all devices in a given cohort. The deployment contains one or more packages made available for the devices to download and use.
The firmware update sample code available in the Golioth Firmware SDK implements support for a single package (configurably) named main
. Let’s dig into setting up device firmware to support multiple packages in a deployment.
Configure Firmware to Allow Multiple Packages
The Golioth OTA service is configured using Kconfig symbols. One handy tool for visualizing these settings is to run west build -t menuconfig
, then navigate to Modules
→golioth-firmware-sdk
→Golioth Firmware SDK
.
Of note in this menu is that the OTA service enabled separately from the Golioth Firmware Update service.
- The OTA service allows your device to receive a “manifest” with the packages available in your cohort’s latest deployment. It also facilitates download of these packages.
- The firmware service handles automatically updating the device firmware based on one particular package in the latest deployment
When you want to add more than one package to a deployment, the Golioth maximum OTA number of components value must be updated from its default value of 1 to match your expectations. Changing this setting may also be done in your prj.conf file by setting CONFIG_GOLIOTH_OTA_MAX_NUM_COMPONENTS=2
.
You may still use the firmware update service when OTA is configured for multiple images. Note that firmware update will only apply to one of the images. As shown above, the package name of main
is used by default and may be updated by changing the value of CONFIG_GOLIOTH_FW_UPDATE_PACKAGE_NAME
.
There is no automatic handling of the other packages added to your deployment. Let’s look at how to craft firmware to look for, and download these other packages.
Example: Multiple Artifact OTA Support
I’m starting from the Golioth Hello sample code for today’s multi-artifact OTA demo.
Kconfig
To start, I’m adding the following Kconfig symbols to prj.conf
to turn on the OTA service and allow multiple images. Note that I am not using the firmware update system in this example.
# Add multi-artifact OTA support for up to two packages CONFIG_GOLIOTH_OTA=y CONFIG_GOLIOTH_OTA_MAX_NUM_COMPONENTS=2
Getting the OTA Manifest
While the firmware update sample code uses golioth_ota_observe_manifest_async()
to received updates when a new deployment becomes available, you may want to more tightly control when and how your device gets an OTA manifest by requesting it using a get call. The code snippet below performs a few steps to accomplish this:
- Declare a global variable to store the most recently received manifest
- Define a callback function
on_manifest
used by the get function to handle the server response - Perform the actual get using
golioth_ota_get_manifest_async()
static struct golioth_ota_manifest _stored_manifest; static void on_manifest(struct golioth_client *client, enum golioth_status status, const struct golioth_coap_rsp_code *coap_rsp_code, const char *path, const uint8_t *payload, size_t payload_size, void *arg) { LOG_HEXDUMP_WRN(payload, payload_size, "manifest"); struct golioth_ota_manifest new_manifest; enum golioth_status err = golioth_ota_payload_as_manifest(payload, payload_size, &new_manifest); if (GOLIOTH_OK != err) { LOG_ERR("Unable to parse manifest: %d", err); return; } if (memcmp(&_stored_manifest, &new_manifest, sizeof(struct golioth_ota_manifest)) != 0) { LOG_INF("New manifest received") memcpy(&_stored_manifest, &new_manifest, sizeof(struct golioth_ota_manifest)); /* TODO: set a flag to indicate a new manifest was received */ return; } else { LOG_INF("Local manifest matches what was received; ignoring"); return; } } int main(void) { /* After Golioth connection is established */ err = golioth_ota_get_manifest_async(client, on_manifest, NULL); if (GOLIOTH_OK != err) { LOG_ERR("Failed to get Golioth OTA manifest: %d", err); } }
When a manifest is received from the server, it is compared to the stored manifest to see if there is any new information available. If there are differences, the stored manifest is updated.
However, we’re not actually doing anything with the new manifest. In the final application we’ll set a flag to indicate a new manifest is available. Since the new manifest is received in a callback, we can’t directly download it in this context. The next section will detail that download process.
Downloading OTA Components
Whenever we have a new manifest, the firmware should parse it for the expected components. Those components are binary images like firmware updates, machine learning models, UI image assets, or whatever binary data your fleet needs.
Components in the manifest come with a package name, version, size, and a hash you can use to verify the component. There is also a URI that the Golioth SDK uses to request the binary from the server, and a bootloader name you can choose to associate with the binary.
The code block below accomplishes the following:
- Add defines for expected package name and compiled-in version.
- Add a global array to store the current version number.
- Add a
on_next_block()
callback function used during package download. - Add a
process_manifest()
that may be called from the main thread to process the manifest and download component updates.
#define SPLASHSCREEN_PACKAGE "splashscreen" #define SPLASHSCREEN_BASE_VERSION "1.0.0" static char _splash_ver[CONFIG_GOLIOTH_OTA_MAX_VERSION_LEN + 1] = SPLASHSCREEN_BASE_VERSION; enum golioth_status on_next_block(const struct golioth_ota_component *component, uint32_t block_idx, uint8_t *block_buffer, size_t block_buffer_len, bool is_last, size_t negotiated_block_size, void *arg) { /* TODO: Save block */ char *package = (char *) arg; LOG_DBG("on_next_block: package: %s, block_idx %u, len %zu, is_last %d", package, block_idx, block_buffer_len, is_last); if (true == is_last) { /* TODO: Perform integrity check */ LOG_INF("Successfully downloaded: %s", package); } } static void process_manifest(void) { const struct golioth_ota_component *splash = golioth_ota_find_component(&_stored_manifest, SPLASHSCREEN_PACKAGE); if (NULL == splash) { LOG_WRN("Manifest does not contain component: %s", SPLASHSCREEN_PACKAGE); return; } if (strncmp(_splash_ver, splash->version, CONFIG_GOLIOTH_OTA_MAX_VERSION_LEN) == 0) { LOG_INF("%s version matches stored version: %s", SPLASHSCREEN_PACKAGE, splash->version); return; } LOG_INF("Downloading %s; Old: %s New: %s", SPLASHSCREEN_PACKAGE, _splash_ver, splash->version); enum golioth_status err = golioth_ota_download_component(client, splash, 0, on_next_block, SPLASHSCREEN_PACKAGE); if (GOLIOTH_OK != err) { LOG_ERR("Failed to download package %s: %d", SPLASHSCREEN_PACKAGE, err); /* TODO: add logic to retry the download */ return; } strncpy(_splash_ver, splash->version, CONFIG_GOLIOTH_OTA_MAX_VERSION_LEN); /* TODO: check has and report status to server */ }
Components will be downloaded in blocks, so the on_next_block()
callback should be used to store each block, using the negotiated_block_size
and block_idx
to calculate the memory offset for the current block, and the block_buffer_len
for how much data to store. The last block is signified by is_last
.
Where and how you store your components varies by application so this demo merely prints a log message and drops the data. However, it is important to note the callback includes a component->hash
that you should use to compare to a SHA256 value you calculate for the received binary.
One note about storing versions numbers. Your main firmware will have a version number compiled into it by Zephyr. So as soon as you update what’s running on the chip, that version will be up-to-date. This is not true with the other components in a multi-component manifest. In the example above, the “splashscreen” version is stored in RAM, and will revert when the chip reboots. New version numbers should be stored in persistent memory, but this is an exercise left up to the reader.
Combining all of these elements, we now have a way to query for and download new components when they are available on the cloud.
Complete OTA Multi-Artifact Example
The the full example is found below. You can paste it into main.c
of Golioth hello as long as you also add the Kconfig symbols to prj.conf
as discussed above. To test the code, add your device to a cohort, add your packages to the project, then create a multiple-package deployment for your cohort.
Over-the-Air updates are a core requirement of IoT fleets, but how you implement them can vary wildly from one project to the next. With this in mind, Golioth has gone to great lengths to make OTA easy and approachable, yet still highly customizable. We think we have succeeded, but as always, would love to hear your thoughts and experiences. Consider starting a conversation about it on the Golioth forum!
/* * Copyright (c) 2025 Golioth, Inc. * * SPDX-License-Identifier: Apache-2.0 */ #include <zephyr/logging/log.h> LOG_MODULE_REGISTER(hello_zephyr, LOG_LEVEL_DBG); #include <golioth/client.h> #include <golioth/ota.h> #include <samples/common/sample_credentials.h> #include <string.h> #include <zephyr/kernel.h> #include <samples/common/net_connect.h> struct golioth_client *client; static K_SEM_DEFINE(connected, 0, 1); struct golioth_ota_manifest _stored_manifest; #define MAIN_PACKAGE "main" #define MAIN_BASE_VERSION "1.2.4" static char _main_ver[CONFIG_GOLIOTH_OTA_MAX_VERSION_LEN + 1] = MAIN_BASE_VERSION; #define SPLASHSCREEN_PACKAGE "splashscreen" #define SPLASHSCREEN_BASE_VERSION "1.0.0" static char _splash_ver[CONFIG_GOLIOTH_OTA_MAX_VERSION_LEN + 1] = SPLASHSCREEN_BASE_VERSION; struct package_data { const char *name; char *version; }; struct package_data _stored_components[] = { {.name = MAIN_PACKAGE, .version = _main_ver}, {.name = SPLASHSCREEN_PACKAGE, .version = _splash_ver}, }; static void on_client_event(struct golioth_client *client, enum golioth_client_event event, void *arg) { bool is_connected = (event == GOLIOTH_CLIENT_EVENT_CONNECTED); if (is_connected) { k_sem_give(&connected); } LOG_INF("Golioth client %s", is_connected ? "connected" : "disconnected"); } static void on_manifest(struct golioth_client *client, enum golioth_status status, const struct golioth_coap_rsp_code *coap_rsp_code, const char *path, const uint8_t *payload, size_t payload_size, void *arg) { LOG_HEXDUMP_WRN(payload, payload_size, "manifest"); struct golioth_ota_manifest new_manifest; enum golioth_status err = golioth_ota_payload_as_manifest(payload, payload_size, &new_manifest); if (GOLIOTH_OK != err) { LOG_ERR("Unable to parse manifest: %d", err); return; } if (memcmp(&_stored_manifest, &new_manifest, sizeof(struct golioth_ota_manifest)) != 0) { LOG_INF("New manifest received"); if (NULL == arg) { LOG_WRN("User arg is NULL"); } else { memcpy(&_stored_manifest, &new_manifest, sizeof(struct golioth_ota_manifest)); bool *new_manifest = (bool *) arg; *new_manifest = true; return; } } else { LOG_INF("Local manifest matches what was received; ignoring"); return; } } enum golioth_status on_next_block(const struct golioth_ota_component *component, uint32_t block_idx, uint8_t *block_buffer, size_t block_buffer_len, bool is_last, size_t negotiated_block_size, void *arg) { /* TODO: Save block */ char *package = (char *) arg; LOG_DBG("on_next_block: package: %s, block_idx %u, len %zu, is_last %d", package, block_idx, block_buffer_len, is_last); if (true == is_last) { /* TODO: Perform integrity check */ LOG_INF("Successfully downloaded: %s", package); } return GOLIOTH_OK; } static void process_manifest(void) { for (int i = 0; i < ARRAY_SIZE(_stored_components); i++) { struct package_data package = _stored_components[i]; const struct golioth_ota_component *comp = golioth_ota_find_component(&_stored_manifest, package.name); if (NULL == comp) { LOG_WRN("Manifest does not contain component: %s", package.name); continue; } if (strncmp(package.version, comp->version, CONFIG_GOLIOTH_OTA_MAX_VERSION_LEN) == 0) { LOG_INF("%s version matches stored version: %s", package.name, comp->version); continue; } LOG_INF("Downloading %s; Old: %s New: %s", package.name, package.version, comp->version); golioth_ota_report_state_sync(client, GOLIOTH_OTA_STATE_DOWNLOADING, GOLIOTH_OTA_REASON_READY, package.name, package.version, comp->version, 1000); enum golioth_status err = golioth_ota_download_component(client, comp, 0, on_next_block, (void *)package.name); if (GOLIOTH_OK != err) { LOG_ERR("Failed to download package %s: %d", package.name, err); /* TODO: add logic to retry the download */ golioth_ota_report_state_sync(client, GOLIOTH_OTA_STATE_IDLE, GOLIOTH_OTA_REASON_INTEGRITY_CHECK_FAILURE, package.name, package.version, comp->version, 1000); return; } strncpy(package.version, comp->version, CONFIG_GOLIOTH_OTA_MAX_VERSION_LEN); golioth_ota_report_state_sync(client, GOLIOTH_OTA_STATE_DOWNLOADED, GOLIOTH_OTA_REASON_READY, package.name, package.version, NULL, 1000); /* TODO: check has and report status to server */ } } int main(void) { int counter = 0; LOG_DBG("start hello sample"); net_connect(); /* Note: In production, you would provision unique credentials onto each * device. For simplicity, we provide a utility to hardcode credentials as * kconfig options in the samples. */ const struct golioth_client_config *client_config = golioth_sample_credentials_get(); client = golioth_client_create(client_config); golioth_client_register_event_callback(client, on_client_event, NULL); k_sem_take(&connected, K_FOREVER); bool new_manifest = false; enum golioth_status err = golioth_ota_report_state_sync(client, GOLIOTH_OTA_STATE_IDLE, GOLIOTH_OTA_REASON_READY, SPLASHSCREEN_PACKAGE, _splash_ver, NULL, 1000); if (GOLIOTH_OK != 0) { LOG_ERR("Unable to report package version: %d", err); } while (true) { if (counter % 6 == 0) { /* Get manifest every ~30 seconds for this example */ err = golioth_ota_get_manifest_async(client, on_manifest, &new_manifest); if (GOLIOTH_OK != err) { LOG_ERR("Failed to get Golioth OTA manifest: %d", err); } } if (true == new_manifest) { /* Check for new manifest and process */ LOG_INF("Received new manifest"); process_manifest(); new_manifest = false; } LOG_INF("Sending hello! %d", counter); ++counter; k_sleep(K_SECONDS(5)); } return 0; }
No comments yet! Start the discussion at forum.golioth.io