Compare commits

..

No commits in common. "c326e228a1d60f1dce5666e76bc5bdeba1901fd0" and "8a6a1e40b449752cc84718d43d5775741efc9317" have entirely different histories.

110 changed files with 74 additions and 14433 deletions

View File

@ -1,145 +0,0 @@
name: Build Installers
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
run: npm install -g pnpm
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
run: cd frontend && pnpm install --frozen-lockfile
- name: Build frontend
run: make build-frontend build-embed
- name: Build server (tray-enabled)
run: make build-server-tray
- name: Stage installer payload
run: make installer-payload
- name: Build macOS installer
run: |
cd installer && wails build -clean
codesign --force --deep --sign - build/bin/EdgeAI-Installer.app
- name: Package macOS installer
run: |
cd installer/build/bin
ditto -c -k --sequesterRsrc --keepParent EdgeAI-Installer.app EdgeAI-Installer-macOS.zip
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
with:
name: EdgeAI-Installer-macOS
path: installer/build/bin/EdgeAI-Installer-macOS.zip
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
run: npm install -g pnpm
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
run: cd frontend && pnpm install --frozen-lockfile
- name: Build frontend
run: |
cd frontend && pnpm build
xcopy /E /I /Y out ..\server\web\out
- name: Build server (no tray on Windows CI)
run: |
cd server
$env:CGO_ENABLED="0"
go build -tags notray -ldflags="-s -w" -o ..\dist\edge-ai-server.exe main.go
- name: Stage installer payload
run: |
Remove-Item -Recurse -Force installer\payload -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path installer\payload\data\nef\kl520
New-Item -ItemType Directory -Force -Path installer\payload\data\nef\kl720
New-Item -ItemType Directory -Force -Path installer\payload\scripts\firmware\KL520
New-Item -ItemType Directory -Force -Path installer\payload\scripts\firmware\KL720
Copy-Item dist\edge-ai-server.exe installer\payload\
Copy-Item server\data\models.json installer\payload\data\
Copy-Item server\data\nef\kl520\*.nef installer\payload\data\nef\kl520\
Copy-Item server\data\nef\kl720\*.nef installer\payload\data\nef\kl720\
Copy-Item server\scripts\kneron_bridge.py installer\payload\scripts\
Copy-Item server\scripts\requirements.txt installer\payload\scripts\
Copy-Item server\scripts\update_kl720_firmware.py installer\payload\scripts\
Copy-Item scripts\kneron_detect.py installer\payload\scripts\
Copy-Item server\scripts\firmware\KL520\*.bin installer\payload\scripts\firmware\KL520\
Copy-Item server\scripts\firmware\KL720\*.bin installer\payload\scripts\firmware\KL720\
- name: Build Windows installer
run: |
cd installer
wails build -clean
- name: Package Windows installer
run: |
Compress-Archive -Path installer\build\bin\EdgeAI-Installer.exe -DestinationPath installer\build\bin\EdgeAI-Installer-Windows.zip
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: EdgeAI-Installer-Windows
path: installer/build/bin/EdgeAI-Installer-Windows.zip
release:
needs: [build-macos, build-windows]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/EdgeAI-Installer-macOS/EdgeAI-Installer-macOS.zip
artifacts/EdgeAI-Installer-Windows/EdgeAI-Installer-Windows.zip
draft: false
prerelease: false
generate_release_notes: true

28
.gitignore vendored
View File

@ -1,28 +0,0 @@
# Build outputs
/dist/
/frontend/out/
/frontend/.next/
# Embedded frontend (copied from frontend/out/ at build time)
/server/web/out/
!/server/web/out/.gitkeep
# Installer payload & build (staged at build time)
/installer/payload/
!/installer/payload/.gitkeep
/installer/build/
/installer/frontend/wailsjs/
/installer/edge-ai-installer
# Test coverage
coverage.out
coverage.html
/frontend/coverage/
# OS
.DS_Store
# IDE
.idea/
.vscode/
*.swp

View File

@ -1,80 +0,0 @@
version: 2
before:
hooks:
- make build-frontend build-embed
builds:
- id: edge-ai-server
dir: server
main: ./main.go
binary: edge-ai-server
env:
- CGO_ENABLED=0
tags:
- notray
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
- goos: linux
goarch: arm64
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.BuildTime={{.Date}}
archives:
- id: default
builds:
- edge-ai-server
format_overrides:
- goos: windows
format: zip
name_template: >-
edge-ai-platform_{{.Version}}_{{.Os}}_{{.Arch}}
files:
- src: server/data/models.json
dst: data/
strip_parent: true
- src: server/scripts/kneron_bridge.py
dst: scripts/
strip_parent: true
- src: server/scripts/requirements.txt
dst: scripts/
strip_parent: true
- src: server/scripts/update_kl720_firmware.py
dst: scripts/
strip_parent: true
- src: scripts/kneron_detect.py
dst: scripts/
strip_parent: true
- src: server/scripts/firmware/KL520/*
dst: firmware/KL520/
strip_parent: true
- src: server/scripts/firmware/KL720/*
dst: firmware/KL720/
strip_parent: true
checksum:
name_template: checksums.txt
algorithm: sha256
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
release:
gitea:
owner: warrenchen
name: web_academy_prototype

169
Makefile
View File

@ -1,169 +0,0 @@
# Edge AI Platform - Makefile
.PHONY: help dev dev-mock build clean test lint mock install fmt build-embed release release-snapshot \
build-server-tray installer-payload installer installer-dev installer-clean deploy-frontend deploy-frontend-setup deploy-ec2 build-relay
VERSION ?= v0.1.0
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
help: ## Show available targets
@echo "Edge AI Platform - Available targets:"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
# ── Development ──────────────────────────────────────────
dev: ## Start frontend + backend with REAL hardware (no mock)
@echo "Starting development servers (real hardware)..."
@$(MAKE) -j2 dev-server dev-frontend
dev-server:
cd server && go run main.go --dev
dev-frontend:
cd frontend && pnpm dev
dev-mock: ## Start frontend + backend with mock devices
@echo "Starting development servers (mock mode)..."
@$(MAKE) -j2 dev-mock-server dev-frontend
dev-mock-server:
cd server && go run main.go --dev --mock --mock-devices=3
mock: ## Start server in mock mode only (no frontend)
cd server && go run main.go --dev --mock --mock-devices=3 --mock-camera
# ── Build ────────────────────────────────────────────────
build: build-frontend build-embed build-server ## Build single binary with embedded frontend
@echo "Build complete! Binary: dist/edge-ai-server"
build-frontend: ## Build Next.js frontend static export
cd frontend && pnpm build
@echo "Frontend built: frontend/out/"
build-embed: ## Copy frontend static export into server for go:embed
@rm -rf server/web/out
@mkdir -p server/web/out
cp -r frontend/out/. server/web/out/
@echo "Frontend static files copied to server/web/out/"
build-server: ## Build Go server binary (embeds frontend)
@mkdir -p dist
cd server && go build \
-ldflags="-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)" \
-o ../dist/edge-ai-server main.go
@echo "Server binary: dist/edge-ai-server"
build-relay: ## Build relay server binary
@mkdir -p dist
cd server && go build -o ../dist/relay-server ./cmd/relay-server
@echo "Relay binary: dist/relay-server"
build-server-tray: build-frontend build-embed ## Build server with tray support (CGO required)
@mkdir -p dist
cd server && CGO_ENABLED=1 go build \
-ldflags="-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)" \
-o ../dist/edge-ai-server main.go
@echo "Server binary (tray-enabled): dist/edge-ai-server"
# ── Release ──────────────────────────────────────────────
release-snapshot: ## Build release archives locally (no publish)
goreleaser release --snapshot --clean
release: ## Build and publish release to Gitea
goreleaser release --clean
# ── Testing ──────────────────────────────────────────────
test: test-server test-frontend ## Run all tests
@echo "All tests passed!"
test-server: ## Run Go tests
cd server && go test -v ./...
test-frontend: ## Run Vitest tests
cd frontend && pnpm test
test-coverage: ## Run tests with coverage reports
cd server && go test -coverprofile=coverage.out ./... && \
go tool cover -html=coverage.out -o coverage.html
cd frontend && pnpm test -- --coverage
# ── Linting ──────────────────────────────────────────────
lint: lint-server lint-frontend ## Lint all code
lint-server: ## Lint Go code
cd server && go vet ./...
lint-frontend: ## Lint frontend code
cd frontend && pnpm lint
fmt: ## Format all code
cd server && go fmt ./...
# ── Dependencies ─────────────────────────────────────────
install: ## Install all dependencies
cd server && go mod download
cd frontend && pnpm install
# ── GUI Installer ────────────────────────────────────────
installer-payload: build-server-tray ## Stage payload files for GUI installer
@echo "Staging installer payload..."
@rm -rf installer/payload
@mkdir -p installer/payload/data/nef/kl520
@mkdir -p installer/payload/data/nef/kl720
@mkdir -p installer/payload/scripts/firmware/KL520
@mkdir -p installer/payload/scripts/firmware/KL720
cp dist/edge-ai-server installer/payload/
cp server/data/models.json installer/payload/data/
cp server/data/nef/kl520/*.nef installer/payload/data/nef/kl520/
cp server/data/nef/kl720/*.nef installer/payload/data/nef/kl720/
cp server/scripts/kneron_bridge.py installer/payload/scripts/
cp server/scripts/requirements.txt installer/payload/scripts/
cp server/scripts/update_kl720_firmware.py installer/payload/scripts/
cp scripts/kneron_detect.py installer/payload/scripts/
cp server/scripts/firmware/KL520/*.bin installer/payload/scripts/firmware/KL520/
cp server/scripts/firmware/KL720/*.bin installer/payload/scripts/firmware/KL720/
@echo "Payload staged in installer/payload/"
installer: installer-payload ## Build GUI installer app
cd installer && wails build -clean
codesign --force --deep --sign - installer/build/bin/EdgeAI-Installer.app
@echo "Installer built and signed! Check installer/build/"
installer-dev: installer-payload ## Run GUI installer in dev mode
cd installer && wails dev
installer-clean: ## Remove installer build artifacts
rm -rf installer/payload
@mkdir -p installer/payload && touch installer/payload/.gitkeep
rm -rf installer/build
@echo "Installer artifacts cleaned!"
# ── Deploy ───────────────────────────────────────────────
deploy-frontend: build-frontend ## Deploy frontend to AWS (CloudFront + S3)
bash scripts/deploy-aws.sh
deploy-frontend-setup: build-frontend ## First-time AWS S3+CloudFront setup + deploy
bash scripts/deploy-aws.sh --setup
deploy-ec2: build-frontend ## Deploy frontend to EC2 (nginx). Usage: make deploy-ec2 HOST=user@ip KEY=~/.ssh/key.pem
bash scripts/deploy-ec2.sh $(HOST) --key $(KEY)
# ── Cleanup ──────────────────────────────────────────────
clean: installer-clean ## Remove build artifacts
rm -rf dist/
rm -rf frontend/.next
rm -rf frontend/out
rm -rf server/web/out
@mkdir -p server/web/out && touch server/web/out/.gitkeep
rm -f server/coverage.out server/coverage.html
@echo "Clean complete!"

173
README.md
View File

@ -1,116 +1,91 @@
# Edge AI Platform
# web_academy_prototype
邊緣 AI 開發平台 — 管理 AI 模型、連接邊緣裝置Kneron KL720/KL730、即時攝影機推論。
## 專案目標
此 repository 的 PoC 主軸是**線上教學平台核心流程**。
核心流程定義請參考 `docs/PRD-Integrated.md`
單一執行檔,下載即可使用。
`local_service_win` 是整體 PoC 其中一個模組,負責本機硬體控制與推論流程驗證
## Quick Start
## PoC 範圍與路線圖
- 主目標:線上教學平台核心流程 PoC。
- Local Service PoC
- Windows已在本 repo`local_service_win/`)。
- Linux規劃中KneronPLUS 已支援)。
- macOS規劃中待 KneronPLUS 支援)。
- 網頁流程 PoC規劃中後續加入相關專案或模組
- `local_agent_win/`:會納入此專案範圍。
### macOS
## 目前已存在模組
- `local_service_win/`
- Python + FastAPI localhost 服務。
- 透過 KneronPLUS`kp`)與 Kneron USB 裝置互動。
- 涵蓋掃描、連線、模型載入、推論流程。
- 預設位址:`http://127.0.0.1:4398`
```bash
# 安裝(下載至 ~/.edge-ai-platform
curl -fsSL https://gitea.innovedus.com/warrenchen/web_academy_prototype/raw/branch/main/scripts/install.sh | bash
目前 Windows local service 資料流:
`Client (Browser/App) -> LocalAPI (127.0.0.1:4398) -> KneronPLUS kp -> KL520/KL720`
# 啟動Mock 模式,不需硬體)
edge-ai-server --mock --mock-devices=3
## 版本相容性(目前觀察)
- 你目前環境使用 Python `3.13` 看起來可運作。
- KneronPLUS 既有生態資訊常見以 Python `3.9` 為主。
- 後續建議補上正式相容矩陣Python / KP 版本)。
# 開啟瀏覽器
open http://127.0.0.1:3721
## 專案結構(目前)
```text
web_academy_prototype/
|- docs/
| `- PRD-Integrated.md
|- local_service_win/
| |- .gitignore
| |- KneronPLUS-3.1.2-py3-none-any.whl
| |- requirements.txt
| |- STRATEGY.md
| |- LocalAPI/
| | |- __init__.py
| | `- main.py
| `- TestRes/
| `- API 測試素材(模型與圖片;圖片已內嵌為 Base64可直接放入推論請求
| |- TEST_PAIRS.md
| |- Images/
| | |- Pic64View.html
| | |- bike_cars_street_224x224.html
| | `- one_bike_many_cars_800x800.html
| `- Models/
| |- models_520.nef
| |- kl520_20004_fcos-drk53s_w512h512.nef
| |- kl520_20005_yolov5-noupsample_w640h640.nef
| |- kl720_20004_fcos-drk53s_w512h512.nef
| `- kl720_20005_yolov5-noupsample_w640h640.nef
`- README.md
```
### Windows (PowerShell)
## Pic64View 工具說明
- 檔案:`local_service_win/TestRes/Images/Pic64View.html`
- 用途:本機快速預覽 Base64 圖片字串,方便測試 `/inference/run``image_base64` 內容是否正確。
- 輸入格式:
- 可直接貼 `data:image/...;base64,...`
- 也可只貼純 Base64工具會自動補上 `data:image/png;base64,` 前綴再渲染。
- 操作:
- `Render`:顯示預覽圖。
- `Clear`:清空輸入與預覽結果。
## 快速開始local_service_win
1. 安裝相依套件:
```powershell
# 安裝
irm https://gitea.innovedus.com/warrenchen/web_academy_prototype/raw/branch/main/scripts/install.ps1 | iex
# 啟動Mock 模式)
edge-ai-server.exe --mock --mock-devices=3
# 開啟瀏覽器
Start-Process http://127.0.0.1:3721
cd local_service_win
python -m pip install -r requirements.txt
```
### 手動下載
從 [Releases](https://gitea.innovedus.com/warrenchen/web_academy_prototype/releases) 下載對應平台的壓縮檔:
| 平台 | 檔案 |
|:-----|:-----|
| macOS Intel | `edge-ai-platform_vX.Y.Z_darwin_amd64.tar.gz` |
| macOS Apple Silicon | `edge-ai-platform_vX.Y.Z_darwin_arm64.tar.gz` |
| Windows x64 | `edge-ai-platform_vX.Y.Z_windows_amd64.zip` |
解壓後執行:
```bash
# macOS
tar xzf edge-ai-platform_*.tar.gz
cd edge-ai-platform_*/
./edge-ai-server --mock --mock-devices=3
# Windows: 解壓 zip在資料夾中開啟 PowerShell
.\edge-ai-server.exe --mock --mock-devices=3
```
然後開啟瀏覽器 http://127.0.0.1:3721
## 命令列選項
| Flag | 預設值 | 說明 |
|:-----|:------|:-----|
| `--port` | `3721` | 伺服器連接埠 |
| `--host` | `127.0.0.1` | 伺服器位址 |
| `--mock` | `false` | 啟用模擬裝置驅動 |
| `--mock-camera` | `false` | 啟用模擬攝影機 |
| `--mock-devices` | `1` | 模擬裝置數量 |
| `--log-level` | `info` | 日誌等級debug/info/warn/error |
| `--dev` | `false` | 開發模式(停用嵌入式前端) |
## 可選依賴
以下工具可增強功能,但**非必要**
| 工具 | 用途 | macOS 安裝 | Windows 安裝 |
|:-----|:-----|:----------|:------------|
| `ffmpeg` | 攝影機擷取、影片處理 | `brew install ffmpeg` | `winget install Gyan.FFmpeg` |
| `yt-dlp` | YouTube / 影片 URL 解析 | `brew install yt-dlp` | `winget install yt-dlp` |
| `python3` | Kneron KL720 硬體驅動 | `brew install python3` | `winget install Python.Python.3.12` |
啟動時會自動檢查並提示缺少的工具。
## 解除安裝
### macOS
```bash
rm -rf ~/.edge-ai-platform
sudo rm -f /usr/local/bin/edge-ai-server
```
### Windows (PowerShell)
2. 安裝 KneronPLUS wheel
```powershell
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\EdgeAIPlatform"
# 手動從系統環境變數移除 PATH 中的 EdgeAIPlatform 路徑
python -m pip install .\KneronPLUS-3.1.2-py3-none-any.whl
```
## 開發
```bash
# 安裝依賴
make install
# 啟動開發伺服器(前端 :3000 + 後端 :3721
make dev
# 編譯單一 binary
make build
# 跨平台打包(本機測試,不發佈)
make release-snapshot
# 發佈至 Gitea Release
make release
3. 啟動本機服務:
```powershell
python .\LocalAPI\main.py
```
## 參考文件
- 核心流程與產品規劃:`docs/PRD-Integrated.md`
- Windows local service 策略:`local_service_win/STRATEGY.md`

@ -1 +0,0 @@
Subproject commit 9239a97721215a14ecac520c21e64d440dd36d98

View File

@ -1,689 +0,0 @@
package main
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// SystemInfo describes the current platform and pre-existing state.
type SystemInfo struct {
OS string `json:"os"`
Arch string `json:"arch"`
DefaultDir string `json:"defaultDir"`
PythonAvailable bool `json:"pythonAvailable"`
PythonVersion string `json:"pythonVersion"`
BrewAvailable bool `json:"brewAvailable"`
LibusbInstalled bool `json:"libusbInstalled"`
ExistingInstall bool `json:"existingInstall"`
ExistingVersion string `json:"existingVersion"`
FfmpegAvailable bool `json:"ffmpegAvailable"`
YtdlpAvailable bool `json:"ytdlpAvailable"`
}
// InstallConfig holds user choices from the wizard.
type InstallConfig struct {
InstallDir string `json:"installDir"`
CreateSymlink bool `json:"createSymlink"`
InstallPythonEnv bool `json:"installPythonEnv"`
InstallLibusb bool `json:"installLibusb"`
RelayURL string `json:"relayURL"`
RelayToken string `json:"relayToken"`
DashboardURL string `json:"dashboardURL"`
ServerPort int `json:"serverPort"`
Language string `json:"language"`
}
// ProgressEvent is emitted via Wails Events to update the frontend.
type ProgressEvent struct {
Step string `json:"step"`
Message string `json:"message"`
Percent float64 `json:"percent"`
IsError bool `json:"isError"`
IsComplete bool `json:"isComplete"`
}
// HardwareDevice describes a detected Kneron device.
type HardwareDevice struct {
Model string `json:"model"`
Port string `json:"port"`
Serial string `json:"serial"`
Product string `json:"product"`
}
// Installer is the main app struct bound to the Wails frontend.
type Installer struct {
ctx context.Context
payload embed.FS
}
// NewInstaller creates a new Installer instance.
func NewInstaller(payload embed.FS) *Installer {
return &Installer{payload: payload}
}
// startup is called by Wails when the app starts.
func (inst *Installer) startup(ctx context.Context) {
inst.ctx = ctx
ensureGUIPath()
}
// ensureGUIPath expands PATH for macOS/Linux GUI apps that inherit a
// minimal environment from launchd/Finder. Without this, exec.LookPath
// cannot find brew, python3, ffmpeg, etc.
func ensureGUIPath() {
extraDirs := []string{
"/usr/local/bin",
"/opt/homebrew/bin", // Apple Silicon Homebrew
"/opt/homebrew/sbin",
"/usr/local/sbin",
}
// Also add ~/bin and ~/.local/bin
if home, err := os.UserHomeDir(); err == nil {
extraDirs = append(extraDirs,
filepath.Join(home, ".local", "bin"),
filepath.Join(home, "bin"),
)
}
current := os.Getenv("PATH")
for _, d := range extraDirs {
if _, err := os.Stat(d); err == nil && !strings.Contains(current, d) {
current = current + ":" + d
}
}
os.Setenv("PATH", current)
}
// emitProgress sends a progress event to the frontend.
func (inst *Installer) emitProgress(event ProgressEvent) {
wailsRuntime.EventsEmit(inst.ctx, "install:progress", event)
}
// GetSystemInfo probes the host system and returns platform details.
func (inst *Installer) GetSystemInfo() (*SystemInfo, error) {
info := &SystemInfo{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
info.DefaultDir = platformDefaultDir()
// Check existing installation
if _, err := os.Stat(filepath.Join(info.DefaultDir, "edge-ai-server")); err == nil {
info.ExistingInstall = true
}
binName := "edge-ai-server"
if runtime.GOOS == "windows" {
binName = "edge-ai-server.exe"
}
if _, err := os.Stat(filepath.Join(info.DefaultDir, binName)); err == nil {
info.ExistingInstall = true
}
// Check Python
pythonPath, err := findPython3()
if err == nil {
info.PythonAvailable = true
out, _ := exec.Command(pythonPath, "--version").Output()
info.PythonVersion = strings.TrimSpace(string(out))
}
// Check brew (macOS)
if runtime.GOOS == "darwin" {
if _, err := exec.LookPath("brew"); err == nil {
info.BrewAvailable = true
}
}
// Check libusb
info.LibusbInstalled = checkLibusbInstalled()
// Check optional deps
if _, err := exec.LookPath("ffmpeg"); err == nil {
info.FfmpegAvailable = true
}
if _, err := exec.LookPath("yt-dlp"); err == nil {
info.YtdlpAvailable = true
}
return info, nil
}
// BrowseDirectory opens a native directory picker dialog.
func (inst *Installer) BrowseDirectory() (string, error) {
dir, err := wailsRuntime.OpenDirectoryDialog(inst.ctx, wailsRuntime.OpenDialogOptions{
Title: "Choose Installation Directory",
})
return dir, err
}
// ValidatePath checks if the given path is writable and has enough space.
func (inst *Installer) ValidatePath(path string) string {
if path == "" {
return "Please select an installation directory."
}
// Check if parent directory exists and is writable
parent := filepath.Dir(path)
if _, err := os.Stat(parent); os.IsNotExist(err) {
return fmt.Sprintf("Parent directory does not exist: %s", parent)
}
// Try to create a temp file to test write permission
testFile := filepath.Join(parent, ".edge-ai-write-test")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
return fmt.Sprintf("Cannot write to directory: %s", err)
}
os.Remove(testFile)
return ""
}
// StartInstall begins the installation process.
func (inst *Installer) StartInstall(config InstallConfig) error {
go inst.runInstall(config)
return nil
}
func (inst *Installer) runInstall(config InstallConfig) {
steps := []struct {
name string
percent float64
fn func(config InstallConfig) error
}{
{"Creating installation directory", 5, inst.stepCreateDir},
{"Extracting server binary", 10, inst.stepExtractBinary},
{"Extracting models and firmware", 30, inst.stepExtractData},
{"Extracting scripts", 48, inst.stepExtractScripts},
{"Configuring system", 55, inst.stepConfigureSystem},
{"Setting up USB driver", 62, inst.stepSetupLibusb},
{"Setting up Python environment", 72, inst.stepSetupPython},
{"Writing configuration", 85, inst.stepWriteConfig},
{"Verifying installation", 90, inst.stepVerify},
{"Setting up auto-start launcher", 95, inst.stepAutoRestart},
}
for _, step := range steps {
inst.emitProgress(ProgressEvent{
Step: step.name,
Message: step.name + "...",
Percent: step.percent,
})
if err := step.fn(config); err != nil {
inst.emitProgress(ProgressEvent{
Step: step.name,
Message: fmt.Sprintf("Warning: %s — %s", step.name, err),
Percent: step.percent,
IsError: true,
})
// Non-critical steps continue; critical ones are handled inside
}
}
inst.emitProgress(ProgressEvent{
Step: "complete",
Message: "Installation complete!",
Percent: 100,
IsComplete: true,
})
}
func (inst *Installer) stepCreateDir(config InstallConfig) error {
return os.MkdirAll(config.InstallDir, 0755)
}
func (inst *Installer) stepExtractBinary(config InstallConfig) error {
binName := "edge-ai-server"
if runtime.GOOS == "windows" {
binName = "edge-ai-server.exe"
}
return inst.extractFile("payload/"+binName, filepath.Join(config.InstallDir, binName), 0755)
}
func (inst *Installer) stepExtractData(config InstallConfig) error {
// Extract everything under payload/data/
return inst.extractDir("payload/data", filepath.Join(config.InstallDir, "data"))
}
func (inst *Installer) stepExtractScripts(config InstallConfig) error {
return inst.extractDir("payload/scripts", filepath.Join(config.InstallDir, "scripts"))
}
func (inst *Installer) stepConfigureSystem(config InstallConfig) error {
// Remove macOS quarantine attribute
removeQuarantine(config.InstallDir)
if config.CreateSymlink {
return createSystemLink(config.InstallDir)
}
return nil
}
func (inst *Installer) stepSetupLibusb(config InstallConfig) error {
if !config.InstallLibusb {
return nil
}
return installLibusb(config.InstallDir)
}
func (inst *Installer) stepSetupPython(config InstallConfig) error {
if !config.InstallPythonEnv {
return nil
}
return inst.setupPythonVenv(config.InstallDir)
}
func (inst *Installer) stepVerify(config InstallConfig) error {
binName := "edge-ai-server"
if runtime.GOOS == "windows" {
binName = "edge-ai-server.exe"
}
binPath := filepath.Join(config.InstallDir, binName)
info, err := os.Stat(binPath)
if err != nil {
return fmt.Errorf("server binary not found: %w", err)
}
if info.Size() == 0 {
return fmt.Errorf("server binary is empty")
}
return nil
}
func (inst *Installer) stepAutoRestart(config InstallConfig) error {
inst.emitProgress(ProgressEvent{
Step: "auto-restart",
Message: "Registering auto-restart service...",
Percent: 96,
})
return installAutoRestart(config.InstallDir)
}
// extractFile copies a single file from the embedded payload to disk.
func (inst *Installer) extractFile(embedPath, destPath string, perm os.FileMode) error {
data, err := inst.payload.ReadFile(embedPath)
if err != nil {
return fmt.Errorf("read embedded %s: %w", embedPath, err)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
return os.WriteFile(destPath, data, perm)
}
// extractDir copies an entire directory tree from the embedded payload to disk.
func (inst *Installer) extractDir(embedDir, destDir string) error {
return fs.WalkDir(inst.payload, embedDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(embedDir, path)
if relPath == "." {
return nil
}
outPath := filepath.Join(destDir, relPath)
if d.IsDir() {
return os.MkdirAll(outPath, 0755)
}
data, err := inst.payload.ReadFile(path)
if err != nil {
return fmt.Errorf("read embedded %s: %w", path, err)
}
perm := os.FileMode(0644)
// Make .py scripts executable
if strings.HasSuffix(path, ".py") {
perm = 0755
}
return os.WriteFile(outPath, data, perm)
})
}
// setupPythonVenv creates a Python virtual environment and installs requirements.
func (inst *Installer) setupPythonVenv(installDir string) error {
venvDir := filepath.Join(installDir, "venv")
reqFile := filepath.Join(installDir, "scripts", "requirements.txt")
pythonPath, err := findPython3()
if err != nil {
return fmt.Errorf("python3 not found on PATH: %w", err)
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Creating Python virtual environment...",
Percent: 76,
})
cmd := exec.Command(pythonPath, "-m", "venv", venvDir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("venv creation failed: %s — %w", string(out), err)
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Installing Python packages (numpy, opencv, pyusb)...",
Percent: 80,
})
pipPath := venvPipPath(venvDir)
cmd = exec.Command(pipPath, "install", "-r", reqFile)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("pip install failed: %s — %w", string(out), err)
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Python environment ready.",
Percent: 90,
})
return nil
}
func (inst *Installer) stepWriteConfig(config InstallConfig) error {
cfgDir := platformConfigDir()
if err := os.MkdirAll(cfgDir, 0755); err != nil {
return err
}
port := config.ServerPort
if port == 0 {
port = 3721
}
appCfg := map[string]interface{}{
"version": 1,
"server": map[string]interface{}{
"port": port,
"host": "127.0.0.1",
},
"relay": map[string]interface{}{
"url": config.RelayURL,
"token": config.RelayToken,
"dashboardURL": config.DashboardURL,
},
"launcher": map[string]interface{}{
"autoStart": true,
"language": config.Language,
},
}
data, err := json.MarshalIndent(appCfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(cfgDir, "config.json"), data, 0644)
}
// DetectHardware runs kneron_detect.py via the installed venv.
func (inst *Installer) DetectHardware() ([]HardwareDevice, error) {
installDir := platformDefaultDir()
detectScript := filepath.Join(installDir, "scripts", "kneron_detect.py")
venvDir := filepath.Join(installDir, "venv")
pythonPath := venvPythonPath(venvDir)
// Fallback to system python if venv doesn't exist
if _, err := os.Stat(pythonPath); os.IsNotExist(err) {
p, err := findPython3()
if err != nil {
return nil, fmt.Errorf("python3 not found")
}
pythonPath = p
}
if _, err := os.Stat(detectScript); os.IsNotExist(err) {
return nil, fmt.Errorf("detection script not found: %s", detectScript)
}
cmd := exec.Command(pythonPath, detectScript)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("detection failed: %s — %w", string(out), err)
}
// Parse JSON output
var devices []HardwareDevice
if err := json.Unmarshal(out, &devices); err != nil {
// kneron_detect.py outputs plain text, not JSON — parse manually
devices = parseDetectOutput(string(out))
}
return devices, nil
}
func parseDetectOutput(output string) []HardwareDevice {
var devices []HardwareDevice
lines := strings.Split(output, "\n")
var current *HardwareDevice
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Device #") {
// Start a new device
if current != nil {
devices = append(devices, *current)
}
current = &HardwareDevice{}
} else if current != nil {
if strings.HasPrefix(line, "Model:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "Model:"))
current.Model = strings.TrimPrefix(val, "Kneron ")
current.Product = val
} else if strings.HasPrefix(line, "Serial:") {
current.Serial = strings.TrimSpace(strings.TrimPrefix(line, "Serial:"))
} else if strings.HasPrefix(line, "Bus:") {
current.Port = strings.TrimSpace(line)
}
}
}
if current != nil && current.Model != "" {
devices = append(devices, *current)
}
return devices
}
// LaunchServer starts the installed edge-ai-server in the background.
func (inst *Installer) LaunchServer() (string, error) {
installDir := platformDefaultDir()
binName := "edge-ai-server"
if runtime.GOOS == "windows" {
binName = "edge-ai-server.exe"
}
binPath := filepath.Join(installDir, binName)
// Read config to get relay args
args := []string{"--tray"}
cfgPath := filepath.Join(platformConfigDir(), "config.json")
if data, err := os.ReadFile(cfgPath); err == nil {
var cfg map[string]interface{}
if json.Unmarshal(data, &cfg) == nil {
if relay, ok := cfg["relay"].(map[string]interface{}); ok {
if u, ok := relay["url"].(string); ok && u != "" {
args = append(args, "--relay-url", u)
}
if t, ok := relay["token"].(string); ok && t != "" {
args = append(args, "--relay-token", t)
}
}
}
}
cmd := exec.Command(binPath, args...)
cmd.Dir = installDir
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("failed to start launcher: %w", err)
}
return "Launcher started", nil
}
// GetDashboardURL returns the configured dashboard URL from the install config.
func (inst *Installer) GetDashboardURL() string {
cfgPath := filepath.Join(platformConfigDir(), "config.json")
data, err := os.ReadFile(cfgPath)
if err != nil {
return ""
}
var cfg map[string]interface{}
if json.Unmarshal(data, &cfg) != nil {
return ""
}
if relay, ok := cfg["relay"].(map[string]interface{}); ok {
if u, ok := relay["dashboardURL"].(string); ok {
return u
}
}
return ""
}
// GenerateToken generates a random 16-character hex token for relay authentication.
func (inst *Installer) GenerateToken() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// fallback: should never happen
return "abcdef0123456789"
}
return hex.EncodeToString(b)
}
// OpenBrowser opens the given URL in the system default browser.
func (inst *Installer) OpenBrowser(url string) error {
return openBrowser(url)
}
// GetExistingInstall checks if an installation exists.
func (inst *Installer) GetExistingInstall() (string, error) {
dir := platformDefaultDir()
binName := "edge-ai-server"
if runtime.GOOS == "windows" {
binName = "edge-ai-server.exe"
}
if _, err := os.Stat(filepath.Join(dir, binName)); err == nil {
return dir, nil
}
return "", nil
}
// Uninstall removes all installed files.
func (inst *Installer) Uninstall() error {
go inst.runUninstall()
return nil
}
func (inst *Installer) runUninstall() {
installDir := platformDefaultDir()
emit := func(step, msg string, pct float64) {
wailsRuntime.EventsEmit(inst.ctx, "uninstall:progress", ProgressEvent{
Step: step, Message: msg, Percent: pct,
})
}
// 1. Stop running server and remove auto-restart service
emit("stop", "Stopping server and removing auto-restart service...", 10)
removeAutoRestart()
if runtime.GOOS == "windows" {
exec.Command("taskkill", "/F", "/IM", "edge-ai-server.exe").Run()
} else {
exec.Command("pkill", "-f", "edge-ai-server").Run()
}
// 2. Remove symlink / PATH entry
emit("links", "Removing system links...", 20)
removeSystemLink()
// 3. Remove only Edge AI Platform files (not system deps like libusb, Python, Homebrew)
emit("files", "Removing server binary...", 30)
binName := "edge-ai-server"
if runtime.GOOS == "windows" {
binName = "edge-ai-server.exe"
}
os.Remove(filepath.Join(installDir, binName))
emit("files", "Removing models and firmware...", 45)
os.RemoveAll(filepath.Join(installDir, "data"))
emit("files", "Removing scripts...", 55)
os.RemoveAll(filepath.Join(installDir, "scripts"))
emit("files", "Removing Python virtual environment...", 70)
os.RemoveAll(filepath.Join(installDir, "venv"))
// Remove any other platform-generated files (logs, config, etc.)
emit("files", "Removing configuration and logs...", 85)
os.Remove(filepath.Join(installDir, "config.json"))
os.RemoveAll(filepath.Join(installDir, "logs"))
// Try to remove the install directory if empty
entries, _ := os.ReadDir(installDir)
if len(entries) == 0 {
os.Remove(installDir)
}
emit("complete", "Uninstall complete. System dependencies (Python, libusb, Homebrew) were preserved.", 100)
wailsRuntime.EventsEmit(inst.ctx, "uninstall:progress", ProgressEvent{
Step: "complete",
Message: "Uninstall complete. System dependencies (Python, libusb, Homebrew) were preserved.",
Percent: 100,
IsComplete: true,
})
}
// findPython3 locates a Python 3 interpreter.
func findPython3() (string, error) {
if p, err := exec.LookPath("python3"); err == nil {
return p, nil
}
if p, err := exec.LookPath("python"); err == nil {
out, _ := exec.Command(p, "--version").Output()
if strings.Contains(string(out), "Python 3") {
return p, nil
}
}
return "", fmt.Errorf("python3 not found")
}
// venvPipPath returns the pip executable path inside a venv.
func venvPipPath(venvDir string) string {
if runtime.GOOS == "windows" {
return filepath.Join(venvDir, "Scripts", "pip.exe")
}
return filepath.Join(venvDir, "bin", "pip")
}
// venvPythonPath returns the python executable path inside a venv.
func venvPythonPath(venvDir string) string {
if runtime.GOOS == "windows" {
return filepath.Join(venvDir, "Scripts", "python.exe")
}
return filepath.Join(venvDir, "bin", "python3")
}
// openBrowser opens a URL in the default browser.
func openBrowser(url string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
case "windows":
return exec.Command("cmd", "/c", "start", url).Start()
default:
return exec.Command("xdg-open", url).Start()
}
}

View File

@ -1,9 +0,0 @@
package main
import "embed"
// payloadFS contains the server binary, models, firmware, and scripts.
// Populated by `make installer-payload` before `wails build`.
//
//go:embed all:payload
var payloadFS embed.FS

View File

@ -1,536 +0,0 @@
// Edge AI Platform Installer — Wizard Controller v0.2
// ── i18n Dictionary ───────────────────────────────────────
const i18n = {
en: {
'welcome.title': 'Edge AI Platform Installer',
'welcome.subtitle': 'Set up your edge AI development environment with Kneron hardware support.',
'path.title': 'Installation Path',
'path.subtitle': 'Choose where to install Edge AI Platform.',
'path.browse': 'Browse',
'path.required': 'Installation path is required.',
'path.valid': 'Path is valid.',
'components.title': 'Select Components',
'components.subtitle': 'Choose which components to install.',
'components.server': 'Edge AI Server',
'components.serverDesc': 'Core server binary for hardware communication (~10 MB)',
'components.models': 'Kneron Models',
'components.modelsDesc': 'Pre-trained NEF model files for KL520/KL720 (~50 MB)',
'components.python': 'Python Environment',
'components.pythonDesc': 'Python venv with Kneron PLUS SDK and dependencies (~200 MB)',
'components.libusb': 'libusb',
'components.libusbDesc': 'USB library required for Kneron device communication',
'components.symlink': 'CLI Symlink',
'components.symlinkDesc': "Add 'edge-ai' command to /usr/local/bin",
'relay.title': 'Relay Configuration',
'relay.subtitle': 'Configure the relay server for remote access. You can skip this and configure later.',
'relay.url': 'Relay URL',
'relay.token': 'Relay Token',
'relay.port': 'Server Port',
'relay.hint': 'Leave empty to skip relay configuration. You can set this later in the config file.',
'progress.title': 'Installing...',
'progress.subtitle': 'Please wait while components are being installed.',
'progress.preparing': 'Preparing installation...',
'hardware.title': 'Hardware Detection',
'hardware.subtitle': 'Connect your Kneron devices and scan for hardware.',
'hardware.scanning': 'Scanning for devices...',
'hardware.noDevices': 'No Kneron devices found. Connect a device and try again.',
'hardware.rescan': 'Rescan',
'complete.title': 'Installation Complete',
'complete.subtitle': 'Edge AI Platform has been installed successfully.',
'complete.location': 'Install Location',
'complete.server': 'Edge AI Server',
'complete.models': 'Kneron Models',
'complete.python': 'Python Environment',
'complete.libusb': 'libusb',
'complete.installed': 'Installed',
'complete.skipped': 'Skipped',
'btn.next': 'Next',
'btn.back': 'Back',
'btn.install': 'Install',
'btn.launch': 'Launch Server',
'btn.openDashboard': 'Open Dashboard',
'btn.close': 'Close',
'relay.tokenHint': 'Auto-generated random token. Both the server and browser use this to authenticate with the relay.',
'relay.dashboardUrl': 'Dashboard URL',
'relay.dashboardHint': 'The HTTP URL to access the dashboard via relay. Opened after server launch.',
'existing.detected': 'Existing installation detected',
'existing.desc': 'An existing installation was found. You can uninstall it or install over it.',
'existing.uninstall': 'Uninstall',
'uninstall.title': 'Uninstalling...',
'uninstall.subtitle': 'Removing installed files.',
'uninstall.confirm': 'This will remove the Edge AI Platform and all installed files. Continue?',
'uninstall.complete': 'Uninstall complete. System dependencies (Python, libusb) were preserved.',
'system.platform': 'Platform',
'system.python': 'Python',
'system.libusb': 'libusb',
'system.ffmpeg': 'FFmpeg',
'status.installed': 'Installed',
'status.notFound': 'Not found',
'status.notInstalled': 'Not installed',
'status.optional': 'Not installed (optional)',
},
'zh-TW': {
'welcome.title': 'Edge AI 平台安裝程式',
'welcome.subtitle': '設定您的邊緣 AI 開發環境,支援 Kneron 硬體。',
'path.title': '安裝路徑',
'path.subtitle': '選擇 Edge AI 平台的安裝位置。',
'path.browse': '瀏覽',
'path.required': '安裝路徑為必填。',
'path.valid': '路徑有效。',
'components.title': '選擇元件',
'components.subtitle': '選擇要安裝的元件。',
'components.server': 'Edge AI 伺服器',
'components.serverDesc': '硬體通訊核心伺服器程式 (~10 MB)',
'components.models': 'Kneron 模型',
'components.modelsDesc': 'KL520/KL720 預訓練 NEF 模型檔案 (~50 MB)',
'components.python': 'Python 環境',
'components.pythonDesc': '包含 Kneron PLUS SDK 的 Python 虛擬環境 (~200 MB)',
'components.libusb': 'libusb',
'components.libusbDesc': 'Kneron 裝置通訊所需的 USB 函式庫',
'components.symlink': 'CLI 捷徑',
'components.symlinkDesc': "新增 'edge-ai' 指令到 /usr/local/bin",
'relay.title': 'Relay 設定',
'relay.subtitle': '設定 Relay 伺服器以進行遠端存取。可以跳過稍後再設定。',
'relay.url': 'Relay URL',
'relay.token': 'Relay Token',
'relay.port': '伺服器連接埠',
'relay.hint': '留空可跳過 Relay 設定,稍後可在設定檔中修改。',
'progress.title': '安裝中...',
'progress.subtitle': '正在安裝元件,請稍候。',
'progress.preparing': '準備安裝中...',
'hardware.title': '硬體偵測',
'hardware.subtitle': '連接您的 Kneron 裝置並掃描硬體。',
'hardware.scanning': '正在掃描裝置...',
'hardware.noDevices': '未偵測到 Kneron 裝置。請連接裝置後再試。',
'hardware.rescan': '重新掃描',
'complete.title': '安裝完成',
'complete.subtitle': 'Edge AI 平台已成功安裝。',
'complete.location': '安裝位置',
'complete.server': 'Edge AI 伺服器',
'complete.models': 'Kneron 模型',
'complete.python': 'Python 環境',
'complete.libusb': 'libusb',
'complete.installed': '已安裝',
'complete.skipped': '已跳過',
'btn.next': '下一步',
'btn.back': '上一步',
'btn.install': '安裝',
'btn.launch': '啟動伺服器',
'btn.openDashboard': '開啟控制台',
'btn.close': '關閉',
'relay.tokenHint': '自動產生的隨機 Token。伺服器和瀏覽器都透過此 Token 向 Relay 驗證身份。',
'relay.dashboardUrl': 'Dashboard URL',
'relay.dashboardHint': '透過 Relay 存取 Dashboard 的 HTTP URL。啟動伺服器後會自動開啟。',
'existing.detected': '偵測到既有安裝',
'existing.desc': '發現既有安裝。您可以解除安裝或覆蓋安裝。',
'existing.uninstall': '解除安裝',
'uninstall.title': '解除安裝中...',
'uninstall.subtitle': '正在移除已安裝的檔案。',
'uninstall.confirm': '這將移除 Edge AI 平台及所有已安裝的檔案。是否繼續?',
'uninstall.complete': '解除安裝完成。系統相依套件Python、libusb已保留。',
'system.platform': '平台',
'system.python': 'Python',
'system.libusb': 'libusb',
'system.ffmpeg': 'FFmpeg',
'status.installed': '已安裝',
'status.notFound': '未找到',
'status.notInstalled': '未安裝',
'status.optional': '未安裝(選用)',
}
};
// ── State ─────────────────────────────────────────────────
let currentStep = 0;
let systemInfo = null;
let installConfig = {
installDir: '',
createSymlink: true,
installPythonEnv: true,
installLibusb: true,
relayURL: '',
relayToken: '',
dashboardURL: '',
serverPort: 3721,
language: 'en',
};
// ── i18n Functions ────────────────────────────────────────
function t(key) {
const dict = i18n[installConfig.language] || i18n.en;
return dict[key] || i18n.en[key] || key;
}
function setLanguage(lang) {
installConfig.language = lang;
// Update active button state
document.getElementById('lang-en').classList.toggle('active', lang === 'en');
document.getElementById('lang-zh').classList.toggle('active', lang === 'zh-TW');
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const text = t(key);
if (text) {
el.textContent = text;
}
});
// Update html lang attribute
document.documentElement.lang = lang === 'zh-TW' ? 'zh-TW' : 'en';
}
// ── Step Navigation ───────────────────────────────────────
function showStep(n) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.step-dot').forEach((d, i) => {
d.classList.remove('active', 'completed');
if (i < n) d.classList.add('completed');
if (i === n) d.classList.add('active');
});
const step = document.getElementById('step-' + n);
if (step) step.classList.add('active');
currentStep = n;
}
// ── Step 0: Welcome ───────────────────────────────────────
async function initWelcome() {
try {
systemInfo = await window.go.main.Installer.GetSystemInfo();
document.getElementById('info-platform').textContent =
systemInfo.os + ' / ' + systemInfo.arch;
const pyEl = document.getElementById('info-python');
if (systemInfo.pythonAvailable) {
pyEl.textContent = systemInfo.pythonVersion;
pyEl.className = 'info-value status-ok';
} else {
pyEl.textContent = t('status.notFound');
pyEl.className = 'info-value status-warn';
}
const luEl = document.getElementById('info-libusb');
luEl.textContent = systemInfo.libusbInstalled ? t('status.installed') : t('status.notInstalled');
luEl.className = 'info-value ' + (systemInfo.libusbInstalled ? 'status-ok' : 'status-warn');
const ffEl = document.getElementById('info-ffmpeg');
ffEl.textContent = systemInfo.ffmpegAvailable ? t('status.installed') : t('status.optional');
ffEl.className = 'info-value ' + (systemInfo.ffmpegAvailable ? 'status-ok' : '');
installConfig.installDir = systemInfo.defaultDir;
if (systemInfo.existingInstall) {
document.getElementById('existing-install').style.display = 'block';
}
} catch (err) {
console.error('GetSystemInfo failed:', err);
}
}
// ── Step 1: Path ──────────────────────────────────────────
document.getElementById('btn-browse').addEventListener('click', async () => {
try {
const dir = await window.go.main.Installer.BrowseDirectory();
if (dir) {
document.getElementById('install-path').value = dir;
installConfig.installDir = dir;
const msg = await window.go.main.Installer.ValidatePath(dir);
const statusEl = document.getElementById('path-status');
if (msg) {
statusEl.textContent = msg;
statusEl.className = 'status-text error';
} else {
statusEl.textContent = t('path.valid');
statusEl.className = 'status-text';
}
}
} catch (err) {
console.error('BrowseDirectory failed:', err);
}
});
// ── Step 4: Install Progress ──────────────────────────────
function addLogLine(message, type) {
const log = document.getElementById('progress-log');
const line = document.createElement('div');
line.className = 'log-' + (type || 'line');
line.textContent = message;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
async function startInstall() {
showStep(4);
document.getElementById('progress-title').textContent = t('progress.title');
document.getElementById('progress-subtitle').textContent = t('progress.subtitle');
document.getElementById('progress-log').innerHTML = '';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-percent').textContent = '0%';
document.getElementById('progress-message').textContent = t('progress.preparing');
try {
await window.go.main.Installer.StartInstall(installConfig);
} catch (err) {
addLogLine('Error: ' + err, 'error');
}
}
if (window.runtime && window.runtime.EventsOn) {
window.runtime.EventsOn('install:progress', (event) => {
const fill = document.getElementById('progress-fill');
const percent = document.getElementById('progress-percent');
const message = document.getElementById('progress-message');
fill.style.width = event.percent + '%';
percent.textContent = Math.round(event.percent) + '%';
message.textContent = event.message;
addLogLine(event.message, event.isError ? 'error' : (event.isComplete ? 'success' : 'line'));
if (event.isComplete && !event.isError) {
setTimeout(() => {
showStep(5);
detectHardware();
}, 500);
}
});
window.runtime.EventsOn('uninstall:progress', (event) => {
const message = document.getElementById('progress-message');
const fill = document.getElementById('progress-fill');
const percent = document.getElementById('progress-percent');
fill.style.width = event.percent + '%';
percent.textContent = Math.round(event.percent) + '%';
message.textContent = event.message;
addLogLine(event.message, event.isError ? 'error' : 'line');
if (event.isComplete) {
document.getElementById('progress-title').textContent = t('uninstall.title').replace('...', '');
document.getElementById('progress-subtitle').textContent = t('uninstall.complete');
addLogLine(t('uninstall.complete'), 'success');
}
});
}
// ── Step 5: Hardware Detection ────────────────────────────
async function detectHardware() {
const el = document.getElementById('hardware-results');
el.innerHTML =
'<div class="device-scanning">' +
'<div class="spinner"></div>' +
'<p>' + t('hardware.scanning') + '</p>' +
'</div>';
try {
const devices = await window.go.main.Installer.DetectHardware();
if (!devices || devices.length === 0) {
el.innerHTML = '<div class="no-devices"><p>' + t('hardware.noDevices') + '</p></div>';
} else {
el.innerHTML = devices.map(d =>
'<div class="device-card">' +
'<div class="device-icon">&#x2B21;</div>' +
'<div class="device-info">' +
'<span class="device-name">Kneron ' + (d.model || 'Unknown') + '</span>' +
'<span class="device-detail">' + (d.product || d.port || '') + '</span>' +
'</div>' +
'</div>'
).join('');
}
} catch (err) {
el.innerHTML = '<div class="no-devices"><p>Detection skipped: ' + err + '</p></div>';
}
}
document.getElementById('btn-rescan').addEventListener('click', () => {
detectHardware();
});
// ── Step 6: Complete ──────────────────────────────────────
document.getElementById('btn-launch').addEventListener('click', async () => {
try {
await window.go.main.Installer.LaunchServer();
// After launching, if dashboard URL is configured, open it automatically
const dashUrl = installConfig.dashboardURL || await window.go.main.Installer.GetDashboardURL();
if (dashUrl) {
// Give the server a moment to start, then open dashboard
setTimeout(async () => {
// Append relay token as query param if available
let url = dashUrl;
if (installConfig.relayToken) {
const sep = url.includes('?') ? '&' : '?';
url = url + sep + 'token=' + encodeURIComponent(installConfig.relayToken);
}
await window.go.main.Installer.OpenBrowser(url);
}, 2000);
} else {
// No relay — open local
setTimeout(async () => {
const port = installConfig.serverPort || 3721;
await window.go.main.Installer.OpenBrowser('http://127.0.0.1:' + port);
}, 2000);
}
} catch (err) {
alert('Failed to launch: ' + err);
}
});
document.getElementById('btn-open-dashboard').addEventListener('click', async () => {
const dashUrl = installConfig.dashboardURL || await window.go.main.Installer.GetDashboardURL();
if (dashUrl) {
let url = dashUrl;
if (installConfig.relayToken) {
const sep = url.includes('?') ? '&' : '?';
url = url + sep + 'token=' + encodeURIComponent(installConfig.relayToken);
}
await window.go.main.Installer.OpenBrowser(url);
}
});
document.getElementById('btn-close').addEventListener('click', () => {
if (window.runtime && window.runtime.Quit) {
window.runtime.Quit();
} else {
window.close();
}
});
// ── Uninstall ─────────────────────────────────────────────
document.getElementById('btn-uninstall').addEventListener('click', async () => {
if (!confirm(t('uninstall.confirm'))) {
return;
}
showStep(4);
document.getElementById('progress-title').textContent = t('uninstall.title');
document.getElementById('progress-subtitle').textContent = t('uninstall.subtitle');
document.getElementById('progress-log').innerHTML = '';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-percent').textContent = '0%';
try {
await window.go.main.Installer.Uninstall();
} catch (err) {
addLogLine('Error: ' + err, 'error');
}
});
// ── Navigation Wiring ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Detect initial language from browser
const browserLang = navigator.language || navigator.userLanguage || 'en';
const initialLang = browserLang.startsWith('zh') ? 'zh-TW' : 'en';
setLanguage(initialLang);
initWelcome();
// Language switcher
document.getElementById('lang-en').addEventListener('click', () => setLanguage('en'));
document.getElementById('lang-zh').addEventListener('click', () => setLanguage('zh-TW'));
// Step 0 -> Step 1
document.getElementById('btn-next-0').addEventListener('click', () => {
showStep(1);
document.getElementById('install-path').value = installConfig.installDir;
});
// Step 1 Back -> Step 0
document.getElementById('btn-back-1').addEventListener('click', () => {
showStep(0);
});
// Step 1 -> Step 2
document.getElementById('btn-next-1').addEventListener('click', async () => {
const msg = await window.go.main.Installer.ValidatePath(installConfig.installDir);
if (msg) {
const statusEl = document.getElementById('path-status');
statusEl.textContent = msg;
statusEl.className = 'status-text error';
return;
}
showStep(2);
});
// Step 2 Back -> Step 1
document.getElementById('btn-back-2').addEventListener('click', () => {
showStep(1);
});
// Step 2 Install -> Step 3 (Relay Config)
document.getElementById('btn-install').addEventListener('click', async () => {
installConfig.createSymlink = document.getElementById('comp-symlink').checked;
installConfig.installPythonEnv = document.getElementById('comp-python').checked;
installConfig.installLibusb = document.getElementById('comp-libusb').checked;
showStep(3);
// Auto-generate relay token if empty
const tokenInput = document.getElementById('relay-token');
if (!tokenInput.value.trim()) {
try {
const token = await window.go.main.Installer.GenerateToken();
tokenInput.value = token;
} catch (err) {
console.error('GenerateToken failed:', err);
}
}
});
// Step 3 Back -> Step 2
document.getElementById('btn-back-3').addEventListener('click', () => {
showStep(2);
});
// Regenerate token button
document.getElementById('btn-regen-token').addEventListener('click', async () => {
try {
const token = await window.go.main.Installer.GenerateToken();
document.getElementById('relay-token').value = token;
} catch (err) {
console.error('GenerateToken failed:', err);
}
});
// Step 3 Next -> collect relay fields -> Step 4 (Progress) -> start install
document.getElementById('btn-next-3').addEventListener('click', () => {
installConfig.relayURL = document.getElementById('relay-url').value.trim();
installConfig.relayToken = document.getElementById('relay-token').value.trim();
installConfig.dashboardURL = document.getElementById('dashboard-url').value.trim();
const portVal = parseInt(document.getElementById('server-port').value, 10);
installConfig.serverPort = (portVal >= 1024 && portVal <= 65535) ? portVal : 3721;
startInstall();
});
// Step 5 Next -> Step 6 (Complete)
document.getElementById('btn-next-5').addEventListener('click', () => {
showStep(6);
document.getElementById('summary-path').textContent = installConfig.installDir;
const modelsEl = document.getElementById('summary-models');
modelsEl.textContent = t('complete.installed');
modelsEl.className = 'info-value status-ok';
const pyEl = document.getElementById('summary-python');
pyEl.textContent = installConfig.installPythonEnv ? t('complete.installed') : t('complete.skipped');
pyEl.className = 'info-value ' + (installConfig.installPythonEnv ? 'status-ok' : 'status-skipped');
const luEl = document.getElementById('summary-libusb');
luEl.textContent = installConfig.installLibusb ? t('complete.installed') : t('complete.skipped');
luEl.className = 'info-value ' + (installConfig.installLibusb ? 'status-ok' : 'status-skipped');
// Show "Open Dashboard" button if relay dashboard URL is configured
if (installConfig.dashboardURL) {
document.getElementById('btn-open-dashboard').style.display = 'inline-flex';
}
});
});

View File

@ -1,258 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge AI Platform Installer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- Header -->
<header id="wizard-header">
<div class="header-left">
<div class="logo-text" data-i18n="welcome.title">Edge AI Platform</div>
<div class="version-text">Installer v0.2.0</div>
</div>
<div class="header-center">
<div class="step-indicators">
<span class="step-dot active" data-step="0">1</span>
<span class="step-line"></span>
<span class="step-dot" data-step="1">2</span>
<span class="step-line"></span>
<span class="step-dot" data-step="2">3</span>
<span class="step-line"></span>
<span class="step-dot" data-step="3">4</span>
<span class="step-line"></span>
<span class="step-dot" data-step="4">5</span>
<span class="step-line"></span>
<span class="step-dot" data-step="5">6</span>
<span class="step-line"></span>
<span class="step-dot" data-step="6">7</span>
</div>
</div>
<div class="header-right">
<div class="lang-switch">
<button class="lang-btn active" id="lang-en">EN</button>
<span class="lang-sep">|</span>
<button class="lang-btn" id="lang-zh">中文</button>
</div>
</div>
</header>
<main>
<!-- Step 0: Welcome -->
<section id="step-0" class="step active">
<h1 data-i18n="welcome.title">Edge AI Platform Installer</h1>
<p class="subtitle" data-i18n="welcome.subtitle">Set up your edge AI development environment with Kneron hardware support.</p>
<div id="system-info" class="info-card">
<div class="info-row"><span class="info-label" data-i18n="system.platform">Platform</span><span id="info-platform" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="system.python">Python</span><span id="info-python" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="system.libusb">libusb</span><span id="info-libusb" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="system.ffmpeg">FFmpeg</span><span id="info-ffmpeg" class="info-value">-</span></div>
</div>
<div id="existing-install" class="warning-card" style="display:none">
<strong data-i18n="existing.detected">Existing installation detected</strong>
<p data-i18n="existing.desc">An existing installation was found. You can uninstall it or install over it.</p>
<p id="existing-path" class="existing-path"></p>
<button id="btn-uninstall" class="btn btn-danger" data-i18n="existing.uninstall">Uninstall</button>
</div>
<div class="actions">
<button id="btn-next-0" class="btn btn-primary" data-i18n="btn.next">Next</button>
</div>
</section>
<!-- Step 1: Install Path -->
<section id="step-1" class="step">
<h1 data-i18n="path.title">Installation Path</h1>
<p class="subtitle" data-i18n="path.subtitle">Choose where to install Edge AI Platform.</p>
<div class="form-group">
<div class="path-input-group">
<input type="text" id="install-path" class="input-field" readonly>
<button id="btn-browse" class="btn btn-secondary" data-i18n="path.browse">Browse</button>
</div>
<p id="path-status" class="status-text"></p>
</div>
<div class="actions">
<button id="btn-back-1" class="btn btn-ghost" data-i18n="btn.back">Back</button>
<button id="btn-next-1" class="btn btn-primary" data-i18n="btn.next">Next</button>
</div>
</section>
<!-- Step 2: Components -->
<section id="step-2" class="step">
<h1 data-i18n="components.title">Select Components</h1>
<p class="subtitle" data-i18n="components.subtitle">Choose which components to install.</p>
<div class="component-list">
<label class="component-item required">
<div class="component-check">
<input type="checkbox" id="comp-server" checked disabled>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.server">Edge AI Server</span>
<span class="component-desc" data-i18n="components.serverDesc">Core server binary for hardware communication (~10 MB)</span>
</div>
</label>
<label class="component-item">
<div class="component-check">
<input type="checkbox" id="comp-models" checked>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.models">Kneron Models</span>
<span class="component-desc" data-i18n="components.modelsDesc">Pre-trained NEF model files for KL520/KL720 (~50 MB)</span>
</div>
</label>
<label class="component-item">
<div class="component-check">
<input type="checkbox" id="comp-python" checked>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.python">Python Environment</span>
<span class="component-desc" data-i18n="components.pythonDesc">Python venv with Kneron PLUS SDK and dependencies (~200 MB)</span>
</div>
</label>
<label class="component-item">
<div class="component-check">
<input type="checkbox" id="comp-libusb" checked>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.libusb">libusb</span>
<span class="component-desc" data-i18n="components.libusbDesc">USB library required for Kneron device communication</span>
</div>
</label>
<label class="component-item" id="comp-symlink-row">
<div class="component-check">
<input type="checkbox" id="comp-symlink" checked>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.symlink">CLI Symlink</span>
<span class="component-desc" data-i18n="components.symlinkDesc">Add 'edge-ai' command to /usr/local/bin</span>
</div>
</label>
</div>
<div class="actions">
<button id="btn-back-2" class="btn btn-ghost" data-i18n="btn.back">Back</button>
<button id="btn-install" class="btn btn-primary" data-i18n="btn.install">Install</button>
</div>
</section>
<!-- Step 3: Relay Configuration -->
<section id="step-3" class="step">
<h1 data-i18n="relay.title">Relay Configuration</h1>
<p class="subtitle" data-i18n="relay.subtitle">Configure the relay server for remote access. You can skip this and configure later.</p>
<div class="form-group">
<label class="field-label" data-i18n="relay.url">Relay URL</label>
<input type="text" class="input-field" id="relay-url" value="ws://ec2-13-192-170-7.ap-northeast-1.compute.amazonaws.com/tunnel/connect">
</div>
<div class="form-group">
<label class="field-label" data-i18n="relay.token">Relay Token</label>
<div class="path-input-group">
<input type="text" class="input-field" id="relay-token" placeholder="auto-generated" readonly>
<button id="btn-regen-token" class="btn btn-secondary" title="Regenerate">&#x21BB;</button>
</div>
<p class="field-hint" data-i18n="relay.tokenHint">Auto-generated random token. Both the server and browser use this to authenticate with the relay.</p>
</div>
<div class="form-group">
<label class="field-label" data-i18n="relay.dashboardUrl">Dashboard URL</label>
<input type="text" class="input-field" id="dashboard-url" value="http://ec2-13-192-170-7.ap-northeast-1.compute.amazonaws.com">
<p class="field-hint" data-i18n="relay.dashboardHint">The HTTP URL to access the dashboard via relay. Opened after server launch.</p>
</div>
<div class="form-group">
<label class="field-label" data-i18n="relay.port">Server Port</label>
<input type="number" class="input-field" id="server-port" value="3721" min="1024" max="65535">
</div>
<p class="field-hint" data-i18n="relay.hint">Leave empty to skip relay configuration. You can set this later in the config file.</p>
<div class="actions">
<button id="btn-back-3" class="btn btn-ghost" data-i18n="btn.back">Back</button>
<button id="btn-next-3" class="btn btn-primary" data-i18n="btn.next">Next</button>
</div>
</section>
<!-- Step 4: Progress -->
<section id="step-4" class="step">
<h1 id="progress-title" data-i18n="progress.title">Installing...</h1>
<p class="subtitle" id="progress-subtitle" data-i18n="progress.subtitle">Please wait while components are being installed.</p>
<div class="progress-container">
<div class="progress-bar">
<div id="progress-fill" class="progress-fill" style="width:0%"></div>
</div>
<span id="progress-percent" class="progress-percent">0%</span>
</div>
<p id="progress-message" class="progress-message" data-i18n="progress.preparing">Preparing installation...</p>
<div id="progress-log" class="log-area"></div>
</section>
<!-- Step 5: Hardware Detection -->
<section id="step-5" class="step">
<h1 data-i18n="hardware.title">Hardware Detection</h1>
<p class="subtitle" data-i18n="hardware.subtitle">Connect your Kneron devices and scan for hardware.</p>
<div id="hardware-results" class="hardware-list">
<div class="device-scanning" id="device-scanning">
<div class="spinner"></div>
<p data-i18n="hardware.scanning">Scanning for devices...</p>
</div>
<div class="no-devices" id="no-devices" style="display:none;">
<p data-i18n="hardware.noDevices">No Kneron devices found. Connect a device and try again.</p>
</div>
</div>
<div class="actions">
<button id="btn-rescan" class="btn btn-secondary" data-i18n="hardware.rescan">Rescan</button>
<button id="btn-next-5" class="btn btn-primary" data-i18n="btn.next">Next</button>
</div>
</section>
<!-- Step 6: Complete -->
<section id="step-6" class="step">
<div class="complete-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h1 data-i18n="complete.title">Installation Complete</h1>
<p class="subtitle" data-i18n="complete.subtitle">Edge AI Platform has been installed successfully.</p>
<div id="install-summary" class="info-card">
<div class="info-row"><span class="info-label" data-i18n="complete.location">Install Location</span><span id="summary-path" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="complete.server">Edge AI Server</span><span id="summary-server" class="info-value status-ok" data-i18n="complete.installed">Installed</span></div>
<div class="info-row"><span class="info-label" data-i18n="complete.models">Kneron Models</span><span id="summary-models" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="complete.python">Python Environment</span><span id="summary-python" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="complete.libusb">libusb</span><span id="summary-libusb" class="info-value">-</span></div>
</div>
<div class="actions">
<button id="btn-launch" class="btn btn-primary" data-i18n="btn.launch">Launch Server</button>
<button id="btn-open-dashboard" class="btn btn-secondary" style="display:none" data-i18n="btn.openDashboard">Open Dashboard</button>
<button id="btn-close" class="btn btn-ghost" data-i18n="btn.close">Close</button>
</div>
</section>
</main>
</div>
<script src="wailsjs/runtime/runtime.js"></script>
<script src="wailsjs/go/main/App.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@ -1,508 +0,0 @@
/* Edge AI Platform Installer — Modernized v0.2 */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #6366f1;
--primary-hover: #4f46e5;
--primary-light: #e0e7ff;
--bg: #fafbff;
--surface: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--text-secondary: #64748b;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--danger: #dc2626;
--danger-hover: #b91c1c;
--radius: 10px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* ── Header ─────────────────────────── */
#wizard-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
color: white;
--wails-draggable: drag;
gap: 16px;
}
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 140px; }
.logo-text { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
.version-text { font-size: 10px; opacity: 0.5; }
.header-center { flex: 1; display: flex; justify-content: center; }
.header-right { min-width: 100px; display: flex; justify-content: flex-end; }
.step-indicators { display: flex; align-items: center; gap: 4px; }
.step-dot {
width: 28px; height: 28px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.25);
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700;
color: rgba(255,255,255,0.35);
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.step-dot.active {
border-color: var(--primary);
background: var(--primary);
color: white;
box-shadow: 0 0 12px rgba(99, 102, 241, 0.5);
transform: scale(1.1);
}
.step-dot.completed {
border-color: var(--success);
background: var(--success);
color: white;
}
.step-line {
width: 14px; height: 2px;
background: rgba(255,255,255,0.15);
border-radius: 1px;
transition: background 0.3s;
}
/* ── Language Switcher ─────────────── */
.lang-switch {
display: flex;
align-items: center;
gap: 6px;
--wails-draggable: no-drag;
}
.lang-sep {
color: rgba(255,255,255,0.3);
font-size: 12px;
}
.lang-btn {
background: transparent;
border: 1px solid rgba(255,255,255,0.25);
color: rgba(255,255,255,0.6);
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
border-color: rgba(255,255,255,0.5);
color: rgba(255,255,255,0.9);
}
.lang-btn.active {
background: rgba(255,255,255,0.15);
border-color: rgba(255,255,255,0.4);
color: white;
}
/* ── Main Content ───────────────────── */
main {
flex: 1;
position: relative;
overflow: hidden;
}
.step {
position: absolute;
inset: 0;
padding: 32px 36px;
display: none;
flex-direction: column;
overflow-y: auto;
opacity: 0;
transform: translateX(20px);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.step.active {
display: flex;
opacity: 1;
transform: translateX(0);
}
h1 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
color: var(--text);
}
.subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 24px;
line-height: 1.6;
}
/* ── Info Card ──────────────────────── */
.info-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 18px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 7px 0;
}
.info-row + .info-row { border-top: 1px solid var(--border); }
.info-label { font-size: 13px; color: var(--text-secondary); }
.info-value { font-size: 13px; font-weight: 500; }
.status-ok { color: var(--success); }
.status-installed { color: var(--success); }
.status-warn { color: var(--warning); }
.status-err { color: var(--error); }
.status-skipped { color: var(--text-secondary); }
/* ── Warning Card ───────────────────── */
.warning-card {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: var(--radius);
padding: 14px 18px;
margin-bottom: 20px;
font-size: 13px;
}
.warning-card p { margin: 6px 0; color: #92400e; }
.existing-path { font-family: "SF Mono", Menlo, monospace; font-size: 12px; }
/* ── Buttons ────────────────────────── */
.actions {
margin-top: auto;
padding-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn {
padding: 9px 22px;
border: none;
border-radius: var(--radius);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--primary);
color: white;
box-shadow: 0 1px 3px rgba(99, 102, 241, 0.3);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--border);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface);
color: var(--text);
border-color: #cbd5e1;
}
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover:not(:disabled) { background: var(--danger-hover); }
.btn-warning { background: var(--warning); color: white; }
.btn-warning:hover:not(:disabled) { background: #d97706; }
/* ── Form Groups ───────────────────── */
.form-group {
margin-bottom: 18px;
}
.field-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.field-hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.5;
font-style: italic;
}
/* ── Path Input ─────────────────────── */
.path-input-group {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.input-field {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
background: var(--surface);
color: var(--text);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
}
.input-field:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
}
.input-field::placeholder {
color: #94a3b8;
}
.status-text {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
min-height: 18px;
}
.status-text.error { color: var(--error); }
/* ── Component List ─────────────────── */
.component-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 20px;
}
.component-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.component-item:hover {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.08);
}
.component-item.required { opacity: 0.85; }
.component-check {
display: flex;
align-items: center;
padding-top: 1px;
}
.component-check input[type="checkbox"] { margin: 0; }
.component-info { display: flex; flex-direction: column; gap: 3px; }
.component-name { font-size: 13px; font-weight: 600; }
.component-desc { font-size: 11px; color: var(--text-secondary); line-height: 1.4; }
/* ── Progress ───────────────────────── */
.progress-container {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.progress-bar {
flex: 1;
height: 10px;
background: var(--border);
border-radius: 5px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), #818cf8, var(--primary));
background-size: 200% 100%;
border-radius: 5px;
transition: width 0.4s ease;
animation: progress-pulse 2s ease-in-out infinite;
}
@keyframes progress-pulse {
0%, 100% { background-position: 0% 0%; }
50% { background-position: 100% 0%; }
}
.progress-percent {
font-size: 14px;
font-weight: 700;
color: var(--primary);
min-width: 40px;
text-align: right;
}
.progress-message {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 14px;
}
.log-area {
flex: 1;
min-height: 120px;
max-height: 220px;
background: #0f172a;
color: #94a3b8;
border-radius: var(--radius);
padding: 12px 14px;
font-family: "SF Mono", "Menlo", "Cascadia Code", monospace;
font-size: 11px;
line-height: 1.7;
overflow-y: auto;
box-shadow: inset 0 1px 4px rgba(0,0,0,0.2);
}
.log-area .log-line { color: #94a3b8; }
.log-area .log-error { color: var(--error); }
.log-area .log-success { color: var(--success); }
/* ── Hardware List ──────────────────── */
.hardware-list { margin-bottom: 20px; }
.device-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: border-color 0.2s;
}
.device-card:hover { border-color: var(--primary); }
.device-card .device-icon {
width: 40px; height: 40px;
background: var(--primary-light);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
color: var(--primary);
}
.device-card .device-info { display: flex; flex-direction: column; gap: 3px; }
.device-card .device-name { font-size: 14px; font-weight: 600; }
.device-card .device-detail { font-size: 11px; color: var(--text-secondary); }
.device-scanning {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px;
color: var(--text-secondary);
font-size: 13px;
}
.spinner {
width: 28px; height: 28px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.no-devices {
text-align: center;
padding: 28px;
color: var(--text-secondary);
font-size: 13px;
}
/* ── Complete Icon ──────────────────── */
.complete-icon {
text-align: center;
margin-bottom: 12px;
color: var(--success);
animation: checkmark-pop 0.5s ease;
}
@keyframes checkmark-pop {
0% { transform: scale(0.5); opacity: 0; }
60% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
/* ── Summary List ──────────────────── */
.summary-list {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 18px;
margin-bottom: 20px;
}
/* ── Scrollbar ──────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ── Step transition for newly shown steps ── */
@keyframes step-enter {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.step.active {
animation: step-enter 0.35s ease forwards;
}

View File

@ -1,35 +0,0 @@
module edge-ai-installer
go 1.22.0
require github.com/wailsapp/wails/v2 v2.11.0
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

View File

@ -1,81 +0,0 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,36 +0,0 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend
var assets embed.FS
func main() {
installer := NewInstaller(payloadFS)
err := wails.Run(&options.App{
Title: "Edge AI Platform Installer",
Width: 720,
Height: 560,
MinWidth: 720,
MinHeight: 560,
MaxWidth: 720,
MaxHeight: 560,
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: installer.startup,
Bind: []interface{}{
installer,
},
})
if err != nil {
panic(err)
}
}

View File

@ -1,150 +0,0 @@
//go:build darwin
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const launchdLabel = "com.innovedus.edge-ai-server"
func platformDefaultDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".edge-ai-platform")
}
func platformConfigDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".edge-ai-platform")
}
func createSystemLink(installDir string) error {
target := filepath.Join(installDir, "edge-ai-server")
link := "/usr/local/bin/edge-ai-server"
// Remove existing symlink if present
os.Remove(link)
// Try without sudo first
if err := os.Symlink(target, link); err != nil {
// Need admin privileges — use osascript
script := fmt.Sprintf(
`do shell script "ln -sf '%s' '%s'" with administrator privileges`,
target, link,
)
cmd := exec.Command("osascript", "-e", script)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("symlink failed: %s — %w", string(out), err)
}
}
return nil
}
func removeSystemLink() {
os.Remove("/usr/local/bin/edge-ai-server")
}
func installLibusb(installDir string) error {
if _, err := exec.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew not found. Install from https://brew.sh then retry")
}
// Check if already installed
if err := exec.Command("brew", "list", "libusb").Run(); err == nil {
return nil
}
cmd := exec.Command("brew", "install", "libusb")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("brew install libusb failed: %s — %w", string(out), err)
}
return nil
}
func checkLibusbInstalled() bool {
if _, err := exec.LookPath("brew"); err != nil {
return false
}
return exec.Command("brew", "list", "libusb").Run() == nil
}
func removeQuarantine(installDir string) {
binPath := filepath.Join(installDir, "edge-ai-server")
exec.Command("xattr", "-dr", "com.apple.quarantine", binPath).Run()
}
func installAutoRestart(installDir string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot determine home directory: %w", err)
}
plistDir := filepath.Join(home, "Library", "LaunchAgents")
plistPath := filepath.Join(plistDir, launchdLabel+".plist")
logDir := filepath.Join(installDir, "logs")
os.MkdirAll(plistDir, 0755)
os.MkdirAll(logDir, 0755)
binPath := filepath.Join(installDir, "edge-ai-server")
plist := strings.Join([]string{
`<?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>`,
` <key>Label</key>`,
` <string>` + launchdLabel + `</string>`,
` <key>ProgramArguments</key>`,
` <array>`,
` <string>` + binPath + `</string>`,
` <string>--tray</string>`,
` </array>`,
` <key>WorkingDirectory</key>`,
` <string>` + installDir + `</string>`,
` <key>KeepAlive</key>`,
` <dict>`,
` <key>SuccessfulExit</key>`,
` <false/>`,
` </dict>`,
` <key>ThrottleInterval</key>`,
` <integer>5</integer>`,
` <key>StandardOutPath</key>`,
` <string>` + filepath.Join(logDir, "server.log") + `</string>`,
` <key>StandardErrorPath</key>`,
` <string>` + filepath.Join(logDir, "server.err.log") + `</string>`,
` <key>ProcessType</key>`,
` <string>Interactive</string>`,
` <key>RunAtLoad</key>`,
` <true/>`,
`</dict>`,
`</plist>`,
}, "\n")
if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil {
return fmt.Errorf("write plist: %w", err)
}
// Unload if already loaded, then load
exec.Command("launchctl", "unload", plistPath).Run()
if out, err := exec.Command("launchctl", "load", plistPath).CombinedOutput(); err != nil {
return fmt.Errorf("launchctl load failed: %s — %w", string(out), err)
}
return nil
}
func removeAutoRestart() {
home, err := os.UserHomeDir()
if err != nil {
return
}
plistPath := filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist")
exec.Command("launchctl", "unload", plistPath).Run()
os.Remove(plistPath)
}

View File

@ -1,122 +0,0 @@
//go:build linux
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const systemdServiceName = "edge-ai-server"
func platformDefaultDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".edge-ai-platform")
}
func platformConfigDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".edge-ai-platform")
}
func createSystemLink(installDir string) error {
target := filepath.Join(installDir, "edge-ai-server")
link := "/usr/local/bin/edge-ai-server"
os.Remove(link)
if err := os.Symlink(target, link); err != nil {
// Try with pkexec for GUI sudo
cmd := exec.Command("pkexec", "ln", "-sf", target, link)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("symlink failed: %s — %w", string(out), err)
}
}
return nil
}
func removeSystemLink() {
os.Remove("/usr/local/bin/edge-ai-server")
}
func installLibusb(installDir string) error {
// Check if already installed
if _, err := exec.LookPath("lsusb"); err == nil {
return nil
}
cmd := exec.Command("pkexec", "apt-get", "install", "-y", "libusb-1.0-0-dev")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt install libusb failed: %s — %w", string(out), err)
}
return nil
}
func checkLibusbInstalled() bool {
return exec.Command("dpkg", "-s", "libusb-1.0-0-dev").Run() == nil
}
func removeQuarantine(installDir string) {
// No-op on Linux
}
func installAutoRestart(installDir string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot determine home directory: %w", err)
}
serviceDir := filepath.Join(home, ".config", "systemd", "user")
servicePath := filepath.Join(serviceDir, systemdServiceName+".service")
logDir := filepath.Join(installDir, "logs")
os.MkdirAll(serviceDir, 0755)
os.MkdirAll(logDir, 0755)
binPath := filepath.Join(installDir, "edge-ai-server")
service := strings.Join([]string{
"[Unit]",
"Description=Edge AI Platform Server",
"After=network.target",
"",
"[Service]",
"Type=simple",
"ExecStart=" + binPath + " --tray",
"WorkingDirectory=" + installDir,
"Restart=on-failure",
"RestartSec=5",
"StandardOutput=append:" + filepath.Join(logDir, "server.log"),
"StandardError=append:" + filepath.Join(logDir, "server.err.log"),
"",
"[Install]",
"WantedBy=default.target",
}, "\n")
if err := os.WriteFile(servicePath, []byte(service), 0644); err != nil {
return fmt.Errorf("write service file: %w", err)
}
exec.Command("systemctl", "--user", "daemon-reload").Run()
exec.Command("systemctl", "--user", "enable", systemdServiceName+".service").Run()
if out, err := exec.Command("systemctl", "--user", "start", systemdServiceName+".service").CombinedOutput(); err != nil {
return fmt.Errorf("systemctl start failed: %s — %w", string(out), err)
}
return nil
}
func removeAutoRestart() {
exec.Command("systemctl", "--user", "disable", "--now", systemdServiceName+".service").Run()
home, err := os.UserHomeDir()
if err != nil {
return
}
servicePath := filepath.Join(home, ".config", "systemd", "user", systemdServiceName+".service")
os.Remove(servicePath)
exec.Command("systemctl", "--user", "daemon-reload").Run()
}

View File

@ -1,132 +0,0 @@
//go:build windows
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func platformDefaultDir() string {
return filepath.Join(os.Getenv("LOCALAPPDATA"), "EdgeAIPlatform")
}
func platformConfigDir() string {
return filepath.Join(os.Getenv("LOCALAPPDATA"), "EdgeAIPlatform")
}
func createSystemLink(installDir string) error {
// On Windows, add the install directory to user PATH
cmd := exec.Command("powershell", "-Command",
`[Environment]::GetEnvironmentVariable("PATH", "User")`)
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to read PATH: %w", err)
}
currentPath := strings.TrimSpace(string(out))
if strings.Contains(strings.ToLower(currentPath), strings.ToLower(installDir)) {
return nil // already in PATH
}
newPath := currentPath
if newPath != "" {
newPath += ";"
}
newPath += installDir
cmd = exec.Command("powershell", "-Command",
fmt.Sprintf(`[Environment]::SetEnvironmentVariable("PATH", "%s", "User")`, newPath))
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to update PATH: %s — %w", string(out), err)
}
return nil
}
func removeSystemLink() {
installDir := platformDefaultDir()
cmd := exec.Command("powershell", "-Command",
`[Environment]::GetEnvironmentVariable("PATH", "User")`)
out, err := cmd.Output()
if err != nil {
return
}
currentPath := strings.TrimSpace(string(out))
parts := strings.Split(currentPath, ";")
var filtered []string
for _, p := range parts {
if !strings.EqualFold(strings.TrimSpace(p), installDir) {
filtered = append(filtered, p)
}
}
newPath := strings.Join(filtered, ";")
exec.Command("powershell", "-Command",
fmt.Sprintf(`[Environment]::SetEnvironmentVariable("PATH", "%s", "User")`, newPath)).Run()
}
func installLibusb(installDir string) error {
// Check known system DLL locations
dllPaths := []string{
filepath.Join(os.Getenv("SystemRoot"), "System32", "libusb-1.0.dll"),
filepath.Join(os.Getenv("SystemRoot"), "SysWOW64", "libusb-1.0.dll"),
}
for _, p := range dllPaths {
if _, err := os.Stat(p); err == nil {
return nil // already installed
}
}
return fmt.Errorf("libusb not found. Please install the WinUSB driver via Zadig: https://zadig.akeo.ie")
}
func checkLibusbInstalled() bool {
dllPaths := []string{
filepath.Join(os.Getenv("SystemRoot"), "System32", "libusb-1.0.dll"),
filepath.Join(os.Getenv("SystemRoot"), "SysWOW64", "libusb-1.0.dll"),
}
for _, p := range dllPaths {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
func removeQuarantine(installDir string) {
// No-op on Windows
}
func installAutoRestart(installDir string) error {
taskName := "EdgeAIPlatformServer"
binPath := filepath.Join(installDir, "edge-ai-server.exe")
// Remove existing task if present
exec.Command("schtasks", "/Delete", "/TN", taskName, "/F").Run()
// Create scheduled task that runs at logon and restarts on failure
// Using PowerShell for more control over restart settings
psScript := fmt.Sprintf(`
$Action = New-ScheduledTaskAction -Execute '%s' -Argument '--tray' -WorkingDirectory '%s'
$Trigger = New-ScheduledTaskTrigger -AtLogOn
$Settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Seconds 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Days 0)
Register-ScheduledTask -TaskName '%s' -Action $Action -Trigger $Trigger -Settings $Settings -Description 'Edge AI Platform Server' -Force
Start-ScheduledTask -TaskName '%s'
`, binPath, installDir, taskName, taskName)
cmd := exec.Command("powershell", "-NoProfile", "-Command", psScript)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("scheduled task creation failed: %s — %w", string(out), err)
}
return nil
}
func removeAutoRestart() {
exec.Command("schtasks", "/Delete", "/TN", "EdgeAIPlatformServer", "/F").Run()
}

View File

@ -1,20 +0,0 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "EdgeAI-Installer",
"outputfilename": "EdgeAI-Installer",
"frontend:install": "",
"frontend:build": "",
"frontend:dev:watcher": "",
"frontend:dev:serverUrl": "",
"assetdir": "./frontend",
"author": {
"name": "Innovedus",
"email": "support@innovedus.com"
},
"info": {
"companyName": "Innovedus",
"productName": "Edge AI Platform Installer",
"productVersion": "0.1.0",
"copyright": "Copyright 2026 Innovedus"
}
}

Binary file not shown.

View File

@ -1,408 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Edge AI Platform — AWS Frontend Deployment (CloudFront + S3)
#
# Usage:
# bash scripts/deploy-aws.sh # sync only (requires prior build)
# bash scripts/deploy-aws.sh --build # build frontend then deploy
# bash scripts/deploy-aws.sh --setup # first-time: create S3 + CloudFront
# bash scripts/deploy-aws.sh --help # show help
#
# Environment variables:
# AWS_BUCKET_NAME S3 bucket name (default: edge-ai-platform-frontend)
# AWS_REGION AWS region (default: ap-northeast-1)
# AWS_PROFILE AWS CLI profile (optional)
#
# Prerequisites:
# - AWS CLI v2 installed and configured (aws configure)
# - Sufficient IAM permissions: S3, CloudFront, IAM (for OAC)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FRONTEND_DIR="$PROJECT_ROOT/frontend"
OUT_DIR="$FRONTEND_DIR/out"
STATE_FILE="$PROJECT_ROOT/.aws-deploy-state"
BUCKET_NAME="${AWS_BUCKET_NAME:-edge-ai-platform-frontend}"
REGION="${AWS_REGION:-ap-northeast-1}"
PROFILE_FLAG=""
if [ -n "${AWS_PROFILE:-}" ]; then
PROFILE_FLAG="--profile $AWS_PROFILE"
fi
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
step() { echo -e "\n${CYAN}=== $* ===${NC}\n"; }
# ── Helpers ──────────────────────────────────────────────
aws_cmd() {
# shellcheck disable=SC2086
aws $PROFILE_FLAG "$@"
}
load_state() {
if [ -f "$STATE_FILE" ]; then
# shellcheck disable=SC1090
source "$STATE_FILE"
fi
}
save_state() {
cat > "$STATE_FILE" <<EOF
# Auto-generated by deploy-aws.sh — do not edit manually
DISTRIBUTION_ID="$DISTRIBUTION_ID"
OAC_ID="$OAC_ID"
BUCKET_NAME="$BUCKET_NAME"
REGION="$REGION"
CLOUDFRONT_DOMAIN="$CLOUDFRONT_DOMAIN"
EOF
info "State saved to $STATE_FILE"
}
show_help() {
cat <<'HELP'
Edge AI Platform — AWS Frontend Deployment
Usage:
bash scripts/deploy-aws.sh [OPTIONS]
Options:
--setup First-time setup: create S3 bucket + CloudFront distribution
--build Build the frontend before deploying
--help Show this help message
Environment:
AWS_BUCKET_NAME S3 bucket name (default: edge-ai-platform-frontend)
AWS_REGION AWS region (default: ap-northeast-1)
AWS_PROFILE AWS CLI profile (optional)
Examples:
# First-time deployment (creates infrastructure + deploys)
bash scripts/deploy-aws.sh --setup --build
# Update deployment (build + sync + invalidate cache)
bash scripts/deploy-aws.sh --build
# Quick sync (frontend already built)
bash scripts/deploy-aws.sh
# Custom bucket and region
AWS_BUCKET_NAME=my-app AWS_REGION=us-east-1 bash scripts/deploy-aws.sh --setup --build
HELP
}
# ── Check Prerequisites ──────────────────────────────────
check_aws_cli() {
if ! command -v aws &>/dev/null; then
error "AWS CLI not found. Install: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
fi
if ! aws_cmd sts get-caller-identity &>/dev/null; then
error "AWS credentials not configured. Run: aws configure"
fi
local identity
identity=$(aws_cmd sts get-caller-identity --output text --query 'Account')
info "AWS Account: $identity"
}
# ── Build Frontend ───────────────────────────────────────
build_frontend() {
step "Building frontend"
if ! command -v pnpm &>/dev/null; then
error "pnpm not found. Install: npm install -g pnpm"
fi
(cd "$FRONTEND_DIR" && pnpm build)
info "Frontend built: $OUT_DIR"
}
# ── Setup S3 Bucket ──────────────────────────────────────
setup_s3_bucket() {
step "Setting up S3 bucket: $BUCKET_NAME"
# Create bucket if not exists
if aws_cmd s3api head-bucket --bucket "$BUCKET_NAME" 2>/dev/null; then
info "Bucket already exists: $BUCKET_NAME"
else
info "Creating bucket: $BUCKET_NAME"
if [ "$REGION" = "us-east-1" ]; then
aws_cmd s3api create-bucket --bucket "$BUCKET_NAME" --region "$REGION"
else
aws_cmd s3api create-bucket --bucket "$BUCKET_NAME" --region "$REGION" \
--create-bucket-configuration LocationConstraint="$REGION"
fi
info "Bucket created"
fi
# Block all public access (CloudFront OAC will access it)
aws_cmd s3api put-public-access-block --bucket "$BUCKET_NAME" \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
info "Public access blocked (CloudFront OAC will be used)"
}
# ── Setup CloudFront ─────────────────────────────────────
setup_cloudfront() {
step "Setting up CloudFront distribution"
# Create OAC (Origin Access Control)
local oac_name="edge-ai-platform-oac"
OAC_ID=$(aws_cmd cloudfront list-origin-access-controls --output text \
--query "OriginAccessControlList.Items[?Name=='$oac_name'].Id | [0]" 2>/dev/null || true)
if [ -z "$OAC_ID" ] || [ "$OAC_ID" = "None" ]; then
info "Creating Origin Access Control..."
OAC_ID=$(aws_cmd cloudfront create-origin-access-control \
--origin-access-control-config \
"Name=$oac_name,Description=Edge AI Platform OAC,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3" \
--output text --query 'OriginAccessControl.Id')
info "OAC created: $OAC_ID"
else
info "OAC already exists: $OAC_ID"
fi
# Check if distribution already exists
DISTRIBUTION_ID=$(aws_cmd cloudfront list-distributions --output text \
--query "DistributionList.Items[?Origins.Items[0].DomainName=='${BUCKET_NAME}.s3.${REGION}.amazonaws.com'].Id | [0]" 2>/dev/null || true)
if [ -n "$DISTRIBUTION_ID" ] && [ "$DISTRIBUTION_ID" != "None" ]; then
info "CloudFront distribution already exists: $DISTRIBUTION_ID"
CLOUDFRONT_DOMAIN=$(aws_cmd cloudfront get-distribution --id "$DISTRIBUTION_ID" \
--output text --query 'Distribution.DomainName')
else
info "Creating CloudFront distribution..."
local caller_ref
caller_ref="edge-ai-$(date +%s)"
local dist_config
dist_config=$(cat <<DISTCONFIG
{
"CallerReference": "$caller_ref",
"Comment": "Edge AI Platform Frontend",
"Enabled": true,
"DefaultRootObject": "index.html",
"Origins": {
"Quantity": 1,
"Items": [
{
"Id": "S3-${BUCKET_NAME}",
"DomainName": "${BUCKET_NAME}.s3.${REGION}.amazonaws.com",
"OriginAccessControlId": "$OAC_ID",
"S3OriginConfig": {
"OriginAccessIdentity": ""
}
}
]
},
"DefaultCacheBehavior": {
"TargetOriginId": "S3-${BUCKET_NAME}",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
}
},
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true
},
"CustomErrorResponses": {
"Quantity": 1,
"Items": [
{
"ErrorCode": 403,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 0
}
]
},
"PriceClass": "PriceClass_200",
"HttpVersion": "http2and3"
}
DISTCONFIG
)
local result
result=$(aws_cmd cloudfront create-distribution \
--distribution-config "$dist_config" \
--output json)
DISTRIBUTION_ID=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['Distribution']['Id'])")
CLOUDFRONT_DOMAIN=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['Distribution']['DomainName'])")
info "CloudFront distribution created: $DISTRIBUTION_ID"
info "Domain: $CLOUDFRONT_DOMAIN"
fi
# Set S3 bucket policy to allow CloudFront OAC
local account_id
account_id=$(aws_cmd sts get-caller-identity --output text --query 'Account')
local bucket_policy
bucket_policy=$(cat <<POLICY
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::${BUCKET_NAME}/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::${account_id}:distribution/${DISTRIBUTION_ID}"
}
}
}
]
}
POLICY
)
aws_cmd s3api put-bucket-policy --bucket "$BUCKET_NAME" --policy "$bucket_policy"
info "Bucket policy updated for CloudFront OAC"
save_state
}
# ── Sync Files to S3 ────────────────────────────────────
sync_to_s3() {
step "Syncing files to S3"
if [ ! -d "$OUT_DIR" ]; then
error "Frontend build output not found: $OUT_DIR\n Run with --build flag or run 'cd frontend && pnpm build' first."
fi
# 1. HTML files — no cache (always fresh)
info "Uploading HTML files (no cache)..."
aws_cmd s3 sync "$OUT_DIR" "s3://$BUCKET_NAME" \
--exclude "*" \
--include "*.html" \
--cache-control "public, max-age=0, must-revalidate" \
--content-type "text/html" \
--delete
# 2. _next/ assets — immutable long cache (hashed filenames)
info "Uploading _next/ assets (immutable cache)..."
aws_cmd s3 sync "$OUT_DIR/_next" "s3://$BUCKET_NAME/_next" \
--cache-control "public, max-age=31536000, immutable"
# 3. Everything else — 1 day cache
info "Uploading other static assets..."
aws_cmd s3 sync "$OUT_DIR" "s3://$BUCKET_NAME" \
--exclude "*.html" \
--exclude "_next/*" \
--cache-control "public, max-age=86400" \
--delete
info "Sync complete"
}
# ── Invalidate CloudFront Cache ──────────────────────────
invalidate_cache() {
step "Invalidating CloudFront cache"
if [ -z "${DISTRIBUTION_ID:-}" ] || [ "$DISTRIBUTION_ID" = "None" ]; then
warn "No CloudFront distribution ID found. Skipping invalidation."
return
fi
local inv_id
inv_id=$(aws_cmd cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION_ID" \
--paths "/*" \
--output text --query 'Invalidation.Id')
info "Invalidation created: $inv_id"
info "Note: CloudFront invalidation may take 1-2 minutes to propagate."
}
# ── Main ─────────────────────────────────────────────────
DO_BUILD=false
DO_SETUP=false
for arg in "$@"; do
case "$arg" in
--build) DO_BUILD=true ;;
--setup) DO_SETUP=true ;;
--help) show_help; exit 0 ;;
*) error "Unknown option: $arg. Use --help for usage." ;;
esac
done
echo ""
info "Edge AI Platform — AWS Frontend Deployment"
echo ""
info "Bucket: $BUCKET_NAME"
info "Region: $REGION"
echo ""
# Load saved state
DISTRIBUTION_ID=""
OAC_ID=""
CLOUDFRONT_DOMAIN=""
load_state
# Check AWS CLI
check_aws_cli
# Build if requested
if [ "$DO_BUILD" = true ]; then
build_frontend
fi
# Setup infrastructure if requested
if [ "$DO_SETUP" = true ]; then
setup_s3_bucket
setup_cloudfront
fi
# Sync files
sync_to_s3
# Invalidate cache
invalidate_cache
# Done
echo ""
echo -e "${GREEN}=== Deployment complete! ===${NC}"
echo ""
if [ -n "${CLOUDFRONT_DOMAIN:-}" ] && [ "$CLOUDFRONT_DOMAIN" != "None" ]; then
info "URL: https://$CLOUDFRONT_DOMAIN"
echo ""
info "Next steps:"
info " 1. Open the URL above in your browser"
info " 2. Go to Settings and set the Backend URL to your local server"
info " (e.g., http://<your-local-ip>:3721)"
info " 3. Start your local edge-ai-server and connect!"
else
info "Files uploaded to s3://$BUCKET_NAME"
info "Run with --setup flag to create CloudFront distribution."
fi
echo ""

View File

@ -1,481 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Edge AI Platform — EC2 Frontend + Relay Deployment (nginx)
#
# Builds the frontend locally, uploads to EC2 via scp, and configures nginx.
# Optionally deploys the relay-server binary for cloud-to-local tunnelling.
#
# Usage:
# bash scripts/deploy-ec2.sh user@host # deploy frontend only
# bash scripts/deploy-ec2.sh user@host --build # build + deploy frontend
# bash scripts/deploy-ec2.sh user@host --relay # deploy frontend + relay server (multi-tenant)
# bash scripts/deploy-ec2.sh user@host --key ~/.ssh/id.pem # specify SSH key
# bash scripts/deploy-ec2.sh --help
#
# First-time: installs nginx + configures SPA routing automatically.
# Subsequent runs: uploads files + reloads nginx.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FRONTEND_DIR="$PROJECT_ROOT/frontend"
OUT_DIR="$FRONTEND_DIR/out"
RELAY_BINARY="$PROJECT_ROOT/dist/relay-server"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
step() { echo -e "\n${CYAN}=== $* ===${NC}\n"; }
show_help() {
cat <<'HELP'
Edge AI Platform — EC2 Frontend + Relay Deployment
Usage:
bash scripts/deploy-ec2.sh <user@host> [OPTIONS]
Arguments:
user@host SSH destination (e.g., ec2-user@1.2.3.4, ubuntu@myhost)
Options:
--key <path> Path to SSH private key (e.g., ~/.ssh/mykey.pem)
--build Build the frontend before deploying
--relay Deploy relay-server for cloud-to-local tunnelling (multi-tenant)
--relay-port <port> Relay server listen port (default: 3800)
--port <port> nginx listening port (default: 80)
--help Show this help message
Examples:
# Frontend only
bash scripts/deploy-ec2.sh ec2-user@54.199.1.2 --key ~/.ssh/mykey.pem --build
# Frontend + relay server (multi-tenant, no token needed)
bash scripts/deploy-ec2.sh ec2-user@54.199.1.2 --key ~/.ssh/mykey.pem --build --relay
# Update relay-server only (no frontend rebuild)
bash scripts/deploy-ec2.sh ec2-user@54.199.1.2 --key ~/.ssh/mykey.pem --relay
# Connect local server to the relay:
# ./edge-ai-server --relay-url ws://ec2-host:3800/tunnel/connect # token auto-generated from hardware ID
HELP
}
# ── Parse Arguments ──────────────────────────────────────
SSH_HOST=""
SSH_KEY=""
DO_BUILD=false
DO_RELAY=false
NGINX_PORT=80
RELAY_PORT=3800
RELAY_TOKEN=""
while [ $# -gt 0 ]; do
case "$1" in
--key) SSH_KEY="$2"; shift 2 ;;
--build) DO_BUILD=true; shift ;;
--relay) DO_RELAY=true; shift ;;
--relay-token) RELAY_TOKEN="$2"; shift 2 ;;
--relay-port) RELAY_PORT="$2"; shift 2 ;;
--port) NGINX_PORT="$2"; shift 2 ;;
--help) show_help; exit 0 ;;
-*) error "Unknown option: $1. Use --help for usage." ;;
*)
if [ -z "$SSH_HOST" ]; then
SSH_HOST="$1"
else
error "Unexpected argument: $1"
fi
shift ;;
esac
done
if [ -z "$SSH_HOST" ]; then
error "Missing SSH destination. Usage: bash scripts/deploy-ec2.sh user@host [--key key.pem] [--build] [--relay]"
fi
# Build SSH command
SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10"
if [ -n "$SSH_KEY" ]; then
SSH_OPTS="$SSH_OPTS -i $SSH_KEY"
fi
ssh_cmd() {
# shellcheck disable=SC2086
ssh $SSH_OPTS "$SSH_HOST" "$@"
}
scp_cmd() {
# shellcheck disable=SC2086
scp $SSH_OPTS "$@"
}
# Count total steps
TOTAL_STEPS=4
if [ "$DO_RELAY" = true ]; then
TOTAL_STEPS=5
fi
# ── Main ─────────────────────────────────────────────────
echo ""
info "Edge AI Platform — EC2 Deployment"
echo ""
info "Host: $SSH_HOST"
info "Port: $NGINX_PORT"
if [ "$DO_RELAY" = true ]; then
info "Relay: enabled (port $RELAY_PORT)"
fi
echo ""
# 1. Build frontend if requested
if [ "$DO_BUILD" = true ]; then
step "1/$TOTAL_STEPS Building frontend"
if ! command -v pnpm &>/dev/null; then
error "pnpm not found. Install: npm install -g pnpm"
fi
(cd "$FRONTEND_DIR" && pnpm build)
info "Frontend built: $OUT_DIR"
else
step "1/$TOTAL_STEPS Checking build output"
fi
if [ ! -d "$OUT_DIR" ]; then
error "Frontend build output not found: $OUT_DIR\n Run with --build flag or run 'cd frontend && pnpm build' first."
fi
# Build relay-server if needed
if [ "$DO_RELAY" = true ] && [ ! -f "$RELAY_BINARY" ]; then
info "Building relay-server binary..."
(cd "$PROJECT_ROOT" && make build-relay)
fi
# 2. Test SSH connection
step "2/$TOTAL_STEPS Connecting to EC2"
if ! ssh_cmd "echo 'SSH connection OK'" 2>/dev/null; then
error "Cannot connect to $SSH_HOST. Check your host and key."
fi
info "SSH connection successful"
# Detect OS on remote
REMOTE_OS=$(ssh_cmd "cat /etc/os-release 2>/dev/null | head -1 || echo unknown")
info "Remote OS: $REMOTE_OS"
# Detect package manager
PKG_MGR=$(ssh_cmd "command -v apt-get >/dev/null 2>&1 && echo apt || (command -v yum >/dev/null 2>&1 && echo yum || (command -v dnf >/dev/null 2>&1 && echo dnf || echo unknown))")
info "Package manager: $PKG_MGR"
# 3. Install and configure nginx
step "3/$TOTAL_STEPS Setting up nginx on EC2"
DEPLOY_DIR="/var/www/edge-ai-platform"
ssh_cmd "DEPLOY_DIR=$DEPLOY_DIR NGINX_PORT=$NGINX_PORT RELAY_PORT=$RELAY_PORT DO_RELAY=$DO_RELAY PKG_MGR=$PKG_MGR bash -s" <<'REMOTE_SETUP'
set -euo pipefail
# Install nginx if not present
if ! command -v nginx &>/dev/null; then
echo "[INFO] Installing nginx..."
if [ "$PKG_MGR" = "apt" ]; then
sudo apt-get update -qq
sudo apt-get install -y -qq nginx
elif [ "$PKG_MGR" = "yum" ]; then
sudo yum install -y nginx
elif [ "$PKG_MGR" = "dnf" ]; then
sudo dnf install -y nginx
else
echo "[ERROR] Unknown package manager. Install nginx manually."
exit 1
fi
echo "[INFO] nginx installed"
else
echo "[INFO] nginx already installed"
fi
# Create deploy directory
sudo mkdir -p "$DEPLOY_DIR"
sudo chown "$(whoami):$(id -gn)" "$DEPLOY_DIR"
# Build nginx config
if [ "$DO_RELAY" = "true" ]; then
# With relay proxy rules
sudo tee /etc/nginx/conf.d/edge-ai-platform.conf > /dev/null <<NGINX_CONF
server {
listen ${NGINX_PORT};
server_name _;
root ${DEPLOY_DIR};
index index.html;
# API requests → relay server
location /api/ {
proxy_pass http://127.0.0.1:${RELAY_PORT};
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
client_max_body_size 100M;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
# WebSocket connections → relay server
location /ws/ {
proxy_pass http://127.0.0.1:${RELAY_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_set_header Host \$host;
}
# Tunnel endpoint (edge-ai-server ↔ relay)
location /tunnel/ {
proxy_pass http://127.0.0.1:${RELAY_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
# Relay status
location /relay/ {
proxy_pass http://127.0.0.1:${RELAY_PORT};
}
# Next.js hashed assets: cache forever
location /_next/static/ {
expires max;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Other static assets: 1 day cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ {
expires 1d;
add_header Cache-Control "public, max-age=86400";
}
# Next.js dynamic route fallbacks — serve the placeholder HTML for each
# dynamic segment so that client-side hydration renders the correct page.
location ~ ^/devices/[^/]+(/?)$ {
try_files \$uri /devices/_/index.html;
}
location ~ ^/models/[^/]+(/?)$ {
try_files \$uri /models/_/index.html;
}
location ~ ^/workspace/cluster/[^/]+(/?)$ {
try_files \$uri /workspace/cluster/_/index.html;
}
location ~ ^/workspace/[^/]+(/?)$ {
try_files \$uri /workspace/_/index.html;
}
# SPA fallback: all other routes serve index.html
location / {
try_files \$uri \$uri/ /index.html;
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1000;
}
NGINX_CONF
else
# Frontend only (no relay)
sudo tee /etc/nginx/conf.d/edge-ai-platform.conf > /dev/null <<NGINX_CONF
server {
listen ${NGINX_PORT};
server_name _;
root ${DEPLOY_DIR};
index index.html;
# Next.js hashed assets: cache forever
location /_next/static/ {
expires max;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Other static assets: 1 day cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ {
expires 1d;
add_header Cache-Control "public, max-age=86400";
}
# Next.js dynamic route fallbacks
location ~ ^/devices/[^/]+(/?)$ {
try_files \$uri /devices/_/index.html;
}
location ~ ^/models/[^/]+(/?)$ {
try_files \$uri /models/_/index.html;
}
location ~ ^/workspace/cluster/[^/]+(/?)$ {
try_files \$uri /workspace/cluster/_/index.html;
}
location ~ ^/workspace/[^/]+(/?)$ {
try_files \$uri /workspace/_/index.html;
}
# SPA fallback: all routes serve index.html
location / {
try_files \$uri \$uri/ /index.html;
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1000;
}
NGINX_CONF
fi
# Disable default site if it exists (avoid port conflict)
if [ -f /etc/nginx/sites-enabled/default ]; then
sudo rm -f /etc/nginx/sites-enabled/default
fi
# Comment out default server block in nginx.conf if present (Amazon Linux)
if grep -q "^[[:space:]]*server {" /etc/nginx/nginx.conf 2>/dev/null; then
sudo sed -i '/^[[:space:]]*server {/,/^[[:space:]]*}/s/^/#/' /etc/nginx/nginx.conf
echo "[INFO] Commented out default server block in nginx.conf"
fi
# Amazon Linux: ensure nginx starts on boot
sudo systemctl enable nginx 2>/dev/null || true
# Test nginx config
sudo nginx -t
echo "[INFO] nginx configured"
REMOTE_SETUP
info "nginx configured on EC2"
# 4. Upload frontend files
step "4/$TOTAL_STEPS Uploading frontend files"
# Create a tarball for faster upload
TAR_FILE=$(mktemp /tmp/edge-ai-frontend-XXXXX.tar.gz)
(cd "$OUT_DIR" && tar czf "$TAR_FILE" .)
TAR_SIZE=$(du -h "$TAR_FILE" | cut -f1)
info "Archive size: $TAR_SIZE"
info "Uploading to $SSH_HOST:$DEPLOY_DIR ..."
scp_cmd "$TAR_FILE" "$SSH_HOST:/tmp/edge-ai-frontend.tar.gz"
rm -f "$TAR_FILE"
# Extract on remote and restart nginx
ssh_cmd "DEPLOY_DIR=$DEPLOY_DIR bash -s" <<'REMOTE_DEPLOY'
set -euo pipefail
# Clear old files and extract new
rm -rf "${DEPLOY_DIR:?}"/*
tar xzf /tmp/edge-ai-frontend.tar.gz -C "$DEPLOY_DIR"
rm -f /tmp/edge-ai-frontend.tar.gz
# Restart nginx
sudo systemctl reload nginx 2>/dev/null || sudo systemctl restart nginx
echo "[INFO] Files deployed and nginx reloaded"
REMOTE_DEPLOY
info "Upload complete"
# 5. Deploy relay-server (if --relay)
if [ "$DO_RELAY" = true ]; then
step "5/$TOTAL_STEPS Deploying relay-server"
if [ ! -f "$RELAY_BINARY" ]; then
error "Relay binary not found at $RELAY_BINARY. Run 'make build-relay' first."
fi
info "Uploading relay-server binary..."
scp_cmd "$RELAY_BINARY" "$SSH_HOST:/tmp/relay-server"
ssh_cmd "RELAY_PORT=$RELAY_PORT RELAY_TOKEN=$RELAY_TOKEN bash -s" <<'REMOTE_RELAY'
set -euo pipefail
RELAY_DIR="/opt/edge-ai-relay"
RELAY_BIN="$RELAY_DIR/relay-server"
# Stop existing service if running
sudo systemctl stop edge-ai-relay 2>/dev/null || true
# Install binary
sudo mkdir -p "$RELAY_DIR"
sudo mv /tmp/relay-server "$RELAY_BIN"
sudo chmod +x "$RELAY_BIN"
# Build relay-server command with flags (multi-tenant: no --token needed)
RELAY_EXEC="$RELAY_BIN --port $RELAY_PORT"
# Create systemd service
sudo tee /etc/systemd/system/edge-ai-relay.service > /dev/null <<SERVICE
[Unit]
Description=Edge AI Relay Server
After=network.target
[Service]
Type=simple
ExecStart=$RELAY_EXEC
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
SERVICE
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable edge-ai-relay
sudo systemctl start edge-ai-relay
echo "[INFO] relay-server deployed and started"
echo "[INFO] Check status: sudo systemctl status edge-ai-relay"
echo "[INFO] View logs: sudo journalctl -u edge-ai-relay -f"
REMOTE_RELAY
info "relay-server deployed and running on port $RELAY_PORT"
fi
# Get public IP
PUBLIC_IP=$(ssh_cmd "curl -s --connect-timeout 3 http://169.254.169.254/latest/meta-data/public-ipv4 2>/dev/null || echo '$SSH_HOST'" | tail -1)
# Done
echo ""
echo -e "${GREEN}=== Deployment complete! ===${NC}"
echo ""
if [ "$NGINX_PORT" = "80" ]; then
info "URL: http://$PUBLIC_IP"
else
info "URL: http://$PUBLIC_IP:$NGINX_PORT"
fi
echo ""
info "Next steps:"
info " 1. Ensure EC2 Security Group allows inbound port $NGINX_PORT (HTTP)"
info " 2. Open the URL above in your browser"
if [ "$DO_RELAY" = true ]; then
echo ""
info "Relay tunnel:"
info " Connect your local edge-ai-server to the cloud relay:"
info " ./edge-ai-server --relay-url ws://$PUBLIC_IP:$RELAY_PORT/tunnel/connect"
echo ""
info " Check relay status: curl http://$PUBLIC_IP/relay/status"
else
echo ""
info " 3. Go to Settings and set the Backend URL to your local edge-ai-server"
info " (e.g., http://<your-local-ip>:3721)"
fi
echo ""
info "To update later:"
info " bash scripts/deploy-ec2.sh $SSH_HOST${SSH_KEY:+ --key $SSH_KEY} --build${DO_RELAY:+ --relay}"
echo ""

View File

@ -1,282 +0,0 @@
# Edge AI Platform Installer for Windows
# Usage: irm https://gitea.innovedus.com/.../install.ps1 | iex
#
# Installs:
# 1. Edge AI Platform binary + data files
# 2. Python venv with pyusb (for Kneron hardware)
# 3. Optional: ffmpeg, yt-dlp (shows install hints)
#
# Uninstall:
# Remove-Item -Recurse -Force "$env:LOCALAPPDATA\EdgeAIPlatform"
# # Then remove EdgeAIPlatform from your PATH in System Environment Variables
$ErrorActionPreference = "Stop"
$Version = if ($env:EDGE_AI_VERSION) { $env:EDGE_AI_VERSION } else { "latest" }
$InstallDir = if ($env:EDGE_AI_INSTALL_DIR) { $env:EDGE_AI_INSTALL_DIR } else { "$env:LOCALAPPDATA\EdgeAIPlatform" }
$VenvDir = "$InstallDir\venv"
$GiteaServer = if ($env:GITEA_SERVER) { $env:GITEA_SERVER } else { "https://gitea.innovedus.com" }
$Repo = "warrenchen/web_academy_prototype"
function Write-Info($msg) { Write-Host "[INFO] $msg" -ForegroundColor Green }
function Write-Warn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow }
function Write-Step($msg) { Write-Host "`n=== $msg ===`n" -ForegroundColor Cyan }
function Write-Err($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red; exit 1 }
Write-Host ""
Write-Info "Edge AI Platform Installer"
Write-Host ""
# ── Step 1/5: Download and install binary ──
Write-Step "1/5 Installing Edge AI Platform"
# Resolve latest version
if ($Version -eq "latest") {
$apiUrl = "$GiteaServer/api/v1/repos/$Repo/releases/latest"
try {
$release = Invoke-RestMethod -Uri $apiUrl -UseBasicParsing
$Version = $release.tag_name
} catch {
Write-Err "Failed to resolve latest version: $_"
}
}
Write-Info "Version: $Version"
# Download
$arch = "amd64"
$archiveName = "edge-ai-platform_${Version}_windows_${arch}.zip"
$downloadUrl = "$GiteaServer/$Repo/releases/download/$Version/$archiveName"
$tmpDir = Join-Path $env:TEMP "edge-ai-install-$(Get-Random)"
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
$zipPath = Join-Path $tmpDir $archiveName
Write-Info "Downloading from: $downloadUrl"
try {
Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -UseBasicParsing
} catch {
Write-Err "Download failed. Check version and URL: $_"
}
# Extract
Write-Info "Extracting to $InstallDir ..."
if (Test-Path $InstallDir) {
# Preserve venv if it exists
$preserveVenv = $false
if (Test-Path $VenvDir) {
$venvBackup = Join-Path $env:TEMP "edge-ai-venv-backup-$(Get-Random)"
Move-Item -Path $VenvDir -Destination $venvBackup -Force
$preserveVenv = $true
}
Remove-Item -Recurse -Force $InstallDir
}
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Expand-Archive -Path $zipPath -DestinationPath $tmpDir -Force
# Move files from the extracted subfolder
$extractedDir = Get-ChildItem -Path $tmpDir -Directory | Where-Object { $_.Name -like "edge-ai-platform_*" } | Select-Object -First 1
if ($extractedDir) {
Copy-Item -Path "$($extractedDir.FullName)\*" -Destination $InstallDir -Recurse -Force
} else {
Copy-Item -Path "$tmpDir\*" -Destination $InstallDir -Recurse -Force
}
# Restore venv if backed up
if ($preserveVenv -and (Test-Path $venvBackup)) {
Move-Item -Path $venvBackup -Destination $VenvDir -Force
Write-Info "Restored existing Python venv"
}
# Add to PATH (user scope)
$currentPath = [Environment]::GetEnvironmentVariable("PATH", "User")
if ($currentPath -notlike "*$InstallDir*") {
[Environment]::SetEnvironmentVariable("PATH", "$currentPath;$InstallDir", "User")
Write-Info "Added $InstallDir to PATH (restart terminal to take effect)"
} else {
Write-Info "$InstallDir already in PATH"
}
# Cleanup temp
Remove-Item -Recurse -Force $tmpDir
# ── Step 2/5: Install libusb (Windows) ──
Write-Step "2/5 Setting up USB driver"
# On Windows, libusb is typically bundled or installed via Zadig.
# Check if libusb-1.0.dll is accessible
$libusbFound = $false
$libusbPaths = @(
"$env:SystemRoot\System32\libusb-1.0.dll",
"$env:SystemRoot\SysWOW64\libusb-1.0.dll",
"$InstallDir\libusb-1.0.dll"
)
foreach ($p in $libusbPaths) {
if (Test-Path $p) {
$libusbFound = $true
break
}
}
if ($libusbFound) {
Write-Info "libusb: found"
} else {
Write-Warn "libusb: NOT FOUND"
Write-Warn " Kneron USB devices require a libusb-compatible driver."
Write-Warn " Option 1: Download Zadig (https://zadig.akeo.ie/) and install WinUSB driver"
Write-Warn " Option 2: Install libusb via vcpkg or manually copy libusb-1.0.dll"
}
# ── Step 3/5: Setup Python venv + pyusb ──
Write-Step "3/5 Setting up Kneron hardware environment"
$pythonCmd = $null
# Try python3 first, then python (Windows often uses 'python' for Python 3)
if (Get-Command python3 -ErrorAction SilentlyContinue) {
$pythonCmd = "python3"
} elseif (Get-Command python -ErrorAction SilentlyContinue) {
$pyVer = & python --version 2>&1
if ($pyVer -match "Python 3") {
$pythonCmd = "python"
}
}
if (-not $pythonCmd) {
Write-Warn "Python 3 not found. Skipping Kneron hardware setup."
Write-Warn " Install: winget install Python.Python.3.12"
} else {
$pyVersion = & $pythonCmd --version 2>&1
Write-Info "Python: $pyVersion"
$venvPython = "$VenvDir\Scripts\python.exe"
$venvPip = "$VenvDir\Scripts\pip.exe"
# Check if venv already set up with pyusb
$venvReady = $false
if ((Test-Path $venvPython) -and (Test-Path $venvPip)) {
try {
& $venvPython -c "import usb.core" 2>$null
if ($LASTEXITCODE -eq 0) { $venvReady = $true }
} catch {}
}
if ($venvReady) {
Write-Info "Python venv already set up: $VenvDir"
} else {
Write-Info "Creating Python venv: $VenvDir ..."
& $pythonCmd -m venv $VenvDir
if ($LASTEXITCODE -ne 0) {
Write-Warn "Failed to create Python venv. Skipping Kneron setup."
} else {
Write-Info "Installing pyusb ..."
& $venvPip install --quiet pyusb
if ($LASTEXITCODE -ne 0) {
Write-Warn "Failed to install pyusb."
} else {
Write-Info "Python environment ready"
}
}
}
}
# ── Step 4/5: Check environment ──
Write-Step "4/5 Checking environment"
# Check optional dependencies
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) {
$ffmpegVer = & ffmpeg -version 2>&1 | Select-Object -First 1
Write-Info "ffmpeg: $ffmpegVer"
} else {
Write-Warn "ffmpeg: NOT FOUND"
Write-Warn " Camera capture requires ffmpeg."
Write-Warn " Install: winget install Gyan.FFmpeg"
}
if (Get-Command yt-dlp -ErrorAction SilentlyContinue) {
$ytdlpVer = & yt-dlp --version 2>&1
Write-Info "yt-dlp: $ytdlpVer"
} else {
Write-Warn "yt-dlp: NOT FOUND (optional, for YouTube URL support)"
Write-Warn " Install: winget install yt-dlp"
}
# Detect Kneron hardware
Write-Host ""
Write-Info "Detecting Kneron hardware..."
$detectScript = "$InstallDir\scripts\kneron_detect.py"
$venvPythonPath = "$VenvDir\Scripts\python.exe"
if ((Test-Path $venvPythonPath) -and (Test-Path $detectScript)) {
try {
& $venvPythonPath $detectScript
} catch {
Write-Warn "Hardware detection failed: $_"
}
} else {
Write-Warn "Skipping hardware detection (Python venv or detection script not available)"
}
# ── Step 5/5: Setup auto-restart service ──
Write-Step "5/5 Setting up auto-restart service"
$TaskName = "EdgeAIPlatformServer"
$BinPath = Join-Path $InstallDir "edge-ai-server.exe"
$LogDir = Join-Path $InstallDir "logs"
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
# Remove existing scheduled task if present
try {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
} catch {}
# Create a scheduled task that:
# 1. Runs at user logon
# 2. Restarts on failure (up to 3 times with 5-second delay)
$Action = New-ScheduledTaskAction -Execute $BinPath -WorkingDirectory $InstallDir
$Trigger = New-ScheduledTaskTrigger -AtLogOn
$Settings = New-ScheduledTaskSettingsSet `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Seconds 5) `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Days 0)
try {
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Description "Edge AI Platform Server - auto-starts and restarts on failure" -Force | Out-Null
Write-Info "Scheduled task installed: $TaskName"
Write-Info " Server will auto-start on logon and restart on crash (up to 3 times)."
Write-Info " Logs: $LogDir"
Write-Info ""
Write-Info " Manual controls:"
Write-Info " Start-ScheduledTask -TaskName $TaskName # start"
Write-Info " Stop-ScheduledTask -TaskName $TaskName # stop"
Write-Info " Unregister-ScheduledTask -TaskName $TaskName # remove"
# Start the server now
Start-ScheduledTask -TaskName $TaskName
Write-Info "Server started."
} catch {
Write-Warn "Failed to create scheduled task: $_"
Write-Warn "You can start the server manually: $BinPath"
}
# ── Done ──
Write-Host ""
Write-Host "=== Installation complete! ===" -ForegroundColor Green
Write-Host ""
Write-Info "Installed to: $InstallDir"
Write-Info "Server is running and will auto-restart on crash."
Write-Info ""
Write-Info "Open: http://127.0.0.1:3721"
Write-Host ""
Write-Info "Uninstall:"
Write-Info " Unregister-ScheduledTask -TaskName $TaskName -Confirm:`$false"
Write-Info " Remove-Item -Recurse -Force `"$InstallDir`""
Write-Info " # Remove EdgeAIPlatform from PATH in System Environment Variables"

View File

@ -1,344 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Edge AI Platform Installer (macOS / Linux)
# Usage: curl -fsSL https://gitea.innovedus.com/.../install.sh | bash
#
# Installs:
# 1. Edge AI Platform binary + data files
# 2. Python venv with pyusb (for Kneron hardware)
# 3. Optional: ffmpeg, yt-dlp (prompts user)
#
# Uninstall:
# rm -rf ~/.edge-ai-platform
# sudo rm -f /usr/local/bin/edge-ai-server
VERSION="${EDGE_AI_VERSION:-latest}"
INSTALL_DIR="${EDGE_AI_INSTALL_DIR:-$HOME/.edge-ai-platform}"
VENV_DIR="$INSTALL_DIR/venv"
BIN_LINK="/usr/local/bin/edge-ai-server"
GITEA_SERVER="${GITEA_SERVER:-https://gitea.innovedus.com}"
REPO="warrenchen/web_academy_prototype"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
step() { echo -e "\n${CYAN}=== $* ===${NC}\n"; }
detect_platform() {
local os arch
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$os" in
darwin) os="darwin" ;;
linux) os="linux" ;;
*) error "Unsupported OS: $os" ;;
esac
case "$arch" in
x86_64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*) error "Unsupported architecture: $arch" ;;
esac
echo "${os}_${arch}"
}
resolve_version() {
if [ "$VERSION" = "latest" ]; then
local api_url="${GITEA_SERVER}/api/v1/repos/${REPO}/releases/latest"
VERSION=$(curl -fsSL "$api_url" 2>/dev/null | grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4) || true
if [ -z "$VERSION" ]; then
error "Failed to resolve latest version. Set EDGE_AI_VERSION manually."
fi
fi
info "Version: $VERSION"
}
install_binary() {
local platform="$1"
local archive_name="edge-ai-platform_${VERSION}_${platform}.tar.gz"
local download_url="${GITEA_SERVER}/${REPO}/releases/download/${VERSION}/${archive_name}"
info "Downloading from: $download_url"
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
curl -fsSL "$download_url" -o "$tmp_dir/archive.tar.gz" || error "Download failed. Check version and platform."
info "Extracting to $INSTALL_DIR ..."
mkdir -p "$INSTALL_DIR"
tar -xzf "$tmp_dir/archive.tar.gz" -C "$INSTALL_DIR" --strip-components=1
chmod +x "$INSTALL_DIR/edge-ai-server"
# Remove macOS quarantine attribute
xattr -d com.apple.quarantine "$INSTALL_DIR/edge-ai-server" 2>/dev/null || true
}
create_symlink() {
if [ -w "$(dirname "$BIN_LINK")" ]; then
ln -sf "$INSTALL_DIR/edge-ai-server" "$BIN_LINK"
info "Symlinked to $BIN_LINK"
else
info "Creating symlink requires sudo..."
sudo ln -sf "$INSTALL_DIR/edge-ai-server" "$BIN_LINK"
info "Symlinked to $BIN_LINK (via sudo)"
fi
}
setup_python_venv() {
if ! command -v python3 &>/dev/null; then
warn "python3 not found. Skipping Kneron hardware setup."
warn " Install: brew install python3 (macOS) / apt install python3 (Linux)"
return
fi
info "Python: $(python3 --version 2>&1)"
if [ -d "$VENV_DIR" ] && "$VENV_DIR/bin/python3" -c "import usb.core" 2>/dev/null; then
info "Python venv already set up: $VENV_DIR"
return
fi
info "Creating Python venv: $VENV_DIR ..."
python3 -m venv "$VENV_DIR"
info "Installing pyusb ..."
"$VENV_DIR/bin/pip" install --quiet pyusb
info "Python environment ready"
}
setup_libusb() {
local os_type
os_type="$(uname -s)"
if [ "$os_type" = "Darwin" ]; then
if ! command -v brew &>/dev/null; then
warn "Homebrew not found. Please install libusb manually."
return
fi
if brew list libusb &>/dev/null 2>&1; then
info "libusb: already installed"
else
info "Installing libusb ..."
brew install libusb
fi
elif [ "$os_type" = "Linux" ]; then
if dpkg -s libusb-1.0-0-dev &>/dev/null 2>&1; then
info "libusb: already installed"
else
info "Installing libusb ..."
sudo apt-get install -y libusb-1.0-0-dev
fi
fi
}
detect_kneron_devices() {
if [ ! -f "$VENV_DIR/bin/python3" ]; then
warn "Python venv not available, skipping device detection."
return
fi
"$VENV_DIR/bin/python3" "$INSTALL_DIR/scripts/kneron_detect.py" 2>/dev/null || true
}
setup_auto_restart() {
local os_type
os_type="$(uname -s)"
if [ "$os_type" = "Darwin" ]; then
setup_launchd_service
elif [ "$os_type" = "Linux" ]; then
setup_systemd_service
fi
}
setup_launchd_service() {
local plist_dir="$HOME/Library/LaunchAgents"
local plist_name="com.innovedus.edge-ai-server"
local plist_path="$plist_dir/$plist_name.plist"
local log_dir="$INSTALL_DIR/logs"
mkdir -p "$plist_dir"
mkdir -p "$log_dir"
cat > "$plist_path" <<PLIST
<?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>
<key>Label</key>
<string>${plist_name}</string>
<key>ProgramArguments</key>
<array>
<string>${INSTALL_DIR}/edge-ai-server</string>
</array>
<key>WorkingDirectory</key>
<string>${INSTALL_DIR}</string>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>5</integer>
<key>StandardOutPath</key>
<string>${log_dir}/server.log</string>
<key>StandardErrorPath</key>
<string>${log_dir}/server.err.log</string>
<key>ProcessType</key>
<string>Background</string>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
PLIST
# Load the service (unload first if already loaded)
launchctl unload "$plist_path" 2>/dev/null || true
launchctl load "$plist_path"
info "launchd service installed: $plist_name"
info " Server will auto-start on login and restart on crash."
info " Logs: $log_dir/server.log"
info ""
info " Manual controls:"
info " launchctl stop $plist_name # stop"
info " launchctl start $plist_name # start"
info " launchctl unload $plist_path # disable"
}
setup_systemd_service() {
local service_dir="$HOME/.config/systemd/user"
local service_name="edge-ai-server"
local service_path="$service_dir/$service_name.service"
local log_dir="$INSTALL_DIR/logs"
mkdir -p "$service_dir"
mkdir -p "$log_dir"
cat > "$service_path" <<SERVICE
[Unit]
Description=Edge AI Platform Server
After=network.target
[Service]
Type=simple
ExecStart=${INSTALL_DIR}/edge-ai-server
WorkingDirectory=${INSTALL_DIR}
Restart=on-failure
RestartSec=5
StandardOutput=append:${log_dir}/server.log
StandardError=append:${log_dir}/server.err.log
[Install]
WantedBy=default.target
SERVICE
# Reload and enable
systemctl --user daemon-reload
systemctl --user enable "$service_name.service"
systemctl --user start "$service_name.service"
info "systemd user service installed: $service_name"
info " Server will auto-start on login and restart on crash."
info " Logs: $log_dir/server.log"
info ""
info " Manual controls:"
info " systemctl --user stop $service_name # stop"
info " systemctl --user start $service_name # start"
info " systemctl --user disable $service_name # disable"
}
check_optional_deps() {
if command -v ffmpeg &>/dev/null; then
info "ffmpeg: $(ffmpeg -version 2>&1 | head -1)"
else
warn "ffmpeg: NOT FOUND"
warn " Camera capture requires ffmpeg."
warn " Install: brew install ffmpeg (macOS) / apt install ffmpeg (Linux)"
fi
if command -v yt-dlp &>/dev/null; then
info "yt-dlp: $(yt-dlp --version 2>&1)"
else
warn "yt-dlp: NOT FOUND (optional, for YouTube URL support)"
warn " Install: brew install yt-dlp (macOS) / pip install yt-dlp (Linux)"
fi
}
main() {
echo ""
info "Edge AI Platform Installer"
echo ""
local platform
platform="$(detect_platform)"
info "Platform: $platform"
# Step 1: Download and install binary
step "1/5 Installing Edge AI Platform"
resolve_version
install_binary "$platform"
create_symlink
# Step 2: Install libusb (system dependency for Kneron USB)
step "2/5 Setting up USB driver"
setup_libusb
# Step 3: Setup Python venv + pyusb (for Kneron hardware)
step "3/5 Setting up Kneron hardware environment"
setup_python_venv
# Step 4: Check dependencies and detect hardware
step "4/5 Checking environment"
check_optional_deps
echo ""
info "Detecting Kneron hardware..."
detect_kneron_devices
# Step 5: Setup auto-restart service
step "5/5 Setting up auto-restart service"
setup_auto_restart
# Done
echo ""
echo -e "${GREEN}=== Installation complete! ===${NC}"
echo ""
info "Installed to: $INSTALL_DIR"
info "Server is running and will auto-restart on crash."
info ""
info "Open: http://127.0.0.1:3721"
echo ""
info "Uninstall:"
local os_type
os_type="$(uname -s)"
if [ "$os_type" = "Darwin" ]; then
info " launchctl unload ~/Library/LaunchAgents/com.innovedus.edge-ai-server.plist"
elif [ "$os_type" = "Linux" ]; then
info " systemctl --user disable --now edge-ai-server"
fi
info " rm -rf $INSTALL_DIR"
info " sudo rm -f $BIN_LINK"
}
main "$@"

View File

@ -1,46 +0,0 @@
"""Kneron USB device detection — shared by install scripts and kneron_bridge.py"""
import sys
try:
import usb.core
except ImportError:
print('{"error": "pyusb not installed"}')
sys.exit(1)
KNERON_VENDOR_ID = 0x3231
KNOWN_PRODUCTS = {
0x0100: "KL520",
0x0200: "KL720",
0x0720: "KL720",
0x0530: "KL530",
0x0630: "KL630",
0x0730: "KL730",
}
devices = list(usb.core.find(find_all=True, idVendor=KNERON_VENDOR_ID))
if not devices:
print("No Kneron devices found.")
sys.exit(0)
print(f"Found {len(devices)} Kneron device(s):\n")
for i, dev in enumerate(devices):
product_name = KNOWN_PRODUCTS.get(dev.idProduct, f"Unknown (0x{dev.idProduct:04X})")
serial = "N/A"
product = "N/A"
try:
serial = dev.serial_number or "N/A"
except Exception:
pass
try:
product = dev.product or "N/A"
except Exception:
pass
print(f" Device #{i+1}:")
print(f" Model: Kneron {product_name}")
print(f" Product: {product}")
print(f" Serial: {serial}")
print(f" Vendor: 0x{dev.idVendor:04X}")
print(f" Product: 0x{dev.idProduct:04X}")
print(f" Bus: {dev.bus}, Address: {dev.address}")
print()

View File

@ -1,123 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Kneron KL520/KL720 環境設定腳本macOS
# 用法: bash scripts/setup-kneron.sh
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
VENV_DIR="${EDGE_AI_VENV:-$HOME/.edge-ai-platform/venv}"
echo ""
info "=== Kneron 硬體環境設定 ==="
echo ""
# Step 1: 檢查 Homebrew
if ! command -v brew &>/dev/null; then
error "需要 Homebrew。請先安裝: https://brew.sh"
fi
info "Homebrew: OK"
# Step 2: 安裝 libusb
if brew list libusb &>/dev/null 2>&1; then
info "libusb: 已安裝"
else
info "安裝 libusb ..."
brew install libusb
info "libusb: 安裝完成"
fi
# Step 3: 檢查 Python3
if ! command -v python3 &>/dev/null; then
error "需要 Python 3。請安裝: brew install python3"
fi
PYTHON_VER=$(python3 --version 2>&1)
info "Python: $PYTHON_VER"
# Step 4: 建立 Python venv
if [ -d "$VENV_DIR" ]; then
info "Python venv 已存在: $VENV_DIR"
else
info "建立 Python venv: $VENV_DIR ..."
mkdir -p "$(dirname "$VENV_DIR")"
python3 -m venv "$VENV_DIR"
info "Python venv: 建立完成"
fi
# Activate venv
source "$VENV_DIR/bin/activate"
# Step 5: 安裝 pyusb
if python3 -c "import usb.core" 2>/dev/null; then
info "pyusb: 已安裝"
else
info "安裝 pyusb ..."
pip install --quiet pyusb
info "pyusb: 安裝完成"
fi
# Step 6: 偵測 Kneron 裝置
echo ""
info "=== 偵測 Kneron USB 裝置 ==="
echo ""
python3 << 'PYEOF'
import usb.core
import usb.util
KNERON_VENDOR_ID = 0x3231
KNOWN_PRODUCTS = {
0x0100: "KL520",
0x0200: "KL720",
0x0720: "KL720",
0x0530: "KL530",
0x0630: "KL630",
0x0730: "KL730",
}
devices = list(usb.core.find(find_all=True, idVendor=KNERON_VENDOR_ID))
if not devices:
print("[WARN] 未偵測到 Kneron 裝置。")
print(" 請確認 USB Dongle 已插入。")
else:
print(f"[INFO] 偵測到 {len(devices)} 個 Kneron 裝置:")
print("")
for i, dev in enumerate(devices):
product_name = KNOWN_PRODUCTS.get(dev.idProduct, f"Unknown (0x{dev.idProduct:04X})")
serial = "N/A"
product = "N/A"
try:
serial = dev.serial_number or "N/A"
except Exception:
pass
try:
product = dev.product or "N/A"
except Exception:
pass
print(f" 裝置 #{i+1}:")
print(f" 型號: Kneron {product_name}")
print(f" Product: {product}")
print(f" Serial: {serial}")
print(f" Vendor: 0x{dev.idVendor:04X}")
print(f" Product: 0x{dev.idProduct:04X}")
print(f" Bus: {dev.bus}, Device: {dev.address}")
print("")
print("[INFO] USB 連線正常!")
PYEOF
echo ""
info "=== 設定完成 ==="
echo ""
info "Python venv 位置: $VENV_DIR"
info ""
info "下次啟動 server 時會自動使用此環境。"
info "如需手動測試: source $VENV_DIR/bin/activate"

View File

@ -1 +0,0 @@
[{"name":"generate-buildid","duration":266,"timestamp":494591728065,"id":4,"parentId":1,"tags":{},"startTime":1772620716188,"traceId":"5e5954c44fbe79f2"},{"name":"load-custom-routes","duration":354,"timestamp":494591728448,"id":5,"parentId":1,"tags":{},"startTime":1772620716188,"traceId":"5e5954c44fbe79f2"},{"name":"create-dist-dir","duration":421,"timestamp":494591728832,"id":6,"parentId":1,"tags":{},"startTime":1772620716188,"traceId":"5e5954c44fbe79f2"},{"name":"clean","duration":2253,"timestamp":494591729919,"id":7,"parentId":1,"tags":{},"startTime":1772620716189,"traceId":"5e5954c44fbe79f2"},{"name":"next-build","duration":1692006,"timestamp":494590042560,"id":1,"tags":{"buildMode":"default","version":"16.1.6","bundler":"turbopack"},"startTime":1772620714502,"traceId":"5e5954c44fbe79f2"}]

View File

@ -1 +0,0 @@
[{"name":"next-build","duration":1692006,"timestamp":494590042560,"id":1,"tags":{"buildMode":"default","version":"16.1.6","bundler":"turbopack"},"startTime":1772620714502,"traceId":"5e5954c44fbe79f2"}]

View File

@ -1,44 +0,0 @@
// Command relay-server runs a reverse-proxy relay that bridges browser
// clients to tunnelled edge-ai-servers over yamux-multiplexed WebSockets.
// Multiple local servers can connect simultaneously, each identified by
// a unique token derived from their hardware ID.
package main
import (
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"edge-ai-platform/internal/relay"
)
func main() {
port := flag.Int("port", 3800, "Listen port")
flag.Parse()
srv := relay.NewServer()
addr := relay.FormatAddr(*port)
httpServer := &http.Server{
Addr: addr,
Handler: srv.Handler(),
}
// Graceful shutdown on SIGINT/SIGTERM
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-quit
log.Println("[relay] shutting down...")
srv.Shutdown()
httpServer.Close()
}()
log.Printf("[relay] listening on %s (multi-tenant mode)", addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[relay] server error: %v", err)
}
}

View File

@ -1,340 +0,0 @@
[
{
"id": "yolov5-face-detection",
"name": "YOLOv5 Face Detection",
"description": "Real-time face detection model based on YOLOv5 architecture, optimized for edge deployment on Kneron KL720. Detects faces with high accuracy in various lighting conditions.",
"thumbnail": "/images/models/yolov5-face.png",
"taskType": "object_detection",
"categories": ["face", "security", "people"],
"framework": "ONNX",
"inputSize": {"width": 640, "height": 640},
"modelSize": 14200000,
"quantization": "INT8",
"accuracy": 0.92,
"latencyMs": 33,
"fps": 30,
"supportedHardware": ["KL720", "KL730"],
"labels": ["face"],
"version": "1.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-01-15T00:00:00Z",
"updatedAt": "2024-06-01T00:00:00Z"
},
{
"id": "imagenet-classification",
"name": "ImageNet Classification (ResNet18)",
"description": "ResNet18-based image classification model trained on ImageNet. Supports 1000 object categories with efficient inference on KL520 edge devices.",
"thumbnail": "/images/models/imagenet-cls.png",
"taskType": "classification",
"categories": ["general", "image-classification"],
"framework": "ONNX",
"inputSize": {"width": 224, "height": 224},
"modelSize": 12000000,
"quantization": "INT8",
"accuracy": 0.78,
"latencyMs": 15,
"fps": 60,
"supportedHardware": ["KL520", "KL720", "KL730"],
"labels": ["airplane", "automobile", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck"],
"filePath": "data/nef/kl520/kl520_20001_resnet18_w224h224.nef",
"version": "2.1.0",
"author": "Kneron",
"license": "MIT",
"createdAt": "2024-02-10T00:00:00Z",
"updatedAt": "2024-07-15T00:00:00Z"
},
{
"id": "person-detection",
"name": "Person Detection",
"description": "Lightweight person detection model optimized for real-time surveillance and people counting. Low latency with high accuracy on person class.",
"thumbnail": "/images/models/person-det.png",
"taskType": "object_detection",
"categories": ["people", "security", "surveillance"],
"framework": "ONNX",
"inputSize": {"width": 416, "height": 416},
"modelSize": 11800000,
"quantization": "INT8",
"accuracy": 0.89,
"latencyMs": 28,
"fps": 35,
"supportedHardware": ["KL720", "KL730"],
"labels": ["person"],
"version": "1.2.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-03-01T00:00:00Z",
"updatedAt": "2024-08-01T00:00:00Z"
},
{
"id": "vehicle-classification",
"name": "Vehicle Classification",
"description": "Vehicle type classification model that identifies cars, trucks, buses, motorcycles, and bicycles. Ideal for traffic monitoring and smart parking.",
"thumbnail": "/images/models/vehicle-cls.png",
"taskType": "classification",
"categories": ["vehicle", "traffic", "transportation"],
"framework": "ONNX",
"inputSize": {"width": 224, "height": 224},
"modelSize": 6200000,
"quantization": "INT8",
"accuracy": 0.85,
"latencyMs": 12,
"fps": 75,
"supportedHardware": ["KL520", "KL720", "KL730"],
"labels": ["car", "truck", "bus", "motorcycle", "bicycle"],
"version": "1.0.0",
"author": "Kneron",
"license": "MIT",
"createdAt": "2024-03-20T00:00:00Z",
"updatedAt": "2024-05-10T00:00:00Z"
},
{
"id": "hand-gesture-recognition",
"name": "Hand Gesture Recognition",
"description": "Recognizes 10 common hand gestures in real-time. Suitable for touchless interfaces and gesture-based control systems.",
"thumbnail": "/images/models/hand-gesture.png",
"taskType": "classification",
"categories": ["gesture", "hand", "interaction"],
"framework": "ONNX",
"inputSize": {"width": 224, "height": 224},
"modelSize": 5800000,
"quantization": "INT8",
"accuracy": 0.88,
"latencyMs": 18,
"fps": 50,
"supportedHardware": ["KL520", "KL720"],
"labels": ["thumbs_up", "thumbs_down", "open_palm", "fist", "peace", "ok", "pointing", "wave", "grab", "pinch"],
"version": "1.1.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-04-05T00:00:00Z",
"updatedAt": "2024-09-01T00:00:00Z"
},
{
"id": "coco-object-detection",
"name": "COCO Object Detection",
"description": "General-purpose object detection model trained on COCO dataset. Detects 80 common object categories including people, animals, vehicles, and household items.",
"thumbnail": "/images/models/coco-det.png",
"taskType": "object_detection",
"categories": ["general", "multi-object", "coco"],
"framework": "ONNX",
"inputSize": {"width": 640, "height": 640},
"modelSize": 23500000,
"quantization": "INT8",
"accuracy": 0.82,
"latencyMs": 45,
"fps": 22,
"supportedHardware": ["KL720", "KL730"],
"labels": ["person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", "dog", "horse", "sheep", "cow"],
"version": "3.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-10-01T00:00:00Z"
},
{
"id": "face-mask-detection",
"name": "Face Mask Detection",
"description": "Detects whether a person is wearing a face mask, wearing it incorrectly, or not wearing one. Built for health compliance monitoring.",
"thumbnail": "/images/models/face-mask.png",
"taskType": "object_detection",
"categories": ["face", "health", "safety"],
"framework": "ONNX",
"inputSize": {"width": 320, "height": 320},
"modelSize": 9800000,
"quantization": "INT8",
"accuracy": 0.91,
"latencyMs": 22,
"fps": 45,
"supportedHardware": ["KL720", "KL730"],
"labels": ["mask_on", "mask_off", "mask_incorrect"],
"version": "1.3.0",
"author": "Kneron",
"license": "MIT",
"createdAt": "2024-02-28T00:00:00Z",
"updatedAt": "2024-07-20T00:00:00Z"
},
{
"id": "license-plate-detection",
"name": "License Plate Detection",
"description": "Detects and localizes license plates in images and video streams. Optimized for various plate formats and viewing angles.",
"thumbnail": "/images/models/license-plate.png",
"taskType": "object_detection",
"categories": ["vehicle", "traffic", "ocr"],
"framework": "ONNX",
"inputSize": {"width": 416, "height": 416},
"modelSize": 12400000,
"quantization": "INT8",
"accuracy": 0.87,
"latencyMs": 30,
"fps": 33,
"supportedHardware": ["KL720", "KL730"],
"labels": ["license_plate"],
"version": "1.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-05-15T00:00:00Z",
"updatedAt": "2024-08-30T00:00:00Z"
},
{
"id": "kl520-yolov5-detection",
"name": "YOLOv5 Detection (KL520)",
"description": "YOLOv5 object detection model compiled for Kneron KL520. No upsample variant optimized for NPU inference at 640x640 resolution.",
"thumbnail": "/images/models/yolov5-det.png",
"taskType": "object_detection",
"categories": ["general", "multi-object"],
"framework": "NEF",
"inputSize": {"width": 640, "height": 640},
"modelSize": 7200000,
"quantization": "INT8",
"accuracy": 0.80,
"latencyMs": 50,
"fps": 20,
"supportedHardware": ["KL520"],
"labels": ["person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"],
"filePath": "data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef",
"version": "1.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
},
{
"id": "kl520-fcos-detection",
"name": "FCOS Detection (KL520)",
"description": "FCOS (Fully Convolutional One-Stage) object detection with DarkNet53s backbone, compiled for KL520. Anchor-free detection at 512x512.",
"thumbnail": "/images/models/fcos-det.png",
"taskType": "object_detection",
"categories": ["general", "multi-object"],
"framework": "NEF",
"inputSize": {"width": 512, "height": 512},
"modelSize": 8900000,
"quantization": "INT8",
"accuracy": 0.78,
"latencyMs": 45,
"fps": 22,
"supportedHardware": ["KL520"],
"labels": ["person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"],
"filePath": "data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef",
"version": "1.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
},
{
"id": "kl520-ssd-face-detection",
"name": "SSD Face Detection (KL520)",
"description": "SSD-based face detection with landmark localization, compiled for KL520. Lightweight model suitable for face detection and alignment tasks.",
"thumbnail": "/images/models/ssd-face.png",
"taskType": "object_detection",
"categories": ["face", "security"],
"framework": "NEF",
"inputSize": {"width": 320, "height": 240},
"modelSize": 1000000,
"quantization": "INT8",
"accuracy": 0.85,
"latencyMs": 10,
"fps": 100,
"supportedHardware": ["KL520"],
"labels": ["face"],
"filePath": "data/nef/kl520/kl520_ssd_fd_lm.nef",
"version": "1.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
},
{
"id": "kl520-tiny-yolov3",
"name": "Tiny YOLOv3 (KL520)",
"description": "Tiny YOLOv3 object detection model compiled for KL520. Compact and fast model for general-purpose multi-object detection on edge devices.",
"thumbnail": "/images/models/tiny-yolov3.png",
"taskType": "object_detection",
"categories": ["general", "multi-object"],
"framework": "NEF",
"inputSize": {"width": 416, "height": 416},
"modelSize": 9400000,
"quantization": "INT8",
"accuracy": 0.75,
"latencyMs": 35,
"fps": 28,
"supportedHardware": ["KL520"],
"labels": ["person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"],
"filePath": "data/nef/kl520/kl520_tiny_yolo_v3.nef",
"version": "1.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
},
{
"id": "kl720-yolov5-detection",
"name": "YOLOv5 Detection (KL720)",
"description": "YOLOv5 object detection model compiled for Kneron KL720. No upsample variant optimized for KL720 NPU inference at 640x640 resolution with USB 3.0 throughput.",
"thumbnail": "/images/models/yolov5-det.png",
"taskType": "object_detection",
"categories": ["general", "multi-object"],
"framework": "NEF",
"inputSize": {"width": 640, "height": 640},
"modelSize": 10168348,
"quantization": "INT8",
"accuracy": 0.82,
"latencyMs": 30,
"fps": 33,
"supportedHardware": ["KL720"],
"labels": ["person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"],
"filePath": "data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef",
"version": "1.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
},
{
"id": "kl720-resnet18-classification",
"name": "ImageNet Classification ResNet18 (KL720)",
"description": "ResNet18-based image classification compiled for KL720. Supports 1000 ImageNet categories with fast inference via USB 3.0.",
"thumbnail": "/images/models/imagenet-cls.png",
"taskType": "classification",
"categories": ["general", "image-classification"],
"framework": "NEF",
"inputSize": {"width": 224, "height": 224},
"modelSize": 12826804,
"quantization": "INT8",
"accuracy": 0.78,
"latencyMs": 10,
"fps": 100,
"supportedHardware": ["KL720"],
"labels": ["airplane", "automobile", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck"],
"filePath": "data/nef/kl720/kl720_20001_resnet18_w224h224.nef",
"version": "1.0.0",
"author": "Kneron",
"license": "MIT",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
},
{
"id": "kl720-fcos-detection",
"name": "FCOS Detection (KL720)",
"description": "FCOS (Fully Convolutional One-Stage) object detection with DarkNet53s backbone, compiled for KL720. Anchor-free detection at 512x512.",
"thumbnail": "/images/models/fcos-det.png",
"taskType": "object_detection",
"categories": ["general", "multi-object"],
"framework": "NEF",
"inputSize": {"width": 512, "height": 512},
"modelSize": 13004640,
"quantization": "INT8",
"accuracy": 0.80,
"latencyMs": 30,
"fps": 33,
"supportedHardware": ["KL720"],
"labels": ["person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light"],
"filePath": "data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef",
"version": "1.0.0",
"author": "Kneron",
"license": "Apache-2.0",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
]

Binary file not shown.

View File

@ -1,46 +0,0 @@
module edge-ai-platform
go 1.26.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
)
require (
fyne.io/systray v1.12.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

View File

@ -1,98 +0,0 @@
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,790 +0,0 @@
package handlers
import (
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"edge-ai-platform/internal/api/ws"
"edge-ai-platform/internal/camera"
"edge-ai-platform/internal/device"
"edge-ai-platform/internal/driver"
"edge-ai-platform/internal/inference"
"github.com/gin-gonic/gin"
)
type CameraHandler struct {
cameraMgr *camera.Manager
deviceMgr *device.Manager
inferenceSvc *inference.Service
wsHub *ws.Hub
streamer *camera.MJPEGStreamer
pipeline *camera.InferencePipeline
activeSource camera.FrameSource
sourceType camera.SourceType
// Video seek state — preserved across seek operations
videoPath string // original file path or resolved URL
videoIsURL bool // true if source is a URL
videoFPS float64 // target FPS
videoInfo camera.VideoInfo // duration, total frames
activeDeviceID string // device ID for current video session
}
func NewCameraHandler(
cameraMgr *camera.Manager,
deviceMgr *device.Manager,
inferenceSvc *inference.Service,
wsHub *ws.Hub,
) *CameraHandler {
streamer := camera.NewMJPEGStreamer()
go streamer.Run()
return &CameraHandler{
cameraMgr: cameraMgr,
deviceMgr: deviceMgr,
inferenceSvc: inferenceSvc,
wsHub: wsHub,
streamer: streamer,
}
}
func (h *CameraHandler) ListCameras(c *gin.Context) {
cameras := h.cameraMgr.ListCameras()
c.JSON(200, gin.H{"success": true, "data": gin.H{"cameras": cameras}})
}
func (h *CameraHandler) StartPipeline(c *gin.Context) {
var req struct {
CameraID string `json:"cameraId"`
DeviceID string `json:"deviceId"`
Width int `json:"width"`
Height int `json:"height"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": err.Error()}})
return
}
if req.Width == 0 {
req.Width = 640
}
if req.Height == 0 {
req.Height = 480
}
// Clean up any existing pipeline
h.stopActivePipeline()
// Open camera
if err := h.cameraMgr.Open(0, req.Width, req.Height); err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "CAMERA_OPEN_FAILED", "message": err.Error()}})
return
}
// Get device driver
session, err := h.deviceMgr.GetDevice(req.DeviceID)
if err != nil {
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}})
return
}
// Create inference result channel
resultCh := make(chan *driver.InferenceResult, 10)
// Forward results to WebSocket, enriching with device ID
go func() {
room := "inference:" + req.DeviceID
for result := range resultCh {
result.DeviceID = req.DeviceID
h.wsHub.BroadcastToRoom(room, result)
}
}()
// Start pipeline with camera as source
h.activeSource = h.cameraMgr
h.sourceType = camera.SourceCamera
h.pipeline = camera.NewInferencePipeline(
h.cameraMgr,
camera.SourceCamera,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
)
h.pipeline.Start()
streamURL := "/api/camera/stream"
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"streamUrl": streamURL,
"sourceType": "camera",
},
})
}
func (h *CameraHandler) StopPipeline(c *gin.Context) {
h.stopActivePipeline()
c.JSON(200, gin.H{"success": true})
}
func (h *CameraHandler) StreamMJPEG(c *gin.Context) {
h.streamer.ServeHTTP(c.Writer, c.Request)
}
// UploadImage handles image file upload for single-shot inference.
func (h *CameraHandler) UploadImage(c *gin.Context) {
h.stopActivePipeline()
deviceID := c.PostForm("deviceId")
if deviceID == "" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "deviceId is required"}})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "file is required"}})
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only JPG/PNG files are supported"}})
return
}
// Save to temp file
tmpFile, err := os.CreateTemp("", "edge-ai-image-*"+ext)
if err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "STORAGE_ERROR", "message": err.Error()}})
return
}
if _, err := io.Copy(tmpFile, file); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "STORAGE_ERROR", "message": err.Error()}})
return
}
tmpFile.Close()
// Create ImageSource
imgSource, err := camera.NewImageSource(tmpFile.Name())
if err != nil {
os.Remove(tmpFile.Name())
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "IMAGE_DECODE_FAILED", "message": err.Error()}})
return
}
// Get device driver
session, err := h.deviceMgr.GetDevice(deviceID)
if err != nil {
imgSource.Close()
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}})
return
}
resultCh := make(chan *driver.InferenceResult, 10)
go func() {
room := "inference:" + deviceID
for result := range resultCh {
result.DeviceID = deviceID
h.wsHub.BroadcastToRoom(room, result)
}
}()
h.activeSource = imgSource
h.sourceType = camera.SourceImage
h.pipeline = camera.NewInferencePipeline(
imgSource,
camera.SourceImage,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
)
h.pipeline.Start()
// Clean up result channel after pipeline completes
go func() {
<-h.pipeline.Done()
close(resultCh)
}()
w, ht := imgSource.Dimensions()
streamURL := "/api/camera/stream"
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"streamUrl": streamURL,
"sourceType": "image",
"width": w,
"height": ht,
"filename": header.Filename,
},
})
}
// UploadVideo handles video file upload for frame-by-frame inference.
func (h *CameraHandler) UploadVideo(c *gin.Context) {
h.stopActivePipeline()
deviceID := c.PostForm("deviceId")
if deviceID == "" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "deviceId is required"}})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "file is required"}})
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".mp4" && ext != ".avi" && ext != ".mov" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV files are supported"}})
return
}
// Save to temp file
tmpFile, err := os.CreateTemp("", "edge-ai-video-*"+ext)
if err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "STORAGE_ERROR", "message": err.Error()}})
return
}
if _, err := io.Copy(tmpFile, file); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "STORAGE_ERROR", "message": err.Error()}})
return
}
tmpFile.Close()
// Probe video info (duration, frame count) before starting pipeline
videoInfo := camera.ProbeVideoInfo(tmpFile.Name(), 15)
// Create VideoSource
videoSource, err := camera.NewVideoSource(tmpFile.Name(), 15)
if err != nil {
os.Remove(tmpFile.Name())
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "VIDEO_DECODE_FAILED", "message": err.Error()}})
return
}
if videoInfo.TotalFrames > 0 {
videoSource.SetTotalFrames(videoInfo.TotalFrames)
}
// Get device driver
session, err := h.deviceMgr.GetDevice(deviceID)
if err != nil {
videoSource.Close()
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}})
return
}
resultCh := make(chan *driver.InferenceResult, 10)
go func() {
room := "inference:" + deviceID
for result := range resultCh {
result.DeviceID = deviceID
h.wsHub.BroadcastToRoom(room, result)
}
}()
h.activeSource = videoSource
h.sourceType = camera.SourceVideo
h.videoPath = tmpFile.Name()
h.videoIsURL = false
h.videoFPS = 15
h.videoInfo = videoInfo
h.activeDeviceID = deviceID
h.pipeline = camera.NewInferencePipeline(
videoSource,
camera.SourceVideo,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
)
h.pipeline.Start()
// Notify frontend when video playback completes
go func() {
<-h.pipeline.Done()
close(resultCh)
h.wsHub.BroadcastToRoom("inference:"+deviceID, map[string]interface{}{
"type": "pipeline_complete",
"sourceType": "video",
})
}()
streamURL := "/api/camera/stream"
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"streamUrl": streamURL,
"sourceType": "video",
"filename": header.Filename,
"totalFrames": videoInfo.TotalFrames,
"durationSeconds": videoInfo.DurationSec,
},
})
}
// ytdlpHosts lists hostnames where yt-dlp should be used to resolve the actual
// video stream URL before passing to ffmpeg.
var ytdlpHosts = map[string]bool{
"youtube.com": true, "www.youtube.com": true, "youtu.be": true, "m.youtube.com": true,
"vimeo.com": true, "www.vimeo.com": true,
"dailymotion.com": true, "www.dailymotion.com": true,
"twitch.tv": true, "www.twitch.tv": true,
"bilibili.com": true, "www.bilibili.com": true,
"tiktok.com": true, "www.tiktok.com": true,
"facebook.com": true, "www.facebook.com": true, "fb.watch": true,
"instagram.com": true, "www.instagram.com": true,
"twitter.com": true, "x.com": true,
}
type urlKind int
const (
urlDirect urlKind = iota // direct video file or RTSP, pass to ffmpeg directly
urlYTDLP // needs yt-dlp to resolve first
urlBad // invalid or unsupported
)
// classifyVideoURL determines how to handle the given URL.
func classifyVideoURL(rawURL string) (urlKind, string) {
parsed, err := url.Parse(rawURL)
if err != nil {
return urlBad, "Invalid URL format"
}
scheme := strings.ToLower(parsed.Scheme)
host := strings.ToLower(parsed.Hostname())
// RTSP streams — direct to ffmpeg
if scheme == "rtsp" || scheme == "rtsps" {
return urlDirect, ""
}
// Must be http or https
if scheme != "http" && scheme != "https" {
return urlBad, "Unsupported protocol: " + scheme + ". Use http, https, or rtsp."
}
// Known video platforms — use yt-dlp
if ytdlpHosts[host] {
return urlYTDLP, ""
}
// Everything else — pass directly to ffmpeg
return urlDirect, ""
}
// StartFromURL handles video/stream inference from a URL (HTTP, HTTPS, RTSP).
func (h *CameraHandler) StartFromURL(c *gin.Context) {
var req struct {
URL string `json:"url"`
DeviceID string `json:"deviceId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": err.Error()}})
return
}
if req.URL == "" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "url is required"}})
return
}
if req.DeviceID == "" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "deviceId is required"}})
return
}
// Classify the URL
kind, reason := classifyVideoURL(req.URL)
if kind == urlBad {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "UNSUPPORTED_URL", "message": reason}})
return
}
// For video platforms (YouTube, etc.), resolve actual stream URL via yt-dlp
videoURL := req.URL
if kind == urlYTDLP {
resolved, err := camera.ResolveWithYTDLP(req.URL)
if err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "URL_RESOLVE_FAILED", "message": "無法解析影片連結: " + err.Error()}})
return
}
videoURL = resolved
}
h.stopActivePipeline()
// Probe video info (duration, frame count) - may be slow for remote URLs
videoInfo := camera.ProbeVideoInfo(videoURL, 15)
// Create VideoSource from URL (ffmpeg supports HTTP/HTTPS/RTSP natively)
videoSource, err := camera.NewVideoSourceFromURL(videoURL, 15)
if err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "URL_OPEN_FAILED", "message": err.Error()}})
return
}
if videoInfo.TotalFrames > 0 {
videoSource.SetTotalFrames(videoInfo.TotalFrames)
}
// Get device driver
session, err := h.deviceMgr.GetDevice(req.DeviceID)
if err != nil {
videoSource.Close()
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}})
return
}
resultCh := make(chan *driver.InferenceResult, 10)
go func() {
room := "inference:" + req.DeviceID
for result := range resultCh {
h.wsHub.BroadcastToRoom(room, result)
}
}()
h.activeSource = videoSource
h.sourceType = camera.SourceVideo
h.videoPath = videoURL
h.videoIsURL = true
h.videoFPS = 15
h.videoInfo = videoInfo
h.activeDeviceID = req.DeviceID
h.pipeline = camera.NewInferencePipeline(
videoSource,
camera.SourceVideo,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
)
h.pipeline.Start()
go func() {
<-h.pipeline.Done()
close(resultCh)
h.wsHub.BroadcastToRoom("inference:"+req.DeviceID, map[string]interface{}{
"type": "pipeline_complete",
"sourceType": "video",
})
}()
streamURL := "/api/camera/stream"
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"streamUrl": streamURL,
"sourceType": "video",
"filename": req.URL,
"totalFrames": videoInfo.TotalFrames,
"durationSeconds": videoInfo.DurationSec,
},
})
}
// UploadBatchImages handles multiple image files for sequential batch inference.
func (h *CameraHandler) UploadBatchImages(c *gin.Context) {
h.stopActivePipeline()
deviceID := c.PostForm("deviceId")
if deviceID == "" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "deviceId is required"}})
return
}
form, err := c.MultipartForm()
if err != nil {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "multipart form required"}})
return
}
files := form.File["files"]
if len(files) == 0 {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "at least one file is required"}})
return
}
if len(files) > 50 {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "maximum 50 images per batch"}})
return
}
// Save all files to temp
filePaths := make([]string, 0, len(files))
filenames := make([]string, 0, len(files))
for _, fh := range files {
ext := strings.ToLower(filepath.Ext(fh.Filename))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
for _, fp := range filePaths {
os.Remove(fp)
}
c.JSON(400, gin.H{"success": false, "error": gin.H{
"code": "BAD_REQUEST",
"message": fmt.Sprintf("unsupported file: %s (only JPG/PNG)", fh.Filename),
}})
return
}
f, openErr := fh.Open()
if openErr != nil {
for _, fp := range filePaths {
os.Remove(fp)
}
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "STORAGE_ERROR", "message": openErr.Error()}})
return
}
tmpFile, tmpErr := os.CreateTemp("", "edge-ai-batch-*"+ext)
if tmpErr != nil {
f.Close()
for _, fp := range filePaths {
os.Remove(fp)
}
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "STORAGE_ERROR", "message": tmpErr.Error()}})
return
}
io.Copy(tmpFile, f)
tmpFile.Close()
f.Close()
filePaths = append(filePaths, tmpFile.Name())
filenames = append(filenames, fh.Filename)
}
// Create MultiImageSource
batchSource, err := camera.NewMultiImageSource(filePaths, filenames)
if err != nil {
for _, fp := range filePaths {
os.Remove(fp)
}
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "IMAGE_DECODE_FAILED", "message": err.Error()}})
return
}
// Get device driver
session, err := h.deviceMgr.GetDevice(deviceID)
if err != nil {
batchSource.Close()
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}})
return
}
batchID := fmt.Sprintf("batch-%d", time.Now().UnixNano())
resultCh := make(chan *driver.InferenceResult, 10)
go func() {
room := "inference:" + deviceID
for result := range resultCh {
result.DeviceID = deviceID
h.wsHub.BroadcastToRoom(room, result)
}
}()
h.activeSource = batchSource
h.sourceType = camera.SourceBatchImage
h.pipeline = camera.NewInferencePipeline(
batchSource,
camera.SourceBatchImage,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
)
h.pipeline.Start()
// Notify frontend when batch completes
go func() {
<-h.pipeline.Done()
close(resultCh)
h.wsHub.BroadcastToRoom("inference:"+deviceID, map[string]interface{}{
"type": "pipeline_complete",
"sourceType": "batch_image",
"batchId": batchID,
})
}()
// Build image list for response
imageList := make([]gin.H, len(batchSource.Images()))
for i, entry := range batchSource.Images() {
imageList[i] = gin.H{
"index": i,
"filename": entry.Filename,
"width": entry.Width,
"height": entry.Height,
}
}
streamURL := "/api/camera/stream"
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"streamUrl": streamURL,
"sourceType": "batch_image",
"batchId": batchID,
"totalImages": len(files),
"images": imageList,
},
})
}
// GetBatchImageFrame serves a specific image from the active batch by index.
func (h *CameraHandler) GetBatchImageFrame(c *gin.Context) {
if h.sourceType != camera.SourceBatchImage || h.activeSource == nil {
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "NO_BATCH", "message": "no batch image source active"}})
return
}
indexStr := c.Param("index")
index, err := strconv.Atoi(indexStr)
if err != nil || index < 0 {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "invalid index"}})
return
}
mis, ok := h.activeSource.(*camera.MultiImageSource)
if !ok {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "INTERNAL_ERROR", "message": "source type mismatch"}})
return
}
jpegData, err := mis.GetImageByIndex(index)
if err != nil {
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "NOT_FOUND", "message": err.Error()}})
return
}
c.Data(200, "image/jpeg", jpegData)
}
// stopPipelineForSeek stops the pipeline and ffmpeg process but keeps the video file.
func (h *CameraHandler) stopPipelineForSeek() {
if h.pipeline != nil {
h.pipeline.Stop()
h.pipeline = nil
}
if h.activeSource != nil {
if vs, ok := h.activeSource.(*camera.VideoSource); ok {
vs.CloseWithoutRemove()
}
}
h.activeSource = nil
}
// stopActivePipeline stops the current pipeline and cleans up resources.
func (h *CameraHandler) stopActivePipeline() {
if h.pipeline != nil {
h.pipeline.Stop()
h.pipeline = nil
}
// Only close non-camera sources (camera is managed by cameraMgr)
if h.activeSource != nil && h.sourceType != camera.SourceCamera {
h.activeSource.Close()
}
if h.sourceType == camera.SourceCamera {
h.cameraMgr.Close()
}
h.activeSource = nil
h.sourceType = ""
h.videoPath = ""
h.videoIsURL = false
h.activeDeviceID = ""
}
// SeekVideo seeks to a specific position in the current video and restarts inference.
func (h *CameraHandler) SeekVideo(c *gin.Context) {
var req struct {
TimeSeconds float64 `json:"timeSeconds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": err.Error()}})
return
}
if h.videoPath == "" || h.sourceType != camera.SourceVideo {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "NO_VIDEO", "message": "no video is currently playing"}})
return
}
// Clamp seek time
if req.TimeSeconds < 0 {
req.TimeSeconds = 0
}
if h.videoInfo.DurationSec > 0 && req.TimeSeconds > h.videoInfo.DurationSec {
req.TimeSeconds = h.videoInfo.DurationSec
}
// Stop current pipeline without deleting the video file
h.stopPipelineForSeek()
// Create new VideoSource with seek position
var videoSource *camera.VideoSource
var err error
if h.videoIsURL {
videoSource, err = camera.NewVideoSourceFromURLWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
} else {
videoSource, err = camera.NewVideoSourceWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
}
if err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "SEEK_FAILED", "message": err.Error()}})
return
}
if h.videoInfo.TotalFrames > 0 {
videoSource.SetTotalFrames(h.videoInfo.TotalFrames)
}
// Get device driver
session, err := h.deviceMgr.GetDevice(h.activeDeviceID)
if err != nil {
videoSource.Close()
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}})
return
}
// Calculate frame offset from seek position
frameOffset := int(req.TimeSeconds * h.videoFPS)
resultCh := make(chan *driver.InferenceResult, 10)
go func() {
room := "inference:" + h.activeDeviceID
for result := range resultCh {
result.DeviceID = h.activeDeviceID
h.wsHub.BroadcastToRoom(room, result)
}
}()
h.activeSource = videoSource
h.pipeline = camera.NewInferencePipelineWithOffset(
videoSource,
camera.SourceVideo,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
frameOffset,
)
h.pipeline.Start()
go func() {
<-h.pipeline.Done()
close(resultCh)
h.wsHub.BroadcastToRoom("inference:"+h.activeDeviceID, map[string]interface{}{
"type": "pipeline_complete",
"sourceType": "video",
})
}()
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"seekTo": req.TimeSeconds,
"frameOffset": frameOffset,
},
})
}

View File

@ -1,376 +0,0 @@
package handlers
import (
"context"
"fmt"
"sync"
"time"
"edge-ai-platform/internal/api/ws"
"edge-ai-platform/internal/cluster"
"edge-ai-platform/internal/driver"
"edge-ai-platform/internal/flash"
"edge-ai-platform/internal/model"
"github.com/gin-gonic/gin"
)
type ClusterHandler struct {
clusterMgr *cluster.Manager
flashSvc *flash.Service
modelRepo *model.Repository
wsHub *ws.Hub
pipelines map[string]*cluster.ClusterPipeline
mu sync.Mutex
}
func NewClusterHandler(
clusterMgr *cluster.Manager,
flashSvc *flash.Service,
modelRepo *model.Repository,
wsHub *ws.Hub,
) *ClusterHandler {
return &ClusterHandler{
clusterMgr: clusterMgr,
flashSvc: flashSvc,
modelRepo: modelRepo,
wsHub: wsHub,
pipelines: make(map[string]*cluster.ClusterPipeline),
}
}
func (h *ClusterHandler) ListClusters(c *gin.Context) {
clusters := h.clusterMgr.ListClusters()
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"clusters": clusters,
},
})
}
func (h *ClusterHandler) GetCluster(c *gin.Context) {
id := c.Param("id")
cl, err := h.clusterMgr.GetCluster(id)
if err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{"code": "CLUSTER_NOT_FOUND", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true, "data": cl})
}
func (h *ClusterHandler) CreateCluster(c *gin.Context) {
var req struct {
Name string `json:"name"`
DeviceIDs []string `json:"deviceIds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "name and deviceIds are required"},
})
return
}
if req.Name == "" || len(req.DeviceIDs) == 0 {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "name and deviceIds are required"},
})
return
}
cl, err := h.clusterMgr.CreateCluster(req.Name, req.DeviceIDs)
if err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "CREATE_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true, "data": cl})
}
func (h *ClusterHandler) DeleteCluster(c *gin.Context) {
id := c.Param("id")
// Stop any running pipeline first.
h.mu.Lock()
if p, ok := h.pipelines[id]; ok {
p.Stop()
delete(h.pipelines, id)
}
h.mu.Unlock()
if err := h.clusterMgr.DeleteCluster(id); err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{"code": "CLUSTER_NOT_FOUND", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}
func (h *ClusterHandler) AddDevice(c *gin.Context) {
clusterID := c.Param("id")
var req struct {
DeviceID string `json:"deviceId"`
Weight int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeviceID == "" {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "deviceId is required"},
})
return
}
if err := h.clusterMgr.AddDevice(clusterID, req.DeviceID, req.Weight); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "ADD_DEVICE_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}
func (h *ClusterHandler) RemoveDevice(c *gin.Context) {
clusterID := c.Param("id")
deviceID := c.Param("deviceId")
if err := h.clusterMgr.RemoveDevice(clusterID, deviceID); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "REMOVE_DEVICE_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}
func (h *ClusterHandler) UpdateWeight(c *gin.Context) {
clusterID := c.Param("id")
deviceID := c.Param("deviceId")
var req struct {
Weight int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "weight is required"},
})
return
}
if err := h.clusterMgr.UpdateWeight(clusterID, deviceID, req.Weight); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "UPDATE_WEIGHT_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}
func (h *ClusterHandler) FlashCluster(c *gin.Context) {
clusterID := c.Param("id")
var req struct {
ModelID string `json:"modelId"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.ModelID == "" {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "modelId is required"},
})
return
}
cl, err := h.clusterMgr.GetCluster(clusterID)
if err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{"code": "CLUSTER_NOT_FOUND", "message": err.Error()},
})
return
}
// Flash each device in the cluster. Each device gets its own
// flash task, and progress is forwarded to the cluster flash WS room.
room := "flash:cluster:" + clusterID
deviceMgr := h.clusterMgr.DeviceManager()
var flashErrors []string
for _, member := range cl.Devices {
if member.Status == cluster.MemberRemoved {
continue
}
_, progressCh, err := h.flashSvc.StartFlash(member.DeviceID, req.ModelID)
if err != nil {
flashErrors = append(flashErrors, fmt.Sprintf("%s: %s", member.DeviceID, err.Error()))
continue
}
go func(did string) {
for progress := range progressCh {
h.wsHub.BroadcastToRoom(room, cluster.ClusterFlashProgress{
DeviceID: did,
Percent: progress.Percent,
Stage: progress.Stage,
Message: progress.Message,
Error: progress.Error,
})
}
}(member.DeviceID)
}
_ = deviceMgr // used above to verify devices exist via flashSvc
if len(flashErrors) > 0 && len(flashErrors) == len(cl.Devices) {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "FLASH_FAILED", "message": fmt.Sprintf("all devices failed: %v", flashErrors)},
})
return
}
cl.ModelID = req.ModelID
c.JSON(200, gin.H{"success": true, "data": gin.H{
"clusterId": clusterID,
"errors": flashErrors,
}})
}
func (h *ClusterHandler) StartInference(c *gin.Context) {
clusterID := c.Param("id")
cl, err := h.clusterMgr.GetCluster(clusterID)
if err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{"code": "CLUSTER_NOT_FOUND", "message": err.Error()},
})
return
}
h.mu.Lock()
if _, exists := h.pipelines[clusterID]; exists {
h.mu.Unlock()
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "ALREADY_RUNNING", "message": "cluster inference already running"},
})
return
}
h.mu.Unlock()
// Build drivers list from cluster members.
deviceMgr := h.clusterMgr.DeviceManager()
var members []cluster.DeviceMember
var drivers []driver.DeviceDriver
for _, m := range cl.Devices {
if m.Status == cluster.MemberRemoved {
continue
}
session, err := deviceMgr.GetDevice(m.DeviceID)
if err != nil {
continue
}
if !session.Driver.IsConnected() {
continue
}
members = append(members, m)
drivers = append(drivers, session.Driver)
}
if len(drivers) == 0 {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "NO_ACTIVE_DEVICES", "message": "no connected devices in cluster"},
})
return
}
// Start inference on each device.
for _, drv := range drivers {
if err := drv.StartInference(); err != nil {
// Non-fatal: log and continue with devices that succeed.
continue
}
}
resultCh := make(chan *cluster.ClusterResult, 20)
dispatcher := cluster.NewDispatcher(members, drivers)
pipeline := cluster.NewClusterPipeline(cl, dispatcher, resultCh)
pipeline.StartContinuous()
h.mu.Lock()
h.pipelines[clusterID] = pipeline
h.mu.Unlock()
h.clusterMgr.SetStatus(clusterID, cluster.ClusterInferencing)
// Forward cluster results to WebSocket.
go func() {
room := "inference:cluster:" + clusterID
for result := range resultCh {
h.wsHub.BroadcastToRoom(room, result)
}
}()
c.JSON(200, gin.H{"success": true})
}
func (h *ClusterHandler) StopInference(c *gin.Context) {
clusterID := c.Param("id")
h.mu.Lock()
pipeline, exists := h.pipelines[clusterID]
if exists {
delete(h.pipelines, clusterID)
}
h.mu.Unlock()
if !exists {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "NOT_RUNNING", "message": "cluster inference not running"},
})
return
}
// Stop pipeline with timeout.
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
doneCh := make(chan struct{})
go func() {
pipeline.Stop()
// Stop inference on each device.
cl, err := h.clusterMgr.GetCluster(clusterID)
if err == nil {
deviceMgr := h.clusterMgr.DeviceManager()
for _, m := range cl.Devices {
if s, err := deviceMgr.GetDevice(m.DeviceID); err == nil {
s.Driver.StopInference()
}
}
}
close(doneCh)
}()
select {
case <-doneCh:
h.clusterMgr.SetStatus(clusterID, cluster.ClusterIdle)
c.JSON(200, gin.H{"success": true})
case <-ctx.Done():
h.clusterMgr.SetStatus(clusterID, cluster.ClusterIdle)
c.JSON(200, gin.H{"success": true})
}
}

View File

@ -1,181 +0,0 @@
package handlers
import (
"context"
"fmt"
"time"
"edge-ai-platform/internal/api/ws"
"edge-ai-platform/internal/device"
"edge-ai-platform/internal/driver"
"edge-ai-platform/internal/flash"
"edge-ai-platform/internal/inference"
"github.com/gin-gonic/gin"
)
type DeviceHandler struct {
deviceMgr *device.Manager
flashSvc *flash.Service
inferenceSvc *inference.Service
wsHub *ws.Hub
}
func NewDeviceHandler(
deviceMgr *device.Manager,
flashSvc *flash.Service,
inferenceSvc *inference.Service,
wsHub *ws.Hub,
) *DeviceHandler {
return &DeviceHandler{
deviceMgr: deviceMgr,
flashSvc: flashSvc,
inferenceSvc: inferenceSvc,
wsHub: wsHub,
}
}
func (h *DeviceHandler) ScanDevices(c *gin.Context) {
devices := h.deviceMgr.Rescan()
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"devices": devices,
},
})
}
func (h *DeviceHandler) ListDevices(c *gin.Context) {
devices := h.deviceMgr.ListDevices()
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"devices": devices,
},
})
}
func (h *DeviceHandler) GetDevice(c *gin.Context) {
id := c.Param("id")
session, err := h.deviceMgr.GetDevice(id)
if err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true, "data": session.Driver.Info()})
}
func (h *DeviceHandler) ConnectDevice(c *gin.Context) {
id := c.Param("id")
// Run connect with a 30-second timeout to avoid blocking the HTTP
// request for over a minute when the SDK connect hangs.
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- h.deviceMgr.Connect(id)
}()
select {
case err := <-errCh:
if err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "CONNECT_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
case <-ctx.Done():
c.JSON(504, gin.H{
"success": false,
"error": gin.H{"code": "CONNECT_TIMEOUT", "message": fmt.Sprintf("device connect timed out after 30s for %s", id)},
})
}
}
func (h *DeviceHandler) DisconnectDevice(c *gin.Context) {
id := c.Param("id")
if err := h.deviceMgr.Disconnect(id); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "DISCONNECT_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}
func (h *DeviceHandler) FlashDevice(c *gin.Context) {
id := c.Param("id")
var req struct {
ModelID string `json:"modelId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "modelId is required"},
})
return
}
taskID, progressCh, err := h.flashSvc.StartFlash(id, req.ModelID)
if err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "FLASH_FAILED", "message": err.Error()},
})
return
}
// Forward progress to WebSocket
go func() {
room := "flash:" + id
for progress := range progressCh {
h.wsHub.BroadcastToRoom(room, progress)
}
}()
c.JSON(200, gin.H{"success": true, "data": gin.H{"taskId": taskID}})
}
func (h *DeviceHandler) StartInference(c *gin.Context) {
id := c.Param("id")
resultCh := make(chan *driver.InferenceResult, 10)
if err := h.inferenceSvc.Start(id, resultCh); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "INFERENCE_ERROR", "message": err.Error()},
})
return
}
// Forward results to WebSocket, enriching with device ID
go func() {
room := "inference:" + id
for result := range resultCh {
result.DeviceID = id
h.wsHub.BroadcastToRoom(room, result)
}
}()
c.JSON(200, gin.H{"success": true})
}
func (h *DeviceHandler) StopInference(c *gin.Context) {
id := c.Param("id")
if err := h.inferenceSvc.Stop(id); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "INFERENCE_ERROR", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}

View File

@ -1,47 +0,0 @@
package handlers
import (
"edge-ai-platform/internal/model"
"github.com/gin-gonic/gin"
)
type ModelHandler struct {
repo *model.Repository
}
func NewModelHandler(repo *model.Repository) *ModelHandler {
return &ModelHandler{repo: repo}
}
func (h *ModelHandler) ListModels(c *gin.Context) {
filter := model.ModelFilter{
TaskType: c.Query("type"),
Hardware: c.Query("hardware"),
Query: c.Query("q"),
}
models, total := h.repo.List(filter)
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"models": models,
"total": total,
},
})
}
func (h *ModelHandler) GetModel(c *gin.Context) {
id := c.Param("id")
m, err := h.repo.GetByID(id)
if err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{
"code": "MODEL_NOT_FOUND",
"message": "Model not found",
},
})
return
}
c.JSON(200, gin.H{"success": true, "data": m})
}

View File

@ -1,154 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"edge-ai-platform/internal/model"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ModelUploadHandler struct {
repo *model.Repository
store *model.ModelStore
}
func NewModelUploadHandler(repo *model.Repository, store *model.ModelStore) *ModelUploadHandler {
return &ModelUploadHandler{repo: repo, store: store}
}
func (h *ModelUploadHandler) UploadModel(c *gin.Context) {
// Get uploaded file
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "file is required"},
})
return
}
defer file.Close()
// Validate file extension
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".nef" {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "only .nef files are supported"},
})
return
}
// Get required fields
name := c.PostForm("name")
taskType := c.PostForm("taskType")
labelsStr := c.PostForm("labels")
if name == "" || taskType == "" || labelsStr == "" {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "name, taskType, and labels are required"},
})
return
}
// Parse labels
var labels []string
if err := json.Unmarshal([]byte(labelsStr), &labels); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "labels must be a JSON array"},
})
return
}
// Optional fields
description := c.PostForm("description")
quantization := c.PostForm("quantization")
if quantization == "" {
quantization = "INT8"
}
inputWidth := 640
inputHeight := 640
if w := c.PostForm("inputWidth"); w != "" {
fmt.Sscanf(w, "%d", &inputWidth)
}
if h := c.PostForm("inputHeight"); h != "" {
fmt.Sscanf(h, "%d", &inputHeight)
}
// Generate ID
id := uuid.New().String()
now := time.Now().UTC().Format(time.RFC3339)
// Save file
nefPath, err := h.store.SaveModel(id, file)
if err != nil {
c.JSON(500, gin.H{
"success": false,
"error": gin.H{"code": "STORAGE_ERROR", "message": err.Error()},
})
return
}
// Build model
m := model.Model{
ID: id,
Name: name,
Description: description,
TaskType: taskType,
Categories: []string{"custom"},
Framework: "NEF",
InputSize: model.InputSize{Width: inputWidth, Height: inputHeight},
ModelSize: header.Size,
Quantization: quantization,
SupportedHardware: []string{"KL520", "KL720"},
Labels: labels,
Version: "1.0.0",
Author: "Custom",
License: "Custom",
CreatedAt: now,
UpdatedAt: now,
IsCustom: true,
FilePath: nefPath,
}
// Save metadata
if err := h.store.SaveMetadata(id, m); err != nil {
c.JSON(500, gin.H{
"success": false,
"error": gin.H{"code": "STORAGE_ERROR", "message": err.Error()},
})
return
}
// Add to repository
h.repo.Add(m)
c.JSON(200, gin.H{"success": true, "data": m})
}
func (h *ModelUploadHandler) DeleteModel(c *gin.Context) {
id := c.Param("id")
// Remove from repository (validates it's custom)
if err := h.repo.Remove(id); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "DELETE_FAILED", "message": err.Error()},
})
return
}
// Remove files
if err := h.store.DeleteModel(id); err != nil {
fmt.Printf("[WARN] Failed to delete model files for %s: %v\n", id, err)
}
c.JSON(200, gin.H{"success": true})
}

View File

@ -1,102 +0,0 @@
package handlers
import (
"net/http"
"runtime"
"time"
"edge-ai-platform/internal/deps"
"edge-ai-platform/internal/update"
"github.com/gin-gonic/gin"
)
type SystemHandler struct {
startTime time.Time
version string
buildTime string
shutdownFn func()
depsCache []deps.Dependency
giteaURL string
}
func NewSystemHandler(version, buildTime, giteaURL string, shutdownFn func()) *SystemHandler {
return &SystemHandler{
startTime: time.Now(),
version: version,
buildTime: buildTime,
shutdownFn: shutdownFn,
depsCache: deps.CheckAll(),
giteaURL: giteaURL,
}
}
func (h *SystemHandler) HealthCheck(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
}
func (h *SystemHandler) Info(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"version": h.version,
"platform": runtime.GOOS + "/" + runtime.GOARCH,
"uptime": time.Since(h.startTime).Seconds(),
"goVersion": runtime.Version(),
},
})
}
func (h *SystemHandler) Metrics(c *gin.Context) {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"version": h.version,
"buildTime": h.buildTime,
"platform": runtime.GOOS + "/" + runtime.GOARCH,
"goVersion": runtime.Version(),
"uptimeSeconds": time.Since(h.startTime).Seconds(),
"goroutines": runtime.NumGoroutine(),
"memHeapAllocMB": float64(ms.HeapAlloc) / 1024 / 1024,
"memSysMB": float64(ms.Sys) / 1024 / 1024,
"memHeapObjects": ms.HeapObjects,
"gcCycles": ms.NumGC,
"nextGcMB": float64(ms.NextGC) / 1024 / 1024,
},
})
}
func (h *SystemHandler) Deps(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": gin.H{"deps": h.depsCache},
})
}
func (h *SystemHandler) CheckUpdate(c *gin.Context) {
// Gitea release repo: use the same owner/repo as .goreleaser.yaml
const owner = "warrenchen"
const repo = "web_academy_prototype"
info := update.Check(h.version, h.giteaURL, owner, repo)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": info,
})
}
func (h *SystemHandler) Restart(c *gin.Context) {
c.JSON(200, gin.H{"success": true, "data": gin.H{"message": "restarting"}})
if f, ok := c.Writer.(http.Flusher); ok {
f.Flush()
}
go func() {
time.Sleep(200 * time.Millisecond)
// shutdownFn signals the main goroutine to perform exec after server shutdown
h.shutdownFn()
}()
}

View File

@ -1,29 +0,0 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
// In production, frontend is same-origin so browsers don't send Origin header.
// In dev, Next.js on :3000 needs CORS to reach Go on :3721.
// Allow all origins since this is a local-first application.
c.Header("Access-Control-Allow-Origin", origin)
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Relay-Token")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

View File

@ -1,204 +0,0 @@
package api
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"edge-ai-platform/internal/api/handlers"
"edge-ai-platform/internal/api/ws"
"edge-ai-platform/internal/camera"
"edge-ai-platform/internal/cluster"
"edge-ai-platform/internal/device"
"edge-ai-platform/internal/flash"
"edge-ai-platform/internal/inference"
"edge-ai-platform/internal/model"
"edge-ai-platform/pkg/logger"
"github.com/gin-gonic/gin"
)
func NewRouter(
modelRepo *model.Repository,
modelStore *model.ModelStore,
deviceMgr *device.Manager,
cameraMgr *camera.Manager,
clusterMgr *cluster.Manager,
flashSvc *flash.Service,
inferenceSvc *inference.Service,
wsHub *ws.Hub,
staticFS http.FileSystem,
logBroadcaster *logger.Broadcaster,
systemHandler *handlers.SystemHandler,
relayToken string,
) *gin.Engine {
// Use gin.New() instead of gin.Default() to replace the default logger
// with one that also pushes to the WebSocket broadcaster.
r := gin.New()
r.Use(gin.Recovery())
r.Use(broadcasterLogger(logBroadcaster))
r.Use(CORSMiddleware())
modelHandler := handlers.NewModelHandler(modelRepo)
modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore)
deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub)
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
clusterHandler := handlers.NewClusterHandler(clusterMgr, flashSvc, modelRepo, wsHub)
api := r.Group("/api")
{
api.GET("/system/health", systemHandler.HealthCheck)
api.GET("/system/info", systemHandler.Info)
api.GET("/system/metrics", systemHandler.Metrics)
api.GET("/system/deps", systemHandler.Deps)
api.POST("/system/restart", systemHandler.Restart)
api.GET("/system/update-check", systemHandler.CheckUpdate)
api.GET("/models", modelHandler.ListModels)
api.GET("/models/:id", modelHandler.GetModel)
api.POST("/models/upload", modelUploadHandler.UploadModel)
api.DELETE("/models/:id", modelUploadHandler.DeleteModel)
api.GET("/devices", deviceHandler.ListDevices)
api.POST("/devices/scan", deviceHandler.ScanDevices)
api.GET("/devices/:id", deviceHandler.GetDevice)
api.POST("/devices/:id/connect", deviceHandler.ConnectDevice)
api.POST("/devices/:id/disconnect", deviceHandler.DisconnectDevice)
api.POST("/devices/:id/flash", deviceHandler.FlashDevice)
api.POST("/devices/:id/inference/start", deviceHandler.StartInference)
api.POST("/devices/:id/inference/stop", deviceHandler.StopInference)
api.GET("/camera/list", cameraHandler.ListCameras)
api.POST("/camera/start", cameraHandler.StartPipeline)
api.POST("/camera/stop", cameraHandler.StopPipeline)
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
api.POST("/media/upload/image", cameraHandler.UploadImage)
api.POST("/media/upload/video", cameraHandler.UploadVideo)
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
api.POST("/media/url", cameraHandler.StartFromURL)
api.POST("/media/seek", cameraHandler.SeekVideo)
api.GET("/clusters", clusterHandler.ListClusters)
api.POST("/clusters", clusterHandler.CreateCluster)
api.GET("/clusters/:id", clusterHandler.GetCluster)
api.DELETE("/clusters/:id", clusterHandler.DeleteCluster)
api.POST("/clusters/:id/devices", clusterHandler.AddDevice)
api.DELETE("/clusters/:id/devices/:deviceId", clusterHandler.RemoveDevice)
api.PUT("/clusters/:id/devices/:deviceId/weight", clusterHandler.UpdateWeight)
api.POST("/clusters/:id/flash", clusterHandler.FlashCluster)
api.POST("/clusters/:id/inference/start", clusterHandler.StartInference)
api.POST("/clusters/:id/inference/stop", clusterHandler.StopInference)
}
// Relay token endpoint — browser fetches this from localhost to auto-detect token.
// CORS is handled by CORSMiddleware. We must register OPTIONS explicitly
// because Gin only runs middleware on routes with a matching method handler;
// without this, preflight requests get 405 and no CORS headers.
r.GET("/auth/token", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"token": relayToken})
})
r.OPTIONS("/auth/token", func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr))
r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub))
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
r.GET("/ws/clusters/:id/inference", ws.ClusterInferenceHandler(wsHub))
r.GET("/ws/clusters/:id/flash-progress", ws.ClusterFlashProgressHandler(wsHub))
// Embedded frontend static file serving (production mode)
if staticFS != nil {
fileServer := http.FileServer(staticFS)
// Serve Next.js static assets
r.GET("/_next/*filepath", func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
r.GET("/favicon.ico", func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
// SPA fallback for all other routes (client-side routing)
r.NoRoute(spaFallback(staticFS))
}
return r
}
// broadcasterLogger is a Gin middleware that logs HTTP requests to both
// stdout (like gin.Logger) and the WebSocket log broadcaster so that
// request logs are visible in the frontend Settings page.
func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
method := c.Request.Method
if raw != "" {
path = path + "?" + raw
}
msg := fmt.Sprintf("%3d | %13v | %-7s %s",
status, latency, method, path)
// Write to stdout (original Gin behaviour)
fmt.Printf("[GIN] %s\n", msg)
// Push to broadcaster for WebSocket streaming
if b != nil {
level := "INFO"
if status >= 500 {
level = "ERROR"
} else if status >= 400 {
level = "WARN"
}
b.Push(level, fmt.Sprintf("[GIN] %s", msg))
}
}
}
// spaFallback tries to serve the exact file from the embedded FS.
// If the file doesn't exist, it serves index.html for client-side routing.
func spaFallback(staticFS http.FileSystem) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// Don't serve index.html for API or WebSocket routes
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
c.Status(http.StatusNotFound)
return
}
// Try to serve the exact file (e.g., /models/index.html)
f, err := staticFS.Open(path)
if err == nil {
f.Close()
http.FileServer(staticFS).ServeHTTP(c.Writer, c.Request)
return
}
// Fall back to root index.html for SPA routing
index, err := staticFS.Open("/index.html")
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
defer index.Close()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
io.Copy(c.Writer, index)
}
}

View File

@ -1,39 +0,0 @@
package ws
import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func ClusterFlashProgressHandler(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
clusterID := c.Param("id")
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
client := &Client{Conn: conn, Send: make(chan []byte, 20)}
room := "flash:cluster:" + clusterID
sub := &Subscription{Client: client, Room: room}
hub.RegisterSync(sub)
defer hub.Unregister(sub)
// Read pump — drain incoming messages (ping/pong, close frames)
go func() {
defer conn.Close()
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
}()
for msg := range client.Send {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
}

View File

@ -1,29 +0,0 @@
package ws
import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func ClusterInferenceHandler(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
clusterID := c.Param("id")
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
client := &Client{Conn: conn, Send: make(chan []byte, 20)}
room := "inference:cluster:" + clusterID
sub := &Subscription{Client: client, Room: room}
hub.Register(sub)
defer hub.Unregister(sub)
for msg := range client.Send {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
}

View File

@ -1,44 +0,0 @@
package ws
import (
"net/http"
"edge-ai-platform/internal/device"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func DeviceEventsHandler(hub *Hub, deviceMgr *device.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
client := &Client{Conn: conn, Send: make(chan []byte, 10)}
room := "device-events"
sub := &Subscription{Client: client, Room: room}
hub.Register(sub)
defer hub.Unregister(sub)
// Forward device events to this WebSocket room
go func() {
for event := range deviceMgr.Events() {
hub.BroadcastToRoom(room, event)
}
}()
// Write pump
for msg := range client.Send {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
}

View File

@ -1,39 +0,0 @@
package ws
import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func FlashProgressHandler(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
deviceID := c.Param("id")
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
client := &Client{Conn: conn, Send: make(chan []byte, 20)}
room := "flash:" + deviceID
sub := &Subscription{Client: client, Room: room}
hub.RegisterSync(sub)
defer hub.Unregister(sub)
// Read pump — drain incoming messages (ping/pong, close frames)
go func() {
defer conn.Close()
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
}()
for msg := range client.Send {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
}

View File

@ -1,106 +0,0 @@
package ws
import (
"encoding/json"
"sync"
"github.com/gorilla/websocket"
)
type Client struct {
Conn *websocket.Conn
Send chan []byte
}
type Subscription struct {
Client *Client
Room string
done chan struct{} // used by RegisterSync to wait for completion
}
type RoomMessage struct {
Room string
Message []byte
}
type Hub struct {
rooms map[string]map[*Client]bool
register chan *Subscription
unregister chan *Subscription
broadcast chan *RoomMessage
mu sync.RWMutex
}
func NewHub() *Hub {
return &Hub{
rooms: make(map[string]map[*Client]bool),
register: make(chan *Subscription, 10),
unregister: make(chan *Subscription, 10),
broadcast: make(chan *RoomMessage, 100),
}
}
func (h *Hub) Run() {
for {
select {
case sub := <-h.register:
h.mu.Lock()
if h.rooms[sub.Room] == nil {
h.rooms[sub.Room] = make(map[*Client]bool)
}
h.rooms[sub.Room][sub.Client] = true
h.mu.Unlock()
if sub.done != nil {
close(sub.done)
}
case sub := <-h.unregister:
h.mu.Lock()
if clients, ok := h.rooms[sub.Room]; ok {
if _, exists := clients[sub.Client]; exists {
delete(clients, sub.Client)
close(sub.Client.Send)
}
}
h.mu.Unlock()
case msg := <-h.broadcast:
h.mu.RLock()
if clients, ok := h.rooms[msg.Room]; ok {
for client := range clients {
select {
case client.Send <- msg.Message:
default:
close(client.Send)
delete(clients, client)
}
}
}
h.mu.RUnlock()
}
}
}
func (h *Hub) Register(sub *Subscription) {
h.register <- sub
}
// RegisterSync registers a subscription and blocks until the Hub has processed it,
// ensuring the client is in the room before returning.
func (h *Hub) RegisterSync(sub *Subscription) {
sub.done = make(chan struct{})
h.register <- sub
<-sub.done
}
func (h *Hub) Unregister(sub *Subscription) {
h.unregister <- sub
}
func (h *Hub) BroadcastToRoom(room string, data interface{}) {
jsonData, err := json.Marshal(data)
if err != nil {
return
}
h.broadcast <- &RoomMessage{Room: room, Message: jsonData}
}

View File

@ -1,31 +0,0 @@
package ws
import (
"edge-ai-platform/internal/inference"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func InferenceHandler(hub *Hub, inferenceSvc *inference.Service) gin.HandlerFunc {
return func(c *gin.Context) {
deviceID := c.Param("id")
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
client := &Client{Conn: conn, Send: make(chan []byte, 20)}
room := "inference:" + deviceID
sub := &Subscription{Client: client, Room: room}
hub.Register(sub)
defer hub.Unregister(sub)
for msg := range client.Send {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
}

View File

@ -1,57 +0,0 @@
package ws
import (
"encoding/json"
"edge-ai-platform/pkg/logger"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func ServerLogsHandler(hub *Hub, broadcaster *logger.Broadcaster) gin.HandlerFunc {
return func(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
client := &Client{Conn: conn, Send: make(chan []byte, 100)}
sub := &Subscription{Client: client, Room: "server-logs"}
hub.RegisterSync(sub)
defer hub.Unregister(sub)
// Send buffered recent logs to the newly connected client
if broadcaster != nil {
for _, entry := range broadcaster.Recent() {
data, err := json.Marshal(entry)
if err != nil {
continue
}
select {
case client.Send <- data:
default:
return
}
}
}
// Read pump — drain incoming messages (close frames)
go func() {
defer conn.Close()
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
}()
// Write pump
for msg := range client.Send {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
}

View File

@ -1,188 +0,0 @@
package camera
import (
"bufio"
"fmt"
"io"
"os/exec"
"runtime"
"sync"
)
// FFmpegCamera captures webcam frames using ffmpeg subprocess.
// Supports macOS (AVFoundation) and Windows (DirectShow).
// ffmpeg outputs a continuous MJPEG stream to stdout which is parsed
// by scanning for JPEG SOI (0xFFD8) and EOI (0xFFD9) markers.
type FFmpegCamera struct {
cmd *exec.Cmd
stdout io.ReadCloser
latestFrame []byte
mu sync.Mutex
done chan struct{}
err error
}
// NewFFmpegCamera starts an ffmpeg process to capture from the given camera.
// On macOS, cameraIndex is used (e.g. 0 for first camera).
// On Windows, cameraName from device detection is used; cameraIndex is ignored
// unless no name is provided.
func NewFFmpegCamera(cameraIndex, width, height, framerate int) (*FFmpegCamera, error) {
return NewFFmpegCameraWithName(cameraIndex, "", width, height, framerate)
}
// NewFFmpegCameraWithName starts ffmpeg with explicit camera name (needed for Windows dshow).
func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height, framerate int) (*FFmpegCamera, error) {
args := buildCaptureArgs(cameraIndex, cameraName, width, height, framerate)
cmd := exec.Command("ffmpeg", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
}
// Suppress ffmpeg's stderr banner/logs
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
cam := &FFmpegCamera{
cmd: cmd,
stdout: stdout,
done: make(chan struct{}),
}
go cam.readLoop()
return cam, nil
}
// buildCaptureArgs returns the ffmpeg arguments for the current OS.
func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framerate int) []string {
videoSize := fmt.Sprintf("%dx%d", width, height)
fps := fmt.Sprintf("%d", framerate)
switch runtime.GOOS {
case "windows":
// DirectShow on Windows: -f dshow -i video="Camera Name"
inputName := cameraName
if inputName == "" {
// Fallback: try to detect first camera
devices := ListFFmpegDevices()
if len(devices) > 0 {
inputName = devices[0].Name
} else {
inputName = "Integrated Camera"
}
}
return []string{
"-f", "dshow",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("video=%s", inputName),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
}
default:
// AVFoundation on macOS: -f avfoundation -i "index:none"
return []string{
"-f", "avfoundation",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("%d:none", cameraIndex),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
}
}
}
// readLoop continuously reads ffmpeg's stdout and extracts JPEG frames.
func (c *FFmpegCamera) readLoop() {
defer close(c.done)
reader := bufio.NewReaderSize(c.stdout, 1024*1024) // 1MB buffer
buf := make([]byte, 0, 512*1024) // 512KB initial frame buffer
inFrame := false
for {
b, err := reader.ReadByte()
if err != nil {
c.mu.Lock()
c.err = fmt.Errorf("ffmpeg stream ended: %w", err)
c.mu.Unlock()
return
}
if !inFrame {
// Look for SOI marker: 0xFF 0xD8
if b == 0xFF {
next, err := reader.ReadByte()
if err != nil {
c.mu.Lock()
c.err = fmt.Errorf("ffmpeg stream ended: %w", err)
c.mu.Unlock()
return
}
if next == 0xD8 {
// Start of JPEG
buf = buf[:0]
buf = append(buf, 0xFF, 0xD8)
inFrame = true
}
}
continue
}
// Inside a frame, collect bytes
buf = append(buf, b)
// Look for EOI marker: 0xFF 0xD9
if b == 0xD9 && len(buf) >= 2 && buf[len(buf)-2] == 0xFF {
// Complete JPEG frame
frame := make([]byte, len(buf))
copy(frame, buf)
c.mu.Lock()
c.latestFrame = frame
c.mu.Unlock()
inFrame = false
}
}
}
// ReadFrame returns the most recently captured JPEG frame.
func (c *FFmpegCamera) ReadFrame() ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.err != nil {
return nil, c.err
}
if c.latestFrame == nil {
return nil, fmt.Errorf("no frame available yet")
}
// Return a copy to avoid data races
frame := make([]byte, len(c.latestFrame))
copy(frame, c.latestFrame)
return frame, nil
}
// Close stops the ffmpeg process and cleans up resources.
func (c *FFmpegCamera) Close() error {
if c.cmd != nil && c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
_ = c.cmd.Wait()
}
<-c.done
return nil
}

View File

@ -1,134 +0,0 @@
package camera
import (
"fmt"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
)
// DetectFFmpeg checks if ffmpeg is available on the system.
func DetectFFmpeg() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}
// ListFFmpegDevices detects available video devices using ffmpeg.
// Automatically selects the correct capture framework for the current OS:
// - macOS: AVFoundation
// - Windows: DirectShow (dshow)
func ListFFmpegDevices() []CameraInfo {
if !DetectFFmpeg() {
return nil
}
switch runtime.GOOS {
case "windows":
return listDShowDevices()
default:
return listAVFoundationDevices()
}
}
// --- macOS (AVFoundation) ---
func listAVFoundationDevices() []CameraInfo {
cmd := exec.Command("ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", "")
output, _ := cmd.CombinedOutput()
return parseAVFoundationOutput(string(output))
}
// parseAVFoundationOutput parses ffmpeg AVFoundation device listing.
// Example:
//
// [AVFoundation indev @ 0x...] AVFoundation video devices:
// [AVFoundation indev @ 0x...] [0] FaceTime HD Camera
// [AVFoundation indev @ 0x...] [1] Capture screen 0
// [AVFoundation indev @ 0x...] AVFoundation audio devices:
func parseAVFoundationOutput(output string) []CameraInfo {
var cameras []CameraInfo
lines := strings.Split(output, "\n")
deviceRe := regexp.MustCompile(`\[AVFoundation[^\]]*\]\s*\[(\d+)\]\s*(.+)`)
inVideoSection := false
for _, line := range lines {
if strings.Contains(line, "AVFoundation video devices") {
inVideoSection = true
continue
}
if strings.Contains(line, "AVFoundation audio devices") {
break
}
if !inVideoSection {
continue
}
matches := deviceRe.FindStringSubmatch(line)
if len(matches) == 3 {
index, err := strconv.Atoi(matches[1])
if err != nil {
continue
}
name := strings.TrimSpace(matches[2])
// Skip screen capture devices
if strings.Contains(strings.ToLower(name), "capture screen") {
continue
}
cameras = append(cameras, CameraInfo{
ID: fmt.Sprintf("cam-%d", index),
Name: name,
Index: index,
Width: 640,
Height: 480,
})
}
}
return cameras
}
// --- Windows (DirectShow) ---
func listDShowDevices() []CameraInfo {
cmd := exec.Command("ffmpeg", "-f", "dshow", "-list_devices", "true", "-i", "dummy")
output, _ := cmd.CombinedOutput()
return parseDShowOutput(string(output))
}
// parseDShowOutput parses ffmpeg DirectShow device listing.
// Example:
//
// [dshow @ 0x...] "Integrated Camera" (video)
// [dshow @ 0x...] Alternative name "@device_pnp_..."
// [dshow @ 0x...] "Microphone" (audio)
func parseDShowOutput(output string) []CameraInfo {
var cameras []CameraInfo
lines := strings.Split(output, "\n")
// Match: [dshow @ 0x...] "Device Name" (video)
deviceRe := regexp.MustCompile(`\[dshow[^\]]*\]\s*"([^"]+)"\s*\(video\)`)
index := 0
for _, line := range lines {
matches := deviceRe.FindStringSubmatch(line)
if len(matches) == 2 {
name := strings.TrimSpace(matches[1])
cameras = append(cameras, CameraInfo{
ID: fmt.Sprintf("cam-%d", index),
Name: name,
Index: index,
Width: 640,
Height: 480,
})
index++
}
}
return cameras
}

View File

@ -1,8 +0,0 @@
package camera
// FrameSource abstracts anything that produces JPEG frames.
// Camera manager, image files, and video files all implement this.
type FrameSource interface {
ReadFrame() ([]byte, error)
Close() error
}

View File

@ -1,82 +0,0 @@
package camera
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
)
// ImageSource provides a single JPEG frame from an uploaded image file.
// ReadFrame() returns the frame once, then subsequent calls return an error
// to signal the pipeline that the source is exhausted.
type ImageSource struct {
jpegData []byte
width int
height int
filePath string
done bool
}
// NewImageSource reads an image file (JPG or PNG) and converts to JPEG bytes.
func NewImageSource(filePath string) (*ImageSource, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open image: %w", err)
}
defer f.Close()
ext := strings.ToLower(filepath.Ext(filePath))
var img image.Image
switch ext {
case ".jpg", ".jpeg":
img, err = jpeg.Decode(f)
case ".png":
img, err = png.Decode(f)
default:
return nil, fmt.Errorf("unsupported image format: %s", ext)
}
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
bounds := img.Bounds()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
return nil, fmt.Errorf("failed to encode JPEG: %w", err)
}
return &ImageSource{
jpegData: buf.Bytes(),
width: bounds.Dx(),
height: bounds.Dy(),
filePath: filePath,
}, nil
}
func (s *ImageSource) ReadFrame() ([]byte, error) {
if !s.done {
s.done = true
}
// Always return the same frame so the MJPEG streamer can serve it
// to clients that connect at any time.
return s.jpegData, nil
}
func (s *ImageSource) Close() error {
return os.Remove(s.filePath)
}
// Dimensions returns the image width and height.
func (s *ImageSource) Dimensions() (int, int) {
return s.width, s.height
}
// IsDone returns whether the single frame has been consumed.
func (s *ImageSource) IsDone() bool {
return s.done
}

View File

@ -1,107 +0,0 @@
package camera
import (
"fmt"
"sync"
)
type CameraInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Index int `json:"index"`
Width int `json:"width"`
Height int `json:"height"`
}
type Manager struct {
mockMode bool
mockCamera *MockCamera
ffmpegCam *FFmpegCamera
isOpen bool
mu sync.Mutex
}
func NewManager(mockMode bool) *Manager {
return &Manager{mockMode: mockMode}
}
func (m *Manager) ListCameras() []CameraInfo {
if m.mockMode {
return []CameraInfo{
{ID: "mock-cam-0", Name: "Mock Camera 0", Index: 0, Width: 640, Height: 480},
}
}
// Try to detect real cameras via ffmpeg (auto-detects OS)
devices := ListFFmpegDevices()
if len(devices) > 0 {
return devices
}
if !DetectFFmpeg() {
fmt.Println("[WARN] ffmpeg not found — install with: brew install ffmpeg (macOS) or winget install ffmpeg (Windows)")
} else {
fmt.Println("[WARN] No video devices detected by ffmpeg")
}
return []CameraInfo{}
}
func (m *Manager) Open(index, width, height int) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.mockMode {
m.mockCamera = NewMockCamera(width, height)
m.isOpen = true
return nil
}
// Try real camera via ffmpeg
if !DetectFFmpeg() {
return fmt.Errorf("ffmpeg not found — install with: brew install ffmpeg (macOS) or winget install ffmpeg (Windows)")
}
cam, err := NewFFmpegCamera(index, width, height, 30)
if err != nil {
return fmt.Errorf("failed to open camera (index=%d): %w", index, err)
}
m.ffmpegCam = cam
m.isOpen = true
fmt.Printf("[INFO] Opened real camera (index=%d) via ffmpeg\n", index)
return nil
}
func (m *Manager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.ffmpegCam != nil {
_ = m.ffmpegCam.Close()
m.ffmpegCam = nil
}
m.mockCamera = nil
m.isOpen = false
return nil
}
func (m *Manager) ReadFrame() ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
if !m.isOpen {
return nil, fmt.Errorf("camera not open")
}
if m.ffmpegCam != nil {
return m.ffmpegCam.ReadFrame()
}
if m.mockCamera != nil {
return m.mockCamera.ReadFrame()
}
return nil, fmt.Errorf("no camera available")
}
func (m *Manager) IsOpen() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.isOpen
}

View File

@ -1,81 +0,0 @@
package camera
import (
"fmt"
"net/http"
"sync"
)
type MJPEGStreamer struct {
frameCh chan []byte
clients map[chan []byte]bool
mu sync.Mutex
}
func NewMJPEGStreamer() *MJPEGStreamer {
return &MJPEGStreamer{
frameCh: make(chan []byte, 3),
clients: make(map[chan []byte]bool),
}
}
func (s *MJPEGStreamer) FrameChannel() chan<- []byte {
return s.frameCh
}
func (s *MJPEGStreamer) Run() {
for frame := range s.frameCh {
s.mu.Lock()
for ch := range s.clients {
select {
case ch <- frame:
default:
// drop frame for slow client
}
}
s.mu.Unlock()
}
}
func (s *MJPEGStreamer) AddClient() chan []byte {
ch := make(chan []byte, 3)
s.mu.Lock()
s.clients[ch] = true
s.mu.Unlock()
return ch
}
func (s *MJPEGStreamer) RemoveClient(ch chan []byte) {
s.mu.Lock()
delete(s.clients, ch)
s.mu.Unlock()
}
func (s *MJPEGStreamer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", 500)
return
}
clientCh := s.AddClient()
defer s.RemoveClient(clientCh)
for {
select {
case <-r.Context().Done():
return
case frame := <-clientCh:
fmt.Fprintf(w, "--frame\r\n")
fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
w.Write(frame)
fmt.Fprintf(w, "\r\n")
flusher.Flush()
}
}
}

View File

@ -1,95 +0,0 @@
package camera
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
"time"
)
type MockCamera struct {
width int
height int
frameCount int
}
func NewMockCamera(width, height int) *MockCamera {
return &MockCamera{width: width, height: height}
}
func (mc *MockCamera) ReadFrame() ([]byte, error) {
mc.frameCount++
return mc.generateTestCard()
}
func (mc *MockCamera) generateTestCard() ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, mc.width, mc.height))
offset := mc.frameCount % mc.width
for y := 0; y < mc.height; y++ {
for x := 0; x < mc.width; x++ {
pos := (x + offset) % mc.width
ratio := float64(pos) / float64(mc.width)
var r, g, b uint8
if ratio < 0.33 {
r = uint8(255 * (1 - ratio/0.33))
g = uint8(255 * ratio / 0.33)
} else if ratio < 0.66 {
g = uint8(255 * (1 - (ratio-0.33)/0.33))
b = uint8(255 * (ratio - 0.33) / 0.33)
} else {
b = uint8(255 * (1 - (ratio-0.66)/0.34))
r = uint8(255 * (ratio - 0.66) / 0.34)
}
img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
}
}
// Draw dark overlay bar at top for text area
for y := 0; y < 40; y++ {
for x := 0; x < mc.width; x++ {
img.SetRGBA(x, y, color.RGBA{R: 0, G: 0, B: 0, A: 180})
}
}
// Draw "MOCK CAMERA" text block and frame counter using simple rectangles
drawTextBlock(img, 10, 10, fmt.Sprintf("MOCK CAMERA | Frame: %d | %s", mc.frameCount, time.Now().Format("15:04:05")))
// Draw center crosshair
cx, cy := mc.width/2, mc.height/2
for i := -20; i <= 20; i++ {
if cx+i >= 0 && cx+i < mc.width {
img.SetRGBA(cx+i, cy, color.RGBA{R: 255, G: 255, B: 255, A: 200})
}
if cy+i >= 0 && cy+i < mc.height {
img.SetRGBA(cx, cy+i, color.RGBA{R: 255, G: 255, B: 255, A: 200})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func drawTextBlock(img *image.RGBA, x, y int, text string) {
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
// Simple pixel-based text rendering: each character is a 5x7 block
for i, ch := range text {
if ch == ' ' {
continue
}
px := x + i*6
// Draw a small white dot for each character position
for dy := 0; dy < 5; dy++ {
for dx := 0; dx < 4; dx++ {
if px+dx < img.Bounds().Max.X && y+dy < img.Bounds().Max.Y {
img.SetRGBA(px+dx, y+dy, white)
}
}
}
}
}

View File

@ -1,146 +0,0 @@
package camera
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"sync"
)
// BatchImageEntry holds metadata and decoded JPEG data for a single image in a batch.
type BatchImageEntry struct {
Filename string
FilePath string
JpegData []byte
Width int
Height int
}
// MultiImageSource provides sequential JPEG frames from multiple uploaded images.
// It implements FrameSource. The pipeline calls ReadFrame for the current image,
// then Advance to move to the next one. After the last image, ReadFrame returns an error.
type MultiImageSource struct {
images []BatchImageEntry
currentIdx int
mu sync.Mutex
}
// NewMultiImageSource creates a source from multiple file paths.
// Each file is decoded (JPG/PNG) and converted to JPEG in memory.
func NewMultiImageSource(filePaths []string, filenames []string) (*MultiImageSource, error) {
if len(filePaths) != len(filenames) {
return nil, fmt.Errorf("filePaths and filenames length mismatch")
}
entries := make([]BatchImageEntry, 0, len(filePaths))
for i, fp := range filePaths {
entry, err := loadBatchImageEntry(fp, filenames[i])
if err != nil {
// Clean up already-loaded temp files
for _, e := range entries {
os.Remove(e.FilePath)
}
return nil, fmt.Errorf("image %d (%s): %w", i, filenames[i], err)
}
entries = append(entries, entry)
}
return &MultiImageSource{images: entries}, nil
}
func loadBatchImageEntry(filePath, filename string) (BatchImageEntry, error) {
f, err := os.Open(filePath)
if err != nil {
return BatchImageEntry{}, fmt.Errorf("failed to open: %w", err)
}
defer f.Close()
ext := strings.ToLower(filepath.Ext(filePath))
var img image.Image
switch ext {
case ".jpg", ".jpeg":
img, err = jpeg.Decode(f)
case ".png":
img, err = png.Decode(f)
default:
return BatchImageEntry{}, fmt.Errorf("unsupported format: %s", ext)
}
if err != nil {
return BatchImageEntry{}, fmt.Errorf("failed to decode: %w", err)
}
bounds := img.Bounds()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
return BatchImageEntry{}, fmt.Errorf("failed to encode JPEG: %w", err)
}
return BatchImageEntry{
Filename: filename,
FilePath: filePath,
JpegData: buf.Bytes(),
Width: bounds.Dx(),
Height: bounds.Dy(),
}, nil
}
// ReadFrame returns the current image's JPEG data.
func (s *MultiImageSource) ReadFrame() ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.currentIdx >= len(s.images) {
return nil, fmt.Errorf("all images consumed")
}
return s.images[s.currentIdx].JpegData, nil
}
// Advance moves to the next image. Returns false if no more images remain.
func (s *MultiImageSource) Advance() bool {
s.mu.Lock()
defer s.mu.Unlock()
s.currentIdx++
return s.currentIdx < len(s.images)
}
// CurrentIndex returns the 0-based index of the current image.
func (s *MultiImageSource) CurrentIndex() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.currentIdx
}
// CurrentEntry returns metadata for the current image.
func (s *MultiImageSource) CurrentEntry() BatchImageEntry {
s.mu.Lock()
defer s.mu.Unlock()
return s.images[s.currentIdx]
}
// TotalImages returns the number of images in the batch.
func (s *MultiImageSource) TotalImages() int {
return len(s.images)
}
// GetImageByIndex returns JPEG data for a specific image by index.
func (s *MultiImageSource) GetImageByIndex(index int) ([]byte, error) {
if index < 0 || index >= len(s.images) {
return nil, fmt.Errorf("image index %d out of range [0, %d)", index, len(s.images))
}
return s.images[index].JpegData, nil
}
// Images returns all batch entries.
func (s *MultiImageSource) Images() []BatchImageEntry {
return s.images
}
// Close removes all temporary files.
func (s *MultiImageSource) Close() error {
for _, entry := range s.images {
os.Remove(entry.FilePath)
}
return nil
}

View File

@ -1,230 +0,0 @@
package camera
import (
"context"
"time"
"edge-ai-platform/internal/driver"
)
// SourceType identifies the kind of frame source used in the pipeline.
type SourceType string
const (
SourceCamera SourceType = "camera"
SourceImage SourceType = "image"
SourceVideo SourceType = "video"
SourceBatchImage SourceType = "batch_image"
)
type InferencePipeline struct {
source FrameSource
sourceType SourceType
device driver.DeviceDriver
frameCh chan<- []byte
resultCh chan<- *driver.InferenceResult
cancel context.CancelFunc
doneCh chan struct{}
frameOffset int // starting frame index (non-zero after seek)
}
func NewInferencePipeline(
source FrameSource,
sourceType SourceType,
device driver.DeviceDriver,
frameCh chan<- []byte,
resultCh chan<- *driver.InferenceResult,
) *InferencePipeline {
return &InferencePipeline{
source: source,
sourceType: sourceType,
device: device,
frameCh: frameCh,
resultCh: resultCh,
doneCh: make(chan struct{}),
}
}
// NewInferencePipelineWithOffset creates a pipeline with a frame offset (used after seek).
func NewInferencePipelineWithOffset(
source FrameSource,
sourceType SourceType,
device driver.DeviceDriver,
frameCh chan<- []byte,
resultCh chan<- *driver.InferenceResult,
frameOffset int,
) *InferencePipeline {
return &InferencePipeline{
source: source,
sourceType: sourceType,
device: device,
frameCh: frameCh,
resultCh: resultCh,
doneCh: make(chan struct{}),
frameOffset: frameOffset,
}
}
func (p *InferencePipeline) Start() {
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.run(ctx)
}
func (p *InferencePipeline) Stop() {
if p.cancel != nil {
p.cancel()
}
}
// Done returns a channel that closes when the pipeline finishes.
// For camera mode this only closes on Stop(); for image/video it
// closes when the source is exhausted.
func (p *InferencePipeline) Done() <-chan struct{} {
return p.doneCh
}
func (p *InferencePipeline) run(ctx context.Context) {
defer close(p.doneCh)
targetInterval := time.Second / 15 // 15 FPS
inferenceRan := false // for image mode: only run inference once
frameIndex := 0 // video frame counter
for {
select {
case <-ctx.Done():
return
default:
}
start := time.Now()
var jpegFrame []byte
var readErr error
// Video mode: ReadFrame blocks on channel, need to respect ctx cancel
if p.sourceType == SourceVideo {
vs := p.source.(*VideoSource)
select {
case <-ctx.Done():
return
case frame, ok := <-vs.frameCh:
if !ok {
return // all frames consumed
}
jpegFrame = frame
}
} else {
jpegFrame, readErr = p.source.ReadFrame()
if readErr != nil {
time.Sleep(100 * time.Millisecond)
continue
}
}
// Send to MJPEG stream
select {
case p.frameCh <- jpegFrame:
default:
}
// Batch image mode: process each image sequentially, then advance.
if p.sourceType == SourceBatchImage {
mis := p.source.(*MultiImageSource)
for {
select {
case <-ctx.Done():
return
default:
}
frame, err := mis.ReadFrame()
if err != nil {
return
}
// Send current frame to MJPEG
select {
case p.frameCh <- frame:
default:
}
// Run inference on this image
result, inferErr := p.device.RunInference(frame)
if inferErr == nil {
entry := mis.CurrentEntry()
result.ImageIndex = mis.CurrentIndex()
result.TotalImages = mis.TotalImages()
result.Filename = entry.Filename
select {
case p.resultCh <- result:
default:
}
}
// Move to next image
if !mis.Advance() {
// Keep sending last frame for late-connecting MJPEG clients (~2s)
for i := 0; i < 30; i++ {
select {
case <-ctx.Done():
return
default:
}
select {
case p.frameCh <- frame:
default:
}
time.Sleep(time.Second / 15)
}
return
}
}
}
// Image mode: only run inference once, then keep sending
// the same frame to MJPEG so late-connecting clients can see it.
if p.sourceType == SourceImage {
if !inferenceRan {
inferenceRan = true
result, err := p.device.RunInference(jpegFrame)
if err == nil {
select {
case p.resultCh <- result:
default:
}
}
}
elapsed := time.Since(start)
if elapsed < targetInterval {
time.Sleep(targetInterval - elapsed)
}
continue
}
// Camera / Video mode: run inference every frame
result, err := p.device.RunInference(jpegFrame)
if err != nil {
continue
}
// Video mode: attach frame progress
if p.sourceType == SourceVideo {
result.FrameIndex = p.frameOffset + frameIndex
frameIndex++
vs := p.source.(*VideoSource)
result.TotalFrames = vs.TotalFrames()
}
select {
case p.resultCh <- result:
default:
}
elapsed := time.Since(start)
if elapsed < targetInterval {
time.Sleep(targetInterval - elapsed)
}
}
}

View File

@ -1,277 +0,0 @@
package camera
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"sync/atomic"
)
// VideoInfo holds metadata extracted by ffprobe before pipeline starts.
type VideoInfo struct {
DurationSec float64 // total duration in seconds
TotalFrames int // estimated total frames at target FPS
}
// ProbeVideoInfo runs ffprobe to extract duration from a video file or URL.
// Returns zero values (no error) when duration is indeterminate (e.g. live streams).
func ProbeVideoInfo(input string, fps float64) VideoInfo {
cmd := exec.Command("ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "format=duration",
"-of", "csv=p=0",
input,
)
out, err := cmd.Output()
if err != nil {
return VideoInfo{}
}
durStr := strings.TrimSpace(string(out))
if durStr == "" || durStr == "N/A" {
return VideoInfo{}
}
dur, err := strconv.ParseFloat(durStr, 64)
if err != nil {
return VideoInfo{}
}
if fps <= 0 {
fps = 15
}
return VideoInfo{
DurationSec: dur,
TotalFrames: int(dur * fps),
}
}
// VideoSource reads a video file or URL frame-by-frame using ffmpeg, outputting
// JPEG frames via stdout. Reuses the same JPEG SOI/EOI marker parsing
// pattern as FFmpegCamera.
type VideoSource struct {
cmd *exec.Cmd
stdout io.ReadCloser
frameCh chan []byte // decoded frames queue
mu sync.Mutex
done chan struct{}
finished bool
err error
filePath string // local file path (empty for URL sources)
isURL bool // true when source is a URL, skip file cleanup
totalFrames int64 // 0 means unknown
frameCount int64 // atomic counter incremented in readLoop
}
// NewVideoSource starts an ffmpeg process that decodes a video file
// and outputs MJPEG frames to stdout at the specified FPS.
func NewVideoSource(filePath string, fps float64) (*VideoSource, error) {
return newVideoSource(filePath, fps, false, 0)
}
// NewVideoSourceWithSeek starts ffmpeg from a specific position (in seconds).
func NewVideoSourceWithSeek(filePath string, fps float64, seekSeconds float64) (*VideoSource, error) {
return newVideoSource(filePath, fps, false, seekSeconds)
}
// NewVideoSourceFromURL starts an ffmpeg process that reads from a URL
// (HTTP, HTTPS, RTSP, etc.) and outputs MJPEG frames to stdout.
func NewVideoSourceFromURL(rawURL string, fps float64) (*VideoSource, error) {
return newVideoSource(rawURL, fps, true, 0)
}
// NewVideoSourceFromURLWithSeek starts ffmpeg from a URL at a specific position.
func NewVideoSourceFromURLWithSeek(rawURL string, fps float64, seekSeconds float64) (*VideoSource, error) {
return newVideoSource(rawURL, fps, true, seekSeconds)
}
// ResolveWithYTDLP uses yt-dlp to extract the direct video stream URL
// from platforms like YouTube, Vimeo, etc.
// Returns the resolved direct URL or an error.
func ResolveWithYTDLP(rawURL string) (string, error) {
// yt-dlp -f "best[ext=mp4]/best" --get-url <URL>
cmd := exec.Command("yt-dlp", "-f", "best[ext=mp4]/best", "--get-url", rawURL)
out, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("yt-dlp failed: %s", string(exitErr.Stderr))
}
return "", fmt.Errorf("yt-dlp not available: %w", err)
}
resolved := strings.TrimSpace(string(out))
if resolved == "" {
return "", fmt.Errorf("yt-dlp returned empty URL")
}
// yt-dlp may return multiple lines (video + audio); take only the first
if idx := strings.Index(resolved, "\n"); idx > 0 {
resolved = resolved[:idx]
}
return resolved, nil
}
func newVideoSource(input string, fps float64, isURL bool, seekSeconds float64) (*VideoSource, error) {
if fps <= 0 {
fps = 15
}
args := []string{}
if seekSeconds > 0 {
args = append(args, "-ss", fmt.Sprintf("%.3f", seekSeconds))
}
args = append(args,
"-i", input,
"-vf", fmt.Sprintf("fps=%g", fps),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
)
cmd := exec.Command("ffmpeg", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
}
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
filePath := ""
if !isURL {
filePath = input
}
vs := &VideoSource{
cmd: cmd,
stdout: stdout,
frameCh: make(chan []byte, 30), // buffer up to 30 frames
done: make(chan struct{}),
filePath: filePath,
isURL: isURL,
}
go vs.readLoop()
return vs, nil
}
// readLoop scans ffmpeg stdout for JPEG SOI/EOI markers to extract frames.
func (v *VideoSource) readLoop() {
defer close(v.done)
defer close(v.frameCh)
reader := bufio.NewReaderSize(v.stdout, 1024*1024)
buf := make([]byte, 0, 512*1024)
inFrame := false
for {
b, err := reader.ReadByte()
if err != nil {
v.mu.Lock()
v.finished = true
if err != io.EOF {
v.err = fmt.Errorf("ffmpeg stream ended: %w", err)
}
v.mu.Unlock()
return
}
if !inFrame {
if b == 0xFF {
next, err := reader.ReadByte()
if err != nil {
v.mu.Lock()
v.finished = true
v.mu.Unlock()
return
}
if next == 0xD8 {
buf = buf[:0]
buf = append(buf, 0xFF, 0xD8)
inFrame = true
}
}
continue
}
buf = append(buf, b)
if b == 0xD9 && len(buf) >= 2 && buf[len(buf)-2] == 0xFF {
frame := make([]byte, len(buf))
copy(frame, buf)
v.frameCh <- frame // blocks if buffer full, applies backpressure
atomic.AddInt64(&v.frameCount, 1)
inFrame = false
}
}
}
// ReadFrame returns the next decoded frame, blocking until one is available.
// Returns an error when all frames have been consumed and ffmpeg has finished.
func (v *VideoSource) ReadFrame() ([]byte, error) {
frame, ok := <-v.frameCh
if !ok {
return nil, fmt.Errorf("video playback complete")
}
return frame, nil
}
// SetTotalFrames sets the expected total frame count (from ffprobe).
func (v *VideoSource) SetTotalFrames(n int) {
atomic.StoreInt64(&v.totalFrames, int64(n))
}
// TotalFrames returns the expected total frame count, or 0 if unknown.
func (v *VideoSource) TotalFrames() int {
return int(atomic.LoadInt64(&v.totalFrames))
}
// FrameCount returns the number of frames decoded so far.
func (v *VideoSource) FrameCount() int {
return int(atomic.LoadInt64(&v.frameCount))
}
// IsFinished returns true when the video file has been fully decoded
// AND all buffered frames have been consumed.
func (v *VideoSource) IsFinished() bool {
v.mu.Lock()
finished := v.finished
v.mu.Unlock()
return finished && len(v.frameCh) == 0
}
// CloseWithoutRemove stops the ffmpeg process but does NOT delete the temp file.
// Used when seeking: we need to restart ffmpeg from a different position but keep the file.
func (v *VideoSource) CloseWithoutRemove() error {
if v.cmd != nil && v.cmd.Process != nil {
_ = v.cmd.Process.Kill()
_ = v.cmd.Wait()
}
for range v.frameCh {
}
<-v.done
return nil
}
func (v *VideoSource) Close() error {
if v.cmd != nil && v.cmd.Process != nil {
_ = v.cmd.Process.Kill()
_ = v.cmd.Wait()
}
// Drain any remaining frames so readLoop can exit
for range v.frameCh {
}
<-v.done
// Only remove temp files, not URL sources
if !v.isURL && v.filePath != "" {
_ = os.Remove(v.filePath)
}
return nil
}

View File

@ -1,100 +0,0 @@
package cluster
import (
"fmt"
"sync"
"edge-ai-platform/internal/driver"
)
// Dispatcher implements Weighted Round-Robin frame dispatching across
// multiple devices. Each device receives frames proportional to its weight.
//
// Example: devices A(w=3), B(w=1), C(w=3)
// Sequence: A,A,A, B, C,C,C, A,A,A, B, ...
type Dispatcher struct {
members []DeviceMember
drivers []driver.DeviceDriver
degraded map[string]bool
current int
remaining int
frameIndex int64
mu sync.Mutex
}
// NewDispatcher creates a dispatcher with the given members and drivers.
// The members and drivers slices must be in the same order.
func NewDispatcher(members []DeviceMember, drivers []driver.DeviceDriver) *Dispatcher {
d := &Dispatcher{
members: members,
drivers: drivers,
degraded: make(map[string]bool),
}
if len(members) > 0 {
d.remaining = members[0].Weight
}
return d
}
// Next returns the next device driver to dispatch a frame to, along
// with a monotonically increasing frame index. Degraded devices are
// skipped. Returns an error if no active devices remain.
func (d *Dispatcher) Next() (driver.DeviceDriver, int64, error) {
d.mu.Lock()
defer d.mu.Unlock()
if len(d.members) == 0 {
return nil, 0, fmt.Errorf("no devices in dispatcher")
}
// Try to find an active device within one full cycle.
tried := 0
for tried < len(d.members) {
if d.remaining <= 0 {
d.current = (d.current + 1) % len(d.members)
d.remaining = d.members[d.current].Weight
}
if !d.degraded[d.members[d.current].DeviceID] {
drv := d.drivers[d.current]
idx := d.frameIndex
d.frameIndex++
d.remaining--
return drv, idx, nil
}
// Skip degraded device.
d.remaining = 0
tried++
}
return nil, 0, fmt.Errorf("no active devices available")
}
// MarkDegraded marks a device as degraded; the dispatcher will skip it.
func (d *Dispatcher) MarkDegraded(deviceID string) {
d.mu.Lock()
defer d.mu.Unlock()
d.degraded[deviceID] = true
}
// MarkActive re-enables a previously degraded device.
func (d *Dispatcher) MarkActive(deviceID string) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.degraded, deviceID)
}
// ActiveCount returns the number of non-degraded devices.
func (d *Dispatcher) ActiveCount() int {
d.mu.Lock()
defer d.mu.Unlock()
count := 0
for _, m := range d.members {
if !d.degraded[m.DeviceID] {
count++
}
}
return count
}

View File

@ -1,233 +0,0 @@
package cluster
import (
"fmt"
"strings"
"sync"
"edge-ai-platform/internal/device"
)
// Manager handles cluster lifecycle (CRUD) and holds cluster state in memory.
type Manager struct {
clusters map[string]*Cluster
deviceMgr *device.Manager
nextID int
mu sync.RWMutex
}
// NewManager creates a new cluster manager.
func NewManager(deviceMgr *device.Manager) *Manager {
return &Manager{
clusters: make(map[string]*Cluster),
deviceMgr: deviceMgr,
}
}
// ListClusters returns all clusters.
func (m *Manager) ListClusters() []*Cluster {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*Cluster, 0, len(m.clusters))
for _, c := range m.clusters {
result = append(result, c)
}
return result
}
// GetCluster returns a cluster by ID.
func (m *Manager) GetCluster(id string) (*Cluster, error) {
m.mu.RLock()
defer m.mu.RUnlock()
c, ok := m.clusters[id]
if !ok {
return nil, fmt.Errorf("cluster not found: %s", id)
}
return c, nil
}
// CreateCluster creates a new cluster with the given devices.
// Devices must exist and be connected.
func (m *Manager) CreateCluster(name string, deviceIDs []string) (*Cluster, error) {
if len(deviceIDs) == 0 {
return nil, fmt.Errorf("at least one device is required")
}
if len(deviceIDs) > MaxClusterSize {
return nil, fmt.Errorf("cluster size exceeds maximum of %d devices", MaxClusterSize)
}
m.mu.Lock()
defer m.mu.Unlock()
// Verify devices exist and are not in another cluster.
members := make([]DeviceMember, 0, len(deviceIDs))
for _, did := range deviceIDs {
session, err := m.deviceMgr.GetDevice(did)
if err != nil {
return nil, fmt.Errorf("device %s not found", did)
}
// Check device isn't already in a cluster.
for _, existing := range m.clusters {
for _, dm := range existing.Devices {
if dm.DeviceID == did && dm.Status != MemberRemoved {
return nil, fmt.Errorf("device %s is already in cluster %s", did, existing.Name)
}
}
}
info := session.Driver.Info()
weight := defaultWeight(info.Type)
members = append(members, DeviceMember{
DeviceID: did,
Weight: weight,
Status: MemberActive,
DeviceName: info.Name,
DeviceType: info.Type,
})
}
m.nextID++
id := fmt.Sprintf("cluster-%d", m.nextID)
cluster := &Cluster{
ID: id,
Name: name,
Devices: members,
Status: ClusterIdle,
}
m.clusters[id] = cluster
return cluster, nil
}
// DeleteCluster removes a cluster.
func (m *Manager) DeleteCluster(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.clusters[id]; !ok {
return fmt.Errorf("cluster not found: %s", id)
}
delete(m.clusters, id)
return nil
}
// AddDevice adds a device to an existing cluster.
func (m *Manager) AddDevice(clusterID, deviceID string, weight int) error {
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.clusters[clusterID]
if !ok {
return fmt.Errorf("cluster not found: %s", clusterID)
}
if len(c.Devices) >= MaxClusterSize {
return fmt.Errorf("cluster already has maximum %d devices", MaxClusterSize)
}
// Check not already in this cluster.
for _, dm := range c.Devices {
if dm.DeviceID == deviceID && dm.Status != MemberRemoved {
return fmt.Errorf("device %s is already in this cluster", deviceID)
}
}
session, err := m.deviceMgr.GetDevice(deviceID)
if err != nil {
return fmt.Errorf("device %s not found", deviceID)
}
info := session.Driver.Info()
if weight <= 0 {
weight = defaultWeight(info.Type)
}
c.Devices = append(c.Devices, DeviceMember{
DeviceID: deviceID,
Weight: weight,
Status: MemberActive,
DeviceName: info.Name,
DeviceType: info.Type,
})
return nil
}
// RemoveDevice removes a device from a cluster.
func (m *Manager) RemoveDevice(clusterID, deviceID string) error {
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.clusters[clusterID]
if !ok {
return fmt.Errorf("cluster not found: %s", clusterID)
}
found := false
filtered := make([]DeviceMember, 0, len(c.Devices))
for _, dm := range c.Devices {
if dm.DeviceID == deviceID {
found = true
continue
}
filtered = append(filtered, dm)
}
if !found {
return fmt.Errorf("device %s not found in cluster", deviceID)
}
c.Devices = filtered
return nil
}
// UpdateWeight changes a device's dispatch weight.
func (m *Manager) UpdateWeight(clusterID, deviceID string, weight int) error {
if weight < 1 {
return fmt.Errorf("weight must be at least 1")
}
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.clusters[clusterID]
if !ok {
return fmt.Errorf("cluster not found: %s", clusterID)
}
for i := range c.Devices {
if c.Devices[i].DeviceID == deviceID {
c.Devices[i].Weight = weight
return nil
}
}
return fmt.Errorf("device %s not found in cluster", deviceID)
}
// SetStatus updates the cluster status.
func (m *Manager) SetStatus(clusterID string, status ClusterStatus) {
m.mu.Lock()
defer m.mu.Unlock()
if c, ok := m.clusters[clusterID]; ok {
c.Status = status
}
}
// DeviceManager returns the underlying device manager.
func (m *Manager) DeviceManager() *device.Manager {
return m.deviceMgr
}
func defaultWeight(deviceType string) int {
dt := strings.ToLower(deviceType)
if strings.Contains(dt, "kl720") || strings.Contains(dt, "kl730") {
return DefaultWeightKL720
}
return DefaultWeightKL520
}

View File

@ -1,135 +0,0 @@
package cluster
import (
"context"
"fmt"
"sync"
"edge-ai-platform/internal/driver"
)
// frameJob is sent from the main loop to a per-device worker goroutine.
type frameJob struct {
frame []byte
frameIndex int64
}
// ClusterPipeline manages parallel inference across multiple devices.
// It dispatches frames using a weighted round-robin dispatcher and
// collects results into a unified output channel.
type ClusterPipeline struct {
cluster *Cluster
dispatcher *Dispatcher
resultCh chan<- *ClusterResult
cancel context.CancelFunc
doneCh chan struct{}
}
// NewClusterPipeline creates a pipeline for the given cluster.
func NewClusterPipeline(
cluster *Cluster,
dispatcher *Dispatcher,
resultCh chan<- *ClusterResult,
) *ClusterPipeline {
return &ClusterPipeline{
cluster: cluster,
dispatcher: dispatcher,
resultCh: resultCh,
doneCh: make(chan struct{}),
}
}
// RunInference runs a single frame through the cluster dispatcher and
// returns the result. This is used by the camera pipeline integration.
func (p *ClusterPipeline) RunInference(imageData []byte) (*ClusterResult, error) {
drv, frameIdx, err := p.dispatcher.Next()
if err != nil {
return nil, fmt.Errorf("dispatcher error: %w", err)
}
result, err := drv.RunInference(imageData)
if err != nil {
return nil, err
}
return &ClusterResult{
InferenceResult: result,
ClusterID: p.cluster.ID,
FrameIndex: frameIdx,
}, nil
}
// StartContinuous starts the pipeline in continuous mode, where frames
// are read from all devices in parallel. Each device runs its own
// inference loop. Results are merged into the unified resultCh.
func (p *ClusterPipeline) StartContinuous() {
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
var wg sync.WaitGroup
for i := range p.dispatcher.members {
member := p.dispatcher.members[i]
drv := p.dispatcher.drivers[i]
if p.dispatcher.degraded[member.DeviceID] {
continue
}
wg.Add(1)
go func(deviceID string, d driver.DeviceDriver) {
defer wg.Done()
consecutiveErrors := 0
for {
select {
case <-ctx.Done():
return
default:
}
result, err := d.ReadInference()
if err != nil {
consecutiveErrors++
if consecutiveErrors >= 3 {
p.dispatcher.MarkDegraded(deviceID)
p.cluster.Status = ClusterDegraded
}
continue
}
consecutiveErrors = 0
result.DeviceID = deviceID
cr := &ClusterResult{
InferenceResult: result,
ClusterID: p.cluster.ID,
FrameIndex: -1, // continuous mode doesn't use ordered frames
}
select {
case p.resultCh <- cr:
default:
// Drop result if channel is full.
}
}
}(member.DeviceID, drv)
}
go func() {
wg.Wait()
close(p.doneCh)
}()
}
// Stop cancels the pipeline and waits for all workers to finish.
func (p *ClusterPipeline) Stop() {
if p.cancel != nil {
p.cancel()
}
<-p.doneCh
}
// Done returns a channel that is closed when the pipeline has stopped.
func (p *ClusterPipeline) Done() <-chan struct{} {
return p.doneCh
}

View File

@ -1,64 +0,0 @@
package cluster
import "edge-ai-platform/internal/driver"
// Default dispatch weights per chip type.
const (
DefaultWeightKL720 = 3
DefaultWeightKL520 = 1
)
// MaxClusterSize is the maximum number of devices in a single cluster.
const MaxClusterSize = 8
// ClusterStatus represents the current state of a cluster.
type ClusterStatus string
const (
ClusterIdle ClusterStatus = "idle"
ClusterInferencing ClusterStatus = "inferencing"
ClusterDegraded ClusterStatus = "degraded"
)
// MemberStatus represents the state of a device within a cluster.
type MemberStatus string
const (
MemberActive MemberStatus = "active"
MemberDegraded MemberStatus = "degraded"
MemberRemoved MemberStatus = "removed"
)
// DeviceMember represents a device participating in a cluster.
type DeviceMember struct {
DeviceID string `json:"deviceId"`
Weight int `json:"weight"`
Status MemberStatus `json:"status"`
DeviceName string `json:"deviceName,omitempty"`
DeviceType string `json:"deviceType,omitempty"`
}
// Cluster represents a group of devices performing parallel inference.
type Cluster struct {
ID string `json:"id"`
Name string `json:"name"`
Devices []DeviceMember `json:"devices"`
ModelID string `json:"modelId,omitempty"`
Status ClusterStatus `json:"status"`
}
// ClusterResult extends InferenceResult with cluster-specific metadata.
type ClusterResult struct {
*driver.InferenceResult
ClusterID string `json:"clusterId"`
FrameIndex int64 `json:"frameIndex"`
}
// ClusterFlashProgress reports per-device flash progress within a cluster.
type ClusterFlashProgress struct {
DeviceID string `json:"deviceId"`
Percent int `json:"percent"`
Stage string `json:"stage"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}

View File

@ -1,41 +0,0 @@
package config
import (
"flag"
"fmt"
)
type Config struct {
Port int
Host string
MockMode bool
MockCamera bool
MockDeviceCount int
LogLevel string
DevMode bool
RelayURL string
RelayToken string
TrayMode bool
GiteaURL string
}
func Load() *Config {
cfg := &Config{}
flag.IntVar(&cfg.Port, "port", 3721, "Server port")
flag.StringVar(&cfg.Host, "host", "127.0.0.1", "Server host")
flag.BoolVar(&cfg.MockMode, "mock", false, "Enable mock device driver")
flag.BoolVar(&cfg.MockCamera, "mock-camera", false, "Enable mock camera")
flag.IntVar(&cfg.MockDeviceCount, "mock-devices", 1, "Number of mock devices")
flag.StringVar(&cfg.LogLevel, "log-level", "info", "Log level (debug/info/warn/error)")
flag.BoolVar(&cfg.DevMode, "dev", false, "Dev mode: disable embedded static file serving")
flag.StringVar(&cfg.RelayURL, "relay-url", "", "Relay server WebSocket URL (e.g. ws://relay-host:3800/tunnel/connect)")
flag.StringVar(&cfg.RelayToken, "relay-token", "", "Authentication token for relay tunnel")
flag.BoolVar(&cfg.TrayMode, "tray", false, "Run as system tray launcher")
flag.StringVar(&cfg.GiteaURL, "gitea-url", "", "Gitea server URL for update checks (e.g. https://gitea.example.com)")
flag.Parse()
return cfg
}
func (c *Config) Addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}

View File

@ -1,73 +0,0 @@
package deps
import (
"os/exec"
"strings"
)
// Dependency describes an external CLI tool the platform may use.
type Dependency struct {
Name string `json:"name"`
Available bool `json:"available"`
Version string `json:"version,omitempty"`
Required bool `json:"required"`
InstallHint string `json:"installHint,omitempty"`
}
// CheckAll probes all known external dependencies.
func CheckAll() []Dependency {
return []Dependency{
check("ffmpeg", false,
"macOS: brew install ffmpeg | Windows: winget install Gyan.FFmpeg",
"ffmpeg", "-version"),
check("yt-dlp", false,
"macOS: brew install yt-dlp | Windows: winget install yt-dlp",
"yt-dlp", "--version"),
check("python3", false,
"Required only for Kneron KL720 hardware. macOS: brew install python3",
"python3", "--version"),
}
}
func check(name string, required bool, hint string, cmd string, args ...string) Dependency {
d := Dependency{
Name: name,
Required: required,
InstallHint: hint,
}
path, err := exec.LookPath(cmd)
if err != nil {
return d
}
d.Available = true
out, err := exec.Command(path, args...).Output()
if err == nil {
lines := strings.SplitN(string(out), "\n", 2)
if len(lines) > 0 {
d.Version = strings.TrimSpace(lines[0])
}
}
return d
}
// Logger is the minimal interface used for startup reporting.
type Logger interface {
Info(msg string, args ...interface{})
}
// PrintStartupReport logs the status of every external dependency.
func PrintStartupReport(logger Logger) {
deps := CheckAll()
logger.Info("External dependency check:")
for _, d := range deps {
if d.Available {
logger.Info(" [OK] %s: %s", d.Name, d.Version)
} else {
tag := "OPTIONAL"
if d.Required {
tag = "MISSING"
}
logger.Info(" [%s] %s: not found — %s", tag, d.Name, d.InstallHint)
}
}
}

View File

@ -1,173 +0,0 @@
package device
import (
"fmt"
"log"
"sync"
"edge-ai-platform/internal/driver"
"edge-ai-platform/internal/driver/kneron"
mockdriver "edge-ai-platform/internal/driver/mock"
"edge-ai-platform/pkg/logger"
)
type Manager struct {
registry *DriverRegistry
sessions map[string]*DeviceSession
eventBus chan DeviceEvent
mockMode bool
scriptPath string
logBroadcaster *logger.Broadcaster
mu sync.RWMutex
}
func NewManager(registry *DriverRegistry, mockMode bool, mockCount int, scriptPath string) *Manager {
m := &Manager{
registry: registry,
sessions: make(map[string]*DeviceSession),
eventBus: make(chan DeviceEvent, 100),
mockMode: mockMode,
scriptPath: scriptPath,
}
if mockMode {
for i := 0; i < mockCount; i++ {
id := fmt.Sprintf("mock-device-%d", i+1)
d := mockdriver.Factory(id, i)
m.sessions[id] = NewSession(d)
}
}
return m
}
// SetLogBroadcaster attaches a log broadcaster so that Kneron driver
// and bridge logs are forwarded to the frontend.
func (m *Manager) SetLogBroadcaster(b *logger.Broadcaster) {
m.logBroadcaster = b
// Also set on any already-registered kneron drivers.
m.mu.RLock()
defer m.mu.RUnlock()
for _, s := range m.sessions {
if kd, ok := s.Driver.(*kneron.KneronDriver); ok {
kd.SetLogBroadcaster(b)
}
}
}
func (m *Manager) Start() {
if m.mockMode {
return
}
// Detect real Kneron devices (KL520, KL720, etc.) via Python bridge.
devices := kneron.DetectDevices(m.scriptPath)
if len(devices) == 0 {
log.Println("No Kneron devices detected")
return
}
m.mu.Lock()
defer m.mu.Unlock()
for _, info := range devices {
d := kneron.NewKneronDriver(info, m.scriptPath)
if m.logBroadcaster != nil {
d.SetLogBroadcaster(m.logBroadcaster)
}
m.sessions[info.ID] = NewSession(d)
log.Printf("Registered Kneron device: %s (%s, type=%s)", info.Name, info.ID, info.Type)
}
}
// Rescan re-detects connected Kneron devices. New devices are registered,
// removed devices are cleaned up, and existing devices are left untouched.
func (m *Manager) Rescan() []driver.DeviceInfo {
if m.mockMode {
return m.ListDevices()
}
detected := kneron.DetectDevices(m.scriptPath)
// Build a set of detected device IDs.
detectedIDs := make(map[string]driver.DeviceInfo, len(detected))
for _, info := range detected {
detectedIDs[info.ID] = info
}
m.mu.Lock()
defer m.mu.Unlock()
// Remove devices that are no longer present.
for id, s := range m.sessions {
if _, exists := detectedIDs[id]; !exists {
log.Printf("Device removed: %s", id)
s.Driver.Disconnect()
delete(m.sessions, id)
}
}
// Add newly detected devices.
for _, info := range detected {
if _, exists := m.sessions[info.ID]; !exists {
d := kneron.NewKneronDriver(info, m.scriptPath)
if m.logBroadcaster != nil {
d.SetLogBroadcaster(m.logBroadcaster)
}
m.sessions[info.ID] = NewSession(d)
log.Printf("Registered Kneron device: %s (%s, type=%s)", info.Name, info.ID, info.Type)
}
}
// Return current list.
devices := make([]driver.DeviceInfo, 0, len(m.sessions))
for _, s := range m.sessions {
devices = append(devices, s.Driver.Info())
}
return devices
}
func (m *Manager) ListDevices() []driver.DeviceInfo {
m.mu.RLock()
defer m.mu.RUnlock()
devices := make([]driver.DeviceInfo, 0, len(m.sessions))
for _, s := range m.sessions {
devices = append(devices, s.Driver.Info())
}
return devices
}
func (m *Manager) GetDevice(id string) (*DeviceSession, error) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok {
return nil, fmt.Errorf("device not found: %s", id)
}
return s, nil
}
func (m *Manager) Connect(id string) error {
s, err := m.GetDevice(id)
if err != nil {
return err
}
if err := s.Driver.Connect(); err != nil {
return err
}
m.eventBus <- DeviceEvent{Event: "updated", Device: s.Driver.Info()}
return nil
}
func (m *Manager) Disconnect(id string) error {
s, err := m.GetDevice(id)
if err != nil {
return err
}
if err := s.Driver.Disconnect(); err != nil {
return err
}
m.eventBus <- DeviceEvent{Event: "updated", Device: s.Driver.Info()}
return nil
}
func (m *Manager) Events() <-chan DeviceEvent {
return m.eventBus
}

View File

@ -1,93 +0,0 @@
package device
import (
"testing"
"edge-ai-platform/internal/driver"
)
type testDriver struct {
info driver.DeviceInfo
connected bool
}
func (d *testDriver) Info() driver.DeviceInfo { return d.info }
func (d *testDriver) Connect() error { d.connected = true; d.info.Status = driver.StatusConnected; return nil }
func (d *testDriver) Disconnect() error { d.connected = false; d.info.Status = driver.StatusDisconnected; return nil }
func (d *testDriver) IsConnected() bool { return d.connected }
func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
func (d *testDriver) StartInference() error { return nil }
func (d *testDriver) StopInference() error { return nil }
func (d *testDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil }
func (d *testDriver) RunInference(_ []byte) (*driver.InferenceResult, error) { return nil, nil }
func (d *testDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil }
func TestNewManager_MockMode(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, true, 2, "")
devices := mgr.ListDevices()
if len(devices) != 2 {
t.Errorf("NewManager mock mode: got %d devices, want 2", len(devices))
}
}
func TestManager_ListDevices(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, false, 0, "")
mgr.sessions["test-1"] = NewSession(&testDriver{
info: driver.DeviceInfo{ID: "test-1", Name: "Test Device", Type: "KL720", Status: driver.StatusDetected},
})
devices := mgr.ListDevices()
if len(devices) != 1 {
t.Errorf("ListDevices() = %d, want 1", len(devices))
}
}
func TestManager_GetDevice(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, false, 0, "")
mgr.sessions["test-1"] = NewSession(&testDriver{
info: driver.DeviceInfo{ID: "test-1"},
})
t.Run("existing device", func(t *testing.T) {
s, err := mgr.GetDevice("test-1")
if err != nil {
t.Errorf("GetDevice() error = %v", err)
}
if s == nil {
t.Error("GetDevice() returned nil session")
}
})
t.Run("non-existing device", func(t *testing.T) {
_, err := mgr.GetDevice("test-999")
if err == nil {
t.Error("GetDevice() expected error for non-existing device")
}
})
}
func TestManager_Connect(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, false, 0, "")
td := &testDriver{info: driver.DeviceInfo{ID: "test-1", Status: driver.StatusDetected}}
mgr.sessions["test-1"] = NewSession(td)
// Drain event bus in background
go func() {
for range mgr.Events() {
}
}()
err := mgr.Connect("test-1")
if err != nil {
t.Errorf("Connect() error = %v", err)
}
if !td.connected {
t.Error("Connect() did not connect device")
}
}

View File

@ -1,20 +0,0 @@
package device
import "edge-ai-platform/internal/driver"
type DriverFactory struct {
Name string
Create func(id string, index int) driver.DeviceDriver
}
type DriverRegistry struct {
factories []DriverFactory
}
func NewRegistry() *DriverRegistry {
return &DriverRegistry{}
}
func (r *DriverRegistry) Register(factory DriverFactory) {
r.factories = append(r.factories, factory)
}

View File

@ -1,15 +0,0 @@
package device
import (
"edge-ai-platform/internal/driver"
"sync"
)
type DeviceSession struct {
Driver driver.DeviceDriver
mu sync.Mutex
}
func NewSession(d driver.DeviceDriver) *DeviceSession {
return &DeviceSession{Driver: d}
}

View File

@ -1,8 +0,0 @@
package device
import "edge-ai-platform/internal/driver"
type DeviceEvent struct {
Event string `json:"event"`
Device driver.DeviceInfo `json:"device"`
}

View File

@ -1,90 +0,0 @@
package driver
import "time"
type DeviceDriver interface {
Info() DeviceInfo
Connect() error
Disconnect() error
IsConnected() bool
Flash(modelPath string, progressCh chan<- FlashProgress) error
StartInference() error
StopInference() error
ReadInference() (*InferenceResult, error)
RunInference(imageData []byte) (*InferenceResult, error)
GetModelInfo() (*ModelInfo, error)
}
type DeviceInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Port string `json:"port"`
VendorID uint16 `json:"vendorId,omitempty"`
ProductID uint16 `json:"productId,omitempty"`
Status DeviceStatus `json:"status"`
FirmwareVer string `json:"firmwareVersion,omitempty"`
FlashedModel string `json:"flashedModel,omitempty"`
}
type DeviceStatus string
const (
StatusDetected DeviceStatus = "detected"
StatusConnecting DeviceStatus = "connecting"
StatusConnected DeviceStatus = "connected"
StatusFlashing DeviceStatus = "flashing"
StatusInferencing DeviceStatus = "inferencing"
StatusError DeviceStatus = "error"
StatusDisconnected DeviceStatus = "disconnected"
)
type FlashProgress struct {
Percent int `json:"percent"`
Stage string `json:"stage"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
type InferenceResult struct {
DeviceID string `json:"deviceId,omitempty"`
ModelID string `json:"modelId,omitempty"`
TaskType string `json:"taskType"`
Timestamp int64 `json:"timestamp"`
LatencyMs float64 `json:"latencyMs"`
Classifications []ClassResult `json:"classifications,omitempty"`
Detections []DetectionResult `json:"detections,omitempty"`
// Batch image fields (omitted for single-image/camera/video modes)
ImageIndex int `json:"imageIndex,omitempty"`
TotalImages int `json:"totalImages,omitempty"`
Filename string `json:"filename,omitempty"`
// Video progress fields (omitted for non-video modes)
FrameIndex int `json:"frameIndex,omitempty"`
TotalFrames int `json:"totalFrames,omitempty"`
}
type ClassResult struct {
Label string `json:"label"`
Confidence float64 `json:"confidence"`
}
type DetectionResult struct {
Label string `json:"label"`
Confidence float64 `json:"confidence"`
BBox BBox `json:"bbox"`
}
type BBox struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
type ModelInfo struct {
ID string `json:"id"`
Name string `json:"name"`
LoadedAt time.Time `json:"loadedAt"`
}

View File

@ -1,192 +0,0 @@
package kneron
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"edge-ai-platform/internal/driver"
)
// ResolvePython finds the best Python interpreter for the given script path.
// Search order: script-local venv → parent venv → ~/.edge-ai-platform/venv → system python3.
func ResolvePython(scriptPath string) string {
scriptDir := filepath.Dir(scriptPath)
candidates := []string{
filepath.Join(scriptDir, "venv", "bin", "python3"),
filepath.Join(filepath.Dir(scriptDir), "venv", "bin", "python3"),
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates, filepath.Join(home, ".edge-ai-platform", "venv", "bin", "python3"))
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
return "python3"
}
// KneronVendorID is the USB vendor ID for Kneron devices.
const KneronVendorID uint16 = 0x3231
// Known Kneron product IDs.
const (
ProductIDKL520 = "0x0100"
ProductIDKL720 = "0x0200"
ProductIDKL720Alt = "0x0720"
)
// chipFromProductID returns the chip name and device type from the product_id
// reported by the Python bridge scan result.
func chipFromProductID(productID string) (chip string, deviceType string) {
pid := strings.ToLower(strings.TrimSpace(productID))
switch pid {
case "0x0100":
return "KL520", "kneron_kl520"
case "0x0200", "0x0720":
return "KL720", "kneron_kl720"
default:
// Unknown product — default to KL520 for USB Boot devices,
// otherwise use the raw product ID as suffix.
return "KL520", "kneron_kl520"
}
}
// DetectDevices attempts to discover all connected Kneron devices (KL520, KL720, etc.)
// by invoking the Python bridge script with a scan command. If Python or
// the bridge script is not available, it returns an empty list.
func DetectDevices(scriptPath string) []driver.DeviceInfo {
// Try to run the bridge script with a scan command via a short-lived process.
pythonBin := ResolvePython(scriptPath)
cmd := exec.Command(pythonBin, scriptPath)
cmd.Stdin = nil
stdinPipe, err := cmd.StdinPipe()
if err != nil {
return nil
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
stdinPipe.Close()
return nil
}
if err := cmd.Start(); err != nil {
return nil
}
defer func() {
stdinPipe.Close()
cmd.Process.Kill()
cmd.Wait()
}()
// Read the ready signal.
decoder := json.NewDecoder(stdoutPipe)
var readyResp map[string]interface{}
done := make(chan error, 1)
go func() {
done <- decoder.Decode(&readyResp)
}()
select {
case err := <-done:
if err != nil {
return nil
}
case <-time.After(5 * time.Second):
return nil
}
if status, ok := readyResp["status"].(string); !ok || status != "ready" {
return nil
}
// Send the scan command.
scanCmd, _ := json.Marshal(map[string]interface{}{"cmd": "scan"})
scanCmd = append(scanCmd, '\n')
if _, err := stdinPipe.Write(scanCmd); err != nil {
return nil
}
// Read the scan response.
var scanResp map[string]interface{}
scanDone := make(chan error, 1)
go func() {
scanDone <- decoder.Decode(&scanResp)
}()
select {
case err := <-scanDone:
if err != nil {
return nil
}
case <-time.After(5 * time.Second):
return nil
}
// Parse detected devices from the response.
devicesRaw, ok := scanResp["devices"].([]interface{})
if !ok || len(devicesRaw) == 0 {
return nil
}
// Track per-chip counters for naming (e.g. "KL520 #1", "KL720 #1").
chipCount := map[string]int{}
var devices []driver.DeviceInfo
for _, devRaw := range devicesRaw {
dev, ok := devRaw.(map[string]interface{})
if !ok {
continue
}
port := ""
if p, ok := dev["port"].(string); ok {
port = p
}
fw := ""
if f, ok := dev["firmware"].(string); ok {
fw = f
}
productID := ""
if p, ok := dev["product_id"].(string); ok {
productID = p
}
chip, devType := chipFromProductID(productID)
chipCount[chip]++
idx := chipCount[chip]
info := driver.DeviceInfo{
ID: fmt.Sprintf("%s-%d", strings.ToLower(chip), idx-1),
Name: fmt.Sprintf("Kneron %s #%d", chip, idx),
Type: devType,
Port: port,
VendorID: KneronVendorID,
Status: driver.StatusDetected,
FirmwareVer: fw,
}
devices = append(devices, info)
}
return devices
}
// DetectKL720Devices is a backward-compatible alias for DetectDevices.
// Deprecated: Use DetectDevices instead.
func DetectKL720Devices(scriptPath string) []driver.DeviceInfo {
return DetectDevices(scriptPath)
}

View File

@ -1,669 +0,0 @@
package kneron
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"edge-ai-platform/internal/driver"
"edge-ai-platform/pkg/logger"
)
// LogFunc is a function that writes a log line to both stderr and
// the WebSocket broadcaster. When nil, logs go only to stderr.
type LogFunc func(level, msg string)
// KneronDriver implements driver.DeviceDriver for Kneron NPU devices
// (KL520, KL720, etc.). It delegates hardware operations to a Python
// subprocess (kneron_bridge.py) that communicates via JSON-RPC over
// stdin/stdout.
type KneronDriver struct {
info driver.DeviceInfo
connected bool
inferring bool
modelLoaded string
chipType string // "KL520" or "KL720" — derived from info.Type
mu sync.Mutex
scriptPath string
pythonCmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Scanner
pythonReady bool
logBroadcaster *logger.Broadcaster
needsReset bool // true on first connect after server start to clear stale models
}
// NewKneronDriver creates a new KneronDriver with the given device info and
// path to the kneron_bridge.py script. Works for any Kneron chip variant.
func NewKneronDriver(info driver.DeviceInfo, scriptPath string) *KneronDriver {
chip := "KL520"
if strings.Contains(strings.ToLower(info.Type), "kl720") {
chip = "KL720"
}
return &KneronDriver{
info: info,
scriptPath: scriptPath,
chipType: chip,
needsReset: true,
}
}
// SetLogBroadcaster attaches a log broadcaster so that bridge stderr
// and driver messages are forwarded to the frontend.
func (d *KneronDriver) SetLogBroadcaster(b *logger.Broadcaster) {
d.logBroadcaster = b
}
// driverLog writes a log message to stderr and the broadcaster.
func (d *KneronDriver) driverLog(level, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "%s\n", msg)
if d.logBroadcaster != nil {
d.logBroadcaster.Push(level, msg)
}
}
// NewKL720Driver is a backward-compatible alias for NewKneronDriver.
// Deprecated: Use NewKneronDriver instead.
func NewKL720Driver(info driver.DeviceInfo, scriptPath string) *KneronDriver {
return NewKneronDriver(info, scriptPath)
}
// KL720Driver is a backward-compatible type alias for KneronDriver.
// Deprecated: Use KneronDriver instead.
type KL720Driver = KneronDriver
// resolvePython finds the best Python interpreter using the package-level resolver.
func (d *KneronDriver) resolvePython() string {
return ResolvePython(d.scriptPath)
}
// startPython launches the Python bridge subprocess and waits for the
// "ready" signal on stdout.
func (d *KneronDriver) startPython() error {
pythonBin := d.resolvePython()
scriptDir := filepath.Dir(d.scriptPath)
cmd := exec.Command(pythonBin, d.scriptPath)
// On macOS with Apple Silicon, Kneron SDK requires x86_64 (Rosetta 2).
// The venv should already contain the correct architecture Python.
// Set DYLD_LIBRARY_PATH so libkplus.dylib can be found.
cmd.Env = append(os.Environ(),
"PYTHONUNBUFFERED=1",
)
// Add library path for native kp module if lib directory exists.
libDir := filepath.Join(scriptDir, "lib")
if _, err := os.Stat(libDir); err == nil {
if runtime.GOOS == "darwin" {
cmd.Env = append(cmd.Env, "DYLD_LIBRARY_PATH="+libDir)
} else {
cmd.Env = append(cmd.Env, "LD_LIBRARY_PATH="+libDir)
}
}
stdinPipe, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
stdinPipe.Close()
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
// Capture stderr from the Python bridge: forward each line to both
// os.Stderr and the WebSocket broadcaster so it shows in the frontend.
stderrPipe, err := cmd.StderrPipe()
if err != nil {
stdinPipe.Close()
stdoutPipe.Close()
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
stdinPipe.Close()
return fmt.Errorf("failed to start python bridge (%s): %w", pythonBin, err)
}
// Forward bridge stderr line-by-line to os.Stderr + broadcaster.
go func() {
scanner := bufio.NewScanner(stderrPipe)
for scanner.Scan() {
line := scanner.Text()
fmt.Fprintln(os.Stderr, line)
if d.logBroadcaster != nil {
d.logBroadcaster.Push("DEBUG", line)
}
}
}()
d.pythonCmd = cmd
d.stdin = stdinPipe
d.stdout = bufio.NewScanner(stdoutPipe)
// Increase scanner buffer for large inference responses.
d.stdout.Buffer(make([]byte, 0, 64*1024), 1024*1024)
// Wait for the ready signal from the Python process.
if d.stdout.Scan() {
var resp map[string]interface{}
if err := json.Unmarshal([]byte(d.stdout.Text()), &resp); err == nil {
if status, ok := resp["status"].(string); ok && status == "ready" {
d.pythonReady = true
return nil
}
}
}
// If we didn't get a ready signal, clean up and report failure.
d.stopPython()
return fmt.Errorf("python bridge did not send ready signal")
}
// sendCommand sends a JSON command to the Python subprocess and returns
// the parsed JSON response.
func (d *KneronDriver) sendCommand(cmd map[string]interface{}) (map[string]interface{}, error) {
if !d.pythonReady {
return nil, fmt.Errorf("python bridge is not running")
}
data, err := json.Marshal(cmd)
if err != nil {
return nil, fmt.Errorf("failed to marshal command: %w", err)
}
// Write the JSON command followed by a newline.
if _, err := fmt.Fprintf(d.stdin, "%s\n", data); err != nil {
return nil, fmt.Errorf("failed to write to python bridge: %w", err)
}
// Read the response line.
if !d.stdout.Scan() {
if err := d.stdout.Err(); err != nil {
return nil, fmt.Errorf("failed to read from python bridge: %w", err)
}
return nil, fmt.Errorf("python bridge closed unexpectedly")
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(d.stdout.Text()), &resp); err != nil {
return nil, fmt.Errorf("failed to parse python response: %w", err)
}
// Check for error responses from the bridge.
if errMsg, ok := resp["error"].(string); ok {
return nil, fmt.Errorf("python bridge error: %s", errMsg)
}
return resp, nil
}
// stopPython kills the Python subprocess and cleans up resources.
func (d *KneronDriver) stopPython() {
d.pythonReady = false
if d.stdin != nil {
d.stdin.Close()
d.stdin = nil
}
if d.pythonCmd != nil && d.pythonCmd.Process != nil {
d.pythonCmd.Process.Kill()
d.pythonCmd.Wait()
d.pythonCmd = nil
}
d.stdout = nil
}
// Info returns the current device information.
func (d *KneronDriver) Info() driver.DeviceInfo {
d.mu.Lock()
defer d.mu.Unlock()
return d.info
}
// Connect starts the Python bridge subprocess and connects to the Kneron device.
// On the first connect after server start, the device is reset to clear any
// stale model from a previous session.
func (d *KneronDriver) Connect() error {
d.mu.Lock()
if d.connected {
d.mu.Unlock()
return nil
}
needsReset := d.needsReset
d.info.Status = driver.StatusConnecting
// Start the Python bridge process.
if err := d.startPython(); err != nil {
d.info.Status = driver.StatusError
d.mu.Unlock()
return fmt.Errorf("failed to start hardware bridge: %w", err)
}
// Send connect command to the bridge.
resp, err := d.sendCommand(map[string]interface{}{
"cmd": "connect",
"port": d.info.Port,
"index": 0,
"device_type": d.info.Type,
})
if err != nil {
d.stopPython()
d.info.Status = driver.StatusError
d.mu.Unlock()
return fmt.Errorf("failed to connect to device: %w", err)
}
d.connected = true
d.needsReset = false
d.info.Status = driver.StatusConnected
if fw, ok := resp["firmware"].(string); ok {
d.info.FirmwareVer = fw
}
d.mu.Unlock()
// First connect after server start: reset device to clear stale models.
if needsReset {
d.driverLog("INFO", "[kneron] first connect after server start — resetting device to clear stale model...")
if err := d.restartBridge(); err != nil {
d.driverLog("WARN", "[kneron] reset on connect failed (non-fatal): %v", err)
// Non-fatal: device is still connected, just might have stale model
} else {
d.driverLog("INFO", "[kneron] device reset complete — clean state ready")
}
}
return nil
}
// Disconnect stops the Python bridge and disconnects from the device.
func (d *KneronDriver) Disconnect() error {
d.mu.Lock()
defer d.mu.Unlock()
if !d.connected {
return nil
}
// Try to send disconnect command if Python is running.
if d.pythonReady {
d.sendCommand(map[string]interface{}{"cmd": "disconnect"})
}
d.stopPython()
d.connected = false
d.inferring = false
d.info.Status = driver.StatusDisconnected
return nil
}
// IsConnected returns whether the driver is currently connected.
func (d *KneronDriver) IsConnected() bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.connected
}
// restartBridge resets the Kneron device and restarts the Python bridge.
//
// The KL520 USB Boot mode only allows loading one model per firmware
// session. To load a different model we must:
// 1. Send a "reset" command via the current bridge — this calls
// kp.core.reset_device() which forces the device back to Loader
// (USB Boot) state, wiping firmware + model from RAM.
// 2. Kill the Python bridge process.
// 3. Wait for the device to re-enumerate on USB (~8 s).
// 4. Start a fresh Python bridge.
// 5. Send "connect" which reloads firmware from scratch.
//
// After this the device is in a clean state ready for load_model.
//
// Caller must NOT hold d.mu.
func (d *KneronDriver) restartBridge() error {
d.mu.Lock()
port := d.info.Port
d.modelLoaded = ""
// Step 1: Ask the running bridge to reset the device.
if d.pythonReady {
d.driverLog("INFO", "[kneron] sending reset command to device...")
d.sendCommand(map[string]interface{}{"cmd": "reset"})
// Ignore errors — the device may have already disconnected.
}
// Step 2: Kill the bridge process.
d.stopPython()
d.mu.Unlock()
// Step 3: Wait for USB device to re-enumerate after hardware reset.
// The reset causes the device to drop off USB and reappear as a
// Loader-mode device. This typically takes 5-8 seconds.
d.driverLog("INFO", "[kneron] bridge stopped, waiting for USB re-enumerate after reset...")
time.Sleep(8 * time.Second)
d.mu.Lock()
defer d.mu.Unlock()
// Step 4: Start a fresh Python bridge.
d.driverLog("INFO", "[kneron] starting new bridge process...")
if err := d.startPython(); err != nil {
return fmt.Errorf("failed to restart bridge: %w", err)
}
// Step 5: Reconnect — firmware will be loaded fresh.
d.driverLog("INFO", "[kneron] bridge started, reconnecting to device (port=%s)...", port)
_, err := d.sendCommand(map[string]interface{}{
"cmd": "connect",
"port": port,
"index": 0,
"device_type": d.info.Type,
})
if err != nil {
d.stopPython()
return fmt.Errorf("failed to reconnect after bridge restart: %w", err)
}
d.driverLog("INFO", "[kneron] device reconnected after reset + bridge restart")
return nil
}
// Flash loads a model onto the Kneron device. Progress is reported through
// the provided channel.
//
// Behavior differs by chip:
// - KL520 (USB Boot): only one model per session. Error 40 triggers
// a full device reset + bridge restart + firmware reload.
// - KL720 (flash-based): models can be freely reloaded. Error 40
// should not occur; if it does, a simple retry is attempted first.
func (d *KneronDriver) Flash(modelPath string, progressCh chan<- driver.FlashProgress) error {
d.mu.Lock()
d.info.Status = driver.StatusFlashing
pythonReady := d.pythonReady
currentModel := d.modelLoaded
chip := d.chipType
d.mu.Unlock()
if !pythonReady {
d.mu.Lock()
d.info.Status = driver.StatusConnected
d.mu.Unlock()
return fmt.Errorf("hardware bridge is not running — cannot flash model")
}
// Same model already loaded — skip, report success
if currentModel != "" && currentModel == modelPath {
d.driverLog("INFO", "[kneron] model already loaded (%s), skipping reload", modelPath)
progressCh <- driver.FlashProgress{
Percent: 50,
Stage: "transferring",
Message: "model already loaded on device",
}
d.mu.Lock()
d.info.Status = driver.StatusConnected
d.mu.Unlock()
progressCh <- driver.FlashProgress{Percent: 100, Stage: "done", Message: "Flash complete (model already loaded)"}
return nil
}
// Try loading the model
progressCh <- driver.FlashProgress{
Percent: 5,
Stage: "preparing",
Message: "preparing... loading model to device",
}
d.mu.Lock()
_, err := d.sendCommand(map[string]interface{}{
"cmd": "load_model",
"path": modelPath,
})
d.mu.Unlock()
// Handle retryable errors (error 40, broken pipe).
if err != nil {
errMsg := err.Error()
d.driverLog("WARN", "[kneron] load_model failed: %s", errMsg)
isRetryable := strings.Contains(errMsg, "Error code: 40") ||
strings.Contains(errMsg, "SECOND_MODEL") ||
strings.Contains(errMsg, "broken pipe") ||
strings.Contains(errMsg, "USB_TIMEOUT")
if isRetryable {
if chip == "KL720" {
// KL720: error 40 should not occur. Try a simple retry
// without full bridge restart first.
d.driverLog("WARN", "[kneron] KL720 unexpected retryable error, retrying without restart...")
progressCh <- driver.FlashProgress{
Percent: 5,
Stage: "preparing",
Message: "preparing... retrying model load",
}
d.mu.Lock()
_, err = d.sendCommand(map[string]interface{}{
"cmd": "load_model",
"path": modelPath,
})
d.mu.Unlock()
// If still failing, fall back to bridge restart as last resort.
if err != nil {
d.driverLog("WARN", "[kneron] KL720 retry failed: %v, falling back to bridge restart...", err)
if restartErr := d.restartBridge(); restartErr != nil {
d.mu.Lock()
d.info.Status = driver.StatusConnected
d.mu.Unlock()
return fmt.Errorf("failed to reset device: %w", restartErr)
}
d.mu.Lock()
d.info.Status = driver.StatusFlashing
_, err = d.sendCommand(map[string]interface{}{
"cmd": "load_model",
"path": modelPath,
})
d.mu.Unlock()
}
} else {
// KL520: error 40 means a model is already loaded in this
// USB Boot session. Must reset device + reload firmware.
d.driverLog("WARN", "[kneron] KL520 retryable error, restarting bridge...")
progressCh <- driver.FlashProgress{
Percent: 5,
Stage: "preparing",
Message: "preparing... resetting device for new model",
}
if restartErr := d.restartBridge(); restartErr != nil {
d.driverLog("ERROR", "[kneron] restartBridge failed: %v", restartErr)
d.mu.Lock()
d.info.Status = driver.StatusConnected
d.mu.Unlock()
return fmt.Errorf("failed to reset device: %w", restartErr)
}
d.driverLog("INFO", "[kneron] bridge restarted, retrying load_model...")
d.mu.Lock()
d.info.Status = driver.StatusFlashing
_, err = d.sendCommand(map[string]interface{}{
"cmd": "load_model",
"path": modelPath,
})
d.mu.Unlock()
}
}
}
if err != nil {
d.driverLog("ERROR", "[kneron] load_model ultimately failed: %v", err)
d.mu.Lock()
d.info.Status = driver.StatusConnected
d.mu.Unlock()
return fmt.Errorf("failed to load model: %w", err)
}
d.driverLog("INFO", "[kneron] load_model succeeded: %s", modelPath)
// Simulate remaining flash progress stages (the Kneron SDK does not
// provide granular progress, so we approximate it after the model
// has been loaded successfully).
type stage struct {
name string
duration time.Duration
startPct int
endPct int
}
stages := []stage{
{"transferring", 2 * time.Second, 10, 80},
{"verifying", 1 * time.Second, 80, 95},
{"finalizing", 500 * time.Millisecond, 95, 99},
}
// KL720 is faster (USB 3.0, no firmware reload needed)
if chip == "KL720" {
stages = []stage{
{"transferring", 1 * time.Second, 10, 80},
{"verifying", 500 * time.Millisecond, 80, 95},
{"finalizing", 200 * time.Millisecond, 95, 99},
}
}
for _, s := range stages {
steps := (s.endPct - s.startPct) / 5
if steps < 1 {
steps = 1
}
interval := s.duration / time.Duration(steps)
for i := 0; i <= steps; i++ {
pct := s.startPct + (s.endPct-s.startPct)*i/steps
progressCh <- driver.FlashProgress{
Percent: pct,
Stage: s.name,
Message: fmt.Sprintf("%s... %d%%", s.name, pct),
}
time.Sleep(interval)
}
}
d.mu.Lock()
d.modelLoaded = modelPath
d.info.FlashedModel = modelPath
d.info.Status = driver.StatusConnected
d.mu.Unlock()
progressCh <- driver.FlashProgress{Percent: 100, Stage: "done", Message: "Flash complete"}
return nil
}
// StartInference begins continuous inference mode.
func (d *KneronDriver) StartInference() error {
d.mu.Lock()
defer d.mu.Unlock()
if !d.connected {
return fmt.Errorf("device not connected")
}
d.inferring = true
d.info.Status = driver.StatusInferencing
return nil
}
// StopInference stops continuous inference mode.
func (d *KneronDriver) StopInference() error {
d.mu.Lock()
defer d.mu.Unlock()
d.inferring = false
d.info.Status = driver.StatusConnected
return nil
}
// ReadInference reads the latest inference result. This is equivalent to
// calling RunInference with nil image data.
func (d *KneronDriver) ReadInference() (*driver.InferenceResult, error) {
return d.RunInference(nil)
}
// RunInference runs inference on the provided image data and returns
// the result. If imageData is nil, the bridge will run inference on
// a default/empty input.
func (d *KneronDriver) RunInference(imageData []byte) (*driver.InferenceResult, error) {
d.mu.Lock()
pythonReady := d.pythonReady
d.mu.Unlock()
if !pythonReady {
return nil, fmt.Errorf("hardware bridge is not running — device may not be connected")
}
// Encode image data as base64 for transmission to Python.
imageB64 := ""
if imageData != nil {
imageB64 = base64.StdEncoding.EncodeToString(imageData)
}
d.mu.Lock()
resp, err := d.sendCommand(map[string]interface{}{
"cmd": "inference",
"image_base64": imageB64,
})
d.mu.Unlock()
if err != nil {
return nil, fmt.Errorf("inference failed: %w", err)
}
return parseInferenceResult(resp)
}
// parseInferenceResult converts a JSON response map into an InferenceResult.
func parseInferenceResult(resp map[string]interface{}) (*driver.InferenceResult, error) {
// Re-marshal to JSON and unmarshal into the struct for clean conversion.
data, err := json.Marshal(resp)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
var result driver.InferenceResult
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse inference result: %w", err)
}
return &result, nil
}
// GetModelInfo returns information about the currently loaded model.
func (d *KneronDriver) GetModelInfo() (*driver.ModelInfo, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.modelLoaded == "" {
return nil, fmt.Errorf("no model loaded")
}
return &driver.ModelInfo{
ID: d.modelLoaded,
Name: d.modelLoaded,
LoadedAt: time.Now(),
}, nil
}

View File

@ -1,183 +0,0 @@
package mock
import (
"fmt"
"math/rand"
"sync"
"time"
"edge-ai-platform/internal/driver"
)
var mockLabels = []string{"person", "car", "bicycle", "dog", "cat", "chair", "bottle", "phone"}
type MockDriver struct {
info driver.DeviceInfo
connected bool
inferring bool
modelLoaded string
mu sync.Mutex
}
func NewMockDriver(info driver.DeviceInfo) *MockDriver {
return &MockDriver{info: info}
}
func Factory(id string, index int) driver.DeviceDriver {
info := driver.DeviceInfo{
ID: id,
Name: fmt.Sprintf("Kneron KL720 (Mock #%d)", index+1),
Type: "kneron_kl720",
Port: fmt.Sprintf("/dev/ttyMOCK%d", index),
Status: driver.StatusDetected,
FirmwareVer: "2.2.0-mock",
}
return NewMockDriver(info)
}
func (d *MockDriver) Info() driver.DeviceInfo {
d.mu.Lock()
defer d.mu.Unlock()
return d.info
}
func (d *MockDriver) Connect() error {
d.mu.Lock()
defer d.mu.Unlock()
time.Sleep(200 * time.Millisecond)
d.connected = true
d.info.Status = driver.StatusConnected
return nil
}
func (d *MockDriver) Disconnect() error {
d.mu.Lock()
defer d.mu.Unlock()
d.connected = false
d.inferring = false
d.info.Status = driver.StatusDisconnected
return nil
}
func (d *MockDriver) IsConnected() bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.connected
}
func (d *MockDriver) Flash(modelPath string, progressCh chan<- driver.FlashProgress) error {
d.mu.Lock()
d.info.Status = driver.StatusFlashing
d.mu.Unlock()
type stage struct {
name string
duration time.Duration
startPct int
endPct int
}
stages := []stage{
{"preparing", 1 * time.Second, 0, 10},
{"transferring", 6 * time.Second, 10, 80},
{"verifying", 2 * time.Second, 80, 95},
{"rebooting", 1 * time.Second, 95, 99},
}
for _, s := range stages {
steps := (s.endPct - s.startPct) / 5
if steps < 1 {
steps = 1
}
interval := s.duration / time.Duration(steps)
for i := 0; i <= steps; i++ {
pct := s.startPct + (s.endPct-s.startPct)*i/steps
progressCh <- driver.FlashProgress{
Percent: pct,
Stage: s.name,
Message: fmt.Sprintf("%s... %d%%", s.name, pct),
}
time.Sleep(interval)
}
}
d.mu.Lock()
d.modelLoaded = modelPath
d.info.FlashedModel = modelPath
d.info.Status = driver.StatusConnected
d.mu.Unlock()
progressCh <- driver.FlashProgress{Percent: 100, Stage: "done", Message: "Flash complete"}
return nil
}
func (d *MockDriver) StartInference() error {
d.mu.Lock()
defer d.mu.Unlock()
d.inferring = true
d.info.Status = driver.StatusInferencing
return nil
}
func (d *MockDriver) StopInference() error {
d.mu.Lock()
defer d.mu.Unlock()
d.inferring = false
d.info.Status = driver.StatusConnected
return nil
}
func (d *MockDriver) ReadInference() (*driver.InferenceResult, error) {
return d.RunInference(nil)
}
func (d *MockDriver) RunInference(imageData []byte) (*driver.InferenceResult, error) {
time.Sleep(30 * time.Millisecond)
numDetections := rand.Intn(3) + 1
detections := make([]driver.DetectionResult, numDetections)
for i := 0; i < numDetections; i++ {
w := 0.1 + rand.Float64()*0.3
h := 0.1 + rand.Float64()*0.3
detections[i] = driver.DetectionResult{
Label: mockLabels[rand.Intn(len(mockLabels))],
Confidence: 0.3 + rand.Float64()*0.7,
BBox: driver.BBox{
X: rand.Float64() * (1 - w),
Y: rand.Float64() * (1 - h),
Width: w,
Height: h,
},
}
}
classifications := []driver.ClassResult{
{Label: "person", Confidence: 0.5 + rand.Float64()*0.5},
{Label: "car", Confidence: rand.Float64() * 0.5},
{Label: "dog", Confidence: rand.Float64() * 0.3},
{Label: "cat", Confidence: rand.Float64() * 0.2},
{Label: "bicycle", Confidence: rand.Float64() * 0.15},
}
return &driver.InferenceResult{
TaskType: "detection",
Timestamp: time.Now().UnixMilli(),
LatencyMs: 20 + rand.Float64()*30,
Detections: detections,
Classifications: classifications,
}, nil
}
func (d *MockDriver) GetModelInfo() (*driver.ModelInfo, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.modelLoaded == "" {
return nil, fmt.Errorf("no model loaded")
}
return &driver.ModelInfo{
ID: d.modelLoaded,
Name: d.modelLoaded,
LoadedAt: time.Now(),
}, nil
}

View File

@ -1,51 +0,0 @@
package flash
import (
"edge-ai-platform/internal/driver"
"sync"
)
type FlashTask struct {
ID string
DeviceID string
ModelID string
ProgressCh chan driver.FlashProgress
Done bool
}
type ProgressTracker struct {
tasks map[string]*FlashTask
mu sync.RWMutex
}
func NewProgressTracker() *ProgressTracker {
return &ProgressTracker{
tasks: make(map[string]*FlashTask),
}
}
func (pt *ProgressTracker) Create(taskID, deviceID, modelID string) *FlashTask {
pt.mu.Lock()
defer pt.mu.Unlock()
task := &FlashTask{
ID: taskID,
DeviceID: deviceID,
ModelID: modelID,
ProgressCh: make(chan driver.FlashProgress, 20),
}
pt.tasks[taskID] = task
return task
}
func (pt *ProgressTracker) Get(taskID string) (*FlashTask, bool) {
pt.mu.RLock()
defer pt.mu.RUnlock()
t, ok := pt.tasks[taskID]
return t, ok
}
func (pt *ProgressTracker) Remove(taskID string) {
pt.mu.Lock()
defer pt.mu.Unlock()
delete(pt.tasks, taskID)
}

View File

@ -1,140 +0,0 @@
package flash
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"edge-ai-platform/internal/device"
"edge-ai-platform/internal/driver"
"edge-ai-platform/internal/model"
)
// isCompatible checks if any of the model's supported hardware types match
// the device type. The match is case-insensitive and also checks if the
// device type string contains the hardware name (e.g. "kneron_kl720" contains "KL720").
func isCompatible(modelHardware []string, deviceType string) bool {
dt := strings.ToUpper(deviceType)
for _, hw := range modelHardware {
if strings.ToUpper(hw) == dt || strings.Contains(dt, strings.ToUpper(hw)) {
return true
}
}
return false
}
// resolveModelPath checks if a chip-specific NEF file exists for the given
// model. For cross-platform models whose filePath points to a KL520 NEF,
// this tries to find the equivalent KL720 NEF (and vice versa).
//
// Resolution: data/nef/kl520/kl520_20001_... → data/nef/kl720/kl720_20001_...
func resolveModelPath(filePath string, deviceType string) string {
if filePath == "" {
return filePath
}
targetChip := ""
if strings.Contains(strings.ToLower(deviceType), "kl720") {
targetChip = "kl720"
} else if strings.Contains(strings.ToLower(deviceType), "kl520") {
targetChip = "kl520"
}
if targetChip == "" {
return filePath
}
// Already points to the target chip directory — use as-is.
if strings.Contains(filePath, "/"+targetChip+"/") {
return filePath
}
// Try to swap chip prefix in both directory and filename.
dir := filepath.Dir(filePath)
base := filepath.Base(filePath)
sourceChip := ""
if strings.Contains(dir, "kl520") {
sourceChip = "kl520"
} else if strings.Contains(dir, "kl720") {
sourceChip = "kl720"
}
if sourceChip != "" && sourceChip != targetChip {
newDir := strings.Replace(dir, sourceChip, targetChip, 1)
newBase := strings.Replace(base, sourceChip, targetChip, 1)
candidate := filepath.Join(newDir, newBase)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return filePath
}
type Service struct {
deviceMgr *device.Manager
modelRepo *model.Repository
tracker *ProgressTracker
}
func NewService(deviceMgr *device.Manager, modelRepo *model.Repository) *Service {
return &Service{
deviceMgr: deviceMgr,
modelRepo: modelRepo,
tracker: NewProgressTracker(),
}
}
func (s *Service) StartFlash(deviceID, modelID string) (string, <-chan driver.FlashProgress, error) {
session, err := s.deviceMgr.GetDevice(deviceID)
if err != nil {
return "", nil, fmt.Errorf("device not found: %w", err)
}
if !session.Driver.IsConnected() {
return "", nil, fmt.Errorf("device not connected")
}
m, err := s.modelRepo.GetByID(modelID)
if err != nil {
return "", nil, fmt.Errorf("model not found: %w", err)
}
// Check hardware compatibility
deviceInfo := session.Driver.Info()
if !isCompatible(m.SupportedHardware, deviceInfo.Type) {
return "", nil, fmt.Errorf("model not compatible with device type %s", deviceInfo.Type)
}
// Use the model's .nef file path if available, otherwise fall back to modelID.
modelPath := m.FilePath
if modelPath == "" {
modelPath = modelID
}
// Resolve chip-specific NEF (e.g. KL520 path → KL720 equivalent).
modelPath = resolveModelPath(modelPath, deviceInfo.Type)
taskID := fmt.Sprintf("flash-%s-%s", deviceID, modelID)
task := s.tracker.Create(taskID, deviceID, modelID)
go func() {
defer func() {
task.Done = true
close(task.ProgressCh)
}()
// Brief pause to allow the WebSocket client to connect before
// progress messages start flowing.
time.Sleep(500 * time.Millisecond)
if err := session.Driver.Flash(modelPath, task.ProgressCh); err != nil {
task.ProgressCh <- driver.FlashProgress{
Percent: -1,
Stage: "error",
Error: err.Error(),
}
}
}()
return taskID, task.ProgressCh, nil
}

View File

@ -1,94 +0,0 @@
package inference
import (
"context"
"sync"
"edge-ai-platform/internal/device"
"edge-ai-platform/internal/driver"
)
type stream struct {
cancel context.CancelFunc
done chan struct{}
}
type Service struct {
deviceMgr *device.Manager
streams map[string]*stream
mu sync.Mutex
}
func NewService(deviceMgr *device.Manager) *Service {
return &Service{
deviceMgr: deviceMgr,
streams: make(map[string]*stream),
}
}
func (s *Service) Start(deviceID string, resultCh chan<- *driver.InferenceResult) error {
session, err := s.deviceMgr.GetDevice(deviceID)
if err != nil {
return err
}
if err := session.Driver.StartInference(); err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
s.mu.Lock()
s.streams[deviceID] = &stream{cancel: cancel, done: done}
s.mu.Unlock()
go func() {
defer close(done)
defer session.Driver.StopInference()
for {
select {
case <-ctx.Done():
return
default:
result, err := session.Driver.ReadInference()
if err != nil {
continue
}
select {
case resultCh <- result:
default:
}
}
}
}()
return nil
}
// StopAll stops all running inference streams. Used during graceful shutdown.
func (s *Service) StopAll() {
s.mu.Lock()
ids := make([]string, 0, len(s.streams))
for id := range s.streams {
ids = append(ids, id)
}
s.mu.Unlock()
for _, id := range ids {
_ = s.Stop(id)
}
}
func (s *Service) Stop(deviceID string) error {
s.mu.Lock()
st, ok := s.streams[deviceID]
if ok {
delete(s.streams, deviceID)
}
s.mu.Unlock()
if ok {
st.cancel()
<-st.done // wait for goroutine to finish and StopInference to complete
}
return nil
}

View File

@ -1,100 +0,0 @@
package model
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
)
type Repository struct {
models []Model
mu sync.RWMutex
}
func NewRepository(dataPath string) *Repository {
r := &Repository{}
data, err := os.ReadFile(dataPath)
if err != nil {
fmt.Printf("Warning: could not load models from %s: %v\n", dataPath, err)
return r
}
if err := json.Unmarshal(data, &r.models); err != nil {
fmt.Printf("Warning: could not parse models JSON: %v\n", err)
}
return r
}
func (r *Repository) List(filter ModelFilter) ([]ModelSummary, int) {
r.mu.RLock()
defer r.mu.RUnlock()
var results []ModelSummary
for _, m := range r.models {
if filter.TaskType != "" && m.TaskType != filter.TaskType {
continue
}
if filter.Hardware != "" {
found := false
for _, hw := range m.SupportedHardware {
if hw == filter.Hardware {
found = true
break
}
}
if !found {
continue
}
}
if filter.Query != "" {
q := strings.ToLower(filter.Query)
if !strings.Contains(strings.ToLower(m.Name), q) &&
!strings.Contains(strings.ToLower(m.Description), q) {
continue
}
}
results = append(results, m.ToSummary())
}
return results, len(results)
}
func (r *Repository) GetByID(id string) (*Model, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for i := range r.models {
if r.models[i].ID == id {
return &r.models[i], nil
}
}
return nil, fmt.Errorf("model not found: %s", id)
}
func (r *Repository) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.models)
}
func (r *Repository) Add(m Model) {
r.mu.Lock()
defer r.mu.Unlock()
r.models = append(r.models, m)
}
func (r *Repository) Remove(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
for i := range r.models {
if r.models[i].ID == id {
if !r.models[i].IsCustom {
return fmt.Errorf("cannot delete built-in model: %s", id)
}
r.models = append(r.models[:i], r.models[i+1:]...)
return nil
}
}
return fmt.Errorf("model not found: %s", id)
}

View File

@ -1,122 +0,0 @@
package model
import (
"testing"
)
func newTestRepo() *Repository {
return &Repository{
models: []Model{
{
ID: "model-1",
Name: "YOLOv8",
Description: "Object detection model",
TaskType: "object_detection",
SupportedHardware: []string{"KL720", "KL730"},
},
{
ID: "model-2",
Name: "ResNet",
Description: "Classification model",
TaskType: "classification",
SupportedHardware: []string{"KL720"},
},
{
ID: "custom-1",
Name: "My Custom Model",
TaskType: "object_detection",
IsCustom: true,
},
},
}
}
func TestRepository_List(t *testing.T) {
repo := newTestRepo()
tests := []struct {
name string
filter ModelFilter
expectedCount int
}{
{"no filter", ModelFilter{}, 3},
{"filter by task type", ModelFilter{TaskType: "object_detection"}, 2},
{"filter by hardware", ModelFilter{Hardware: "KL730"}, 1},
{"filter by query", ModelFilter{Query: "YOLO"}, 1},
{"query case insensitive", ModelFilter{Query: "resnet"}, 1},
{"no matches", ModelFilter{TaskType: "segmentation"}, 0},
{"combined filters", ModelFilter{TaskType: "object_detection", Query: "YOLO"}, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results, count := repo.List(tt.filter)
if count != tt.expectedCount {
t.Errorf("List() count = %d, want %d", count, tt.expectedCount)
}
if len(results) != tt.expectedCount {
t.Errorf("List() len(results) = %d, want %d", len(results), tt.expectedCount)
}
})
}
}
func TestRepository_GetByID(t *testing.T) {
repo := newTestRepo()
tests := []struct {
name string
id string
wantErr bool
}{
{"existing model", "model-1", false},
{"another existing", "model-2", false},
{"non-existing", "model-999", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m, err := repo.GetByID(tt.id)
if (err != nil) != tt.wantErr {
t.Errorf("GetByID() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && m.ID != tt.id {
t.Errorf("GetByID() ID = %s, want %s", m.ID, tt.id)
}
})
}
}
func TestRepository_Add(t *testing.T) {
repo := &Repository{models: []Model{}}
m := Model{ID: "new-1", Name: "New Model"}
repo.Add(m)
if repo.Count() != 1 {
t.Errorf("Count() = %d, want 1", repo.Count())
}
}
func TestRepository_Remove(t *testing.T) {
repo := newTestRepo()
tests := []struct {
name string
id string
wantErr bool
}{
{"remove custom model", "custom-1", false},
{"cannot remove built-in", "model-1", true},
{"not found", "model-999", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := repo.Remove(tt.id)
if (err != nil) != tt.wantErr {
t.Errorf("Remove() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@ -1,102 +0,0 @@
package model
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
)
// ModelStore manages custom model files on disk.
type ModelStore struct {
baseDir string
}
func NewModelStore(baseDir string) *ModelStore {
_ = os.MkdirAll(baseDir, 0755)
return &ModelStore{baseDir: baseDir}
}
// SaveModel saves a .nef file for the given model ID.
func (s *ModelStore) SaveModel(id string, file io.Reader) (string, error) {
dir := filepath.Join(s.baseDir, id)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("failed to create model directory: %w", err)
}
nefPath := filepath.Join(dir, "model.nef")
f, err := os.Create(nefPath)
if err != nil {
return "", fmt.Errorf("failed to create model file: %w", err)
}
defer f.Close()
written, err := io.Copy(f, file)
if err != nil {
return "", fmt.Errorf("failed to write model file: %w", err)
}
fmt.Printf("[INFO] Saved model file: %s (%d bytes)\n", nefPath, written)
return nefPath, nil
}
// SaveMetadata saves model metadata as JSON.
func (s *ModelStore) SaveMetadata(id string, m Model) error {
dir := filepath.Join(s.baseDir, id)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create model directory: %w", err)
}
metaPath := filepath.Join(dir, "metadata.json")
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
return os.WriteFile(metaPath, data, 0644)
}
// GetModelPath returns the .nef file path for a model.
func (s *ModelStore) GetModelPath(id string) string {
return filepath.Join(s.baseDir, id, "model.nef")
}
// DeleteModel removes a model's directory and all files.
func (s *ModelStore) DeleteModel(id string) error {
dir := filepath.Join(s.baseDir, id)
return os.RemoveAll(dir)
}
// LoadCustomModels scans the store directory and returns all custom models.
func (s *ModelStore) LoadCustomModels() ([]Model, error) {
entries, err := os.ReadDir(s.baseDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var models []Model
for _, entry := range entries {
if !entry.IsDir() {
continue
}
metaPath := filepath.Join(s.baseDir, entry.Name(), "metadata.json")
data, err := os.ReadFile(metaPath)
if err != nil {
continue
}
var m Model
if err := json.Unmarshal(data, &m); err != nil {
continue
}
m.IsCustom = true
models = append(models, m)
}
return models, nil
}

View File

@ -1,65 +0,0 @@
package model
type Model struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Thumbnail string `json:"thumbnail"`
TaskType string `json:"taskType"`
Categories []string `json:"categories"`
Framework string `json:"framework"`
InputSize InputSize `json:"inputSize"`
ModelSize int64 `json:"modelSize"`
Quantization string `json:"quantization"`
Accuracy float64 `json:"accuracy"`
LatencyMs float64 `json:"latencyMs"`
FPS float64 `json:"fps"`
SupportedHardware []string `json:"supportedHardware"`
Labels []string `json:"labels"`
Version string `json:"version"`
Author string `json:"author"`
License string `json:"license"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
IsCustom bool `json:"isCustom,omitempty"`
FilePath string `json:"filePath,omitempty"`
}
type InputSize struct {
Width int `json:"width"`
Height int `json:"height"`
}
type ModelSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Thumbnail string `json:"thumbnail"`
TaskType string `json:"taskType"`
Categories []string `json:"categories"`
ModelSize int64 `json:"modelSize"`
Accuracy float64 `json:"accuracy"`
FPS float64 `json:"fps"`
SupportedHardware []string `json:"supportedHardware"`
IsCustom bool `json:"isCustom,omitempty"`
}
type ModelFilter struct {
TaskType string
Hardware string
Query string
}
func (m *Model) ToSummary() ModelSummary {
return ModelSummary{
ID: m.ID,
Name: m.Name,
Thumbnail: m.Thumbnail,
TaskType: m.TaskType,
Categories: m.Categories,
ModelSize: m.ModelSize,
Accuracy: m.Accuracy,
FPS: m.FPS,
SupportedHardware: m.SupportedHardware,
IsCustom: m.IsCustom,
}
}

View File

@ -1,327 +0,0 @@
// Package relay implements a reverse-proxy relay server that forwards HTTP
// and WebSocket traffic through a yamux-multiplexed WebSocket tunnel to a
// remote edge-ai-server. Multiple local servers can connect simultaneously,
// each identified by a unique token (derived from hardware ID).
package relay
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"edge-ai-platform/pkg/wsconn"
"github.com/gorilla/websocket"
"github.com/hashicorp/yamux"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Server is the relay server that bridges browser clients to tunnelled
// edge-ai-servers. Each local server is identified by its token and gets
// its own yamux session.
type Server struct {
sessions map[string]*yamux.Session
mu sync.RWMutex
}
// NewServer creates a multi-tenant relay server.
func NewServer() *Server {
return &Server{
sessions: make(map[string]*yamux.Session),
}
}
// Handler returns an http.Handler that routes tunnel connections and proxied
// requests.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/tunnel/connect", s.handleTunnel)
mux.HandleFunc("/relay/status", s.handleStatus)
mux.HandleFunc("/", s.handleProxy)
return mux
}
// handleTunnel accepts a WebSocket connection from an edge-ai-server and
// sets up a yamux session keyed by the provided token.
func (s *Server) handleTunnel(w http.ResponseWriter, r *http.Request) {
tok := r.URL.Query().Get("token")
if tok == "" {
http.Error(w, "token required", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[relay] tunnel upgrade failed: %v", err)
return
}
netConn := wsconn.New(conn)
session, err := yamux.Server(netConn, yamux.DefaultConfig())
if err != nil {
log.Printf("[relay] yamux server creation failed: %v", err)
conn.Close()
return
}
// Replace existing session for this token
s.mu.Lock()
old := s.sessions[tok]
s.sessions[tok] = session
s.mu.Unlock()
if old != nil {
old.Close()
}
log.Printf("[relay] tunnel connected: token=%s... from=%s", tok[:8], r.RemoteAddr)
// Block until session closes
<-session.CloseChan()
log.Printf("[relay] tunnel disconnected: token=%s...", tok[:8])
s.mu.Lock()
if s.sessions[tok] == session {
delete(s.sessions, tok)
}
s.mu.Unlock()
}
// handleStatus reports connected tunnels. If a token query param is provided,
// reports status for that specific token only.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
tok := r.URL.Query().Get("token")
if tok != "" {
s.mu.RLock()
sess, ok := s.sessions[tok]
connected := ok && !sess.IsClosed()
s.mu.RUnlock()
json.NewEncoder(w).Encode(map[string]interface{}{
"tunnelConnected": connected,
})
return
}
// No token — report total count
s.mu.RLock()
count := 0
for _, sess := range s.sessions {
if !sess.IsClosed() {
count++
}
}
s.mu.RUnlock()
json.NewEncoder(w).Encode(map[string]interface{}{
"tunnelConnected": count > 0,
"tunnelCount": count,
})
}
// getToken extracts the relay token from the request. It checks the
// X-Relay-Token header first, then the "token" query parameter.
func getToken(r *http.Request) string {
if tok := r.Header.Get("X-Relay-Token"); tok != "" {
return tok
}
return r.URL.Query().Get("token")
}
// handleProxy forwards an HTTP request through the yamux tunnel to the
// edge-ai-server identified by the token. Supports standard requests,
// streaming, and WebSocket upgrades.
func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
// CORS preflight
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Relay-Token")
w.WriteHeader(http.StatusNoContent)
return
}
tok := getToken(r)
if tok == "" {
http.Error(w, `{"success":false,"error":{"code":"NO_TOKEN","message":"X-Relay-Token header or token query param required"}}`, http.StatusUnauthorized)
return
}
s.mu.RLock()
session := s.sessions[tok]
s.mu.RUnlock()
if session == nil || session.IsClosed() {
http.Error(w, `{"success":false,"error":{"code":"TUNNEL_DISCONNECTED","message":"Edge server is not connected"}}`, http.StatusBadGateway)
return
}
// Open a yamux stream
stream, err := session.Open()
if err != nil {
log.Printf("[relay] failed to open yamux stream: %v", err)
http.Error(w, `{"success":false,"error":{"code":"TUNNEL_ERROR","message":"Failed to open tunnel stream"}}`, http.StatusBadGateway)
return
}
defer stream.Close()
// Strip the X-Relay-Token header before forwarding to local server
r.Header.Del("X-Relay-Token")
// Check if this is a WebSocket upgrade
if isWebSocketUpgrade(r) {
s.proxyWebSocket(w, r, stream)
return
}
// Forward the HTTP request
if err := r.Write(stream); err != nil {
log.Printf("[relay] failed to write request to tunnel: %v", err)
http.Error(w, "tunnel write error", http.StatusBadGateway)
return
}
// Read the HTTP response
resp, err := http.ReadResponse(bufio.NewReader(stream), r)
if err != nil {
log.Printf("[relay] failed to read response from tunnel: %v", err)
http.Error(w, "tunnel read error", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Copy response headers
for key, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
// Stream body — use flusher for MJPEG and other streaming responses
if flusher, ok := w.(http.Flusher); ok {
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
w.Write(buf[:n])
flusher.Flush()
}
if err != nil {
break
}
}
} else {
io.Copy(w, resp.Body)
}
}
// proxyWebSocket handles WebSocket upgrade requests by forwarding the upgrade
// through the yamux stream and then bidirectionally copying frames.
func (s *Server) proxyWebSocket(w http.ResponseWriter, r *http.Request, stream net.Conn) {
// Write the HTTP upgrade request to tunnel
if err := r.Write(stream); err != nil {
log.Printf("[relay] ws: failed to write upgrade request: %v", err)
http.Error(w, "tunnel write error", http.StatusBadGateway)
return
}
// Read the response (should be 101 Switching Protocols)
resp, err := http.ReadResponse(bufio.NewReader(stream), r)
if err != nil {
log.Printf("[relay] ws: failed to read upgrade response: %v", err)
http.Error(w, "tunnel read error", http.StatusBadGateway)
return
}
if resp.StatusCode != http.StatusSwitchingProtocols {
// Not upgraded — send error response back
for key, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
resp.Body.Close()
return
}
// Hijack the client connection
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, clientBuf, err := hijacker.Hijack()
if err != nil {
log.Printf("[relay] ws: hijack failed: %v", err)
return
}
defer clientConn.Close()
// Write the 101 response to the browser client
resp.Write(clientBuf)
clientBuf.Flush()
// Bidirectional copy
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
io.Copy(stream, clientConn)
stream.Close()
}()
go func() {
defer wg.Done()
io.Copy(clientConn, stream)
clientConn.Close()
}()
wg.Wait()
}
func isWebSocketUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
// TunnelConnected reports whether at least one tunnel session is active.
func (s *Server) TunnelConnected() bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sess := range s.sessions {
if !sess.IsClosed() {
return true
}
}
return false
}
// Shutdown closes the relay server and all active tunnels.
func (s *Server) Shutdown() {
s.mu.Lock()
defer s.mu.Unlock()
for tok, sess := range s.sessions {
sess.Close()
delete(s.sessions, tok)
}
}
// FormatAddr formats the listen address from a port number.
func FormatAddr(port int) string {
return fmt.Sprintf(":%d", port)
}

View File

@ -1,242 +0,0 @@
// Package tunnel implements a client that connects to a relay server and
// forwards incoming requests to the local edge-ai-server.
package tunnel
import (
"bufio"
"io"
"log"
"math"
"net"
"net/http"
"net/url"
"sync"
"time"
"edge-ai-platform/pkg/wsconn"
"github.com/gorilla/websocket"
"github.com/hashicorp/yamux"
)
// Client maintains a persistent tunnel connection to a relay server.
type Client struct {
relayURL string // ws(s)://host:port/tunnel/connect
token string
localAddr string // local server address, e.g. "127.0.0.1:3721"
stopCh chan struct{}
stoppedCh chan struct{}
}
// NewClient creates a tunnel client that connects to the given relay URL
// and forwards traffic to localAddr.
func NewClient(relayURL, token, localAddr string) *Client {
return &Client{
relayURL: relayURL,
token: token,
localAddr: localAddr,
stopCh: make(chan struct{}),
stoppedCh: make(chan struct{}),
}
}
// Start begins the tunnel connection loop in a background goroutine.
// It automatically reconnects on failure with exponential backoff.
func (c *Client) Start() {
go c.run()
}
// Stop closes the tunnel connection and stops reconnecting.
func (c *Client) Stop() {
close(c.stopCh)
<-c.stoppedCh
}
func (c *Client) run() {
defer close(c.stoppedCh)
attempt := 0
for {
select {
case <-c.stopCh:
return
default:
}
err := c.connect()
if err != nil {
attempt++
delay := backoff(attempt)
log.Printf("[tunnel] connection failed (attempt %d): %v — retrying in %v", attempt, err, delay)
select {
case <-c.stopCh:
return
case <-time.After(delay):
}
continue
}
// Connected successfully — reset attempt counter
attempt = 0
}
}
// connect establishes a single tunnel session and blocks until it closes.
func (c *Client) connect() error {
// Build WebSocket URL
u, err := url.Parse(c.relayURL)
if err != nil {
return err
}
q := u.Query()
if c.token != "" {
q.Set("token", c.token)
}
u.RawQuery = q.Encode()
log.Printf("[tunnel] connecting to %s", u.Host)
dialer := websocket.DefaultDialer
conn, _, err := dialer.Dial(u.String(), nil)
if err != nil {
return err
}
netConn := wsconn.New(conn)
session, err := yamux.Client(netConn, yamux.DefaultConfig())
if err != nil {
conn.Close()
return err
}
log.Printf("[tunnel] connected to relay at %s", u.Host)
// Accept incoming streams until session closes or stop is requested
var wg sync.WaitGroup
go func() {
<-c.stopCh
session.Close()
}()
for {
stream, err := session.Accept()
if err != nil {
if session.IsClosed() {
break
}
log.Printf("[tunnel] accept error: %v", err)
break
}
wg.Add(1)
go func(s net.Conn) {
defer wg.Done()
c.handleStream(s)
}(stream)
}
wg.Wait()
log.Printf("[tunnel] disconnected from relay")
return nil
}
// handleStream reads an HTTP request from the yamux stream, forwards it
// to the local server, and writes back the response.
func (c *Client) handleStream(stream net.Conn) {
defer stream.Close()
// Read the forwarded HTTP request
req, err := http.ReadRequest(bufio.NewReader(stream))
if err != nil {
log.Printf("[tunnel] failed to read request: %v", err)
return
}
// Set the destination URL
req.URL.Scheme = "http"
req.URL.Host = c.localAddr
req.RequestURI = "" // must clear for http.Client
// Check if this is a WebSocket upgrade
if isWebSocketUpgrade(req) {
c.handleWebSocket(stream, req)
return
}
// Forward to local server
resp, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
log.Printf("[tunnel] local request failed: %v", err)
// Write an error response back through the tunnel
errResp := &http.Response{
StatusCode: http.StatusBadGateway,
ProtoMajor: 1,
ProtoMinor: 1,
Header: make(http.Header),
Body: http.NoBody,
}
errResp.Write(stream)
return
}
defer resp.Body.Close()
// Write response back through the tunnel
resp.Write(stream)
}
// handleWebSocket forwards a WebSocket upgrade request to the local server
// using a raw TCP connection and bidirectional copy.
func (c *Client) handleWebSocket(stream net.Conn, req *http.Request) {
// Connect to local server via raw TCP
localConn, err := net.DialTimeout("tcp", c.localAddr, 10*time.Second)
if err != nil {
log.Printf("[tunnel] ws: failed to connect to local: %v", err)
return
}
defer localConn.Close()
// Write the original HTTP upgrade request to the local server
req.RequestURI = req.URL.RequestURI() // restore for raw write
req.Write(localConn)
// Bidirectional copy between tunnel stream and local connection
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
io.Copy(localConn, stream)
localConn.Close()
}()
go func() {
defer wg.Done()
io.Copy(stream, localConn)
stream.Close()
}()
wg.Wait()
}
func isWebSocketUpgrade(r *http.Request) bool {
for _, v := range r.Header["Upgrade"] {
if v == "websocket" || v == "Websocket" || v == "WebSocket" {
return true
}
}
return false
}
// backoff returns an exponential backoff duration capped at 30 seconds.
func backoff(attempt int) time.Duration {
d := time.Duration(math.Min(float64(time.Second)*math.Pow(2, float64(attempt-1)), 30)) * time.Second
if d < time.Second {
d = time.Second
}
if d > 30*time.Second {
d = 30 * time.Second
}
return d
}

View File

@ -1,83 +0,0 @@
package update
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// UpdateInfo holds the result of a version check.
type UpdateInfo struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
UpdateAvailable bool `json:"updateAvailable"`
ReleaseURL string `json:"releaseUrl"`
ReleaseNotes string `json:"releaseNotes"`
PublishedAt string `json:"publishedAt"`
}
// giteaRelease represents a subset of the Gitea release API response.
type giteaRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
}
// Check queries the Gitea API for the latest release and compares it with
// the current version. If giteaURL is empty, it returns a result indicating
// no update info is available. Network or API errors are treated as
// non-fatal: the function returns an UpdateInfo with UpdateAvailable=false.
func Check(currentVersion, giteaURL, owner, repo string) *UpdateInfo {
info := &UpdateInfo{
CurrentVersion: currentVersion,
}
if giteaURL == "" || owner == "" || repo == "" {
return info
}
// Build API URL: GET /api/v1/repos/{owner}/{repo}/releases/latest
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/latest",
strings.TrimRight(giteaURL, "/"), owner, repo)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return info
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return info
}
var release giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return info
}
info.LatestVersion = release.TagName
info.ReleaseURL = release.HTMLURL
info.ReleaseNotes = truncateNotes(release.Body, 500)
info.PublishedAt = release.PublishedAt
// Compare versions: strip leading "v" for comparison.
current := strings.TrimPrefix(currentVersion, "v")
latest := strings.TrimPrefix(release.TagName, "v")
if latest != "" && latest != current && currentVersion != "dev" {
info.UpdateAvailable = true
}
return info
}
// truncateNotes limits release notes to maxLen characters.
func truncateNotes(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View File

@ -1,298 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"edge-ai-platform/internal/api"
"edge-ai-platform/internal/api/handlers"
"edge-ai-platform/internal/api/ws"
"edge-ai-platform/internal/camera"
"edge-ai-platform/internal/cluster"
"edge-ai-platform/internal/config"
"edge-ai-platform/internal/deps"
"edge-ai-platform/internal/device"
"edge-ai-platform/internal/flash"
"edge-ai-platform/internal/inference"
"edge-ai-platform/internal/model"
"edge-ai-platform/internal/tunnel"
"edge-ai-platform/pkg/hwid"
pkglogger "edge-ai-platform/pkg/logger"
"edge-ai-platform/tray"
"edge-ai-platform/web"
)
var (
Version = "dev"
BuildTime = "unknown"
)
// baseDir returns the base directory for resolving data/ and scripts/ paths.
// In dev mode (go run), uses the working directory.
// In production (compiled binary), uses the binary's directory so the server
// works correctly regardless of the working directory.
func baseDir(devMode bool) string {
if devMode {
return "."
}
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
func main() {
cfg := config.Load()
// Tray mode: launch system tray launcher instead of server.
if cfg.TrayMode {
trayCfg := tray.LoadConfig()
tray.Run(trayCfg)
return
}
logger := pkglogger.New(cfg.LogLevel)
logger.Info("Starting Edge AI Platform Server %s (built: %s)", Version, BuildTime)
logger.Info("Mock mode: %v, Mock camera: %v, Dev mode: %v", cfg.MockMode, cfg.MockCamera, cfg.DevMode)
// Check external dependencies
deps.PrintStartupReport(logger)
// Initialize model repository (built-in models from JSON)
baseDir := baseDir(cfg.DevMode)
modelRepo := model.NewRepository(filepath.Join(baseDir, "data", "models.json"))
logger.Info("Loaded %d built-in models", modelRepo.Count())
// Initialize model store (custom uploaded models)
modelStore := model.NewModelStore(filepath.Join(baseDir, "data", "custom-models"))
customModels, err := modelStore.LoadCustomModels()
if err != nil {
logger.Warn("Failed to load custom models: %v", err)
}
for _, m := range customModels {
modelRepo.Add(m)
}
if len(customModels) > 0 {
logger.Info("Loaded %d custom models", len(customModels))
}
// Initialize WebSocket hub (before device manager so log broadcaster is ready)
wsHub := ws.NewHub()
go wsHub.Run()
// Initialize log broadcaster for real-time log streaming
logBroadcaster := pkglogger.NewBroadcaster(500, func(entry pkglogger.LogEntry) {
wsHub.BroadcastToRoom("server-logs", entry)
})
logger.SetBroadcaster(logBroadcaster)
// Initialize device manager
registry := device.NewRegistry()
deviceMgr := device.NewManager(registry, cfg.MockMode, cfg.MockDeviceCount, filepath.Join(baseDir, "scripts", "kneron_bridge.py"))
deviceMgr.SetLogBroadcaster(logBroadcaster)
deviceMgr.Start()
// Initialize camera manager
cameraMgr := camera.NewManager(cfg.MockCamera)
// Initialize cluster manager
clusterMgr := cluster.NewManager(deviceMgr)
// Initialize services
flashSvc := flash.NewService(deviceMgr, modelRepo)
inferenceSvc := inference.NewService(deviceMgr)
// Determine static file system for embedded frontend
var staticFS http.FileSystem
if !cfg.DevMode {
staticFS = web.StaticFS()
logger.Info("Serving embedded frontend static files")
} else {
logger.Info("Dev mode: frontend static serving disabled (use Next.js dev server on :3000)")
}
// Build HTTP server (needed for graceful shutdown and restart)
var httpServer *http.Server
var tunnelClient *tunnel.Client
restartRequested := make(chan struct{}, 1)
shutdownFn := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if tunnelClient != nil {
tunnelClient.Stop()
}
inferenceSvc.StopAll()
cameraMgr.Close()
if httpServer != nil {
httpServer.Shutdown(ctx)
}
}
restartFn := func() {
// Signal the main goroutine to perform exec after server shutdown
select {
case restartRequested <- struct{}{}:
default:
}
shutdownFn()
}
// Auto-generate relay token from hardware ID if not explicitly set
relayToken := cfg.RelayToken
if cfg.RelayURL != "" && relayToken == "" {
relayToken = hwid.Generate()
logger.Info("Auto-generated relay token from hardware ID: %s...", relayToken[:8])
}
// Create system handler with injected version and restart function
systemHandler := handlers.NewSystemHandler(Version, BuildTime, cfg.GiteaURL, restartFn)
if cfg.GiteaURL != "" {
logger.Info("Update check enabled: %s", cfg.GiteaURL)
}
// Create router
r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, clusterMgr, flashSvc, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler, relayToken)
// Configure HTTP server
addr := cfg.Addr()
httpServer = &http.Server{
Addr: addr,
Handler: r,
}
// Handle OS signals for graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-quit
logger.Info("Received signal %v, shutting down gracefully...", sig)
shutdownFn()
os.Exit(0)
}()
// Start tunnel client if relay URL is configured
if cfg.RelayURL != "" {
tunnelClient = tunnel.NewClient(cfg.RelayURL, relayToken, cfg.Addr())
tunnelClient.Start()
logger.Info("Tunnel client started, connecting to relay: %s", cfg.RelayURL)
// Open browser with token-embedded URL so the user is automatically
// authenticated with the relay.
if relayHTTP := relayWebURL(cfg.RelayURL, relayToken); relayHTTP != "" {
logger.Info("Opening browser: %s", relayHTTP)
openBrowser(relayHTTP)
}
}
// Kill existing process on the port if occupied
killExistingProcess(addr, logger)
// Start server
logger.Info("Server listening on %s", addr)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("Failed to start server: %v", err)
}
// If restart was requested, exec the same binary to replace this process
select {
case <-restartRequested:
logger.Info("Performing self-restart via exec...")
exe, err := os.Executable()
if err != nil {
log.Fatalf("Failed to get executable path: %v", err)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
log.Fatalf("Failed to resolve executable symlinks: %v", err)
}
_ = syscall.Exec(exe, os.Args, os.Environ())
log.Fatalf("syscall.Exec failed")
default:
// Normal shutdown, just exit
}
}
// killExistingProcess checks if the port is already in use and kills the
// occupying process so the server can start cleanly.
func killExistingProcess(addr string, logger *pkglogger.Logger) {
// Extract port from addr (e.g. "127.0.0.1:3721" → "3721")
_, port, err := net.SplitHostPort(addr)
if err != nil {
return
}
// Quick check: try to listen — if it works, port is free
ln, err := net.Listen("tcp", addr)
if err == nil {
ln.Close()
return
}
// Port is occupied, find and kill the process
logger.Info("Port %s is in use, killing existing process...", port)
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
// netstat -ano | findstr :PORT
cmd = exec.Command("cmd", "/C", fmt.Sprintf("for /f \"tokens=5\" %%a in ('netstat -ano ^| findstr :%s') do taskkill /F /PID %%a", port))
} else {
// lsof -ti:PORT | xargs kill -9
cmd = exec.Command("sh", "-c", fmt.Sprintf("lsof -ti:%s | xargs kill -9 2>/dev/null", port))
}
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warn("Failed to kill process on port %s: %v (%s)", port, err, strings.TrimSpace(string(output)))
return
}
// Wait briefly for port to be released
time.Sleep(500 * time.Millisecond)
logger.Info("Previous process killed, port %s is now free", port)
}
// relayWebURL converts a relay WebSocket URL (ws://host/tunnel/connect)
// to an HTTP URL with the token embedded as a query parameter.
func relayWebURL(wsURL, token string) string {
// ws://host:port/tunnel/connect → http://host:port/?token=xxx
u := wsURL
u = strings.Replace(u, "wss://", "https://", 1)
u = strings.Replace(u, "ws://", "http://", 1)
// Strip the /tunnel/connect path
if i := strings.Index(u, "/tunnel"); i != -1 {
u = u[:i]
}
return fmt.Sprintf("%s/?token=%s", u, token)
}
// openBrowser opens a URL in the default browser.
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
return
}
cmd.Start()
}

View File

@ -1,39 +0,0 @@
// Package hwid generates a stable hardware identifier from the machine's
// first non-loopback network interface MAC address.
package hwid
import (
"crypto/sha256"
"fmt"
"net"
)
// Generate returns a 16-character hex string derived from the SHA-256 hash of
// the first non-loopback network interface's MAC address. If no suitable
// interface is found, it falls back to a hash of the hostname-like string
// "unknown" so the server can still start.
func Generate() string {
ifaces, err := net.Interfaces()
if err != nil {
return hashString("unknown")
}
for _, iface := range ifaces {
// Skip loopback and interfaces without a hardware address
if iface.Flags&net.FlagLoopback != 0 {
continue
}
mac := iface.HardwareAddr.String()
if mac == "" {
continue
}
return hashString(mac)
}
return hashString("unknown")
}
func hashString(s string) string {
h := sha256.Sum256([]byte(s))
return fmt.Sprintf("%x", h)[:16]
}

View File

@ -1,73 +0,0 @@
package logger
import (
"sync"
"time"
)
// LogEntry represents a single structured log entry for WebSocket streaming.
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
}
// BroadcastFunc is called whenever a new log entry is produced.
type BroadcastFunc func(entry LogEntry)
// Broadcaster captures log output, maintains a ring buffer of recent entries,
// and notifies subscribers (via BroadcastFunc) in real time.
type Broadcaster struct {
mu sync.RWMutex
buffer []LogEntry
bufSize int
pos int
full bool
broadcast BroadcastFunc
}
// NewBroadcaster creates a broadcaster with the given ring buffer capacity.
func NewBroadcaster(bufferSize int, fn BroadcastFunc) *Broadcaster {
return &Broadcaster{
buffer: make([]LogEntry, bufferSize),
bufSize: bufferSize,
broadcast: fn,
}
}
// Push adds a log entry to the ring buffer and broadcasts it.
func (b *Broadcaster) Push(level, message string) {
entry := LogEntry{
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Level: level,
Message: message,
}
b.mu.Lock()
b.buffer[b.pos] = entry
b.pos = (b.pos + 1) % b.bufSize
if b.pos == 0 {
b.full = true
}
b.mu.Unlock()
if b.broadcast != nil {
b.broadcast(entry)
}
}
// Recent returns a copy of all buffered log entries in chronological order.
func (b *Broadcaster) Recent() []LogEntry {
b.mu.RLock()
defer b.mu.RUnlock()
if !b.full {
result := make([]LogEntry, b.pos)
copy(result, b.buffer[:b.pos])
return result
}
result := make([]LogEntry, b.bufSize)
copy(result, b.buffer[b.pos:])
copy(result[b.bufSize-b.pos:], b.buffer[:b.pos])
return result
}

View File

@ -1,66 +0,0 @@
package logger
import (
"fmt"
"log"
"os"
)
type Logger struct {
info *log.Logger
warn *log.Logger
err *log.Logger
debug *log.Logger
level string
broadcaster *Broadcaster
}
func New(level string) *Logger {
return &Logger{
info: log.New(os.Stdout, "[INFO] ", log.Ldate|log.Ltime|log.Lshortfile),
warn: log.New(os.Stdout, "[WARN] ", log.Ldate|log.Ltime|log.Lshortfile),
err: log.New(os.Stderr, "[ERROR] ", log.Ldate|log.Ltime|log.Lshortfile),
debug: log.New(os.Stdout, "[DEBUG] ", log.Ldate|log.Ltime|log.Lshortfile),
level: level,
}
}
// SetBroadcaster attaches a log broadcaster for real-time log streaming.
func (l *Logger) SetBroadcaster(b *Broadcaster) {
l.broadcaster = b
}
// GetBroadcaster returns the attached broadcaster (may be nil).
func (l *Logger) GetBroadcaster() *Broadcaster {
return l.broadcaster
}
func (l *Logger) Info(msg string, args ...interface{}) {
l.info.Printf(msg, args...)
if l.broadcaster != nil {
l.broadcaster.Push("INFO", fmt.Sprintf(msg, args...))
}
}
func (l *Logger) Warn(msg string, args ...interface{}) {
l.warn.Printf(msg, args...)
if l.broadcaster != nil {
l.broadcaster.Push("WARN", fmt.Sprintf(msg, args...))
}
}
func (l *Logger) Error(msg string, args ...interface{}) {
l.err.Printf(msg, args...)
if l.broadcaster != nil {
l.broadcaster.Push("ERROR", fmt.Sprintf(msg, args...))
}
}
func (l *Logger) Debug(msg string, args ...interface{}) {
if l.level == "debug" {
l.debug.Printf(msg, args...)
if l.broadcaster != nil {
l.broadcaster.Push("DEBUG", fmt.Sprintf(msg, args...))
}
}
}

View File

@ -1,89 +0,0 @@
// Package wsconn wraps a gorilla/websocket.Conn into a net.Conn so that
// stream multiplexers like hashicorp/yamux can run on top of a WebSocket.
package wsconn
import (
"io"
"net"
"sync"
"time"
"github.com/gorilla/websocket"
)
// Conn adapts a *websocket.Conn to the net.Conn interface.
// All messages are sent/received as Binary frames.
type Conn struct {
ws *websocket.Conn
reader io.Reader
rmu sync.Mutex
wmu sync.Mutex
}
// New wraps a WebSocket connection as a net.Conn.
func New(ws *websocket.Conn) *Conn {
return &Conn{ws: ws}
}
func (c *Conn) Read(p []byte) (int, error) {
c.rmu.Lock()
defer c.rmu.Unlock()
for {
if c.reader != nil {
n, err := c.reader.Read(p)
if err == io.EOF {
c.reader = nil
if n > 0 {
return n, nil
}
continue
}
return n, err
}
_, reader, err := c.ws.NextReader()
if err != nil {
return 0, err
}
c.reader = reader
}
}
func (c *Conn) Write(p []byte) (int, error) {
c.wmu.Lock()
defer c.wmu.Unlock()
err := c.ws.WriteMessage(websocket.BinaryMessage, p)
if err != nil {
return 0, err
}
return len(p), nil
}
func (c *Conn) Close() error {
return c.ws.Close()
}
func (c *Conn) LocalAddr() net.Addr {
return c.ws.LocalAddr()
}
func (c *Conn) RemoteAddr() net.Addr {
return c.ws.RemoteAddr()
}
func (c *Conn) SetDeadline(t time.Time) error {
if err := c.ws.SetReadDeadline(t); err != nil {
return err
}
return c.ws.SetWriteDeadline(t)
}
func (c *Conn) SetReadDeadline(t time.Time) error {
return c.ws.SetReadDeadline(t)
}
func (c *Conn) SetWriteDeadline(t time.Time) error {
return c.ws.SetWriteDeadline(t)
}

Some files were not shown because too many files have changed in this diff Show More