diff --git a/.gitea/workflows/build-installer.yaml b/.gitea/workflows/build-installer.yaml new file mode 100644 index 0000000..23e3e24 --- /dev/null +++ b/.gitea/workflows/build-installer.yaml @@ -0,0 +1,145 @@ +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f5b1b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# 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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..8f43ccd --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,80 @@ +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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..992d257 --- /dev/null +++ b/Makefile @@ -0,0 +1,169 @@ +# 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!" diff --git a/README.md b/README.md index d7b54e1..46717b7 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,116 @@ -# web_academy_prototype +# Edge AI Platform -## 專案目標 -此 repository 的 PoC 主軸是**線上教學平台核心流程**。 -核心流程定義請參考 `docs/PRD-Integrated.md`。 +邊緣 AI 開發平台 — 管理 AI 模型、連接邊緣裝置(Kneron KL720/KL730)、即時攝影機推論。 -`local_service_win` 是整體 PoC 其中一個模組,負責本機硬體控制與推論流程驗證。 +單一執行檔,下載即可使用。 -## PoC 範圍與路線圖 -- 主目標:線上教學平台核心流程 PoC。 -- Local Service PoC: - - Windows:已在本 repo(`local_service_win/`)。 - - Linux:規劃中(KneronPLUS 已支援)。 - - macOS:規劃中(待 KneronPLUS 支援)。 -- 網頁流程 PoC:規劃中(後續加入相關專案或模組)。 -- `local_agent_win/`:會納入此專案範圍。 +## Quick Start -## 目前已存在模組 -- `local_service_win/` - - Python + FastAPI localhost 服務。 - - 透過 KneronPLUS(`kp`)與 Kneron USB 裝置互動。 - - 涵蓋掃描、連線、模型載入、推論流程。 - - 預設位址:`http://127.0.0.1:4398`。 +### macOS -目前 Windows local service 資料流: -`Client (Browser/App) -> LocalAPI (127.0.0.1:4398) -> KneronPLUS kp -> KL520/KL720` +```bash +# 安裝(下載至 ~/.edge-ai-platform) +curl -fsSL https://gitea.innovedus.com/warrenchen/web_academy_prototype/raw/branch/main/scripts/install.sh | bash -## 版本相容性(目前觀察) -- 你目前環境使用 Python `3.13` 看起來可運作。 -- KneronPLUS 既有生態資訊常見以 Python `3.9` 為主。 -- 後續建議補上正式相容矩陣(Python / KP 版本)。 +# 啟動(Mock 模式,不需硬體) +edge-ai-server --mock --mock-devices=3 -## 專案結構(目前) -```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 +# 開啟瀏覽器 +open http://127.0.0.1:3721 ``` -## Pic64View 工具說明 -- 檔案:`local_service_win/TestRes/Images/Pic64View.html` -- 用途:本機快速預覽 Base64 圖片字串,方便測試 `/inference/run` 的 `image_base64` 內容是否正確。 -- 輸入格式: - - 可直接貼 `data:image/...;base64,...`。 - - 也可只貼純 Base64,工具會自動補上 `data:image/png;base64,` 前綴再渲染。 -- 操作: - - `Render`:顯示預覽圖。 - - `Clear`:清空輸入與預覽結果。 +### Windows (PowerShell) -## 快速開始(local_service_win) -1. 安裝相依套件: ```powershell -cd local_service_win -python -m pip install -r requirements.txt +# 安裝 +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 ``` -2. 安裝 KneronPLUS wheel: +### 手動下載 + +從 [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) + ```powershell -python -m pip install .\KneronPLUS-3.1.2-py3-none-any.whl +Remove-Item -Recurse -Force "$env:LOCALAPPDATA\EdgeAIPlatform" +# 手動從系統環境變數移除 PATH 中的 EdgeAIPlatform 路徑 ``` -3. 啟動本機服務: -```powershell -python .\LocalAPI\main.py -``` +## 開發 -## 參考文件 -- 核心流程與產品規劃:`docs/PRD-Integrated.md` -- Windows local service 策略:`local_service_win/STRATEGY.md` +```bash +# 安裝依賴 +make install + +# 啟動開發伺服器(前端 :3000 + 後端 :3721) +make dev + +# 編譯單一 binary +make build + +# 跨平台打包(本機測試,不發佈) +make release-snapshot + +# 發佈至 Gitea Release +make release +``` diff --git a/frontend b/frontend new file mode 160000 index 0000000..9239a97 --- /dev/null +++ b/frontend @@ -0,0 +1 @@ +Subproject commit 9239a97721215a14ecac520c21e64d440dd36d98 diff --git a/installer/app.go b/installer/app.go new file mode 100644 index 0000000..385dee2 --- /dev/null +++ b/installer/app.go @@ -0,0 +1,689 @@ +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() + } +} diff --git a/installer/embed.go b/installer/embed.go new file mode 100644 index 0000000..92ecabc --- /dev/null +++ b/installer/embed.go @@ -0,0 +1,9 @@ +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 diff --git a/installer/frontend/app.js b/installer/frontend/app.js new file mode 100644 index 0000000..e3e7f1e --- /dev/null +++ b/installer/frontend/app.js @@ -0,0 +1,536 @@ +// 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 = + '
' + + '
' + + '

' + t('hardware.scanning') + '

' + + '
'; + + try { + const devices = await window.go.main.Installer.DetectHardware(); + if (!devices || devices.length === 0) { + el.innerHTML = '

' + t('hardware.noDevices') + '

'; + } else { + el.innerHTML = devices.map(d => + '
' + + '
' + + '
' + + 'Kneron ' + (d.model || 'Unknown') + '' + + '' + (d.product || d.port || '') + '' + + '
' + + '
' + ).join(''); + } + } catch (err) { + el.innerHTML = '

Detection skipped: ' + err + '

'; + } +} + +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'; + } + }); +}); diff --git a/installer/frontend/index.html b/installer/frontend/index.html new file mode 100644 index 0000000..03ecb12 --- /dev/null +++ b/installer/frontend/index.html @@ -0,0 +1,258 @@ + + + + + + Edge AI Platform Installer + + + +
+ +
+
+
Edge AI Platform
+
Installer v0.2.0
+
+
+
+ 1 + + 2 + + 3 + + 4 + + 5 + + 6 + + 7 +
+
+
+
+ + | + +
+
+
+ +
+ +
+

Edge AI Platform Installer

+

Set up your edge AI development environment with Kneron hardware support.

+ +
+
Platform-
+
Python-
+
libusb-
+
FFmpeg-
+
+ + + +
+ +
+
+ + +
+

Installation Path

+

Choose where to install Edge AI Platform.

+ +
+
+ + +
+

+
+ +
+ + +
+
+ + +
+

Select Components

+

Choose which components to install.

+ +
+ + + + + +
+ +
+ + +
+
+ + +
+

Relay Configuration

+

Configure the relay server for remote access. You can skip this and configure later.

+ +
+ + +
+ +
+ +
+ + +
+

Auto-generated random token. Both the server and browser use this to authenticate with the relay.

+
+ +
+ + +

The HTTP URL to access the dashboard via relay. Opened after server launch.

+
+ +
+ + +
+ +

Leave empty to skip relay configuration. You can set this later in the config file.

+ +
+ + +
+
+ + +
+

Installing...

+

Please wait while components are being installed.

+ +
+
+
+
+ 0% +
+

Preparing installation...

+ +
+
+ + +
+

Hardware Detection

+

Connect your Kneron devices and scan for hardware.

+ +
+
+
+

Scanning for devices...

+
+ +
+ +
+ + +
+
+ + +
+
+ + + + +
+

Installation Complete

+

Edge AI Platform has been installed successfully.

+ +
+
Install Location-
+
Edge AI ServerInstalled
+
Kneron Models-
+
Python Environment-
+
libusb-
+
+ +
+ + + +
+
+
+
+ + + + + + diff --git a/installer/frontend/style.css b/installer/frontend/style.css new file mode 100644 index 0000000..0fae6e0 --- /dev/null +++ b/installer/frontend/style.css @@ -0,0 +1,508 @@ +/* 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; +} diff --git a/installer/go.mod b/installer/go.mod new file mode 100644 index 0000000..b9ede35 --- /dev/null +++ b/installer/go.mod @@ -0,0 +1,35 @@ +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 +) diff --git a/installer/go.sum b/installer/go.sum new file mode 100644 index 0000000..e3658ec --- /dev/null +++ b/installer/go.sum @@ -0,0 +1,81 @@ +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= diff --git a/installer/main.go b/installer/main.go new file mode 100644 index 0000000..f946329 --- /dev/null +++ b/installer/main.go @@ -0,0 +1,36 @@ +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) + } +} diff --git a/installer/platform_darwin.go b/installer/platform_darwin.go new file mode 100644 index 0000000..304c3c2 --- /dev/null +++ b/installer/platform_darwin.go @@ -0,0 +1,150 @@ +//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{ + ``, + ``, + ``, + ``, + ` Label`, + ` ` + launchdLabel + ``, + ` ProgramArguments`, + ` `, + ` ` + binPath + ``, + ` --tray`, + ` `, + ` WorkingDirectory`, + ` ` + installDir + ``, + ` KeepAlive`, + ` `, + ` SuccessfulExit`, + ` `, + ` `, + ` ThrottleInterval`, + ` 5`, + ` StandardOutPath`, + ` ` + filepath.Join(logDir, "server.log") + ``, + ` StandardErrorPath`, + ` ` + filepath.Join(logDir, "server.err.log") + ``, + ` ProcessType`, + ` Interactive`, + ` RunAtLoad`, + ` `, + ``, + ``, + }, "\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) +} diff --git a/installer/platform_linux.go b/installer/platform_linux.go new file mode 100644 index 0000000..15ad61b --- /dev/null +++ b/installer/platform_linux.go @@ -0,0 +1,122 @@ +//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() +} diff --git a/installer/platform_windows.go b/installer/platform_windows.go new file mode 100644 index 0000000..e22b0aa --- /dev/null +++ b/installer/platform_windows.go @@ -0,0 +1,132 @@ +//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() +} diff --git a/installer/wails.json b/installer/wails.json new file mode 100644 index 0000000..869da0f --- /dev/null +++ b/installer/wails.json @@ -0,0 +1,20 @@ +{ + "$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" + } +} diff --git a/relay-server-linux b/relay-server-linux new file mode 100755 index 0000000..01c341e Binary files /dev/null and b/relay-server-linux differ diff --git a/scripts/deploy-aws.sh b/scripts/deploy-aws.sh new file mode 100755 index 0000000..3ca2066 --- /dev/null +++ b/scripts/deploy-aws.sh @@ -0,0 +1,408 @@ +#!/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" </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 <: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 "" diff --git a/scripts/deploy-ec2.sh b/scripts/deploy-ec2.sh new file mode 100755 index 0000000..e2ba5ba --- /dev/null +++ b/scripts/deploy-ec2.sh @@ -0,0 +1,481 @@ +#!/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 [OPTIONS] + +Arguments: + user@host SSH destination (e.g., ec2-user@1.2.3.4, ubuntu@myhost) + +Options: + --key 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 Relay server listen port (default: 3800) + --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 < /dev/null </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 </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://: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 "" diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..c1475d8 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,282 @@ +# 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" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..223f3e2 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,344 @@ +#!/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" < + + + + Label + ${plist_name} + + ProgramArguments + + ${INSTALL_DIR}/edge-ai-server + + + WorkingDirectory + ${INSTALL_DIR} + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 5 + + StandardOutPath + ${log_dir}/server.log + + StandardErrorPath + ${log_dir}/server.err.log + + ProcessType + Background + + RunAtLoad + + + +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" </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 "$@" diff --git a/scripts/kneron_detect.py b/scripts/kneron_detect.py new file mode 100644 index 0000000..95b338b --- /dev/null +++ b/scripts/kneron_detect.py @@ -0,0 +1,46 @@ +"""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() diff --git a/scripts/setup-kneron.sh b/scripts/setup-kneron.sh new file mode 100755 index 0000000..575590d --- /dev/null +++ b/scripts/setup-kneron.sh @@ -0,0 +1,123 @@ +#!/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" diff --git a/server/.next/trace b/server/.next/trace new file mode 100644 index 0000000..a41bb9e --- /dev/null +++ b/server/.next/trace @@ -0,0 +1 @@ +[{"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"}] diff --git a/server/.next/trace-build b/server/.next/trace-build new file mode 100644 index 0000000..4bcbd6d --- /dev/null +++ b/server/.next/trace-build @@ -0,0 +1 @@ +[{"name":"next-build","duration":1692006,"timestamp":494590042560,"id":1,"tags":{"buildMode":"default","version":"16.1.6","bundler":"turbopack"},"startTime":1772620714502,"traceId":"5e5954c44fbe79f2"}] diff --git a/server/cmd/relay-server/main.go b/server/cmd/relay-server/main.go new file mode 100644 index 0000000..61ffc46 --- /dev/null +++ b/server/cmd/relay-server/main.go @@ -0,0 +1,44 @@ +// 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) + } +} diff --git a/server/data/models.json b/server/data/models.json new file mode 100644 index 0000000..833f6f7 --- /dev/null +++ b/server/data/models.json @@ -0,0 +1,340 @@ +[ + { + "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" + } +] diff --git a/server/data/nef/kl520/kl520_20001_resnet18_w224h224.nef b/server/data/nef/kl520/kl520_20001_resnet18_w224h224.nef new file mode 100644 index 0000000..52a8c0e Binary files /dev/null and b/server/data/nef/kl520/kl520_20001_resnet18_w224h224.nef differ diff --git a/server/data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef b/server/data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef new file mode 100644 index 0000000..8d9138a Binary files /dev/null and b/server/data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef differ diff --git a/server/data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef b/server/data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef new file mode 100644 index 0000000..3ebaf01 Binary files /dev/null and b/server/data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef differ diff --git a/server/data/nef/kl520/kl520_ssd_fd_lm.nef b/server/data/nef/kl520/kl520_ssd_fd_lm.nef new file mode 100644 index 0000000..cbcfab8 Binary files /dev/null and b/server/data/nef/kl520/kl520_ssd_fd_lm.nef differ diff --git a/server/data/nef/kl520/kl520_tiny_yolo_v3.nef b/server/data/nef/kl520/kl520_tiny_yolo_v3.nef new file mode 100644 index 0000000..adefdaa Binary files /dev/null and b/server/data/nef/kl520/kl520_tiny_yolo_v3.nef differ diff --git a/server/data/nef/kl720/kl720_20001_resnet18_w224h224.nef b/server/data/nef/kl720/kl720_20001_resnet18_w224h224.nef new file mode 100644 index 0000000..af703e7 Binary files /dev/null and b/server/data/nef/kl720/kl720_20001_resnet18_w224h224.nef differ diff --git a/server/data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef b/server/data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef new file mode 100644 index 0000000..e540bff Binary files /dev/null and b/server/data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef differ diff --git a/server/data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef b/server/data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef new file mode 100644 index 0000000..64a0bec Binary files /dev/null and b/server/data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef differ diff --git a/server/edge-ai-server b/server/edge-ai-server new file mode 100755 index 0000000..144a2b6 Binary files /dev/null and b/server/edge-ai-server differ diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..504d30c --- /dev/null +++ b/server/go.mod @@ -0,0 +1,46 @@ +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 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..3d8510a --- /dev/null +++ b/server/go.sum @@ -0,0 +1,98 @@ +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= diff --git a/server/internal/api/handlers/camera_handler.go b/server/internal/api/handlers/camera_handler.go new file mode 100644 index 0000000..f8cc3e9 --- /dev/null +++ b/server/internal/api/handlers/camera_handler.go @@ -0,0 +1,790 @@ +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, + }, + }) +} diff --git a/server/internal/api/handlers/cluster_handler.go b/server/internal/api/handlers/cluster_handler.go new file mode 100644 index 0000000..50ebdcf --- /dev/null +++ b/server/internal/api/handlers/cluster_handler.go @@ -0,0 +1,376 @@ +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}) + } +} diff --git a/server/internal/api/handlers/device_handler.go b/server/internal/api/handlers/device_handler.go new file mode 100644 index 0000000..5eea516 --- /dev/null +++ b/server/internal/api/handlers/device_handler.go @@ -0,0 +1,181 @@ +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}) +} diff --git a/server/internal/api/handlers/model_handler.go b/server/internal/api/handlers/model_handler.go new file mode 100644 index 0000000..94a0d10 --- /dev/null +++ b/server/internal/api/handlers/model_handler.go @@ -0,0 +1,47 @@ +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}) +} diff --git a/server/internal/api/handlers/model_upload_handler.go b/server/internal/api/handlers/model_upload_handler.go new file mode 100644 index 0000000..81b704d --- /dev/null +++ b/server/internal/api/handlers/model_upload_handler.go @@ -0,0 +1,154 @@ +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}) +} diff --git a/server/internal/api/handlers/system_handler.go b/server/internal/api/handlers/system_handler.go new file mode 100644 index 0000000..4e80521 --- /dev/null +++ b/server/internal/api/handlers/system_handler.go @@ -0,0 +1,102 @@ +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() + }() +} diff --git a/server/internal/api/middleware.go b/server/internal/api/middleware.go new file mode 100644 index 0000000..e300d84 --- /dev/null +++ b/server/internal/api/middleware.go @@ -0,0 +1,29 @@ +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() + } +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go new file mode 100644 index 0000000..c156406 --- /dev/null +++ b/server/internal/api/router.go @@ -0,0 +1,204 @@ +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) + } +} diff --git a/server/internal/api/ws/cluster_flash_ws.go b/server/internal/api/ws/cluster_flash_ws.go new file mode 100644 index 0000000..98091bc --- /dev/null +++ b/server/internal/api/ws/cluster_flash_ws.go @@ -0,0 +1,39 @@ +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 + } + } + } +} diff --git a/server/internal/api/ws/cluster_inference_ws.go b/server/internal/api/ws/cluster_inference_ws.go new file mode 100644 index 0000000..69f7987 --- /dev/null +++ b/server/internal/api/ws/cluster_inference_ws.go @@ -0,0 +1,29 @@ +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 + } + } + } +} diff --git a/server/internal/api/ws/device_events_ws.go b/server/internal/api/ws/device_events_ws.go new file mode 100644 index 0000000..37a6d7f --- /dev/null +++ b/server/internal/api/ws/device_events_ws.go @@ -0,0 +1,44 @@ +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 + } + } + } +} diff --git a/server/internal/api/ws/flash_ws.go b/server/internal/api/ws/flash_ws.go new file mode 100644 index 0000000..712aafb --- /dev/null +++ b/server/internal/api/ws/flash_ws.go @@ -0,0 +1,39 @@ +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 + } + } + } +} diff --git a/server/internal/api/ws/hub.go b/server/internal/api/ws/hub.go new file mode 100644 index 0000000..5b663b4 --- /dev/null +++ b/server/internal/api/ws/hub.go @@ -0,0 +1,106 @@ +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} +} diff --git a/server/internal/api/ws/inference_ws.go b/server/internal/api/ws/inference_ws.go new file mode 100644 index 0000000..21dbdb7 --- /dev/null +++ b/server/internal/api/ws/inference_ws.go @@ -0,0 +1,31 @@ +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 + } + } + } +} diff --git a/server/internal/api/ws/server_logs_ws.go b/server/internal/api/ws/server_logs_ws.go new file mode 100644 index 0000000..f3816ee --- /dev/null +++ b/server/internal/api/ws/server_logs_ws.go @@ -0,0 +1,57 @@ +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 + } + } + } +} diff --git a/server/internal/camera/ffmpeg_camera.go b/server/internal/camera/ffmpeg_camera.go new file mode 100644 index 0000000..b6a7c24 --- /dev/null +++ b/server/internal/camera/ffmpeg_camera.go @@ -0,0 +1,188 @@ +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 +} diff --git a/server/internal/camera/ffmpeg_detect.go b/server/internal/camera/ffmpeg_detect.go new file mode 100644 index 0000000..ca587ba --- /dev/null +++ b/server/internal/camera/ffmpeg_detect.go @@ -0,0 +1,134 @@ +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 +} diff --git a/server/internal/camera/frame_source.go b/server/internal/camera/frame_source.go new file mode 100644 index 0000000..b7d6e51 --- /dev/null +++ b/server/internal/camera/frame_source.go @@ -0,0 +1,8 @@ +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 +} diff --git a/server/internal/camera/image_source.go b/server/internal/camera/image_source.go new file mode 100644 index 0000000..ed4152b --- /dev/null +++ b/server/internal/camera/image_source.go @@ -0,0 +1,82 @@ +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 +} diff --git a/server/internal/camera/manager.go b/server/internal/camera/manager.go new file mode 100644 index 0000000..2b7fd35 --- /dev/null +++ b/server/internal/camera/manager.go @@ -0,0 +1,107 @@ +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 +} diff --git a/server/internal/camera/mjpeg.go b/server/internal/camera/mjpeg.go new file mode 100644 index 0000000..fa75cfc --- /dev/null +++ b/server/internal/camera/mjpeg.go @@ -0,0 +1,81 @@ +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() + } + } +} diff --git a/server/internal/camera/mock_camera.go b/server/internal/camera/mock_camera.go new file mode 100644 index 0000000..9a1e268 --- /dev/null +++ b/server/internal/camera/mock_camera.go @@ -0,0 +1,95 @@ +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) + } + } + } + } +} diff --git a/server/internal/camera/multi_image_source.go b/server/internal/camera/multi_image_source.go new file mode 100644 index 0000000..c1ca4d1 --- /dev/null +++ b/server/internal/camera/multi_image_source.go @@ -0,0 +1,146 @@ +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 +} diff --git a/server/internal/camera/pipeline.go b/server/internal/camera/pipeline.go new file mode 100644 index 0000000..eb29e03 --- /dev/null +++ b/server/internal/camera/pipeline.go @@ -0,0 +1,230 @@ +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) + } + } +} diff --git a/server/internal/camera/video_source.go b/server/internal/camera/video_source.go new file mode 100644 index 0000000..4502fa2 --- /dev/null +++ b/server/internal/camera/video_source.go @@ -0,0 +1,277 @@ +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 + 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 +} diff --git a/server/internal/cluster/dispatcher.go b/server/internal/cluster/dispatcher.go new file mode 100644 index 0000000..fa2c0d7 --- /dev/null +++ b/server/internal/cluster/dispatcher.go @@ -0,0 +1,100 @@ +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 +} diff --git a/server/internal/cluster/manager.go b/server/internal/cluster/manager.go new file mode 100644 index 0000000..e7f0292 --- /dev/null +++ b/server/internal/cluster/manager.go @@ -0,0 +1,233 @@ +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 +} diff --git a/server/internal/cluster/pipeline.go b/server/internal/cluster/pipeline.go new file mode 100644 index 0000000..3b7ba50 --- /dev/null +++ b/server/internal/cluster/pipeline.go @@ -0,0 +1,135 @@ +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 +} diff --git a/server/internal/cluster/types.go b/server/internal/cluster/types.go new file mode 100644 index 0000000..d712935 --- /dev/null +++ b/server/internal/cluster/types.go @@ -0,0 +1,64 @@ +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"` +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..b268fa1 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,41 @@ +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) +} diff --git a/server/internal/deps/checker.go b/server/internal/deps/checker.go new file mode 100644 index 0000000..3fce52c --- /dev/null +++ b/server/internal/deps/checker.go @@ -0,0 +1,73 @@ +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) + } + } +} diff --git a/server/internal/device/manager.go b/server/internal/device/manager.go new file mode 100644 index 0000000..c1f16fd --- /dev/null +++ b/server/internal/device/manager.go @@ -0,0 +1,173 @@ +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 +} diff --git a/server/internal/device/manager_test.go b/server/internal/device/manager_test.go new file mode 100644 index 0000000..a4b4e9d --- /dev/null +++ b/server/internal/device/manager_test.go @@ -0,0 +1,93 @@ +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") + } +} diff --git a/server/internal/device/registry.go b/server/internal/device/registry.go new file mode 100644 index 0000000..21ccbc2 --- /dev/null +++ b/server/internal/device/registry.go @@ -0,0 +1,20 @@ +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) +} diff --git a/server/internal/device/session.go b/server/internal/device/session.go new file mode 100644 index 0000000..7695448 --- /dev/null +++ b/server/internal/device/session.go @@ -0,0 +1,15 @@ +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} +} diff --git a/server/internal/device/types.go b/server/internal/device/types.go new file mode 100644 index 0000000..3af9ffa --- /dev/null +++ b/server/internal/device/types.go @@ -0,0 +1,8 @@ +package device + +import "edge-ai-platform/internal/driver" + +type DeviceEvent struct { + Event string `json:"event"` + Device driver.DeviceInfo `json:"device"` +} diff --git a/server/internal/driver/interface.go b/server/internal/driver/interface.go new file mode 100644 index 0000000..1a1ea9f --- /dev/null +++ b/server/internal/driver/interface.go @@ -0,0 +1,90 @@ +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"` +} diff --git a/server/internal/driver/kneron/detector.go b/server/internal/driver/kneron/detector.go new file mode 100644 index 0000000..69103bc --- /dev/null +++ b/server/internal/driver/kneron/detector.go @@ -0,0 +1,192 @@ +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) +} diff --git a/server/internal/driver/kneron/kl720_driver.go b/server/internal/driver/kneron/kl720_driver.go new file mode 100644 index 0000000..1442315 --- /dev/null +++ b/server/internal/driver/kneron/kl720_driver.go @@ -0,0 +1,669 @@ +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 +} diff --git a/server/internal/driver/mock/mock_driver.go b/server/internal/driver/mock/mock_driver.go new file mode 100644 index 0000000..ba840e2 --- /dev/null +++ b/server/internal/driver/mock/mock_driver.go @@ -0,0 +1,183 @@ +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 +} diff --git a/server/internal/flash/progress.go b/server/internal/flash/progress.go new file mode 100644 index 0000000..f452313 --- /dev/null +++ b/server/internal/flash/progress.go @@ -0,0 +1,51 @@ +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) +} diff --git a/server/internal/flash/service.go b/server/internal/flash/service.go new file mode 100644 index 0000000..8e972c9 --- /dev/null +++ b/server/internal/flash/service.go @@ -0,0 +1,140 @@ +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 +} diff --git a/server/internal/inference/service.go b/server/internal/inference/service.go new file mode 100644 index 0000000..bb0fd21 --- /dev/null +++ b/server/internal/inference/service.go @@ -0,0 +1,94 @@ +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 +} diff --git a/server/internal/model/repository.go b/server/internal/model/repository.go new file mode 100644 index 0000000..37c7fae --- /dev/null +++ b/server/internal/model/repository.go @@ -0,0 +1,100 @@ +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) +} diff --git a/server/internal/model/repository_test.go b/server/internal/model/repository_test.go new file mode 100644 index 0000000..ae20e59 --- /dev/null +++ b/server/internal/model/repository_test.go @@ -0,0 +1,122 @@ +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) + } + }) + } +} diff --git a/server/internal/model/store.go b/server/internal/model/store.go new file mode 100644 index 0000000..dc436b2 --- /dev/null +++ b/server/internal/model/store.go @@ -0,0 +1,102 @@ +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 +} diff --git a/server/internal/model/types.go b/server/internal/model/types.go new file mode 100644 index 0000000..47d1374 --- /dev/null +++ b/server/internal/model/types.go @@ -0,0 +1,65 @@ +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, + } +} diff --git a/server/internal/relay/server.go b/server/internal/relay/server.go new file mode 100644 index 0000000..e451a3b --- /dev/null +++ b/server/internal/relay/server.go @@ -0,0 +1,327 @@ +// 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) +} diff --git a/server/internal/tunnel/client.go b/server/internal/tunnel/client.go new file mode 100644 index 0000000..6d7274b --- /dev/null +++ b/server/internal/tunnel/client.go @@ -0,0 +1,242 @@ +// 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 +} diff --git a/server/internal/update/checker.go b/server/internal/update/checker.go new file mode 100644 index 0000000..a646fe6 --- /dev/null +++ b/server/internal/update/checker.go @@ -0,0 +1,83 @@ +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] + "..." +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..931ac9a --- /dev/null +++ b/server/main.go @@ -0,0 +1,298 @@ +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() +} diff --git a/server/pkg/hwid/hwid.go b/server/pkg/hwid/hwid.go new file mode 100644 index 0000000..c40771a --- /dev/null +++ b/server/pkg/hwid/hwid.go @@ -0,0 +1,39 @@ +// 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] +} diff --git a/server/pkg/logger/broadcaster.go b/server/pkg/logger/broadcaster.go new file mode 100644 index 0000000..c8a6237 --- /dev/null +++ b/server/pkg/logger/broadcaster.go @@ -0,0 +1,73 @@ +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 +} diff --git a/server/pkg/logger/logger.go b/server/pkg/logger/logger.go new file mode 100644 index 0000000..15fb750 --- /dev/null +++ b/server/pkg/logger/logger.go @@ -0,0 +1,66 @@ +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...)) + } + } +} diff --git a/server/pkg/wsconn/wsconn.go b/server/pkg/wsconn/wsconn.go new file mode 100644 index 0000000..4a98a43 --- /dev/null +++ b/server/pkg/wsconn/wsconn.go @@ -0,0 +1,89 @@ +// 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) +} diff --git a/server/scripts/__pycache__/kneron_bridge.cpython-39.pyc b/server/scripts/__pycache__/kneron_bridge.cpython-39.pyc new file mode 100644 index 0000000..f48b3c5 Binary files /dev/null and b/server/scripts/__pycache__/kneron_bridge.cpython-39.pyc differ diff --git a/server/scripts/firmware/KL520/fw_ncpu.bin b/server/scripts/firmware/KL520/fw_ncpu.bin new file mode 100644 index 0000000..10cd08b Binary files /dev/null and b/server/scripts/firmware/KL520/fw_ncpu.bin differ diff --git a/server/scripts/firmware/KL520/fw_scpu.bin b/server/scripts/firmware/KL520/fw_scpu.bin new file mode 100644 index 0000000..482315a Binary files /dev/null and b/server/scripts/firmware/KL520/fw_scpu.bin differ diff --git a/server/scripts/firmware/KL720/fw_ncpu.bin b/server/scripts/firmware/KL720/fw_ncpu.bin new file mode 100644 index 0000000..815530e Binary files /dev/null and b/server/scripts/firmware/KL720/fw_ncpu.bin differ diff --git a/server/scripts/firmware/KL720/fw_scpu.bin b/server/scripts/firmware/KL720/fw_scpu.bin new file mode 100644 index 0000000..82f23e2 Binary files /dev/null and b/server/scripts/firmware/KL720/fw_scpu.bin differ diff --git a/server/scripts/kneron_bridge.py b/server/scripts/kneron_bridge.py new file mode 100644 index 0000000..7f1cea2 --- /dev/null +++ b/server/scripts/kneron_bridge.py @@ -0,0 +1,977 @@ +#!/usr/bin/env python3 +"""Kneron Bridge - JSON-RPC over stdin/stdout + +This script acts as a bridge between the Go backend and the Kneron PLUS +Python SDK. It reads JSON commands from stdin and writes JSON responses +to stdout. + +Supports: + - KL520 (USB Boot mode - firmware must be loaded each session) + - KL720 (flash-based - firmware pre-installed, models freely reloadable) +""" +import sys +import json +import base64 +import time +import os +import io + +import numpy as np + +try: + import kp + HAS_KP = True +except ImportError: + HAS_KP = False + +try: + import cv2 + HAS_CV2 = True +except ImportError: + HAS_CV2 = False + +# ── Global state ────────────────────────────────────────────────────── +_device_group = None +_model_id = None +_model_nef = None +_model_input_size = 224 # updated on model load +_model_type = "tiny_yolov3" # updated on model load based on model_id / nef name +_firmware_loaded = False +_device_chip = "KL520" # updated on connect from product_id / device_type + +# COCO 80-class labels +COCO_CLASSES = [ + "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", + "boat", "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", + "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", + "giraffe", "backpack", "umbrella", "handbag", "tie", "suitcase", "frisbee", + "skis", "snowboard", "sports ball", "kite", "baseball bat", "baseball glove", + "skateboard", "surfboard", "tennis racket", "bottle", "wine glass", "cup", + "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange", + "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair", "couch", + "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse", + "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", + "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier", + "toothbrush" +] + +# Anchor boxes per model type (each list entry = one output head) +ANCHORS_TINY_YOLOV3 = [ + [(81, 82), (135, 169), (344, 319)], # 7×7 head (large objects) + [(10, 14), (23, 27), (37, 58)], # 14×14 head (small objects) +] + +# YOLOv5s anchors (Kneron model 20005, no-upsample variant for KL520) +ANCHORS_YOLOV5S = [ + [(116, 90), (156, 198), (373, 326)], # P5/32 (large) + [(30, 61), (62, 45), (59, 119)], # P4/16 (medium) + [(10, 13), (16, 30), (33, 23)], # P3/8 (small) +] + +CONF_THRESHOLD = 0.25 +NMS_IOU_THRESHOLD = 0.45 + +# Known Kneron model IDs → (model_type, input_size) +KNOWN_MODELS = { + # Tiny YOLO v3 (default KL520 model) + 0: ("tiny_yolov3", 224), + # ResNet18 classification (model 20001) + 20001: ("resnet18", 224), + # FCOS DarkNet53s detection (model 20004) + 20004: ("fcos", 512), + # YOLOv5s no-upsample (model 20005) + 20005: ("yolov5s", 640), +} + + +def _log(msg): + """Write log messages to stderr (stdout is reserved for JSON-RPC).""" + print(f"[kneron_bridge] {msg}", file=sys.stderr, flush=True) + + +def _resolve_firmware_paths(chip="KL520"): + """Resolve firmware paths relative to this script's directory.""" + base = os.path.dirname(os.path.abspath(__file__)) + fw_dir = os.path.join(base, "firmware", chip) + scpu = os.path.join(fw_dir, "fw_scpu.bin") + ncpu = os.path.join(fw_dir, "fw_ncpu.bin") + if os.path.exists(scpu) and os.path.exists(ncpu): + return scpu, ncpu + # Fallback: check KNERON_FW_DIR env var + fw_dir = os.environ.get("KNERON_FW_DIR", "") + if fw_dir: + scpu = os.path.join(fw_dir, "fw_scpu.bin") + ncpu = os.path.join(fw_dir, "fw_ncpu.bin") + if os.path.exists(scpu) and os.path.exists(ncpu): + return scpu, ncpu + return None, None + + +def _detect_model_type(model_id, nef_path): + """Detect model type and input size from model ID or .nef filename.""" + global _model_type, _model_input_size + + # Check known model IDs + if model_id in KNOWN_MODELS: + _model_type, _model_input_size = KNOWN_MODELS[model_id] + _log(f"Model type detected by ID {model_id}: {_model_type} ({_model_input_size}x{_model_input_size})") + return + + # Fallback: try to infer from filename + basename = os.path.basename(nef_path).lower() if nef_path else "" + + if "yolov5" in basename: + _model_type = "yolov5s" + # Try to parse input size from filename like w640h640 + _model_input_size = _parse_size_from_name(basename, default=640) + elif "fcos" in basename: + _model_type = "fcos" + _model_input_size = _parse_size_from_name(basename, default=512) + elif "ssd" in basename: + _model_type = "ssd" + _model_input_size = _parse_size_from_name(basename, default=320) + elif "resnet" in basename or "classification" in basename: + _model_type = "resnet18" + _model_input_size = _parse_size_from_name(basename, default=224) + elif "tiny_yolo" in basename or "tinyyolo" in basename: + _model_type = "tiny_yolov3" + _model_input_size = _parse_size_from_name(basename, default=224) + else: + # Default: assume YOLO-like detection + _model_type = "tiny_yolov3" + _model_input_size = 224 + + _log(f"Model type detected by filename '{basename}': {_model_type} ({_model_input_size}x{_model_input_size})") + + +def _parse_size_from_name(name, default=224): + """Extract input size from filename like 'w640h640' or 'w512h512'.""" + import re + m = re.search(r'w(\d+)h(\d+)', name) + if m: + return int(m.group(1)) + return default + + +# ── Post-processing ────────────────────────────────────────────────── + +def _sigmoid(x): + return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500))) + + +def _nms(detections, iou_threshold=NMS_IOU_THRESHOLD): + """Non-Maximum Suppression.""" + detections.sort(key=lambda d: d["confidence"], reverse=True) + keep = [] + for d in detections: + skip = False + for k in keep: + if d["class_id"] != k["class_id"]: + continue + x1 = max(d["bbox"]["x"], k["bbox"]["x"]) + y1 = max(d["bbox"]["y"], k["bbox"]["y"]) + x2 = min(d["bbox"]["x"] + d["bbox"]["width"], + k["bbox"]["x"] + k["bbox"]["width"]) + y2 = min(d["bbox"]["y"] + d["bbox"]["height"], + k["bbox"]["y"] + k["bbox"]["height"]) + inter = max(0, x2 - x1) * max(0, y2 - y1) + a1 = d["bbox"]["width"] * d["bbox"]["height"] + a2 = k["bbox"]["width"] * k["bbox"]["height"] + if inter / (a1 + a2 - inter + 1e-6) > iou_threshold: + skip = True + break + if not skip: + keep.append(d) + return keep + + +def _get_preproc_info(result): + """Extract letterbox padding info from the inference result. + + Kneron SDK applies letterbox resize (aspect-ratio-preserving + zero padding) + before inference. The hw_pre_proc_info tells us how to reverse it. + + Returns (pad_left, pad_top, resize_w, resize_h, model_w, model_h) or None. + """ + try: + info = result.header.hw_pre_proc_info_list[0] + return { + "pad_left": info.pad_left if hasattr(info, 'pad_left') else 0, + "pad_top": info.pad_top if hasattr(info, 'pad_top') else 0, + "resized_w": info.resized_img_width if hasattr(info, 'resized_img_width') else 0, + "resized_h": info.resized_img_height if hasattr(info, 'resized_img_height') else 0, + "model_w": info.model_input_width if hasattr(info, 'model_input_width') else 0, + "model_h": info.model_input_height if hasattr(info, 'model_input_height') else 0, + "img_w": info.img_width if hasattr(info, 'img_width') else 0, + "img_h": info.img_height if hasattr(info, 'img_height') else 0, + } + except Exception: + return None + + +def _correct_bbox_for_letterbox(x, y, w, h, preproc, model_size): + """Remove letterbox padding offset from normalized bbox coordinates. + + Input (x, y, w, h) is in model-input-space normalized to 0-1. + Output is re-normalized to the original image aspect ratio (still 0-1). + + For KP_PADDING_CORNER (default): image is at top-left, padding at bottom/right. + """ + if preproc is None: + return x, y, w, h + + model_w = preproc["model_w"] or model_size + model_h = preproc["model_h"] or model_size + pad_left = preproc["pad_left"] + pad_top = preproc["pad_top"] + resized_w = preproc["resized_w"] or model_w + resized_h = preproc["resized_h"] or model_h + + # If no padding was applied, skip correction + if pad_left == 0 and pad_top == 0 and resized_w == model_w and resized_h == model_h: + return x, y, w, h + + # Convert from normalized (0-1 of model input) to pixel coords in model space + px = x * model_w + py = y * model_h + pw = w * model_w + ph = h * model_h + + # Subtract padding offset + px -= pad_left + py -= pad_top + + # Re-normalize to the resized (un-padded) image dimensions + nx = px / resized_w + ny = py / resized_h + nw = pw / resized_w + nh = ph / resized_h + + # Clip to 0-1 + nx = max(0.0, min(1.0, nx)) + ny = max(0.0, min(1.0, ny)) + nw = min(1.0 - nx, nw) + nh = min(1.0 - ny, nh) + + return nx, ny, nw, nh + + +def _parse_yolo_output(result, anchors, input_size, num_classes=80): + """Parse YOLO (v3/v5) raw output into detection results. + + Works for both Tiny YOLOv3 and YOLOv5 — the tensor layout is the same: + (num_anchors * (5 + num_classes), grid_h, grid_w) + + The key differences are: + - anchor values + - input_size used for anchor normalization + - number of output heads + + Bounding boxes are corrected for letterbox padding so coordinates + are relative to the original image (normalized 0-1). + """ + detections = [] + entry_size = 5 + num_classes # 85 for COCO 80 classes + + # Get letterbox padding info + preproc = _get_preproc_info(result) + if preproc: + _log(f"Preproc info: pad=({preproc['pad_left']},{preproc['pad_top']}), " + f"resized=({preproc['resized_w']}x{preproc['resized_h']}), " + f"model=({preproc['model_w']}x{preproc['model_h']}), " + f"img=({preproc['img_w']}x{preproc['img_h']})") + + for head_idx in range(result.header.num_output_node): + output = kp.inference.generic_inference_retrieve_float_node( + node_idx=head_idx, + generic_raw_result=result, + channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW + ) + arr = output.ndarray[0] # (C, H, W) + channels, grid_h, grid_w = arr.shape + + # Determine number of anchors for this head + num_anchors = channels // entry_size + if num_anchors < 1: + _log(f"Head {head_idx}: unexpected shape {arr.shape}, skipping") + continue + + # Use the correct anchor set for this head + if head_idx < len(anchors): + head_anchors = anchors[head_idx] + else: + _log(f"Head {head_idx}: no anchors defined, skipping") + continue + + for a_idx in range(min(num_anchors, len(head_anchors))): + off = a_idx * entry_size + for cy in range(grid_h): + for cx in range(grid_w): + obj_conf = _sigmoid(arr[off + 4, cy, cx]) + if obj_conf < CONF_THRESHOLD: + continue + + cls_scores = _sigmoid(arr[off + 5:off + entry_size, cy, cx]) + cls_id = int(np.argmax(cls_scores)) + cls_conf = float(cls_scores[cls_id]) + conf = float(obj_conf * cls_conf) + + if conf < CONF_THRESHOLD: + continue + + bx = (_sigmoid(arr[off, cy, cx]) + cx) / grid_w + by = (_sigmoid(arr[off + 1, cy, cx]) + cy) / grid_h + aw, ah = head_anchors[a_idx] + bw = (np.exp(min(float(arr[off + 2, cy, cx]), 10)) * aw) / input_size + bh = (np.exp(min(float(arr[off + 3, cy, cx]), 10)) * ah) / input_size + + # Convert center x,y,w,h to corner x,y,w,h (normalized to model input) + x = max(0.0, bx - bw / 2) + y = max(0.0, by - bh / 2) + w = min(1.0, bx + bw / 2) - x + h = min(1.0, by + bh / 2) - y + + # Correct for letterbox padding + x, y, w, h = _correct_bbox_for_letterbox(x, y, w, h, preproc, input_size) + + label = COCO_CLASSES[cls_id] if cls_id < len(COCO_CLASSES) else f"class_{cls_id}" + detections.append({ + "label": label, + "class_id": cls_id, + "confidence": conf, + "bbox": {"x": x, "y": y, "width": w, "height": h}, + }) + + detections = _nms(detections) + + # Remove internal class_id before returning + for d in detections: + del d["class_id"] + + return detections + + +def _parse_ssd_output(result, input_size=320, num_classes=2): + """Parse SSD face detection output. + + SSD typically outputs two tensors: + - locations: (num_boxes, 4) — bounding box coordinates + - confidences: (num_boxes, num_classes) — class scores + + For the KL520 SSD face detection model (kl520_ssd_fd_lm.nef), + the output contains face detections with landmarks. + """ + detections = [] + preproc = _get_preproc_info(result) + + try: + # Retrieve all output nodes + num_outputs = result.header.num_output_node + outputs = [] + for i in range(num_outputs): + output = kp.inference.generic_inference_retrieve_float_node( + node_idx=i, + generic_raw_result=result, + channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW + ) + outputs.append(output.ndarray[0]) + + if num_outputs < 2: + _log(f"SSD: expected >=2 output nodes, got {num_outputs}") + return detections + + # Heuristic: the larger tensor is locations, smaller is confidences + # Or: first output = locations, second = confidences + locations = outputs[0] + confidences = outputs[1] + + # Flatten if needed + if locations.ndim > 2: + locations = locations.reshape(-1, 4) + if confidences.ndim > 2: + confidences = confidences.reshape(-1, confidences.shape[-1]) + + num_boxes = min(locations.shape[0], confidences.shape[0]) + + for i in range(num_boxes): + # SSD confidence: class 0 = background, class 1 = face + if confidences.shape[-1] > 1: + conf = float(confidences[i, 1]) # face class + else: + conf = float(_sigmoid(confidences[i, 0])) + + if conf < CONF_THRESHOLD: + continue + + # SSD outputs are typically [x_min, y_min, x_max, y_max] normalized + x_min = float(np.clip(locations[i, 0], 0.0, 1.0)) + y_min = float(np.clip(locations[i, 1], 0.0, 1.0)) + x_max = float(np.clip(locations[i, 2], 0.0, 1.0)) + y_max = float(np.clip(locations[i, 3], 0.0, 1.0)) + + w = x_max - x_min + h = y_max - y_min + if w <= 0 or h <= 0: + continue + + # Correct for letterbox padding + x_min, y_min, w, h = _correct_bbox_for_letterbox( + x_min, y_min, w, h, preproc, input_size) + + detections.append({ + "label": "face", + "class_id": 0, + "confidence": conf, + "bbox": {"x": x_min, "y": y_min, "width": w, "height": h}, + }) + + detections = _nms(detections) + for d in detections: + del d["class_id"] + + except Exception as e: + _log(f"SSD parse error: {e}") + + return detections + + +def _parse_fcos_output(result, input_size=512, num_classes=80): + """Parse FCOS (Fully Convolutional One-Stage) detection output. + + FCOS outputs per feature level: + - classification: (num_classes, H, W) + - centerness: (1, H, W) + - regression: (4, H, W) — distances from each pixel to box edges (l, t, r, b) + + The outputs come in groups of 3 per feature level. + """ + detections = [] + preproc = _get_preproc_info(result) + + try: + num_outputs = result.header.num_output_node + outputs = [] + for i in range(num_outputs): + output = kp.inference.generic_inference_retrieve_float_node( + node_idx=i, + generic_raw_result=result, + channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW + ) + outputs.append(output.ndarray[0]) + + # FCOS typically has 5 feature levels × 3 outputs = 15 output nodes + # Or fewer for simplified models. Group by 3: (cls, centerness, reg) + # If we can't determine the grouping, try a simpler approach. + strides = [8, 16, 32, 64, 128] + num_levels = num_outputs // 3 + + for level in range(num_levels): + cls_out = outputs[level * 3] # (num_classes, H, W) + cnt_out = outputs[level * 3 + 1] # (1, H, W) + reg_out = outputs[level * 3 + 2] # (4, H, W) + + stride = strides[level] if level < len(strides) else (8 * (2 ** level)) + h, w = cls_out.shape[1], cls_out.shape[2] + + for cy in range(h): + for cx in range(w): + cls_scores = _sigmoid(cls_out[:, cy, cx]) + cls_id = int(np.argmax(cls_scores)) + cls_conf = float(cls_scores[cls_id]) + centerness = float(_sigmoid(cnt_out[0, cy, cx])) + conf = cls_conf * centerness + + if conf < CONF_THRESHOLD: + continue + + # Regression: distances from pixel center to box edges + px = (cx + 0.5) * stride + py = (cy + 0.5) * stride + l = float(np.exp(min(reg_out[0, cy, cx], 10))) * stride + t = float(np.exp(min(reg_out[1, cy, cx], 10))) * stride + r = float(np.exp(min(reg_out[2, cy, cx], 10))) * stride + b = float(np.exp(min(reg_out[3, cy, cx], 10))) * stride + + x_min = max(0.0, (px - l) / input_size) + y_min = max(0.0, (py - t) / input_size) + x_max = min(1.0, (px + r) / input_size) + y_max = min(1.0, (py + b) / input_size) + + bw = x_max - x_min + bh = y_max - y_min + if bw <= 0 or bh <= 0: + continue + + # Correct for letterbox padding + x_min, y_min, bw, bh = _correct_bbox_for_letterbox( + x_min, y_min, bw, bh, preproc, input_size) + + label = COCO_CLASSES[cls_id] if cls_id < len(COCO_CLASSES) else f"class_{cls_id}" + detections.append({ + "label": label, + "class_id": cls_id, + "confidence": conf, + "bbox": {"x": x_min, "y": y_min, "width": bw, "height": bh}, + }) + + detections = _nms(detections) + for d in detections: + del d["class_id"] + + except Exception as e: + _log(f"FCOS parse error: {e}") + + return detections + + +def _parse_classification_output(result, num_classes=1000): + """Parse classification model output (e.g., ResNet18 ImageNet).""" + try: + output = kp.inference.generic_inference_retrieve_float_node( + node_idx=0, + generic_raw_result=result, + channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW + ) + scores = output.ndarray.flatten() + + # Apply softmax + exp_scores = np.exp(scores - np.max(scores)) + probs = exp_scores / exp_scores.sum() + + # Top-5 + top_indices = np.argsort(probs)[::-1][:5] + classifications = [] + for idx in top_indices: + label = COCO_CLASSES[idx] if idx < len(COCO_CLASSES) else f"class_{idx}" + classifications.append({ + "label": label, + "confidence": float(probs[idx]), + }) + + return classifications + + except Exception as e: + _log(f"Classification parse error: {e}") + return [] + + +# ── Command handlers ───────────────────────────────────────────────── + +def handle_scan(): + """Scan for connected Kneron devices.""" + if not HAS_KP: + return {"devices": [], "error_detail": "kp module not available"} + + try: + descs = kp.core.scan_devices() + devices = [] + for i in range(descs.device_descriptor_number): + dev = descs.device_descriptor_list[i] + devices.append({ + "port": str(dev.usb_port_id), + "firmware": str(dev.firmware), + "kn_number": f"0x{dev.kn_number:08X}", + "product_id": f"0x{dev.product_id:04X}", + "connectable": dev.is_connectable, + }) + return {"devices": devices} + except Exception as e: + return {"devices": [], "error_detail": str(e)} + + +def handle_connect(params): + """Connect to a Kneron device and load firmware if needed. + + KL520: USB Boot mode — firmware MUST be uploaded every session. + KL720 (KDP2, pid=0x0720): Flash-based — firmware pre-installed. + KL720 (KDP legacy, pid=0x0200): Old firmware — needs connect_without_check + + firmware load to RAM before normal operation. + """ + global _device_group, _firmware_loaded, _device_chip + + if not HAS_KP: + return {"error": "kp module not available"} + + try: + port = params.get("port", "") + device_type = params.get("device_type", "") + + # Scan to find device + descs = kp.core.scan_devices() + if descs.device_descriptor_number == 0: + return {"error": "no Kneron device found"} + + # Find device by port or use first one + target_dev = None + for i in range(descs.device_descriptor_number): + dev = descs.device_descriptor_list[i] + if port and str(dev.usb_port_id) == port: + target_dev = dev + break + if target_dev is None: + target_dev = descs.device_descriptor_list[0] + + if not target_dev.is_connectable: + return {"error": "device is not connectable"} + + # Determine chip type from device_type param or product_id + pid = target_dev.product_id + if "kl720" in device_type.lower(): + _device_chip = "KL720" + elif "kl520" in device_type.lower(): + _device_chip = "KL520" + elif pid in (0x0200, 0x0720): + _device_chip = "KL720" + else: + _device_chip = "KL520" + + fw_str = str(target_dev.firmware) + is_kdp_legacy = (_device_chip == "KL720" and pid == 0x0200) + + _log(f"Chip type: {_device_chip} (product_id=0x{pid:04X}, device_type={device_type}, fw={fw_str})") + + # ── KL720 KDP Legacy (pid=0x0200): old firmware, incompatible with SDK ── + if is_kdp_legacy: + _log(f"KL720 has legacy KDP firmware (pid=0x0200). Using connect_devices_without_check...") + _device_group = kp.core.connect_devices_without_check( + usb_port_ids=[target_dev.usb_port_id] + ) + kp.core.set_timeout(device_group=_device_group, milliseconds=60000) + + # Load KDP2 firmware to RAM so the device can operate with this SDK + scpu_path, ncpu_path = _resolve_firmware_paths("KL720") + if scpu_path and ncpu_path: + _log(f"KL720: Loading KDP2 firmware to RAM: {scpu_path}") + kp.core.load_firmware_from_file( + _device_group, scpu_path, ncpu_path + ) + _firmware_loaded = True + _log("KL720: Firmware loaded to RAM, waiting for reboot...") + time.sleep(5) + + # Reconnect — device should now be running KDP2 in RAM + descs = kp.core.scan_devices() + reconnected = False + for i in range(descs.device_descriptor_number): + dev = descs.device_descriptor_list[i] + if dev.product_id in (0x0200, 0x0720): + target_dev = dev + reconnected = True + break + if not reconnected: + return {"error": "KL720 not found after firmware load. Unplug and re-plug."} + + # Try normal connect first, fallback to without_check + try: + _device_group = kp.core.connect_devices( + usb_port_ids=[target_dev.usb_port_id] + ) + except Exception as conn_err: + _log(f"KL720: Normal reconnect failed ({conn_err}), using without_check...") + _device_group = kp.core.connect_devices_without_check( + usb_port_ids=[target_dev.usb_port_id] + ) + kp.core.set_timeout(device_group=_device_group, milliseconds=10000) + fw_str = str(target_dev.firmware) + _log(f"KL720: Reconnected after firmware load, pid=0x{target_dev.product_id:04X}, fw={fw_str}") + else: + _log("WARNING: KL720 firmware files not found. Cannot operate with KDP legacy device.") + _device_group = None + return {"error": "KL720 has legacy KDP firmware but KDP2 firmware files not found. " + "Run update_kl720_firmware.py to flash KDP2 permanently."} + + return { + "status": "connected", + "firmware": fw_str, + "kn_number": f"0x{target_dev.kn_number:08X}", + "chip": _device_chip, + "kdp_legacy": True, + } + + # ── Normal connection (KL520 or KL720 KDP2) ── + # KL720 KDP2: connect_devices() often fails with Error 28. Using it + # before connect_devices_without_check() corrupts SDK internal state + # and causes SIGSEGV. Go directly to connect_devices_without_check() + # for KL720 to avoid the crash. + if _device_chip == "KL720": + _log(f"KL720: Using connect_devices_without_check(usb_port_id={target_dev.usb_port_id})...") + _device_group = kp.core.connect_devices_without_check( + usb_port_ids=[target_dev.usb_port_id] + ) + _log(f"connect_devices_without_check succeeded") + else: + _log(f"Calling kp.core.connect_devices(usb_port_id={target_dev.usb_port_id})...") + _device_group = kp.core.connect_devices( + usb_port_ids=[target_dev.usb_port_id] + ) + _log(f"connect_devices succeeded") + + # KL720 needs longer timeout for large NEF transfers (12MB+ over USB) + _timeout_ms = 60000 if _device_chip == "KL720" else 10000 + _log(f"Calling set_timeout(milliseconds={_timeout_ms})...") + kp.core.set_timeout(device_group=_device_group, milliseconds=_timeout_ms) + _log(f"set_timeout succeeded") + + # Firmware handling — chip-dependent + if "Loader" in fw_str: + # Device is in USB Boot (Loader) mode and needs firmware + if _device_chip == "KL720": + _log(f"WARNING: {_device_chip} is in Loader mode (unusual). Attempting firmware load...") + scpu_path, ncpu_path = _resolve_firmware_paths(_device_chip) + if scpu_path and ncpu_path: + _log(f"{_device_chip}: Loading firmware: {scpu_path}") + kp.core.load_firmware_from_file( + _device_group, scpu_path, ncpu_path + ) + _firmware_loaded = True + _log("Firmware loaded, waiting for reboot...") + time.sleep(5) + + # Reconnect after firmware load + descs = kp.core.scan_devices() + target_dev = descs.device_descriptor_list[0] + _device_group = kp.core.connect_devices( + usb_port_ids=[target_dev.usb_port_id] + ) + kp.core.set_timeout( + device_group=_device_group, milliseconds=_timeout_ms + ) + fw_str = str(target_dev.firmware) + _log(f"Reconnected after firmware load, firmware: {fw_str}") + else: + _log(f"WARNING: {_device_chip} firmware files not found, skipping firmware load") + else: + # Not in Loader mode — firmware already present + _log(f"{_device_chip}: firmware already present (normal). fw={fw_str}") + + return { + "status": "connected", + "firmware": fw_str, + "kn_number": f"0x{target_dev.kn_number:08X}", + "chip": _device_chip, + } + + except Exception as e: + _device_group = None + return {"error": str(e)} + + +def handle_disconnect(params): + """Disconnect from the current device.""" + global _device_group, _model_id, _model_nef, _firmware_loaded + global _model_type, _model_input_size, _device_chip + + _device_group = None + _model_id = None + _model_nef = None + _model_type = "tiny_yolov3" + _model_input_size = 224 + _firmware_loaded = False + _device_chip = "KL520" + + return {"status": "disconnected"} + + +def handle_reset(params): + """Reset the device back to USB Boot (Loader) state. + + This forces the device to drop its firmware and any loaded models. + After reset the device will re-enumerate on USB, so the caller + must wait and issue a fresh 'connect' command. + """ + global _device_group, _model_id, _model_nef, _firmware_loaded + global _model_type, _model_input_size, _device_chip + + if _device_group is None: + return {"error": "device not connected"} + + try: + _log("Resetting device (kp.core.reset_device KP_RESET_REBOOT)...") + kp.core.reset_device( + device_group=_device_group, + reset_mode=kp.ResetMode.KP_RESET_REBOOT, + ) + _log("Device reset command sent successfully") + except Exception as e: + _log(f"reset_device raised: {e}") + # Even if it throws, the device usually does reset. + + # Clear all state — the device is gone until it re-enumerates. + _device_group = None + _model_id = None + _model_nef = None + _model_type = "tiny_yolov3" + _model_input_size = 224 + _firmware_loaded = False + _device_chip = "KL520" + + return {"status": "reset"} + + +def handle_load_model(params): + """Load a model file onto the device. + + KL520 USB Boot mode limitation: only one model can be loaded per + USB session. If error 40 occurs, the error is returned to the Go + driver which handles it by restarting the entire Python bridge. + """ + global _model_id, _model_nef + + if _device_group is None: + return {"error": "device not connected"} + + path = params.get("path", "") + if not path or not os.path.exists(path): + return {"error": f"model file not found: {path}"} + + try: + _model_nef = kp.core.load_model_from_file( + device_group=_device_group, + file_path=path + ) + except Exception as e: + return {"error": str(e)} + + try: + model = _model_nef.models[0] + _model_id = model.id + + # Detect model type and input size + _detect_model_type(_model_id, path) + + _log(f"Model loaded: id={_model_id}, type={_model_type}, " + f"input={_model_input_size}, target={_model_nef.target_chip}") + return { + "status": "loaded", + "model_id": _model_id, + "model_type": _model_type, + "input_size": _model_input_size, + "model_path": path, + "target_chip": str(_model_nef.target_chip), + } + except Exception as e: + return {"error": str(e)} + + +def handle_inference(params): + """Run inference on the provided image data.""" + if _device_group is None: + return {"error": "device not connected"} + if _model_id is None: + return {"error": "no model loaded"} + + image_b64 = params.get("image_base64", "") + + try: + t0 = time.time() + + if image_b64: + # Decode base64 image + img_bytes = base64.b64decode(image_b64) + + if HAS_CV2: + # Decode image with OpenCV + img_array = np.frombuffer(img_bytes, dtype=np.uint8) + img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) + if img is None: + return {"error": "failed to decode image"} + # Convert BGR to BGR565 + img_bgr565 = cv2.cvtColor(src=img, code=cv2.COLOR_BGR2BGR565) + else: + # Fallback: try to use raw bytes (assume RGB565 format) + img_bgr565 = np.frombuffer(img_bytes, dtype=np.uint8) + else: + return {"error": "no image data provided"} + + # Create inference config + inf_config = kp.GenericImageInferenceDescriptor( + model_id=_model_id, + inference_number=0, + input_node_image_list=[ + kp.GenericInputNodeImage( + image=img_bgr565, + image_format=kp.ImageFormat.KP_IMAGE_FORMAT_RGB565, + ) + ] + ) + + # Send and receive + kp.inference.generic_image_inference_send(_device_group, inf_config) + result = kp.inference.generic_image_inference_receive(_device_group) + + elapsed_ms = (time.time() - t0) * 1000 + + # Parse output based on model type + detections = [] + classifications = [] + task_type = "detection" + + if _model_type == "resnet18": + task_type = "classification" + classifications = _parse_classification_output(result) + elif _model_type == "ssd": + detections = _parse_ssd_output(result, input_size=_model_input_size) + elif _model_type == "fcos": + detections = _parse_fcos_output(result, input_size=_model_input_size) + elif _model_type == "yolov5s": + detections = _parse_yolo_output( + result, + anchors=ANCHORS_YOLOV5S, + input_size=_model_input_size, + ) + else: + # Default: Tiny YOLOv3 + detections = _parse_yolo_output( + result, + anchors=ANCHORS_TINY_YOLOV3, + input_size=_model_input_size, + ) + + return { + "taskType": task_type, + "timestamp": int(time.time() * 1000), + "latencyMs": round(elapsed_ms, 1), + "detections": detections, + "classifications": classifications, + } + + except Exception as e: + return {"error": str(e)} + + +# ── Main loop ──────────────────────────────────────────────────────── + +def main(): + """Main loop: read JSON commands from stdin, write responses to stdout.""" + # Signal readiness + print(json.dumps({"status": "ready"}), flush=True) + _log(f"Bridge started (kp={'yes' if HAS_KP else 'no'}, cv2={'yes' if HAS_CV2 else 'no'})") + + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + cmd = json.loads(line) + action = cmd.get("cmd", "") + if action == "scan": + result = handle_scan() + elif action == "connect": + result = handle_connect(cmd) + elif action == "disconnect": + result = handle_disconnect(cmd) + elif action == "reset": + result = handle_reset(cmd) + elif action == "load_model": + result = handle_load_model(cmd) + elif action == "inference": + result = handle_inference(cmd) + else: + result = {"error": f"unknown command: {action}"} + print(json.dumps(result), flush=True) + except Exception as e: + print(json.dumps({"error": str(e)}), flush=True) + + +if __name__ == "__main__": + main() diff --git a/server/scripts/requirements.txt b/server/scripts/requirements.txt new file mode 100644 index 0000000..483f368 --- /dev/null +++ b/server/scripts/requirements.txt @@ -0,0 +1,3 @@ +numpy +opencv-python-headless +pyusb diff --git a/server/scripts/update_kl720_firmware.py b/server/scripts/update_kl720_firmware.py new file mode 100644 index 0000000..f7d8866 --- /dev/null +++ b/server/scripts/update_kl720_firmware.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Update KL720 firmware from KDP (legacy) to KDP2. + +KL720 devices with old KDP firmware (product_id=0x0200) are incompatible +with Kneron PLUS SDK v3.1.2. This script uses the DFUT magic bypass to +connect to KDP devices and flash KDP2 firmware to the device's SPI flash. + +After a successful update the device will enumerate as product_id=0x0720 +instead of 0x0200. + +Usage: + arch -x86_64 /tmp/kneron_venv39/bin/python3 update_kl720_firmware.py + +Firmware files expected at: + scripts/firmware/KL720/fw_scpu.bin + scripts/firmware/KL720/fw_ncpu.bin +""" +import ctypes +import os +import sys +import time + +try: + import kp + from kp.KPWrapper import KPWrapper +except ImportError: + print("ERROR: kp module not available. Run with the Kneron Python venv.") + sys.exit(1) + +# DFUT magic value that bypasses firmware version check in kp_connect_devices +KDP_MAGIC_CONNECTION_PASS = 0x1FF55B4F + + +def _get_lib(): + """Get the native libkplus shared library handle.""" + return KPWrapper().LIB + + +def _connect_kdp_device(lib, port_id): + """Connect to a KDP (legacy) device using the DFUT magic bypass.""" + port_ids = (ctypes.c_int * 1)(port_id) + status = ctypes.c_int(KDP_MAGIC_CONNECTION_PASS) + + dg = lib.kp_connect_devices( + 1, + ctypes.cast(port_ids, ctypes.POINTER(ctypes.c_int)), + ctypes.byref(status), + ) + + if dg is None or dg == 0: + raise RuntimeError(f"kp_connect_devices failed, status={status.value}") + + return dg + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + fw_dir = os.path.join(script_dir, "firmware", "KL720") + scpu_path = os.path.join(fw_dir, "fw_scpu.bin") + ncpu_path = os.path.join(fw_dir, "fw_ncpu.bin") + + if not os.path.exists(scpu_path) or not os.path.exists(ncpu_path): + print(f"ERROR: Firmware files not found in {fw_dir}") + sys.exit(1) + + print(f"SCPU firmware: {scpu_path} ({os.path.getsize(scpu_path)} bytes)") + print(f"NCPU firmware: {ncpu_path} ({os.path.getsize(ncpu_path)} bytes)") + + lib = _get_lib() + + # ── Step 1: Scan ── + print("\n[1/5] Scanning for Kneron devices...") + descs = kp.core.scan_devices() + if descs.device_descriptor_number == 0: + print("ERROR: No Kneron device found. Check USB connection.") + sys.exit(1) + + kl720_dev = None + for i in range(descs.device_descriptor_number): + dev = descs.device_descriptor_list[i] + pid_hex = f"0x{dev.product_id:04X}" + print(f" Device {i}: port={dev.usb_port_id}, product_id={pid_hex}, " + f"firmware={dev.firmware}, connectable={dev.is_connectable}") + if dev.product_id in (0x0200, 0x0720): + kl720_dev = dev + + if kl720_dev is None: + print("ERROR: No KL720 device found (product_id 0x0200 or 0x0720).") + sys.exit(1) + + port_id = kl720_dev.usb_port_id + pid = kl720_dev.product_id + fw_str = str(kl720_dev.firmware) + print(f"\n Target: port={port_id}, product_id=0x{pid:04X}, firmware={fw_str}") + + if pid == 0x0720: + print("\nDevice already has KDP2 firmware (product_id=0x0720). No update needed.") + sys.exit(0) + + # ── Step 2: Connect with DFUT magic bypass ── + print("\n[2/5] Connecting to KL720 KDP device (magic bypass)...") + dg = _connect_kdp_device(lib, port_id) + lib.kp_set_timeout(ctypes.c_void_p(dg), 20000) + print(" Connected successfully.") + + # ── Step 3: Flash SCPU firmware ── + print("\n[3/5] Flashing SCPU firmware to device flash...") + print(" Do NOT unplug the device!") + + lib.kp_update_kdp_firmware_from_files.restype = ctypes.c_int + lib.kp_update_kdp_firmware_from_files.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_bool + ] + + ret = lib.kp_update_kdp_firmware_from_files( + ctypes.c_void_p(dg), + scpu_path.encode("utf-8"), + None, # NCPU later + False, # don't auto-reboot yet + ) + if ret != 0: + print(f" ERROR: SCPU flash failed with code {ret}") + sys.exit(1) + print(" SCPU flashed. Rebooting device...") + + lib.kp_reset_device(ctypes.c_void_p(dg), 0) # KP_RESET_REBOOT = 0 + time.sleep(3) + lib.kp_disconnect_devices(ctypes.c_void_p(dg)) + time.sleep(3) + + # ── Step 4: Reconnect and flash NCPU firmware ── + print("\n[4/5] Reconnecting and flashing NCPU firmware...") + + # Re-scan to get the updated port_id (may change after reboot) + descs = kp.core.scan_devices() + for i in range(descs.device_descriptor_number): + dev = descs.device_descriptor_list[i] + if dev.product_id in (0x0200, 0x0720): + port_id = dev.usb_port_id + print(f" Found: port={port_id}, product_id=0x{dev.product_id:04X}, firmware={dev.firmware}") + break + else: + print(" ERROR: KL720 not found after SCPU flash. Try unplugging and re-plugging.") + sys.exit(1) + + dg = _connect_kdp_device(lib, port_id) + lib.kp_set_timeout(ctypes.c_void_p(dg), 20000) + + ret = lib.kp_update_kdp_firmware_from_files( + ctypes.c_void_p(dg), + None, # SCPU already done + ncpu_path.encode("utf-8"), + False, + ) + if ret != 0: + print(f" ERROR: NCPU flash failed with code {ret}") + sys.exit(1) + print(" NCPU flashed. Final reboot...") + + lib.kp_reset_device(ctypes.c_void_p(dg), 0) + time.sleep(3) + lib.kp_disconnect_devices(ctypes.c_void_p(dg)) + time.sleep(5) + + # ── Step 5: Verify ── + print("\n[5/5] Verifying firmware update...") + descs = kp.core.scan_devices() + if descs.device_descriptor_number == 0: + print(" WARNING: No device found. Unplug and re-plug, then run scan.") + sys.exit(1) + + for i in range(descs.device_descriptor_number): + dev = descs.device_descriptor_list[i] + if dev.product_id in (0x0200, 0x0720): + pid_hex = f"0x{dev.product_id:04X}" + print(f" KL720: port={dev.usb_port_id}, product_id={pid_hex}, firmware={dev.firmware}") + if dev.product_id == 0x0720: + print("\n SUCCESS! KL720 firmware updated to KDP2.") + print(" Product ID changed from 0x0200 → 0x0720.") + print(" Device is now compatible with Kneron PLUS SDK v3.1.2.") + else: + print("\n WARNING: Device still shows product_id=0x0200.") + print(" Try unplugging and re-plugging, then scan again.") + return + + print(" WARNING: KL720 not found. Unplug and re-plug the device.") + + +if __name__ == "__main__": + main() diff --git a/server/tray/assets/icon_running.png b/server/tray/assets/icon_running.png new file mode 100644 index 0000000..3bbcecf Binary files /dev/null and b/server/tray/assets/icon_running.png differ diff --git a/server/tray/assets/icon_stopped.png b/server/tray/assets/icon_stopped.png new file mode 100644 index 0000000..28a1026 Binary files /dev/null and b/server/tray/assets/icon_stopped.png differ diff --git a/server/tray/config.go b/server/tray/config.go new file mode 100644 index 0000000..fb167b1 --- /dev/null +++ b/server/tray/config.go @@ -0,0 +1,97 @@ +package tray + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" +) + +// Config holds the shared configuration for both the installer and the tray launcher. +type Config struct { + Version int `json:"version"` + Server ServerConfig `json:"server"` + Relay RelayConfig `json:"relay"` + Launcher LauncherConfig `json:"launcher"` +} + +type ServerConfig struct { + Port int `json:"port"` + Host string `json:"host"` +} + +type RelayConfig struct { + URL string `json:"url"` + Token string `json:"token"` +} + +type LauncherConfig struct { + AutoStart bool `json:"autoStart"` + Language string `json:"language"` +} + +// DefaultConfig returns a config with sensible defaults. +func DefaultConfig() *Config { + return &Config{ + Version: 1, + Server: ServerConfig{Port: 3721, Host: "127.0.0.1"}, + Relay: RelayConfig{}, + Launcher: LauncherConfig{AutoStart: true, Language: "en"}, + } +} + +// ConfigDir returns the platform-specific configuration directory. +func ConfigDir() string { + if runtime.GOOS == "windows" { + return filepath.Join(os.Getenv("LOCALAPPDATA"), "EdgeAIPlatform") + } + home, err := os.UserHomeDir() + if err != nil { + return ".edge-ai-platform" + } + return filepath.Join(home, ".edge-ai-platform") +} + +// ConfigPath returns the full path to config.json. +func ConfigPath() string { + return filepath.Join(ConfigDir(), "config.json") +} + +// LoadConfig reads the config from disk. If the file does not exist or is +// invalid, it returns the default config. +func LoadConfig() *Config { + data, err := os.ReadFile(ConfigPath()) + if err != nil { + return DefaultConfig() + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return DefaultConfig() + } + // Ensure sensible defaults for zero values. + if cfg.Server.Port == 0 { + cfg.Server.Port = 3721 + } + if cfg.Server.Host == "" { + cfg.Server.Host = "127.0.0.1" + } + return &cfg +} + +// Save writes the config to disk atomically. +func (c *Config) Save() error { + dir := ConfigDir() + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + // Atomic write: write to temp file then rename. + tmp := ConfigPath() + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, ConfigPath()) +} diff --git a/server/tray/icons.go b/server/tray/icons.go new file mode 100644 index 0000000..89dff73 --- /dev/null +++ b/server/tray/icons.go @@ -0,0 +1,9 @@ +package tray + +import _ "embed" + +//go:embed assets/icon_running.png +var iconRunning []byte + +//go:embed assets/icon_stopped.png +var iconStopped []byte diff --git a/server/tray/stub.go b/server/tray/stub.go new file mode 100644 index 0000000..063c916 --- /dev/null +++ b/server/tray/stub.go @@ -0,0 +1,14 @@ +//go:build notray + +package tray + +import ( + "fmt" + "os" +) + +// Run prints an error and exits when the binary was built without tray support. +func Run(_ *Config) { + fmt.Fprintln(os.Stderr, "Tray mode is not available in this build. Rebuild with CGO_ENABLED=1 (without the notray tag).") + os.Exit(1) +} diff --git a/server/tray/tray.go b/server/tray/tray.go new file mode 100644 index 0000000..2256883 --- /dev/null +++ b/server/tray/tray.go @@ -0,0 +1,255 @@ +//go:build !notray + +package tray + +import ( + "fmt" + "log" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "syscall" + + "fyne.io/systray" +) + +// TrayApp manages the system tray icon and the server child process. +type TrayApp struct { + mu sync.Mutex + cmd *exec.Cmd + running bool + cfg *Config + + mStatus *systray.MenuItem + mToggle *systray.MenuItem + mOpenBrowser *systray.MenuItem + mViewLogs *systray.MenuItem + mRelayStatus *systray.MenuItem + mQuit *systray.MenuItem +} + +// Run blocks until the tray application is quit. +func Run(cfg *Config) { + app := &TrayApp{cfg: cfg} + systray.Run(app.onReady, app.onExit) +} + +func (a *TrayApp) onReady() { + // Set initial icon and tooltip. + if runtime.GOOS == "darwin" { + systray.SetTemplateIcon(iconStopped, iconStopped) + } else { + systray.SetIcon(iconStopped) + } + systray.SetTooltip("Edge AI Platform") + + // Build menu. + a.mStatus = systray.AddMenuItem("Status: Stopped", "") + a.mStatus.Disable() + + // Relay status (informational, non-clickable). + if a.cfg.Relay.URL != "" { + a.mRelayStatus = systray.AddMenuItem("Relay: Configured", "") + a.mRelayStatus.Disable() + } + + systray.AddSeparator() + + a.mToggle = systray.AddMenuItem("Start Server", "Start the Edge AI server") + a.mOpenBrowser = systray.AddMenuItem("Open Browser", "Open the web interface in your browser") + a.mViewLogs = systray.AddMenuItem("View Logs", "Open server log output") + + systray.AddSeparator() + + a.mQuit = systray.AddMenuItem("Quit", "Quit Edge AI Platform") + + // Auto-start the server on launch. + a.startServer() + + // Event loop. + go func() { + for { + select { + case <-a.mToggle.ClickedCh: + a.mu.Lock() + running := a.running + a.mu.Unlock() + if running { + a.stopServer() + } else { + a.startServer() + } + case <-a.mOpenBrowser.ClickedCh: + a.openBrowser() + case <-a.mViewLogs.ClickedCh: + a.viewLogs() + case <-a.mQuit.ClickedCh: + a.stopServer() + systray.Quit() + return + } + } + }() +} + +func (a *TrayApp) onExit() { + a.stopServer() +} + +func (a *TrayApp) startServer() { + a.mu.Lock() + defer a.mu.Unlock() + + if a.running { + return + } + + // Build command args: re-invoke the same binary without --tray. + args := []string{ + "--port", fmt.Sprintf("%d", a.cfg.Server.Port), + "--host", a.cfg.Server.Host, + } + if a.cfg.Relay.URL != "" { + args = append(args, "--relay-url", a.cfg.Relay.URL) + } + if a.cfg.Relay.Token != "" { + args = append(args, "--relay-token", a.cfg.Relay.Token) + } + + exe, err := os.Executable() + if err != nil { + log.Printf("[tray] failed to get executable path: %v", err) + return + } + + a.cmd = exec.Command(exe, args...) + a.cmd.Stdout = os.Stdout + a.cmd.Stderr = os.Stderr + + if err := a.cmd.Start(); err != nil { + log.Printf("[tray] failed to start server: %v", err) + return + } + + a.running = true + a.updateStatus(true) + + // Monitor the child process in a goroutine. + go func() { + err := a.cmd.Wait() + a.mu.Lock() + a.running = false + a.mu.Unlock() + a.updateStatus(false) + if err != nil { + log.Printf("[tray] server process exited: %v", err) + } else { + log.Println("[tray] server process exited normally") + } + }() +} + +func (a *TrayApp) stopServer() { + a.mu.Lock() + defer a.mu.Unlock() + + if !a.running || a.cmd == nil || a.cmd.Process == nil { + return + } + + // Send SIGTERM for graceful shutdown; on Windows use Kill(). + if runtime.GOOS == "windows" { + a.cmd.Process.Kill() + } else { + a.cmd.Process.Signal(syscall.SIGTERM) + } + // Wait handled by the goroutine in startServer. +} + +func (a *TrayApp) updateStatus(running bool) { + port := a.cfg.Server.Port + if running { + a.mStatus.SetTitle(fmt.Sprintf("Status: Running (:%d)", port)) + a.mToggle.SetTitle("Stop Server") + if runtime.GOOS == "darwin" { + systray.SetTemplateIcon(iconRunning, iconRunning) + } else { + systray.SetIcon(iconRunning) + } + systray.SetTooltip(fmt.Sprintf("Edge AI Platform - Running (:%d)", port)) + } else { + a.mStatus.SetTitle("Status: Stopped") + a.mToggle.SetTitle("Start Server") + if runtime.GOOS == "darwin" { + systray.SetTemplateIcon(iconStopped, iconStopped) + } else { + systray.SetIcon(iconStopped) + } + systray.SetTooltip("Edge AI Platform - Stopped") + } +} + +// openBrowser opens the web interface in the default browser. +// If a relay URL is configured, opens the relay URL with the token embedded. +// Otherwise, opens the local server URL. +func (a *TrayApp) openBrowser() { + var url string + if a.cfg.Relay.URL != "" && a.cfg.Relay.Token != "" { + // Convert ws:// relay URL to http:// web URL with token + u := a.cfg.Relay.URL + u = strings.Replace(u, "wss://", "https://", 1) + u = strings.Replace(u, "ws://", "http://", 1) + if i := strings.Index(u, "/tunnel"); i != -1 { + u = u[:i] + } + url = fmt.Sprintf("%s/?token=%s", u, a.cfg.Relay.Token) + } else { + host := a.cfg.Server.Host + if host == "0.0.0.0" || host == "" { + host = "127.0.0.1" + } + url = fmt.Sprintf("http://%s:%d", host, a.cfg.Server.Port) + } + + 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 + } + if err := cmd.Start(); err != nil { + log.Printf("[tray] failed to open browser: %v", err) + } +} + +// viewLogs opens the platform-appropriate log viewer. +func (a *TrayApp) viewLogs() { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + // Open Console.app which shows stdout/stderr logs + cmd = exec.Command("open", "-a", "Console") + case "linux": + // Try to open a terminal with journalctl or just show recent logs + cmd = exec.Command("xdg-open", fmt.Sprintf("http://%s:%d/settings", a.cfg.Server.Host, a.cfg.Server.Port)) + case "windows": + // Open the Settings page which has the log viewer + host := a.cfg.Server.Host + if host == "0.0.0.0" || host == "" { + host = "127.0.0.1" + } + cmd = exec.Command("cmd", "/c", "start", fmt.Sprintf("http://%s:%d/settings", host, a.cfg.Server.Port)) + default: + return + } + if err := cmd.Start(); err != nil { + log.Printf("[tray] failed to open log viewer: %v", err) + } +} diff --git a/server/web/embed.go b/server/web/embed.go new file mode 100644 index 0000000..3ce266b --- /dev/null +++ b/server/web/embed.go @@ -0,0 +1,20 @@ +package web + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed all:out +var staticFiles embed.FS + +// StaticFS returns an http.FileSystem rooted at the embedded "out" directory. +// This strips the "out/" prefix so paths like "/_next/static/..." work directly. +func StaticFS() http.FileSystem { + sub, err := fs.Sub(staticFiles, "out") + if err != nil { + panic("web: failed to create sub-filesystem: " + err.Error()) + } + return http.FS(sub) +}