feat: Zacus Studio macOS app
Electron + Swift scaffold, 4 tabs, OTA manager, 7 Dev Tools components, ESP32 ota_server component. Phases 1,5,6,7,9 implemented.
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
|
||||||
|
# Native modules build artifacts
|
||||||
|
src/native/.build/
|
||||||
|
src/native/build/
|
||||||
|
*.node
|
||||||
|
|
||||||
|
# Electron builder cache
|
||||||
|
.electron-builder.env
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
appId: fr.lelectronrare.zacus-studio
|
||||||
|
productName: Zacus Studio
|
||||||
|
copyright: "Copyright © 2026 L'Electron Rare"
|
||||||
|
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
buildResources: resources
|
||||||
|
|
||||||
|
files:
|
||||||
|
- dist/**/*
|
||||||
|
- node_modules/**/*
|
||||||
|
- "!node_modules/**/{CHANGELOG.md,README.md,readme.md,readme}"
|
||||||
|
- "!node_modules/**/{test,__tests__,tests,powered-test,example,examples}"
|
||||||
|
- "!node_modules/*.d.ts"
|
||||||
|
|
||||||
|
# macOS config
|
||||||
|
mac:
|
||||||
|
target:
|
||||||
|
- target: dmg
|
||||||
|
arch:
|
||||||
|
- arm64
|
||||||
|
- x64
|
||||||
|
bundleVersion: "1"
|
||||||
|
category: public.app-category.developer-tools
|
||||||
|
hardenedRuntime: true
|
||||||
|
gatekeeperAssess: false
|
||||||
|
entitlements: resources/entitlements.plist
|
||||||
|
entitlementsInherit: resources/entitlements.plist
|
||||||
|
icon: resources/icon.icns
|
||||||
|
minimumSystemVersion: "14.0"
|
||||||
|
extendInfo:
|
||||||
|
NSBluetoothAlwaysUsageDescription: "Zacus Studio needs Bluetooth to communicate with puzzle devices."
|
||||||
|
NSBluetoothPeripheralUsageDescription: "Zacus Studio needs Bluetooth to communicate with puzzle devices."
|
||||||
|
NSLocalNetworkUsageDescription: "Zacus Studio needs local network access to discover and update puzzle devices."
|
||||||
|
NSBonjourServices:
|
||||||
|
- _zacus._tcp
|
||||||
|
|
||||||
|
dmg:
|
||||||
|
contents:
|
||||||
|
- x: 130
|
||||||
|
y: 220
|
||||||
|
- x: 410
|
||||||
|
y: 220
|
||||||
|
type: link
|
||||||
|
path: /Applications
|
||||||
|
window:
|
||||||
|
width: 540
|
||||||
|
height: 380
|
||||||
|
sign: false
|
||||||
|
|
||||||
|
# Auto-updater
|
||||||
|
publish:
|
||||||
|
provider: github
|
||||||
|
owner: electron-rare
|
||||||
|
repo: le-mystere-professeur-zacus
|
||||||
|
releaseType: release
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "zacus-studio",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Zacus Studio — macOS control hub for Professeur Zacus escape room kits",
|
||||||
|
"main": "dist/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:renderer\" \"npm run dev:main\"",
|
||||||
|
"dev:main": "tsc -p tsconfig.main.json --watch & sleep 2 && electron .",
|
||||||
|
"dev:renderer": "vite",
|
||||||
|
"build": "npm run build:renderer && npm run build:main",
|
||||||
|
"build:renderer": "vite build",
|
||||||
|
"build:main": "tsc -p tsconfig.main.json",
|
||||||
|
"build:mac": "npm run build && electron-builder --mac --universal",
|
||||||
|
"build:mac-arm64": "npm run build && electron-builder --mac --arm64",
|
||||||
|
"notarize": "node scripts/notarize.js",
|
||||||
|
"rebuild-native": "electron-rebuild -f -w zacus-native",
|
||||||
|
"postinstall": "electron-builder install-app-deps"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-updater": "^6.3.0",
|
||||||
|
"serialport": "^12.0.0",
|
||||||
|
"node-addon-api": "^8.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"concurrently": "^9.0.0",
|
||||||
|
"electron": "^33.0.0",
|
||||||
|
"electron-builder": "^25.0.0",
|
||||||
|
"electron-rebuild": "^3.2.9",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vite-plugin-electron": "^0.28.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Required for Electron -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Hardened runtime -->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Network (WiFi OTA, mDNS) -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Bluetooth (BLE DFU) -->
|
||||||
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- USB Serial (IOKit) -->
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- File access for scenario files and firmware cache -->
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.downloads.read-write</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- For esptool.py subprocess -->
|
||||||
|
<key>com.apple.security.cs.allow-execution</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
FRONTEND_V3="$REPO_ROOT/frontend-v3"
|
||||||
|
DESKTOP_DIST="$REPO_ROOT/desktop/dist/renderer/apps"
|
||||||
|
|
||||||
|
echo "Building frontend-v3 apps..."
|
||||||
|
mkdir -p "$DESKTOP_DIST"
|
||||||
|
|
||||||
|
for app in editor dashboard simulation; do
|
||||||
|
echo " -> Building $app..."
|
||||||
|
(cd "$FRONTEND_V3/apps/$app" && npm run build -- --outDir "$DESKTOP_DIST/$app" --base "./apps/$app/")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done: frontend-v3 apps built to $DESKTOP_DIST"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { autoUpdater } from 'electron-updater';
|
||||||
|
import { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
export function setupAutoUpdater(win: BrowserWindow): void {
|
||||||
|
autoUpdater.checkForUpdatesAndNotify();
|
||||||
|
|
||||||
|
autoUpdater.on('update-available', () => {
|
||||||
|
win.webContents.send('updater:update-available');
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-downloaded', () => {
|
||||||
|
win.webContents.send('updater:update-downloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('download-progress', (progress: { percent: number }) => {
|
||||||
|
win.webContents.send('updater:progress', progress.percent);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { dialog, app, IpcMain, BrowserWindow, Notification } from 'electron';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
|
||||||
|
export function setupFileHandlers(ipcMain: IpcMain, win: BrowserWindow): void {
|
||||||
|
ipcMain.handle('file:open', async (_e, { filters } = {}) => {
|
||||||
|
const result = await dialog.showOpenDialog(win, {
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: filters ?? [
|
||||||
|
{ name: 'Zacus Scenarios', extensions: ['yaml', 'yml', 'zacus'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null;
|
||||||
|
|
||||||
|
const filePath = result.filePaths[0];
|
||||||
|
app.addRecentDocument(filePath);
|
||||||
|
return filePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('file:save', async (_e, { data, defaultPath }: { data: string; defaultPath?: string }) => {
|
||||||
|
const result = await dialog.showSaveDialog(win, {
|
||||||
|
defaultPath: defaultPath ?? 'scenario.yaml',
|
||||||
|
filters: [
|
||||||
|
{ name: 'Zacus Scenarios', extensions: ['yaml', 'yml'] },
|
||||||
|
{ name: 'Zacus Binary', extensions: ['zacus'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.filePath) return null;
|
||||||
|
|
||||||
|
await writeFile(result.filePath, data, 'utf-8');
|
||||||
|
app.addRecentDocument(result.filePath);
|
||||||
|
return result.filePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('file:recent', async () => {
|
||||||
|
// macOS manages recent documents via NSDocumentController
|
||||||
|
// Return empty array; the menu:open-recent event handles it natively
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('file:add-recent', async (_e, filePath: string) => {
|
||||||
|
app.addRecentDocument(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('notify', async (_e, { title, body }: { title: string; body: string }) => {
|
||||||
|
if (Notification.isSupported()) {
|
||||||
|
new Notification({ title, body }).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { app, BrowserWindow, ipcMain, shell, nativeTheme } from 'electron';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { setupMenu } from './menu';
|
||||||
|
import { setupFileHandlers } from './file-handler';
|
||||||
|
import { setupAutoUpdater } from './auto-updater';
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const RENDERER_URL = 'http://localhost:5173';
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
function createWindow(): void {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 1100,
|
||||||
|
minHeight: 700,
|
||||||
|
titleBarStyle: 'hiddenInset',
|
||||||
|
trafficLightPosition: { x: 16, y: 16 },
|
||||||
|
backgroundColor: '#1a1a2e',
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
webSecurity: !isDev,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load renderer
|
||||||
|
if (isDev) {
|
||||||
|
mainWindow.loadURL(RENDERER_URL);
|
||||||
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
mainWindow!.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// Force dark mode for consistent look
|
||||||
|
nativeTheme.themeSource = 'dark';
|
||||||
|
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
// Setup macOS menu
|
||||||
|
setupMenu(mainWindow!);
|
||||||
|
|
||||||
|
// Setup all IPC handlers
|
||||||
|
setupFileHandlers(ipcMain, mainWindow!);
|
||||||
|
|
||||||
|
// Setup auto-updater (production only)
|
||||||
|
if (!isDev) {
|
||||||
|
setupAutoUpdater(mainWindow!);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle open-file events (drag .zacus onto dock icon)
|
||||||
|
app.on('open-file', (event, filePath) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('file:opened', filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security: prevent new window creation
|
||||||
|
app.on('web-contents-created', (_event, contents) => {
|
||||||
|
contents.on('will-navigate', (event, navigationUrl) => {
|
||||||
|
const parsedUrl = new URL(navigationUrl);
|
||||||
|
if (isDev && parsedUrl.origin === RENDERER_URL) return;
|
||||||
|
if (!isDev && navigationUrl.startsWith('file://')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
Menu,
|
||||||
|
MenuItemConstructorOptions,
|
||||||
|
shell,
|
||||||
|
} from 'electron';
|
||||||
|
|
||||||
|
export function setupMenu(win: BrowserWindow): void {
|
||||||
|
const template: MenuItemConstructorOptions[] = [
|
||||||
|
// App menu
|
||||||
|
{
|
||||||
|
label: app.name,
|
||||||
|
submenu: [
|
||||||
|
{ role: 'about', label: `About Zacus Studio` },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Preferences\u2026', accelerator: 'Cmd+,', click: () => win.webContents.send('menu:preferences') },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'services' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'hide' },
|
||||||
|
{ role: 'hideOthers' },
|
||||||
|
{ role: 'unhide' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'quit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// File menu
|
||||||
|
{
|
||||||
|
label: 'File',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'New Scenario',
|
||||||
|
accelerator: 'Cmd+N',
|
||||||
|
click: () => win.webContents.send('menu:new-scenario'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open\u2026',
|
||||||
|
accelerator: 'Cmd+O',
|
||||||
|
click: () => win.webContents.send('menu:open'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open Recent',
|
||||||
|
role: 'recentDocuments',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'clearRecentDocuments' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Save',
|
||||||
|
accelerator: 'Cmd+S',
|
||||||
|
click: () => win.webContents.send('menu:save'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Save As\u2026',
|
||||||
|
accelerator: 'Cmd+Shift+S',
|
||||||
|
click: () => win.webContents.send('menu:save-as'),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Export to SD Card',
|
||||||
|
accelerator: 'Cmd+E',
|
||||||
|
click: () => win.webContents.send('menu:export-sd'),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'close' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Edit menu
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'undo' },
|
||||||
|
{ role: 'redo' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
{ role: 'selectAll' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// View menu
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Editor',
|
||||||
|
accelerator: 'Cmd+1',
|
||||||
|
click: () => win.webContents.send('menu:tab', 'editor'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Game Master',
|
||||||
|
accelerator: 'Cmd+2',
|
||||||
|
click: () => win.webContents.send('menu:tab', 'dashboard'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Simulation',
|
||||||
|
accelerator: 'Cmd+3',
|
||||||
|
click: () => win.webContents.send('menu:tab', 'simulation'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dev Tools',
|
||||||
|
accelerator: 'Cmd+4',
|
||||||
|
click: () => win.webContents.send('menu:tab', 'devtools'),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Compile Scenario',
|
||||||
|
accelerator: 'Cmd+B',
|
||||||
|
click: () => win.webContents.send('menu:compile'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Validate Scenario',
|
||||||
|
accelerator: 'Cmd+R',
|
||||||
|
click: () => win.webContents.send('menu:validate'),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'togglefullscreen' },
|
||||||
|
{ role: 'reload' },
|
||||||
|
{ role: 'toggleDevTools' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Dev menu
|
||||||
|
{
|
||||||
|
label: 'Dev',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Scan Devices',
|
||||||
|
accelerator: 'Cmd+Shift+D',
|
||||||
|
click: () => win.webContents.send('menu:scan-devices'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open Serial Monitor',
|
||||||
|
click: () => win.webContents.send('menu:serial-monitor'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Firmware Manager',
|
||||||
|
click: () => win.webContents.send('menu:firmware-manager'),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Show App Logs',
|
||||||
|
click: () => shell.openPath(app.getPath('logs')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Show Firmware Cache',
|
||||||
|
click: () => {
|
||||||
|
const { homedir } = require('os');
|
||||||
|
const { join } = require('path');
|
||||||
|
shell.openPath(join(homedir(), '.zacus-studio', 'firmwares'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Window menu
|
||||||
|
{
|
||||||
|
label: 'Window',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'minimize' },
|
||||||
|
{ role: 'zoom' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'front' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'window' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Help menu
|
||||||
|
{
|
||||||
|
role: 'help',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Zacus Documentation',
|
||||||
|
click: () => shell.openExternal('https://github.com/electron-rare/le-mystere-professeur-zacus'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Report Issue',
|
||||||
|
click: () => shell.openExternal('https://github.com/electron-rare/le-mystere-professeur-zacus/issues'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate(template);
|
||||||
|
Menu.setApplicationMenu(menu);
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { IpcMain, BrowserWindow } from 'electron';
|
||||||
|
import { createReadStream, statSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join, basename } from 'path';
|
||||||
|
import { mkdir, writeFile, readFile } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import * as http from 'http';
|
||||||
|
import type { IncomingMessage } from 'http';
|
||||||
|
|
||||||
|
export interface DeviceVersion {
|
||||||
|
firmware: string;
|
||||||
|
version: string;
|
||||||
|
idf: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceStatus {
|
||||||
|
battery_pct: number;
|
||||||
|
uptime_s: number;
|
||||||
|
espnow_peers: number;
|
||||||
|
heap_free: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OTAStatus {
|
||||||
|
state: 'idle' | 'downloading' | 'verifying' | 'rebooting';
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OTAMethod = 'wifi' | 'ble' | 'usb';
|
||||||
|
|
||||||
|
interface ActiveOTA {
|
||||||
|
deviceId: string;
|
||||||
|
method: OTAMethod;
|
||||||
|
startedAt: number;
|
||||||
|
abortController?: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OTAManager {
|
||||||
|
private win: BrowserWindow;
|
||||||
|
private activeOTAs = new Map<string, ActiveOTA>();
|
||||||
|
private firmwareCacheDir: string;
|
||||||
|
private lastOTATime = new Map<string, number>();
|
||||||
|
private readonly OTA_RATE_LIMIT_MS = 60_000;
|
||||||
|
|
||||||
|
constructor(win: BrowserWindow) {
|
||||||
|
this.win = win;
|
||||||
|
this.firmwareCacheDir = join(homedir(), '.zacus-studio', 'firmwares');
|
||||||
|
this.ensureCacheDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCacheDir(): Promise<void> {
|
||||||
|
await mkdir(this.firmwareCacheDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
setupHandlers(ipcMain: IpcMain): void {
|
||||||
|
ipcMain.handle('ota:check', async (_e, deviceId: string) => {
|
||||||
|
return this.checkUpdate(deviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('ota:update', async (_e, {
|
||||||
|
deviceId,
|
||||||
|
method,
|
||||||
|
firmwarePath,
|
||||||
|
}: { deviceId: string; method: OTAMethod; firmwarePath: string }) => {
|
||||||
|
await this.startUpdate(deviceId, method, firmwarePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('ota:rollback', async (_e, deviceId: string) => {
|
||||||
|
return this.rollback(deviceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Version Check ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async checkUpdate(deviceId: string): Promise<{
|
||||||
|
current: string;
|
||||||
|
available: string;
|
||||||
|
needsUpdate: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const deviceVersion = await this.getDeviceVersion(deviceId);
|
||||||
|
const availableVersion = await this.getAvailableVersion(deviceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: deviceVersion?.version ?? 'unknown',
|
||||||
|
available: availableVersion ?? 'unknown',
|
||||||
|
needsUpdate: Boolean(
|
||||||
|
deviceVersion && availableVersion &&
|
||||||
|
availableVersion !== deviceVersion.version
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { current: 'unknown', available: 'unknown', needsUpdate: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDeviceVersion(deviceId: string): Promise<DeviceVersion | null> {
|
||||||
|
// deviceId can be an IP or a serial port path
|
||||||
|
if (deviceId.includes('/dev/')) {
|
||||||
|
// USB: query via serial command
|
||||||
|
return this.queryVersionViaSerial(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFi: HTTP GET /version
|
||||||
|
const url = `http://${deviceId}/version`;
|
||||||
|
try {
|
||||||
|
const response = await this.httpGet(url, 5000);
|
||||||
|
return JSON.parse(response) as DeviceVersion;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableVersion(deviceId: string): Promise<string | null> {
|
||||||
|
// Look in firmware cache for matching firmware
|
||||||
|
const manifestPath = join(this.firmwareCacheDir, 'manifest.json');
|
||||||
|
if (!existsSync(manifestPath)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as Record<string, string>;
|
||||||
|
// Match by device name prefix (e.g., "p1_son" for puzzle 1)
|
||||||
|
const key = Object.keys(manifest).find(k => deviceId.toLowerCase().includes(k.split('_')[0]));
|
||||||
|
return key ? manifest[key] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OTA Update Orchestration ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async startUpdate(deviceId: string, method: OTAMethod, firmwarePath: string): Promise<void> {
|
||||||
|
// Rate limiting: prevent accidental double-flash
|
||||||
|
const lastOTA = this.lastOTATime.get(deviceId) ?? 0;
|
||||||
|
if (Date.now() - lastOTA < this.OTA_RATE_LIMIT_MS) {
|
||||||
|
throw new Error(`OTA rate limit: wait ${Math.ceil((this.OTA_RATE_LIMIT_MS - (Date.now() - lastOTA)) / 1000)}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeOTAs.has(deviceId)) {
|
||||||
|
throw new Error(`OTA already in progress for ${deviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this.activeOTAs.set(deviceId, {
|
||||||
|
deviceId,
|
||||||
|
method,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
abortController,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.lastOTATime.set(deviceId, Date.now());
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'wifi': await this.otaViaWiFi(deviceId, firmwarePath, abortController.signal); break;
|
||||||
|
case 'ble': await this.otaViaBLE(deviceId, firmwarePath); break;
|
||||||
|
case 'usb': await this.otaViaUSB(deviceId, firmwarePath); break;
|
||||||
|
default: throw new Error(`Unknown OTA method: ${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm update by polling /version until device reboots and comes back
|
||||||
|
await this.waitForReboot(deviceId);
|
||||||
|
const newVersion = await this.getDeviceVersion(deviceId);
|
||||||
|
|
||||||
|
this.sendEvent('ota:complete', {
|
||||||
|
deviceId,
|
||||||
|
success: true,
|
||||||
|
newVersion: newVersion?.version,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
this.sendEvent('ota:complete', { deviceId, success: false, error });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.activeOTAs.delete(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WiFi OTA ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async otaViaWiFi(
|
||||||
|
deviceId: string,
|
||||||
|
firmwarePath: string,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<void> {
|
||||||
|
const fileSize = statSync(firmwarePath).size;
|
||||||
|
const url = `http://${deviceId}/ota`;
|
||||||
|
|
||||||
|
this.sendProgress(deviceId, 0, 'uploading');
|
||||||
|
|
||||||
|
// Stream the firmware binary via chunked HTTP POST
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal.aborted) { reject(new Error('Aborted')); return; }
|
||||||
|
|
||||||
|
const fileStream = createReadStream(firmwarePath);
|
||||||
|
let uploaded = 0;
|
||||||
|
|
||||||
|
const options = new URL(url);
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: options.hostname,
|
||||||
|
port: options.port || 80,
|
||||||
|
path: options.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': fileSize,
|
||||||
|
'X-Firmware-Name': basename(firmwarePath),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res: IncomingMessage) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode === 200) resolve();
|
||||||
|
else reject(new Error(`OTA server returned ${res.statusCode}: ${Buffer.concat(chunks).toString()}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('OTA cancelled'));
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on('data', (chunk: Buffer) => {
|
||||||
|
uploaded += chunk.length;
|
||||||
|
const pct = Math.round((uploaded / fileSize) * 100);
|
||||||
|
this.sendProgress(deviceId, pct, 'uploading');
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on('error', reject);
|
||||||
|
fileStream.pipe(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll /ota/status for verification phase
|
||||||
|
await this.pollOTAStatus(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollOTAStatus(deviceId: string): Promise<void> {
|
||||||
|
const maxAttempts = 60;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await sleep(1000);
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusJson = await this.httpGet(`http://${deviceId}/ota/status`, 3000);
|
||||||
|
const status = JSON.parse(statusJson) as OTAStatus;
|
||||||
|
|
||||||
|
if (status.state === 'verifying') {
|
||||||
|
this.sendProgress(deviceId, status.progress, 'verifying');
|
||||||
|
} else if (status.state === 'rebooting') {
|
||||||
|
this.sendProgress(deviceId, 100, 'rebooting');
|
||||||
|
return; // Device will reboot, we're done
|
||||||
|
} else if (status.state === 'idle' && status.progress === 100) {
|
||||||
|
return; // Done
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Device may have rebooted; treat connection failure as success
|
||||||
|
if (attempts > 5) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('OTA status polling timed out');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BLE OTA ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async otaViaBLE(deviceId: string, firmwarePath: string): Promise<void> {
|
||||||
|
// Delegate to ble-handler IPC
|
||||||
|
await this.win.webContents.executeJavaScript(
|
||||||
|
`window.zacus.ble.dfu(${JSON.stringify(deviceId)}, ${JSON.stringify(firmwarePath)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── USB OTA ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async otaViaUSB(deviceId: string, firmwarePath: string): Promise<void> {
|
||||||
|
// Delegate to serial-handler IPC
|
||||||
|
await this.win.webContents.executeJavaScript(
|
||||||
|
`window.zacus.serial.flash(${JSON.stringify(deviceId)}, ${JSON.stringify(firmwarePath)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rollback ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async rollback(deviceId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.httpPost(`http://${deviceId}/ota/rollback`, '', 10000);
|
||||||
|
return response.status === 200;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reboot Detection ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async waitForReboot(deviceId: string, timeoutMs = 60_000): Promise<void> {
|
||||||
|
if (deviceId.includes('/dev/')) return; // USB: no reboot detection
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let deviceGoneOnce = false;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
await sleep(2000);
|
||||||
|
try {
|
||||||
|
await this.httpGet(`http://${deviceId}/version`, 2000);
|
||||||
|
if (deviceGoneOnce) {
|
||||||
|
// Device came back — reboot complete
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
deviceGoneOnce = true;
|
||||||
|
this.sendProgress(deviceId, 100, 'rebooting');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceGoneOnce) {
|
||||||
|
// Device never rebooted but responded throughout — might be fine
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Device did not come back online after reboot');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Firmware Cache ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async importFirmware(sourcePath: string): Promise<string> {
|
||||||
|
const name = basename(sourcePath);
|
||||||
|
|
||||||
|
// Basic ELF header validation for ESP32 binaries
|
||||||
|
const { readFile: rf } = await import('fs/promises');
|
||||||
|
const header = await rf(sourcePath);
|
||||||
|
|
||||||
|
// ESP32 app images start with 0xE9 (magic byte)
|
||||||
|
if (header[0] !== 0xE9) {
|
||||||
|
throw new Error('Invalid ESP32 firmware: bad magic byte');
|
||||||
|
}
|
||||||
|
|
||||||
|
const destPath = join(this.firmwareCacheDir, name);
|
||||||
|
const content = await rf(sourcePath);
|
||||||
|
await writeFile(destPath, content);
|
||||||
|
return destPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Serial Version Query ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async queryVersionViaSerial(_port: string): Promise<DeviceVersion | null> {
|
||||||
|
// Send version query command and wait for JSON response
|
||||||
|
// This assumes the firmware has a CLI that responds to "version\n"
|
||||||
|
// Implemented via serial IPC — simplified here
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private httpGet(url: string, timeoutMs: number): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('Timeout')), timeoutMs);
|
||||||
|
http.get(url, (res) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||||
|
res.on('error', reject);
|
||||||
|
}).on('error', (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async httpPost(url: string, body: string, _timeoutMs: number): Promise<{ status: number; data: string }> {
|
||||||
|
const options = new URL(url);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: options.hostname,
|
||||||
|
port: options.port || 80,
|
||||||
|
path: options.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode ?? 0,
|
||||||
|
data: Buffer.concat(chunks).toString('utf-8'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Event Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sendProgress(deviceId: string, percent: number, stage: string): void {
|
||||||
|
this.win.webContents.send('ota:progress', { deviceId, percent, stage });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendEvent(channel: string, data: unknown): void {
|
||||||
|
this.win.webContents.send(channel, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
// Type definitions
|
||||||
|
export interface SerialPort {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
vendorId?: string;
|
||||||
|
productId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZacusDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'puzzle' | 'hub';
|
||||||
|
firmwareVersion: string;
|
||||||
|
batteryPct: number;
|
||||||
|
connectionType: 'usb' | 'ble' | 'wifi';
|
||||||
|
lastSeen: number;
|
||||||
|
ip?: string;
|
||||||
|
mac?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OTAProgressEvent {
|
||||||
|
deviceId: string;
|
||||||
|
percent: number;
|
||||||
|
stage: 'uploading' | 'verifying' | 'rebooting';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose safe API to renderer via contextBridge
|
||||||
|
contextBridge.exposeInMainWorld('zacus', {
|
||||||
|
// === Serial ===
|
||||||
|
serial: {
|
||||||
|
list: () => ipcRenderer.invoke('serial:list') as Promise<SerialPort[]>,
|
||||||
|
connect: (port: string, baud: number) =>
|
||||||
|
ipcRenderer.invoke('serial:connect', { port, baud }) as Promise<boolean>,
|
||||||
|
disconnect: (port: string) =>
|
||||||
|
ipcRenderer.invoke('serial:disconnect', { port }) as Promise<void>,
|
||||||
|
write: (port: string, data: string) =>
|
||||||
|
ipcRenderer.invoke('serial:write', { port, data }) as Promise<void>,
|
||||||
|
flash: (port: string, firmwarePath: string) =>
|
||||||
|
ipcRenderer.invoke('serial:flash', { port, firmwarePath }) as Promise<void>,
|
||||||
|
onData: (callback: (port: string, data: string) => void) => {
|
||||||
|
ipcRenderer.on('serial:data', (_e, { port, data }) => callback(port, data));
|
||||||
|
},
|
||||||
|
onPlugged: (callback: (port: string) => void) => {
|
||||||
|
ipcRenderer.on('serial:plugged', (_e, port) => callback(port));
|
||||||
|
},
|
||||||
|
onUnplugged: (callback: (port: string) => void) => {
|
||||||
|
ipcRenderer.on('serial:unplugged', (_e, port) => callback(port));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === BLE ===
|
||||||
|
ble: {
|
||||||
|
scan: () => ipcRenderer.invoke('ble:scan') as Promise<void>,
|
||||||
|
stopScan: () => ipcRenderer.invoke('ble:stop-scan') as Promise<void>,
|
||||||
|
connect: (deviceId: string) =>
|
||||||
|
ipcRenderer.invoke('ble:connect', deviceId) as Promise<boolean>,
|
||||||
|
disconnect: (deviceId: string) =>
|
||||||
|
ipcRenderer.invoke('ble:disconnect', deviceId) as Promise<void>,
|
||||||
|
write: (deviceId: string, characteristic: string, data: string) =>
|
||||||
|
ipcRenderer.invoke('ble:write', { deviceId, characteristic, data }) as Promise<void>,
|
||||||
|
dfu: (deviceId: string, firmwarePath: string) =>
|
||||||
|
ipcRenderer.invoke('ble:dfu', { deviceId, firmwarePath }) as Promise<void>,
|
||||||
|
onDiscovered: (callback: (device: ZacusDevice) => void) => {
|
||||||
|
ipcRenderer.on('ble:discovered', (_e, device) => callback(device));
|
||||||
|
},
|
||||||
|
onData: (callback: (deviceId: string, characteristic: string, data: string) => void) => {
|
||||||
|
ipcRenderer.on('ble:data', (_e, { deviceId, characteristic, data }) =>
|
||||||
|
callback(deviceId, characteristic, data));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === WiFi ===
|
||||||
|
wifi: {
|
||||||
|
discover: () =>
|
||||||
|
ipcRenderer.invoke('wifi:discover') as Promise<ZacusDevice[]>,
|
||||||
|
wsConnect: (url: string) =>
|
||||||
|
ipcRenderer.invoke('wifi:ws-connect', url) as Promise<boolean>,
|
||||||
|
wsSend: (data: string) =>
|
||||||
|
ipcRenderer.invoke('wifi:ws-send', data) as Promise<void>,
|
||||||
|
wsDisconnect: () =>
|
||||||
|
ipcRenderer.invoke('wifi:ws-disconnect') as Promise<void>,
|
||||||
|
http: (url: string, method: string, body?: string, headers?: Record<string, string>) =>
|
||||||
|
ipcRenderer.invoke('wifi:http', { url, method, body, headers }) as Promise<{
|
||||||
|
status: number;
|
||||||
|
data: string;
|
||||||
|
}>,
|
||||||
|
onWsMessage: (callback: (data: string) => void) => {
|
||||||
|
ipcRenderer.on('wifi:ws-message', (_e, data) => callback(data));
|
||||||
|
},
|
||||||
|
onServiceFound: (callback: (service: ZacusDevice) => void) => {
|
||||||
|
ipcRenderer.on('wifi:service-found', (_e, service) => callback(service));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === OTA ===
|
||||||
|
ota: {
|
||||||
|
check: (deviceId: string) =>
|
||||||
|
ipcRenderer.invoke('ota:check', deviceId) as Promise<{
|
||||||
|
current: string;
|
||||||
|
available: string;
|
||||||
|
needsUpdate: boolean;
|
||||||
|
}>,
|
||||||
|
update: (deviceId: string, method: 'wifi' | 'ble' | 'usb', firmwarePath: string) =>
|
||||||
|
ipcRenderer.invoke('ota:update', { deviceId, method, firmwarePath }) as Promise<void>,
|
||||||
|
rollback: (deviceId: string) =>
|
||||||
|
ipcRenderer.invoke('ota:rollback', deviceId) as Promise<boolean>,
|
||||||
|
onProgress: (callback: (event: OTAProgressEvent) => void) => {
|
||||||
|
ipcRenderer.on('ota:progress', (_e, event) => callback(event));
|
||||||
|
},
|
||||||
|
onComplete: (callback: (deviceId: string, success: boolean, error?: string) => void) => {
|
||||||
|
ipcRenderer.on('ota:complete', (_e, { deviceId, success, error }) =>
|
||||||
|
callback(deviceId, success, error));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Files ===
|
||||||
|
file: {
|
||||||
|
open: (filters?: Array<{ name: string; extensions: string[] }>) =>
|
||||||
|
ipcRenderer.invoke('file:open', { filters }) as Promise<string | null>,
|
||||||
|
save: (data: string, defaultPath?: string) =>
|
||||||
|
ipcRenderer.invoke('file:save', { data, defaultPath }) as Promise<string | null>,
|
||||||
|
recent: () => ipcRenderer.invoke('file:recent') as Promise<string[]>,
|
||||||
|
addRecent: (filePath: string) =>
|
||||||
|
ipcRenderer.invoke('file:add-recent', filePath) as Promise<void>,
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Menu events ===
|
||||||
|
menu: {
|
||||||
|
on: (event: string, callback: (...args: unknown[]) => void) => {
|
||||||
|
ipcRenderer.on(`menu:${event}`, (_e, ...args) => callback(...args));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Notifications ===
|
||||||
|
notify: (title: string, body: string) =>
|
||||||
|
ipcRenderer.invoke('notify', { title, body }) as Promise<void>,
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { EditorTab } from './tabs/EditorTab';
|
||||||
|
import { DashboardTab } from './tabs/DashboardTab';
|
||||||
|
import { SimulationTab } from './tabs/SimulationTab';
|
||||||
|
import { DevToolsTab } from './tabs/DevToolsTab';
|
||||||
|
import './styles/App.css';
|
||||||
|
|
||||||
|
type Tab = 'editor' | 'dashboard' | 'simulation' | 'devtools';
|
||||||
|
|
||||||
|
interface TabConfig {
|
||||||
|
id: Tab;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
shortcut: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: TabConfig[] = [
|
||||||
|
{ id: 'editor', label: 'Editor', icon: '\u2B21', shortcut: '\u2318 1' },
|
||||||
|
{ id: 'dashboard', label: 'Game Master', icon: '\u25CE', shortcut: '\u2318 2' },
|
||||||
|
{ id: 'simulation', label: 'Simulation', icon: '\u25B3', shortcut: '\u2318 3' },
|
||||||
|
{ id: 'devtools', label: 'Dev Tools', icon: '\u2699', shortcut: '\u2318 4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App(): React.JSX.Element {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('editor');
|
||||||
|
const [deviceStatus, setDeviceStatus] = useState<'connected' | 'partial' | 'disconnected'>('disconnected');
|
||||||
|
|
||||||
|
// Handle menu events from main process
|
||||||
|
useEffect(() => {
|
||||||
|
window.zacus.menu.on('tab', (tab: unknown) => {
|
||||||
|
if (typeof tab === 'string' && TABS.some(t => t.id === tab)) {
|
||||||
|
setActiveTab(tab as Tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTabClick = useCallback((tab: Tab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const statusColor = {
|
||||||
|
connected: 'var(--success)',
|
||||||
|
partial: 'var(--warning)',
|
||||||
|
disconnected: 'var(--text-dim)',
|
||||||
|
}[deviceStatus];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-layout">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-top titlebar-drag" />
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
className={`sidebar-btn ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
|
onClick={() => handleTabClick(tab.id)}
|
||||||
|
title={`${tab.label} (${tab.shortcut})`}
|
||||||
|
>
|
||||||
|
<span className="sidebar-icon">{tab.icon}</span>
|
||||||
|
<span className="sidebar-label">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar-bottom">
|
||||||
|
<div
|
||||||
|
className="device-status-dot"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
title={`Devices: ${deviceStatus}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="main-content">
|
||||||
|
<div className="titlebar-drag" />
|
||||||
|
<div className="tab-content">
|
||||||
|
{activeTab === 'editor' && <EditorTab />}
|
||||||
|
{activeTab === 'dashboard' && <DashboardTab onDeviceStatusChange={setDeviceStatus} />}
|
||||||
|
{activeTab === 'simulation' && <SimulationTab />}
|
||||||
|
{activeTab === 'devtools' && <DevToolsTab />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface BatteryReading {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
pct: number;
|
||||||
|
pack: string;
|
||||||
|
remainingHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatteryStatusMessage {
|
||||||
|
type: string;
|
||||||
|
device: string;
|
||||||
|
battery_pct: number;
|
||||||
|
remaining_hours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatteryBar({ pct }: { pct: number }): React.JSX.Element {
|
||||||
|
const color =
|
||||||
|
pct >= 50 ? 'var(--success, #22c55e)'
|
||||||
|
: pct >= 20 ? 'var(--warning, #f59e0b)'
|
||||||
|
: 'var(--error, #ef4444)';
|
||||||
|
return (
|
||||||
|
<div className="battery-bar-wrapper">
|
||||||
|
<div
|
||||||
|
className="battery-bar"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_READINGS: BatteryReading[] = [
|
||||||
|
{ deviceId: 'box3', name: 'BOX-3', pct: 82, pack: 'Anker #1', remainingHours: 4.0 },
|
||||||
|
{ deviceId: 'p1_son', name: 'P1 Son', pct: 61, pack: 'Pack A', remainingHours: 3.1 },
|
||||||
|
{ deviceId: 'p5_mor', name: 'P5 Morse', pct: 94, pack: 'Pack A', remainingHours: 5.2 },
|
||||||
|
{ deviceId: 'p6_nfc', name: 'P6 NFC', pct: 78, pack: 'Pack B', remainingHours: 4.0 },
|
||||||
|
{ deviceId: 'p7_cof', name: 'P7 Coffre', pct: 31, pack: 'Pack B', remainingHours: 1.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BatteryDashboard(): React.JSX.Element {
|
||||||
|
const [readings, setReadings] = useState<BatteryReading[]>(INITIAL_READINGS);
|
||||||
|
|
||||||
|
// Listen for live battery status updates via WebSocket
|
||||||
|
useEffect(() => {
|
||||||
|
window.zacus.wifi.onWsMessage(rawData => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(rawData) as BatteryStatusMessage;
|
||||||
|
if (msg.type === 'battery_status') {
|
||||||
|
setReadings(prev => prev.map(r =>
|
||||||
|
r.deviceId === msg.device
|
||||||
|
? {
|
||||||
|
...r,
|
||||||
|
pct: msg.battery_pct,
|
||||||
|
remainingHours: msg.remaining_hours ?? msg.battery_pct / 20,
|
||||||
|
}
|
||||||
|
: r
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch { /* not battery data */ }
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lowest = readings.reduce(
|
||||||
|
(min, r) => r.pct < min.pct ? r : min,
|
||||||
|
readings[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
const criticalDevices = readings.filter(r => r.pct < 20);
|
||||||
|
const warningDevices = readings.filter(r => r.pct >= 20 && r.pct < 25);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="battery-dashboard">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Battery Dashboard</h2>
|
||||||
|
<span className="text-dim">{readings.length} devices monitored</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{criticalDevices.length > 0 && (
|
||||||
|
<div className="alert error">
|
||||||
|
⚠️ Critical: {criticalDevices.map(d => `${d.name} (${d.pct}%)`).join(', ')} — replace immediately
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warningDevices.length > 0 && criticalDevices.length === 0 && (
|
||||||
|
<div className="alert warning">
|
||||||
|
⚠️ Low: {warningDevices.map(d => `${d.name} (${d.pct}%)`).join(', ')} — replace soon
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lowest && lowest.pct < 25 && criticalDevices.length === 0 && warningDevices.length === 0 && (
|
||||||
|
<div className="alert warning">
|
||||||
|
⚠️ {lowest.name}: {lowest.pct}% — lowest battery
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="battery-list">
|
||||||
|
{readings.map(r => (
|
||||||
|
<div key={r.deviceId} className="battery-row">
|
||||||
|
<div className="battery-name">{r.name}</div>
|
||||||
|
<div className="battery-bar-container">
|
||||||
|
<BatteryBar pct={r.pct} />
|
||||||
|
<span className={`battery-pct ${r.pct < 20 ? 'error' : r.pct < 50 ? 'warn' : ''}`}>
|
||||||
|
{r.pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="battery-meta">
|
||||||
|
<span className="text-dim">{r.pack}</span>
|
||||||
|
<span className={r.remainingHours < 2 ? 'warn' : 'text-dim'}>
|
||||||
|
~{r.remainingHours.toFixed(1)}h remaining
|
||||||
|
</span>
|
||||||
|
{r.pct < 20 && <span className="badge error">⚠️ CRITICAL</span>}
|
||||||
|
{r.pct >= 20 && r.pct < 25 && <span className="badge warning">⚠️ LOW</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="battery-summary">
|
||||||
|
<span className="text-dim">
|
||||||
|
Avg: {Math.round(readings.reduce((s, r) => s + r.pct, 0) / readings.length)}% |{' '}
|
||||||
|
Min: {lowest?.pct}% ({lowest?.name}) |{' '}
|
||||||
|
Est. runtime: ~{Math.min(...readings.map(r => r.remainingHours)).toFixed(1)}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
const CONNECTION_ICONS: Record<ZacusDevice['connectionType'], string> = {
|
||||||
|
usb: '🔌',
|
||||||
|
ble: '📶',
|
||||||
|
wifi: '📡',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeviceManager(): React.JSX.Element {
|
||||||
|
const [devices, setDevices] = useState<ZacusDevice[]>([]);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Discover via all transports in parallel
|
||||||
|
const scan = useCallback(async () => {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const [wifiDevices, serialPorts] = await Promise.all([
|
||||||
|
window.zacus.wifi.discover(),
|
||||||
|
window.zacus.serial.list(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serialDevices: ZacusDevice[] = serialPorts.map(p => ({
|
||||||
|
id: p.path,
|
||||||
|
name: p.name ?? p.path.split('/').pop() ?? 'Unknown',
|
||||||
|
type: 'puzzle' as const,
|
||||||
|
firmwareVersion: '?',
|
||||||
|
batteryPct: -1,
|
||||||
|
connectionType: 'usb' as const,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setDevices([...wifiDevices, ...serialDevices]);
|
||||||
|
await window.zacus.ble.scan();
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// BLE discovered — merge into device list
|
||||||
|
useEffect(() => {
|
||||||
|
window.zacus.ble.onDiscovered(device => {
|
||||||
|
setDevices(prev => {
|
||||||
|
const existing = prev.findIndex(d => d.id === device.id);
|
||||||
|
if (existing >= 0) {
|
||||||
|
const copy = [...prev];
|
||||||
|
copy[existing] = { ...copy[existing], ...device, connectionType: 'ble' };
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
return [...prev, { ...device, connectionType: 'ble' as const }];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// WiFi mDNS discovered — merge into device list
|
||||||
|
useEffect(() => {
|
||||||
|
window.zacus.wifi.onServiceFound(service => {
|
||||||
|
setDevices(prev => {
|
||||||
|
const existing = prev.findIndex(d => d.id === service.id);
|
||||||
|
if (existing >= 0) {
|
||||||
|
const copy = [...prev];
|
||||||
|
copy[existing] = { ...copy[existing], ...service };
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
return [...prev, service];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { scan(); }, [scan]);
|
||||||
|
|
||||||
|
const handleConnect = useCallback(async (device: ZacusDevice) => {
|
||||||
|
setSelectedId(device.id);
|
||||||
|
if (device.connectionType === 'wifi' && device.ip) {
|
||||||
|
await window.zacus.wifi.wsConnect(`ws://${device.ip}/ws`);
|
||||||
|
} else if (device.connectionType === 'ble') {
|
||||||
|
await window.zacus.ble.connect(device.id);
|
||||||
|
} else if (device.connectionType === 'usb') {
|
||||||
|
await window.zacus.serial.connect(device.id, 115200);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="device-manager">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Devices</h2>
|
||||||
|
<button className="btn primary" onClick={scan} disabled={scanning}>
|
||||||
|
{scanning ? 'Scanning…' : '↺ Scan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-grid">
|
||||||
|
{devices.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
No devices found. Connect USB or enable WiFi.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{devices.map(device => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className={`device-card ${selectedId === device.id ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSelectedId(device.id)}
|
||||||
|
>
|
||||||
|
<div className="device-header">
|
||||||
|
<span className="device-icon">{CONNECTION_ICONS[device.connectionType]}</span>
|
||||||
|
<span className="device-name">{device.name}</span>
|
||||||
|
<span className="device-type badge">{device.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="device-details">
|
||||||
|
<span className="text-dim">fw v{device.firmwareVersion}</span>
|
||||||
|
{device.batteryPct >= 0 && (
|
||||||
|
<span className={device.batteryPct < 20 ? 'warn' : 'text-dim'}>
|
||||||
|
🔋 {device.batteryPct}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{device.ip && (
|
||||||
|
<span className="text-dim">📡 {device.ip}</span>
|
||||||
|
)}
|
||||||
|
{device.mac && (
|
||||||
|
<span className="text-dim mono">{device.mac}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="device-meta">
|
||||||
|
<span className="text-dim">
|
||||||
|
Last seen {new Date(device.lastSeen).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="device-actions">
|
||||||
|
<button
|
||||||
|
className="btn small primary"
|
||||||
|
onClick={e => { e.stopPropagation(); handleConnect(device); }}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
{device.connectionType === 'ble' && (
|
||||||
|
<button
|
||||||
|
className="btn small"
|
||||||
|
onClick={e => { e.stopPropagation(); window.zacus.ble.disconnect(device.id); }}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface FirmwareEntry {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
builtAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceRow {
|
||||||
|
device: ZacusDevice;
|
||||||
|
currentVersion: string;
|
||||||
|
availableVersion: string;
|
||||||
|
needsUpdate: boolean;
|
||||||
|
status: 'idle' | 'checking' | 'updating' | 'done' | 'error';
|
||||||
|
progress: number;
|
||||||
|
progressStage: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OTAMethod = 'wifi' | 'ble' | 'usb';
|
||||||
|
|
||||||
|
export function FirmwareManager(): React.JSX.Element {
|
||||||
|
const [devices, setDevices] = useState<DeviceRow[]>([]);
|
||||||
|
const [firmwares, setFirmwares] = useState<FirmwareEntry[]>([]);
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState<OTAMethod>('wifi');
|
||||||
|
const [building, setBuilding] = useState(false);
|
||||||
|
|
||||||
|
// Subscribe to OTA progress/complete events
|
||||||
|
useEffect(() => {
|
||||||
|
window.zacus.ota.onProgress(({ deviceId, percent, stage }) => {
|
||||||
|
setDevices(prev => prev.map(row =>
|
||||||
|
row.device.id === deviceId
|
||||||
|
? { ...row, status: 'updating', progress: percent, progressStage: stage }
|
||||||
|
: row
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.zacus.ota.onComplete((deviceId, success, error) => {
|
||||||
|
setDevices(prev => prev.map(row =>
|
||||||
|
row.device.id === deviceId
|
||||||
|
? { ...row, status: success ? 'done' : 'error', progress: 100, error }
|
||||||
|
: row
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check all devices for updates
|
||||||
|
const checkAll = useCallback(async () => {
|
||||||
|
const checked = await Promise.all(
|
||||||
|
devices.map(async row => {
|
||||||
|
setDevices(prev => prev.map(r =>
|
||||||
|
r.device.id === row.device.id ? { ...r, status: 'checking' } : r
|
||||||
|
));
|
||||||
|
const info = await window.zacus.ota.check(row.device.id);
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
currentVersion: info.current,
|
||||||
|
availableVersion: info.available,
|
||||||
|
needsUpdate: info.needsUpdate,
|
||||||
|
status: 'idle' as const,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setDevices(checked);
|
||||||
|
}, [devices]);
|
||||||
|
|
||||||
|
const updateDevice = useCallback(async (row: DeviceRow) => {
|
||||||
|
const fw = firmwares.find(f => f.name.startsWith(row.device.name.toLowerCase().split(' ')[0]));
|
||||||
|
if (!fw) {
|
||||||
|
alert(`No firmware found for ${row.device.name}. Import a .bin first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await window.zacus.ota.update(row.device.id, selectedMethod, fw.path);
|
||||||
|
}, [firmwares, selectedMethod]);
|
||||||
|
|
||||||
|
const updateAll = useCallback(async () => {
|
||||||
|
const pending = devices.filter(r => r.needsUpdate && r.status === 'idle');
|
||||||
|
await Promise.allSettled(pending.map(row => updateDevice(row)));
|
||||||
|
}, [devices, updateDevice]);
|
||||||
|
|
||||||
|
const importFirmware = useCallback(async () => {
|
||||||
|
const path = await window.zacus.file.open([
|
||||||
|
{ name: 'ESP32 Firmware', extensions: ['bin'] },
|
||||||
|
]);
|
||||||
|
if (!path) return;
|
||||||
|
// Firmware import handled by main process via file:open
|
||||||
|
// Display it in the cache list
|
||||||
|
const name = path.split('/').pop() ?? 'firmware.bin';
|
||||||
|
setFirmwares(prev => [...prev, {
|
||||||
|
name,
|
||||||
|
version: 'imported',
|
||||||
|
path,
|
||||||
|
size: 0,
|
||||||
|
builtAt: Date.now(),
|
||||||
|
}]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rollbackDevice = useCallback(async (row: DeviceRow) => {
|
||||||
|
if (!confirm(`Rollback ${row.device.name} to previous firmware?`)) return;
|
||||||
|
const ok = await window.zacus.ota.rollback(row.device.id);
|
||||||
|
if (!ok) alert('Rollback failed');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildFromSource = useCallback(async () => {
|
||||||
|
setBuilding(true);
|
||||||
|
try {
|
||||||
|
await window.zacus.build.run();
|
||||||
|
} finally {
|
||||||
|
setBuilding(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="firmware-manager">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Firmware Manager</h2>
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={selectedMethod}
|
||||||
|
onChange={e => setSelectedMethod(e.target.value as OTAMethod)}
|
||||||
|
>
|
||||||
|
<option value="wifi">WiFi OTA (~30s/MB)</option>
|
||||||
|
<option value="ble">BLE DFU (~2min/MB)</option>
|
||||||
|
<option value="usb">USB Serial (~15s/MB)</option>
|
||||||
|
</select>
|
||||||
|
<button className="btn" onClick={checkAll}>Check Updates</button>
|
||||||
|
<button className="btn primary" onClick={updateAll}>Update All</button>
|
||||||
|
<button className="btn" onClick={importFirmware}>Import .bin</button>
|
||||||
|
<button className="btn" onClick={buildFromSource} disabled={building}>
|
||||||
|
{building ? 'Building...' : 'Build from Source'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update Matrix */}
|
||||||
|
<table className="update-matrix">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Connection</th>
|
||||||
|
<th>Current</th>
|
||||||
|
<th>Available</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{devices.map(row => (
|
||||||
|
<tr key={row.device.id} className={row.needsUpdate ? 'needs-update' : ''}>
|
||||||
|
<td>{row.device.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge">{row.device.connectionType.toUpperCase()}</span>
|
||||||
|
</td>
|
||||||
|
<td className="mono">{row.currentVersion}</td>
|
||||||
|
<td className="mono">
|
||||||
|
{row.availableVersion}
|
||||||
|
{row.needsUpdate && <span className="badge warning">UPDATE</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{row.status === 'updating' ? (
|
||||||
|
<div className="progress-container">
|
||||||
|
<div
|
||||||
|
className="progress-bar"
|
||||||
|
style={{ width: `${row.progress}%` }}
|
||||||
|
/>
|
||||||
|
<span className="progress-text">
|
||||||
|
{row.progressStage} {row.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={`status-badge ${row.status}`}>{row.status}</span>
|
||||||
|
)}
|
||||||
|
{row.error && <div className="error-text">{row.error}</div>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn small primary"
|
||||||
|
onClick={() => updateDevice(row)}
|
||||||
|
disabled={row.status === 'updating' || !row.needsUpdate}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn small"
|
||||||
|
onClick={() => rollbackDevice(row)}
|
||||||
|
disabled={row.status === 'updating'}
|
||||||
|
>
|
||||||
|
Rollback
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{devices.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="empty-state">
|
||||||
|
No devices connected. Scan for devices in the Dashboard tab.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Firmware Cache */}
|
||||||
|
<div className="firmware-cache">
|
||||||
|
<h3>Firmware Cache</h3>
|
||||||
|
{firmwares.length === 0 ? (
|
||||||
|
<div className="empty-state">No firmwares cached. Build from source or import a .bin.</div>
|
||||||
|
) : (
|
||||||
|
firmwares.map(fw => (
|
||||||
|
<div key={fw.path} className="firmware-entry">
|
||||||
|
<span className="fw-name">{fw.name}</span>
|
||||||
|
<span className="fw-version mono">v{fw.version}</span>
|
||||||
|
<span className="fw-size">{(fw.size / 1024).toFixed(0)} KB</span>
|
||||||
|
<span className="fw-date">{new Date(fw.builtAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface LogEvent {
|
||||||
|
timestamp: number;
|
||||||
|
source: 'serial' | 'ws' | 'npc' | 'puzzle' | 'system';
|
||||||
|
device?: string;
|
||||||
|
type: string;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_COLORS: Record<LogEvent['source'], string> = {
|
||||||
|
serial: '#60a5fa',
|
||||||
|
ws: '#34d399',
|
||||||
|
npc: '#a78bfa',
|
||||||
|
puzzle: '#f59e0b',
|
||||||
|
system: '#94a3b8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogRecorder(): React.JSX.Element {
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [events, setEvents] = useState<LogEvent[]>([]);
|
||||||
|
const [filterSource, setFilterSource] = useState<'all' | LogEvent['source']>('all');
|
||||||
|
const sessionRef = useRef<LogEvent[]>([]);
|
||||||
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const record = useCallback((event: LogEvent) => {
|
||||||
|
sessionRef.current.push(event);
|
||||||
|
setEvents(prev => [...prev, event]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recording) return;
|
||||||
|
|
||||||
|
window.zacus.serial.onData((port, data) => {
|
||||||
|
record({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'serial',
|
||||||
|
device: port,
|
||||||
|
type: 'serial_data',
|
||||||
|
payload: data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.zacus.wifi.onWsMessage(data => {
|
||||||
|
try {
|
||||||
|
record({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'ws',
|
||||||
|
type: 'ws_message',
|
||||||
|
payload: JSON.parse(data),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
record({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'ws',
|
||||||
|
type: 'ws_raw',
|
||||||
|
payload: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [recording, record]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new events
|
||||||
|
useEffect(() => {
|
||||||
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const toggleRecording = useCallback(() => {
|
||||||
|
if (!recording) {
|
||||||
|
// Starting a new recording — clear previous session
|
||||||
|
sessionRef.current = [];
|
||||||
|
setEvents([]);
|
||||||
|
}
|
||||||
|
setRecording(r => !r);
|
||||||
|
}, [recording]);
|
||||||
|
|
||||||
|
const exportJSON = useCallback(async () => {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
const json = JSON.stringify(
|
||||||
|
{
|
||||||
|
session: {
|
||||||
|
startedAt: events[0]?.timestamp,
|
||||||
|
endedAt: events[events.length - 1]?.timestamp,
|
||||||
|
eventCount: events.length,
|
||||||
|
events,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
await window.zacus.file.save(json, `zacus_session_${Date.now()}.json`);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const exportMarkdown = useCallback(async () => {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
const lines = [
|
||||||
|
'# Zacus Session Log',
|
||||||
|
`Generated: ${new Date().toISOString()}`,
|
||||||
|
`Events: ${events.length}`,
|
||||||
|
'',
|
||||||
|
'## Timeline',
|
||||||
|
'',
|
||||||
|
...events.map(e => {
|
||||||
|
const time = new Date(e.timestamp).toISOString().slice(11, 23);
|
||||||
|
const dev = e.device ? ` (${e.device.split('/').pop()})` : '';
|
||||||
|
const pl = JSON.stringify(e.payload).slice(0, 80);
|
||||||
|
return `- **${time}** [${e.source}${dev}] \`${e.type}\`: \`${pl}\``;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
await window.zacus.file.save(lines.join('\n'), `zacus_session_${Date.now()}.md`);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const visibleEvents = filterSource === 'all'
|
||||||
|
? events
|
||||||
|
: events.filter(e => e.source === filterSource);
|
||||||
|
|
||||||
|
const sources = ['serial', 'ws', 'npc', 'puzzle', 'system'] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="log-recorder">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Log Recorder</h2>
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button
|
||||||
|
className={`btn ${recording ? 'error' : 'primary'}`}
|
||||||
|
onClick={toggleRecording}
|
||||||
|
>
|
||||||
|
{recording ? '⏹ Stop' : '● Record'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={exportJSON}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
>
|
||||||
|
Export JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={exportMarkdown}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
>
|
||||||
|
Export Markdown
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => { setEvents([]); sessionRef.current = []; }}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recording && (
|
||||||
|
<div className="recording-indicator">
|
||||||
|
<span className="rec-dot" /> Recording — {events.length} events captured
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="source-filter">
|
||||||
|
<button
|
||||||
|
className={`btn small ${filterSource === 'all' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilterSource('all')}
|
||||||
|
>
|
||||||
|
All ({events.length})
|
||||||
|
</button>
|
||||||
|
{sources.map(src => {
|
||||||
|
const count = events.filter(e => e.source === src).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={src}
|
||||||
|
className={`btn small ${filterSource === src ? 'active' : ''}`}
|
||||||
|
style={{ color: SOURCE_COLORS[src] }}
|
||||||
|
onClick={() => setFilterSource(src)}
|
||||||
|
>
|
||||||
|
{src} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="event-log">
|
||||||
|
{visibleEvents.slice(-200).map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`event-row event-${e.source}`}
|
||||||
|
style={{ borderLeftColor: SOURCE_COLORS[e.source] }}
|
||||||
|
>
|
||||||
|
<span className="event-time">
|
||||||
|
{new Date(e.timestamp).toISOString().slice(11, 23)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="event-source"
|
||||||
|
style={{ color: SOURCE_COLORS[e.source] }}
|
||||||
|
>
|
||||||
|
[{e.source}]
|
||||||
|
</span>
|
||||||
|
{e.device && (
|
||||||
|
<span className="event-device">
|
||||||
|
{e.device.split('/').pop()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="event-type">{e.type}</span>
|
||||||
|
<span className="event-payload">
|
||||||
|
{JSON.stringify(e.payload).slice(0, 120)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 && !recording && (
|
||||||
|
<div className="empty-state">
|
||||||
|
Press Record to start capturing serial and WebSocket events.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface MeshNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rssi: number;
|
||||||
|
latencyMs: number;
|
||||||
|
packetLoss: number;
|
||||||
|
lastSeen: number;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeshTopologyMessage {
|
||||||
|
type: string;
|
||||||
|
nodes: Array<{
|
||||||
|
name: string;
|
||||||
|
rssi: number;
|
||||||
|
latency_ms: number;
|
||||||
|
packet_loss: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUZZLE_NODES = ['BOX-3', 'P1 Son', 'P2', 'P4 Radio', 'P5 Morse', 'P6 NFC', 'P7 Coffre'];
|
||||||
|
|
||||||
|
function rssiColor(rssi: number): string {
|
||||||
|
if (rssi > -60) return '#22c55e'; // strong — green
|
||||||
|
if (rssi > -75) return '#f59e0b'; // medium — amber
|
||||||
|
return '#ef4444'; // weak — red
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeshDiagnostic(): React.JSX.Element {
|
||||||
|
const [nodes, setNodes] = useState<MeshNode[]>(
|
||||||
|
PUZZLE_NODES.map((name, i) => ({
|
||||||
|
id: `node-${i}`,
|
||||||
|
name,
|
||||||
|
rssi: 0,
|
||||||
|
latencyMs: 0,
|
||||||
|
packetLoss: 0,
|
||||||
|
lastSeen: 0,
|
||||||
|
online: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
// Listen for mesh topology messages from BOX-3 WebSocket
|
||||||
|
useEffect(() => {
|
||||||
|
window.zacus.wifi.onWsMessage(rawData => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(rawData) as MeshTopologyMessage;
|
||||||
|
if (msg.type === 'mesh_topology') {
|
||||||
|
setNodes(prev => prev.map(node => {
|
||||||
|
const update = msg.nodes.find(n => n.name === node.name);
|
||||||
|
if (!update) return node;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
rssi: update.rssi,
|
||||||
|
latencyMs: update.latency_ms,
|
||||||
|
packetLoss: update.packet_loss,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
online: true,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch { /* not a JSON message */ }
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Draw mesh topology on canvas
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const W = canvas.width;
|
||||||
|
const H = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// BOX-3 at center top
|
||||||
|
const masterX = W / 2;
|
||||||
|
const masterY = 60;
|
||||||
|
|
||||||
|
// Puzzle nodes in a row below (skip index 0 = BOX-3)
|
||||||
|
const puzzleNodes = nodes.slice(1);
|
||||||
|
const spacing = W / (puzzleNodes.length + 1);
|
||||||
|
|
||||||
|
ctx.font = '11px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
puzzleNodes.forEach((node, i) => {
|
||||||
|
const x = spacing * (i + 1);
|
||||||
|
const y = H - 70;
|
||||||
|
|
||||||
|
// Connection line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(masterX, masterY + 24);
|
||||||
|
ctx.lineTo(x, y - 18);
|
||||||
|
ctx.strokeStyle = node.online ? rssiColor(node.rssi) : '#333';
|
||||||
|
ctx.lineWidth = node.online ? 2 : 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// RSSI label at line midpoint
|
||||||
|
if (node.online) {
|
||||||
|
ctx.fillStyle = '#94a3b8';
|
||||||
|
ctx.fillText(
|
||||||
|
`${node.rssi}dB`,
|
||||||
|
(masterX + x) / 2,
|
||||||
|
(masterY + 24 + y - 18) / 2 - 6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node circle
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 18, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = node.online ? rssiColor(node.rssi) : '#374151';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#1f2937';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Node name label
|
||||||
|
ctx.fillStyle = '#e2e8f0';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.fillText(node.name.slice(0, 6), x, y + 34);
|
||||||
|
|
||||||
|
if (node.online) {
|
||||||
|
ctx.fillStyle = '#94a3b8';
|
||||||
|
ctx.fillText(`${node.latencyMs}ms`, x, y + 46);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// BOX-3 master node
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(masterX, masterY, 24, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#7c3aed';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#4c1d95';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#e2e8f0';
|
||||||
|
ctx.font = 'bold 11px monospace';
|
||||||
|
ctx.fillText('BOX-3', masterX, masterY + 4);
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
useEffect(() => { draw(); }, [draw]);
|
||||||
|
|
||||||
|
const onlineCount = nodes.filter(n => n.online).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mesh-diagnostic">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Mesh Diagnostic</h2>
|
||||||
|
<span className="text-dim">
|
||||||
|
ESP-NOW via BOX-3 WebSocket — {onlineCount}/{nodes.length} online
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={700}
|
||||||
|
height={280}
|
||||||
|
className="mesh-canvas"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<table className="mesh-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>RSSI</th>
|
||||||
|
<th>Latency</th>
|
||||||
|
<th>Loss %</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{nodes.map(node => (
|
||||||
|
<tr key={node.id}>
|
||||||
|
<td>{node.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-dot ${node.online ? 'online' : 'offline'}`} />
|
||||||
|
{node.online ? 'Online' : 'Offline'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="mono"
|
||||||
|
style={{ color: node.online ? rssiColor(node.rssi) : undefined }}
|
||||||
|
>
|
||||||
|
{node.online ? `${node.rssi} dBm` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="mono">
|
||||||
|
{node.online ? `${node.latencyMs} ms` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className={node.online && node.packetLoss > 5 ? 'warn mono' : 'mono'}>
|
||||||
|
{node.online ? `${node.packetLoss.toFixed(1)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="text-dim">
|
||||||
|
{node.lastSeen
|
||||||
|
? new Date(node.lastSeen).toLocaleTimeString()
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface NVSSetting {
|
||||||
|
key: string;
|
||||||
|
type: 'string' | 'uint8' | 'float' | 'blob';
|
||||||
|
value: string;
|
||||||
|
puzzle: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: NVSSetting[] = [
|
||||||
|
{ key: 'wifi_ssid', type: 'string', value: 'ZacusNet', puzzle: 'all', description: 'WiFi network SSID' },
|
||||||
|
{ key: 'wifi_pass', type: 'string', value: '', puzzle: 'all', description: 'WiFi password' },
|
||||||
|
{ key: 'gm_ip', type: 'string', value: '192.168.4.1', puzzle: 'all', description: 'Game Master IP address' },
|
||||||
|
{ key: 'code_digits', type: 'blob', value: '1 4', puzzle: 'all', description: 'Code digits (space-separated)' },
|
||||||
|
{ key: 'morse_msg', type: 'string', value: 'ZACUS', puzzle: 'p5', description: 'Morse code message' },
|
||||||
|
{ key: 'rf_freq', type: 'float', value: '107.5', puzzle: 'p4', description: 'Target FM frequency (MHz)' },
|
||||||
|
{ key: 'nfc_uid_0', type: 'string', value: '', puzzle: 'p6', description: 'NFC UID #1 (hex)' },
|
||||||
|
{ key: 'nfc_uid_1', type: 'string', value: '', puzzle: 'p6', description: 'NFC UID #2 (hex)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PUZZLE_FILTERS = ['all', 'p4', 'p5', 'p6'];
|
||||||
|
|
||||||
|
export function NvsConfigurator(): React.JSX.Element {
|
||||||
|
const [settings, setSettings] = useState<NVSSetting[]>(DEFAULT_SETTINGS);
|
||||||
|
const [targetPort, setTargetPort] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [puzzleFilter, setPuzzleFilter] = useState('all');
|
||||||
|
const [isWriting, setIsWriting] = useState(false);
|
||||||
|
|
||||||
|
const updateSetting = useCallback((key: string, value: string) => {
|
||||||
|
setSettings(prev => prev.map(s => s.key === key ? { ...s, value } : s));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const writeAll = useCallback(async () => {
|
||||||
|
if (!targetPort) {
|
||||||
|
setStatus('Select a device port first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsWriting(true);
|
||||||
|
setStatus('Writing NVS settings…');
|
||||||
|
let ok = 0;
|
||||||
|
|
||||||
|
for (const setting of settings) {
|
||||||
|
// Send "nvs set <key> <value>\n" via serial
|
||||||
|
const cmd = `nvs set ${setting.key} ${setting.value}\n`;
|
||||||
|
await window.zacus.serial.write(targetPort, cmd);
|
||||||
|
await delay(200); // Wait for ESP32 to process
|
||||||
|
ok++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsWriting(false);
|
||||||
|
setStatus(`Done: ${ok}/${settings.length} settings written`);
|
||||||
|
}, [settings, targetPort]);
|
||||||
|
|
||||||
|
const readAll = useCallback(async () => {
|
||||||
|
if (!targetPort) {
|
||||||
|
setStatus('Select a device port first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('Reading NVS…');
|
||||||
|
await window.zacus.serial.write(targetPort, 'nvs dump\n');
|
||||||
|
setStatus('Sent nvs dump — check Serial Monitor for output');
|
||||||
|
}, [targetPort]);
|
||||||
|
|
||||||
|
const resetToDefaults = useCallback(() => {
|
||||||
|
setSettings(DEFAULT_SETTINGS);
|
||||||
|
setStatus('Reset to defaults');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredSettings = puzzleFilter === 'all'
|
||||||
|
? settings
|
||||||
|
: settings.filter(s => s.puzzle === puzzleFilter || s.puzzle === 'all');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="nvs-configurator">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>NVS Configurator</h2>
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
placeholder="/dev/cu.usbmodem…"
|
||||||
|
value={targetPort}
|
||||||
|
onChange={e => setTargetPort(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn" onClick={readAll} disabled={!targetPort}>
|
||||||
|
Read
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
onClick={writeAll}
|
||||||
|
disabled={!targetPort || isWriting}
|
||||||
|
>
|
||||||
|
{isWriting ? 'Writing…' : 'Write All'}
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={resetToDefaults}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<div className={`status-bar ${status.startsWith('Done') ? 'success' : ''}`}>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="puzzle-filter">
|
||||||
|
{PUZZLE_FILTERS.map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
className={`btn small ${puzzleFilter === f ? 'active' : ''}`}
|
||||||
|
onClick={() => setPuzzleFilter(f)}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'All puzzles' : f.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="nvs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Puzzle</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredSettings.map(s => (
|
||||||
|
<tr key={s.key}>
|
||||||
|
<td className="mono">{s.key}</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge">{s.type}</span>
|
||||||
|
</td>
|
||||||
|
<td>{s.puzzle}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type={s.key.includes('pass') ? 'password' : 'text'}
|
||||||
|
value={s.value}
|
||||||
|
onChange={e => updateSetting(s.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="text-dim">{s.description}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
port: string;
|
||||||
|
timestamp: number;
|
||||||
|
text: string;
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<LogEntry['level'], string> = {
|
||||||
|
error: '#ef4444',
|
||||||
|
warn: '#f59e0b',
|
||||||
|
info: '#94a3b8',
|
||||||
|
debug: '#475569',
|
||||||
|
};
|
||||||
|
|
||||||
|
function classifyLine(text: string): LogEntry['level'] {
|
||||||
|
if (/error|fail|crash/i.test(text)) return 'error';
|
||||||
|
if (/warn|caution/i.test(text)) return 'warn';
|
||||||
|
if (/debug|verbose/i.test(text)) return 'debug';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SerialMonitor(): React.JSX.Element {
|
||||||
|
const [ports, setPorts] = useState<string[]>([]);
|
||||||
|
const [connectedPorts, setConnectedPorts] = useState<Set<string>>(new Set());
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [filterPort, setFilterPort] = useState<string>('all');
|
||||||
|
const [filterText, setFilterText] = useState('');
|
||||||
|
const [filterLevel, setFilterLevel] = useState<'all' | LogEntry['level']>('all');
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [sendText, setSendText] = useState('');
|
||||||
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load port list on mount
|
||||||
|
useEffect(() => {
|
||||||
|
window.zacus.serial.list().then(serialPorts => {
|
||||||
|
setPorts(serialPorts.map(p => p.path));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for incoming serial data and plug/unplug events
|
||||||
|
useEffect(() => {
|
||||||
|
window.zacus.serial.onData((port, data) => {
|
||||||
|
if (paused) return;
|
||||||
|
const lines = data.split('\n').filter(Boolean);
|
||||||
|
setLogs(prev => [
|
||||||
|
...prev.slice(-2000), // keep last 2000 lines
|
||||||
|
...lines.map(text => ({
|
||||||
|
port,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
text: text.trim(),
|
||||||
|
level: classifyLine(text),
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.zacus.serial.onPlugged(port => {
|
||||||
|
setPorts(prev => [...new Set([...prev, port])]);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.zacus.serial.onUnplugged(port => {
|
||||||
|
setConnectedPorts(prev => {
|
||||||
|
const s = new Set(prev);
|
||||||
|
s.delete(port);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [paused]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new logs arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paused) {
|
||||||
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs, paused]);
|
||||||
|
|
||||||
|
const connect = useCallback(async (port: string) => {
|
||||||
|
const ok = await window.zacus.serial.connect(port, 115200);
|
||||||
|
if (ok) setConnectedPorts(prev => new Set([...prev, port]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnect = useCallback(async (port: string) => {
|
||||||
|
await window.zacus.serial.disconnect(port);
|
||||||
|
setConnectedPorts(prev => {
|
||||||
|
const s = new Set(prev);
|
||||||
|
s.delete(port);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendCommand = useCallback(async () => {
|
||||||
|
if (!sendText.trim()) return;
|
||||||
|
// Send to all connected ports or the filtered port
|
||||||
|
const targets = filterPort !== 'all'
|
||||||
|
? [filterPort]
|
||||||
|
: [...connectedPorts];
|
||||||
|
for (const port of targets) {
|
||||||
|
await window.zacus.serial.write(port, sendText + '\n');
|
||||||
|
}
|
||||||
|
setSendText('');
|
||||||
|
}, [sendText, filterPort, connectedPorts]);
|
||||||
|
|
||||||
|
const visibleLogs = logs.filter(e => {
|
||||||
|
if (filterPort !== 'all' && e.port !== filterPort) return false;
|
||||||
|
if (filterLevel !== 'all' && e.level !== filterLevel) return false;
|
||||||
|
if (filterText && !e.text.toLowerCase().includes(filterText.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportLogs = useCallback(() => {
|
||||||
|
const text = visibleLogs
|
||||||
|
.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.port}] ${e.text}`)
|
||||||
|
.join('\n');
|
||||||
|
window.zacus.file.save(text, 'serial_log.txt');
|
||||||
|
}, [visibleLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="serial-monitor">
|
||||||
|
<div className="serial-toolbar">
|
||||||
|
<select
|
||||||
|
value={filterPort}
|
||||||
|
onChange={e => setFilterPort(e.target.value)}
|
||||||
|
className="select"
|
||||||
|
>
|
||||||
|
<option value="all">All ports</option>
|
||||||
|
{ports.map(p => (
|
||||||
|
<option key={p} value={p}>{p.split('/').pop()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterLevel}
|
||||||
|
onChange={e => setFilterLevel(e.target.value as 'all' | LogEntry['level'])}
|
||||||
|
className="select"
|
||||||
|
>
|
||||||
|
<option value="all">All levels</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="warn">Warn</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="debug">Debug</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Filter text…"
|
||||||
|
value={filterText}
|
||||||
|
onChange={e => setFilterText(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button className="btn" onClick={() => setPaused(p => !p)}>
|
||||||
|
{paused ? '▶ Resume' : '⏸ Pause'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="btn" onClick={() => setLogs([])}>Clear</button>
|
||||||
|
<button className="btn" onClick={exportLogs} disabled={visibleLogs.length === 0}>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="port-list">
|
||||||
|
{ports.map(port => (
|
||||||
|
<button
|
||||||
|
key={port}
|
||||||
|
className={`btn small ${connectedPorts.has(port) ? 'active' : ''}`}
|
||||||
|
onClick={() => connectedPorts.has(port) ? disconnect(port) : connect(port)}
|
||||||
|
>
|
||||||
|
{connectedPorts.has(port) ? '● ' : '○ '}
|
||||||
|
{port.split('/').pop()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="log-output">
|
||||||
|
{visibleLogs.map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="log-line"
|
||||||
|
style={{ color: LEVEL_COLORS[entry.level] }}
|
||||||
|
>
|
||||||
|
<span className="log-time">
|
||||||
|
{new Date(entry.timestamp).toISOString().slice(11, 23)}
|
||||||
|
</span>
|
||||||
|
<span className="log-port">[{entry.port.split('/').pop()}]</span>
|
||||||
|
<span className="log-text">{entry.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="serial-send-bar">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
placeholder="Send command…"
|
||||||
|
value={sendText}
|
||||||
|
onChange={e => setSendText(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') sendCommand(); }}
|
||||||
|
disabled={connectedPorts.size === 0}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
onClick={sendCommand}
|
||||||
|
disabled={connectedPorts.size === 0 || !sendText.trim()}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+83
@@ -0,0 +1,83 @@
|
|||||||
|
// Type declarations for the contextBridge API exposed by the preload script
|
||||||
|
|
||||||
|
interface SerialPort {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
vendorId?: string;
|
||||||
|
productId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZacusDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'puzzle' | 'hub';
|
||||||
|
firmwareVersion: string;
|
||||||
|
batteryPct: number;
|
||||||
|
connectionType: 'usb' | 'ble' | 'wifi';
|
||||||
|
lastSeen: number;
|
||||||
|
ip?: string;
|
||||||
|
mac?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OTAProgressEvent {
|
||||||
|
deviceId: string;
|
||||||
|
percent: number;
|
||||||
|
stage: 'uploading' | 'verifying' | 'rebooting';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZacusAPI {
|
||||||
|
serial: {
|
||||||
|
list: () => Promise<SerialPort[]>;
|
||||||
|
connect: (port: string, baud: number) => Promise<boolean>;
|
||||||
|
disconnect: (port: string) => Promise<void>;
|
||||||
|
write: (port: string, data: string) => Promise<void>;
|
||||||
|
flash: (port: string, firmwarePath: string) => Promise<void>;
|
||||||
|
onData: (callback: (port: string, data: string) => void) => void;
|
||||||
|
onPlugged: (callback: (port: string) => void) => void;
|
||||||
|
onUnplugged: (callback: (port: string) => void) => void;
|
||||||
|
};
|
||||||
|
ble: {
|
||||||
|
scan: () => Promise<void>;
|
||||||
|
stopScan: () => Promise<void>;
|
||||||
|
connect: (deviceId: string) => Promise<boolean>;
|
||||||
|
disconnect: (deviceId: string) => Promise<void>;
|
||||||
|
write: (deviceId: string, characteristic: string, data: string) => Promise<void>;
|
||||||
|
dfu: (deviceId: string, firmwarePath: string) => Promise<void>;
|
||||||
|
onDiscovered: (callback: (device: ZacusDevice) => void) => void;
|
||||||
|
onData: (callback: (deviceId: string, characteristic: string, data: string) => void) => void;
|
||||||
|
};
|
||||||
|
wifi: {
|
||||||
|
discover: () => Promise<ZacusDevice[]>;
|
||||||
|
wsConnect: (url: string) => Promise<boolean>;
|
||||||
|
wsSend: (data: string) => Promise<void>;
|
||||||
|
wsDisconnect: () => Promise<void>;
|
||||||
|
http: (url: string, method: string, body?: string, headers?: Record<string, string>) => Promise<{ status: number; data: string }>;
|
||||||
|
onWsMessage: (callback: (data: string) => void) => void;
|
||||||
|
onServiceFound: (callback: (service: ZacusDevice) => void) => void;
|
||||||
|
};
|
||||||
|
ota: {
|
||||||
|
check: (deviceId: string) => Promise<{ current: string; available: string; needsUpdate: boolean }>;
|
||||||
|
update: (deviceId: string, method: 'wifi' | 'ble' | 'usb', firmwarePath: string) => Promise<void>;
|
||||||
|
rollback: (deviceId: string) => Promise<boolean>;
|
||||||
|
onProgress: (callback: (event: OTAProgressEvent) => void) => void;
|
||||||
|
onComplete: (callback: (deviceId: string, success: boolean, error?: string) => void) => void;
|
||||||
|
};
|
||||||
|
file: {
|
||||||
|
open: (filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||||
|
save: (data: string, defaultPath?: string) => Promise<string | null>;
|
||||||
|
recent: () => Promise<string[]>;
|
||||||
|
addRecent: (filePath: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
menu: {
|
||||||
|
on: (event: string, callback: (...args: unknown[]) => void) => void;
|
||||||
|
};
|
||||||
|
notify: (title: string, body: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
zacus: ZacusAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" />
|
||||||
|
<title>Zacus Studio</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-top {
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-btn:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 52px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bottom {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
box-shadow: 0 0 6px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0f0f1a;
|
||||||
|
--bg-secondary: #1a1a2e;
|
||||||
|
--bg-tertiary: #16213e;
|
||||||
|
--accent: #7c3aed;
|
||||||
|
--accent-hover: #6d28d9;
|
||||||
|
--accent-dim: rgba(124, 58, 237, 0.2);
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-dim: #475569;
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--error: #ef4444;
|
||||||
|
--sidebar-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* macOS traffic light area */
|
||||||
|
.titlebar-drag {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-dim);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const DASHBOARD_URL = import.meta.env.DEV
|
||||||
|
? 'http://localhost:5175'
|
||||||
|
: './apps/dashboard/index.html';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onDeviceStatusChange: (status: 'connected' | 'partial' | 'disconnected') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardTab({ onDeviceStatusChange }: Props): React.JSX.Element {
|
||||||
|
const webviewRef = useRef<Electron.WebviewTag>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wv = webviewRef.current;
|
||||||
|
if (!wv) return;
|
||||||
|
|
||||||
|
// Listen for device status messages from dashboard app
|
||||||
|
const handleIpcMessage = (e: { channel: string; args: unknown[] }) => {
|
||||||
|
if (e.channel === 'device-status') {
|
||||||
|
onDeviceStatusChange(e.args[0] as 'connected' | 'partial' | 'disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wv.addEventListener('ipc-message', handleIpcMessage as EventListener);
|
||||||
|
return () => wv.removeEventListener('ipc-message', handleIpcMessage as EventListener);
|
||||||
|
}, [onDeviceStatusChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: '100%' }}>
|
||||||
|
<webview
|
||||||
|
ref={webviewRef}
|
||||||
|
src={DASHBOARD_URL}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
allowpopups={'false' as unknown as boolean}
|
||||||
|
nodeintegration={'false' as unknown as boolean}
|
||||||
|
partition="persist:dashboard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.devtools-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devtools-nav {
|
||||||
|
width: 110px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devtools-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devtools-nav-btn:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.devtools-nav-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devtools-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { DeviceManager } from '../devtools/DeviceManager';
|
||||||
|
import { SerialMonitor } from '../devtools/SerialMonitor';
|
||||||
|
import { FirmwareManager } from '../devtools/FirmwareManager';
|
||||||
|
import { NvsConfigurator } from '../devtools/NvsConfigurator';
|
||||||
|
import { MeshDiagnostic } from '../devtools/MeshDiagnostic';
|
||||||
|
import { BatteryDashboard } from '../devtools/BatteryDashboard';
|
||||||
|
import { LogRecorder } from '../devtools/LogRecorder';
|
||||||
|
import './DevToolsTab.css';
|
||||||
|
|
||||||
|
type DevPanel =
|
||||||
|
| 'devices'
|
||||||
|
| 'serial'
|
||||||
|
| 'firmware'
|
||||||
|
| 'nvs'
|
||||||
|
| 'mesh'
|
||||||
|
| 'battery'
|
||||||
|
| 'logs';
|
||||||
|
|
||||||
|
const PANELS: Array<{ id: DevPanel; label: string; icon: string }> = [
|
||||||
|
{ id: 'devices', label: 'Devices', icon: 'D' },
|
||||||
|
{ id: 'serial', label: 'Serial', icon: 'S' },
|
||||||
|
{ id: 'firmware', label: 'Firmware', icon: 'F' },
|
||||||
|
{ id: 'nvs', label: 'NVS', icon: 'N' },
|
||||||
|
{ id: 'mesh', label: 'Mesh', icon: 'M' },
|
||||||
|
{ id: 'battery', label: 'Battery', icon: 'B' },
|
||||||
|
{ id: 'logs', label: 'Logs', icon: 'L' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DevToolsTab(): React.JSX.Element {
|
||||||
|
const [activePanel, setActivePanel] = useState<DevPanel>('devices');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="devtools-layout">
|
||||||
|
<nav className="devtools-nav">
|
||||||
|
{PANELS.map(panel => (
|
||||||
|
<button
|
||||||
|
key={panel.id}
|
||||||
|
className={`devtools-nav-btn ${activePanel === panel.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setActivePanel(panel.id)}
|
||||||
|
>
|
||||||
|
<span>{panel.icon}</span>
|
||||||
|
<span>{panel.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="devtools-content">
|
||||||
|
{activePanel === 'devices' && <DeviceManager />}
|
||||||
|
{activePanel === 'serial' && <SerialMonitor />}
|
||||||
|
{activePanel === 'firmware' && <FirmwareManager />}
|
||||||
|
{activePanel === 'nvs' && <NvsConfigurator />}
|
||||||
|
{activePanel === 'mesh' && <MeshDiagnostic />}
|
||||||
|
{activePanel === 'battery' && <BatteryDashboard />}
|
||||||
|
{activePanel === 'logs' && <LogRecorder />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
// EditorTab loads the frontend-v3 editor app in a webview.
|
||||||
|
// In development, it points to the Vite dev server.
|
||||||
|
// In production, it loads the built app from the renderer bundle.
|
||||||
|
const EDITOR_URL = import.meta.env.DEV
|
||||||
|
? 'http://localhost:5174' // frontend-v3/apps/editor dev server
|
||||||
|
: './apps/editor/index.html';
|
||||||
|
|
||||||
|
export function EditorTab(): React.JSX.Element {
|
||||||
|
const webviewRef = useRef<Electron.WebviewTag>(null);
|
||||||
|
|
||||||
|
// Forward menu events into the webview
|
||||||
|
useEffect(() => {
|
||||||
|
const menuEvents = ['save', 'open', 'compile', 'validate', 'export-sd'];
|
||||||
|
menuEvents.forEach(evt => {
|
||||||
|
window.zacus.menu.on(evt, () => {
|
||||||
|
webviewRef.current?.send(`menu:${evt}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNewWindow = useCallback((e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: '100%' }}>
|
||||||
|
<webview
|
||||||
|
ref={webviewRef}
|
||||||
|
src={EDITOR_URL}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
allowpopups={'false' as unknown as boolean}
|
||||||
|
onNewWindow={handleNewWindow as unknown as React.ReactEventHandler}
|
||||||
|
nodeintegration={'false' as unknown as boolean}
|
||||||
|
partition="persist:editor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SIMULATION_URL = import.meta.env.DEV
|
||||||
|
? 'http://localhost:5176'
|
||||||
|
: './apps/simulation/index.html';
|
||||||
|
|
||||||
|
export function SimulationTab(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: '100%' }}>
|
||||||
|
<webview
|
||||||
|
src={SIMULATION_URL}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
allowpopups={'false' as unknown as boolean}
|
||||||
|
nodeintegration={'false' as unknown as boolean}
|
||||||
|
partition="persist:simulation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "dist/main",
|
||||||
|
"rootDir": "src/main",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/main/**/*", "src/preload/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"outDir": "dist/renderer",
|
||||||
|
"rootDir": "src/renderer",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/renderer/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: 'src/renderer',
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, 'dist/renderer'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: resolve(__dirname, 'src/renderer/index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user