|
| 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 |
0 commit comments