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 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 09:14:58 +02:00
parent 57259c2c2f
commit 4ea8e56485
9 changed files with 198 additions and 75 deletions

9
.gitignore vendored
View File

@@ -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/
.MAIN-APP-REFERENCE/
.project-vesper-plan.md

View File

@@ -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"}

View File

@@ -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:

11
deploy-host.sh Executable file
View File

@@ -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)"

View File

@@ -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: []

View File

@@ -12,7 +12,7 @@ export default function Header() {
}}
>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
BellSystems - Control Panel
BellCloud - Console
</h2>
<div className="flex items-center gap-4">
@@ -41,3 +41,5 @@ export default function Header() {
</header>
);
}
/* my test string */

View File

@@ -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 (
<div className="space-y-4">
{/* Device info header */}
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
@@ -535,42 +587,101 @@ function StepFlash({ device, onFlashed }) {
</div>
)}
{log.length > 0 && (
<div
className="rounded-md border p-3 mb-4 font-mono text-xs overflow-y-auto max-h-36 space-y-0.5"
style={{
backgroundColor: "var(--bg-primary)",
borderColor: "var(--border-secondary)",
color: "var(--text-muted)",
}}
>
{log.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
)}
{!flashing && (
{!busy && (
<button
onClick={handleFlash}
disabled={!webSerialAvailable || connecting}
disabled={!webSerialAvailable}
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{connecting ? "Connecting…" : "Connect & Flash Device"}
{done ? "Flash Again" : "Connect & Flash Device"}
</button>
)}
{flashing && nvsProgress < 100 && fwProgress < 100 && (
{flashing && (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
Flashing in progress do not disconnect
</p>
)}
</div>
{/* Two-column output: Flash log + Serial output */}
{(log.length > 0 || serial.length > 0) && (
<div className="grid grid-cols-2 gap-3">
{/* Left: flash / esptool output */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<div
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
color: "var(--text-muted)",
}}
>
Flash Output
</div>
<div
className="p-3 font-mono text-xs overflow-y-auto space-y-0.5"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-secondary)",
height: "240px",
}}
>
{log.map((line, i) => (
<div key={i}>{line}</div>
))}
<div ref={logEndRef} />
</div>
</div>
{/* Right: live device serial output */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<div
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b flex items-center justify-between"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
color: "var(--text-muted)",
}}
>
<span>Serial Output</span>
{serialActiveRef.current && (
<span className="flex items-center gap-1.5" style={{ color: "#4dd6c8" }}>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: "#4dd6c8", animation: "pulse 1.5s infinite" }}
/>
Live
</span>
)}
</div>
<div
className="p-3 font-mono text-xs overflow-y-auto space-y-0.5"
style={{
backgroundColor: "var(--bg-primary)",
color: "#a3e635",
height: "240px",
}}
>
{serial.length === 0 ? (
<span style={{ color: "var(--text-muted)" }}>
{done ? "Waiting for device boot…" : "Available after flash completes."}
</span>
) : (
serial.map((line, i) => (
<div key={i}>{line}</div>
))
)}
<div ref={serialEndRef} />
</div>
</div>
</div>
)}
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Flash addresses: NVS at 0x9000 · Firmware at 0x10000 · Baud: {FLASH_BAUD}
</p>

View File

@@ -7,6 +7,7 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['console.bellsystems.net'],
hmr: {
clientPort: 80,
},

View File

@@ -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;