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() {
}}
>
@@ -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;