We are implementing an OTA update feature to allow developers to remotely update firmware when new features are released after the product is available to users.
In this article, we have implemented “firmware update function on a single device”.
In addition, in this article, we described a function that notifies users when a new firmware is uploaded by a developer.
This time, as the final implementation of the OTA update function, we will describe the part that “Downloads the new Firmware when the user requests installation of the new Firmware.”
This will complete the implementation of the OTA update function.
- Implementation steps
- Server side (Go)
- Device side (ESP32)
- overall flow
- Update desired state of device shadow via MQTT communication
- Parsing MQTT messages (topic and payload)
- Deserialization of JSON format data (payload)
- Difference detection between device desired settings and current settings
- Firmware update determination and OTA update execution
- Reporting successful update
- Operation confirmation
- in conclusion
Implementation steps
- Server-side (Go) steps :
- Update the database user table to reflect the new Firmware version.
- Update AWS IoT Device Shadow desired to the new version. The communication format used by AWS IoT Device Shadow is JSON.
- Device side (ESP32) operations :
- Receive desired state updates from AWS IoT via MQTT.
- Deserialize the received data (JSON format) and detect the difference between it and the current state (repoted).
- If there is a difference (= firmware update is required), update via OTA.
- Update the Device Shadow’s reported state to report successful updates to the cloud.
Server side (Go)
Update DB and Device Shadow Desired
Database update
UpdateUser(h.db, user)
Call a function to update user information in the database. Specifically, access the Database and update the User table with the UPDATE statement.
Go structures → Protocol Buffers
ToDeviceConfig
A function is a method defined in the Go language that converts dataUser
from a structure instance to a structure. The structure is a data structure based on the Protocol Buffers definition, and is used for the desired settings of AWS IoT device shadows.pb.DeviceConfig
pb.DeviceConfig
User
The fields of the structure are read by mapping the column values in theFirmwareVersion
database table.user
firmware_version
// user.go
type User struct {
// 他のフィールド...
FirmwareVersion string `db:"firmware_version" json:"firmware_version"`
}
func (u *User) ToDeviceConfig() pb.DeviceConfig {
return pb.DeviceConfig{
FirmwareVersion: &u.FirmwareVersion,
}
}
Device shadow updates :
- The communication format used by AWS IoT Device Shadow is JSON, so
h.sm.UpdateShadow()
the function converts the Protocol Buffers format data to JSON and updates the desired state of AWS IoT Device Shadow.
// user_handler.go
type UserHandler struct {
db *sqlx.DB
sm ShadowManager
}
func (h *UserHandler) HandleUpdateUser(c echo.Context) error {
uid := c.Get("uid").(string)
user := &User{}
if err := c.Bind(user); err != nil {
return err
}
user.UID = uid
// DB更新
updatedUser, err := UpdateUser(h.db, user)
if err != nil {
c.Logger().Errorf("failed to update user: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to update user")
}
// デバイスIDが存在するとき
if updatedUser.DeviceID.Valid {
dc := updatedUser.ToDeviceConfig()
desired := &pb.PublishShadowDesired{
State: &pb.ShadowDesiredState{
Desired: &pb.ShadowDesired{
Config: &dc,
},
},
}
// DeviceShadowを反映
if err = h.sm.UpdateShadow(updatedUser.DeviceID.String, desired); err != nil {
c.Logger().Errorf("failed to update device shadow: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to update device shadow")
}
}
return c.JSON(http.StatusOK, updatedUser)
}
Device side (ESP32)
overall flow
First, let’s look at the overall flow. Personally, I was confused about the desired state of Device Shadow and the desired settings inside the device, so I’ve organized it and described it.
AWS IoT Device Shadow desired
States
The AWS IoT Device Shadow desired
state indicates the desired state that the device should achieve. This is configured on the server side (cloud) and sent to the device. This state is managed in the cloud even when the device is offline and synced when the device comes back online.
Settings inside the devicedesired
Settings inside a device desired
are the internal parameters and settings that the device uses to actually perform operations. This is a setting that is updated based on the state of his Device Shadow received desired
, but directly affects the behavior of the device. The device desired
actually performs operations based on these internal settings and reported
reflects the results in its state.
Synchronization and update process
Sync :
desired
The device receives the Device Shadow state from the cloud .- Update the
desired
internal settings of the device based on the received state.desired
execution :
desired
The device adjusts its behavior based on the updated internal settings.
report :
reported
Report the adjusted actual state to the cloud as the device state.
Update desired state of device shadow via MQTT communication
- The MQTT client receives a new message and the configured
onMessage
callback function is called. desired
Devices subscribe to state updates from AWS IoT using the MQTT protocol . Newdesired
state data$aws/things/THING_NAME/shadow/update/delta
is sent through the topic (example)
// src/mqtt_client.cpp
void MqttClient::onMessage(const std::function<void(const String &topic, const String &payload)> &callback) {
client->onMessage([callback](const String &topic, const String &payload) {
Serial.println("MQTTPubSubClient::onMessage: " + topic + " " + payload);
callback(topic, payload);
});
}
Parsing MQTT messages (topic and payload)
- MQTT messages
delta
are sent from a device shadow topic and contain a statereported
different from the current state of the device.desired
- The payload is typically JSON-formatted data that contains information about the state of the device and represents
desired
the state or states of a particular device shadow .reported
topic :$aws/things/XXXXXXXXXXXXXXXXXXXXXXXX/shadow/update/delta
- This is a delta topic for the Device Shadow of a specific device (Thing). where is
XXXXXXXXXXXXXXXXXXXXXXXX
the device Thing Name (or Thing ID)
Payload
version
: Device shadow version number. Increases with each shadow update.timestamp
: UNIX timestamp when the device shadow was last updated.state
:desired
Indicates the new state of the device. In this example,config
inside the objectphrase_type
there is a key called , and its value is"hakata"
. This indicates a parameter related to the device’s configuration or operation, and the device must update its operation according to this new value.metadata
:desired
Contains additional information about each state’s fields. Thisphrase_type
includes the last updated timestamp.
// MQTTメッセージのペイロード例
{
"version": 1859,
"timestamp": 1713327942,
"state": {
"config": {
"phrase_type": "hakata"
}
},
"metadata": {
"config": {
"phrase_type": {
"timestamp": 1713250306
}
}
}
}
Deserialization of JSON format data (payload)
onMessage
In the callback, a corresponding handler function (handleShadowGetAccepted
orhandleShadowUpdateDelta
) is selected and executed based on the received topic. In this case, it’s an update, sohandleShadowUpdateDelta
it’s a function.
// src/sync_shadow.cpp
tl::expected<void, String> SyncShadow::start() {
// MQTTクライアント初期化
client->init();
// TopicごとにHandlerを呼び出す
this->client->onMessage([this](const String &topic, const String &payload) {
Logger &logger = Logger::getInstance();
if (topic == topicGetAccepted) {
if (auto result = handleShadowGetAccepted(payload); !result) {
logger.error(ErrorCode::SHADOW_FAILED, result.error());
}
} else if (topic == topicUpdateDelta) {
if (auto result = handleShadowUpdateDelta(payload); !result) {
logger.error(ErrorCode::SHADOW_FAILED, result.error());
}
}
});
}
// デバイスシャドウの更新があった場合に呼ばれる
tl::expected<void, String> SyncShadow::handleShadowUpdateDelta(const String &payload) {
auto resultDelta = deserializeSubscribeShadowUpdateDelta(payload);
if (!resultDelta) {
return tl::make_unexpected("can not deserialize delta shadow: " + resultDelta.error());
}
// 現在の設定
const auto currentConfig = this->reported.config;
// 更新された設定
const auto deltaConfig = resultDelta->state->config;
// configに変更がない場合は終了
if (!isDeviceConfigChanged(deltaConfig, currentConfig)) {
Serial.println("no change: skip");
return {};
}
// 更新された設定を反映
updateDeviceConfig(*this->desired.config, *resultDelta->state->config);
return {};
}
The received data (JSON format) is deserialized on the device side. This deserializeDeviceConfig
uses functions to DeviceConfig
create objects from JSON data. In the case of the payload of the MQTT message mentioned earlier, resultDelta is SubscribeShadowUpdateDelta
a structure and the following result is obtained.
Phrase Type: hakata
updateDeviceConfig(*this->desired.config, *resultDelta->state->config)
: This function updates the current device desired
settings ( this->desired.config
) based on the newly received delta ( ).resultDelta->state->config
At this point, the internal settings of the device have been updated desired
based on the received AWS IoT Device Shadow state.desired
Next, change the current settings to match the desired settings inside the device.
Difference detection between device desired settings and current settings
getDeviceConfigDiff
Use a function to detect if there is a difference between the device’s current configuration ( currentConfig
) and the desired configuration ( ) and return an object representing that difference.desiredConfig
DeviceConfig
// src/json/device_config.cpp
// 現在の設定とdesiredの設定の差分を取得する
std::optional<DeviceConfig> getDeviceConfigDiff(const std::optional<DeviceConfig> &desiredConfig, const std::optional<DeviceConfig> ¤tConfig) {
DeviceConfig diff;
bool hasDiff = false;
// desiredの値がcurrentと異なる場合にdiffに設定する
auto checkAndAssign = [&hasDiff](const auto &desired, const auto ¤t, auto &diffField) {
if (desired.has_value()) {
if (!current.has_value() || *desired != *current) {
diffField = desired;
hasDiff = true;
}
}
};
checkAndAssign(desiredConfig->firmware_version, currentConfig->firmware_version, diff.firmware_version);
if (hasDiff) {
return diff;
} else {
return std::nullopt;
}
}
bool isDeviceConfigChanged(const std::optional<DeviceConfig> &desiredConfig, const std::optional<DeviceConfig> ¤tConfig) {
if (desiredConfig->firmware_version.has_value() && desiredConfig->firmware_version != currentConfig->firmware_version) {
return true;
}
// その他のフィールド
// ...
// すべてのフィールドが同じ場合
return false;
}
Firmware update determination and OTA update execution
Wi-Fi connection settings
- When the system boots, a Wi-Fi connection is first established.
Process inside SyncShadow’s start method
SyncShadow::start()
The callback configuredonMessage
in , allows the device to automatically receive state updates from AWS IoTdesired
and update the device’s internal desired configuration accordingly.
Role of the applyAndReportConfigUpdates function
applyAndReportConfigUpdates()
The function is called periodically ( within the function) to detect configuration and configuration differencesloop()
within the device , update the device configuration if necessary, and send the results to the state of the AWS IoT Device Shadow. reflect ondesired
reported
reported
- This function operates under the assumption that the
SyncShadow
internal settings managed by the classdesired
have already been updated, and adjusts the actual device behavior based on those settings.
// src/main.cpp
void setup() {
// Wi-Fi接続の設定
setupWiFi(ssid, password);
// システムの起動ログ
Serial.println("System initialization...");
// SyncShadowインスタンスの取得
SyncShadow &syncShadow = SyncShadow::getInstance();
// SyncShadowの開始
if (auto result = syncShadow.start(); !result) {
Serial.println("Failed to start SyncShadow: " + result.error());
} else {
Serial.println("SyncShadow started successfully.");
}
// デバイス設定の変更を適用し、デバイスシャドウに通知する
void applyAndReportConfigUpdates() {
Logger &logger = Logger::getInstance();
SyncShadow &syncShadow = SyncShadow::getInstance();
auto desiredConfig = syncShadow.getDesiredConfig();
auto reportedConfig = syncShadow.getReportedConfig();
auto optConfigDiff = getDeviceConfigDiff(desiredConfig, reportedConfig);
// 変更がない場合はスキップ
if (!optConfigDiff) {
return;
}
logger.debug("device config changed");
// 変更のあった設定
auto configDiff = optConfigDiff.value();
logger.debug("config diff: " + serializeDeviceConfig(configDiff));
// ファームウェアバージョンの変更があった場合
if (configDiff.firmware_version.has_value()) {
logger.debug("Firmware version changed: " + desiredConfig->firmware_version.value());
// ファームウェアアップグレード開始
if (auto result = upgradeFirmware(desiredConfig->firmware_version.value()); !result) {
logger.error(ErrorCode::OTA_UPDATE_FAILED, "failed to upgrade firmware: " + result.error());
return;
} else {
logger.info("Firmware upgrade successful");
}
}
}
}
void loop() {
// デバイスシャドウが更新されていた場合の処理
applyAndReportConfigUpdates();
}
If the previous getDeviceConfigDiff
function returns a difference list and the firmware versions are different, call the do_firmware_upgrade function to start installing the desired firmware version.
The upgradeFirmware function takes the new firmware version (the firmware version listed in device shadow’s desired field) as an argument, generates the complete firmware download URL, and executes the download. A detailed explanation regarding this is below.
Change the function from void type to type and return an tl::expected<void, String>
empty object on success . tl::expected
This allows subsequent methods to inherit the success value, allowing them to report changes to the device’s shadow state.
// src/ota_update.cpp
tl::expected<void, String> upgradeFirmware(const String &firmwareVersion) {
Serial.println("OTA firmware upgrade start");
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi not connected, attempting to connect...");
connectWiFi();
if (WiFi.status() != WL_CONNECTED) {
return tl::make_unexpected("Failed to connect to Wi-Fi");
}
}
// ルートCA証明書を読み込む
String cert = readRootCA();
if (cert.length() == 0) {
return tl::make_unexpected("Failed to load root CA certificate");
}
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
esp_err_t erase_err = nvs_flash_erase();
if (erase_err != ESP_OK) {
return tl::make_unexpected("Failed to erase NVS partition");
}
err = nvs_flash_init();
}
if (err != ESP_OK) {
return tl::make_unexpected("Failed to initialize NVS");
}
// Firmware Update URLを組み立てる
String firmwareUpdateUrl = String(FIRMWARE_UPGRADE_URL) + firmwareVersion + ".bin";
Serial.println("Starting OTA task");
esp_http_client_config_t config = {
.url = firmwareUpdateUrl.c_str(),
.cert_pem = cert.c_str(),
.event_handler = _http_event_handler,
.keep_alive_enable = true,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
// 念の為に通信タイムアウトを10分に設定。
esp_err_t ret = esp_http_client_set_timeout_ms(client, 600000);
// otaアップデート実施
esp_err_t result = esp_https_ota(&config);
if (result == ESP_OK) {
Serial.println("Firmware upgrade succeeded");
esp_restart();
return {};
} else {
String error_message = "Firmware upgrade failed: " + String(result);
return tl::make_unexpected(error_message);
}
}
Reporting successful update
- Update settings :
updateDeviceConfig(*this->reported.config, config)
Use functions to update internally heldreported.config
(reported settings) with new settings. Overwrite the device’s current settings with the latest changes. - Update device shadow :
this->report()
Calls the method to reflect updated settings in the device shadow. This method internally communicates with the AWS IoT Device Shadow service using MQTT andreported
synchronizes the state to the cloud shadow. - Retrieve and save reported configuration :
syncShadow.getReported()
Retrieves the current reported configuration with andPersistentShadow::getInstance().save(reported)
saves this configuration to local persistent storage. This ensures that the latest settings are retained even after the device is restarted or powered off.
// src/main.cpp
// デバイス設定の変更を適用し、デバイスシャドウに通知する
void setup() {
// 既存のコード
// ...
// デバイス設定をデバイスシャドウに通知
if (auto result = syncShadow.reportConfig(configDiff); !result) {
logger.error(ErrorCode::CONFIG_REPORT_FAILED, "failed to report config: " + result.error());
return;
}
const auto reported = syncShadow.getReported();
if (auto result = PersistentShadow::getInstance().save(reported); !result) {
logger.error(ErrorCode::IO_FAILED, "failed to save app state: " + result.error());
return;
}
}
// src/sync_shadow.cpp
// デバイスシャドウの状態を報告
tl::expected<void, String> SyncShadow::report() const {
if (!isWiFiConnected()) {
return {};
}
const auto publishShadowUpdate = PublishShadowUpdate{.state = reported};
const String payload = serializePublishShadowUpdate(publishShadowUpdate);
if (client->publish(topicUpdate, payload)) {
return {};
} else {
return tl::make_unexpected("Failed to publish shadow update");
}
}
tl::expected<void, String> SyncShadow::reportConfig(const DeviceConfig &config) {
updateDeviceConfig(*this->reported.config, config);
if (auto result = this->report(); !result) {
return tl::make_unexpected("failed to report config: " + result.error());
}
return {};
}
Operation confirmation
Confirmed operation when updating firmware version from 1.0.4 to 1.0.5.
First, if a firmware update is available, a message will appear in the app, and users can choose to accept the update by clicking on the message.
When you click install, the app first sends an API request to the server for a firmware update.
flutter: Request updateUser: {"firmware_version":"1.0.5"}
Device Shadow Update : Set new firmware version as desired state in AWS IoT device shadow.
desired firmware_version has been changed to 1.0.5.
clocky_api_local | 2024/04/17 11:50:00 received listen request from user: [USER_ID]
clocky_api_local | 2024/04/17 11:50:00 received listen firmware update request from user: [USER_ID]
clocky_api_local | 2024/04/17 11:50:00 received listen request from deviceId: [DEVICE_ID]
clocky_api_local | 2024/04/17 11:50:00 sent message to user: 1
clocky_api_local | subscribing to topic: $aws/things/[DEVICE_ID]/shadow/update/accepted
clocky_api_local | {"time":"2024-04-17T11:50:00.754083024Z","id":"","remote_ip":"[IP_ADDRESS]","host":"[HOST_IP]:8080","method":"GET","uri":"/app/weather","user_agent":"Dart/3.3 (dart:io)","status":200,"error":"","latency":166399359,"latency_human":"166.399359ms","bytes_in":0,"bytes_out":233}
// device shadow
{
"state": {
"desired": {
"config": {
"firmware_version": "1.0.5"
}
},
"reported": {
"config": {
"firmware_version": "1.0.4"
}
},
}
}
MQTT communication : ESP32 detects updates to the device shadow’s desired state and receives changes from the MQTT topic.
MQTTPubSubClient::onMessage: $aws/things/[DEVICE_ID]/shadow/update/delta {"version":1242,"timestamp":[TIMESTAMP],"state":{"config":{"firmware_version":"1.0.5"}},"metadata":{"config":{"firmware_version":{"timestamp":[TIMESTAMP]}}}}
{"timestamp":[TIMESTAMP],"message":"device config changed","level":"DEBUG","thingName":"[DEVICE_ID]","freeHeap":[FREE_HEAP],"usedBytes":[USED_BYTES]}
MQTTPubSubClient::publish: mia/things/[DEVICE_ID]/logs {"timestamp":[TIMESTAMP],"message":"device config changed","level":"DEBUG","thingName":"[DEVICE_ID]","freeHeap":[FREE_HEAP],"usedBytes":[USED_BYTES]}
{"timestamp":[TIMESTAMP],"message":"config diff: {"config":{"firmware_version":"1.0.5"}}","level":"DEBUG","thingName":"[DEVICE_ID]","freeHeap":[FREE_HEAP],"usedBytes":[USED_BYTES]}
MQTTPubSubClient::publish: mia/things/[DEVICE_ID]/logs {"timestamp":[TIMESTAMP],"message":"config diff: {"config":{"firmware_version":"1.0.5"}}","level":"DEBUG","thingName":"[DEVICE_ID]","freeHeap":[FREE_HEAP],"usedBytes":[USED_BYTES]}
{"timestamp":[TIMESTAMP],"message":"Firmware version changed: 1.0.5","level":"DEBUG","thingName":"[DEVICE_ID]","freeHeap":[FREE_HEAP],"usedBytes":[USED_BYTES]}
MQTTPubSubClient::publish: mia/things/[DEVICE_ID]/logs {"timestamp":[TIMESTAMP],"message":"Firmware version changed: 1.0.5","level":"DEBUG","thingName":"[DEVICE_ID]","freeHeap":[FREE_HEAP],"usedBytes":[USED_BYTES]}
Firmware download and installation : Download and install new firmware
OTA firmware upgrade start
Starting OTA task
HTTP_EVENT_ON_CONNECTED
HTTP_EVENT_HEADER_SENT
HTTP_EVENT_ON_HEADER, key=x-amz-id-2, value=[AMAZON_ID]
HTTP_EVENT_ON_HEADER, key=x-amz-request-id, value=[REQUEST_ID]
HTTP_EVENT_ON_HEADER, key=Date, value=[DATE]
HTTP_EVENT_ON_HEADER, key=Last-Modified, value=[MODIFIED_DATE]
HTTP_EVENT_ON_HEADER, key=ETag, value="[ETAG_VALUE]"
HTTP_EVENT_ON_HEADER, key=x-amz-server-side-encryption, value=AES256
HTTP_EVENT_ON_HEADER, key=Accept-Ranges, value=bytes
HTTP_EVENT_ON_HEADER, key=Content-Type, value=application/octet-stream
HTTP_EVENT_ON_HEADER, key=Server, value=AmazonS3
HTTP_EVENT_ON_HEADER, key=Content-Length, value=[CONTENT_LENGTH]
HTTP_EVENT_ON_DATA, len=[DATA_LENGTH]
HTTP_EVENT_ON_DATA, len=[DATA_LENGTH]
HTTP_EVENT_ON_DATA, len=[DATA_LENGTH]
....
HTTP_EVENT_ON_DATA, len=[DATA_LENGTH]
HTTP_EVENT_DISCONNECTED
Total data received before disconnect: [TOTAL_BYTES] bytes
HTTP_EVENT_DISCONNECTED
Total data received before disconnect: [TOTAL_BYTES] bytes
Firmware upgrade succeeded
Reboot device : The device will automatically reboot after the firmware installation is complete.
Success Notification : Notify apps and servers of successful updates.
Logs sent to AWS CloudWatch
{
"timestamp": [TIMESTAMP],
"message": "Firmware version changed: 1.0.5",
"level": "DEBUG",
"thingName": "[DEVICE_ID]",
"freeHeap": [FREE_HEAP],
"usedBytes": [USED_BYTES]
}
Confirmed that Device Shadow reported was also successfully changed to 1.0.5.
It was completed successfully.
in conclusion
When the user accepts the update, the app sends an API request to the server, and the server reflects the changes in the AWS IoT device shadow. The device detects this change via MQTT and includes a section to download and upgrade the firmware.
After that, it will take a few minutes to download the firmware, so I would like to display the progress in the app while it is downloading. Described in a separate article.
コメント