23 KiB
Architecture Research
Domain: AI-powered hardware inventory management with USB peripheral control Researched: 2026-04-09 Confidence: HIGH (project stack is well-defined; patterns are established Go idioms)
Standard Architecture
System Overview
┌─────────────────────────────────────────────────────────────────────┐
│ Browser (React SPA) │
│ ┌────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Inventory │ │ Lab Advisor │ │ USB Testing │ │
│ │ Dashboard │ │ Chat View │ │ Workflow UI │ │
│ └─────┬──────┘ └──────┬────────┘ └──────┬───────┘ │
└────────┼────────────────┼────────────────────┼───────────────────────┘
│ │ │ HTTP/REST + SSE
┌────────▼────────────────▼────────────────────▼───────────────────────┐
│ Go Backend (Single Binary) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ HTTP Layer │ │ AI Orch. │ │ USB Manager │ │
│ │ (Handlers) │ │ (3-tier │ │ (Serial/USB │ │
│ │ │ │ pipeline) │ │ devices) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────▼─────────────────▼──────────────────▼───────┐ │
│ │ Service Layer │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ Inventory │ │ Advisor │ │ Label / Testing │ │ │
│ │ │ Service │ │ Service │ │ Service │ │ │
│ │ └──────┬────┘ └────┬─────┘ └────────┬─────────┘ │ │
│ └─────────┼────────────┼──────────────────┼───────────┘ │
│ │ │ │ │
│ ┌─────────▼────────────▼──────────────────▼───────┐ │
│ │ Client / Adapter Layer │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐│ │
│ │ │ NetBox │ │ AI │ │ SQLite (chat ││ │
│ │ │ Client │ │ Client │ │ history only) ││ │
│ │ └──────┬────┘ └────┬─────┘ └──────────────────┘│ │
│ └─────────┼────────────┼─────────────────────────────┘ │
└────────────┼────────────┼──────────────────────────────────────────────┘
│ │
┌───────▼───┐ ┌─────▼──────────────────────────────┐
│ NetBox │ │ AI Inference Layer │
│ LXC 130 │ │ ┌────────────┐ ┌───────────────┐ │
│ REST API │ │ │ oMLX │ │ OpenRouter │ │
└───────────┘ │ │ (Gemma 4 │ │ (Tier 2/3 │ │
│ │ local) │ │ remote) │ │
│ └────────────┘ └───────────────┘ │
└────────────────────────────────────────┘
Component Responsibilities
| Component | Responsibility | Notes |
|---|---|---|
| React SPA | All user interaction — intake, search, chat, USB workflows | Served as static files by Go; communicates via REST + SSE |
| HTTP Layer (Go handlers) | Route requests, validate input, marshal/unmarshal JSON | Thin — no business logic |
| AI Orchestrator | 3-tier pipeline: route task to local/remote model, aggregate results | Owns escalation logic, prompt construction |
| USB Manager | Own goroutines per device, serial protocol handling, event fan-out | Manages PRT Qutie, 3x Treedix, FNIRSI FNB58 |
| Inventory Service | Photo intake flow, NetBox record creation, quality gate transitions | The core domain service |
| Advisor Service | Chat session management, context assembly from NetBox, response streaming | Wraps AI Orchestrator with lab-context injection |
| Label/Testing Service | Print job construction, cable test sequencing, results capture | Coordinates USB Manager with inventory records |
| NetBox Client | All NetBox REST calls, custom field mapping, HW-XXXXX ID allocation | Single integration point — no direct NetBox calls elsewhere |
| AI Client | OpenAI-compatible interface pointed at oMLX or OpenRouter | Swappable base URL = swappable model tier |
| SQLite store | Chat history, config, local-only ephemeral state | NOT inventory data — NetBox owns that |
| oMLX (Mac Mini) | Local Gemma 4 inference via MLX, OpenAI-compatible endpoint | Tier 1 — always tried first |
| OpenRouter | Remote model routing to Claude Opus, GPT-4o, etc. | Tier 2/3 — escalated only |
| NetBox (LXC 130) | Sole inventory data store, REST API at 10.5.0.130:8000 | Source of truth |
| SearXNG (LXC 129) | Product research search API at 10.5.0.129:8080 | Called by AI Orchestrator during research agent tasks |
Recommended Project Structure
hwlab/
├── cmd/
│ └── hwlab/
│ └── main.go # Wire-up only: init config, start server
├── internal/
│ ├── api/
│ │ ├── handlers/ # HTTP handlers — thin, delegate to services
│ │ │ ├── inventory.go
│ │ │ ├── advisor.go
│ │ │ └── usb.go
│ │ ├── middleware/ # Auth (if any), logging, CORS
│ │ └── router.go # Route registration
│ ├── ai/
│ │ ├── orchestrator.go # 3-tier routing, escalation logic
│ │ ├── client.go # OpenAI-compatible HTTP client (base URL configurable)
│ │ └── prompts/ # Prompt templates per task type
│ ├── inventory/
│ │ ├── service.go # Intake, quality gate, search
│ │ └── types.go # Domain types for inventory items
│ ├── advisor/
│ │ ├── service.go # Chat session, context assembly
│ │ └── history.go # SQLite persistence for chat
│ ├── usb/
│ │ ├── manager.go # Device lifecycle, goroutine-per-device
│ │ ├── printer/ # PRT Qutie protocol
│ │ ├── tester/ # Treedix cable tester protocol
│ │ └── powermeter/ # FNIRSI FNB58 protocol
│ ├── label/
│ │ └── service.go # QR code generation, print job construction
│ ├── netbox/
│ │ └── client.go # All NetBox REST calls, field mapping
│ ├── search/
│ │ └── client.go # SearXNG JSON API client
│ └── config/
│ └── config.go # App config (env/file), secrets
├── web/ # React SPA build output (embedded via go:embed)
│ └── dist/
├── frontend/ # React TypeScript source
│ ├── src/
│ │ ├── views/
│ │ ├── components/
│ │ └── api/ # Typed fetch wrappers
│ ├── package.json
│ └── vite.config.ts
└── Makefile # build, dev, test, embed-frontend
Structure Rationale
- internal/: Go convention — prevents external import of application internals
- internal/ai/: Keeps all AI concerns (prompts, routing, client) co-located; orchestrator is the only caller of the AI client
- internal/usb/: Each device gets its own sub-package; USB Manager owns the goroutines and exposes typed channels upward
- internal/netbox/: Single client prevents NetBox calls from leaking into service or handler layers
- web/dist/: Embedded into the Go binary at build time via
go:embed— single binary deployment, no separate static server needed - cmd/hwlab/: Only wire-up code; no business logic in main
Architectural Patterns
Pattern 1: Single Binary with Embedded Frontend
What: The React SPA build output is embedded into the Go binary using go:embed. The Go HTTP server serves static assets on / and API routes on /api/.
When to use: Single-operator homelab tool where deployment simplicity matters more than independent frontend deploys.
Trade-offs: Rebuild binary to update frontend. Acceptable for solo use; would need rethinking for teams.
Example:
//go:embed web/dist
var staticFiles embed.FS
mux.Handle("/", http.FileServerFS(staticFiles))
mux.Handle("/api/", apiRouter)
Pattern 2: Goroutine-Per-USB-Device with Channel Fan-Out
What: Each USB device runs in a dedicated goroutine. The USB Manager holds typed channels for commands (in) and events (out). HTTP handlers send commands; SSE endpoint subscribes to event stream.
When to use: Any time you need concurrent, non-blocking I/O to physical devices without coordination complexity.
Trade-offs: Simple but requires careful channel sizing and shutdown sequencing via context.Context.
Example:
type USBManager struct {
printer *PrinterDevice
testers [3]*TesterDevice
powerMeter *PowerMeterDevice
}
// Each device goroutine:
func (d *PrinterDevice) run(ctx context.Context, cmds <-chan PrintCmd, events chan<- DeviceEvent) {
for {
select {
case cmd := <-cmds:
// send to serial port
case <-ctx.Done():
return
}
}
}
Pattern 3: Three-Tier AI Orchestrator with Eager Local, Lazy Remote
What: All AI tasks are attempted first with oMLX (Tier 1 — local Gemma 4). If confidence is below threshold or the task type explicitly requires it, the orchestrator escalates to OpenRouter (Tier 2/3 — Claude Opus or similar). The AI Client struct accepts a base URL, so switching tiers is a URL swap, not a code change. When to use: Local inference is available but not always sufficient; cloud costs must be minimized. Trade-offs: Adds latency on escalation paths. Confidence scoring for vision tasks (photo intake) needs calibration.
Example:
type AIClient struct {
BaseURL string // "http://localhost:11434/v1" or "https://openrouter.ai/api/v1"
Model string
APIKey string
}
func (o *Orchestrator) Run(ctx context.Context, task Task) (Result, error) {
result, err := o.tier1.Complete(ctx, task)
if err != nil || result.Confidence < o.threshold {
return o.tier2.Complete(ctx, task)
}
return result, nil
}
Pattern 4: NetBox as External State — Repository Pattern Wrapper
What: The internal/netbox package implements a typed repository interface over the NetBox REST API. Services call the repository; they never construct raw HTTP calls to NetBox. The repository maps between NetBox's JSON schema and HWLab domain types.
When to use: Any time an external service is the source of truth and its API shape should not leak into domain logic.
Trade-offs: Extra mapping layer. Worth it: if NetBox API changes, only internal/netbox needs updating.
Data Flow
Photo Intake Flow (Primary Flow)
User uploads photo
↓
POST /api/intake (handler)
↓
InventoryService.Intake(photo)
↓
AIOrchestrator.AnalyzeHardware(photo)
↓ (Tier 1: oMLX/Gemma 4 vision)
→ if low confidence → Tier 2: OpenRouter (Claude Opus vision)
↓
Structured item data (name, category, specs)
↓
AIOrchestrator.Research(item) [optional, if quality gate requires]
↓ (SearXNG search + AI synthesis)
↓
NetBoxClient.CreateItem(item)
↓ → NetBox LXC (REST POST)
↓
HW-XXXXX ID assigned
↓
LabelService.Print(item)
↓
USBManager.Printer.Print(label)
↓ → PRT Qutie (USB serial)
↓
SSE event → frontend: "intake complete, HW-XXXXX"
Cable Testing Flow
User initiates test (selects item HW-XXXXX, selects tester port)
↓
POST /api/test (handler)
↓
TestingService.RunTest(itemID, testerIndex)
↓
USBManager.Tester[n].RunTest(cableType)
↓ → Treedix device (USB serial command)
↓
DeviceEvent (test result bytes)
↓
TestingService.ParseResult(bytes)
↓
NetBoxClient.UpdateItemField(itemID, "cable_test_result", result)
↓
SSE event → frontend: test result display
Lab Advisor Chat Flow
User sends message
↓
POST /api/advisor/chat (handler, streams response)
↓
AdvisorService.Chat(sessionID, message)
↓
NetBoxClient.GetInventoryContext() [fetch relevant items for context]
↓
AIOrchestrator.Chat(systemPrompt + context + history + message)
↓ (Tier 3: OpenRouter/Claude Opus — always remote for advisor)
↓
Streamed tokens → SSE → frontend chat view
↓
AdvisorService.SaveMessage(sessionID, message, response) → SQLite
Key Data Flow Rules
- NetBox is write-through: Any state change to an inventory item goes to NetBox immediately — no local cache of inventory records.
- USB events are push, not poll: Device goroutines push events onto channels; the SSE handler fans them out to connected browser clients.
- AI calls are always async from the user's perspective: Handlers return immediately with a job ID; progress arrives via SSE.
- SQLite is append-only for chat: No inventory data in SQLite. Chat history rows are never updated, only inserted and read.
Build Order (Phase Dependencies)
The architecture has a clear dependency DAG that should drive phase sequencing:
Phase 1: Go scaffold + NetBox client + basic CRUD
↓ (NetBox client is a dependency of everything)
Phase 2: AI client + local oMLX integration + photo intake pipeline
↓ (AI pipeline needed before intake flow is useful)
Phase 3: React SPA + inventory dashboard (reads from NetBox via Go API)
↓ (frontend needs backend API to be stable)
Phase 4: USB Manager + Printer + label printing flow
↓ (USB hardware arrives 2026-04-13; serial protocols need reverse-engineering)
Phase 5: Cable tester integration + testing workflows
↓ (builds on USB Manager foundation)
Phase 6: Lab Advisor chat (OpenRouter, streaming SSE, SQLite history)
↓ (can be parallel with Phase 4/5 but needs frontend)
Phase 7: SearXNG research agent + quality gate automation
↓ (enhancement layer — all primitives exist by this point)
Critical dependency: The NetBox client and AI client must be stable before building services that depend on them. Building the NetBox client first (with a thin mock layer for tests) unblocks parallel work on AI and USB.
USB hardware blocker: USB device protocols (printer, testers, power meter) cannot be finalized until the hardware arrives (2026-04-13). Phase 4+ USB work should start with a stub/simulator interface that gets replaced once protocols are reverse-engineered.
Anti-Patterns
Anti-Pattern 1: Storing Inventory Data Locally
What people do: Cache NetBox responses in SQLite or an in-memory map for "performance." Why it's wrong: Creates a second source of truth. NetBox records updated via other tools (direct API, NetBox UI) silently diverge from the local cache. Debugging inventory discrepancies is brutal. Do this instead: Accept the latency of NetBox REST calls. If performance becomes an issue post-MVP, add a read-through cache with short TTL (30–60s) and an explicit invalidation hook.
Anti-Pattern 2: USB Device Logic in HTTP Handlers
What people do: Open serial port in the HTTP handler, write bytes, read response synchronously, close port. Why it's wrong: Serial devices are stateful (connection must persist), blocking (reads block the goroutine), and shared (multiple requests may target the same device). Handler-level serial access causes race conditions and hangs. Do this instead: USB Manager owns all device connections. Handlers send typed commands on a channel and wait for a response on a reply channel (or SSE events for streaming results).
Anti-Pattern 3: Mixing AI Tiers in Service Logic
What people do: Hard-code "use Claude for advisor, use Gemma for intake" calls in service layer code. Why it's wrong: Ties business logic to specific model names. Swapping models (e.g., Gemma 4 → Gemma 4 26B) requires touching service code. Do this instead: Services call the AI Orchestrator with a task type and confidence requirement. The Orchestrator owns model selection. Config file maps task types to model preferences.
Anti-Pattern 4: Polling USB Devices from the Frontend
What people do: Frontend polls GET /api/usb/status every second to get device state.
Why it's wrong: Creates 1 HTTP request/second per connected client, adds 0–1s latency to test result display, and hammers the serial port with unnecessary reads.
Do this instead: Use Server-Sent Events (SSE). USB Manager emits events; Go SSE handler fans out to subscribed browser clients. Instant updates, one persistent connection per client.
Integration Points
External Services
| Service | Integration Pattern | Notes |
|---|---|---|
| NetBox (LXC 130) | REST API via internal/netbox typed client |
go-netbox official client or hand-rolled against v4 OpenAPI spec; needs custom fields for HW IDs and quality gate status |
| oMLX (local) | OpenAI-compatible /v1/chat/completions + /v1/completions with vision |
Base URL: http://localhost:PORT/v1; Gemma 4 E4B confirmed fits in 16GB |
| OpenRouter | OpenAI-compatible API with Authorization: Bearer |
Base URL: https://openrouter.ai/api/v1; model specified per request |
| SearXNG (LXC 129) | HTTP GET http://10.5.0.129:8080/search?q=...&format=json |
No auth; called by AI Orchestrator during research tasks |
| PRT Qutie | USB serial (CDC-ACM or HID) | Protocol unknown until hardware arrives; need to sniff USB traffic from official macOS app |
| Treedix testers (x3) | USB serial (likely CDC-ACM) | 3 independent serial ports; USB Manager assigns by device serial number |
| FNIRSI FNB58 | USB HID or CDC-ACM | Power meter; likely streams measurement data continuously |
Internal Boundaries
| Boundary | Communication | Notes |
|---|---|---|
| Handler → Service | Direct Go function call | Handlers are thin wrappers; no business logic |
| Service → AI Orchestrator | Direct Go function call; returns via channel for streaming | Orchestrator manages concurrency internally |
| Service → NetBox Client | Direct Go function call (sync); client handles HTTP retries | Client is the only thing that knows about NetBox |
| Service → USB Manager | Typed command channel (in), event channel (out) | Decouples HTTP request lifecycle from serial I/O timing |
| AI Orchestrator → AI Client | Direct Go function call; streaming via io.Reader |
Client is a thin HTTP wrapper; Orchestrator does prompt assembly |
| HTTP Handler → SSE | Go channel per client connection, fan-out goroutine | SSE handler registers a channel, USB Manager / AI Orchestrator write to it |
| Frontend → Backend | REST JSON for commands, SSE for async results | No WebSocket — SSE is simpler and sufficient for unidirectional push |
Scaling Considerations
This is a single-operator homelab tool. Scaling beyond one concurrent user is out of scope. The relevant concerns are:
| Concern | Approach |
|---|---|
| Memory (16GB shared with oMLX) | Gemma 4 E4B uses ~8–10GB; Go backend + SQLite uses ~100MB; leave headroom for 26B A4B if tested |
| oMLX inference latency | Tier 1 photo analysis: 2–5s expected. Use async + SSE, never block the HTTP response. |
| NetBox API latency | LXC on same network; expect <10ms. No caching needed initially. |
| USB serial reliability | Serial devices disconnect. USB Manager must handle reconnect gracefully without crashing. |
| Single binary size | go:embed of React build adds ~2–5MB. Acceptable. |
Sources
- go-netbox official Go client — MEDIUM confidence (official but may lag NetBox v4.2 custom field API)
- NetBox REST API Overview — HIGH confidence
- OpenAI Go library (official) — HIGH confidence; base URL override works for any OpenAI-compatible server
- go.bug.st/serial for USB serial — HIGH confidence; standard library for serial in Go
- Go modular monolith patterns — MEDIUM confidence
- Azure AI Agent Orchestration Patterns — HIGH confidence for orchestration pattern naming and structure
- Alex Edwards: Fat Service Pattern for Go — HIGH confidence; well-regarded Go architecture blog
Architecture research for: HWLab — AI-powered homelab hardware inventory management Researched: 2026-04-09