From 5d4fb4eca6b3680d33544444cd57b2dab2bb2768 Mon Sep 17 00:00:00 2001 From: Rob Lauer Date: Tue, 19 May 2026 10:58:28 -0500 Subject: [PATCH 1/5] move esp32 dfu files from note-tutorials --- esp32-dfu/dfu.cpp | 275 +++++++++++++++++++++++++++++++ esp32-dfu/esp32-dfu-v1.1.1.1.ino | 251 ++++++++++++++++++++++++++++ esp32-dfu/main.h | 24 +++ 3 files changed, 550 insertions(+) create mode 100644 esp32-dfu/dfu.cpp create mode 100644 esp32-dfu/esp32-dfu-v1.1.1.1.ino create mode 100644 esp32-dfu/main.h diff --git a/esp32-dfu/dfu.cpp b/esp32-dfu/dfu.cpp new file mode 100644 index 0000000..baed210 --- /dev/null +++ b/esp32-dfu/dfu.cpp @@ -0,0 +1,275 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +// Device Firmware Update support. (Note that this is only currently supported on ESP32.) +// As a note to the reader: sometimes firmware update is referred to as "OTA" (Over The Air) or +// "FOTA" (Firmware Over The Air). Technically, this code is reading the already-downloaded and +// already-verified firmware from the Notecard's storage in a fully-offline manner, which is why +// we use the more generic DFU (Device Firmware Update) term of art. + +#include "main.h" +#include +#include +#include "esp_partition.h" +#include "esp_system.h" +#include "esp_ota_ops.h" +#include "esp_flash_partitions.h" + +// Display DFU partition information +void dfuShowPartitions() { + const esp_partition_t *partition; + + notecard.logDebugf("ESP32 PARTITION SCHEME (should be two partitions to support OTA)\n"); + + partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, "app0"); + if (partition == NULL) + notecard.logDebugf(" partition app0: not found\n"); + else + notecard.logDebugf(" partition that should be 'app0' is '%s' at 0x%08x (%d bytes)\n", partition->label, partition->address, partition->size); + + partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, "app1"); + if (partition == NULL) + notecard.logDebugf(" partition app1: not found\n"); + else + notecard.logDebugf(" partition that should be 'app1' is '%s' at 0x%08x (%d bytes)\n", partition->label, partition->address, partition->size); + +} + +// Process DFU +void dfuPoll(bool force) { + static uint32_t dfuCheckMs = 0; + static uint32_t serviceIdleCheckMs = 0; + + // Suppress how often we check + if (!force && dfuCheckMs != 0 && millis() < dfuCheckMs + ms1Hour) + return; + + // Even if DFU is ready, only check the notehub status once every 10s max + if (!force && serviceIdleCheckMs != 0 && millis() < serviceIdleCheckMs + 10*ms1Sec) + return; + serviceIdleCheckMs = millis(); + + // Check status, and determine both if there is an image ready, and if the image is NEW. + bool imageIsReady = false; + bool imageIsSameAsCurrent = false; + char imageMD5[NOTE_MD5_HASH_STRING_SIZE] = {0}; + uint32_t imageLength = 0; + if (J *rsp = notecard.requestAndResponse(notecard.newRequest("dfu.status"))) { + if (strcmp(JGetString(rsp, "mode"), "ready") == 0) { + imageIsReady = true; + if (J *body = JGetObjectItem(rsp, "body")) { + imageLength = JGetInt(body, "length"); + strlcpy(imageMD5, JGetString(body, "md5"), sizeof(imageMD5)); + imageIsSameAsCurrent = strcmp(JGetString(body, "version"), firmwareVersion()) == 0; + if (!imageIsSameAsCurrent) { + notecard.logDebugf("dfu: replacing current image: %s\n", productVersion()); + notecard.logDebugf("dfu: with downloaded image: %s\n", JGetString(body, "name")); + } + } + } + notecard.deleteResponse(rsp); + } + + // Exit if same version or no DFU to process + if (!imageIsReady || imageIsSameAsCurrent || imageLength == 0) { + dfuCheckMs = millis(); + notecard.logDebugf("dfu: no image is ready for firmware update\n"); + return; + } + + // Enter DFU mode. Note that the Notecard will automatically switch us back out of + // DFU mode after 15m, so we don't leave the notecard in a bad state if we had a problem here. + if (J *req = notecard.newRequest("hub.set")) { + JAddStringToObject(req, "mode", "dfu"); + notecard.sendRequest(req); + } + + // Proceed with DFU + dfuCheckMs = millis(); + + // Wait until we have successfully entered the mode. The fact that this loop isn't + // just an infinitely loop is simply defensive programming. If for some odd reason + // we don't enter DFU mode, we'll eventually come back here on the next DFU poll. + bool inDFUMode = false; + int beganDFUModeCheck = millis(); + while (!inDFUMode && millis() < beganDFUModeCheck + (2 * ms1Min)) { + if (J *rsp = notecard.requestAndResponse(notecard.newRequest("dfu.get"))) { + if (!notecard.responseError(rsp)) + inDFUMode = true; + notecard.deleteResponse(rsp); + } + if (!inDFUMode) + delay(2500); + } + + // If we failed, leave DFU mode immediately + if (!inDFUMode) { + if (J *req = notecard.newRequest("hub.set")) { + JAddStringToObject(req, "mode", "dfu-completed"); + notecard.sendRequest(req); + } + return; + } + + // The image is ready. If the version is the same as what's in memory, then of course don't + // bother to do the update. + esp_err_t err; + // update handle : set by esp_ota_begin(), must be freed via esp_ota_end() + esp_ota_handle_t update_handle = 0 ; + const esp_partition_t *update_partition = NULL; + const esp_partition_t *configured = esp_ota_get_boot_partition(); + const esp_partition_t *running = esp_ota_get_running_partition(); + if (configured != running) { + notecard.logDebugf("dfu: configured OTA boot partition at offset 0x%08x, but running from offset 0x%08x\n", + configured->address, running->address); + notecard.logDebugf(" (This can happen if either the OTA boot data or preferred boot image become corrupted.)\n"); + } + notecard.logDebugf("dfu: running partition type %d subtype %d (offset 0x%08x)\n", running->type, running->subtype, running->address); + + update_partition = esp_ota_get_next_update_partition(NULL); + if (update_partition == NULL) // simply being defensive + return; + + notecard.logDebugf("dfu: writing to partition subtype %d at offset 0x%x\n", update_partition->subtype, update_partition->address); + + // Begin the update + err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle); + if (err != ESP_OK) { + notecard.logDebugf("esp_ota_begin failed (%s)\n", esp_err_to_name(err)); + return; + } + + notecard.logDebugf("dfu: beginning firmware update\n"); + + // Loop over received chunks + int offset = 0; + int chunklen = 4096; + int left = imageLength; + NoteMD5Context md5Context; + NoteMD5Init(&md5Context); + while (left) { + + // Read next chunk from card + int thislen = chunklen; + if (left < thislen) + thislen = left; + + // If anywhere, this is the location of the highest probability of I/O error + // on the I2C or serial bus, simply because of the amount of data being transferred. + // As such, it's a conservative measure just to retry. + char *payload = NULL; + for (int retry=0; retry<5; retry++) { + notecard.logDebugf("dfu: reading chunk (offset:%d length:%d try:%d)\n", offset, thislen, retry+1); + + // Request the next chunk from the notecard + J *req = notecard.newRequest("dfu.get"); + if (req == NULL) { + notecard.logDebugf("dfu: insufficient memory\n"); + return; + } + JAddNumberToObject(req, "offset", offset); + JAddNumberToObject(req, "length", thislen); + J *rsp = notecard.requestAndResponse(req); + if (rsp == NULL) { + notecard.logDebugf("dfu: insufficient memory\n"); + return; + } + if (notecard.responseError(rsp)) { + notecard.logDebugf("dfu: error on read: %s\n", JGetString(rsp, "err")); + } else { + char *payloadB64 = JGetString(rsp, "payload"); + if (payloadB64[0] == '\0') { + notecard.logDebugf("dfu: no payload\n"); + notecard.deleteResponse(rsp); + return; + } + payload = (char *) malloc(JB64DecodeLen(payloadB64)); + if (payload == NULL) { + notecard.logDebugf("dfu: can't allocate payload decode buffer\n"); + notecard.deleteResponse(rsp); + return; + } + int actuallen = JB64Decode(payload, payloadB64); + const char *expectedMD5 = JGetString(rsp, "status");; + char chunkMD5[NOTE_MD5_HASH_STRING_SIZE] = {0}; + NoteMD5HashString((uint8_t *)payload, actuallen, chunkMD5, sizeof(chunkMD5)); + if (actuallen == thislen && strcmp(chunkMD5, expectedMD5) == 0) { + notecard.deleteResponse(rsp); + break; + } + + free(payload); + payload = NULL; + + if (thislen != actuallen) + notecard.logDebugf("dfu: decoded data not the correct length (%d != actual %d)\n", thislen, actuallen); + else + notecard.logDebugf("dfu: %d-byte decoded data MD5 mismatch (%s != actual %s)\n", actuallen, expectedMD5, chunkMD5); + } + + notecard.deleteResponse(rsp); + } + if (payload == NULL) { + notecard.logDebugf("dfu: unrecoverable error on read\n"); + return; + } + + // MD5 the chunk + NoteMD5Update(&md5Context, (uint8_t *)payload, thislen); + + // Write the chunk + err = esp_ota_write(update_handle, (const void *)payload, thislen); + if (err != ESP_OK) { + free(payload); + return; + } + + // Move to next chunk + free(payload); + notecard.logDebugf("dfu: successfully transferred offset:%d len:%d\n", offset, thislen); + offset += thislen; + left -= thislen; + } + + // Exit DFU mode. (Had we not done this, the Notecard exits DFU mode automatically after 15m.) + if (J *req = notecard.newRequest("hub.set")) { + JAddStringToObject(req, "mode", "dfu-completed"); + notecard.sendRequest(req); + } + + // Done + if (esp_ota_end(update_handle) != ESP_OK) { + notecard.logDebugf("esp_ota_end failed!\n"); + return; + } + + // Validate the MD5 + uint8_t md5Hash[NOTE_MD5_HASH_SIZE]; + NoteMD5Final(md5Hash, &md5Context); + char md5HashString[NOTE_MD5_HASH_STRING_SIZE]; + NoteMD5HashToString(md5Hash, md5HashString, sizeof(md5HashString)); + notecard.logDebugf("dfu: MD5 of image: %s\n", imageMD5); + notecard.logDebugf("dfu: MD5 of download: %s\n", md5HashString); + if (strcmp(imageMD5, md5HashString) != 0) { + notecard.logDebugf("MD5 MISMATCH - ABANDONING DFU\n"); + return; + } + + // Set the boot partition and reboot + err = esp_ota_set_boot_partition(update_partition); + if (err != ESP_OK) { + notecard.logDebugf("dfu: restart failure\n"); + return; + } + + // Clear out the DFU image + if (J *req = notecard.newRequest("dfu.status")) { + JAddBoolToObject(req, "stop", true); + notecard.sendRequest(req); + } + + // Restart + notecard.logDebugf("dfu: restart system\n"); + esp_restart(); +} diff --git a/esp32-dfu/esp32-dfu-v1.1.1.1.ino b/esp32-dfu/esp32-dfu-v1.1.1.1.ino new file mode 100644 index 0000000..8c4474a --- /dev/null +++ b/esp32-dfu/esp32-dfu-v1.1.1.1.ino @@ -0,0 +1,251 @@ +// +// Copyright 2020 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. +// +// This example extends the periodic communications example by adding support for +// DFU (Device Firmware Update) as well as WiFi triangulation, both of which +// utilize Espressif's esp-idf support library. +// + +#include "main.h" +#include + +#ifndef ARDUINO_ARCH_ESP32 +#error "this sketch exclusively targets the ESP32 because it uses esp-idf" +#endif + +// C trickery to convert a number to a string +#define STRINGIFY(x) STRINGIFY_(x) +#define STRINGIFY_(x) #x + +// Definitions used by firmware update +#define PRODUCT_ORG_NAME "" +#define PRODUCT_DISPLAY_NAME "Notecard Example" +#define PRODUCT_FIRMWARE_ID "notecard-example-v1" +#define PRODUCT_DESC "" +#define PRODUCT_MAJOR 1 +#define PRODUCT_MINOR 1 +#define PRODUCT_PATCH 1 +#define PRODUCT_BUILD 1 +#define PRODUCT_BUILT __DATE__ " " __TIME__ +#define PRODUCT_BUILDER "" +#define PRODUCT_VERSION STRINGIFY(PRODUCT_MAJOR) "." STRINGIFY(PRODUCT_MINOR) "." STRINGIFY(PRODUCT_PATCH) + +// Define pin numbers based on the Feather and the Notecarrier-AF's user push button +#define buttonPin 21 +#define buttonPressedState LOW +#define ledPin 13 + +// Note that both of these definitions are optional; just prefix either line with // to remove it. +// Remove serialNotecard if you wired your Notecard using I2C SDA/SCL pins instead of serial RX/TX +// Remove serialDebug if you don't want the Notecard library to output debug information +//#define serialNotecard Serial1 +#define serialDebugOut Serial + +// This is the unique Product Identifier for your device. +#ifndef PRODUCT_UID +#define PRODUCT_UID "" +#endif +#define myProductID PRODUCT_UID + +Notecard notecard; + +// Button handling +#define BUTTON_IDLE 0 +#define BUTTON_PRESS 1 +#define BUTTON_DOUBLEPRESS 2 +int buttonPress(void); + +// One-time Arduino initialization +void setup() { + // Initialize Arduino GPIO pins + pinMode(ledPin, OUTPUT); + pinMode(buttonPin, buttonPressedState == LOW ? INPUT_PULLUP : INPUT); + + // During development, set up for debug output from the Notecard. Note that the initial delay is + // required by some Arduino cards before debug UART output can be successfully displayed in the + // Arduino IDE, including the Adafruit Feather nRF52840 Express. +#ifdef serialDebugOut + delay(2500); + serialDebugOut.begin(115200); + notecard.setDebugOutputStream(serialDebugOut); + notecard.logDebugf("\n"); +#endif + + // As the first thing, show the DFU partition information + dfuShowPartitions(); + + // Initialize the physical I/O channel to the Notecard +#ifdef serialNotecard + notecard.begin(serialNotecard, 9600); +#else + notecard.begin(); +#endif + + // Configure for sync + J *req = notecard.newRequest("hub.set"); + if (req != NULL) { + if (myProductID[0]) { + JAddStringToObject(req, "product", myProductID); + } + JAddStringToObject(req, "mode", "periodic"); + JAddNumberToObject(req, "outbound", 2); + JAddNumberToObject(req, "inbound", 60); + notecard.sendRequest(req); + } + + // Notify the Notehub of our current firmware version + req = notecard.newRequest("dfu.status"); + if (req != NULL) { + JAddStringToObject(req, "version", firmwareVersion()); + notecard.sendRequest(req); + } +} + +// In the Arduino main loop which is called repeatedly, add outbound data every 15 seconds +void loop() { + static unsigned long lastStatusMs = 0; + + // Wait for a button press, or perform idle activities + int buttonState = buttonPress(); + switch (buttonState) { + + case BUTTON_IDLE: + // Poll subsystems that need periodic servicing + dfuPoll(false); + + // Display sync status on the debug console as a convenience, coming back + // here after 2.5 seconds of sync inactivity + if (notecard.debugSyncStatus(2500, 0)) + lastStatusMs = millis(); + + // Periodically display a help message on the debug console + if (millis() > lastStatusMs + 10000) { + lastStatusMs = millis(); + notecard.sendRequest(notecard.newRequest("dfu.status")); // (just to show current status in debug output) + notecard.logDebug("press button to simulate a sensor measurement; double-press to sync/dfu/wifi-scan\n"); + } + + return; + + case BUTTON_DOUBLEPRESS: + digitalWrite(ledPin, HIGH); + dfuPoll(true); + notecard.requestAndResponse(notecard.newRequest("hub.sync")); + digitalWrite(ledPin, LOW); + return; + + } + + // Activity indicator + digitalWrite(ledPin, HIGH); + + // The button was pressed, so we should begin a transaction + notecard.logDebug("performing sensor measurement\n"); + lastStatusMs = millis(); + + // Count the simulated measurements that we send to the cloud, and stop the demo before long. + static unsigned eventCounter = 0; + if (eventCounter++ > 25) + return; + + // Read the notecard's current temperature and voltage, as simulated sensor measurements + double temperature = 0; + J *rsp = notecard.requestAndResponse(notecard.newRequest("card.temp")); + if (rsp != NULL) { + temperature = JGetNumber(rsp, "value"); + notecard.deleteResponse(rsp); + } + double voltage = 0; + rsp = notecard.requestAndResponse(notecard.newRequest("card.voltage")); + if (rsp != NULL) { + voltage = JGetNumber(rsp, "value"); + notecard.deleteResponse(rsp); + } + + // Enqueue the measurement to the Notecard for transmission to the Notehub. These measurements + // will be staged in the Notecard's flash memory until it's time to transmit them to the service. + J *req = notecard.newRequest("note.add"); + if (req != NULL) { + J *body = JCreateObject(); + if (body != NULL) { + JAddNumberToObject(body, "temp", temperature); + JAddNumberToObject(body, "voltage", voltage); + JAddNumberToObject(body, "count", eventCounter); + JAddItemToObject(req, "body", body); + } + notecard.sendRequest(req); + } + + // Done with transaction + digitalWrite(ledPin, LOW); +} + +// Button handling +int buttonPress() { + // Detect the "press" transition + static bool buttonBeingDebounced = false; + int buttonState = digitalRead(buttonPin); + if (buttonState != buttonPressedState) { + if (buttonBeingDebounced) { + buttonBeingDebounced = false; + } + return BUTTON_IDLE; + } + if (buttonBeingDebounced) + return BUTTON_IDLE; + + // Wait to see if this is a double-press + bool buttonDoublePress = false; + bool buttonReleased = false; + unsigned long buttonPressedMs = millis(); + unsigned long ignoreBounceMs = 100; + unsigned long doublePressMs = 750; + while (millis() < buttonPressedMs+doublePressMs || digitalRead(buttonPin) == buttonPressedState) { + if (millis() < buttonPressedMs+ignoreBounceMs) + continue; + if (digitalRead(buttonPin) != buttonPressedState) { + if (!buttonReleased) + buttonReleased = true; + continue; + } + if (buttonReleased) { + buttonDoublePress = true; + if (digitalRead(buttonPin) != buttonPressedState) + break; + } + } + + return (buttonDoublePress ? BUTTON_DOUBLEPRESS : BUTTON_PRESS); +} + +// This is a product configuration JSON structure that enables the Notehub to recognize this +// firmware when it's uploaded, to help keep track of versions and so we only ever download +// firmware buildss that are appropriate for this device. +#define QUOTE(x) "\"" x "\"" +#define FIRMWARE_VERSION_HEADER "firmware::info:" +#define FIRMWARE_VERSION FIRMWARE_VERSION_HEADER \ + "{" QUOTE("org") ":" QUOTE(PRODUCT_ORG_NAME) \ + "," QUOTE("product") ":" QUOTE(PRODUCT_DISPLAY_NAME) \ + "," QUOTE("description") ":" QUOTE(PRODUCT_DESC) \ + "," QUOTE("firmware") ":" QUOTE(PRODUCT_FIRMWARE_ID) \ + "," QUOTE("version") ":" QUOTE(PRODUCT_VERSION) \ + "," QUOTE("built") ":" QUOTE(PRODUCT_BUILT) \ + "," QUOTE("ver_major") ":" STRINGIFY(PRODUCT_MAJOR) \ + "," QUOTE("ver_minor") ":" STRINGIFY(PRODUCT_MINOR) \ + "," QUOTE("ver_patch") ":" STRINGIFY(PRODUCT_PATCH) \ + "," QUOTE("ver_build") ":" STRINGIFY(PRODUCT_BUILD) \ + "," QUOTE("builder") ":" QUOTE(PRODUCT_BUILDER) \ + "}" + +// In the Arduino IDE, the ino is built regardless of whether or not it is modified. As such, it's a perfect +// place to serve up the build version string because __DATE__ and __TIME__ are updated properly for each build. +const char *productVersion() { + return ("Ver " PRODUCT_VERSION " " PRODUCT_BUILT); +} + +// Return the firmware's version, which is both stored within the image and which is verified by DFU +const char *firmwareVersion() { + return &FIRMWARE_VERSION[strlen(FIRMWARE_VERSION_HEADER)]; +} diff --git a/esp32-dfu/main.h b/esp32-dfu/main.h new file mode 100644 index 0000000..cf29387 --- /dev/null +++ b/esp32-dfu/main.h @@ -0,0 +1,24 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +#pragma once + +#include +#include + +// Constants +#define secs1Min (60) +#define secs1Hour (60*secs1Min) +#define ms1Sec (1000) +#define ms1Min (1000*secs1Min) +#define ms1Hour (1000*secs1Hour) + +// .ino +extern Notecard notecard; +const char *productVersion(void); +const char *firmwareVersion(void); + +// dfu.cpp +void dfuShowPartitions(void); +void dfuPoll(bool force); From 3c2dc9e6a011d0f019f70d358405a9e1978dd674 Mon Sep 17 00:00:00 2001 From: Rob Lauer Date: Tue, 19 May 2026 11:04:46 -0500 Subject: [PATCH 2/5] fix ci errors --- .github/workflows/note-samples-python-ci.yml | 1 - README.md | 14 +++++++++----- python-dfu/test/test_dfu.py | 1 + python-ota-request-manager/requirements-ci.txt | 2 ++ 4 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 python-ota-request-manager/requirements-ci.txt diff --git a/.github/workflows/note-samples-python-ci.yml b/.github/workflows/note-samples-python-ci.yml index 120c2ed..2cce5aa 100644 --- a/.github/workflows/note-samples-python-ci.yml +++ b/.github/workflows/note-samples-python-ci.yml @@ -12,7 +12,6 @@ jobs: python-version: ["3.10"] # ["3.7", "3.8", "3.9", "3.10"] sample: - "python-dfu" - - "python-airnote-data-migration" - "python-remote-commands-attn-rpi" - "python-route-endpoint" - "python-ota-request-manager" diff --git a/README.md b/README.md index 8318340..4272471 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,12 @@ Notecard and Notehub application samples. ## Content Overview |Folder|Application Description| |------|-----------------------| -|[python-dfu](python-dfu)|Enable over-the-air updates of Python files executing on a host MCU via Notecard. OTA content packaged in TAR-file. Supports Python and Micropython. -|[python-large-file-upload](python-large-file-upload)|Upload chunks of a file using Notecard web requests from a Python script. -|[python-remote-commands-attn-rpi](python-remote-commands-attn-rpi)|Example Raspberry Pi application in Python that enables users to send "commands" to Raspberry Pi from Notehub. Uses the ATTN Pin on Notecard to notify the Raspberry Pi a message is available to be read| -|[python-route-endpoint](python-route-endpoint)|Example HTTP endpoint that can deploy on Apache to receive data routed from the Notehub cloud service| - +|[arduino-note-array](arduino-note-array)|Accumulate multiple JSON data elements into a single Notecard note for routing.| +|[esp32-dfu](esp32-dfu)|Arduino/ESP32 sketch demonstrating Notecard-orchestrated firmware updates (DFU) with WiFi triangulation support.| +|[python-dfu](python-dfu)|Enable over-the-air updates of Python files executing on a host MCU via Notecard. OTA content packaged in TAR-file. Supports Python and Micropython.| +|[python-large-file-upload](python-large-file-upload)|Upload chunks of a file using Notecard web requests from a Python script.| +|[python-notehub-api](python-notehub-api)|Generate a Python client for the Notehub API from the OpenAPI spec.| +|[python-ota-request-manager](python-ota-request-manager)|Manage and audit fleet-wide DFU (firmware/host) requests across a Notehub project.| +|[python-remote-commands-attn-rpi](python-remote-commands-attn-rpi)|Example Raspberry Pi application in Python that enables users to send "commands" to Raspberry Pi from Notehub. Uses the ATTN pin on Notecard to notify the Raspberry Pi a message is available to be read.| +|[python-route-endpoint](python-route-endpoint)|Example HTTP endpoint that can deploy on Apache to receive data routed from the Notehub cloud service.| +|[python-softap-fix](python-softap-fix)|Repair NOTE-ESP (ESP32 Wi-Fi) Notecards that are missing the assets required to run SoftAP.| diff --git a/python-dfu/test/test_dfu.py b/python-dfu/test/test_dfu.py index 73b69fa..30ca32f 100644 --- a/python-dfu/test/test_dfu.py +++ b/python-dfu/test/test_dfu.py @@ -25,6 +25,7 @@ def createNotecardAndPort(): port = serial.Serial("/dev/tty.foo", 9600) port.read.side_effect = [b'\r', b'\n', None] port.readline.return_value = "\r\n" + port.in_waiting = 0 port.write() nCard = notecard.OpenSerial(port) diff --git a/python-ota-request-manager/requirements-ci.txt b/python-ota-request-manager/requirements-ci.txt new file mode 100644 index 0000000..af66316 --- /dev/null +++ b/python-ota-request-manager/requirements-ci.txt @@ -0,0 +1,2 @@ +pytest +urllib3 From 481a689ee9126cf1e480689de16b5fc4b39d6e05 Mon Sep 17 00:00:00 2001 From: Rob Lauer Date: Tue, 19 May 2026 11:25:45 -0500 Subject: [PATCH 3/5] esp32 fixes per claude --- README.md | 2 +- esp32-dfu/README.md | 51 ++++++++ esp32-dfu/dfu.cpp | 109 +++++++++++------- .../{esp32-dfu-v1.1.1.1.ino => esp32-dfu.ino} | 21 ++-- esp32-dfu/main.h | 12 ++ 5 files changed, 144 insertions(+), 51 deletions(-) create mode 100644 esp32-dfu/README.md rename esp32-dfu/{esp32-dfu-v1.1.1.1.ino => esp32-dfu.ino} (91%) diff --git a/README.md b/README.md index 4272471..9f2079a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Notecard and Notehub application samples. |Folder|Application Description| |------|-----------------------| |[arduino-note-array](arduino-note-array)|Accumulate multiple JSON data elements into a single Notecard note for routing.| -|[esp32-dfu](esp32-dfu)|Arduino/ESP32 sketch demonstrating Notecard-orchestrated firmware updates (DFU) with WiFi triangulation support.| +|[esp32-dfu](esp32-dfu)|Arduino/ESP32 sketch demonstrating Notecard-orchestrated host firmware updates (DFU) using the IAP flow: chunked `dfu.get`, OTA partition writes, MD5 validation, and clean error reporting back to Notehub.| |[python-dfu](python-dfu)|Enable over-the-air updates of Python files executing on a host MCU via Notecard. OTA content packaged in TAR-file. Supports Python and Micropython.| |[python-large-file-upload](python-large-file-upload)|Upload chunks of a file using Notecard web requests from a Python script.| |[python-notehub-api](python-notehub-api)|Generate a Python client for the Notehub API from the OpenAPI spec.| diff --git a/esp32-dfu/README.md b/esp32-dfu/README.md new file mode 100644 index 0000000..58f0096 --- /dev/null +++ b/esp32-dfu/README.md @@ -0,0 +1,51 @@ +# esp32-dfu + +Arduino/ESP32 sketch demonstrating Notecard-orchestrated host firmware updates (DFU) +using the Notecard's IAP (In-Application Programming) flow. The Notecard downloads +the firmware image from Notehub, then this sketch reads it chunk-by-chunk via +`dfu.get`, writes it to the inactive OTA partition, validates MD5, sets the boot +partition, and reboots. + +## Hardware + +- An ESP32-based host (e.g. Adafruit HUZZAH32 Feather) +- A Blues Notecard on a Notecarrier F (or similar) +- A push button on `buttonPin` (default GPIO 21) — single press logs a simulated + sensor reading, double press forces a DFU poll and `hub.sync` + +The sketch defaults to I2C for the Notecard. To use serial instead, uncomment +`#define serialNotecard Serial1` near the top of [esp32-dfu.ino](esp32-dfu.ino). + +## Setup + +1. Claim a [ProductUID](https://dev.blues.io/notehub/notehub-walkthrough/#finding-a-productuid) + in Notehub and hardcode `#define PRODUCT_UID "..."` in the sketch. +2. Open `esp32-dfu/esp32-dfu.ino` in the Arduino IDE (the sketch filename must + match the directory name). +3. Install the **Blues Wireless Notecard** library via the Arduino Library Manager + or `arduino-cli lib install "Blues Wireless Notecard"`. +4. Select an ESP32 board with an OTA-capable partition scheme. +5. Compile and upload. + +## How DFU Works in This Sketch + +- `setup()` reports the current firmware version to Notehub via `dfu.status`. +- `loop()` calls `dfuPoll(false)` periodically (rate-limited to once per hour + unless forced via a double button press). +- When `dfu.status` reports `mode:"ready"` with a newer image, the sketch: + 1. Sets `hub.set, mode:"dfu"` to put the Notecard in DFU mode. + 2. Waits up to two minutes for DFU mode to actually engage (verified via + `dfu.get`). + 3. Begins an `esp_ota_begin`/`esp_ota_write` sequence, reading 4 KB chunks + via `dfu.get` and verifying each chunk's MD5. + 4. On success: reverts hub mode (`hub.set, mode:"-"`), validates the full-image + MD5, sets the boot partition, clears DFU state (`dfu.status, stop:true`), + and reboots. + 5. On any failure: cleanly releases the OTA handle, reports the error to + Notehub via `dfu.status, stop:true, err:"..."`, and reverts hub mode. + +## Files + +- [esp32-dfu.ino](esp32-dfu.ino) — sketch entry point: setup, loop, button handling, version reporting. +- [dfu.cpp](dfu.cpp) — DFU state machine: partition discovery, chunked `dfu.get`, OTA write, MD5 validation. +- [main.h](main.h) — shared declarations. diff --git a/esp32-dfu/dfu.cpp b/esp32-dfu/dfu.cpp index baed210..478af82 100644 --- a/esp32-dfu/dfu.cpp +++ b/esp32-dfu/dfu.cpp @@ -16,23 +16,43 @@ #include "esp_ota_ops.h" #include "esp_flash_partitions.h" +// Cleanly back out of DFU on any failure: release the ESP OTA handle (if open), +// tell the Notecard to clear staged DFU state (with an optional error string +// that surfaces on Notehub), and revert hub mode to whatever it was before DFU. +static void dfuAbort(esp_ota_handle_t handle, const char *err) { + if (handle != 0) { + esp_ota_end(handle); + } + if (J *req = notecard.newRequest("dfu.status")) { + JAddBoolToObject(req, "stop", true); + if (err != NULL) { + JAddStringToObject(req, "err", err); + } + notecard.sendRequest(req); + } + if (J *req = notecard.newRequest("hub.set")) { + JAddStringToObject(req, "mode", "-"); + notecard.sendRequest(req); + } +} + // Display DFU partition information void dfuShowPartitions() { const esp_partition_t *partition; - notecard.logDebugf("ESP32 PARTITION SCHEME (should be two partitions to support OTA)\n"); + APP_LOGF("ESP32 PARTITION SCHEME (should be two partitions to support OTA)\n"); partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, "app0"); if (partition == NULL) - notecard.logDebugf(" partition app0: not found\n"); + APP_LOGF(" partition app0: not found\n"); else - notecard.logDebugf(" partition that should be 'app0' is '%s' at 0x%08x (%d bytes)\n", partition->label, partition->address, partition->size); + APP_LOGF(" partition that should be 'app0' is '%s' at 0x%08lx (%lu bytes)\n", partition->label, (unsigned long)partition->address, (unsigned long)partition->size); partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, "app1"); if (partition == NULL) - notecard.logDebugf(" partition app1: not found\n"); + APP_LOGF(" partition app1: not found\n"); else - notecard.logDebugf(" partition that should be 'app1' is '%s' at 0x%08x (%d bytes)\n", partition->label, partition->address, partition->size); + APP_LOGF(" partition that should be 'app1' is '%s' at 0x%08lx (%lu bytes)\n", partition->label, (unsigned long)partition->address, (unsigned long)partition->size); } @@ -63,8 +83,8 @@ void dfuPoll(bool force) { strlcpy(imageMD5, JGetString(body, "md5"), sizeof(imageMD5)); imageIsSameAsCurrent = strcmp(JGetString(body, "version"), firmwareVersion()) == 0; if (!imageIsSameAsCurrent) { - notecard.logDebugf("dfu: replacing current image: %s\n", productVersion()); - notecard.logDebugf("dfu: with downloaded image: %s\n", JGetString(body, "name")); + APP_LOGF("dfu: replacing current image: %s\n", productVersion()); + APP_LOGF("dfu: with downloaded image: %s\n", JGetString(body, "name")); } } } @@ -74,7 +94,7 @@ void dfuPoll(bool force) { // Exit if same version or no DFU to process if (!imageIsReady || imageIsSameAsCurrent || imageLength == 0) { dfuCheckMs = millis(); - notecard.logDebugf("dfu: no image is ready for firmware update\n"); + APP_LOGF("dfu: no image is ready for firmware update\n"); return; } @@ -89,10 +109,10 @@ void dfuPoll(bool force) { dfuCheckMs = millis(); // Wait until we have successfully entered the mode. The fact that this loop isn't - // just an infinitely loop is simply defensive programming. If for some odd reason + // just an infinite loop is simply defensive programming. If for some odd reason // we don't enter DFU mode, we'll eventually come back here on the next DFU poll. bool inDFUMode = false; - int beganDFUModeCheck = millis(); + uint32_t beganDFUModeCheck = millis(); while (!inDFUMode && millis() < beganDFUModeCheck + (2 * ms1Min)) { if (J *rsp = notecard.requestAndResponse(notecard.newRequest("dfu.get"))) { if (!notecard.responseError(rsp)) @@ -105,10 +125,7 @@ void dfuPoll(bool force) { // If we failed, leave DFU mode immediately if (!inDFUMode) { - if (J *req = notecard.newRequest("hub.set")) { - JAddStringToObject(req, "mode", "dfu-completed"); - notecard.sendRequest(req); - } + dfuAbort(0, "host failed to enter DFU mode"); return; } @@ -121,26 +138,29 @@ void dfuPoll(bool force) { const esp_partition_t *configured = esp_ota_get_boot_partition(); const esp_partition_t *running = esp_ota_get_running_partition(); if (configured != running) { - notecard.logDebugf("dfu: configured OTA boot partition at offset 0x%08x, but running from offset 0x%08x\n", - configured->address, running->address); - notecard.logDebugf(" (This can happen if either the OTA boot data or preferred boot image become corrupted.)\n"); + APP_LOGF("dfu: configured OTA boot partition at offset 0x%08lx, but running from offset 0x%08lx\n", + (unsigned long)configured->address, (unsigned long)running->address); + APP_LOGF(" (This can happen if either the OTA boot data or preferred boot image become corrupted.)\n"); } - notecard.logDebugf("dfu: running partition type %d subtype %d (offset 0x%08x)\n", running->type, running->subtype, running->address); + APP_LOGF("dfu: running partition type %d subtype %d (offset 0x%08lx)\n", running->type, running->subtype, (unsigned long)running->address); update_partition = esp_ota_get_next_update_partition(NULL); - if (update_partition == NULL) // simply being defensive + if (update_partition == NULL) { // simply being defensive + dfuAbort(0, "no OTA partition available"); return; + } - notecard.logDebugf("dfu: writing to partition subtype %d at offset 0x%x\n", update_partition->subtype, update_partition->address); + APP_LOGF("dfu: writing to partition subtype %d at offset 0x%lx\n", update_partition->subtype, (unsigned long)update_partition->address); // Begin the update err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle); if (err != ESP_OK) { - notecard.logDebugf("esp_ota_begin failed (%s)\n", esp_err_to_name(err)); + APP_LOGF("esp_ota_begin failed (%s)\n", esp_err_to_name(err)); + dfuAbort(0, esp_err_to_name(err)); return; } - notecard.logDebugf("dfu: beginning firmware update\n"); + APP_LOGF("dfu: beginning firmware update\n"); // Loop over received chunks int offset = 0; @@ -160,38 +180,42 @@ void dfuPoll(bool force) { // As such, it's a conservative measure just to retry. char *payload = NULL; for (int retry=0; retry<5; retry++) { - notecard.logDebugf("dfu: reading chunk (offset:%d length:%d try:%d)\n", offset, thislen, retry+1); + APP_LOGF("dfu: reading chunk (offset:%d length:%d try:%d)\n", offset, thislen, retry+1); // Request the next chunk from the notecard J *req = notecard.newRequest("dfu.get"); if (req == NULL) { - notecard.logDebugf("dfu: insufficient memory\n"); + APP_LOGF("dfu: insufficient memory\n"); + dfuAbort(update_handle, "out of memory"); return; } JAddNumberToObject(req, "offset", offset); JAddNumberToObject(req, "length", thislen); J *rsp = notecard.requestAndResponse(req); if (rsp == NULL) { - notecard.logDebugf("dfu: insufficient memory\n"); + APP_LOGF("dfu: insufficient memory\n"); + dfuAbort(update_handle, "out of memory"); return; } if (notecard.responseError(rsp)) { - notecard.logDebugf("dfu: error on read: %s\n", JGetString(rsp, "err")); + APP_LOGF("dfu: error on read: %s\n", JGetString(rsp, "err")); } else { char *payloadB64 = JGetString(rsp, "payload"); if (payloadB64[0] == '\0') { - notecard.logDebugf("dfu: no payload\n"); + APP_LOGF("dfu: no payload\n"); notecard.deleteResponse(rsp); + dfuAbort(update_handle, "no payload"); return; } payload = (char *) malloc(JB64DecodeLen(payloadB64)); if (payload == NULL) { - notecard.logDebugf("dfu: can't allocate payload decode buffer\n"); + APP_LOGF("dfu: can't allocate payload decode buffer\n"); notecard.deleteResponse(rsp); + dfuAbort(update_handle, "out of memory"); return; } int actuallen = JB64Decode(payload, payloadB64); - const char *expectedMD5 = JGetString(rsp, "status");; + const char *expectedMD5 = JGetString(rsp, "status"); char chunkMD5[NOTE_MD5_HASH_STRING_SIZE] = {0}; NoteMD5HashString((uint8_t *)payload, actuallen, chunkMD5, sizeof(chunkMD5)); if (actuallen == thislen && strcmp(chunkMD5, expectedMD5) == 0) { @@ -203,15 +227,16 @@ void dfuPoll(bool force) { payload = NULL; if (thislen != actuallen) - notecard.logDebugf("dfu: decoded data not the correct length (%d != actual %d)\n", thislen, actuallen); + APP_LOGF("dfu: decoded data not the correct length (%d != actual %d)\n", thislen, actuallen); else - notecard.logDebugf("dfu: %d-byte decoded data MD5 mismatch (%s != actual %s)\n", actuallen, expectedMD5, chunkMD5); + APP_LOGF("dfu: %d-byte decoded data MD5 mismatch (%s != actual %s)\n", actuallen, expectedMD5, chunkMD5); } notecard.deleteResponse(rsp); } if (payload == NULL) { - notecard.logDebugf("dfu: unrecoverable error on read\n"); + APP_LOGF("dfu: unrecoverable error on read\n"); + dfuAbort(update_handle, "unrecoverable read error"); return; } @@ -222,25 +247,27 @@ void dfuPoll(bool force) { err = esp_ota_write(update_handle, (const void *)payload, thislen); if (err != ESP_OK) { free(payload); + dfuAbort(update_handle, esp_err_to_name(err)); return; } // Move to next chunk free(payload); - notecard.logDebugf("dfu: successfully transferred offset:%d len:%d\n", offset, thislen); + APP_LOGF("dfu: successfully transferred offset:%d len:%d\n", offset, thislen); offset += thislen; left -= thislen; } // Exit DFU mode. (Had we not done this, the Notecard exits DFU mode automatically after 15m.) if (J *req = notecard.newRequest("hub.set")) { - JAddStringToObject(req, "mode", "dfu-completed"); + JAddStringToObject(req, "mode", "-"); notecard.sendRequest(req); } // Done if (esp_ota_end(update_handle) != ESP_OK) { - notecard.logDebugf("esp_ota_end failed!\n"); + APP_LOGF("esp_ota_end failed!\n"); + dfuAbort(0, "esp_ota_end failed"); return; } @@ -249,17 +276,19 @@ void dfuPoll(bool force) { NoteMD5Final(md5Hash, &md5Context); char md5HashString[NOTE_MD5_HASH_STRING_SIZE]; NoteMD5HashToString(md5Hash, md5HashString, sizeof(md5HashString)); - notecard.logDebugf("dfu: MD5 of image: %s\n", imageMD5); - notecard.logDebugf("dfu: MD5 of download: %s\n", md5HashString); + APP_LOGF("dfu: MD5 of image: %s\n", imageMD5); + APP_LOGF("dfu: MD5 of download: %s\n", md5HashString); if (strcmp(imageMD5, md5HashString) != 0) { - notecard.logDebugf("MD5 MISMATCH - ABANDONING DFU\n"); + APP_LOGF("MD5 MISMATCH - ABANDONING DFU\n"); + dfuAbort(0, "MD5 mismatch"); return; } // Set the boot partition and reboot err = esp_ota_set_boot_partition(update_partition); if (err != ESP_OK) { - notecard.logDebugf("dfu: restart failure\n"); + APP_LOGF("dfu: restart failure\n"); + dfuAbort(0, esp_err_to_name(err)); return; } @@ -270,6 +299,6 @@ void dfuPoll(bool force) { } // Restart - notecard.logDebugf("dfu: restart system\n"); + APP_LOGF("dfu: restart system\n"); esp_restart(); } diff --git a/esp32-dfu/esp32-dfu-v1.1.1.1.ino b/esp32-dfu/esp32-dfu.ino similarity index 91% rename from esp32-dfu/esp32-dfu-v1.1.1.1.ino rename to esp32-dfu/esp32-dfu.ino index 8c4474a..bdf68ba 100644 --- a/esp32-dfu/esp32-dfu-v1.1.1.1.ino +++ b/esp32-dfu/esp32-dfu.ino @@ -37,15 +37,14 @@ #define buttonPressedState LOW #define ledPin 13 -// Note that both of these definitions are optional; just prefix either line with // to remove it. -// Remove serialNotecard if you wired your Notecard using I2C SDA/SCL pins instead of serial RX/TX -// Remove serialDebug if you don't want the Notecard library to output debug information +// Uncomment to wire the Notecard via serial RX/TX instead of I2C SDA/SCL. +// (Application debug output is controlled by `serialDebugOut` in main.h.) //#define serialNotecard Serial1 -#define serialDebugOut Serial // This is the unique Product Identifier for your device. #ifndef PRODUCT_UID #define PRODUCT_UID "" +#pragma message "PRODUCT_UID is not defined. The device will run but will not associate with a Notehub project until PRODUCT_UID is set. See https://dev.blues.io/tools-and-sdks/samples/product-uid" #endif #define myProductID PRODUCT_UID @@ -70,7 +69,7 @@ void setup() { delay(2500); serialDebugOut.begin(115200); notecard.setDebugOutputStream(serialDebugOut); - notecard.logDebugf("\n"); + APP_LOGF("\n"); #endif // As the first thing, show the DFU partition information @@ -83,7 +82,9 @@ void setup() { notecard.begin(); #endif - // Configure for sync + // Configure for sync. Use sendRequestWithRetry() on the first transaction + // after notecard.begin() to absorb the cold-boot race where the Notecard + // may not yet be ready to receive a request. J *req = notecard.newRequest("hub.set"); if (req != NULL) { if (myProductID[0]) { @@ -92,7 +93,7 @@ void setup() { JAddStringToObject(req, "mode", "periodic"); JAddNumberToObject(req, "outbound", 2); JAddNumberToObject(req, "inbound", 60); - notecard.sendRequest(req); + notecard.sendRequestWithRetry(req, 5); } // Notify the Notehub of our current firmware version @@ -124,7 +125,7 @@ void loop() { if (millis() > lastStatusMs + 10000) { lastStatusMs = millis(); notecard.sendRequest(notecard.newRequest("dfu.status")); // (just to show current status in debug output) - notecard.logDebug("press button to simulate a sensor measurement; double-press to sync/dfu/wifi-scan\n"); + APP_LOG("press button to simulate a sensor measurement; double-press to sync/dfu/wifi-scan\n"); } return; @@ -142,7 +143,7 @@ void loop() { digitalWrite(ledPin, HIGH); // The button was pressed, so we should begin a transaction - notecard.logDebug("performing sensor measurement\n"); + APP_LOG("performing sensor measurement\n"); lastStatusMs = millis(); // Count the simulated measurements that we send to the cloud, and stop the demo before long. @@ -222,7 +223,7 @@ int buttonPress() { // This is a product configuration JSON structure that enables the Notehub to recognize this // firmware when it's uploaded, to help keep track of versions and so we only ever download -// firmware buildss that are appropriate for this device. +// firmware builds that are appropriate for this device. #define QUOTE(x) "\"" x "\"" #define FIRMWARE_VERSION_HEADER "firmware::info:" #define FIRMWARE_VERSION FIRMWARE_VERSION_HEADER \ diff --git a/esp32-dfu/main.h b/esp32-dfu/main.h index cf29387..f6af18f 100644 --- a/esp32-dfu/main.h +++ b/esp32-dfu/main.h @@ -14,6 +14,18 @@ #define ms1Min (1000*secs1Min) #define ms1Hour (1000*secs1Hour) +// Optional debug serial port for application logging. Comment this line out +// to silence all APP_LOG / APP_LOGF output across the sketch. +#define serialDebugOut Serial + +#ifdef serialDebugOut + #define APP_LOG(s) serialDebugOut.print(s) + #define APP_LOGF(...) serialDebugOut.printf(__VA_ARGS__) +#else + #define APP_LOG(s) ((void)0) + #define APP_LOGF(...) ((void)0) +#endif + // .ino extern Notecard notecard; const char *productVersion(void); From f414ba1d4ca529a170a649fc6869b44b1dc33a73 Mon Sep 17 00:00:00 2001 From: Rob Lauer Date: Tue, 19 May 2026 11:31:52 -0500 Subject: [PATCH 4/5] bump action versions --- .github/workflows/note-samples-python-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/note-samples-python-ci.yml b/.github/workflows/note-samples-python-ci.yml index 2cce5aa..7132766 100644 --- a/.github/workflows/note-samples-python-ci.yml +++ b/.github/workflows/note-samples-python-ci.yml @@ -16,9 +16,9 @@ jobs: - "python-route-endpoint" - "python-ota-request-manager" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies From 1b1e7c1042ce0b34fe417084a13038576fc16ecd Mon Sep 17 00:00:00 2001 From: Rob Lauer Date: Tue, 19 May 2026 11:45:04 -0500 Subject: [PATCH 5/5] claude updated tests for latest note-python --- python-dfu/test/test_dfu.py | 84 ++++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/python-dfu/test/test_dfu.py b/python-dfu/test/test_dfu.py index 30ca32f..8f35d67 100644 --- a/python-dfu/test/test_dfu.py +++ b/python-dfu/test/test_dfu.py @@ -20,14 +20,63 @@ os.path.join(os.path.dirname(__file__), '..'))) -def createNotecardAndPort(): - serial = Mock() # noqa: F811 - port = serial.Serial("/dev/tty.foo", 9600) - port.read.side_effect = [b'\r', b'\n', None] - port.readline.return_value = "\r\n" - port.in_waiting = 0 - port.write() +# Speed/scope adjustments for note-python's OpenSerial under test: +# * Reset() polls the UART for up to 500 ms × 10 retries during construction. +# Tests don't depend on its side effects — patch it out so each +# createNotecardAndPort() returns instantly. +# * transmit() sleeps CARD_REQUEST_SEGMENT_DELAY_MS (250 ms) after every +# request segment. Across 100+ tests this adds minutes of wall time. +# * use_serial_lock=True would acquire a FileLock on /tmp/serial.lock on +# every transaction. Tests don't need cross-process coordination. +notecard.OpenSerial.Reset = lambda self: None +notecard.notecard.CARD_REQUEST_SEGMENT_DELAY_MS = 0 +notecard.notecard.use_serial_lock = False + + +class MockPort: + """Stateful mock UART for note-python's OpenSerial. + + note-python 2.x reads byte-by-byte through `_read_byte()` (`uart.read(1)`) + and gates reads on `_available()` (`uart.in_waiting > 0`), so a plain + `Mock` no longer works as a stand-in. This class exposes the minimum + surface OpenSerial needs: + + * `in_waiting` reflects bytes pending in the RX buffer + * `read(n)` consumes up to `n` bytes from the RX buffer + * `write(data)` appends to `writebuffer` so tests can inspect requests + * `feed(data)` helper for fixture code to queue a response + + Test helpers (`setResponse`, `setResponseList`) push bytes into the RX + buffer via `feed()`; `receive()` then reads until it sees a newline. + """ + + def __init__(self): + self._rx = bytearray() + self.writebuffer = b'' + + @property + def in_waiting(self): + return len(self._rx) + + def read(self, n=1): + chunk = bytes(self._rx[:n]) + del self._rx[:n] + return chunk + + def write(self, data): + if isinstance(data, str): + data = data.encode('utf-8') + self.writebuffer += bytes(data) + return len(data) + + def feed(self, data): + if isinstance(data, str): + data = data.encode('utf-8') + self._rx.extend(data) + +def createNotecardAndPort(): + port = MockPort() nCard = notecard.OpenSerial(port) return (nCard, port) @@ -41,20 +90,20 @@ def convertToSerialStr(r): def setResponse(port, response): - - port.readline.return_value = convertToSerialStr(response) + # Overwrite semantics: drop any queued bytes from a previous call, then + # queue this response so the next Transaction reads it. + port._rx.clear() + port.feed(convertToSerialStr(response)) def setResponseList(port, response): - if type(response) is not list: response = [response, ] - s = [] + port._rx.clear() for r in response: - s.append(convertToSerialStr(r)) + port.feed(convertToSerialStr(r)) - port.readline.side_effect = s def createReaderWithMockNotecard(card = Mock()): return dfu.dfuReader(card, info={"length":7}) @@ -67,13 +116,10 @@ def createReaderAndPort(): def addWriteableBytesBuffer(port): - def writeToBuffer(p, d): - p.writebuffer += (d) - return len(d) - + # MockPort always buffers writes — reset the buffer so this test sees + # only the bytes written after this call (matches the old fixture's + # behaviour of installing a fresh buffer). port.writebuffer = b'' - port.write = lambda d: writeToBuffer(port, d) - return port