Skip to content

Commit 33c6204

Browse files
committed
feat(https_delta_ota): Added example pytest for https_delta_ota example
Tested both firmware as well as tool in same pytest.
1 parent 24b2828 commit 33c6204

File tree

7 files changed

+292
-3
lines changed

7 files changed

+292
-3
lines changed
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# The following lines of boilerplate have to be in your project's CMakeLists
22
# in this exact order for cmake to work correctly
3-
cmake_minimum_required(VERSION 3.5)
3+
cmake_minimum_required(VERSION 3.16)
44

55
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
6-
set(COMPONENTS main)
6+
set(COMPONENTS main esp_eth)
77
project(https_delta_ota)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
idf_component_register(SRCS "main.c"
22
INCLUDE_DIRS "."
33
EMBED_TXTFILES ca_cert.pem
4-
PRIV_REQUIRES esp_http_client esp_partition nvs_flash app_update esp_timer esp_wifi console)
4+
PRIV_REQUIRES esp_http_client esp_partition nvs_flash app_update esp_timer esp_wifi console esp_eth)

esp_delta_ota/examples/https_delta_ota/main/Kconfig.projbuild

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@ menu "Example Configuration"
2424
help
2525
Maximum time for reception
2626

27+
config EXAMPLE_FIRMWARE_UPG_URL_FROM_STDIN
28+
bool
29+
default y if EXAMPLE_FIRMWARE_UPG_URL = "FROM_STDIN"
30+
2731
endmenu

esp_delta_ota/examples/https_delta_ota/main/main.c

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
#define BUFFSIZE 1024
3737
#define PATCH_HEADER_SIZE 64
3838
#define DIGEST_SIZE 32
39+
#define OTA_URL_SIZE 256
3940
static uint32_t esp_delta_ota_magic = 0xfccdde10;
4041

4142
static const char *TAG = "https_delta_ota_example";
@@ -156,6 +157,20 @@ static void ota_example_task(void *pvParameter)
156157
config.skip_cert_common_name_check = true;
157158
#endif
158159

160+
#ifdef CONFIG_EXAMPLE_FIRMWARE_UPG_URL_FROM_STDIN
161+
char url_buf[OTA_URL_SIZE];
162+
if (strcmp(config.url, "FROM_STDIN") == 0) {
163+
example_configure_stdin_stdout();
164+
fgets(url_buf, OTA_URL_SIZE, stdin);
165+
int len = strlen(url_buf);
166+
url_buf[len - 1] = '\0';
167+
config.url = url_buf;
168+
} else {
169+
ESP_LOGE(TAG, "Configuration mismatch: wrong firmware upgrade image url");
170+
abort();
171+
}
172+
#endif
173+
159174
esp_http_client_handle_t client = esp_http_client_init(&config);
160175
if (client == NULL) {
161176
ESP_LOGE(TAG, "Failed to initialise HTTP connection");
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD
2+
# SPDX-License-Identifier: Unlicense OR CC0-1.0
3+
4+
import http.server
5+
import multiprocessing
6+
import os
7+
import socket
8+
import ssl
9+
import subprocess
10+
import sys
11+
import time
12+
import pexpect
13+
from typing import Any
14+
import pytest
15+
from pytest_embedded import Dut
16+
17+
server_port = 443
18+
19+
server_cert = (
20+
'-----BEGIN CERTIFICATE-----\n'
21+
'MIIDWDCCAkACCQCbF4+gVh/MLjANBgkqhkiG9w0BAQsFADBuMQswCQYDVQQGEwJJ\n'
22+
'TjELMAkGA1UECAwCTUgxDDAKBgNVBAcMA1BVTjEMMAoGA1UECgwDRVNQMQwwCgYD\n'
23+
'VQQLDANFU1AxDDAKBgNVBAMMA0VTUDEaMBgGCSqGSIb3DQEJARYLZXNwQGVzcC5j\n'
24+
'b20wHhcNMjEwNzEyMTIzNjI3WhcNNDEwNzA3MTIzNjI3WjBuMQswCQYDVQQGEwJJ\n'
25+
'TjELMAkGA1UECAwCTUgxDDAKBgNVBAcMA1BVTjEMMAoGA1UECgwDRVNQMQwwCgYD\n'
26+
'VQQLDANFU1AxDDAKBgNVBAMMA0VTUDEaMBgGCSqGSIb3DQEJARYLZXNwQGVzcC5j\n'
27+
'b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDhxF/y7bygndxPwiWL\n'
28+
'SwS9LY3uBMaJgup0ufNKVhx+FhGQOu44SghuJAaH3KkPUnt6SOM8jC97/yQuc32W\n'
29+
'ukI7eBZoA12kargSnzdv5m5rZZpd+NznSSpoDArOAONKVlzr25A1+aZbix2mKRbQ\n'
30+
'S5w9o1N2BriQuSzd8gL0Y0zEk3VkOWXEL+0yFUT144HnErnD+xnJtHe11yPO2fEz\n'
31+
'YaGiilh0ddL26PXTugXMZN/8fRVHP50P2OG0SvFpC7vghlLp4VFM1/r3UJnvL6Oz\n'
32+
'3ALc6dhxZEKQucqlpj8l1UegszQToopemtIj0qXTHw2+uUnkUyWIPjPC+wdOAoap\n'
33+
'rFTRAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAItw24y565k3C/zENZlxyzto44ud\n'
34+
'IYPQXN8Fa2pBlLe1zlSIyuaA/rWQ+i1daS8nPotkCbWZyf5N8DYaTE4B0OfvoUPk\n'
35+
'B5uGDmbuk6akvlB5BGiYLfQjWHRsK9/4xjtIqN1H58yf3QNROuKsPAeywWS3Fn32\n'
36+
'3//OpbWaClQePx6udRYMqAitKR+QxL7/BKZQsX+UyShuq8hjphvXvk0BW8ONzuw9\n'
37+
'RcoORxM0FzySYjeQvm4LhzC/P3ZBhEq0xs55aL2a76SJhq5hJy7T/Xz6NFByvlrN\n'
38+
'lFJJey33KFrAf5vnV9qcyWFIo7PYy2VsaaEjFeefr7q3sTFSMlJeadexW2Y=\n'
39+
'-----END CERTIFICATE-----\n'
40+
)
41+
42+
server_key = (
43+
'-----BEGIN PRIVATE KEY-----\n'
44+
'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDhxF/y7bygndxP\n'
45+
'wiWLSwS9LY3uBMaJgup0ufNKVhx+FhGQOu44SghuJAaH3KkPUnt6SOM8jC97/yQu\n'
46+
'c32WukI7eBZoA12kargSnzdv5m5rZZpd+NznSSpoDArOAONKVlzr25A1+aZbix2m\n'
47+
'KRbQS5w9o1N2BriQuSzd8gL0Y0zEk3VkOWXEL+0yFUT144HnErnD+xnJtHe11yPO\n'
48+
'2fEzYaGiilh0ddL26PXTugXMZN/8fRVHP50P2OG0SvFpC7vghlLp4VFM1/r3UJnv\n'
49+
'L6Oz3ALc6dhxZEKQucqlpj8l1UegszQToopemtIj0qXTHw2+uUnkUyWIPjPC+wdO\n'
50+
'AoaprFTRAgMBAAECggEAE0HCxV/N1Q1h+1OeDDGL5+74yjKSFKyb/vTVcaPCrmaH\n'
51+
'fPvp0ddOvMZJ4FDMAsiQS6/n4gQ7EKKEnYmwTqj4eUYW8yxGUn3f0YbPHbZT+Mkj\n'
52+
'z5woi3nMKi/MxCGDQZX4Ow3xUQlITUqibsfWcFHis8c4mTqdh4qj7xJzehD2PVYF\n'
53+
'gNHZsvVj6MltjBDAVwV1IlGoHjuElm6vuzkfX7phxcA1B4ZqdYY17yCXUnvui46z\n'
54+
'Xn2kUTOOUCEgfgvGa9E+l4OtdXi5IxjaSraU+dlg2KsE4TpCuN2MEVkeR5Ms3Y7Q\n'
55+
'jgJl8vlNFJDQpbFukLcYwG7rO5N5dQ6WWfVia/5XgQKBgQD74at/bXAPrh9NxPmz\n'
56+
'i1oqCHMDoM9sz8xIMZLF9YVu3Jf8ux4xVpRSnNy5RU1gl7ZXbpdgeIQ4v04zy5aw\n'
57+
'8T4tu9K3XnR3UXOy25AK0q+cnnxZg3kFQm+PhtOCKEFjPHrgo2MUfnj+EDddod7N\n'
58+
'JQr9q5rEFbqHupFPpWlqCa3QmQKBgQDldWUGokNaEpmgHDMnHxiibXV5LQhzf8Rq\n'
59+
'gJIQXb7R9EsTSXEvsDyqTBb7PHp2Ko7rZ5YQfyf8OogGGjGElnPoU/a+Jij1gVFv\n'
60+
'kZ064uXAAISBkwHdcuobqc5EbG3ceyH46F+FBFhqM8KcbxJxx08objmh58+83InN\n'
61+
'P9Qr25Xw+QKBgEGXMHuMWgQbSZeM1aFFhoMvlBO7yogBTKb4Ecpu9wI5e3Kan3Al\n'
62+
'pZYltuyf+VhP6XG3IMBEYdoNJyYhu+nzyEdMg8CwXg+8LC7FMis/Ve+o7aS5scgG\n'
63+
'1to/N9DK/swCsdTRdzmc/ZDbVC+TuVsebFBGYZTyO5KgqLpezqaIQrTxAoGALFCU\n'
64+
'10glO9MVyl9H3clap5v+MQ3qcOv/EhaMnw6L2N6WVT481tnxjW4ujgzrFcE4YuxZ\n'
65+
'hgwYu9TOCmeqopGwBvGYWLbj+C4mfSahOAs0FfXDoYazuIIGBpuv03UhbpB1Si4O\n'
66+
'rJDfRnuCnVWyOTkl54gKJ2OusinhjztBjcrV1XkCgYEA3qNi4uBsPdyz9BZGb/3G\n'
67+
'rOMSw0CaT4pEMTLZqURmDP/0hxvTk1polP7O/FYwxVuJnBb6mzDa0xpLFPTpIAnJ\n'
68+
'YXB8xpXU69QVh+EBbemdJWOd+zp5UCfXvb2shAeG3Tn/Dz4cBBMEUutbzP+or0nG\n'
69+
'vSXnRLaxQhooWm+IuX9SuBQ=\n'
70+
'-----END PRIVATE KEY-----\n'
71+
)
72+
73+
def get_env_config_variable(env_name, var_name):
74+
return os.environ.get(f'{env_name}_{var_name}'.upper())
75+
76+
def get_host_ip4_by_dest_ip(dest_ip):
77+
try:
78+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
79+
s.connect((dest_ip, 80))
80+
host_ip = s.getsockname()[0]
81+
s.close()
82+
return host_ip
83+
except Exception:
84+
return '127.0.0.1'
85+
86+
def _ensure_requirements_installed():
87+
example_dir = os.path.dirname(os.path.abspath(__file__))
88+
requirements_path = os.path.join(example_dir, 'tools', 'requirements.txt')
89+
90+
if not os.path.exists(requirements_path):
91+
raise Exception(f'Requirements file not found at {requirements_path}')
92+
93+
subprocess.run(
94+
['pip', 'install', '-r', 'requirements.txt'],
95+
cwd=os.path.dirname(requirements_path),
96+
check=False
97+
)
98+
99+
def setting_connection(dut: Dut, env_name: str | None = None) -> Any:
100+
if env_name is not None and dut.app.sdkconfig.get('EXAMPLE_WIFI_SSID_PWD_FROM_STDIN') is True:
101+
dut.expect('Please input ssid password:')
102+
ap_ssid = get_env_config_variable(env_name, 'ap_ssid')
103+
ap_password = get_env_config_variable(env_name, 'ap_password')
104+
dut.write(f'{ap_ssid} {ap_password}')
105+
try:
106+
ip_address = dut.expect(r'IPv4 address: (\d+\.\d+\.\d+\.\d+)[^\d]', timeout=60)[1].decode()
107+
print(f'Connected to AP/Ethernet with IP: {ip_address}')
108+
except pexpect.exceptions.TIMEOUT:
109+
raise ValueError('ENV_TEST_FAILURE: Cannot connect to AP/Ethernet')
110+
return get_host_ip4_by_dest_ip(ip_address)
111+
112+
def start_https_server(ota_image_dir, server_ip, port, server_file=None, key_file=None):
113+
"""Start an HTTPS server to serve OTA patch files."""
114+
os.chdir(ota_image_dir)
115+
116+
if server_file is None:
117+
server_file = os.path.join(ota_image_dir, 'server_cert.pem')
118+
with open(server_file, 'w', encoding='utf-8') as f:
119+
f.write(server_cert)
120+
121+
if key_file is None:
122+
key_file = os.path.join(ota_image_dir, 'server_key.pem')
123+
with open(key_file, 'w', encoding='utf-8') as f: # Fixed: was 'server_key.pem' literal
124+
f.write(server_key)
125+
126+
# Bind to all interfaces so ESP32 can reach it
127+
httpd = http.server.HTTPServer(('0.0.0.0', port), http.server.SimpleHTTPRequestHandler)
128+
129+
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
130+
ssl_context.check_hostname = False
131+
ssl_context.load_cert_chain(certfile=server_file, keyfile=key_file)
132+
133+
httpd.socket = ssl_context.wrap_socket(httpd.socket, server_side=True)
134+
httpd.serve_forever()
135+
136+
137+
def find_hello_world_binary(example_dir, chip_target='esp32'):
138+
"""
139+
Find the pre-built hello_world binary for the target chip.
140+
141+
This function looks for hello_world_<target>.bin in the tests directory.
142+
These binaries are pre-built and checked into the repository for testing.
143+
144+
Args:
145+
example_dir: Path to the example directory
146+
chip_target: Target chip (default: 'esp32')
147+
148+
Returns:
149+
Path to the hello_world binary file
150+
"""
151+
# Look for hello_world binary in tests directory
152+
binary_name = f'hello_world_{chip_target}.bin'
153+
binary_path = os.path.join(example_dir, 'tests', binary_name)
154+
155+
if os.path.exists(binary_path):
156+
return binary_path
157+
158+
# Fallback: try generic hello_world.bin
159+
fallback_path = os.path.join(example_dir, 'tests', 'hello_world.bin')
160+
if os.path.exists(fallback_path):
161+
print(f'Warning: Using generic hello_world.bin instead of {binary_name}')
162+
return fallback_path
163+
164+
raise Exception(f'Hello world binary not found at {binary_path}. '
165+
f'Expected pre-built binary: {binary_name} in tests/ directory. '
166+
f'Example dir: {example_dir}')
167+
168+
169+
def generate_patch(base_binary, new_binary, patch_output, chip='esp32'):
170+
"""Generate delta OTA patch using the esp_delta_ota_patch_gen.py tool."""
171+
_ensure_requirements_installed()
172+
173+
# Find the tool in the tools directory
174+
example_dir = os.path.dirname(os.path.abspath(__file__))
175+
tool_path = os.path.join(example_dir, 'tools', 'esp_delta_ota_patch_gen.py')
176+
177+
if not os.path.exists(tool_path):
178+
raise Exception(f'Patch generation tool not found at {tool_path}')
179+
180+
# Verify input files exist
181+
if not os.path.exists(base_binary):
182+
raise Exception(f'Base binary not found at {base_binary}')
183+
if not os.path.exists(new_binary):
184+
raise Exception(f'New binary not found at {new_binary}')
185+
186+
# Use the tool to generate patch
187+
cmd = [
188+
sys.executable,
189+
tool_path,
190+
'create_patch',
191+
'--chip', chip,
192+
'--base_binary', base_binary,
193+
'--new_binary', new_binary,
194+
'--patch_file_name', patch_output
195+
]
196+
197+
result = subprocess.run(cmd, capture_output=True, text=True)
198+
199+
# Print output
200+
if result.stdout:
201+
print(result.stdout)
202+
if result.stderr:
203+
print('STDERR:', result.stderr)
204+
205+
if result.returncode != 0:
206+
raise Exception(f'Patch generation failed with return code {result.returncode}')
207+
208+
if not os.path.exists(patch_output):
209+
raise Exception(f'Patch file not created at {patch_output}')
210+
211+
print(f'Patch created successfully: {patch_output} ({os.path.getsize(patch_output)} bytes)')
212+
213+
214+
@pytest.mark.parametrize('target', ['esp32'])
215+
@pytest.mark.ethernet
216+
def test_esp_delta_ota(dut: Dut):
217+
example_dir = os.path.dirname(os.path.abspath(__file__))
218+
build_dir = dut.app.binary_path
219+
chip_target = getattr(dut, 'target', None) or os.environ.get('IDF_TARGET', 'esp32')
220+
221+
try:
222+
base_binary = os.path.join(build_dir, 'https_delta_ota.bin')
223+
if not os.path.exists(base_binary):
224+
raise Exception(f'Base binary not found at {base_binary}. Device was flashed from build directory: {build_dir}')
225+
226+
binary_name = f'hello_world_{chip_target}.bin'
227+
new_binary = os.path.join(example_dir, 'tests', binary_name)
228+
229+
if not os.path.exists(new_binary):
230+
raise Exception(f'New binary not found at {new_binary}. Expected pre-built binary: {binary_name} in tests/ directory. Example dir: {example_dir}')
231+
232+
patch_file = os.path.join(build_dir, 'patch.bin')
233+
generate_patch(base_binary, new_binary, patch_file, chip_target)
234+
235+
server_process = multiprocessing.Process(
236+
target=start_https_server,
237+
args=(build_dir, '0.0.0.0', server_port)
238+
)
239+
240+
server_process.daemon = True
241+
server_process.start()
242+
time.sleep(3) # Let server start
243+
244+
env_name = 'wifi_high_traffic' if dut.app.sdkconfig.get('EXAMPLE_WIFI_SSID_PWD_FROM_STDIN') is True else None
245+
host_ip = setting_connection(dut, env_name)
246+
ota_url = f'https://{host_ip}:{server_port}/patch.bin'
247+
248+
print(f'Providing OTA URL to device: {ota_url}')
249+
dut.write(f'{ota_url}\n')
250+
251+
dut.expect('Rebooting in 5 seconds...', timeout=60)
252+
dut.expect('Hello world!', timeout=60)
253+
254+
# Cleanup
255+
server_process.terminate()
256+
server_process.join(timeout=5)
257+
if server_process.is_alive():
258+
server_process.kill()
259+
260+
print('Delta OTA test PASSED: Successfully updated from https_delta_ota to hello_world')
261+
262+
except Exception as e:
263+
print(f'HTTPS Delta OTA test FAILED: {str(e)}')
264+
raise
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CONFIG_EXAMPLE_FIRMWARE_UPG_URL="FROM_STDIN"
2+
CONFIG_EXAMPLE_SKIP_COMMON_NAME_CHECK=y
3+
CONFIG_EXAMPLE_CONNECT_IPV6=n
4+
5+
CONFIG_EXAMPLE_CONNECT_ETHERNET=y
6+
CONFIG_EXAMPLE_CONNECT_WIFI=n
Binary file not shown.

0 commit comments

Comments
 (0)