diff --git a/scripts/update_all.sh b/scripts/update_all.sh index fac3d8250..8e1ca9bf8 100644 --- a/scripts/update_all.sh +++ b/scripts/update_all.sh @@ -22,6 +22,7 @@ pnpm update pnpm install pnpm format pnpm build-webUI +pnpm build-recovery pnpm lint cd ../mock-api diff --git a/scripts/upload.py b/scripts/upload.py index 2d228b1fd..b8eb26909 100644 --- a/scripts/upload.py +++ b/scripts/upload.py @@ -111,6 +111,52 @@ def build_headers(host_ip, emsesp_url, content_type='application/json', access_t return headers +def post_file(session, upload_url, host_ip, emsesp_url, access_token, filename, data, progress_desc=None): + """POST one multipart file to /rest/uploadFile (WebUI-compatible).""" + encoder = MultipartEncoder(fields={ + 'file': (filename, data, 'application/octet-stream') + }) + + bar = None + if progress_desc is not None: + bar = tqdm( + desc=progress_desc, + total=encoder.len, + dynamic_ncols=True, + unit='B', + unit_scale=True, + unit_divisor=1024, + mininterval=0.2 + ) + monitor = MultipartEncoderMonitor( + encoder, lambda m: bar.update(m.bytes_read - bar.n)) + body = monitor + content_type = monitor.content_type + content_length = monitor.len + else: + body = encoder + content_type = encoder.content_type + content_length = encoder.len + + headers = build_headers( + host_ip, + emsesp_url, + content_type=content_type, + access_token=access_token, + extra_headers={ + 'Content-Length': str(content_length), + 'Origin': emsesp_url + } + ) + + started = time.perf_counter() + response = session.post(upload_url, data=body, headers=headers, timeout=None) + elapsed = time.perf_counter() - started + if bar is not None: + bar.close() + return response, encoder.len, elapsed + + def on_upload(source, target, env): # make sure we have set the upload_protocol to custom @@ -166,48 +212,30 @@ def on_upload(source, target, env): md5 = hashlib.md5(firmware).hexdigest() filename = os.path.basename(firmware_path) - - encoder = MultipartEncoder(fields={ - 'MD5': md5, - 'file': (filename, firmware, 'application/octet-stream')} - ) - - bar = tqdm(desc='Upload Progress', - total=encoder.len, - dynamic_ncols=True, - unit='B', - unit_scale=True, - unit_divisor=1024, - mininterval=0.2 - ) - - monitor = MultipartEncoderMonitor( - encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n)) - - post_headers = build_headers( - host_ip, - emsesp_url, - content_type=monitor.content_type, - access_token=access_token, - extra_headers={ - 'Content-Length': str(monitor.len), - 'Origin': emsesp_url - } - ) - + # Device enables MD5 check only after a file whose name ends in .md5 + # (same as the WebUI). An ElegantOTA "MD5" form field is ignored. + md5_name = os.path.splitext(filename)[0] + '.md5' upload_url = f"{emsesp_url}/rest/uploadFile" - started = time.perf_counter() - response = session.post( - upload_url, data=monitor, headers=post_headers, timeout=None) - elapsed = time.perf_counter() - started - bar.close() + print(f"Uploading MD5 ({md5})") + response, _, _ = post_file( + session, upload_url, host_ip, emsesp_url, access_token, + md5_name, (md5 + '\n').encode('ascii')) + if response.status_code != 200: + print_fail(f"MD5 upload failed (code {response.status_code}).") + return + print_success("MD5 accepted") + + print(f"Uploading firmware ({filename})") + response, size, elapsed = post_file( + session, upload_url, host_ip, emsesp_url, access_token, + filename, firmware, progress_desc='Upload Progress') print() if response.status_code != 200: print_fail(f"Upload failed (code {response.status_code}).") else: - kibs = (encoder.len / 1024) / elapsed if elapsed > 0 else 0 + kibs = (size / 1024) / elapsed if elapsed > 0 else 0 print_success( f"Upload successful ({kibs:.0f} KiB/s in {elapsed:.1f}s). Rebooting device.") restart_headers = build_headers( diff --git a/scripts/upload_cli.py b/scripts/upload_cli.py index c002009a2..2f92def2a 100644 --- a/scripts/upload_cli.py +++ b/scripts/upload_cli.py @@ -82,6 +82,49 @@ def create_base_headers(host_ip, emsesp_url): } +def post_file(session, upload_url, host_ip, emsesp_url, access_token, filename, data, progress_desc=None): + """POST one multipart file to /rest/uploadFile (WebUI-compatible).""" + encoder = MultipartEncoder(fields={ + 'file': (filename, data, 'application/octet-stream') + }) + + bar = None + if progress_desc is not None: + bar = tqdm( + desc=progress_desc, + total=encoder.len, + dynamic_ncols=True, + unit='B', + unit_scale=True, + unit_divisor=1024, + mininterval=0.2 + ) + monitor = MultipartEncoderMonitor( + encoder, lambda m: bar.update(m.bytes_read - bar.n)) + body = monitor + content_type = monitor.content_type + content_length = monitor.len + else: + body = encoder + content_type = encoder.content_type + content_length = encoder.len + + headers = create_base_headers(host_ip, emsesp_url) + headers.update({ + 'Content-Type': content_type, + 'Content-Length': str(content_length), + 'Origin': emsesp_url, + 'Authorization': f'Bearer {access_token}' + }) + + started = time.perf_counter() + response = session.post(upload_url, data=body, headers=headers, timeout=None) + elapsed = time.perf_counter() - started + if bar is not None: + bar.close() + return response, encoder.len, elapsed + + def upload(file, ip, username, password): """Upload firmware to EMS-ESP device.""" print() @@ -127,45 +170,29 @@ def upload(file, ip, username, password): firmware = file_path.read_bytes() md5 = hashlib.md5(firmware).hexdigest() - - encoder = MultipartEncoder(fields={ - 'MD5': md5, - 'file': (file_path.name, firmware, 'application/octet-stream') - }) - - bar = tqdm( - desc='Upload Progress', - total=encoder.len, - dynamic_ncols=True, - unit='B', - unit_scale=True, - unit_divisor=1024, - mininterval=0.2 - ) - - monitor = MultipartEncoderMonitor( - encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n)) - - post_headers = create_base_headers(host_ip, emsesp_url) - post_headers.update({ - 'Content-Type': monitor.content_type, - 'Content-Length': str(monitor.len), - 'Origin': emsesp_url, - 'Authorization': f'Bearer {access_token}' - }) - + # Device enables MD5 check only after a file whose name ends in .md5 + # (same as the WebUI). An ElegantOTA "MD5" form field is ignored. + md5_name = file_path.with_suffix('.md5').name upload_url = f"{emsesp_url}/rest/uploadFile" - started = time.perf_counter() - response = session.post( - upload_url, data=monitor, headers=post_headers, timeout=None) - elapsed = time.perf_counter() - started - bar.close() + print(f"Uploading MD5 ({md5})") + response, _, _ = post_file( + session, upload_url, host_ip, emsesp_url, access_token, + md5_name, (md5 + '\n').encode('ascii')) + if response.status_code != 200: + print_fail(f"MD5 upload failed (code {response.status_code})") + return + print_success("MD5 accepted") + + print(f"Uploading firmware ({file_path.name})") + response, size, elapsed = post_file( + session, upload_url, host_ip, emsesp_url, access_token, + file_path.name, firmware, progress_desc='Upload Progress') if response.status_code != 200: print_fail(f"Upload failed (code {response.status_code})") else: - kibs = (encoder.len / 1024) / elapsed if elapsed > 0 else 0 + kibs = (size / 1024) / elapsed if elapsed > 0 else 0 print_success( f"Upload successful ({kibs:.0f} KiB/s in {elapsed:.1f}s). Rebooting device.") restart_headers = create_base_headers(host_ip, emsesp_url) diff --git a/scripts/upload_curl.sh b/scripts/upload_curl.sh new file mode 100755 index 000000000..4d9bda834 --- /dev/null +++ b/scripts/upload_curl.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# EMS-ESP firmware upload via curl (same job as upload_cli.py). +# +# Usage: +# ./scripts/upload_curl.sh -i 10.10.10.175 -f build/firmware/EMS-ESP-….bin +# ./scripts/upload_curl.sh -i ems-esp.local -f firmware.bin -u admin -p admin +# +# Prefer a numeric IP over .local — mDNS (especially under WSL2) can stall on IPv6. +# +# Requires: curl, and either md5sum or openssl. python3 is used only to parse the +# sign-in JSON and (when needed) resolve a hostname to IPv4. + +set -euo pipefail + +FILE="" +IP="ems-esp.local" +USERNAME="admin" +PASSWORD="admin" + +USER_AGENT='Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0' + +green() { printf '\033[32m%s\033[0m\n' "$*"; } +red() { printf '\033[31m%s\033[0m\n' "$*" >&2; } + +usage() { + cat </dev/null; then + red "curl is required" + exit 1 +fi + +if ! command -v python3 >/dev/null; then + red "python3 is required (sign-in JSON / IPv4 resolve)" + exit 1 +fi + +# Strip optional http:// and trailing slash / path +HOST="$IP" +HOST="${HOST#http://}" +HOST="${HOST#https://}" +HOST="${HOST%%/*}" +# Drop :port for resolve (device always serves HTTP on 80 for this flow) +HOST_NO_PORT="${HOST%%:*}" +if [[ "$HOST" == \[* ]]; then + # literal IPv6 — leave as-is + HOST_IP="$HOST" +else + HOST_IP="$(python3 - "$HOST_NO_PORT" <<'PY' +import socket, sys +host = sys.argv[1] +try: + socket.inet_pton(socket.AF_INET, host) + print(host) +except OSError: + infos = socket.getaddrinfo(host, 80, socket.AF_INET, socket.SOCK_STREAM) + if not infos: + sys.exit(f"No IPv4 address for {host}") + print(infos[0][4][0]) +PY +)" +fi + +BASE="http://${HOST_IP}" +FILE_PATH="$(cd "$(dirname "$FILE")" && pwd)/$(basename "$FILE")" +FILE_NAME="$(basename "$FILE_PATH")" + +echo +echo "EMS-ESP Firmware Upload (curl)" +if [[ "$HOST_IP" != "$HOST_NO_PORT" ]]; then + green "Resolved ${HOST_NO_PORT} -> ${HOST_IP}" +fi + +# --- sign in --- +SIGNIN_RESP="$(curl -sS -f \ + -H "Host: ${HOST_IP}" \ + -H "User-Agent: ${USER_AGENT}" \ + -H "Accept: */*" \ + -H "Content-Type: application/json" \ + -H "Referer: ${BASE}" \ + -d "{\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\"}" \ + "${BASE}/rest/signIn")" || { + red "Authentication failed" + exit 1 +} + +TOKEN="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])' <<<"$SIGNIN_RESP")" || { + red "Authentication failed (no access_token)" + exit 1 +} +green "Authentication successful" + +# MD5 of the firmware — upload as a .md5 file first (same as the WebUI / upload_cli.py). +if command -v md5sum >/dev/null; then + MD5="$(md5sum "$FILE_PATH" | awk '{print $1}')" +elif command -v openssl >/dev/null; then + MD5="$(openssl md5 -r "$FILE_PATH" | awk '{print $1}')" +else + red "Need md5sum or openssl to compute the digest" + exit 1 +fi + +MD5_TMP="$(mktemp "${TMPDIR:-/tmp}/EMS-ESP.XXXXXX.md5")" +RESP_TMP="$(mktemp "${TMPDIR:-/tmp}/EMS-ESP.XXXXXX.rsp")" +trap 'rm -f "$MD5_TMP" "$RESP_TMP"' EXIT +printf '%s\n' "$MD5" >"$MD5_TMP" + +AUTH_HDR=( -H "Authorization: Bearer ${TOKEN}" ) +COMMON_HDR=( + -H "Host: ${HOST_IP}" + -H "User-Agent: ${USER_AGENT}" + -H "Accept: */*" + -H "Origin: ${BASE}" + -H "Referer: ${BASE}" +) + +echo "Uploading MD5 (${MD5})" +MD5_HTTP="$(curl -sS -o "$RESP_TMP" -w '%{http_code}' \ + "${COMMON_HDR[@]}" "${AUTH_HDR[@]}" \ + -F "file=@${MD5_TMP};filename=${FILE_NAME%.bin}.md5;type=application/octet-stream" \ + "${BASE}/rest/uploadFile")" || true +MD5_BODY="$(cat "$RESP_TMP" 2>/dev/null || true)" + +if [[ "$MD5_HTTP" != "200" ]]; then + red "MD5 upload failed (HTTP ${MD5_HTTP}${MD5_BODY:+: $MD5_BODY})" + exit 1 +fi +green "MD5 accepted" + +echo "Uploading firmware (${FILE_NAME})" +START_NS="$(date +%s%N 2>/dev/null || echo 0)" +BIN_HTTP="$(curl -sS --progress-bar -o "$RESP_TMP" -w '%{http_code}' \ + "${COMMON_HDR[@]}" "${AUTH_HDR[@]}" \ + -F "file=@${FILE_PATH};filename=${FILE_NAME};type=application/octet-stream" \ + "${BASE}/rest/uploadFile")" || true +BIN_BODY="$(cat "$RESP_TMP" 2>/dev/null || true)" + +if [[ "$BIN_HTTP" != "200" ]]; then + red "Upload failed (HTTP ${BIN_HTTP}${BIN_BODY:+: $BIN_BODY})" + exit 1 +fi + +END_NS="$(date +%s%N 2>/dev/null || echo 0)" +SIZE="$(wc -c <"$FILE_PATH" | tr -d ' ')" +if [[ "$START_NS" != "0" && "$END_NS" != "0" ]]; then + ELAPSED_MS=$(( (END_NS - START_NS) / 1000000 )) + if [[ "$ELAPSED_MS" -gt 0 ]]; then + KIBS=$(( SIZE * 1000 / ELAPSED_MS / 1024 )) + ELAPSED_S="$(python3 -c "print(f'{${ELAPSED_MS}/1000:.1f}')")" + green "Upload successful (${KIBS} KiB/s in ${ELAPSED_S}s). Rebooting device." + else + green "Upload successful. Rebooting device." + fi +else + green "Upload successful. Rebooting device." +fi + +# Device may drop the connection as it reboots — ignore curl failures here. +curl -sS -m 10 \ + "${COMMON_HDR[@]}" "${AUTH_HDR[@]}" \ + -H "Content-Type: application/json" \ + "${BASE}/api/system/restart" >/dev/null 2>&1 || true + +echo