Added a workflow to let the agent automatically deploy frontend onto the esp32.
This commit is contained in:
49
Provider/.agents/workflows/frontend-ota.md
Normal file
49
Provider/.agents/workflows/frontend-ota.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
description: Build, package, and deploy frontend updates via OTA
|
||||||
|
---
|
||||||
|
|
||||||
|
This workflow automates the process of building the frontend, packaging it into a LittleFS binary, and uploading it to the ESP32 device.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Ensure `VITE_API_BASE` in `frontend/.env` is set to the target device's IP address.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. **Build and Package Frontend**
|
||||||
|
Run the following command in the `frontend/` directory to build the production assets and create the versioned `.bin` file:
|
||||||
|
```bash
|
||||||
|
npm run build:esp32 && npm run ota:package
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Deploy Update**
|
||||||
|
Run the deployment script to upload the latest version to the device:
|
||||||
|
```bash
|
||||||
|
npm run ota:deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify Update**
|
||||||
|
Wait for the device to reboot and verify the changes on the dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Universal Bundle Option
|
||||||
|
|
||||||
|
If you also need to update the firmware, use the universal bundle workflow:
|
||||||
|
|
||||||
|
1. **Build Firmware**
|
||||||
|
From the project root:
|
||||||
|
```bash
|
||||||
|
idf.py build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create Bundle**
|
||||||
|
In the `frontend/` directory:
|
||||||
|
```bash
|
||||||
|
npm run ota:bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Deploy Bundle**
|
||||||
|
In the `frontend/` directory:
|
||||||
|
```bash
|
||||||
|
npm run ota:deploy-bundle
|
||||||
|
```
|
||||||
@@ -86,3 +86,19 @@ Once the backend supports it (Phase 2+), you can update the frontend without usi
|
|||||||
For a safer and more convenient update experience, you can bundle both the Firmware and Frontend into a single file.
|
For a safer and more convenient update experience, you can bundle both the Firmware and Frontend into a single file.
|
||||||
|
|
||||||
See the [Universal OTA Bundle Guide](build_bundle.md) for details.
|
See the [Universal OTA Bundle Guide](build_bundle.md) for details.
|
||||||
|
|
||||||
|
## 7. AI Agent Workflow
|
||||||
|
|
||||||
|
If you are an AI agent, you should use the automated deployment scripts to speed up your work.
|
||||||
|
|
||||||
|
### Deployment Prerequisites
|
||||||
|
- Ensure `VITE_API_BASE` in `frontend/.env` is set to the target device IP.
|
||||||
|
- Ensure `MKLITTLEFS_PATH` is correctly set if you are on Windows.
|
||||||
|
|
||||||
|
### Standard OTA Workflow
|
||||||
|
Follow the [Frontend OTA Workflow](file:///w:/Classified/Calendink/Provider/.agents/workflows/frontend-ota.md) for automated building and deployment.
|
||||||
|
|
||||||
|
**Summary of commands (run in `frontend/`):**
|
||||||
|
1. `npm run build:esp32` - Build production assets.
|
||||||
|
2. `npm run ota:package` - Create LittleFS image.
|
||||||
|
3. `npm run ota:deploy` - Upload to device via HTTP.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"build:esp32": "vite build && node scripts/gzip.js",
|
"build:esp32": "vite build && node scripts/gzip.js",
|
||||||
"ota:package": "node scripts/package.js",
|
"ota:package": "node scripts/package.js",
|
||||||
"ota:bundle": "npm run ota:package && node scripts/bundle.js",
|
"ota:bundle": "npm run ota:package && node scripts/bundle.js",
|
||||||
|
"ota:deploy": "node scripts/deploy.js --frontend",
|
||||||
|
"ota:deploy-bundle": "node scripts/deploy.js --bundle",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
128
Provider/frontend/scripts/deploy.js
Normal file
128
Provider/frontend/scripts/deploy.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* OTA Deployment Script
|
||||||
|
* Uploads www.bin or universal.bundle to the ESP32 device.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, existsSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = resolve(__dirname, '..');
|
||||||
|
const binDir = resolve(projectRoot, 'bin');
|
||||||
|
const bundleDir = resolve(projectRoot, 'bundles');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple .env parser to load VITE_API_BASE
|
||||||
|
*/
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = resolve(projectRoot, '.env');
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
const content = readFileSync(envPath, 'utf8');
|
||||||
|
content.split('\n').forEach(line => {
|
||||||
|
const [key, ...valueParts] = line.split('=');
|
||||||
|
if (key && valueParts.length > 0) {
|
||||||
|
const value = valueParts.join('=').trim().replace(/^["']|["']$/g, '');
|
||||||
|
process.env[key.trim()] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const targetIP = process.env.VITE_API_BASE ? process.env.VITE_API_BASE.replace('http://', '') : null;
|
||||||
|
|
||||||
|
if (!targetIP) {
|
||||||
|
console.error('Error: VITE_API_BASE not found in frontend/.env. Please set it to the target device IP.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const isBundle = args.includes('--bundle');
|
||||||
|
const isFrontend = args.includes('--frontend');
|
||||||
|
|
||||||
|
if (!isBundle && !isFrontend) {
|
||||||
|
console.error('Error: Specify --frontend or --bundle.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestFile(dir, pattern) {
|
||||||
|
if (!existsSync(dir)) return null;
|
||||||
|
const files = readdirSync(dir)
|
||||||
|
.filter(f => f.match(pattern))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const getParts = (s) => {
|
||||||
|
const m = s.match(/v(\d+)\.(\d+)\.(\d+)/);
|
||||||
|
return m ? m.slice(1).map(Number) : [0, 0, 0];
|
||||||
|
};
|
||||||
|
const [aMajor, aMinor, aRev] = getParts(a);
|
||||||
|
const [bMajor, bMinor, bRev] = getParts(b);
|
||||||
|
return (bMajor - aMajor) || (bMinor - aMinor) || (bRev - aRev);
|
||||||
|
});
|
||||||
|
return files.length > 0 ? resolve(dir, files[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = isBundle
|
||||||
|
? getLatestFile(bundleDir, /^universal_v.*\.bundle$/)
|
||||||
|
: getLatestFile(binDir, /^www_v.*\.bin$/);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
console.error(`Error: No recent ${isBundle ? 'bundle' : 'frontend bin'} found in ${isBundle ? bundleDir : binDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = filePath.split(/[\\/]/).pop();
|
||||||
|
const versionMatch = fileName.match(/v(\d+\.\d+\.\d+)/);
|
||||||
|
const version = versionMatch ? versionMatch[1] : 'unknown';
|
||||||
|
|
||||||
|
console.log('-------------------------------------------');
|
||||||
|
console.log(`🚀 Deployment Started`);
|
||||||
|
console.log(`📍 Target device: http://${targetIP}`);
|
||||||
|
console.log(`📦 Packaging type: ${isBundle ? 'Universal Bundle' : 'Frontend Only'}`);
|
||||||
|
console.log(`📄 File: ${fileName}`);
|
||||||
|
console.log(`🔖 Version: ${version}`);
|
||||||
|
console.log('-------------------------------------------');
|
||||||
|
|
||||||
|
const fileBuf = readFileSync(filePath);
|
||||||
|
const urlPath = isBundle ? '/api/ota/bundle' : '/api/ota/frontend';
|
||||||
|
const url = `http://${targetIP}${urlPath}`;
|
||||||
|
|
||||||
|
console.log(`Uploading ${fileBuf.length} bytes using fetch API... (This mimics the browser UI upload)`);
|
||||||
|
|
||||||
|
async function deploy() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fileBuf,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': fileBuf.length.toString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.log('\n✅ Success: Update committed!');
|
||||||
|
console.log('Server response:', text);
|
||||||
|
console.log('The device will reboot in ~1 second.');
|
||||||
|
|
||||||
|
// Wait a few seconds to let the socket close gracefully before killing the process
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Deployment script finished.');
|
||||||
|
process.exit(0);
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error(`\n❌ Error (${response.status}):`, text);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`\n❌ Problem with request:`, e.message);
|
||||||
|
if (e.cause) console.error(e.cause);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy();
|
||||||
@@ -158,6 +158,7 @@
|
|||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<!-- Mobile Top Bar -->
|
<!-- Mobile Top Bar -->
|
||||||
<header class="mobile-header">
|
<header class="mobile-header">
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button class="hamburger" onclick={() => mobileMenuOpen = true}>
|
<button class="hamburger" onclick={() => mobileMenuOpen = true}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||||
@@ -172,7 +173,7 @@
|
|||||||
<div class="w-full max-w-6xl mx-auto space-y-8">
|
<div class="w-full max-w-6xl mx-auto space-y-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h1 class="text-2xl font-bold text-accent">Calendink Provider 📅📅🚀🚀</h1>
|
<h1 class="text-2xl font-bold text-accent">Calendink Provider 📅📅🚀🚀⚡✨🌈</h1>
|
||||||
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
|
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
|
||||||
|
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
|
|||||||
21
Provider/frontend/temp_extract_16/index.html
Normal file
21
Provider/frontend/temp_extract_16/index.html
Normal file
File diff suppressed because one or more lines are too long
BIN
Provider/frontend/temp_extract_16/index.html.gz
Normal file
BIN
Provider/frontend/temp_extract_16/index.html.gz
Normal file
Binary file not shown.
21
Provider/frontend/temp_extract_19/index.html
Normal file
21
Provider/frontend/temp_extract_19/index.html
Normal file
File diff suppressed because one or more lines are too long
BIN
Provider/frontend/temp_extract_19/index.html.gz
Normal file
BIN
Provider/frontend/temp_extract_19/index.html.gz
Normal file
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"major": 0,
|
"major": 0,
|
||||||
"minor": 1,
|
"minor": 1,
|
||||||
"revision": 16
|
"revision": 23
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user