From 4ea8e564859b788ab4709f599293a3cec31fc3d7 Mon Sep 17 00:00:00 2001 From: bonamin Date: Fri, 27 Feb 2026 09:14:58 +0200 Subject: [PATCH] fix: configure gitea webhook auto-deploy, fix NVS CRC, and improve flash UI - Add deploy-host.sh for webhook-triggered docker redeploy - Update docker-compose.yml and nginx.conf for auto-pull setup - Fix vite.config.js and admin router for deployment environment - Fix NVS CRC seed to use 0xFFFFFFFF to match esp_rom_crc32_le - Add dual-panel flash UI: esptool log + live 115200 serial monitor - Auto-reset device via RTS after flash (no manual power cycle needed) - Clean up Header.jsx debug title text Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 9 +- backend/admin/router.py | 27 +-- backend/utils/nvs_generator.py | 3 +- deploy-host.sh | 11 + docker-compose.yml | 2 + frontend/src/layout/Header.jsx | 4 +- .../src/manufacturing/ProvisioningWizard.jsx | 197 ++++++++++++++---- frontend/vite.config.js | 1 + nginx/nginx.conf | 19 +- 9 files changed, 198 insertions(+), 75 deletions(-) create mode 100755 deploy-host.sh diff --git a/.gitignore b/.gitignore index 236a97e..b166237 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +# Auto-deploy generated files +deploy.sh +deploy.log +.deploy-trigger + # Secrets .env firebase-service-account.json @@ -25,4 +30,6 @@ dist/ .DS_Store Thumbs.db -.MAIN-APP-REFERENCE/ \ No newline at end of file +.MAIN-APP-REFERENCE/ + +.project-vesper-plan.md \ No newline at end of file diff --git a/backend/admin/router.py b/backend/admin/router.py index 6e0a3d1..b251278 100644 --- a/backend/admin/router.py +++ b/backend/admin/router.py @@ -42,23 +42,12 @@ async def deploy(request: Request): logger.info("Auto-deploy triggered via Gitea webhook") - project_path = settings.deploy_project_path - cmd = f"cd {project_path} && git pull origin main && docker compose up -d --build" - try: - proc = await asyncio.create_subprocess_shell( - cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=300) - output = stdout.decode(errors="replace") if stdout else "" + # Write a trigger file to the host-mounted project path. + # A host-side watcher service (bellsystems-deploy-watcher) polls for this + # file and runs deploy-host.sh as the bellsystems user when it appears. + trigger_path = f"{settings.deploy_project_path}/.deploy-trigger" + with open(trigger_path, "w") as f: + f.write("deploy\n") - if proc.returncode != 0: - logger.error(f"Deploy failed (exit {proc.returncode}):\n{output}") - raise HTTPException(status_code=500, detail=f"Deploy script failed:\n{output[-500:]}") - - logger.info(f"Deploy succeeded:\n{output[-300:]}") - return {"ok": True, "output": output[-1000:]} - - except asyncio.TimeoutError: - raise HTTPException(status_code=504, detail="Deploy timed out after 300 seconds") + logger.info("Auto-deploy trigger file written") + return {"ok": True, "message": "Deploy started"} diff --git a/backend/utils/nvs_generator.py b/backend/utils/nvs_generator.py index e363e1d..80bf95e 100644 --- a/backend/utils/nvs_generator.py +++ b/backend/utils/nvs_generator.py @@ -60,7 +60,8 @@ ENTRY_TYPE_STRING = 0x21 def _crc32(data: bytes) -> int: - return binascii.crc32(data) & 0xFFFFFFFF + # ESP-IDF uses 0xFFFFFFFF as the initial CRC seed (matches esp_rom_crc32_le) + return binascii.crc32(data, 0xFFFFFFFF) & 0xFFFFFFFF def _page_header_crc(seq: int, version: int) -> int: diff --git a/deploy-host.sh b/deploy-host.sh new file mode 100755 index 0000000..dbeab51 --- /dev/null +++ b/deploy-host.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +PROJECT=/home/bellsystems/bellsystems-cp + +echo "Deploy started at $(date)" +cd "$PROJECT" +git fetch origin main +git reset --hard origin/main +docker compose up -d --build 2>&1 +echo "Deploy finished at $(date)" diff --git a/docker-compose.yml b/docker-compose.yml index ab2ea22..ed00efc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - ./data/built_melodies:/app/storage/built_melodies - ./data/firmware:/app/storage/firmware - ./data/firebase-service-account.json:/app/firebase-service-account.json:ro + # Auto-deploy: project root so container can write the trigger file + - /home/bellsystems/bellsystems-cp:/home/bellsystems/bellsystems-cp ports: - "8000:8000" depends_on: [] diff --git a/frontend/src/layout/Header.jsx b/frontend/src/layout/Header.jsx index 40ec38e..7c1169d 100644 --- a/frontend/src/layout/Header.jsx +++ b/frontend/src/layout/Header.jsx @@ -12,7 +12,7 @@ export default function Header() { }} >

- BellSystems - Control Panel + BellCloud™ - Console

@@ -41,3 +41,5 @@ export default function Header() { ); } + +/* my test string */ \ No newline at end of file diff --git a/frontend/src/manufacturing/ProvisioningWizard.jsx b/frontend/src/manufacturing/ProvisioningWizard.jsx index 3ae1e12..53b5f32 100644 --- a/frontend/src/manufacturing/ProvisioningWizard.jsx +++ b/frontend/src/manufacturing/ProvisioningWizard.jsx @@ -360,13 +360,25 @@ function StepSelectDevice({ onSelected }) { function StepFlash({ device, onFlashed }) { const [connecting, setConnecting] = useState(false); const [flashing, setFlashing] = useState(false); + const [done, setDone] = useState(false); const [nvsProgress, setNvsProgress] = useState(0); const [fwProgress, setFwProgress] = useState(0); const [log, setLog] = useState([]); + const [serial, setSerial] = useState([]); const [error, setError] = useState(""); const loaderRef = useRef(null); + const portRef = useRef(null); + const serialReaderRef = useRef(null); + const serialActiveRef = useRef(false); + const logEndRef = useRef(null); + const serialEndRef = useRef(null); - const appendLog = (msg) => setLog((prev) => [...prev, msg]); + const appendLog = (msg) => setLog((prev) => [...prev, String(msg)]); + const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)]); + + // Auto-scroll both panels to bottom + const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: "smooth" }); const fetchBinary = async (url) => { const token = localStorage.getItem("access_token"); @@ -382,17 +394,56 @@ function StepFlash({ device, onFlashed }) { const arrayBufferToString = (buf) => { const bytes = new Uint8Array(buf); let str = ""; - for (let i = 0; i < bytes.length; i++) { - str += String.fromCharCode(bytes[i]); - } + for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]); return str; }; + // Start reading raw UART output from the device after flash+reset + const startSerialMonitor = async (port) => { + serialActiveRef.current = true; + try { + await port.open({ baudRate: 115200 }); + } catch (_) { + // Port may already be open if esptool left it that way — ignore + } + const decoder = new TextDecoderStream(); + const readableStreamClosed = port.readable.pipeTo(decoder.writable); + const reader = decoder.readable.getReader(); + serialReaderRef.current = reader; + + let lineBuffer = ""; + try { + while (serialActiveRef.current) { + const { value, done: streamDone } = await reader.read(); + if (streamDone) break; + lineBuffer += value; + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop(); // keep incomplete last fragment + for (const line of lines) { + if (line.trim()) { + appendSerial(line); + scrollSerial(); + } + } + } + } catch (_) { + // Reader cancelled on cleanup — expected + } + }; + + const stopSerialMonitor = async () => { + serialActiveRef.current = false; + try { await serialReaderRef.current?.cancel(); } catch (_) {} + try { portRef.current?.close(); } catch (_) {} + }; + const handleFlash = async () => { setError(""); setLog([]); + setSerial([]); setNvsProgress(0); setFwProgress(0); + setDone(false); // 1. Open Web Serial port let port; @@ -400,6 +451,7 @@ function StepFlash({ device, onFlashed }) { setConnecting(true); appendLog("Opening port picker…"); port = await navigator.serial.requestPort(); + portRef.current = port; } catch (err) { setError(err.message || "Port selection cancelled."); setConnecting(false); @@ -427,23 +479,18 @@ function StepFlash({ device, onFlashed }) { baudrate: FLASH_BAUD, terminal: { clean() {}, - writeLine: (line) => appendLog(line), - write: (msg) => appendLog(msg), + writeLine: (line) => { appendLog(line); scrollLog(); }, + write: (msg) => { appendLog(msg); scrollLog(); }, }, }); await loaderRef.current.main(); appendLog("ESP32 connected."); - // 4. Flash NVS + firmware with progress callbacks + // 4. Flash NVS + firmware const nvsData = arrayBufferToString(nvsBuffer); const fwData = arrayBufferToString(fwBuffer); - // Track progress by watching the two images in sequence. - // esptool-js reports progress as { index, fileIndex, written, total } - const totalBytes = nvsBuffer.byteLength + fwBuffer.byteLength; - let writtenSoFar = 0; - await loaderRef.current.writeFlash({ fileArray: [ { data: nvsData, address: NVS_ADDRESS }, @@ -461,41 +508,46 @@ function StepFlash({ device, onFlashed }) { setNvsProgress(100); setFwProgress((written / total) * 100); } - writtenSoFar = written; - }, - calculateMD5Hash: (image) => { - // MD5 is optional for progress verification; returning empty disables it - return ""; }, + calculateMD5Hash: () => "", }); setNvsProgress(100); setFwProgress(100); - appendLog("Flash complete. Disconnecting…"); - await transport.disconnect(); - appendLog("Done."); + appendLog("Flash complete. Resetting device…"); - // 5. Update device status → flashed + // 5. Hard-reset via RTS — device boots into user code automatically + await loaderRef.current.after("hard_reset"); + appendLog("Hard reset sent. Device is booting…"); + + // 6. Update device status → flashed await api.request(`/manufacturing/devices/${device.serial_number}/status`, { method: "PATCH", body: JSON.stringify({ status: "flashed", note: "Flashed via browser provisioning wizard" }), }); + setFlashing(false); + setDone(true); + + // 7. Open serial monitor at 115200 to show live device output + appendSerial("── Serial monitor started (115200 baud) ──"); + startSerialMonitor(port); + onFlashed(); } catch (err) { setError(err.message || String(err)); setFlashing(false); setConnecting(false); - try { - if (loaderRef.current) await loaderRef.current.transport?.disconnect(); - } catch (_) {} + try { await loaderRef.current?.transport?.disconnect(); } catch (_) {} } }; const webSerialAvailable = "serial" in navigator; + const busy = connecting || flashing; return (
+ {/* Device info header */}
)} - {log.length > 0 && ( -
- {log.map((line, i) => ( -
{line}
- ))} -
- )} - - {!flashing && ( + {!busy && ( )} - {flashing && nvsProgress < 100 && fwProgress < 100 && ( + {flashing && (

Flashing in progress — do not disconnect…

)}
+ {/* Two-column output: Flash log + Serial output */} + {(log.length > 0 || serial.length > 0) && ( +
+ {/* Left: flash / esptool output */} +
+
+ Flash Output +
+
+ {log.map((line, i) => ( +
{line}
+ ))} +
+
+
+ + {/* Right: live device serial output */} +
+
+ Serial Output + {serialActiveRef.current && ( + + + Live + + )} +
+
+ {serial.length === 0 ? ( + + {done ? "Waiting for device boot…" : "Available after flash completes."} + + ) : ( + serial.map((line, i) => ( +
{line}
+ )) + )} +
+
+
+
+ )} +

Flash addresses: NVS at 0x9000 · Firmware at 0x10000 · Baud: {FLASH_BAUD}

diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 066f5e8..4a43b08 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -7,6 +7,7 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, + allowedHosts: ['console.bellsystems.net'], hmr: { clientPort: 80, }, diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 2ffdddd..70a0359 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -3,18 +3,17 @@ events { } http { - upstream backend { - server backend:8000; - } - - upstream frontend { - server frontend:5173; - } + client_max_body_size 10m; server { listen 80; server_name localhost; + # Use Docker's internal DNS so nginx re-resolves after container restarts + resolver 127.0.0.11 valid=5s; + set $backend_upstream http://backend:8000; + set $frontend_upstream http://frontend:5173; + # OTA firmware files — allow browser (esptool-js) to fetch .bin files directly location /ota/ { root /srv; @@ -29,7 +28,7 @@ http { # API requests → FastAPI backend location /api/ { - proxy_pass http://backend; + proxy_pass $backend_upstream$request_uri; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -38,7 +37,7 @@ http { # WebSocket support for MQTT live data location /api/mqtt/ws { - proxy_pass http://backend; + proxy_pass $backend_upstream$request_uri; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; @@ -47,7 +46,7 @@ http { # Everything else → React frontend (Vite dev server) location / { - proxy_pass http://frontend; + proxy_pass $frontend_upstream$request_uri; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;