diff --git a/Docs/AI-Integration.md b/Docs/AI-Integration.md new file mode 100644 index 0000000..0c7c99d --- /dev/null +++ b/Docs/AI-Integration.md @@ -0,0 +1,967 @@ +# AI Integration + +> NeewerLite × MCP — Exposing Neewer lights as MCP tools for agentic control, powered by the official MCP Swift SDK and Vapor. + +--- + +## 1. Overview + +NeewerLite exposes Neewer Bluetooth LED lights as [MCP](https://modelcontextprotocol.io) tools, so any MCP-compatible AI agent — VS Code Copilot, Claude Desktop, OpenClaw, Cursor — can discover and control your lights with natural language: *"set the key light to warm white at 80%"* or *"flash red if the build fails."* + +**How it works:** The app embeds an MCP server using the official [MCP Swift SDK](https://github.com/modelcontextprotocol/swift-sdk) and [Vapor](https://github.com/vapor/vapor). It runs entirely in-process — no CLI, no Node.js, no bridge. Users start the server from Settings and register one URL in their AI tool. The server exposes 11 tools covering every light control operation NeewerLite supports. + +**Transport:** Primary transport is **Streamable HTTP** (`POST/GET/DELETE /mcp`) with per-client session isolation via a `SessionManager` actor. A legacy SSE transport (`GET /sse` + `POST /messages`) is also available for older MCP clients. The Stream Deck REST API shares the same Vapor server on `localhost:18486`. + +**Why this stack:** +- **MCP Swift SDK** handles all protocol complexity — JSON-RPC, `Mcp-Session-Id` management, SSE streaming, origin/content-type validation, resumability. No hand-rolled protocol code. +- **Vapor** provides async/await HTTP with proper streaming body support. Replaced the previous Swifter dependency for both MCP and Stream Deck routes. +- **One process** — same port (18486), same server, same light control logic shared by all callers. + +--- + +## 2. Current HTTP Server (Stream Deck) + +`Server.swift` already exposes these endpoints on `localhost:18486`: + +| Method | Endpoint | Purpose | +|---|---|---| +| GET | `/ping` | Health check | +| GET | `/listLights` | List all lights with state, brightness, CCT, RGB support | +| POST | `/switch` | Turn lights on/off | +| POST | `/brightness` | Set brightness | +| POST | `/temperature` | Set color temperature | +| POST | `/cct` | Set CCT mode (brightness + temperature) | +| POST | `/hst` | Set HSI mode (hex color + brightness + saturation) | +| POST | `/hue` | Set hue only | +| POST | `/sat` | Set saturation only | +| POST | `/fx` | Activate scene effect | + +Auth: `User-Agent` prefix check (`neewerlite.sdPlugin/`). + +These endpoints continue to serve the Stream Deck plugin unchanged. MCP traffic goes through a new `/mcp` endpoint. + +--- + +## 3. Architecture + +``` +┌──────────────┐ ┌────────────────────────────────────────┐ +│ OpenClaw │ │ NeewerLite.app │ +│ Claude │ Streamable HTTP │ │ +│ VS Code │ POST/GET/DELETE /mcp │ Vapor HTTP Server (:18486) │ +│ Cursor │ ────────────────────► │ ├── /mcp ──► SessionManager │ +│ Any MCP │ localhost:18486 │ │ (per-client context) │ +│ Client │ │ │ ──► StatefulHTTP+Server │ +└──────────────┘ │ │ ├── 11 tool handlers │ + │ │ │ +┌──────────────┐ │ ├── /sse ──► Legacy SSE transport │ +│ OpenClaw │ Legacy SSE │ ├── /messages (older clients) │ +│ (Python UA) │ GET /sse, POST /messages│ │ │ +│ │ ────────────────────► │ ├── /listLights ← Stream Deck │ +└──────────────┘ localhost:18486 │ ├── /switch ← Stream Deck │ + │ └── ... │ +┌──────────────┐ │ │ +│ Stream Deck │ REST (JSON) │ Light control logic (shared) │ +│ Plugin │ ────────────────────► │ │ │ +└──────────────┘ localhost:18486 │ CoreBluetooth │ + │ │ │ + └─────────┼──────────────────────────────┘ + │ + ┌──────▼───────┐ + │ Neewer LED │ + │ Lights │ + └──────────────┘ +``` + +### Dependency Stack + +| Layer | Component | Role | +|---|---|---| +| HTTP Server | **Vapor** | Listen on port, route requests, serve responses. Replaces Swifter | +| MCP Protocol | **MCP Swift SDK** (`StatefulHTTPServerTransport` + `Server`) | JSON-RPC, sessions, SSE, tool schemas, request validation | +| Session Isolation | **`SessionManager`** actor | Per-client `StatefulHTTPServerTransport`+`Server` contexts, session ID routing, TTL eviction, max-24 cap | +| Business Logic | Tool handlers (11 tools) | Light control via `NeewerLight` device API | +| Hardware | CoreBluetooth | BLE commands to physical lights | + +### How it connects + +The MCP SDK's `StatefulHTTPServerTransport` is **framework-agnostic** — it takes an `HTTPRequest` (SDK type) and returns an `HTTPResponse` (SDK type). Vapor acts as the HTTP adapter: + +```swift +// Vapor route → MCP SDK transport +app.on(.POST, "mcp") { req -> Response in + let sdkRequest = HTTPRequest( + method: "POST", + headers: req.headers.asDictionary(), + body: req.body.data.map { Data(buffer: $0) }, + path: "/mcp" + ) + let sdkResponse = await transport.handleRequest(sdkRequest) + return sdkResponse.toVaporResponse() +} +``` + +The MCP SDK `Server` handles `initialize`, `tools/list`, `tools/call` via registered handlers — no manual JSON-RPC parsing needed. + +--- + +## 4. MCP Streamable HTTP Transport + +NeewerLite uses the MCP Swift SDK's `StatefulHTTPServerTransport` which implements the full [MCP Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) specification. + +### 4.1 What the SDK Handles (we don't write this code) + +| Feature | SDK Component | +|---|---| +| JSON-RPC parsing & routing | `Server` + `Transport` | +| `initialize` handshake | `Server.start(transport:)` | +| `Mcp-Session-Id` management | `StatefulHTTPServerTransport` | +| SSE streaming for POST responses | `StatefulHTTPServerTransport` | +| Standalone GET SSE stream | `StatefulHTTPServerTransport` | +| Session termination via DELETE | `StatefulHTTPServerTransport` | +| Request validation (Origin, Content-Type, Accept, Protocol-Version) | `StandardValidationPipeline` | +| Event store for resumability (Last-Event-ID) | `StatefulHTTPServerTransport` | +| `notifications/initialized` handling | `Server` | +| `ping` handling | `Server` | +| Tool schema registration & discovery | `Server.withMethodHandler(ListTools.self)` | +| Tool call dispatch | `Server.withMethodHandler(CallTool.self)` | + +### 4.2 What We Write + +| Component | Scope | +|---|---| +| Vapor route adapter (`POST/GET/DELETE /mcp`) | Convert Vapor Request ↔ SDK HTTPRequest/HTTPResponse | +| `ListTools` handler | Return 8 tool schemas using SDK's `Tool` type | +| `CallTool` handler | Dispatch to 8 tool implementations | +| 8 tool implementations | Light control via device API (mostly reused from Phase 1) | +| Stream Deck routes | Port existing endpoints from Swifter to Vapor | + +### 4.3 Protocol Version + +The SDK implements MCP specification **2025-11-25** (latest). This is a version upgrade from our Phase 1 implementation which used 2025-03-26. + +--- + +## 5. MCP Tool Definitions + +> **Note:** The tool names below are from the original spec. During Phase 2 implementation, the tools evolved: `switch_light` → `turn_on`/`turn_off`, `set_brightness` merged into `set_light_cct`, `set_cct` → `set_light_cct`, `set_hsi` → `set_light_hsi`, `set_scene` → `set_light_scene`, `list_scenes` removed, `scan_lights` → `scan`, and `get_light_image`/`get_logs` added. See `Server.swift` for the actual schemas. + +### 5.1 `list_lights` + +> Query all connected Neewer lights and their current state. + +```json +{ + "name": "list_lights", + "description": "List all connected Neewer LED lights with their current state, brightness, color temperature, and capabilities. Shows capability flags (RGB, scenes, music) and scene count — use list_scenes to get the full scene list for a specific light.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } +} +``` + +**Implementation:** Reuses the same `viewObjects` iteration as `GET /listLights`, plus reads `supportedFX.count`, `supportRGB`, and `supportedMusicFX` from each `NeewerLight`. + +**Return:** Human-readable text summary with capability flags so the agent knows what each light can do. + +```json +{ + "content": [{ + "type": "text", + "text": "Found 2 lights:\n1. KeyLight (NL660) — ON, brightness 80%, CCT 5600K\n Capabilities: RGB ✓, 17 scenes, music-reactive ✓\n2. RopeLight (NS02) — ON, brightness 60%, CCT 4500K\n Capabilities: RGB ✓, 73 scenes, music-reactive ✓" + }] +} +``` + +**Design note:** `list_lights` intentionally omits scene names to keep responses concise. The NS02 alone has 73 scenes — listing all of them here would waste tokens. The agent should call `list_scenes` when it needs scene details for a specific light. + +### 5.2 `switch_light` + +> Turn lights on or off. + +```json +{ + "name": "switch_light", + "description": "Turn one or more Neewer lights on or off. Use list_lights first to see available light names.", + "inputSchema": { + "type": "object", + "properties": { + "lights": { + "type": "array", + "items": { "type": "string" }, + "description": "Light names or IDs to control. Use 'all' to target every connected light." + }, + "state": { + "type": "boolean", + "description": "true = on, false = off" + } + }, + "required": ["lights", "state"] + } +} +``` + +**Implementation:** Same logic as `POST /switch`. + +### 5.3 `set_brightness` + +> Adjust brightness without changing color mode. + +```json +{ + "name": "set_brightness", + "description": "Set brightness level for one or more lights. Does not change the current color mode.", + "inputSchema": { + "type": "object", + "properties": { + "lights": { + "type": "array", + "items": { "type": "string" }, + "description": "Light names or IDs." + }, + "brightness": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Brightness percentage (0–100)." + } + }, + "required": ["lights", "brightness"] + } +} +``` + +**Implementation:** Same logic as `POST /brightness`. + +### 5.4 `set_cct` + +> Set white light mode with color temperature and brightness. + +```json +{ + "name": "set_cct", + "description": "Set a light to white (CCT) mode with a specific color temperature and brightness. Good for video calls, photography, and general workspace lighting.", + "inputSchema": { + "type": "object", + "properties": { + "lights": { + "type": "array", + "items": { "type": "string" }, + "description": "Light names or IDs." + }, + "brightness": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Brightness percentage (0–100)." + }, + "temperature": { + "type": "number", + "minimum": 3200, + "maximum": 8500, + "description": "Color temperature in Kelvin. 3200K = warm/tungsten, 5600K = daylight, 8500K = cool/blue." + } + }, + "required": ["lights", "brightness", "temperature"] + } +} +``` + +**Implementation:** Same logic as `POST /cct`. + +### 5.5 `set_hsi` + +> Set a colored light using hex color. + +```json +{ + "name": "set_hsi", + "description": "Set a light to a specific color using hex color code. Only works on RGB-capable lights. Use list_lights to check supportRGB first.", + "inputSchema": { + "type": "object", + "properties": { + "lights": { + "type": "array", + "items": { "type": "string" }, + "description": "Light names or IDs." + }, + "hex_color": { + "type": "string", + "description": "Hex color code (e.g. 'FF0000' for red, '0066FF' for blue)." + }, + "brightness": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Brightness percentage (0–100)." + }, + "saturation": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Color saturation percentage (0–100). Default 100." + } + }, + "required": ["lights", "hex_color", "brightness"] + } +} +``` + +**Implementation:** Same logic as `POST /hst`. + +### 5.6 `set_scene` + +> Activate a scene effect. + +```json +{ + "name": "set_scene", + "description": "Activate a dynamic scene effect on one or more lights. Use list_scenes first to see which scenes a light supports — different lights have different scene sets. Pass the scene ID from list_scenes.", + "inputSchema": { + "type": "object", + "properties": { + "lights": { + "type": "array", + "items": { "type": "string" }, + "description": "Light names or IDs." + }, + "scene_id": { + "type": "integer", + "minimum": 1, + "description": "Scene effect ID from list_scenes. IDs are light-specific — always check list_scenes first." + } + }, + "required": ["lights", "scene_id"] + } +} +``` + +**Implementation:** Same logic as `POST /fx`. + +### 5.7 `list_scenes`, `list_sources`, `list_gels` + +> List available scenes, light sources, or gel presets for a specific light. + +`list_sources` — Returns the calibrated light-source presets (e.g. Tungsten, Daylight, HMI) for a given light. `list_gels` — Returns the 39 Neewer gel presets with their color values. + +### 5.7a `list_scenes` + +> List available scenes for a specific light. + +```json +{ + "name": "list_scenes", + "description": "List all available scene effects for a specific light. Different lights support different scenes — an NS02 rope light has 73 scenes (nature, moods, holidays, sports), while a standard RGB panel has 9 or 17. Always call this before set_scene to get valid scene IDs.", + "inputSchema": { + "type": "object", + "properties": { + "light": { + "type": "string", + "description": "Light name or ID. Must be a single light — scene lists are per-light." + } + }, + "required": ["light"] + } +} +``` + +**Implementation:** Looks up the `NeewerLight` by name, iterates `supportedFX`, returns each scene's `id` and `name`. Also includes `supportedMusicFX` if present. + +**Return example (standard RGB panel):** +```json +{ + "content": [{ + "type": "text", + "text": "KeyLight (NL660) — 17 scenes:\n 1. Lighting\n 2. Paparazzi\n 3. Defective bulb\n 4. Explosion\n 5. Welding\n 6. CCT flash\n 7. HUE flash\n 8. CCT pulse\n 9. HUE pulse\n10. Cop Car\n11. Candlelight\n12. HUE Loop\n13. CCT Loop\n14. INT loop\n15. TV Screen\n16. Firework\n17. Party" + }] +} +``` + +**Return example (NS02 rope light, abbreviated):** +```json +{ + "content": [{ + "type": "text", + "text": "RopeLight (NS02) — 73 scenes:\n\nNature: 1. Rainbow, 2. Starry Sky, 3. Flame, 4. Sunrise, 5. Aurora, ...\nMoods: 20. Romantic, 21. Lazy, 22. Dream, ...\nHolidays: 40. Christmas, 41. Halloween, 42. New Year, ...\nSports: 60. Dallas Football, 61. Los Angeles Basketball, ...\n\nMusic-reactive modes: 1. Energetic, 2. Rhythm, 3. Spectrum, 4. Rolling, 5. Stamping, 6. Star" + }] +} +``` + +### 5.8 `scan_lights` + +> Trigger a Bluetooth scan for new lights. + +```json +{ + "name": "scan_lights", + "description": "Trigger a Bluetooth scan to discover new Neewer lights. Results will appear in list_lights after a few seconds.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } +} +``` + +**Implementation:** Calls the same scan logic as `neewerlite://scanLight` URL scheme, but directly via `appDelegate`. + +### 5.9 `get_light_image` + +> Return the product image for a light as a base64-encoded PNG data URL. + +Useful for agents that want to show the user a visual of the detected light model. + +--- + +## 6. JSON-RPC Message Flow + +### 6.1 Initialization + +Client POSTs: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { "name": "VSCode", "version": "1.0" } + } +} +``` + +Server responds: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-03-26", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "NeewerLite", + "version": "1.6.0" + }, + "instructions": "NeewerLite controls Neewer Bluetooth LED lights. Use list_lights to discover connected lights and their capabilities. Use list_scenes to see available scenes for a specific light before calling set_scene. Control lights with set_cct, set_hsi, set_scene, switch_light, or set_brightness." + } +} +``` + +Client sends notification: +```json +{ + "jsonrpc": "2.0", + "method": "notifications/initialized" +} +``` + +Server responds: `202 Accepted` (no body). + +### 6.2 Tool Discovery + +Client POSTs: +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" +} +``` + +Server responds with all 8 tool schemas. + +### 6.3 Tool Call + +Client POSTs: +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "set_cct", + "arguments": { + "lights": ["KeyLight"], + "brightness": 80, + "temperature": 5600 + } + } +} +``` + +Server responds: +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [{ + "type": "text", + "text": "Set KeyLight to CCT mode: 80% brightness, 5600K" + }], + "isError": false + } +} +``` + +--- + +## 7. Implementation in Server.swift + +### 7.1 Phase 1 (Completed — Hand-Rolled) + +Phase 1 was implemented with hand-rolled JSON-RPC parsing on Swifter. All 8 tools were E2E tested on 3 real lights. This code is being replaced by the MCP SDK approach. + +**What was built and validated:** +- 8 tool handlers with direct device API calls (bypassing view layer) +- Bug fixes: `set_brightness`, `set_hsi`, `set_scene` all use device-level API, not view-dependent methods +- Scene sub-variants: optional `color`, `speed`, `brightness` params +- `list_scenes` shows per-scene parameters (color variants, speed, brightness) +- 11 unit tests, all passing +- E2E tested with step-by-step hardware confirmation on GL1C, PD20250030, RGB660 PRO + +### 7.2 Phase 2: Migrate to Vapor + MCP SDK + +#### What Changes + +| Change | Details | +|---|---| +| **Replace Swifter with Vapor** | New HTTP framework — all routes migrate | +| **Add MCP Swift SDK** | `modelcontextprotocol/swift-sdk` v0.12.0 | +| **Remove hand-rolled MCP code** | `handleMCP()`, `mcpInitializeResponse()`, `mcpToolsList()`, `mcpResult()`, `mcpError()`, `mcpToolResult()` — all replaced by SDK | +| **MCP Server + Transport** | `Server` + `StatefulHTTPServerTransport` from SDK | +| **Vapor adapter for /mcp** | Route handler that converts Vapor ↔ SDK request/response types | +| **Tool registration** | `withMethodHandler(ListTools.self)` and `withMethodHandler(CallTool.self)` using SDK's `Tool` type | +| **Update Package.swift** | Replace Swifter dependency with Vapor + MCP SDK | + +#### What Stays + +- **All 8 tool implementations** — the light control logic (device API calls) is reused +- **Stream Deck endpoints** — ported to Vapor, same functionality +- **Port 18486** +- **Settings UI toggle** — same UserDefaults key, same start/stop behavior +- **`resolveViewObjects()`** — light matching and "all" expansion logic +- **`DeviceViewObject.matches(lightId:)`** — name/ID matching + +#### Code Structure + +```swift +import MCP +import Vapor + +final class NeewerLiteServer { + private let app: Application // Vapor + private let mcpServer: MCP.Server // MCP SDK + private let transport: StatefulHTTPServerTransport + private let port: Int + private weak var appDelegate: AppDelegate? + + init(appDelegate: AppDelegate, port: Int = 18486) { + self.appDelegate = appDelegate + self.port = port + + // Create MCP Server + self.mcpServer = MCP.Server( + name: "NeewerLite", + version: Bundle.main.shortVersion, + capabilities: .init(tools: .init(listChanged: false)) + ) + + // Create Streamable HTTP transport + self.transport = StatefulHTTPServerTransport() + + // Create Vapor app + self.app = Application() + + // Register MCP handlers + registerMCPHandlers() + + // Setup all routes (MCP + Stream Deck) + setupRoutes() + } + + private func registerMCPHandlers() { + // Tool list — using SDK's Tool type + Task { + await mcpServer.withMethodHandler(ListTools.self) { [weak self] _ in + return .init(tools: self?.buildToolList() ?? []) + } + + // Tool calls — dispatch to implementations + await mcpServer.withMethodHandler(CallTool.self) { [weak self] params in + guard let self else { + return .init(content: [.text("Server not ready")], isError: true) + } + return await self.handleToolCall(params) + } + + // Start MCP server with transport + try await mcpServer.start(transport: transport) + } + } +} +``` + +### 7.3 Vapor ↔ MCP SDK Adapter + +The Vapor route converts between framework types: + +```swift +// POST /mcp — main MCP endpoint +app.on(.POST, "mcp") { [transport] req -> Response in + let sdkRequest = req.toMCPHTTPRequest(path: "/mcp") + let sdkResponse = await transport.handleRequest(sdkRequest) + return sdkResponse.toVaporResponse() +} + +// GET /mcp — SSE stream for server-initiated messages +app.on(.GET, "mcp") { [transport] req -> Response in + let sdkRequest = req.toMCPHTTPRequest(path: "/mcp") + let sdkResponse = await transport.handleRequest(sdkRequest) + return sdkResponse.toVaporResponse() +} + +// DELETE /mcp — terminate session +app.on(.DELETE, "mcp") { [transport] req -> Response in + let sdkRequest = req.toMCPHTTPRequest(path: "/mcp") + let sdkResponse = await transport.handleRequest(sdkRequest) + return sdkResponse.toVaporResponse() +} +``` + +Conversion extensions: +```swift +extension Vapor.Request { + func toMCPHTTPRequest(path: String) -> MCP.HTTPRequest { + HTTPRequest( + method: self.method.string, + headers: Dictionary(self.headers.map { ($0.name, $0.value) }, + uniquingKeysWith: { _, last in last }), + body: self.body.data.map { Data(buffer: $0) }, + path: path + ) + } +} + +extension MCP.HTTPResponse { + func toVaporResponse() -> Vapor.Response { + switch self { + case .accepted(let headers): + return Response(status: .accepted, headers: headers.toHTTPHeaders()) + case .ok(let headers): + return Response(status: .ok, headers: headers.toHTTPHeaders()) + case .data(let data, let headers): + return Response(status: .ok, headers: headers.toHTTPHeaders(), + body: .init(data: data)) + case .stream(let sseStream, let headers): + // Pipe AsyncThrowingStream to Vapor's streaming body + return Response(status: .ok, headers: headers.toHTTPHeaders(), + body: .init(asyncStream: sseStream)) + case .error(let statusCode, _, _, _): + return Response(status: HTTPStatus(statusCode: statusCode), + headers: self.headers.toHTTPHeaders(), + body: .init(data: self.bodyData ?? Data())) + } + } +} +``` + +### 7.4 Auth + +- **MCP endpoint**: Validated by SDKs `StandardValidationPipeline` — origin check (localhost), Accept header, Content-Type, protocol version, session validation. No UA check. +- **Stream Deck endpoints**: Keep UA prefix check (`neewerlite.sdPlugin/`), ported to Vapor middleware. +- The server is localhost-only (`127.0.0.1`). DNS rebinding protection is handled by the SDK's `OriginValidator.localhost()`. + +--- + +## 8. Client Registration + +### VS Code + +`.vscode/mcp.json` in any workspace: +```json +{ + "servers": { + "neewerlite": { + "type": "http", + "url": "http://127.0.0.1:18486/mcp" + } + } +} +``` + +Or globally in VS Code settings: +```json +{ + "mcp": { + "servers": { + "neewerlite": { + "type": "http", + "url": "http://127.0.0.1:18486/mcp" + } + } + } +} +``` + +### Claude Desktop + +`~/Library/Application Support/Claude/claude_desktop_config.json`: +```json +{ + "mcpServers": { + "neewerlite": { + "url": "http://127.0.0.1:18486/mcp" + } + } +} +``` + +### OpenClaw + +NeewerLite auto-writes this config when you enable the OpenClaw toggle in **Settings → MCP Clients**. The file is `~/.openclaw/openclaw.json`: + +```json +{ + "commands": { + "mcp": true + }, + "mcp": { + "servers": { + "neewerlite": { + "url": "http://127.0.0.1:18486/mcp", + "type": "streamable-http" + } + } + } +} +``` + +Key points: +- `commands.mcp: true` enables the MCP panel in OpenClaw's UI. +- `type: "streamable-http"` tells OpenClaw's Python runtime which transport to use. +- No `command` / `args` — NeewerLite is already running. +- OpenClaw's Python runtime may use `undici` for probe requests and `Python-urllib` for POST requests. NeewerLite normalises these to a single session key so no session drops occur. + +--- + +## 9. Use Cases + +### 9.1 Basic Control + +| User says | Agent calls | +|---|---| +| "Turn on all the lights" | `switch_light(lights:["all"], state:true)` | +| "Set key light to 5600K, 80% brightness" | `set_cct(lights:["KeyLight"], brightness:80, temperature:5600)` | +| "Make the fill light blue" | `set_hsi(lights:["FillLight"], hex_color:"0066FF", brightness:70, saturation:100)` | +| "What lights are connected?" | `list_lights()` | +| "Activate squad car mode" | `list_scenes(light:"KeyLight")` → `set_scene(lights:["KeyLight"], scene_id:10, color:2)` | + +### 9.2 Context-Aware Automation + +| Trigger | Workflow | +|---|---| +| "I'm on a Zoom call" | Agent sets CCT 5600K, brightness 80%, turns on key + fill lights | +| "Xcode build failed" | Agent flashes lights red via set_hsi → wait → switch_light off | +| "It's movie time" | Agent dims to 10% warm (3200K) bias lighting | +| "Good night" | Agent turns off all lights | + +### 9.3 Multi-Agent Composition + +Any MCP client can compose NeewerLite tools with other tools: + +- **Pomodoro agent:** Cycles light color across focus (warm) → break (cool) → alert (red flash). +- **Meeting agent:** Detects calendar events, adjusts lighting for camera-on vs. off. +- **CI agent:** Monitors build status, provides visual feedback through light color. + +--- + +## 10. Implementation Plan + +### Phase 1: Hand-Rolled MCP on Swifter ✅ DONE + +Proved the concept with a working MCP endpoint. All 8 tools E2E tested on real hardware. This code is replaced by Phase 2. + +| # | Task | Status | +|---|---|---| +| 1 | `POST /mcp` route + JSON-RPC dispatcher | ✅ | +| 2 | `initialize` handler | ✅ | +| 3 | `tools/list` — 8 tool schemas | ✅ | +| 4 | `tools/call` → 8 tool handlers | ✅ | +| 5 | "all" light expansion | ✅ | +| 6 | Skip UA check for `/mcp` | ✅ | +| 7 | `GET /mcp` → 405 | ✅ | +| 8 | 11 unit tests | ✅ | +| 9 | E2E test: all 8 tools on 3 real lights | ✅ | +| 10 | Fix `set_brightness` — direct device API | ✅ | +| 11 | Fix `set_hsi` — direct device API | ✅ | +| 12 | Fix `set_scene` — direct device API | ✅ | +| 13 | Scene sub-variants (color, speed, brightness) | ✅ | +| 14 | `list_scenes` with per-scene parameters | ✅ | + +### Phase 2: Vapor + MCP SDK Migration ✅ DONE + +Replaced Swifter + hand-rolled MCP with Vapor + official MCP Swift SDK. Final tool set: **11 tools** (`list_lights`, `switch_light`, `set_brightness`, `set_cct`, `set_hsi`, `set_scene`, `list_scenes`, `list_sources`, `list_gels`, `scan_lights`, `get_light_image`). + +| # | Task | Scope | Status | +|---|---|---|---| +| 15 | Add MCP SDK + Vapor to `Package.swift`, remove Swifter | Dependencies | ✅ | +| 16 | Create `MCP.Server` + `StatefulHTTPServerTransport` | Server init | ✅ | +| 17 | Register `ListTools` handler — 11 tool schemas using SDK's `Tool` type | Tool discovery | ✅ | +| 18 | Register `CallTool` handler — 11 tool implementations | Tool dispatch | ✅ | +| 19 | Vapor route adapter: `POST/GET/DELETE /mcp` → SDK transport | HTTP adapter | ✅ | +| 20 | Vapor ↔ SDK type conversion extensions (`Request` → `HTTPRequest`, `HTTPResponse` → `Response`) | Helpers | ✅ | +| 21 | Port Stream Deck routes to Vapor (same paths, no prefix change) | Route migration | ✅ | +| 22 | Port Stream Deck UA middleware to Vapor | Auth | ✅ | +| 23 | Vapor server start/stop lifecycle (integrate with AppDelegate toggle + Settings UI) | Lifecycle | ✅ | +| 24 | 44 unit tests — MCP protocol, tool discovery, Value numeric coercion, middleware, session isolation | Tests | ✅ | +| 25 | Build & run all 234 tests | Validation | ✅ | +| 26 | E2E test: tools on real lights via curl (mini, GL1C, NS02, RGB660 PRO) | Hardware verification | ✅ | +| 27 | Verify Stream Deck plugin still works | Regression | ✅ | +| 28 | Settings UI: HTTP server toggle + Launch at Login checkbox | New UI | ✅ | +| 29 | SRCMode tracking in `list_lights` output | Mode reporting | ✅ | +| 30 | Light source preset CCT/GM defaults (reset on selection) | Source presets | ✅ | +| 31 | Localize 10 light source names in 6 languages | Localization | ✅ | +| 32 | Fix source view slider width | UI fix | ✅ | + +### Phase 3: OpenClaw Integration + Session Isolation ✅ DONE + +| # | Task | Status | +|---|---|---| +| 33 | OpenClaw added to Settings MCP client list with auto config-write/remove | ✅ | +| 34 | Multi-path install detection (`/Applications/OpenClaw.app` OR `~/.openclaw`) | ✅ | +| 35 | Nested dotted key-path support in config read/write (`mcp.servers`) | ✅ | +| 36 | Write `commands.mcp: true` and `type: streamable-http` to openclaw.json | ✅ | +| 37 | `SessionManager` actor — per-client `MCPSessionContext` (transport+server), session-ID routing, TTL, 24-session cap | ✅ | +| 38 | Fix session race: bind `Mcp-Session-Id` before streaming response body | ✅ | +| 39 | Fix client-key normalisation: `UA@IP` for standard clients; `openclaw@IP` for undici/Python-urllib mixed stacks | ✅ | +| 40 | Legacy SSE transport: `GET /sse` persistent stream + `POST /messages` inbound queue (separate inbound/outbound `AsyncStream` channels) | ✅ | +| 41 | Restore UA enforcement on Stream Deck routes; remove loopback bypass | ✅ | +| 42 | Multi-client isolation tests: `DELETE` from one client must not break another | ✅ | +| 43 | Full suite green: 234 tests, 0 failures | ✅ | + +### Phase 4: Extended Capabilities (Future) + +Enabled by the SDK's protocol support — requires minimal code. + +| Capability | SDK Support | Notes | +|---|---|---| +| **Server-initiated notifications** (light connected/disconnected) | `server.notify()` + standalone GET SSE | Transport handles SSE piping | +| **MCP Resources** — light state as subscribable resources | `ListResources` + `ReadResource` handlers | Agents can watch for state changes | +| **Preset management** — save/recall named setups | New tool | "studio preset", "movie night" | +| **Sound-to-Light** — start/stop audio-reactive mode | New tool + STL engine hooks | "Start music mode" | +| **Progress tracking** for long operations | SDK's `ProgressNotification` | Scan progress, firmware updates | +| **Batch requests** | Handled automatically by SDK | Multiple tool calls in one HTTP request | + +--- + +## 11. Testing Strategy + +### 11.1 Unit Tests (44 tests in MCPServerTests.swift) + +- Tool discovery: 11 tools registered with correct names. +- Tool metadata: descriptions, required parameters, input schemas. +- `Value` numeric coercion: `numericInt`/`numericDouble` across int, double, fractional, negative, and string inputs. +- MCP protocol: initialize, tools/list, tools/call, SSE response format, GET probe stream. +- Session isolation: `DELETE` from one client must not break another active session. +- Middleware: UA enforcement for Stream Deck routes; MCP and legacy SSE routes bypass UA check. +- All 234 project tests passing (including 44 MCP tests). + +### 11.2 Integration Test + +Use the MCP Inspector: +```bash +npx @modelcontextprotocol/inspector +``` +Point it at `http://127.0.0.1:18486/mcp`. Verify: +- Initialization handshake succeeds. +- Tool list shows all 11 tools. +- Tool calls execute (check NeewerLite debug logs). + +### 11.3 E2E Test + +**VS Code Copilot:** +1. Launch NeewerLite (lights optional). +2. Add to `.vscode/mcp.json`: + ```json + { "servers": { "neewerlite": { "type": "http", "url": "http://127.0.0.1:18486/mcp" } } } + ``` +3. In VS Code Copilot: "List my Neewer lights." +4. Verify the agent calls `list_lights` and returns light info. +5. "Turn on the key light." → Verify light responds. + +**OpenClaw:** +1. Enable OpenClaw in **Settings → MCP Clients** (auto-writes `~/.openclaw/openclaw.json`). +2. Restart OpenClaw. +3. Verify the NeewerLite server entry appears in OpenClaw's MCP panel. +4. Issue a natural-language light command and confirm it executes. + +--- + +## 12. SessionManager — Multi-Client Isolation ✅ DONE + +### 12.1 Architecture + +`SessionManager` is a Swift `actor` that owns MCP session lifecycle and request routing. + +- `clientKey → MCPSessionContext` — maps client identity to a dedicated `StatefulHTTPServerTransport` + `MCP.Server` pair. +- `sessionId → clientKey` — reverse lookup so requests with `Mcp-Session-Id` skip initialise logic. +- TTL eviction and a hard cap of **24 sessions** keep memory bounded. + +`MCPSessionContext` contains: +- One `StatefulHTTPServerTransport` +- One `MCP.Server` (started on creation) +- `sessionID: String?`, `lastSeen: Date` + +### 12.2 Request Routing + +1. **Initialize (no `Mcp-Session-Id`)** — resolve client key (`UA@IP`), `getOrCreateContext`, forward through that context's transport, bind returned `Mcp-Session-Id`. +2. **Normal flow (with `Mcp-Session-Id`)** — look up by session ID, forward to its context. +3. **DELETE** — terminate and remove only the targeted context; other clients unaffected. + +### 12.3 Client Key Strategy + +| Client type | Key format | Reason | +|---|---|---| +| Standard clients (VS Code, Cursor, …) | `UserAgent@IP` | Preserves isolation when multiple tools share loopback | +| OpenClaw (undici probe + Python-urllib POST) | `openclaw@IP` | Different UA per request from same logical client | + +### 12.4 Security Hardening + +Same defences as originally planned, now implemented: +- `maxSessions = 24` process-wide cap +- `getOrCreateContext` — reuse existing context for same client key (1 session per client) +- Session TTL cleanup on each request +- Fast-fail for malformed requests before any transport allocation + + + +--- + +## 13. Open Questions + +| # | Question | Resolution | +|---|---|---| +| 1 | ~~Does Swifter support chunked/SSE responses?~~ | ✅ Resolved — migrated to Vapor + MCP SDK. SSE handled by `StatefulHTTPServerTransport`. | +| 2 | Should `list_lights` return human-readable text or structured JSON? | ✅ Resolved — returns human-readable text. Agents reason better with natural language; structured data can be added as a second content block later if needed. | +| 3 | Do we want MCP Prompts (pre-built templates like "video call setup")? | Open — deferred to Phase 3. | +| 4 | ~~Same port or separate port?~~ | ✅ Resolved — same port (18486). Stream Deck routes at their original paths; MCP on `/mcp`; legacy SSE on `/sse`+`/messages`. | +| 5 | Should `GET /sse` + `POST /messages` legacy transport be permanent or temporary? | Open — keep until OpenClaw ships native Streamable HTTP support. | +| 6 | BLE command serialization for concurrent clients? | Open — `BLECommandCoordinator` deferred to a future phase. Current behaviour: BLE is naturally serialized by Swift actor isolation on the light model. | + +--- + +## References + +- [MCP Specification — Streamable HTTP Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) +- [MCP Specification — Lifecycle](https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle) +- [MCP Specification — Tools](https://modelcontextprotocol.io/specification/2025-03-26/server/tools) +- [NeewerLite HTTP Server](../NeewerLite/NeewerLite/Server.swift) +- [NeewerLite Codebase Guide](./Codebase-Guide.md) + +> **Note:** This file was previously named `OpenClaw-Integration.md`. diff --git a/Docs/Codebase-Guide.md b/Docs/Codebase-Guide.md index 7d4b2a0..799f21e 100644 --- a/Docs/Codebase-Guide.md +++ b/Docs/Codebase-Guide.md @@ -38,7 +38,8 @@ NeewerLite is a **native macOS app** (Swift, AppKit) that controls Neewer Blueto **Minimum deployment target:** macOS 13 (Ventura) **Dependencies** (via SPM): -- [Swifter](https://github.com/httpswift/swifter) — Lightweight HTTP server +- [Vapor](https://github.com/vapor/vapor) — HTTP server framework +- [MCP Swift SDK](https://github.com/modelcontextprotocol/swift-sdk) — Model Context Protocol server (0.12.0) - [Sparkle](https://github.com/sparkle-project/Sparkle) — Auto-update framework - [swift-atomics](https://github.com/apple/swift-atomics) — Lock-free atomic operations @@ -76,13 +77,13 @@ NeewerLite/ ← Root │ │ ├── AppDelegate.swift ← App lifecycle, BLE scanning, UI orchestration │ │ ├── ContentManager.swift ← Light database loading & caching │ │ ├── NeewerLiteApplication.swift ← Custom NSApplication (suppress activation) -│ │ ├── Server.swift ← HTTP API (localhost:18486) +│ │ ├── Server.swift ← HTTP + MCP server (localhost:18486) │ │ │ │ │ ├── Model/ ← Data model │ │ │ ├── NeewerLight.swift ← Core light model + BLE comms │ │ │ ├── NeewerLightConstant.swift ← BLE constants, type mapping │ │ │ ├── NeewerLightFX.swift ← Scene effect definitions -│ │ │ ├── NeewerLightSource.swift ← Light source presets +│ │ │ ├── NeewerLightSource.swift ← Light source presets (with default CCT/GM) │ │ │ ├── Command.swift ← URL scheme command routing │ │ │ ├── CommandPatternParser.swift ← BLE command template engine │ │ │ ├── NeewerGel.swift ← Gel presets + stacking math @@ -99,6 +100,10 @@ NeewerLite/ ← Root │ │ │ ├── NSBezierPathExtensions.swift │ │ │ └── Utils.swift │ │ │ +│ │ ├── Views/ +│ │ │ ├── SettingsView.swift ← Settings: launch at login, server toggle +│ │ │ └── ... ← (see View Layer section) +│ │ │ │ │ ├── Spectrogram/ ← Sound-to-Light engine │ │ │ ├── AudioSpectrogram.swift ← Audio capture + mel-spectrogram │ │ │ ├── AudioAnalysisEngine.swift ← Feature extraction + beat detection @@ -135,7 +140,9 @@ NeewerLite/ ← Root │ ├── CommandParserTests.swift ← BLE command generation │ ├── AudioAnalysisEngineTests.swift ← Audio feature extraction │ ├── SoundToLightModeTests.swift ← Mapping modes + reactivity -│ └── GelsTests.swift ← Gel stacking math +│ ├── GelsTests.swift ← Gel stacking math +│ ├── MCPServerTests.swift ← MCP tool discovery & Value coercion +│ └── StringLocalizedTests.swift ← Localization string tests │ ├── NeewerLiteStreamDeck/ ← Elgato Stream Deck plugin │ ├── build.sh ← Build & package plugin @@ -218,7 +225,7 @@ cd Tools │ │ │ │ │ │ │ ┌───────┴───────┐ │ ┌───────┴──────┐ │ ┌─────────────────┐│ │ │ CBCentralMgr │ │ │ Server │ │ │ ContentManager ││ -│ │ (BLE scan & │ │ │ (HTTP API │ │ │ (Light DB, ││ +│ │ (BLE scan & │ │ │ (HTTP+MCP │ │ │ (Light DB, ││ │ │ connection) │ │ │ port 18486) │ │ │ remote fetch) ││ │ └───────┬───────┘ │ └──────────────┘ │ └─────────────────┘│ │ │ │ │ │ @@ -322,7 +329,7 @@ A timer fires every **10 seconds** per connected light, sending a read request o The core model representing a single physical LED light. Holds: - **BLE state**: `peripheral: CBPeripheral`, `deviceCtlCharacteristic`, `gattCharacteristic` -- **Light state** (all `Observable`): `isOn`, `brrValue`, `cctValue`, `hueValue`, `satValue`, `gmmValue`, `channel` +- **Light state** (all `Observable`): `isOn`, `brrValue`, `cctValue`, `hueValue`, `satValue`, `gmmValue`, `channel`, `sourceChannel` - **Identity**: `userLightName`, `projectName`, `nickName`, `lightType: UInt8` - **Capabilities**: `supportRGB`, `supportCCTGM`, `supportMusic`, `support9FX`, `support17FX`, `cctRange` - **Sound-to-Light**: `followMusic: Bool` — whether this light follows the audio engine @@ -355,6 +362,17 @@ Static utilities for: - **CCT range**: Per-type min/max Kelvin (default 32–56, extended to 85 for SL80/SL140) - **FX/Source lookup**: `getLightFX(lightType:)`, `getLightSources(lightType:)` → arrays from database +### NeewerLightSource (`Model/NeewerLightSource.swift`) + +Light source presets (Sunlight, Halogen, Tungsten, etc.) loaded from the database. Each source has: +- `id`, `name` (localized), `iconName` +- `cmdPattern` / `defaultCmdPattern` — BLE command templates +- `needBRR`, `needCCT`, `needGM` — which sliders to show +- `featureValues` — per-source parameter dictionary +- `defaultCCTValue`, `defaultGMValue` — factory-set defaults (not persisted via Codable), reset on each source selection so slider changes don't permanently mutate the preset + +10 factory presets with calibrated CCT/GM defaults: Sunlight (56K/+4), White Halogen (32K/+2), Xenon short-arc (60K/−8), Horizon daylight (25K/+8), Daylight (55K/0), Tungsten (32K/−4), Studio Bulb (34K/−2), Modeling Lights (45K/0), Dysprosic (58K/−6), HMI6000 (60K/+2). + ### Command (`Model/Command.swift`) URL scheme command routing. Defines: @@ -542,16 +560,34 @@ If `light` is omitted, the command targets all connected lights. ### HTTP Server (port 18486) -For Stream Deck plugin and programmatic control: +The server (built on Vapor) hosts both the Stream Deck HTTP API and a Model Context Protocol (MCP) endpoint. + +**Stream Deck HTTP routes** (require `User-Agent: neewerlite.sdPlugin/*`): | Endpoint | Method | Purpose | |----------|--------|---------| -| `/listLights` | GET | JSON array of connected lights with state | -| `/ping` | GET | Health check (`{"status": "pong"}`) | -| `/switch` | POST | Toggle lights by ID/name | -| `/setLight` | POST | Set light parameters (CCT/HSI/Scene) | +| `/sd/listLights` | GET | JSON array of connected lights with state | +| `/sd/ping` | GET | Health check (`{"status": "pong"}`) | +| `/sd/switch` | POST | Toggle lights by ID/name | +| `/sd/setLight` | POST | Set light parameters (CCT/HSI/Scene) | + +**MCP endpoint** (`POST /mcp`): + +Exposes light control to AI assistants and automation tools via the [Model Context Protocol](https://modelcontextprotocol.io). Uses `StatefulHTTPServerTransport` from the MCP Swift SDK. + +| Tool | Description | +|------|-------------| +| `list_lights` | List all lights with state, mode, and capabilities | +| `turn_on` | Turn on lights by name/index | +| `turn_off` | Turn off lights by name/index | +| `set_light_cct` | Set CCT mode (brightness, color temperature, GM) | +| `set_light_hsi` | Set HSI mode (hue, saturation, brightness) | +| `set_light_scene` | Set scene effect by name | +| `get_light_image` | Get product image for a light | +| `scan` | Trigger BLE scan for new lights | +| `get_logs` | Retrieve recent app logs | -**Authentication:** All requests must include `User-Agent: neewerlite.sdPlugin/*` header. +The server can be enabled/disabled from Settings (persisted as `HTTPServerEnabled` in UserDefaults, defaults to on). ### Custom NSApplication @@ -640,20 +676,22 @@ Two top-level arrays: `lights` (60+ entries) and `gels` (39 entries). ## Testing -**103 tests**, all under `NeewerLiteTests/`: +**222 tests**, all under `NeewerLiteTests/`: | File | Tests | Coverage | -|------|-------|----------| -| `NeewerLiteTests.swift` | ~18 | Light name parsing, type mapping from BLE names | -| `CommandParserTests.swift` | ~25 | BLE command generation: power, CCT, HSI, range validation, checksum | -| `AudioAnalysisEngineTests.swift` | ~24 | Silence, per-band isolation, AGC, beat detection, spectral flux | -| `SoundToLightModeTests.swift` | ~33 | PulseMode, ColorFlowMode, BassCannon, reactivity scaling, palette, presets | -| `GelsTests.swift` | ~3 | Subtractive mixing, mired addition, transmission compounding | +|------|-------|---------| +| `NeewerLiteTests.swift` | 3 | Light name parsing, type mapping from BLE names | +| `CommandParserTests.swift` | 57 | BLE command generation: power, CCT, HSI, range validation, checksum | +| `AudioAnalysisEngineTests.swift` | 45 | Silence, per-band isolation, AGC, beat detection, spectral flux | +| `SoundToLightModeTests.swift` | 47 | PulseMode, ColorFlowMode, BassCannon, reactivity scaling, palette, presets | +| `GelsTests.swift` | 24 | Subtractive mixing, mired addition, transmission compounding | +| `MCPServerTests.swift` | 34 | MCP tool discovery, Value numeric coercion, tool metadata | +| `StringLocalizedTests.swift` | 12 | Localization string lookups and fallbacks | **Run:** ```bash cd NeewerLite/NeewerLite -xcodebuild test -project NeewerLite.xcodeproj -scheme NeewerLiteTests -destination 'platform=macOS' +xcodebuild test -project NeewerLite.xcodeproj -scheme NeewerLite -destination 'platform=macOS' ``` --- @@ -771,8 +809,8 @@ Zero code changes needed: ### "I want to add a new HTTP endpoint" 1. Open `Server.swift` -2. Add route handler (follow existing `/listLights` pattern) -3. Remember to respect the User-Agent authentication middleware +2. For Stream Deck routes: add handler in the `/sd` group (auth middleware applies) +3. For MCP tools: add a `Tool` entry in `registerMCPTools()` and a handler in the tool dispatch switch 4. Update Stream Deck plugin `ipc.ts` if the SD plugin should use it ### "I want to add a new URL scheme command" diff --git a/NeewerLite/.swiftlint.yml b/NeewerLite/.swiftlint.yml index 711d96d..de17c41 100644 --- a/NeewerLite/.swiftlint.yml +++ b/NeewerLite/.swiftlint.yml @@ -17,16 +17,6 @@ line_length: type_name: allowed_symbols: "_" -excluded: - - Package.swift - - .build - - .swiftpm - - DerivedData - - Carthage - - Pods - - .build/** - - "**/Package.swift" - - "**/.build/**" - - "**/checkouts/**" - - "**/SourcePackages/**" - - "**/DerivedData/**" +included: + - NeewerLite + - NeewerLiteTests diff --git a/NeewerLite/NeewerLite.xcodeproj/project.pbxproj b/NeewerLite/NeewerLite.xcodeproj/project.pbxproj index ad0c684..50a0cbd 100644 --- a/NeewerLite/NeewerLite.xcodeproj/project.pbxproj +++ b/NeewerLite/NeewerLite.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ A708F58A2E33006E00564DCF /* CommandParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A708F5892E33006E00564DCF /* CommandParserTests.swift */; }; + A7MCP0012F13A00000000001 /* MCPServerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7MCP0012F13A00000000002 /* MCPServerTests.swift */; }; A708F58C2E3300A300564DCF /* CommandPatternParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A708F58B2E3300A300564DCF /* CommandPatternParser.swift */; }; A708F5902E333E8E00564DCF /* PatternEditorPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A708F58F2E333E8E00564DCF /* PatternEditorPanel.swift */; }; A76C38702E00000000000001 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76C38712E00000000000001 /* SettingsView.swift */; }; @@ -36,11 +37,13 @@ A753109D2AFC996F009F533C /* StorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A753109C2AFC996F009F533C /* StorageManager.swift */; }; A753109F2AFCAA25009F533C /* NeewerLightSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A753109E2AFCAA25009F533C /* NeewerLightSource.swift */; }; A75310A12AFF2A19009F533C /* ContentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75310A02AFF2A19009F533C /* ContentManager.swift */; }; - A76C38632DFD4B64006B7C38 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = A76C38622DFD4B64006B7C38 /* Swifter */; }; + A7F1A2B32F10000000000004 /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = A7F1A2B32F10000000000002 /* Vapor */; }; + A7F1A2B32F20000000000004 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = A7F1A2B32F20000000000002 /* MCP */; }; A76C38692DFFFCFA006B7C38 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76C38682DFFFCFA006B7C38 /* Utils.swift */; }; A76C386F2E002B95006B7C38 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A76C386E2E002B95006B7C38 /* Sparkle */; }; A76C38712E002B98006B7C38 /* Atomics in Frameworks */ = {isa = PBXBuildFile; productRef = A76C38702E002B98006B7C38 /* Atomics */; }; - A76C38732E002B9A006B7C38 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = A76C38722E002B9A006B7C38 /* Swifter */; }; + A7F1A2B32F10000000000005 /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = A7F1A2B32F10000000000003 /* Vapor */; }; + A7F1A2B32F20000000000005 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = A7F1A2B32F20000000000003 /* MCP */; }; A76C4B6D275474FA00D2F145 /* AudioSpectrogram.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76C4B6C275474FA00D2F145 /* AudioSpectrogram.swift */; }; A76E1F21266496FC00E5788B /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A76E1F20266496FC00E5788B /* Sparkle */; }; A76E1F2326649F2A00E5788B /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = A76E1F2226649F2A00E5788B /* Credits.rtf */; }; @@ -84,6 +87,7 @@ /* Begin PBXFileReference section */ A708F5892E33006E00564DCF /* CommandParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandParserTests.swift; sourceTree = ""; }; + A7MCP0012F13A00000000002 /* MCPServerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPServerTests.swift; sourceTree = ""; }; A708F58B2E3300A300564DCF /* CommandPatternParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPatternParser.swift; sourceTree = ""; }; A708F58F2E333E8E00564DCF /* PatternEditorPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatternEditorPanel.swift; sourceTree = ""; }; A76C38712E00000000000001 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -153,7 +157,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A76C38632DFD4B64006B7C38 /* Swifter in Frameworks */, + A7F1A2B32F10000000000004 /* Vapor in Frameworks */, + A7F1A2B32F20000000000004 /* MCP in Frameworks */, A76E1F21266496FC00E5788B /* Sparkle in Frameworks */, A71B39BA275726800005E271 /* Atomics in Frameworks */, ); @@ -163,7 +168,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A76C38732E002B9A006B7C38 /* Swifter in Frameworks */, A76C386F2E002B95006B7C38 /* Sparkle in Frameworks */, A76C38712E002B98006B7C38 /* Atomics in Frameworks */, ); @@ -226,6 +230,7 @@ isa = PBXGroup; children = ( A708F5892E33006E00564DCF /* CommandParserTests.swift */, + A7MCP0012F13A00000000002 /* MCPServerTests.swift */, B5D83B6A59A5B72CDFC3ED5E /* GelsTests.swift */, B2C3D4E5F6A7B8C9D0E1F3A1 /* AudioAnalysisEngineTests.swift */, D3E4F5A6B7C8D9E0F1A2B3C4 /* SoundToLightModeTests.swift */, @@ -349,7 +354,8 @@ packageProductDependencies = ( A76E1F20266496FC00E5788B /* Sparkle */, A71B39B9275726800005E271 /* Atomics */, - A76C38622DFD4B64006B7C38 /* Swifter */, + A7F1A2B32F10000000000002 /* Vapor */, + A7F1A2B32F20000000000002 /* MCP */, ); productName = NeewerLite; productReference = A731DD3025A57D0B00302E25 /* NeewerLite.app */; @@ -369,6 +375,9 @@ A731DD4225A57D0B00302E25 /* PBXTargetDependency */, ); name = NeewerLiteTests; + packageProductDependencies = ( + A7F1A2B32F20000000000003 /* MCP */, + ); productName = NeewerLiteTests; productReference = A731DD4025A57D0B00302E25 /* NeewerLiteTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -409,7 +418,8 @@ packageReferences = ( A76E1F1F266496FC00E5788B /* XCRemoteSwiftPackageReference "Sparkle" */, A71B39B8275726800005E271 /* XCRemoteSwiftPackageReference "swift-atomics" */, - A7B4822A2DFD49C200EB4450 /* XCRemoteSwiftPackageReference "swifter" */, + A7F1A2B32F10000000000001 /* XCRemoteSwiftPackageReference "vapor" */, + A7F1A2B32F20000000000001 /* XCRemoteSwiftPackageReference "swift-sdk" */, ); productRefGroup = A731DD3125A57D0B00302E25 /* Products */; projectDirPath = ""; @@ -522,6 +532,7 @@ buildActionMask = 2147483647; files = ( A708F58A2E33006E00564DCF /* CommandParserTests.swift in Sources */, + A7MCP0012F13A00000000001 /* MCPServerTests.swift in Sources */, CCF4C298B8A837A3ED2D4DDB /* GelsTests.swift in Sources */, A1B2C3D4E5F6A7B8C9D0E1F3 /* AudioAnalysisEngineTests.swift in Sources */, E2F3A4B5C6D7E8F9A0B1C2D3 /* SoundToLightModeTests.swift in Sources */, @@ -816,12 +827,20 @@ minimumVersion = 1.26.0; }; }; - A7B4822A2DFD49C200EB4450 /* XCRemoteSwiftPackageReference "swifter" */ = { + A7F1A2B32F10000000000001 /* XCRemoteSwiftPackageReference "vapor" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/httpswift/swifter"; + repositoryURL = "https://github.com/vapor/vapor.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.5.0; + minimumVersion = 4.89.0; + }; + }; + A7F1A2B32F20000000000001 /* XCRemoteSwiftPackageReference "swift-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/modelcontextprotocol/swift-sdk.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.12.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -832,10 +851,15 @@ package = A71B39B8275726800005E271 /* XCRemoteSwiftPackageReference "swift-atomics" */; productName = Atomics; }; - A76C38622DFD4B64006B7C38 /* Swifter */ = { + A7F1A2B32F10000000000002 /* Vapor */ = { + isa = XCSwiftPackageProductDependency; + package = A7F1A2B32F10000000000001 /* XCRemoteSwiftPackageReference "vapor" */; + productName = Vapor; + }; + A7F1A2B32F20000000000002 /* MCP */ = { isa = XCSwiftPackageProductDependency; - package = A7B4822A2DFD49C200EB4450 /* XCRemoteSwiftPackageReference "swifter" */; - productName = Swifter; + package = A7F1A2B32F20000000000001 /* XCRemoteSwiftPackageReference "swift-sdk" */; + productName = MCP; }; A76C386E2E002B95006B7C38 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; @@ -847,10 +871,15 @@ package = A71B39B8275726800005E271 /* XCRemoteSwiftPackageReference "swift-atomics" */; productName = Atomics; }; - A76C38722E002B9A006B7C38 /* Swifter */ = { + A7F1A2B32F10000000000003 /* Vapor */ = { + isa = XCSwiftPackageProductDependency; + package = A7F1A2B32F10000000000001 /* XCRemoteSwiftPackageReference "vapor" */; + productName = Vapor; + }; + A7F1A2B32F20000000000003 /* MCP */ = { isa = XCSwiftPackageProductDependency; - package = A7B4822A2DFD49C200EB4450 /* XCRemoteSwiftPackageReference "swifter" */; - productName = Swifter; + package = A7F1A2B32F20000000000001 /* XCRemoteSwiftPackageReference "swift-sdk" */; + productName = MCP; }; A76E1F20266496FC00E5788B /* Sparkle */ = { isa = XCSwiftPackageProductDependency; diff --git a/NeewerLite/NeewerLite.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NeewerLite/NeewerLite.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 812374e..f44753f 100644 --- a/NeewerLite/NeewerLite.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NeewerLite/NeewerLite.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,60 @@ { - "originHash" : "cfcef09ad624c92ac736fa420ddcfe155e22a902ae4a1344c46d41b5dc5ce2fd", + "originHash" : "e3c144b170ca43145d077f41bf60bab512f08b438e218aa0116d98e9e38512c9", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9", + "version" : "1.22.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", + "version" : "4.16.0" + } + }, + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/eventsource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -10,6 +64,33 @@ "version" : "1.27.3" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", @@ -20,12 +101,192 @@ } }, { - "identity" : "swifter", + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "5aa1c0d1bc204908df47c2075bdbb39573d05e8d", + "version" : "1.19.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "476538ccb827f2dd18efc5de754cc87d77127a47", + "version" : "4.4.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "c6593dac9d65f2517280d88c430dadffdf259737", + "version" : "2.10.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd6710454f25733900e133c6caf5188952763c36", + "version" : "2.98.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "9d4e67af1eea85967c7de778ad73e7776e5f1f22", + "version" : "1.27.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", + "state" : { + "revision" : "6132fd4b5b4217ce4717c4775e4607f5c3120129", + "version" : "0.12.0" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "cfd8f434843ac7850e2d97f46c1aa5ddb906cf1c", + "version" : "4.121.4" + } + }, + { + "identity" : "websocket-kit", "kind" : "remoteSourceControl", - "location" : "https://github.com/httpswift/swifter", + "location" : "https://github.com/vapor/websocket-kit.git", "state" : { - "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", - "version" : "1.5.0" + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" } } ], diff --git a/NeewerLite/NeewerLite.xcodeproj/xcshareddata/xcschemes/NeewerLite.xcscheme b/NeewerLite/NeewerLite.xcodeproj/xcshareddata/xcschemes/NeewerLite.xcscheme index 9b164b5..8dc3cd5 100644 --- a/NeewerLite/NeewerLite.xcodeproj/xcshareddata/xcschemes/NeewerLite.xcscheme +++ b/NeewerLite/NeewerLite.xcodeproj/xcshareddata/xcschemes/NeewerLite.xcscheme @@ -29,7 +29,7 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + skipped = "NO"> = [] var cbCentralManager: CBCentralManager? /* @@ -178,6 +179,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele ContentManager.shared.downloadDatabase(force: false) loadLightsFromDisk() + pendingLaunchPowerOffIDs = Set(viewObjects.map(\ .deviceIdentifier)) restoreFollowMusicSelections() self.updateUI() @@ -187,7 +189,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele self.switchViewAction(self.viewsButton) server = NeewerLiteServer(appDelegate: self) - server!.start() + if UserDefaults.standard.object(forKey: "HTTPServerEnabled") == nil { + UserDefaults.standard.set(true, forKey: "HTTPServerEnabled") + } + if UserDefaults.standard.bool(forKey: "HTTPServerEnabled") { + server!.start() + } commonJobTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in self?.commonJob() @@ -375,6 +382,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele } } + private func forcePowerOffAfterLaunchIfNeeded(_ viewObject: DeviceViewObject) { + guard pendingLaunchPowerOffIDs.contains(viewObject.deviceIdentifier) else { + return + } + pendingLaunchPowerOffIDs.remove(viewObject.deviceIdentifier) + Logger.info(LogTag.app, "Force power off restored light after app launch: \(viewObject.deviceIdentifier)") + viewObject.turnOffLight() + } + func saveLightsToDisk() { if debugFakeLights { return @@ -697,6 +713,43 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele })) } + private func setHTTPServerEnabled(_ enabled: Bool) { + let current = UserDefaults.standard.bool(forKey: "HTTPServerEnabled") + if current == enabled { + Logger.info(LogTag.server, "HTTP server already \(enabled ? "enabled" : "disabled")") + return + } + + UserDefaults.standard.set(enabled, forKey: "HTTPServerEnabled") + + if enabled { + if server == nil { + server = NeewerLiteServer(appDelegate: self) + } + server?.start() + Logger.info(LogTag.server, "HTTP server enabled via URL command") + } else { + server?.stop() + Logger.info(LogTag.server, "HTTP server disabled via URL command") + } + } + + private func handleSwitchHTTPServerURL(rawHost: String) -> Bool { + guard rawHost.lowercased().hasPrefix("switch_http_server=") else { return false } + let value = String(rawHost.dropFirst("switch_http_server=".count)).lowercased() + switch value { + case "on", "true", "1": + setHTTPServerEnabled(true) + return true + case "off", "false", "0": + setHTTPServerEnabled(false) + return true + default: + Logger.error("Invalid switch_http_server value: \(value). Use on/off.") + return true + } + } + private var stlDebugFrameCount: UInt64 = 0 private func driveLightFromFrequency(_ frequency: [Float]) { @@ -1559,6 +1612,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele return } let cmd = components.host ?? "" + + // Supports deep links like: neewerlite://switch_http_server=on + if handleSwitchHTTPServerURL(rawHost: cmd) { + return + } + commandHandler.execute(commandName: cmd, components: components) } @@ -1669,6 +1728,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele peripheral, characteristic1, characteristic2) } targetViewObject.device.startLightOnNotify() + forcePowerOffAfterLaunchIfNeeded(targetViewObject) } if !found { diff --git a/NeewerLite/NeewerLite/Common/ColorUtils.swift b/NeewerLite/NeewerLite/Common/ColorUtils.swift index 7437968..0f50c84 100644 --- a/NeewerLite/NeewerLite/Common/ColorUtils.swift +++ b/NeewerLite/NeewerLite/Common/ColorUtils.swift @@ -24,6 +24,23 @@ struct HSB { var alpha: CGFloat } +struct NormalizedHSIInput { + var hueDegrees: CGFloat + var saturationUnit: CGFloat + var brightnessUnit: Double? +} + +func normalizeHSIInput(hueDegrees: CGFloat, saturation: CGFloat, brightness: Double?) -> NormalizedHSIInput { + let normalizedHue = min(max(hueDegrees, 0.0), 360.0) + let normalizedSaturation = min(max(saturation > 1.0 ? saturation / 100.0 : saturation, 0.0), 1.0) + let normalizedBrightness = brightness.map { min(max($0 > 1.0 ? $0 / 100.0 : $0, 0.0), 1.0) } + return NormalizedHSIInput( + hueDegrees: normalizedHue, + saturationUnit: normalizedSaturation, + brightnessUnit: normalizedBrightness + ) +} + func hsv2rgb(_ hsv: HSB) -> RGB { // Converts HSV to a RGB color var rgb: RGB = RGB(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.0) diff --git a/NeewerLite/NeewerLite/Model/NeewerLight.swift b/NeewerLite/NeewerLite/Model/NeewerLight.swift index 80cf558..43fa983 100644 --- a/NeewerLite/NeewerLite/Model/NeewerLight.swift +++ b/NeewerLite/NeewerLite/Model/NeewerLight.swift @@ -80,6 +80,7 @@ class NeewerLight: NSObject, ObservableNeewerLightProtocol { var supportedFX: [NeewerLightFX] = [] var supportedMusicFX: [NeewerLightFX] = [] var supportedSource: [NeewerLightSource] = [] + var sourceChannel: Observable = Observable(0) // active light source id (0 = none) var musicChannel: UInt8 = 0 var connectionBreakCounter: Int = 0 // if connection break too many times which mean this light disappeared from bluetooth fabric. @@ -134,7 +135,8 @@ class NeewerLight: NSObject, ObservableNeewerLightProtocol { // Assuming both `fxs` and `supportedFX` are of the same type and contain unique ids. supportedSource = sources.map { fx2 in let newFx = fx2 - if let matchingFx = supportedSource.first(where: { $0.id == fx2.id }) { + if newFx.cmdPattern != nil, + let matchingFx = supportedSource.first(where: { $0.id == fx2.id }) { newFx.featureValues = matchingFx.featureValues } return newFx @@ -376,6 +378,7 @@ class NeewerLight: NSObject, ObservableNeewerLightProtocol { vals["supportedMusicFX"] = CodableValue.fxsValue(supportedMusicFX) vals["supportedSource"] = CodableValue.sourcesValue(supportedSource) vals["musicChn"] = CodableValue.uint8Value(musicChannel) + vals["srcChn"] = CodableValue.intValue(Int(sourceChannel.value)) vals["lastTab"] = CodableValue.stringValue(lastTab) } else { vals["type"] = CodableValue.uint8Value(_lightType) @@ -403,6 +406,8 @@ class NeewerLight: NSObject, ObservableNeewerLightProtocol { lightMode = .HSIMode } else if UInt8(val) == NeewerLight.Mode.SCEMode.rawValue { lightMode = .SCEMode + } else if UInt8(val) == NeewerLight.Mode.SRCMode.rawValue { + lightMode = .SRCMode } } @@ -424,6 +429,7 @@ class NeewerLight: NSObject, ObservableNeewerLightProtocol { } musicChannel = config["musicChn"]?.uint8Value ?? 0 + sourceChannel.value = UInt16(config["srcChn"]?.intValue ?? 0) if let val = config["supportedSource"]?.sourcesValue { supportedSource.removeAll() diff --git a/NeewerLite/NeewerLite/Model/NeewerLightSource.swift b/NeewerLite/NeewerLite/Model/NeewerLightSource.swift index ccf16bf..398ec64 100644 --- a/NeewerLite/NeewerLite/Model/NeewerLightSource.swift +++ b/NeewerLite/NeewerLite/Model/NeewerLightSource.swift @@ -20,6 +20,14 @@ class NeewerLightSource: NSObject, Codable { var needGM: Bool = false var featureValues: [String: CGFloat] = [:] + /// Original preset CCT/GM — not persisted, always set from factory methods. + var defaultCCTValue: CGFloat? + var defaultGMValue: CGFloat? + + private enum CodingKeys: String, CodingKey { + case id, name, cmdPattern, defaultCmdPattern, iconName + case needBRR, needCCT, needGM, featureValues + } init(id: UInt16, name: String) { self.id = id @@ -87,6 +95,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 56 // 5600K + scene.gmValue = 4 + scene.defaultCCTValue = 56 + scene.defaultGMValue = 4 return scene } @@ -95,6 +107,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 32 // 3200K + scene.gmValue = 2 + scene.defaultCCTValue = 32 + scene.defaultGMValue = 2 return scene } @@ -103,6 +119,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 60 // 6000K + scene.gmValue = -8 + scene.defaultCCTValue = 60 + scene.defaultGMValue = -8 return scene } @@ -111,6 +131,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 25 // 2500K — golden hour + scene.gmValue = 8 + scene.defaultCCTValue = 25 + scene.defaultGMValue = 8 return scene } @@ -119,6 +143,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 55 // 5500K + scene.gmValue = 0 + scene.defaultCCTValue = 55 + scene.defaultGMValue = 0 return scene } @@ -127,6 +155,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 32 // 3200K + scene.gmValue = -4 + scene.defaultCCTValue = 32 + scene.defaultGMValue = -4 return scene } @@ -135,6 +167,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 34 // 3400K + scene.gmValue = -2 + scene.defaultCCTValue = 34 + scene.defaultGMValue = -2 return scene } @@ -143,6 +179,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 45 // 4500K + scene.gmValue = 0 + scene.defaultCCTValue = 45 + scene.defaultGMValue = 0 return scene } @@ -151,6 +191,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 58 // 5800K + scene.gmValue = -6 + scene.defaultCCTValue = 58 + scene.defaultGMValue = -6 return scene } @@ -159,6 +203,10 @@ extension NeewerLightSource { scene.needBRR = true scene.needCCT = true scene.needGM = true + scene.cctValue = 60 // 6000K + scene.gmValue = 2 + scene.defaultCCTValue = 60 + scene.defaultGMValue = 2 return scene } } diff --git a/NeewerLite/NeewerLite/Resources/Localizable.xcstrings b/NeewerLite/NeewerLite/Resources/Localizable.xcstrings index 6ed3f2a..49412e6 100644 --- a/NeewerLite/NeewerLite/Resources/Localizable.xcstrings +++ b/NeewerLite/NeewerLite/Resources/Localizable.xcstrings @@ -2215,6 +2215,47 @@ } } }, + "Daylight" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tageslicht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luz diurna" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lumière du jour" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "昼光" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주광" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日光" + } + } + } + }, "Deep Sea" : { "extractionState" : "stale", "localizations" : { @@ -2625,6 +2666,47 @@ } } }, + "Dysprosic lamp" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dysprosiumlampe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lámpara de disprosio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lampe au dysprosium" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ジスプロシウムランプ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스프로슘 램프" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "镝灯" + } + } + } + }, "Edit Command Patterns" : { "extractionState" : "stale", "localizations" : { @@ -2748,6 +2830,211 @@ } } }, + "Enables Stream Deck and MCP (AI agent) integration." : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiviert Stream Deck- und MCP (KI-Agent)-Integration." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilita la integración con Stream Deck y MCP (agente de IA)." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active l'intégration Stream Deck et MCP (agent IA)." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stream DeckおよびMCP(AIエージェント)連携を有効にします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stream Deck 및 MCP(AI 에이전트) 연동을 활성화합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 Stream Deck 和 MCP(AI 代理)集成。" + } + } + } + }, + "MCP Clients" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP-Clients" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clientes MCP" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clients MCP" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCPクライアント" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 클라이언트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 客户端" + } + } + } + }, + "Not installed" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht installiert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No instalado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non installé" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未インストール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설치되지 않음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未安装" + } + } + } + }, + "Copy MCP Config" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP-Konfiguration kopieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar configuración MCP" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copier la config MCP" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP設定をコピー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 설정 복사" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制 MCP 配置" + } + } + } + }, + "Check a client to write NeewerLite into its MCP config file." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle einen Client aus, um NeewerLite in seine MCP-Konfigurationsdatei einzutragen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecciona un cliente para añadir NeewerLite a su archivo de configuración MCP." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cochez un client pour écrire NeewerLite dans son fichier de configuration MCP." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クライアントをチェックして、NeewerLite をMCP設定ファイルに追加します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클라이언트를 선택하여 NeewerLite를 MCP 설정 파일에 추가합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "勾选客户端,将 NeewerLite 写入其 MCP 配置文件。" + } + } + } + }, "Energy" : { "extractionState" : "stale", "localizations" : { @@ -4113,72 +4400,195 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "Ayuda" + "value" : "Ayuda" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aide" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ヘルプ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "도움말" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "帮助" + } + } + } + }, + "HMI6000" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HMI6000" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "HMI6000" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HMI6000" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "HMI6000" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HMI6000" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "HMI6000" + } + } + } + }, + "Horizon daylight" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horizontales Tageslicht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luz diurna del horizonte" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lumière d'horizon" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "水平線の日光" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수평선 일광" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "地平线日光" + } + } + } + }, + "HSI" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HSI" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "HSI" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Aide" + "value" : "HSI" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ヘルプ" + "value" : "HSI" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "도움말" + "value" : "HSI" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "帮助" + "value" : "彩光" } } } }, - "HSI" : { + "HTTP Server" : { "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "HSI" + "value" : "HTTP-Server" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "HSI" + "value" : "Servidor HTTP" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "HSI" + "value" : "Serveur HTTP" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "HSI" + "value" : "HTTPサーバー" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "HSI" + "value" : "HTTP 서버" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "彩光" + "value" : "HTTP 服务器" } } } @@ -4798,6 +5208,47 @@ } } }, + "Launch at Login" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Anmelden starten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir al iniciar sesión" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lancer au démarrage" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログイン時に起動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그인 시 실행" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录时启动" + } + } + } + }, "Lazy" : { "extractionState" : "stale", "localizations" : { @@ -5659,6 +6110,47 @@ } } }, + "Modeling Lights" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstelllicht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luz de modelado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lumière pilote" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モデリングライト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델링 라이트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "造型灯" + } + } + } + }, "Moderate" : { "extractionState" : "stale", "localizations" : { @@ -8568,6 +9060,47 @@ } } }, + "Studio Bulb" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studiolampe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bombilla de estudio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ampoule de studio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スタジオバルブ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스튜디오 전구" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "影棚灯泡" + } + } + } + }, "Subtle" : { "extractionState" : "stale", "localizations" : { @@ -8691,6 +9224,47 @@ } } }, + "Sunlight" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonnenlicht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luz solar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lumière du soleil" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "太陽光" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태양광" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "阳光" + } + } + } + }, "Sunrise" : { "extractionState" : "stale", "localizations" : { @@ -9224,6 +9798,47 @@ } } }, + "Tungsten" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wolfram" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tungsteno" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tungstène" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タングステン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "텅스텐" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "钨丝灯" + } + } + } + }, "TV screen" : { "extractionState" : "stale", "localizations" : { @@ -9754,6 +10369,47 @@ } } }, + "White Halogen light" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weißes Halogenlicht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luz halógena blanca" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lumière halogène blanche" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "白色ハロゲンライト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "백색 할로겐 라이트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "白色卤素灯" + } + } + } + }, "Winter" : { "extractionState" : "stale", "localizations" : { @@ -9877,6 +10533,47 @@ } } }, + "Xenon short-arc lamp" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xenon-Kurzbogenlampe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lámpara de arco corto de xenón" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lampe xénon à arc court" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キセノンショートアークランプ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제논 쇼트아크 램프" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "氙气短弧灯" + } + } + } + }, "Yes" : { "extractionState" : "stale", "localizations" : { diff --git a/NeewerLite/NeewerLite/Server.swift b/NeewerLite/NeewerLite/Server.swift index 93e7960..f8e7b39 100644 --- a/NeewerLite/NeewerLite/Server.swift +++ b/NeewerLite/NeewerLite/Server.swift @@ -1,6 +1,28 @@ import Foundation import AppKit -import Swifter +import Vapor +import MCP + +// Resolve Vapor/MCP type name conflicts +private typealias VaporRequest = Vapor.Request +private typealias VaporResponse = Vapor.Response + +/// MCP SDK's `Value` decodes JSON integers as `.int`, not `.double`. +/// These helpers coerce across both numeric cases so tool handlers +/// don't silently fail when a client sends `80` instead of `80.0`. +extension Value { + var numericDouble: Double? { + if let d = doubleValue { return d } + if let i = intValue { return Double(i) } + return nil + } + + var numericInt: Int? { + if let i = intValue { return i } + if let d = doubleValue { return Int(d) } + return nil + } +} extension DeviceViewObject { @@ -13,348 +35,1377 @@ extension DeviceViewObject { } } +// MARK: - Stream Deck Auth Middleware + +struct StreamDeckAuthMiddleware: AsyncMiddleware { + func respond(to request: Vapor.Request, chainingTo next: any AsyncResponder) async throws -> Vapor.Response { + // Public endpoints — no auth required + if request.url.path == "/mcp" || request.url.path == "/ping" + || request.url.path == "/sse" || request.url.path == "/messages" { + return try await next.respond(to: request) + } + guard let ua = request.headers.first(name: "User-Agent"), + ua.hasPrefix("neewerlite.sdPlugin/") else { + return VaporResponse(status: .unauthorized) + } + return try await next.respond(to: request) + } +} + +private final class MCPSessionContext { + let clientKey: String + let transport: StatefulHTTPServerTransport + let server: MCP.Server + var sessionID: String? + var lastSeen: Date + + init(clientKey: String, transport: StatefulHTTPServerTransport, server: MCP.Server) { + self.clientKey = clientKey + self.transport = transport + self.server = server + self.lastSeen = Date() + } +} + +private actor SessionManager { + private var sessionsByClientKey: [String: MCPSessionContext] = [:] + private var clientKeyBySessionID: [String: String] = [:] + private let maxSessions: Int + + init(maxSessions: Int) { + self.maxSessions = maxSessions + } + + func context(forSessionID sessionID: String) -> MCPSessionContext? { + guard let clientKey = clientKeyBySessionID[sessionID] else { return nil } + return sessionsByClientKey[clientKey] + } + + func context(forClientKey clientKey: String) -> MCPSessionContext? { + sessionsByClientKey[clientKey] + } + + func getOrCreateContext(for clientKey: String, create: @Sendable () async throws -> MCPSessionContext) async throws -> MCPSessionContext { + if let existing = sessionsByClientKey[clientKey] { + existing.lastSeen = Date() + return existing + } + + if sessionsByClientKey.count >= maxSessions, + let oldest = sessionsByClientKey.values.min(by: { $0.lastSeen < $1.lastSeen }) { + await removeContext(oldest) + } + + let context = try await create() + sessionsByClientKey[clientKey] = context + return context + } + + func bindSessionID(_ sessionID: String, to context: MCPSessionContext) { + context.sessionID = sessionID + context.lastSeen = Date() + clientKeyBySessionID[sessionID] = context.clientKey + } + + func touch(_ context: MCPSessionContext) { + context.lastSeen = Date() + } + + func removeContext(_ context: MCPSessionContext) async { + sessionsByClientKey.removeValue(forKey: context.clientKey) + if let sid = context.sessionID { + clientKeyBySessionID.removeValue(forKey: sid) + } + await context.server.stop() + } + + func shutdownAll() async { + let contexts = Array(sessionsByClientKey.values) + sessionsByClientKey.removeAll() + clientKeyBySessionID.removeAll() + for context in contexts { + await context.server.stop() + } + } +} + +private actor BLECommandCoordinator { + private var tail: Task? + + func enqueue(_ operation: @escaping () async -> Void) async { + let previous = tail + let current = Task { + if let previous { + _ = await previous.result + } + await operation() + } + tail = current + _ = await current.result + } +} + +// MARK: - NeewerLite Server + final class NeewerLiteServer { - private let server = HttpServer() - private let port: in_port_t + private var app: Application? + private let sessionManager = SessionManager(maxSessions: 24) + private let bleCommandCoordinator = BLECommandCoordinator() + private let port: Int private let appDelegate: AppDelegate? public var user_agent: String? - - init(appDelegate: AppDelegate, port: in_port_t = 18486) { + + /// The actual port the server is listening on (may differ from `port` when port=0). + private(set) var boundPort: Int? + + init(appDelegate: AppDelegate, port: Int = 18486) { self.appDelegate = appDelegate self.port = port - setupRoutes() } deinit { stop() } - - /// Configure HTTP routes - private func setupRoutes() { - - server.middleware.append { request in - guard let ua = request.headers["user-agent"] else { - // No UA header → reject - return HttpResponse.unauthorized + + // MARK: - Lifecycle + + func start() { + Task { [weak self] in + guard let self else { return } + do { + try await self.startAsync() + } catch { + Logger.error(LogTag.server, "Failed to start server: \(error)") + } + } + } + + /// Awaitable start — used by tests to know when the server is ready. + func startAsync() async throws { + let app = try await Application.make(.development) + app.http.server.configuration.hostname = "127.0.0.1" + app.http.server.configuration.port = self.port + app.logger.logLevel = .error + app.environment.arguments = ["serve"] + + app.middleware.use(StreamDeckAuthMiddleware()) + self.setupStreamDeckRoutes(app) + self.setupMCPRoute(app) + self.setupLegacySSERoutes(app) + + try await app.startup() + self.app = app + self.boundPort = app.http.server.shared.localAddress?.port ?? self.port + Logger.info(LogTag.server, "NeewerLiteServer listening on http://127.0.0.1:\(self.boundPort!)") + } + + func stop() { + let app = self.app + let sessionManager = self.sessionManager + self.app = nil + Task { + if let app { + try? await app.asyncShutdown() + } + await sessionManager.shutdownAll() + } + Logger.info(LogTag.server, "NeewerLiteServer stopped") + } + + // MARK: - MCP Server Setup + + private func createMCPServer() async -> MCP.Server { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" + let server = MCP.Server( + name: "NeewerLite", + version: version, + instructions: "NeewerLite controls Neewer Bluetooth LED lights. Use list_lights to discover connected lights and their capabilities. Use list_scenes to see available scenes for a specific light before calling set_scene. Control lights with set_cct, set_hsi, set_scene, switch_light, or set_brightness.", + capabilities: MCP.Server.Capabilities( + tools: MCP.Server.Capabilities.Tools(listChanged: false) + ) + ) + + await server.withMethodHandler(ListTools.self) { [weak self] _ in + self?.mcpToolsList() ?? ListTools.Result(tools: []) + } + + await server.withMethodHandler(CallTool.self) { [weak self] params in + guard let self else { + return toolResult("Server not available.", isError: true) } - if !ua.starts(with: "neewerlite.sdPlugin/") - { - return HttpResponse.unauthorized + return self.handleMCPToolCall(params) + } + + return server + } + + // MARK: - Legacy SSE Transport (GET /sse + POST /messages) + // + // Supports the older MCP "HTTP+SSE" transport used by some clients (e.g. OpenClaw's + // Python runtime) that were written before the Streamable HTTP spec. + // + // Protocol: + // 1. Client opens GET /sse → receives a persistent text/event-stream. + // Server immediately sends: event: endpoint\ndata: /messages?sessionId=\n\n + // 2. Client POSTs JSON-RPC messages to /messages?sessionId= + // Server processes each via the Streamable HTTP transport and pushes + // the JSON-RPC response back as SSE data events on the open stream. + + private final class LegacySSESession { + let id: String + /// SSE events → client (the GET /sse response body reads from this) + let outbound: AsyncStream + let outCont: AsyncStream.Continuation + /// JSON-RPC bodies → server (POST /messages writes into this) + let inbound: AsyncStream + let inCont: AsyncStream.Continuation + + init(id: String) { + self.id = id + var o: AsyncStream.Continuation! + self.outbound = AsyncStream { o = $0 } + self.outCont = o! + var i: AsyncStream.Continuation! + self.inbound = AsyncStream { i = $0 } + self.inCont = i! + } + + func pushSSE(_ event: String) { outCont.yield(event) } + func pushMessage(_ data: Data) { inCont.yield(data) } + func finish() { outCont.finish(); inCont.finish() } + } + + private let sseSessionLock = NSLock() + private var sseSessions: [String: LegacySSESession] = [:] + + private func setupLegacySSERoutes(_ app: Application) { + + // GET /sse — open persistent SSE stream + app.get("sse") { [weak self] req -> VaporResponse in + guard let self else { return VaporResponse(status: .internalServerError) } + + let sessionId = UUID().uuidString + let session = LegacySSESession(id: sessionId) + self.sseSessionLock.withLock { self.sseSessions[sessionId] = session } + Logger.info(LogTag.server, "[SSE-LEGACY] opened session=\(sessionId)") + + // Pump inbound messages through the MCP stack on a background task + Task { [weak self] in + guard let self else { return } + await self.drainLegacySSEInbound(session: session) } - // return nil to let the request continue on to your handlers + + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "text/event-stream") + headers.add(name: .cacheControl, value: "no-cache, no-transform") + headers.add(name: .connection, value: "keep-alive") + + let response = VaporResponse(status: .ok, headers: headers) + response.body = .init(asyncStream: { writer in + // Tell the client where to POST messages + let endpointEvent = "event: endpoint\ndata: /messages?sessionId=\(sessionId)\n\n" + try? await writer.writeBuffer(ByteBuffer(string: endpointEvent)) + + // Stream outbound SSE events until the session ends + for await chunk in session.outbound { + try? await writer.writeBuffer(ByteBuffer(string: chunk)) + } + try? await writer.write(.end) + + // Clean up after client disconnects + self.sseSessionLock.withLock { self.sseSessions.removeValue(forKey: sessionId) } + Logger.info(LogTag.server, "[SSE-LEGACY] closed session=\(sessionId)") + }) + return response + } + + // POST /messages — deliver a JSON-RPC message to the session + app.post("messages") { [weak self] req -> VaporResponse in + guard let self else { return VaporResponse(status: .internalServerError) } + + guard let sessionId = req.query[String.self, at: "sessionId"], + let session = self.sseSessionLock.withLock({ self.sseSessions[sessionId] }) else { + return VaporResponse(status: .notFound) + } + guard let buffer = req.body.data, + let data = buffer.getData(at: buffer.readerIndex, length: buffer.readableBytes) else { + return VaporResponse(status: .badRequest) + } + + Logger.info(LogTag.server, "[SSE-LEGACY] POST /messages session=\(sessionId)") + session.pushMessage(data) + return VaporResponse(status: .accepted) + } + } + + /// Reads JSON-RPC messages from a legacy SSE session's inbound queue, + /// routes each through the (reused) Streamable HTTP session context, + /// then pushes the response as SSE data events back to the client. + private func drainLegacySSEInbound(session: LegacySSESession) async { + let clientKey = "sse-legacy-\(session.id)" + + for await messageData in session.inbound { + // Reuse (or create) an MCP session context for this SSE connection + let context: MCPSessionContext + do { + context = try await sessionManager.getOrCreateContext(for: clientKey) { [weak self] in + guard let self else { throw MCPError.internalError("Server gone") } + return try await self.createSessionContext(clientKey: clientKey) + } + } catch { + Logger.error(LogTag.server, "[SSE-LEGACY] could not get session context: \(error)") + continue + } + + // Wrap the JSON-RPC body as a fake /mcp POST + var fakeHeaders = ["Content-Type": "application/json", + "Accept": "application/json, text/event-stream"] + if let sid = context.sessionID { + fakeHeaders["Mcp-Session-Id"] = sid + } + let mcpRequest = MCP.HTTPRequest(method: "POST", headers: fakeHeaders, + body: messageData, path: "/mcp") + + let mcpResponse = await context.transport.handleRequest(mcpRequest) + + // Eagerly register new session IDs + if let sid = self.headerValue("Mcp-Session-Id", from: mcpResponse.headers) { + await sessionManager.bindSessionID(sid, to: context) + } + await sessionManager.touch(context) + + // Push response chunks as SSE data events + switch mcpResponse { + case .stream(let stream, _): + do { + for try await chunk in stream { + guard let text = String(data: chunk, encoding: .utf8), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + // Strip SSE framing if the SDK already added it; wrap bare JSON + let event = text.hasPrefix("data:") ? text : "data: \(text)\n\n" + session.pushSSE(event) + } + } catch { + Logger.error(LogTag.server, "[SSE-LEGACY] stream error: \(error)") + } + default: + if let data = mcpResponse.bodyData, + let text = String(data: data, encoding: .utf8) { + let event = text.hasPrefix("data:") ? text : "data: \(text)\n\n" + session.pushSSE(event) + } + } + } + } + + // MARK: - MCP Route (Vapor ↔ MCP SDK) + + private func setupMCPRoute(_ app: Application) { + let handler: @Sendable (VaporRequest) async throws -> VaporResponse = { [weak self] req in + guard let self else { + return VaporResponse(status: .internalServerError) + } + // Convert Vapor Request → MCP HTTPRequest + var headers: [String: String] = [:] + for (name, value) in req.headers { + headers[name] = value + } + let body: Data? = req.body.data.flatMap { buffer in + buffer.getData(at: buffer.readerIndex, length: buffer.readableBytes) + } + let mcpRequest = MCP.HTTPRequest( + method: req.method.rawValue, + headers: headers, + body: body, + path: req.url.path + ) + + let clientKey = self.resolveMCPClientKey(req) + let sessionID = self.headerValue("Mcp-Session-Id", from: headers) + Logger.info( + LogTag.server, + "[MCPDBG][REQ] method=\(req.method.rawValue) path=\(req.url.path) session=\(sessionID ?? "none") ua=\(self.headerValue("User-Agent", from: headers) ?? "none") accept=\(self.headerValue("Accept", from: headers) ?? "none") contentType=\(self.headerValue("Content-Type", from: headers) ?? "none")" + ) + + if req.method == .GET, sessionID == nil { + Logger.info(LogTag.server, "[MCPDBG][GET] no-session async notification probe") + return self.makeAsyncNotificationProbeResponse() + } + + let context: MCPSessionContext + if let sessionID, let existing = await self.sessionManager.context(forSessionID: sessionID) { + Logger.info(LogTag.server, "[MCPDBG][SESSION] resolved existing context by session id=\(sessionID)") + context = existing + } else if self.isInitializeRequest(body) { + Logger.info(LogTag.server, "[MCPDBG][SESSION] initialize request; resolving context by client key") + do { + context = try await self.sessionManager.getOrCreateContext(for: clientKey) { [weak self] in + guard let self else { + throw MCPError.internalError("Server no longer available") + } + return try await self.createSessionContext(clientKey: clientKey) + } + } catch { + Logger.error(LogTag.server, "Failed to create MCP session context: \(error)") + return VaporResponse(status: .serviceUnavailable) + } + } else { + Logger.warn(LogTag.server, "[MCPDBG][SESSION] request without valid session and not initialize; returning terminated-session error") + let invalidResponse = self.invalidTerminatedSessionResponse(sessionID: sessionID) + return self.convertMCPResponse(invalidResponse) + } + + let mcpResponse = await context.transport.handleRequest(mcpRequest) + Logger.info(LogTag.server, "[MCPDBG][RESP] status=\(mcpResponse.statusCode) sessionHeader=\(self.headerValue("Mcp-Session-Id", from: mcpResponse.headers) ?? "none")") + await self.sessionManager.touch(context) + + // Bind the session ID BEFORE converting/streaming the response body. + // OpenClaw (and other eager clients) fire a follow-up request immediately + // after receiving the initialize response headers — if we bind after streaming + // the follow-up arrives before the session is registered and gets a 404. + if let returnedSessionID = self.headerValue("Mcp-Session-Id", from: mcpResponse.headers) { + await self.sessionManager.bindSessionID(returnedSessionID, to: context) + } + + if req.method == .DELETE, mcpResponse.statusCode == 200 { + await self.sessionManager.removeContext(context) + } + + // Convert MCP HTTPResponse → Vapor Response + return self.convertMCPResponse(mcpResponse) + } + + app.on(.POST, "mcp", use: handler) + app.on(.GET, "mcp", use: handler) + app.on(.DELETE, "mcp", use: handler) + } + + private func makeAsyncNotificationProbeResponse() -> VaporResponse { + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "text/event-stream") + headers.add(name: .cacheControl, value: "no-cache, no-transform") + headers.add(name: .connection, value: "keep-alive") + + let response = VaporResponse(status: .ok, headers: headers) + response.body = .init(asyncStream: { writer in + do { + Logger.info(LogTag.server, "[MCPDBG][GET-probe] writing async notification probe comment") + let buffer = ByteBuffer(string: ": awaiting session initialization\n\n") + try await writer.writeBuffer(buffer) + try await writer.write(.end) + } catch { + Logger.error(LogTag.server, "[MCPDBG][GET-probe] stream write failed: \(error)") + try await writer.write(.error(error)) + } + }) + return response + } + + private func createSessionContext(clientKey: String) async throws -> MCPSessionContext { + let transport = StatefulHTTPServerTransport() + let server = await createMCPServer() + try await server.start(transport: transport) + return MCPSessionContext(clientKey: clientKey, transport: transport, server: server) + } + + private func invalidTerminatedSessionResponse(sessionID: String?) -> MCP.HTTPResponse { + MCP.HTTPResponse.error( + statusCode: 404, + .invalidRequest("Not Found: Session has been terminated"), + sessionID: sessionID + ) + } + + private func resolveMCPClientKey(_ req: VaporRequest) -> String { + let ip = req.remoteAddress?.ipAddress ?? req.remoteAddress?.description ?? "unknown-remote" + let ua = req.headers.first(name: "User-Agent") ?? "" + + // OpenClaw may use different stacks/user agents across requests + // (notably undici + Python-urllib). Keep those mapped to one logical + // client key so session creation/reuse remains stable. + if ua.lowercased().contains("python-urllib") || ua.lowercased().contains("undici") { + return "openclaw@\(ip)" + } + + // For regular clients, include UA to preserve per-client isolation + // when multiple clients connect from the same loopback IP. + if !ua.isEmpty { + return "\(ua)@\(ip)" + } + return ip + } + + private func headerValue(_ name: String, from headers: [String: String]) -> String? { + if let direct = headers[name] { return direct } + return headers.first(where: { $0.key.caseInsensitiveCompare(name) == .orderedSame })?.value + } + + private func isInitializeRequest(_ body: Data?) -> Bool { + guard let body, + let payload = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let method = payload["method"] as? String else { + return false + } + return method == "initialize" + } + + private func convertMCPResponse(_ mcpResponse: MCP.HTTPResponse) -> VaporResponse { + let status = HTTPResponseStatus(statusCode: mcpResponse.statusCode) + var vaporHeaders = HTTPHeaders() + for (key, value) in mcpResponse.headers { + vaporHeaders.add(name: key, value: value) + } + + switch mcpResponse { + case .stream(let stream, _): + Logger.info(LogTag.server, "[MCPDBG][CONVERT] streaming response status=\(status.code)") + let response = VaporResponse(status: status, headers: vaporHeaders) + response.body = .init(asyncStream: { writer in + do { + for try await chunk in stream { + guard let sanitizedChunk = self.sanitizedSSEChunk(chunk) else { + Logger.info(LogTag.server, "[MCPDBG][SSE] filtered priming/empty chunk") + continue + } + Logger.info(LogTag.server, "[MCPDBG][SSE] forwarding chunk bytes=\(sanitizedChunk.count)") + let buffer = ByteBuffer(data: sanitizedChunk) + try await writer.writeBuffer(buffer) + } + try await writer.write(.end) + } catch { + Logger.error(LogTag.server, "[MCPDBG][SSE] stream forwarding failed: \(error)") + try await writer.write(.error(error)) + } + }) + return response + default: + Logger.info(LogTag.server, "[MCPDBG][CONVERT] non-stream response status=\(status.code) bodyBytes=\(mcpResponse.bodyData?.count ?? 0)") + let response = VaporResponse(status: status, headers: vaporHeaders) + if let data = mcpResponse.bodyData { + response.body = .init(data: data) + } + return response + } + } + + private func sanitizedSSEChunk(_ chunk: Data) -> Data? { + guard let text = String(data: chunk, encoding: .utf8) else { + return chunk + } + + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } - - // GET /listLights → returns dummy lights array - server.GET["/listLights"] = { _ in + + let lines = text + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + .filter { !$0.isEmpty } + + let isPrimingEvent = !lines.isEmpty && lines.allSatisfy { line in + line.hasPrefix("id: ") || line.hasPrefix("retry: ") || line == "data: " + } + + return isPrimingEvent ? nil : chunk + } + + // MARK: - Stream Deck Routes + + private func enqueueBLEOperation(_ operation: @escaping () async -> Void) { + Task { + await self.bleCommandCoordinator.enqueue(operation) + } + } + + private func setupStreamDeckRoutes(_ app: Application) { + + app.get("ping") { [weak self] _ -> VaporResponse in + let lightCount = self?.appDelegate?.viewObjects.count ?? 0 + return jsonResponse(["status": "ok", "lights": lightCount]) + } + + app.get("listLights") { [weak self] _ -> VaporResponse in var lights: [[String: Any]] = [] - self.appDelegate?.viewObjects.forEach { + self?.appDelegate?.viewObjects.forEach { let name = $0.device.userLightName.value.isEmpty ? $0.device.rawName : $0.device.userLightName.value let cct = "\($0.device.CCTRange().minCCT)-\($0.device.CCTRange().maxCCT)" - var item = ["id": "\($0.device.identifier)", "name": name, "cctRange": "\(cct)"] + var item: [String: Any] = ["id": "\($0.device.identifier)", "name": name, "cctRange": cct] item["brightness"] = "\($0.device.brrValue.value)" item["temperature"] = "\($0.device.cctValue.value)" item["supportRGB"] = "\($0.device.supportRGB ? 1 : 0)" item["maxChannel"] = "\($0.device.maxChannel)" - if !$0.deviceConnected - { - item["state"] = "-1" - } - else if $0.device.isOn.value - { - item["state"] = "1" - } - else - { - item["state"] = "0" - } + if !$0.deviceConnected { item["state"] = "-1" } + else if $0.device.isOn.value { item["state"] = "1" } + else { item["state"] = "0" } lights.append(item) } - let payload: [String: Any] = ["lights": lights] - // Logger.debug(LogTag.server, "Received /listLights payload: \(payload)") - return HttpResponse.ok(.json(payload)) + return jsonResponse(["lights": lights]) } - // GET /ping → health check - server.GET["/ping"] = { _ in - // Logger.info(LogTag.server, "Received /ping") - return HttpResponse.ok(.json(["status": "pong"])) - } - - // 4. Switch lights endpoint - // Expects JSON payload: { "lights": ["Front", "Back"] } - server.POST["/switch"] = { request in - Logger.info(LogTag.server, "Received /switch request") - let data = Data(request.body) - struct SwitchPayload: Codable { - let lights: [String] - let state: Bool - } - let payload: SwitchPayload - do { - payload = try JSONDecoder().decode(SwitchPayload.self, from: data) - } catch { - Logger.error(LogTag.server, "/switch: invalid JSON - \(error)") - return HttpResponse.badRequest(.json(["error", "invalid JSON"])) + app.post("switch") { [weak self] req async throws -> VaporResponse in + struct SwitchPayload: Codable { let lights: [String]; let state: Bool } + guard let payload = try? req.content.decode(SwitchPayload.self) else { + return jsonErrorResponse("invalid JSON") } - // Perform your switch logic here - Logger.info(LogTag.server, "Switching lights: \(payload.lights) state: \(payload.state)") for light in payload.lights { - self.appDelegate?.viewObjects + self?.appDelegate?.viewObjects .filter { $0.matches(lightId: light) } .forEach { viewObj in - Task { @MainActor in - if payload.state { - if !viewObj.isON { - viewObj.toggleLight() - } - } - else{ - if viewObj.isON { - viewObj.toggleLight() + self?.enqueueBLEOperation { + await MainActor.run { + if payload.state { + if !viewObj.isON { viewObj.toggleLight() } + } else { + if viewObj.isON { viewObj.toggleLight() } } } } } } - // Respond with success and echoed list - return HttpResponse.ok(.json(["success": true, "switched": payload.lights])) + return jsonResponse(["success": true, "switched": payload.lights] as [String: Any]) } - server.POST["/brightness"] = { request in - let data = Data(request.body) - struct BrightnessPayload: Codable { - let lights: [String] - let brightness: CGFloat + app.post("brightness") { [weak self] req async throws -> VaporResponse in + struct Payload: Codable { let lights: [String]; let brightness: CGFloat } + guard let payload = try? req.content.decode(Payload.self) else { + return jsonErrorResponse("invalid JSON") } - let payload: BrightnessPayload - do { - payload = try JSONDecoder().decode(BrightnessPayload.self, from: data) - } catch { - Logger.error(LogTag.server, "/switch: invalid JSON - \(error)") - return HttpResponse.badRequest(.json(["error", "invalid JSON"])) - } - // Perform your switch logic here for light in payload.lights { - Logger.info(LogTag.server, "light: \(light)") - self.appDelegate?.viewObjects + self?.appDelegate?.viewObjects .filter { $0.matches(lightId: light) } .forEach { viewObj in - Task { @MainActor in - viewObj.device.setBRR100LightValues(payload.brightness) + self?.enqueueBLEOperation { + await MainActor.run { + viewObj.device.setBRR100LightValues(payload.brightness) + } } } } - // Respond with success and echoed list - return HttpResponse.ok(.json(["success": true, "switched": payload.lights])) + return jsonResponse(["success": true, "switched": payload.lights] as [String: Any]) } - - server.POST["/temperature"] = { request in - let data = Data(request.body) - struct TemperaturePayload: Codable { - let lights: [String] - let temperature: CGFloat - } - let payload: TemperaturePayload - do { - payload = try JSONDecoder().decode(TemperaturePayload.self, from: data) - } catch { - Logger.error(LogTag.server, "/switch: invalid JSON - \(error)") - return HttpResponse.badRequest(.json(["error", "invalid JSON"])) + + app.post("temperature") { [weak self] req async throws -> VaporResponse in + struct Payload: Codable { let lights: [String]; let temperature: CGFloat } + guard let payload = try? req.content.decode(Payload.self) else { + return jsonErrorResponse("invalid JSON") } - // Perform your switch logic here for light in payload.lights { - Logger.info(LogTag.server, "light: \(light)") - self.appDelegate?.viewObjects + self?.appDelegate?.viewObjects .filter { $0.matches(lightId: light) } .forEach { viewObj in - Task { @MainActor in - viewObj.device.setCCTLightValues(brr: CGFloat(viewObj.device.brrValue.value), cct: CGFloat(payload.temperature), gmm: CGFloat(viewObj.device.gmmValue.value)) + self?.enqueueBLEOperation { + await MainActor.run { + viewObj.device.setCCTLightValues( + brr: CGFloat(viewObj.device.brrValue.value), + cct: CGFloat(payload.temperature), + gmm: CGFloat(viewObj.device.gmmValue.value)) + } } } } - // Respond with success and echoed list - return HttpResponse.ok(.json(["success": true, "switched": payload.lights])) + return jsonResponse(["success": true, "switched": payload.lights] as [String: Any]) } - server.POST["/cct"] = { request in - let data = Data(request.body) - struct BrightnessPayload: Codable { - let lights: [String] - let brightness: CGFloat - let temperature: CGFloat + app.post("cct") { [weak self] req async throws -> VaporResponse in + struct Payload: Codable { let lights: [String]; let brightness: CGFloat; let temperature: CGFloat } + guard let payload = try? req.content.decode(Payload.self) else { + return jsonErrorResponse("invalid JSON") } - let payload: BrightnessPayload - do { - payload = try JSONDecoder().decode(BrightnessPayload.self, from: data) - } catch { - Logger.error(LogTag.server, "/switch: invalid JSON - \(error)") - return HttpResponse.badRequest(.json(["error", "invalid JSON"])) - } - // Perform your switch logic here for light in payload.lights { - self.appDelegate?.viewObjects + self?.appDelegate?.viewObjects .filter { $0.matches(lightId: light) } .forEach { viewObj in - Task { @MainActor in - viewObj.changeToCCTMode() - viewObj.device.setCCTLightValues(brr: CGFloat(payload.brightness), cct: CGFloat(payload.temperature), gmm: CGFloat(viewObj.device.gmmValue.value)) + self?.enqueueBLEOperation { + await MainActor.run { + viewObj.changeToCCTMode() + viewObj.device.setCCTLightValues( + brr: CGFloat(payload.brightness), + cct: CGFloat(payload.temperature), + gmm: CGFloat(viewObj.device.gmmValue.value)) + } } } } - // Respond with success and echoed list - return HttpResponse.ok(.json(["success": true, "switched": payload.lights])) + return jsonResponse(["success": true, "switched": payload.lights] as [String: Any]) } - - server.POST["/hst"] = { request in - let data = Data(request.body) - struct BrightnessPayload: Codable { - let lights: [String] - let brightness: CGFloat - let saturation: CGFloat - let hex_color: String + + app.post("hst") { [weak self] req async throws -> VaporResponse in + struct Payload: Codable { + let lights: [String]; let brightness: CGFloat + let saturation: CGFloat; let hex_color: String } - let payload: BrightnessPayload - do { - payload = try JSONDecoder().decode(BrightnessPayload.self, from: data) - } catch { - Logger.error(LogTag.server, "/switch: invalid JSON - \(error)") - return HttpResponse.badRequest(.json(["error", "invalid JSON"])) + guard let payload = try? req.content.decode(Payload.self) else { + return jsonErrorResponse("invalid JSON") } let color = NSColor(hex: payload.hex_color, alpha: 1) let hueVal = CGFloat(color.hueComponent * 360.0) let satVal = CGFloat(payload.saturation / 100.0) - // Perform your switch logic here for light in payload.lights { - self.appDelegate?.viewObjects + self?.appDelegate?.viewObjects .filter { $0.matches(lightId: light) } .forEach { viewObj in if viewObj.device.supportRGB { - Task { @MainActor in - viewObj.changeToHSIMode() - viewObj.updateHSI(hue: hueVal, sat: satVal, brr: CGFloat(payload.brightness)) + self?.enqueueBLEOperation { + await MainActor.run { + viewObj.changeToHSIMode() + viewObj.updateHSI(hue: hueVal, sat: satVal, brr: CGFloat(payload.brightness)) + } } } } } - // Respond with success and echoed list - return HttpResponse.ok(.json(["success": true, "switched": payload.lights])) + return jsonResponse(["success": true, "switched": payload.lights] as [String: Any]) } - - server.POST["/hue"] = { request in - let data = Data(request.body) - struct BrightnessPayload: Codable { - let lights: [String] - let hue: CGFloat // 0-100 - } - let payload: BrightnessPayload - do { - payload = try JSONDecoder().decode(BrightnessPayload.self, from: data) - } catch { - Logger.error(LogTag.server, "/switch: invalid JSON - \(error)") - return HttpResponse.badRequest(.json(["error", "invalid JSON"])) + + app.post("hue") { [weak self] req async throws -> VaporResponse in + struct Payload: Codable { let lights: [String]; let hue: CGFloat } + guard let payload = try? req.content.decode(Payload.self) else { + return jsonErrorResponse("invalid JSON") } let hueVal = payload.hue / 100.0 * 360.0 - // Perform your switch logic here for light in payload.lights { - self.appDelegate?.viewObjects + self?.appDelegate?.viewObjects .filter { $0.matches(lightId: light) } .filter { $0.device.supportRGB } .forEach { viewObj in - Task { @MainActor in - viewObj.changeToHSIMode() - viewObj.updateHSI(hue: hueVal, sat: CGFloat(viewObj.device.satValue.value), brr: CGFloat(viewObj.device.brrValue.value)) + self?.enqueueBLEOperation { + await MainActor.run { + viewObj.changeToHSIMode() + viewObj.updateHSI( + hue: hueVal, + sat: CGFloat(viewObj.device.satValue.value), + brr: CGFloat(viewObj.device.brrValue.value)) + } } } } - // Respond with success and echoed list - return HttpResponse.ok(.json(["success": true, "switched": payload.lights])) + return jsonResponse(["success": true, "switched": payload.lights] as [String: Any]) } - - server.POST["/sat"] = { request in - let data = Data(request.body) - struct BrightnessPayload: Codable { - let lights: [String] - let saturation: CGFloat // 0-100 - } - let payload: BrightnessPayload - do { - payload = try JSONDecoder().decode(BrightnessPayload.self, from: data) - } catch { - Logger.error(LogTag.server, "/switch: invalid JSON - \(error)") - return HttpResponse.badRequest(.json(["error", "invalid JSON"])) + + app.post("sat") { [weak self] req async throws -> VaporResponse in + struct Payload: Codable { let lights: [String]; let saturation: CGFloat } + guard let payload = try? req.content.decode(Payload.self) else { + return jsonErrorResponse("invalid JSON") } - // Perform your switch logic here let satVal = CGFloat(payload.saturation / 100.0) - Logger.info(LogTag.server, "cct lights: \(payload.lights) saturation: \(payload.saturation) satVal: \(satVal)") for light in payload.lights { - self.appDelegate?.viewObjects + self?.appDelegate?.viewObjects .filter { $0.matches(lightId: light) } .filter { $0.device.supportRGB } .forEach { viewObj in - Task { @MainActor in - viewObj.changeToHSIMode() - viewObj.updateHSI(hue: CGFloat(viewObj.device.hueValue.value), sat: satVal, brr: CGFloat(viewObj.device.brrValue.value)) + self?.enqueueBLEOperation { + await MainActor.run { + viewObj.changeToHSIMode() + viewObj.updateHSI( + hue: CGFloat(viewObj.device.hueValue.value), + sat: satVal, + brr: CGFloat(viewObj.device.brrValue.value)) + } } } } - // Respond with success and echoed list - return HttpResponse.ok(.json(["success": true, "switched": payload.lights])) + return jsonResponse(["success": true, "switched": payload.lights] as [String: Any]) } - - server.POST["/fx"] = { request in - let data = Data(request.body) + + app.post("fx") { [weak self] req async throws -> VaporResponse in struct FXPayload: Codable { - let lights: [String] - let fx9: Int? - let fx17: Int? - let sceneId: Int? + let lights: [String]; let fx9: Int?; let fx17: Int?; let sceneId: Int? } - let payload: FXPayload - do { - payload = try JSONDecoder().decode(FXPayload.self, from: data) - } catch { - Logger.error(LogTag.server, "/fx: invalid JSON - \(error)") - return HttpResponse.badRequest(.json(["error", "invalid JSON"])) + guard let payload = try? req.content.decode(FXPayload.self) else { + return jsonErrorResponse("invalid JSON") } - Logger.debug(LogTag.server, "fx lights: \(payload.lights) sceneId: \(String(describing: payload.sceneId)) fx9: \(String(describing: payload.fx9)) fx17: \(String(describing: payload.fx17))") for light in payload.lights { - self.appDelegate?.viewObjects + self?.appDelegate?.viewObjects .filter { $0.matches(lightId: light) } .forEach { viewObj in let fxCount = viewObj.device.supportedFX.count let resolvedId = payload.sceneId ?? (fxCount <= 9 ? payload.fx9 : payload.fx17) if let id = resolvedId, id > 0 && id <= fxCount { - Task { @MainActor in - viewObj.changeToSCEMode() - viewObj.changeToSCE(id, CGFloat(viewObj.device.brrValue.value)) + self?.enqueueBLEOperation { + await MainActor.run { + viewObj.changeToSCEMode() + viewObj.changeToSCE(id, CGFloat(viewObj.device.brrValue.value)) + } } } } } - return HttpResponse.ok(.json(["success": true, "switched": payload.lights])) + return jsonResponse(["success": true, "switched": payload.lights] as [String: Any]) } - - // Fallback for other routes - server.notFoundHandler = { request in - Logger.info(LogTag.server, "return notFound for \(request.path)") - return HttpResponse.notFound + } + + // MARK: - MCP Tool Definitions + + func mcpToolsList() -> ListTools.Result { + let tools: [Tool] = [ + Tool(name: "list_lights", + description: "List all connected Neewer LED lights with their current state, brightness, color temperature, and capabilities. Shows capability flags (RGB, scenes, sources, music) and preset counts — use list_scenes and list_sources to get full per-light preset lists.", + inputSchema: .object([ + "type": "object", + "properties": .object([:]), + "required": .array([]) + ])), + + Tool(name: "switch_light", + description: "Turn one or more Neewer lights on or off. Use list_lights first to see available light names.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "lights": .object(["type": "array", "items": .object(["type": "string"]), "description": "Light names or IDs. Use 'all' to target every connected light."]), + "state": .object(["type": "boolean", "description": "true = on, false = off"]) + ]), + "required": .array(["lights", "state"]) + ])), + + Tool(name: "set_brightness", + description: "Set brightness level for one or more lights. Does not change the current color mode.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "lights": .object(["type": "array", "items": .object(["type": "string"]), "description": "Light names or IDs."]), + "brightness": .object(["type": "number", "minimum": .int(0), "maximum": .int(100), "description": "Brightness percentage (0–100)."]) + ]), + "required": .array(["lights", "brightness"]) + ])), + + Tool(name: "set_cct", + description: "Set a light to white (CCT) mode with a specific color temperature and brightness. Good for video calls, photography, and general workspace lighting.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "lights": .object(["type": "array", "items": .object(["type": "string"]), "description": "Light names or IDs."]), + "brightness": .object(["type": "number", "minimum": .int(0), "maximum": .int(100), "description": "Brightness percentage (0–100)."]), + "temperature": .object(["type": "number", "minimum": .int(3200), "maximum": .int(8500), "description": "Color temperature in Kelvin. 3200K = warm/tungsten, 5600K = daylight, 8500K = cool/blue."]) + ]), + "required": .array(["lights", "brightness", "temperature"]) + ])), + + Tool(name: "set_hsi", + description: "Set a light to a specific color using hex color code. Only works on RGB-capable lights. Use list_lights to check RGB support first.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "lights": .object(["type": "array", "items": .object(["type": "string"]), "description": "Light names or IDs."]), + "hex_color": .object(["type": "string", "description": "Hex color code (e.g. 'FF0000' for red, '0066FF' for blue)."]), + "brightness": .object(["type": "number", "minimum": .int(0), "maximum": .int(100), "description": "Brightness percentage (0–100)."]), + "saturation": .object(["type": "number", "minimum": .int(0), "maximum": .int(100), "description": "Color saturation percentage (0–100). Default 100."]) + ]), + "required": .array(["lights", "hex_color", "brightness"]) + ])), + + Tool(name: "set_scene", + description: "Activate a dynamic scene effect on one or more lights. Use list_scenes first to see which scenes a light supports and their available parameters (color variants, speed, brightness). Pass the scene ID from list_scenes.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "lights": .object(["type": "array", "items": .object(["type": "string"]), "description": "Light names or IDs."]), + "scene_id": .object(["type": "integer", "minimum": .int(1), "description": "Scene effect ID from list_scenes. IDs are light-specific — always check list_scenes first."]), + "brightness": .object(["type": "integer", "minimum": .int(0), "maximum": .int(100), "description": "Scene brightness (0–100). Optional."]), + "speed": .object(["type": "integer", "minimum": .int(1), "maximum": .int(10), "description": "Scene animation speed (1–10). Optional."]), + "color": .object(["type": "integer", "minimum": .int(0), "description": "Color variant index from list_scenes. Optional."]) + ]), + "required": .array(["lights", "scene_id"]) + ])), + + Tool(name: "list_scenes", + description: "List all available scene effects for a specific light. Different lights support different scenes — an NS02 rope light has 73 scenes (nature, moods, holidays, sports), while a standard RGB panel has 9 or 17. Always call this before set_scene to get valid scene IDs.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "light": .object(["type": "string", "description": "Light name or ID. Must be a single light — scene lists are per-light."]) + ]), + "required": .array(["light"]) + ])), + + Tool(name: "list_sources", + description: "List all available light source presets for a specific light (for lights that support source mode). Call this before setting a source preset.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "light": .object(["type": "string", "description": "Light name or ID. Must be a single light — source presets are per-light."]) + ]), + "required": .array(["light"]) + ])), + + Tool(name: "list_gels", + description: "List all available gel presets for a specific light. Gels are virtual color-filter presets and require RGB-capable lights.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "light": .object(["type": "string", "description": "Light name or ID. Must be a single light."]) + ]), + "required": .array(["light"]) + ])), + + Tool(name: "scan_lights", + description: "Trigger a Bluetooth scan to discover new Neewer lights. Results will appear in list_lights after a few seconds.", + inputSchema: .object([ + "type": "object", + "properties": .object([:]), + "required": .array([]) + ])), + + Tool(name: "get_light_image", + description: "Get the product image of a connected light. Returns the image as base64-encoded PNG data. Useful for identifying the physical hardware model.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "light": .object(["type": "string", "description": "Light name or ID."]) + ]), + "required": .array(["light"]) + ])) + ] + return ListTools.Result(tools: tools) + } + + // MARK: - MCP Tool Call Dispatcher + + func handleMCPToolCall(_ params: CallTool.Parameters) -> CallTool.Result { + Logger.info(LogTag.server, "/mcp tools/call: \(params.name)") + + switch params.name { + case "list_lights": return mcpToolCallListLights() + case "switch_light": return mcpToolCallSwitchLight(params.arguments) + case "set_brightness": return mcpToolCallSetBrightness(params.arguments) + case "set_cct": return mcpToolCallSetCCT(params.arguments) + case "set_hsi": return mcpToolCallSetHSI(params.arguments) + case "set_scene": return mcpToolCallSetScene(params.arguments) + case "list_scenes": return mcpToolCallListScenes(params.arguments) + case "list_sources": return mcpToolCallListSources(params.arguments) + case "list_gels": return mcpToolCallListGels(params.arguments) + case "scan_lights": return mcpToolCallScanLights() + case "get_light_image": return mcpToolCallGetLightImage(params.arguments) + default: return toolResult("Unknown tool: \(params.name)", isError: true) } } - /// Starts the HTTP server - func start() { - do { - try server.start(self.port, forceIPv4: true) - Logger.info(LogTag.server, "NeewerLiteServer listening on http://127.0.0.1:\(port)") - } catch { - Logger.error(LogTag.server, "Failed to start server: \(error)") + // MARK: - MCP Tool Implementations + + private func mcpToolCallListLights() -> CallTool.Result { + guard let viewObjects = appDelegate?.viewObjects else { + return toolResult("NeewerLite is not ready.", isError: true) + } + if viewObjects.isEmpty { + return toolResult("No lights found. Try scan_lights to discover new lights.") + } + var lines: [String] = ["Found \(viewObjects.count) light(s):"] + for (i, obj) in viewObjects.enumerated() { + let dev = obj.device + let name = dev.userLightName.value.isEmpty ? dev.rawName : dev.userLightName.value + let state: String + if !obj.deviceConnected { state = "DISCONNECTED" } + else if dev.isOn.value { state = "ON" } + else { state = "OFF" } + let cctRange = dev.CCTRange() + var caps: [String] = [] + if dev.supportRGB { caps.append("RGB") } + if dev.supportGMRange.value { caps.append("GM") } + let fxCount = dev.supportedFX.count + if fxCount > 0 { caps.append("\(fxCount) scenes") } + let sourceCount = dev.supportedSource.count + if sourceCount > 0 { caps.append("\(sourceCount) sources") } + let gelCount = GelLibrary.shared.all.count + if dev.supportRGB && gelCount > 0 { caps.append("\(gelCount) gels") } + if !dev.supportedMusicFX.isEmpty { caps.append("music-reactive ✓") } + let capsStr = caps.isEmpty ? "CCT only" : caps.joined(separator: ", ") + var modeInfo: String + switch dev.lightMode { + case .CCTMode: + modeInfo = "CCT \(dev.cctValue.value)K" + if dev.supportGMRange.value { + modeInfo += ", GM \(dev.gmmValue.value)" + } + case .HSIMode: + modeInfo = "HSI hue \(dev.hueValue.value)° sat \(dev.satValue.value)%" + case .SRCMode: + if let activeSrc = dev.supportedSource.first(where: { $0.id == dev.sourceChannel.value }) { + modeInfo = "Source: \(activeSrc.name) (CCT \(dev.cctValue.value)K, GM \(dev.gmmValue.value))" + } else { + modeInfo = "Source (id \(dev.sourceChannel.value))" + } + default: + if let activeFX = dev.supportedFX.first(where: { $0.id == UInt16(dev.channel.value) }) { + modeInfo = "Scene: \(activeFX.name) (id \(activeFX.id))" + } else { + modeInfo = "Scene (id \(dev.channel.value))" + } + } + lines.append("\(i + 1). \(name) (id: \(dev.identifier)) — \(state), brightness \(dev.brrValue.value)%, \(modeInfo), CCT range \(cctRange.minCCT)-\(cctRange.maxCCT)K") + lines.append(" Model: \(dev.projectName), rawName: \(dev.rawName), MAC: \(dev.getMAC())") + let dbItem = ContentManager.shared.fetchLightProperty(lightType: dev.lightType) + if let image = dbItem?.image, !image.isEmpty { + lines.append(" Image: \(image)") + } + if let link = dbItem?.link, !link.isEmpty { + lines.append(" Product URL: \(link)") + } + lines.append(" Capabilities: \(capsStr)") } + return toolResult(lines.joined(separator: "\n")) } - /// Stops the HTTP server - func stop() { - server.stop() - Logger.info(LogTag.server, "NeewerLiteServer stopped") + private func mcpToolCallSwitchLight(_ arguments: [String: Value]?) -> CallTool.Result { + guard let args = arguments, + let lightsArr = args["lights"]?.arrayValue, + let state = args["state"]?.boolValue else { + return toolResult("Missing required parameters: lights (array), state (boolean).", isError: true) + } + let lights = lightsArr.compactMap { $0.stringValue } + let targets = resolveViewObjects(lights) + if targets.isEmpty { + return toolResult("No matching lights found for: \(lights.joined(separator: ", "))", isError: true) + } + var switched: [String] = [] + for viewObj in targets { + enqueueBLEOperation { + await MainActor.run { + if state { + if !viewObj.isON { viewObj.toggleLight() } + } else { + if viewObj.isON { viewObj.toggleLight() } + } + } + } + let name = viewObj.device.userLightName.value.isEmpty ? viewObj.device.rawName : viewObj.device.userLightName.value + switched.append(name) + } + return toolResult("Turned \(state ? "on" : "off"): \(switched.joined(separator: ", "))") + } + + private func mcpToolCallSetBrightness(_ arguments: [String: Value]?) -> CallTool.Result { + guard let args = arguments, + let lightsArr = args["lights"]?.arrayValue, + let brightness = args["brightness"]?.numericDouble else { + return toolResult("Missing required parameters: lights (array), brightness (number).", isError: true) + } + let lights = lightsArr.compactMap { $0.stringValue } + let targets = resolveViewObjects(lights) + if targets.isEmpty { + return toolResult("No matching lights found for: \(lights.joined(separator: ", "))", isError: true) + } + var updated: [String] = [] + for viewObj in targets { + enqueueBLEOperation { + await MainActor.run { + let dev = viewObj.device + if dev.lightMode == .HSIMode { + dev.setHSILightValues(brr100: CGFloat(brightness), + hue: CGFloat(dev.hueValue.value) / 360.0, + hue360: CGFloat(dev.hueValue.value), + sat: CGFloat(dev.satValue.value) / 100.0) + } else { + viewObj.changeToCCTMode() + dev.setCCTLightValues(brr: CGFloat(brightness), + cct: CGFloat(dev.cctValue.value), + gmm: CGFloat(dev.gmmValue.value)) + } + } + } + let name = viewObj.device.userLightName.value.isEmpty ? viewObj.device.rawName : viewObj.device.userLightName.value + updated.append(name) + } + return toolResult("Set brightness to \(Int(brightness))%: \(updated.joined(separator: ", "))") + } + + private func mcpToolCallSetCCT(_ arguments: [String: Value]?) -> CallTool.Result { + guard let args = arguments, + let lightsArr = args["lights"]?.arrayValue, + let brightness = args["brightness"]?.numericDouble, + let temperature = args["temperature"]?.numericDouble else { + return toolResult("Missing required parameters: lights (array), brightness (number), temperature (number).", isError: true) + } + let lights = lightsArr.compactMap { $0.stringValue } + let targets = resolveViewObjects(lights) + if targets.isEmpty { + return toolResult("No matching lights found for: \(lights.joined(separator: ", "))", isError: true) + } + var updated: [String] = [] + for viewObj in targets { + enqueueBLEOperation { + await MainActor.run { + viewObj.changeToCCTMode() + viewObj.device.setCCTLightValues( + brr: CGFloat(brightness), + cct: CGFloat(temperature), + gmm: CGFloat(viewObj.device.gmmValue.value)) + } + } + let name = viewObj.device.userLightName.value.isEmpty ? viewObj.device.rawName : viewObj.device.userLightName.value + updated.append(name) + } + return toolResult("Set CCT mode — \(Int(brightness))% brightness, \(Int(temperature))K: \(updated.joined(separator: ", "))") + } + + private func mcpToolCallSetHSI(_ arguments: [String: Value]?) -> CallTool.Result { + guard let args = arguments, + let lightsArr = args["lights"]?.arrayValue, + let hexColor = args["hex_color"]?.stringValue, + let brightness = args["brightness"]?.numericDouble else { + return toolResult("Missing required parameters: lights (array), hex_color (string), brightness (number).", isError: true) + } + let saturation = args["saturation"]?.numericDouble ?? 100.0 + let lights = lightsArr.compactMap { $0.stringValue } + let targets = resolveViewObjects(lights) + if targets.isEmpty { + return toolResult("No matching lights found for: \(lights.joined(separator: ", "))", isError: true) + } + let color = NSColor(hex: hexColor, alpha: 1) + let hueVal = CGFloat(color.hueComponent * 360.0) + let satVal = CGFloat(saturation / 100.0) + var updated: [String] = [] + var skipped: [String] = [] + for viewObj in targets { + let name = viewObj.device.userLightName.value.isEmpty ? viewObj.device.rawName : viewObj.device.userLightName.value + if viewObj.device.supportRGB { + enqueueBLEOperation { + await MainActor.run { + viewObj.changeToHSIMode() + viewObj.updateHSI(hue: hueVal, sat: satVal, brr: brightness) + } + } + updated.append(name) + } else { + skipped.append(name) + } + } + var text = "Set HSI mode — color #\(hexColor), \(Int(brightness))% brightness, \(Int(saturation))% saturation" + if !updated.isEmpty { text += ": \(updated.joined(separator: ", "))" } + if !skipped.isEmpty { text += "\nSkipped (no RGB support): \(skipped.joined(separator: ", "))" } + return toolResult(text, isError: updated.isEmpty) + } + + private func mcpToolCallSetScene(_ arguments: [String: Value]?) -> CallTool.Result { + guard let args = arguments, + let lightsArr = args["lights"]?.arrayValue, + let sceneId = args["scene_id"]?.numericInt else { + return toolResult("Missing required parameters: lights (array), scene_id (integer).", isError: true) + } + let lights = lightsArr.compactMap { $0.stringValue } + let targets = resolveViewObjects(lights) + if targets.isEmpty { + return toolResult("No matching lights found for: \(lights.joined(separator: ", "))", isError: true) + } + let optBrightness = args["brightness"]?.numericInt + let optSpeed = args["speed"]?.numericInt + let optColor = args["color"]?.numericInt + var updated: [String] = [] + var errors: [String] = [] + for viewObj in targets { + let name = viewObj.device.userLightName.value.isEmpty ? viewObj.device.rawName : viewObj.device.userLightName.value + let fxCount = viewObj.device.supportedFX.count + if sceneId > 0 && sceneId <= fxCount { + let fx = viewObj.device.supportedFX[sceneId - 1] + if let brr = optBrightness, fx.needBRR { fx.brrValue = CGFloat(brr) } + if let speed = optSpeed, fx.needSpeed { fx.speedValue = speed } + if let color = optColor, fx.needColor { fx.colorValue = color } + enqueueBLEOperation { + await MainActor.run { + // Keep the control view in sync with MCP scene changes. + viewObj.changeToSCEMode() + viewObj.changeToSCE(sceneId, fx.needBRR ? Double(fx.brrValue) : nil) + viewObj.device.sendSceneCommand(fx) + } + } + updated.append("\(name) → \(fx.name)") + } else { + errors.append("\(name): scene_id \(sceneId) out of range (1–\(fxCount))") + } + } + var text = "" + if !updated.isEmpty { text += "Activated scene: \(updated.joined(separator: ", "))" } + if !errors.isEmpty { + if !text.isEmpty { text += "\n" } + text += "Errors: \(errors.joined(separator: "; "))" + } + return toolResult(text, isError: updated.isEmpty) + } + + private func mcpToolCallListScenes(_ arguments: [String: Value]?) -> CallTool.Result { + guard let lightId = arguments?["light"]?.stringValue else { + return toolResult("Missing required parameter: light (string).", isError: true) + } + guard let viewObjects = appDelegate?.viewObjects else { + return toolResult("NeewerLite is not ready.", isError: true) + } + guard let viewObj = viewObjects.first(where: { $0.matches(lightId: lightId) }) else { + return toolResult("No light found matching: \(lightId)", isError: true) + } + let dev = viewObj.device + let name = dev.userLightName.value.isEmpty ? dev.rawName : dev.userLightName.value + let scenes = dev.supportedFX + if scenes.isEmpty { + return toolResult("\(name) does not support scenes.") + } + var lines: [String] = ["\(name) — \(scenes.count) scene(s):"] + for fx in scenes { + var detail = " \(fx.id). \(fx.name)" + var params: [String] = [] + if fx.needBRR { params.append("brightness: 0–100") } + if fx.needSpeed { params.append("speed: 1–10") } + if fx.needColor && !fx.colors.isEmpty { + let colorNames = fx.colors.enumerated().map { "\($0.offset): \($0.element.key)" } + params.append("color: [\(colorNames.joined(separator: ", "))]") + } + if !params.isEmpty { + detail += " (\(params.joined(separator: ", ")))" + } + lines.append(detail) + } + let musicFX = dev.supportedMusicFX + if !musicFX.isEmpty { + lines.append("") + lines.append("Music-reactive modes (\(musicFX.count)):") + for fx in musicFX { + lines.append(" \(fx.id). \(fx.name)") + } + } + return toolResult(lines.joined(separator: "\n")) + } + + private func mcpToolCallListSources(_ arguments: [String: Value]?) -> CallTool.Result { + guard let lightId = arguments?["light"]?.stringValue else { + return toolResult("Missing required parameter: light (string).", isError: true) + } + guard let viewObjects = appDelegate?.viewObjects else { + return toolResult("NeewerLite is not ready.", isError: true) + } + guard let viewObj = viewObjects.first(where: { $0.matches(lightId: lightId) }) else { + return toolResult("No light found matching: \(lightId)", isError: true) + } + let dev = viewObj.device + let name = dev.userLightName.value.isEmpty ? dev.rawName : dev.userLightName.value + let sources = dev.supportedSource + if sources.isEmpty { + return toolResult("\(name) does not support source presets.") + } + var lines: [String] = ["\(name) — \(sources.count) source preset(s):"] + for src in sources { + var detail = " \(src.id). \(src.name)" + var params: [String] = [] + if src.needBRR { params.append("brightness") } + if src.needCCT { params.append("cct") } + if src.needGM { params.append("gm") } + if !params.isEmpty { + detail += " (params: \(params.joined(separator: ", ")))" + } + lines.append(detail) + } + return toolResult(lines.joined(separator: "\n")) + } + + private func mcpToolCallListGels(_ arguments: [String: Value]?) -> CallTool.Result { + guard let lightId = arguments?["light"]?.stringValue else { + return toolResult("Missing required parameter: light (string).", isError: true) + } + guard let viewObjects = appDelegate?.viewObjects else { + return toolResult("NeewerLite is not ready.", isError: true) + } + guard let viewObj = viewObjects.first(where: { $0.matches(lightId: lightId) }) else { + return toolResult("No light found matching: \(lightId)", isError: true) + } + let dev = viewObj.device + let name = dev.userLightName.value.isEmpty ? dev.rawName : dev.userLightName.value + guard dev.supportRGB else { + return toolResult("\(name) does not support gels (RGB required).") + } + + let gels = GelLibrary.shared.all + if gels.isEmpty { + return toolResult("No gel presets available in database.") + } + + var lines: [String] = ["\(name) — \(gels.count) gel preset(s):"] + for (idx, gel) in gels.enumerated() { + let maker = gel.manufacturer.isEmpty ? "Generic" : gel.manufacturer + let code = gel.code.isEmpty ? "-" : gel.code + lines.append(" \(idx + 1). \(gel.name) [\(maker) \(code)] — hue \(Int(gel.hue))°, sat \(Int(gel.saturation))%, transmission \(Int(gel.transmissionPercent))%") + } + return toolResult(lines.joined(separator: "\n")) + } + + private func mcpToolCallScanLights() -> CallTool.Result { + enqueueBLEOperation { + await MainActor.run { + self.appDelegate?.scanAction(self) + } + } + return toolResult("Bluetooth scan started. Use list_lights in a few seconds to see newly discovered lights.") + } + + private func mcpToolCallGetLightImage(_ arguments: [String: Value]?) -> CallTool.Result { + guard let args = arguments, + let lightId = args["light"]?.stringValue else { + return toolResult("Missing required parameter: light (string).", isError: true) + } + let targets = resolveViewObjects([lightId]) + guard let obj = targets.first else { + return toolResult("Light not found or not connected: \(lightId)", isError: true) + } + let dev = obj.device + let lightType = dev.lightType + + // Try to get image: cached NSImage → tiffRepresentation → PNG + let image = ContentManager.shared.fetchCachedLightImage(lightType: lightType) + + // If no cached image, try loading from resolved URL directly + let imageData: Data? + if let img = image, let tiff = img.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) { + imageData = bitmap.representation(using: .png, properties: [:]) + } else if let imageRef = ContentManager.shared.fetchLightProperty(lightType: lightType)?.image, + let url = ContentManager.shared.resolveImageURL(imageRef, subdirectory: "light_images") { + imageData = try? Data(contentsOf: url) + } else { + imageData = nil + } + + guard let data = imageData, !data.isEmpty else { + return toolResult("No product image available for this light.") + } + + let base64 = data.base64EncodedString() + let name = dev.userLightName.value.isEmpty ? dev.rawName : dev.userLightName.value + return CallTool.Result(content: [ + .text(text: "Product image for \(name) (\(dev.projectName)):", annotations: nil, _meta: nil), + .image(data: base64, mimeType: "image/png", annotations: nil, _meta: nil) + ]) + } + + // MARK: - Helpers + + /// Resolve light name list to DeviceViewObjects. "all" expands to every connected light. + private func resolveViewObjects(_ lights: [String]) -> [DeviceViewObject] { + guard let viewObjects = appDelegate?.viewObjects else { return [] } + if lights.contains(where: { $0.lowercased() == "all" }) { + return viewObjects.filter { $0.deviceConnected } + } + return lights.flatMap { lightId in + viewObjects.filter { $0.matches(lightId: lightId) && $0.deviceConnected } + } + } +} + +// MARK: - Private Helpers + +private func toolResult(_ text: String, isError: Bool = false) -> CallTool.Result { + CallTool.Result(content: [.text(text: text, annotations: nil, _meta: nil)], isError: isError) +} + +private func jsonResponse(_ object: Any) -> VaporResponse { + guard let data = try? JSONSerialization.data(withJSONObject: object) else { + return VaporResponse(status: .internalServerError) + } + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/json") + return VaporResponse(status: .ok, headers: headers, body: .init(data: data)) +} + +private func jsonErrorResponse(_ message: String) -> VaporResponse { + guard let data = try? JSONSerialization.data(withJSONObject: ["error": message]) else { + return VaporResponse(status: .internalServerError) } + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/json") + return VaporResponse(status: .badRequest, headers: headers, body: .init(data: data)) } diff --git a/NeewerLite/NeewerLite/Views/CollectionViewItem.swift b/NeewerLite/NeewerLite/Views/CollectionViewItem.swift index 195fe91..210c254 100644 --- a/NeewerLite/NeewerLite/Views/CollectionViewItem.swift +++ b/NeewerLite/NeewerLite/Views/CollectionViewItem.swift @@ -1132,7 +1132,7 @@ class CollectionViewItem: NSCollectionViewItem, NSTextFieldDelegate, NSTabViewDe let offsetX = 50.0 let topY = 98.0 var offsetY = fxsubview.bounds.height - 26 - let sliderWidth = self.sliderWidth() + let sliderWidth = fxsubview.bounds.width - 70 let valueItemWidth = 98.0 // Define the gap between subviews @@ -1195,10 +1195,22 @@ class CollectionViewItem: NSCollectionViewItem, NSTextFieldDelegate, NSTabViewDe } offsetY = 70 - + + // Reset CCT/GM to preset defaults on each source selection (BRR is user-adjustable) + if let defaultCCT = safeFx.defaultCCTValue { + safeFx.cctValue = defaultCCT + } + if let defaultGM = safeFx.defaultGMValue { + safeFx.gmValue = defaultGM + } + if safeFx.needBRR { + // Use device's current brightness if no explicit preset + if safeFx.featureValues["brrValue"] == nil { + safeFx.brrValue = CGFloat(dev.brrValue.value) + } fxsubview.addSubview(createBigValueLabel("Brightness".localized)) - fxsubview.addSubview(createBigValueField(ControlTag.brr, formatBrrValue("\(dev.brrValue.value)", .center))) + fxsubview.addSubview(createBigValueField(ControlTag.brr, formatBrrValue("\(Int(safeFx.brrValue))", .center))) fxsubview.addSubview(createLabel(offsetY-4, "BRR".localized)) let slide = NLSlider(frame: NSRect(x: offsetX, y: offsetY, width: sliderWidth, height: 20)) @@ -1215,6 +1227,7 @@ class CollectionViewItem: NSCollectionViewItem, NSTextFieldDelegate, NSTabViewDe if let safeDev = safeSelf.device { safeFx.brrValue = val safeDev.setCCTLightValues(brr: CGFloat(safeFx.brrValue), cct: CGFloat(safeFx.cctValue), gmm: CGFloat(safeFx.gmValue)) + safeDev.lightMode = .SRCMode } } fxsubview.addSubview(slide) @@ -1240,6 +1253,7 @@ class CollectionViewItem: NSCollectionViewItem, NSTextFieldDelegate, NSTabViewDe if let safeDev = safeSelf.device { safeFx.cctValue = val safeDev.setCCTLightValues(brr: CGFloat(safeFx.brrValue), cct: CGFloat(safeFx.cctValue), gmm: CGFloat(safeFx.gmValue)) + safeDev.lightMode = .SRCMode } } fxsubview.addSubview(slide) @@ -1265,6 +1279,7 @@ class CollectionViewItem: NSCollectionViewItem, NSTextFieldDelegate, NSTabViewDe if let safeDev = safeSelf.device { safeFx.gmValue = val safeDev.setCCTLightValues(brr: CGFloat(safeFx.brrValue), cct: CGFloat(safeFx.cctValue), gmm: CGFloat(safeFx.gmValue)) + safeDev.lightMode = .SRCMode } } fxsubview.addSubview(slide) @@ -1280,6 +1295,8 @@ class CollectionViewItem: NSCollectionViewItem, NSTextFieldDelegate, NSTabViewDe { dev.setCCTLightValues(brr: CGFloat(safeFx.brrValue), cct: CGFloat(safeFx.cctValue), gmm: CGFloat(safeFx.gmValue)) } + dev.lightMode = .SRCMode + dev.sourceChannel.value = safeFx.id } } @@ -1914,10 +1931,20 @@ class CollectionViewItem: NSCollectionViewItem, NSTextFieldDelegate, NSTabViewDe if dev.supportRGB { Logger.debug("brr: \(brr) hue: \(hue) sat: \(sat)") let val = getHSIValuesFromView() - let brrValue = brr != nil ? brr! : val.brr - let hueVal = Double(hue) / 360.0 + let normalized = normalizeHSIInput( + hueDegrees: hue, + saturation: sat, + brightness: brr ?? Double(val.brr) + ) + let brrValue = normalized.brightnessUnit ?? 1.0 + let hueVal = Double(normalized.hueDegrees) / 360.0 if let wheel = getHSIWheelFromView() { - let color = NSColor(calibratedHue: hueVal, saturation: sat, brightness: brrValue, alpha: 1) + let color = NSColor( + calibratedHue: hueVal, + saturation: normalized.saturationUnit, + brightness: brrValue, + alpha: 1 + ) wheel.setViewColor(color) } if let brrSlide = getHSIBrrSlideFromView() { @@ -1925,7 +1952,12 @@ class CollectionViewItem: NSCollectionViewItem, NSTextFieldDelegate, NSTabViewDe brrSlide.currentValue = brrValue * brrSlide.maxValue brrSlide.pauseNotify = false } - dev.setHSILightValues(brr100: brrValue * 100.0, hue: hueVal, hue360: hue, sat: sat) + dev.setHSILightValues( + brr100: brrValue * 100.0, + hue: hueVal, + hue360: normalized.hueDegrees, + sat: normalized.saturationUnit + ) } } } diff --git a/NeewerLite/NeewerLite/Views/SettingsView.swift b/NeewerLite/NeewerLite/Views/SettingsView.swift index a62c46f..49317dc 100644 --- a/NeewerLite/NeewerLite/Views/SettingsView.swift +++ b/NeewerLite/NeewerLite/Views/SettingsView.swift @@ -6,17 +6,108 @@ // import Cocoa +import ServiceManagement import Sparkle +private final class FlippedContentView: NSView { + override var isFlipped: Bool { true } +} + +private func dictionary(at keyPath: String, in root: [String: Any]) -> [String: Any]? { + let keys = keyPath.split(separator: ".").map(String.init) + guard !keys.isEmpty else { return nil } + var current: Any = root + for key in keys { + guard let dict = current as? [String: Any], let next = dict[key] else { return nil } + current = next + } + return current as? [String: Any] +} + +private func setDictionary(_ value: [String: Any], at keyPath: String, in root: inout [String: Any]) { + let keys = keyPath.split(separator: ".").map(String.init) + guard !keys.isEmpty else { return } + setDictionary(value, keys: keys, in: &root) +} + +private func setDictionary(_ value: [String: Any], keys: [String], in root: inout [String: Any]) { + if keys.count == 1 { + root[keys[0]] = value + return + } + var child = root[keys[0]] as? [String: Any] ?? [:] + setDictionary(value, keys: Array(keys.dropFirst()), in: &child) + root[keys[0]] = child +} + +private struct MCPClientDef { + let name: String + let installPaths: [String] + let configPath: String // may start with ~ + let configKey: String // e.g. "mcpServers", "servers", "mcp.servers" + /// Returns the JSON object written under config[configKey]["neewerlite"] + let configEntry: () -> [String: Any] + var configURL: URL { + URL(fileURLWithPath: NSString(string: configPath).expandingTildeInPath) + } + var isInstalled: Bool { + installPaths.contains { path in + let expandedPath = NSString(string: path).expandingTildeInPath + return FileManager.default.fileExists(atPath: expandedPath) + } + } + var isConfigured: Bool { + guard let data = try? Data(contentsOf: configURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let servers = dictionary(at: configKey, in: json) else { return false } + return servers["neewerlite"] != nil + } +} + +private let kMCPClients: [MCPClientDef] = [ + .init(name: "Claude Desktop", + installPaths: ["/Applications/Claude.app"], + configPath: "~/Library/Application Support/Claude/claude_desktop_config.json", + configKey: "mcpServers", + configEntry: { ["command": "npx", "args": ["-y", "mcp-remote", "http://127.0.0.1:18486/mcp"]] }), + .init(name: "Cursor", + installPaths: ["/Applications/Cursor.app"], + configPath: "~/.cursor/mcp.json", + configKey: "mcpServers", + configEntry: { ["url": "http://127.0.0.1:18486/mcp", "type": "streamable-http"] }), + .init(name: "Windsurf", + installPaths: ["/Applications/Windsurf.app"], + configPath: "~/.codeium/windsurf/mcp_config.json", + configKey: "mcpServers", + configEntry: { ["url": "http://127.0.0.1:18486/mcp", "type": "streamable-http"] }), + .init(name: "VS Code (Copilot)", + installPaths: ["/Applications/Visual Studio Code.app"], + configPath: "~/Library/Application Support/Code/User/mcp.json", + configKey: "servers", + configEntry: { ["url": "http://127.0.0.1:18486/mcp", "type": "streamable-http"] }), + .init(name: "OpenClaw", + installPaths: ["/Applications/OpenClaw.app", "~/.openclaw"], + configPath: "~/.openclaw/openclaw.json", + configKey: "mcp.servers", + configEntry: { ["url": "http://127.0.0.1:18486/mcp"] }), +] + class SettingsView: NSView { private var dbInfoLabel: NSTextField! + private var appInfoLabel: NSTextField = NSTextField(labelWithString: "") private var dbSyncStatusLabel: NSTextField! private var dbSourceURLField: NSTextField! private var dbStatusIcon: NSTextField! private var deleteDBButton: NSButton! private var syncDBButton: NSButton! private var languagePopUp: NSPopUpButton! + private var launchAtLoginCheckbox: NSButton! + private var serverCheckbox: NSButton! + private var observingHTTPServer = false + private var serverURLLabel: NSTextField! + private var serverTestButton: NSButton! + private var mcpClientCheckboxes: [(MCPClientDef, NSButton)] = [] private var fileMonitorSource: DispatchSourceFileSystemObject? private var dirMonitorSource: DispatchSourceFileSystemObject? @@ -34,38 +125,59 @@ class SettingsView: NSView { wantsLayer = true let padding: CGFloat = 20 + let contentView = FlippedContentView() + contentView.translatesAutoresizingMaskIntoConstraints = false + + let scrollView = NSScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.documentView = contentView + addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor) + ]) // --- App Info Section --- let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" + appInfoLabel.stringValue = "NeewerLite v\(appVersion) (\(buildNumber))" - let appInfoLabel = NSTextField(labelWithString: "NeewerLite v%@ (%@)".localized(appVersion, buildNumber)) appInfoLabel.font = NSFont.boldSystemFont(ofSize: 16) appInfoLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(appInfoLabel) + contentView.addSubview(appInfoLabel) // --- Check for Updates Button --- let updateButton = NSButton(title: "Check for Updates…".localized, target: self, action: #selector(checkForUpdates)) updateButton.bezelStyle = .rounded updateButton.translatesAutoresizingMaskIntoConstraints = false - addSubview(updateButton) + contentView.addSubview(updateButton) // --- GitHub Button --- let githubButton = NSButton(title: "GitHub Repo".localized, target: self, action: #selector(openGitHub)) githubButton.bezelStyle = .rounded githubButton.translatesAutoresizingMaskIntoConstraints = false - addSubview(githubButton) + contentView.addSubview(githubButton) // --- Database Section Header + Status Icon --- let dbHeader = NSTextField(labelWithString: "Light Database".localized) dbHeader.font = NSFont.boldSystemFont(ofSize: 14) dbHeader.translatesAutoresizingMaskIntoConstraints = false - addSubview(dbHeader) + contentView.addSubview(dbHeader) let statusIcon = NSTextField(labelWithString: "") statusIcon.font = NSFont.systemFont(ofSize: 14) statusIcon.translatesAutoresizingMaskIntoConstraints = false - addSubview(statusIcon) + contentView.addSubview(statusIcon) dbStatusIcon = statusIcon // --- Database Info --- @@ -74,7 +186,7 @@ class SettingsView: NSView { infoLabel.textColor = .secondaryLabelColor infoLabel.maximumNumberOfLines = 0 infoLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(infoLabel) + contentView.addSubview(infoLabel) dbInfoLabel = infoLabel // --- Sync Status --- @@ -82,7 +194,7 @@ class SettingsView: NSView { syncStatusLabel.font = NSFont.systemFont(ofSize: 11) syncStatusLabel.textColor = .tertiaryLabelColor syncStatusLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(syncStatusLabel) + contentView.addSubview(syncStatusLabel) dbSyncStatusLabel = syncStatusLabel // --- Sync Source URL --- @@ -92,34 +204,34 @@ class SettingsView: NSView { sourceURLField.isSelectable = true sourceURLField.allowsEditingTextAttributes = true sourceURLField.translatesAutoresizingMaskIntoConstraints = false - addSubview(sourceURLField) + contentView.addSubview(sourceURLField) dbSourceURLField = sourceURLField // --- Sync Database Button --- let syncButton = NSButton(title: "Sync Database Now".localized, target: self, action: #selector(syncDatabase)) syncButton.bezelStyle = .rounded syncButton.translatesAutoresizingMaskIntoConstraints = false - addSubview(syncButton) + contentView.addSubview(syncButton) syncDBButton = syncButton // --- Delete Local DB Button --- let deleteButton = NSButton(title: "Delete Local DB".localized, target: self, action: #selector(deleteLocalDB)) deleteButton.bezelStyle = .rounded deleteButton.translatesAutoresizingMaskIntoConstraints = false - addSubview(deleteButton) + contentView.addSubview(deleteButton) deleteDBButton = deleteButton // --- View in Finder Button --- let finderButton = NSButton(title: "View in Finder".localized, target: self, action: #selector(viewInFinder)) finderButton.bezelStyle = .rounded finderButton.translatesAutoresizingMaskIntoConstraints = false - addSubview(finderButton) + contentView.addSubview(finderButton) // --- Language Section --- let langHeader = NSTextField(labelWithString: "Language".localized) langHeader.font = NSFont.boldSystemFont(ofSize: 14) langHeader.translatesAutoresizingMaskIntoConstraints = false - addSubview(langHeader) + contentView.addSubview(langHeader) let langPopUp = NSPopUpButton(frame: .zero, pullsDown: false) langPopUp.translatesAutoresizingMaskIntoConstraints = false @@ -148,46 +260,113 @@ class SettingsView: NSView { } langPopUp.target = self langPopUp.action = #selector(languageChanged(_:)) - addSubview(langPopUp) + contentView.addSubview(langPopUp) languagePopUp = langPopUp let langNote = NSTextField(labelWithString: "Restart required".localized) langNote.font = NSFont.systemFont(ofSize: 11) langNote.textColor = .tertiaryLabelColor langNote.translatesAutoresizingMaskIntoConstraints = false - addSubview(langNote) + contentView.addSubview(langNote) + + // --- Launch at Login Section --- + let loginCheckbox = NSButton(checkboxWithTitle: "Launch at Login".localized, target: self, action: #selector(launchAtLoginChanged(_:))) + loginCheckbox.translatesAutoresizingMaskIntoConstraints = false + loginCheckbox.state = SMAppService.mainApp.status == .enabled ? .on : .off + contentView.addSubview(loginCheckbox) + launchAtLoginCheckbox = loginCheckbox + + // --- HTTP Server Section --- + let serverEnabled = UserDefaults.standard.bool(forKey: "HTTPServerEnabled") + let srvCheckbox = NSButton(checkboxWithTitle: "HTTP Server".localized, target: self, action: #selector(serverToggled(_:))) + srvCheckbox.translatesAutoresizingMaskIntoConstraints = false + srvCheckbox.state = serverEnabled ? .on : .off + contentView.addSubview(srvCheckbox) + serverCheckbox = srvCheckbox + + let srvURLLabel = NSTextField(labelWithString: "http://127.0.0.1:18486") + srvURLLabel.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + srvURLLabel.textColor = .secondaryLabelColor + srvURLLabel.isSelectable = true + srvURLLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(srvURLLabel) + serverURLLabel = srvURLLabel + + let testButton = NSButton(title: "Test ↗".localized, target: self, action: #selector(openPingURL)) + testButton.bezelStyle = .inline + testButton.font = NSFont.systemFont(ofSize: 11) + testButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(testButton) + serverTestButton = testButton + + let srvNote = NSTextField(labelWithString: "Enables Stream Deck and MCP (AI agent) integration.".localized) + srvNote.font = NSFont.systemFont(ofSize: 11) + srvNote.textColor = .tertiaryLabelColor + srvNote.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(srvNote) + + updateServerUI(enabled: serverEnabled) + + // --- Section Separators --- + let sep1 = NSBox() + sep1.boxType = .separator + sep1.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sep1) + + let sep2 = NSBox() + sep2.boxType = .separator + sep2.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sep2) + + let sep3 = NSBox() + sep3.boxType = .separator + sep3.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sep3) + + let sep4 = NSBox() + sep4.boxType = .separator + sep4.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sep4) + + let bottomSpacer = NSView() + bottomSpacer.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(bottomSpacer) // Layout NSLayoutConstraint.activate([ - appInfoLabel.topAnchor.constraint(equalTo: topAnchor, constant: padding), - appInfoLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + appInfoLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding), + appInfoLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), updateButton.topAnchor.constraint(equalTo: appInfoLabel.bottomAnchor, constant: 12), - updateButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + updateButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), githubButton.centerYAnchor.constraint(equalTo: updateButton.centerYAnchor), githubButton.leadingAnchor.constraint(equalTo: updateButton.trailingAnchor, constant: 8), - dbHeader.topAnchor.constraint(equalTo: updateButton.bottomAnchor, constant: 24), - dbHeader.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + sep1.topAnchor.constraint(equalTo: updateButton.bottomAnchor, constant: 16), + sep1.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + sep1.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), + + dbHeader.topAnchor.constraint(equalTo: sep1.bottomAnchor, constant: 16), + dbHeader.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), statusIcon.centerYAnchor.constraint(equalTo: dbHeader.centerYAnchor), statusIcon.leadingAnchor.constraint(equalTo: dbHeader.trailingAnchor, constant: 6), infoLabel.topAnchor.constraint(equalTo: dbHeader.bottomAnchor, constant: 8), - infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), - infoLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -padding), + infoLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + infoLabel.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -padding), sourceURLField.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 6), - sourceURLField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), - sourceURLField.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -padding), + sourceURLField.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + sourceURLField.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -padding), syncStatusLabel.topAnchor.constraint(equalTo: sourceURLField.bottomAnchor, constant: 6), - syncStatusLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), - syncStatusLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -padding), + syncStatusLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + syncStatusLabel.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -padding), syncButton.topAnchor.constraint(equalTo: syncStatusLabel.bottomAnchor, constant: 12), - syncButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + syncButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), deleteButton.centerYAnchor.constraint(equalTo: syncButton.centerYAnchor), deleteButton.leadingAnchor.constraint(equalTo: syncButton.trailingAnchor, constant: 8), @@ -195,15 +374,123 @@ class SettingsView: NSView { finderButton.centerYAnchor.constraint(equalTo: syncButton.centerYAnchor), finderButton.leadingAnchor.constraint(equalTo: deleteButton.trailingAnchor, constant: 8), - langHeader.topAnchor.constraint(equalTo: syncButton.bottomAnchor, constant: 24), - langHeader.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + sep2.topAnchor.constraint(equalTo: syncButton.bottomAnchor, constant: 16), + sep2.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + sep2.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), + + langHeader.topAnchor.constraint(equalTo: sep2.bottomAnchor, constant: 16), + langHeader.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), langPopUp.centerYAnchor.constraint(equalTo: langHeader.centerYAnchor), langPopUp.leadingAnchor.constraint(equalTo: langHeader.trailingAnchor, constant: 12), langPopUp.widthAnchor.constraint(greaterThanOrEqualToConstant: 160), langNote.topAnchor.constraint(equalTo: langHeader.bottomAnchor, constant: 6), - langNote.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + langNote.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + + sep3.topAnchor.constraint(equalTo: langNote.bottomAnchor, constant: 16), + sep3.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + sep3.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), + + loginCheckbox.topAnchor.constraint(equalTo: sep3.bottomAnchor, constant: 16), + loginCheckbox.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + + sep4.topAnchor.constraint(equalTo: loginCheckbox.bottomAnchor, constant: 16), + sep4.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + sep4.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), + + srvCheckbox.topAnchor.constraint(equalTo: sep4.bottomAnchor, constant: 16), + srvCheckbox.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + + srvURLLabel.centerYAnchor.constraint(equalTo: srvCheckbox.centerYAnchor), + srvURLLabel.leadingAnchor.constraint(equalTo: srvCheckbox.trailingAnchor, constant: 8), + + serverTestButton.centerYAnchor.constraint(equalTo: srvCheckbox.centerYAnchor), + serverTestButton.leadingAnchor.constraint(equalTo: srvURLLabel.trailingAnchor, constant: 6), + + srvNote.topAnchor.constraint(equalTo: srvCheckbox.bottomAnchor, constant: 6), + srvNote.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + ]) + + // --- MCP Clients Section --- + let sep5 = NSBox() + sep5.boxType = .separator + sep5.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sep5) + + let mcpHeader = NSTextField(labelWithString: "MCP Clients".localized) + mcpHeader.font = NSFont.boldSystemFont(ofSize: 14) + mcpHeader.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(mcpHeader) + + let mcpNote = NSTextField(labelWithString: "Check a client to write NeewerLite into its MCP config file.".localized) + mcpNote.font = NSFont.systemFont(ofSize: 11) + mcpNote.textColor = .tertiaryLabelColor + mcpNote.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(mcpNote) + + var mcpConstraints: [NSLayoutConstraint] = [ + sep5.topAnchor.constraint(equalTo: srvNote.bottomAnchor, constant: 16), + sep5.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + sep5.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), + mcpHeader.topAnchor.constraint(equalTo: sep5.bottomAnchor, constant: 16), + mcpHeader.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + mcpNote.topAnchor.constraint(equalTo: mcpHeader.bottomAnchor, constant: 4), + mcpNote.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + ] + + var prevAnchor = mcpNote.bottomAnchor + for client in kMCPClients { + let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(mcpClientToggled(_:))) + checkbox.translatesAutoresizingMaskIntoConstraints = false + checkbox.state = client.isConfigured ? .on : .off + checkbox.isEnabled = client.isInstalled + contentView.addSubview(checkbox) + + let nameLabel = NSTextField(labelWithString: client.name) + nameLabel.font = NSFont.systemFont(ofSize: 13) + nameLabel.textColor = client.isInstalled ? .labelColor : .tertiaryLabelColor + nameLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(nameLabel) + + let pathLabel = NSTextField(labelWithString: client.isInstalled ? client.configPath : "Not installed".localized) + pathLabel.font = NSFont.monospacedSystemFont(ofSize: 10, weight: .regular) + pathLabel.textColor = .tertiaryLabelColor + pathLabel.lineBreakMode = .byTruncatingMiddle + pathLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pathLabel) + + mcpClientCheckboxes.append((client, checkbox)) + + mcpConstraints += [ + checkbox.topAnchor.constraint(equalTo: prevAnchor, constant: 12), + checkbox.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + nameLabel.centerYAnchor.constraint(equalTo: checkbox.centerYAnchor), + nameLabel.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 6), + pathLabel.centerYAnchor.constraint(equalTo: checkbox.centerYAnchor), + pathLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 8), + pathLabel.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -padding), + ] + prevAnchor = checkbox.bottomAnchor + } + + let copyConfigButton = NSButton(title: "Copy MCP Config".localized, target: self, action: #selector(copyMCPConfig)) + copyConfigButton.bezelStyle = .rounded + copyConfigButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(copyConfigButton) + + mcpConstraints += [ + copyConfigButton.topAnchor.constraint(equalTo: prevAnchor, constant: 16), + copyConfigButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + ] + NSLayoutConstraint.activate(mcpConstraints) + + NSLayoutConstraint.activate([ + bottomSpacer.topAnchor.constraint(equalTo: copyConfigButton.bottomAnchor), + bottomSpacer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bottomSpacer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomSpacer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + bottomSpacer.heightAnchor.constraint(greaterThanOrEqualToConstant: padding) ]) } @@ -280,9 +567,24 @@ class SettingsView: NSView { name: ContentManager.databaseUpdatedNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(refreshLoginItemStatus), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + if !observingHTTPServer { + UserDefaults.standard.addObserver(self, forKeyPath: "HTTPServerEnabled", options: .new, context: nil) + observingHTTPServer = true + } } else { stopFileMonitor() NotificationCenter.default.removeObserver(self, name: ContentManager.databaseUpdatedNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil) + if observingHTTPServer { + UserDefaults.standard.removeObserver(self, forKeyPath: "HTTPServerEnabled") + observingHTTPServer = false + } } } @@ -351,6 +653,9 @@ class SettingsView: NSView { deinit { stopFileMonitor() NotificationCenter.default.removeObserver(self) + if observingHTTPServer { + UserDefaults.standard.removeObserver(self, forKeyPath: "HTTPServerEnabled") + } } @objc private func databaseDidUpdate() { @@ -372,6 +677,125 @@ class SettingsView: NSView { refresh() } + @objc private func refreshLoginItemStatus() { + launchAtLoginCheckbox.state = SMAppService.mainApp.status == .enabled ? .on : .off + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard keyPath == "HTTPServerEnabled" else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + return + } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let enabled = UserDefaults.standard.bool(forKey: "HTTPServerEnabled") + self.serverCheckbox.state = enabled ? .on : .off + self.updateServerUI(enabled: enabled) + } + } + + @objc private func launchAtLoginChanged(_ sender: NSButton) { + do { + if sender.state == .on { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + Logger.error("Launch at Login toggle failed: \(error)") + // Revert checkbox to actual state + sender.state = SMAppService.mainApp.status == .enabled ? .on : .off + } + } + + @objc private func serverToggled(_ sender: NSButton) { + let enabled = sender.state == .on + UserDefaults.standard.set(enabled, forKey: "HTTPServerEnabled") + updateServerUI(enabled: enabled) + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + if enabled { + if appDelegate.server == nil { + appDelegate.server = NeewerLiteServer(appDelegate: appDelegate) + } + appDelegate.server?.start() + } else { + appDelegate.server?.stop() + } + } + + private func updateServerUI(enabled: Bool) { + serverURLLabel.textColor = enabled ? .secondaryLabelColor : .tertiaryLabelColor + serverTestButton.isEnabled = enabled + } + + @objc private func openPingURL() { + if let url = URL(string: "http://127.0.0.1:18486/ping") { + NSWorkspace.shared.open(url) + } + } + + // MARK: - MCP Client Config + + @objc private func mcpClientToggled(_ sender: NSButton) { + guard let (client, _) = mcpClientCheckboxes.first(where: { $0.1 === sender }) else { return } + do { + if sender.state == .on { + try writeMCPConfig(to: client.configURL, key: client.configKey, entry: client.configEntry()) + } else { + try removeMCPConfig(from: client.configURL, key: client.configKey) + } + } catch { + Logger.error("MCP config write failed for \(client.name): \(error)") + sender.state = client.isConfigured ? .on : .off + let alert = NSAlert() + alert.messageText = "Could not update config".localized + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + + private func writeMCPConfig(to url: URL, key: String, entry: [String: Any]) throws { + var config: [String: Any] = [:] + if FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + config = json + } + var servers = dictionary(at: key, in: config) ?? [:] + servers["neewerlite"] = entry + setDictionary(servers, at: key, in: &config) + let data = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } + + private func removeMCPConfig(from url: URL, key: String) throws { + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + var config = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + var servers = dictionary(at: key, in: config) else { return } + servers.removeValue(forKey: "neewerlite") + setDictionary(servers, at: key, in: &config) + let out = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + try out.write(to: url, options: .atomic) + } + + @objc private func copyMCPConfig() { + let json = """ + { + "mcpServers": { + "neewerlite": { + "command": "npx", + "args": ["-y", "mcp-remote", "http://127.0.0.1:18486/mcp"] + } + } + } + """ + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(json, forType: .string) + } + @objc private func viewInFinder() { let url = ContentManager.shared.localDatabaseURL if ContentManager.shared.localDatabaseExists { diff --git a/NeewerLite/NeewerLite/Visualization/FrequencyBarsVisualization.swift b/NeewerLite/NeewerLite/Visualization/FrequencyBarsVisualization.swift deleted file mode 100644 index daa6473..0000000 --- a/NeewerLite/NeewerLite/Visualization/FrequencyBarsVisualization.swift +++ /dev/null @@ -1,335 +0,0 @@ -// -// SpectrumVisualization.swift -// NeewerLite -// -// Created by Xu Lian on 4/11/26. -// - -import Cocoa -import MetalKit - -/// Metal-based frequency bar visualization — renders only the instanced -/// bar layer without the waterfall background. -class SpectrumVisualization: MTKView, MTKViewDelegate { - - let num: Int = AudioSpectrogram.filterBankCount - private var barWidth: CGFloat = 10.0 - private var viewHeight: CGFloat = 128.0 - var volume: Float = 1.0 - - private var barXPositions: [Float] = [] - private var barsLeftXPositions: [Float] = [] - private var smoothedHeights: [Float] = [] - private let smoothingAlpha: Float = 0.25 - - private var pendingFrequencyData: [Float]? - private let pendingLock = NSLock() - - // MARK: – Metal resources - private var commandQueue: MTLCommandQueue! - private var barPipelineState: MTLRenderPipelineState? - private var barDataBuffer: MTLBuffer? - private var uniformsBuffer: MTLBuffer? - private var gradientTexture: MTLTexture? - - var mirror: Bool = false { - didSet { setupBars() } - } - - override var isOpaque: Bool { true } - - init(frame frameRect: CGRect) { - super.init(frame: frameRect, device: MTLCreateSystemDefaultDevice()) - commonInit() - } - - required init(coder: NSCoder) { - super.init(coder: coder) - commonInit() - } - - private func commonInit() { - guard let device = device ?? MTLCreateSystemDefaultDevice() else { return } - self.device = device - self.delegate = self - self.isPaused = false - self.enableSetNeedsDisplay = false - self.preferredFramesPerSecond = 60 - self.colorPixelFormat = .bgra8Unorm - self.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) - self.layer?.cornerRadius = 14.0 - self.layer?.masksToBounds = true - - commandQueue = device.makeCommandQueue() - buildPipeline(device: device) - setupBars() - } - - // MARK: - Metal Shader Source - - private let shaderSource = """ - #include - using namespace metal; - - struct BarData { - float xPosition; - float barHeight; - }; - - struct BarUniforms { - float viewWidth; - float viewHeight; - float barWidth; - }; - - struct BarVOut { - float4 position [[position]]; - float normalizedY; - }; - - vertex BarVOut barVertex(uint vid [[vertex_id]], - uint iid [[instance_id]], - constant BarData *bars [[buffer(0)]], - constant BarUniforms &u [[buffer(1)]]) { - float2 c[4] = { float2(0,0), float2(1,0), float2(0,1), float2(1,1) }; - float2 corner = c[vid]; - float x = bars[iid].xPosition + corner.x * u.barWidth; - float y = corner.y * bars[iid].barHeight; - BarVOut out; - out.position = float4(x / u.viewWidth * 2.0 - 1.0, - y / u.viewHeight * 2.0 - 1.0, - 0.0, 1.0); - out.normalizedY = y / u.viewHeight; - return out; - } - - fragment float4 barFragment(BarVOut in [[stage_in]], - texture2d gradient [[texture(0)]]) { - constexpr sampler s(filter::linear, address::clamp_to_edge); - return gradient.sample(s, float2(0.5, 1.0 - in.normalizedY)); - } - """ - - private func buildPipeline(device: MTLDevice) { - guard let library = try? device.makeLibrary(source: shaderSource, options: nil) else { - Logger.error("[FreqBars] Metal shader compilation failed") - return - } - let desc = MTLRenderPipelineDescriptor() - desc.vertexFunction = library.makeFunction(name: "barVertex") - desc.fragmentFunction = library.makeFunction(name: "barFragment") - desc.colorAttachments[0].pixelFormat = colorPixelFormat - barPipelineState = try? device.makeRenderPipelineState(descriptor: desc) - } - - // MARK: - Bar Layout - - private func setupBars() { - barWidth = floor(CGFloat(self.bounds.size.width / CGFloat(mirror ? num + num : num))) - viewHeight = CGFloat(self.bounds.size.height) + 10.0 - - barXPositions = [Float](repeating: 0, count: num) - barsLeftXPositions = [Float](repeating: 0, count: num) - - if mirror { - var xOffset = Float(self.bounds.width / 2.0) - for idx in 0.. 0 { - encoder.setRenderPipelineState(barPipeline) - encoder.setVertexBuffer(barDataBuffer, offset: 0, index: 0) - encoder.setVertexBuffer(uniformsBuffer, offset: 0, index: 1) - encoder.setFragmentTexture(gradTex, index: 0) - encoder.drawPrimitives(type: .triangleStrip, - vertexStart: 0, vertexCount: 4, - instanceCount: barCount) - } - } - - encoder.endEncoding() - cmdBuf.present(drawable) - cmdBuf.commit() - } - - // MARK: - Bar Data Buffer - - private struct BarData { - var xPosition: Float - var barHeight: Float - } - - private struct BarUniforms { - var viewWidth: Float - var viewHeight: Float - var barWidth: Float - } - - private func fillBarDataBuffer() -> Int { - guard let device = device else { return 0 } - - let maxBars = mirror ? num * 2 : num - let bufferSize = MemoryLayout.stride * maxBars - - if barDataBuffer == nil || barDataBuffer!.length < bufferSize { - barDataBuffer = device.makeBuffer(length: bufferSize, options: .storageModeShared) - } - if uniformsBuffer == nil { - uniformsBuffer = device.makeBuffer(length: MemoryLayout.stride, - options: .storageModeShared) - } - guard let barBuf = barDataBuffer, let uniBuf = uniformsBuffer else { return 0 } - - let barPtr = barBuf.contents().bindMemory(to: BarData.self, capacity: maxBars) - var count = 0 - - if mirror { - for idx in 0.. idx ? smoothedHeights[idx] : 0 - guard h > 1 else { continue } - barPtr[count] = BarData(xPosition: barXPositions[idx], barHeight: h) - count += 1 - barPtr[count] = BarData(xPosition: barsLeftXPositions[idx], barHeight: h) - count += 1 - } - } else { - for idx in 0.. idx ? smoothedHeights[idx] : 0 - guard h > 1 else { continue } - barPtr[count] = BarData(xPosition: barXPositions[idx], barHeight: h) - count += 1 - } - } - - let uniPtr = uniBuf.contents().bindMemory(to: BarUniforms.self, capacity: 1) - uniPtr.pointee = BarUniforms( - viewWidth: Float(bounds.width), - viewHeight: Float(bounds.height), - barWidth: Float(barWidth) - ) - - return count - } - - override func resize(withOldSuperviewSize oldSize: NSSize) { - super.resize(withOldSuperviewSize: oldSize) - setupBars() - smoothedHeights = [Float](repeating: 0, count: num) - } - - func clear() { - smoothedHeights = [Float](repeating: 1, count: num) - } -} - -// MARK: - AudioVisualizerPlugin Conformance - -extension SpectrumVisualization: AudioVisualizerPlugin { - static var displayName: String { "Spectrum" } - var visualizerView: NSView { self } - var needsSpectrogramImage: Bool { false } -} diff --git a/NeewerLite/NeewerLite/Visualization/WaterfallVisualization.swift b/NeewerLite/NeewerLite/Visualization/WaterfallVisualization.swift deleted file mode 100644 index fccd206..0000000 --- a/NeewerLite/NeewerLite/Visualization/WaterfallVisualization.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// SpectrogramVisualization.swift -// NeewerLite -// -// Created by Xu Lian on 4/11/26. -// - -import Cocoa -import MetalKit - -/// Metal-based waterfall visualization — renders only the scrolling -/// spectrogram image without the frequency bar overlay. -class SpectrogramVisualization: MTKView, MTKViewDelegate { - - var volume: Float = 1.0 - var mirror: Bool = false - - private var pendingWaterfallImage: CGImage? - private let pendingLock = NSLock() - - // MARK: – Metal resources - private var commandQueue: MTLCommandQueue! - private var waterfallPipelineState: MTLRenderPipelineState? - private var waterfallTexture: MTLTexture? - private var waterfallCtx: CGContext? - private var waterfallCtxSize: (Int, Int) = (0, 0) - - override var isOpaque: Bool { true } - - init(frame frameRect: CGRect) { - super.init(frame: frameRect, device: MTLCreateSystemDefaultDevice()) - commonInit() - } - - required init(coder: NSCoder) { - super.init(coder: coder) - commonInit() - } - - private func commonInit() { - guard let device = device ?? MTLCreateSystemDefaultDevice() else { return } - self.device = device - self.delegate = self - self.isPaused = false - self.enableSetNeedsDisplay = false - self.preferredFramesPerSecond = 60 - self.colorPixelFormat = .bgra8Unorm - self.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) - self.layer?.cornerRadius = 14.0 - self.layer?.masksToBounds = true - - commandQueue = device.makeCommandQueue() - buildPipeline(device: device) - } - - // MARK: - Metal Shader Source - - private let shaderSource = """ - #include - using namespace metal; - - struct WaterfallVOut { - float4 position [[position]]; - float2 texCoord; - }; - - vertex WaterfallVOut waterfallVertex(uint vid [[vertex_id]]) { - float2 pos[4] = { float2(-1,-1), float2(1,-1), float2(-1,1), float2(1,1) }; - float2 uv[4] = { float2(0,1), float2(1,1), float2(0,0), float2(1,0) }; - WaterfallVOut out; - out.position = float4(pos[vid], 0.0, 1.0); - out.texCoord = uv[vid]; - return out; - } - - fragment float4 waterfallFragment(WaterfallVOut in [[stage_in]], - texture2d tex [[texture(0)]]) { - constexpr sampler s(filter::linear); - return tex.sample(s, in.texCoord); - } - """ - - private func buildPipeline(device: MTLDevice) { - guard let library = try? device.makeLibrary(source: shaderSource, options: nil) else { - Logger.error("[Waterfall] Metal shader compilation failed") - return - } - let desc = MTLRenderPipelineDescriptor() - desc.vertexFunction = library.makeFunction(name: "waterfallVertex") - desc.fragmentFunction = library.makeFunction(name: "waterfallFragment") - desc.colorAttachments[0].pixelFormat = colorPixelFormat - waterfallPipelineState = try? device.makeRenderPipelineState(descriptor: desc) - } - - // MARK: - Waterfall Texture - - private func updateWaterfallTexture(from image: CGImage) { - guard let device = device else { return } - let w = image.width - let h = image.height - - if waterfallTexture == nil || - waterfallTexture!.width != w || - waterfallTexture!.height != h { - let desc = MTLTextureDescriptor.texture2DDescriptor( - pixelFormat: .rgba8Unorm, - width: w, height: h, mipmapped: false) - desc.usage = .shaderRead - waterfallTexture = device.makeTexture(descriptor: desc) - } - - guard let tex = waterfallTexture else { return } - let bytesPerRow = w * 4 - - if waterfallCtxSize != (w, h) { - let cs = CGColorSpaceCreateDeviceRGB() - waterfallCtx = CGContext(data: nil, width: w, height: h, - bitsPerComponent: 8, bytesPerRow: bytesPerRow, - space: cs, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) - waterfallCtxSize = (w, h) - } - guard let ctx = waterfallCtx, let data = ctx.data else { return } - ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) - - tex.replace(region: MTLRegionMake2D(0, 0, w, h), - mipmapLevel: 0, - withBytes: data, - bytesPerRow: bytesPerRow) - } - - // MARK: - Public API (thread-safe) - - func updateSpectrogramImage(_ image: CGImage) { - pendingLock.lock() - pendingWaterfallImage = image - pendingLock.unlock() - } - - func updateFrequency(_ data: [Float]) { - // Waterfall-only visualization does not use frequency data. - } - - // MARK: - MTKViewDelegate - - func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} - - func draw(in view: MTKView) { - pendingLock.lock() - let newWaterfall = pendingWaterfallImage - pendingWaterfallImage = nil - pendingLock.unlock() - - guard let img = newWaterfall else { return } - - updateWaterfallTexture(from: img) - - guard let drawable = currentDrawable, - let descriptor = currentRenderPassDescriptor, - let cmdBuf = commandQueue?.makeCommandBuffer(), - let encoder = cmdBuf.makeRenderCommandEncoder(descriptor: descriptor) - else { return } - - if let wfPipeline = waterfallPipelineState, let wfTex = waterfallTexture { - encoder.setRenderPipelineState(wfPipeline) - encoder.setFragmentTexture(wfTex, index: 0) - encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) - } - - encoder.endEncoding() - cmdBuf.present(drawable) - cmdBuf.commit() - } - - func clear() { - waterfallTexture = nil - } -} - -// MARK: - AudioVisualizerPlugin Conformance - -extension SpectrogramVisualization: AudioVisualizerPlugin { - static var displayName: String { "Spectrogram" } - var visualizerView: NSView { self } - var needsSpectrogramImage: Bool { true } -} diff --git a/NeewerLite/NeewerLiteTests/MCPServerTests.swift b/NeewerLite/NeewerLiteTests/MCPServerTests.swift new file mode 100644 index 0000000..f9a3a25 --- /dev/null +++ b/NeewerLite/NeewerLiteTests/MCPServerTests.swift @@ -0,0 +1,615 @@ +// +// MCPServerTests.swift +// NeewerLiteTests +// +// Created on 4/19/26. +// + +import XCTest +import MCP +@testable import NeewerLite + +final class MCPServerTests: XCTestCase { + + private var server: NeewerLiteServer! + + override func setUpWithError() throws { + let appDelegate = NSApplication.shared.delegate as! AppDelegate + server = NeewerLiteServer(appDelegate: appDelegate, port: 0) + } + + override func tearDownWithError() throws { + server = nil + } + + // MARK: - Tool List Tests + + func testToolsList_returns11Tools() throws { + let result = server.mcpToolsList() + XCTAssertEqual(result.tools.count, 11, "Expected 11 MCP tools") + } + + func testToolsList_expectedToolNames() throws { + let result = server.mcpToolsList() + let names = Set(result.tools.map(\.name)) + let expected: Set = [ + "list_lights", "switch_light", "set_brightness", "set_cct", + "set_hsi", "set_scene", "list_scenes", "list_sources", "list_gels", "scan_lights", "get_light_image" + ] + XCTAssertEqual(names, expected, "Tool names mismatch") + } + + func testToolsList_allToolsHaveDescriptionAndSchema() throws { + let result = server.mcpToolsList() + for tool in result.tools { + XCTAssertFalse(tool.name.isEmpty, "Tool has empty name") + XCTAssertNotNil(tool.description, "Tool \(tool.name) missing description") + XCTAssertFalse(tool.description?.isEmpty ?? true, "Tool \(tool.name) has empty description") + XCTAssertNotNil(tool.inputSchema, "Tool \(tool.name) missing inputSchema") + } + } + + // MARK: - Value Numeric Coercion Tests + + func testNumericInt_fromInt_returnsExactValue() { + let value = Value.int(80) + XCTAssertEqual(value.numericInt, 80) + } + + func testNumericInt_fromWholeDouble_returnsInt() { + let value = Value.double(80.0) + XCTAssertEqual(value.numericInt, 80) + } + + func testNumericInt_fromFractionalDouble_truncates() { + // LLM sends brightness: 80.5 — must not silently drop to nil + let value = Value.double(80.5) + XCTAssertEqual(value.numericInt, 80, "Fractional double should truncate, not return nil") + } + + func testNumericInt_fromNegativeFractionalDouble_truncates() { + let value = Value.double(-3.7) + XCTAssertEqual(value.numericInt, -3) + } + + func testNumericDouble_fromDouble_returnsExactValue() { + let value = Value.double(80.5) + XCTAssertEqual(value.numericDouble, 80.5) + } + + func testNumericDouble_fromInt_returnsDouble() { + let value = Value.int(80) + XCTAssertEqual(value.numericDouble, 80.0) + } + + func testNumericInt_fromString_returnsNil() { + let value = Value.string("80") + XCTAssertNil(value.numericInt) + } + + func testNumericDouble_fromString_returnsNil() { + let value = Value.string("80.5") + XCTAssertNil(value.numericDouble) + } + + // MARK: - Tool Call Dispatcher Tests + + func testHandleMCPToolCall_unknownTool() throws { + let params = CallTool.Parameters(name: "nonexistent_tool", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + let text = result.content.first.flatMap { content -> String? in + if case .text(let t, _, _) = content { return t } + return nil + } + XCTAssertTrue(text?.contains("Unknown tool") ?? false) + } + + func testHandleMCPToolCall_listLights() throws { + let params = CallTool.Parameters(name: "list_lights", arguments: nil) + let result = server.handleMCPToolCall(params) + // Either returns lights or "No lights found" — neither is an error + let text = result.content.first.flatMap { content -> String? in + if case .text(let t, _, _) = content { return t } + return nil + } + XCTAssertNotNil(text, "list_lights should return text content") + } + + func testHandleMCPToolCall_scanLights() throws { + let params = CallTool.Parameters(name: "scan_lights", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertNotEqual(result.isError, true) + let text = result.content.first.flatMap { content -> String? in + if case .text(let t, _, _) = content { return t } + return nil + } + XCTAssertTrue(text?.contains("scan") ?? false) + } + + // MARK: - Missing Parameter Validation + + func testSwitchLight_missingParams() throws { + let params = CallTool.Parameters(name: "switch_light", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + func testSetBrightness_missingParams() throws { + let params = CallTool.Parameters(name: "set_brightness", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + func testSetCCT_missingParams() throws { + let params = CallTool.Parameters(name: "set_cct", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + func testSetHSI_missingParams() throws { + let params = CallTool.Parameters(name: "set_hsi", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + func testSetScene_missingParams() throws { + let params = CallTool.Parameters(name: "set_scene", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + func testListScenes_missingParams() throws { + let params = CallTool.Parameters(name: "list_scenes", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + func testListSources_missingParams() throws { + let params = CallTool.Parameters(name: "list_sources", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + func testListGels_missingParams() throws { + let params = CallTool.Parameters(name: "list_gels", arguments: nil) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + // MARK: - No Matching Lights + + func testSwitchLight_noMatchingLights() throws { + let params = CallTool.Parameters( + name: "switch_light", + arguments: ["lights": .array([.string("nonexistent_light_xyz")]), "state": .bool(true)] + ) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + let text = result.content.first.flatMap { content -> String? in + if case .text(let t, _, _) = content { return t } + return nil + } + XCTAssertTrue(text?.contains("No matching") ?? false) + } + + func testSetCCT_noMatchingLights() throws { + let params = CallTool.Parameters( + name: "set_cct", + arguments: [ + "lights": .array([.string("nonexistent_light_xyz")]), + "brightness": .double(50), + "temperature": .double(5600) + ] + ) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + } + + func testListScenes_noMatchingLight() throws { + let params = CallTool.Parameters( + name: "list_scenes", + arguments: ["light": .string("nonexistent_light_xyz")] + ) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + let text = result.content.first.flatMap { content -> String? in + if case .text(let t, _, _) = content { return t } + return nil + } + XCTAssertTrue(text?.contains("No light found") ?? false) + } + + func testListSources_noMatchingLight() throws { + let params = CallTool.Parameters( + name: "list_sources", + arguments: ["light": .string("nonexistent_light_xyz")] + ) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + let text = result.content.first.flatMap { content -> String? in + if case .text(let t, _, _) = content { return t } + return nil + } + XCTAssertTrue(text?.contains("No light found") ?? false) + } + + func testListGels_noMatchingLight() throws { + let params = CallTool.Parameters( + name: "list_gels", + arguments: ["light": .string("nonexistent_light_xyz")] + ) + let result = server.handleMCPToolCall(params) + XCTAssertEqual(result.isError, true) + let text = result.content.first.flatMap { content -> String? in + if case .text(let t, _, _) = content { return t } + return nil + } + XCTAssertTrue(text?.contains("No light found") ?? false) + } +} + +// MARK: - Integration Tests (real HTTP server) + +final class MCPServerIntegrationTests: XCTestCase { + + private var server: NeewerLiteServer! + private var baseURL: String! + + override func setUp() async throws { + let appDelegate = await MainActor.run { + NSApplication.shared.delegate as! AppDelegate + } + server = NeewerLiteServer(appDelegate: appDelegate, port: 0) + try await server.startAsync() + guard let port = server.boundPort else { + XCTFail("Server did not bind to a port") + return + } + baseURL = "http://127.0.0.1:\(port)" + } + + override func tearDown() async throws { + server.stop() + // Give Vapor time to release resources + try await Task.sleep(nanoseconds: 200_000_000) + server = nil + } + + // MARK: - Lifecycle + + func testServerStartAndStop() async throws { + // Server started in setUp — just verify it's listening + let (data, response) = try await httpGet("/ping", userAgent: "neewerlite.sdPlugin/1.0") + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 200) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(json?["status"] as? String, "ok") + } + + // MARK: - Stream Deck Auth Middleware + + func testMiddleware_rejectsNoUserAgent() async throws { + let (_, response) = try await httpGet("/listLights") + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 401) + } + + func testMiddleware_rejectsWrongUserAgent() async throws { + let (_, response) = try await httpGet("/listLights", userAgent: "curl/7.0") + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 401) + } + + func testMiddleware_acceptsStreamDeckUA() async throws { + let (_, response) = try await httpGet("/ping", userAgent: "neewerlite.sdPlugin/2.0") + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 200) + } + + func testMiddleware_mcpEndpointSkipsUACheck() async throws { + // MCP endpoint should not require Stream Deck UA + let body = mcpJSON(id: 1, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "test", "version": "1.0"] + ] as [String: Any]) + let (_, response) = try await httpPostMCP(body) + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + // Should be 200 (accepted) not 401 + XCTAssertNotEqual(httpResponse.statusCode, 401, "MCP endpoint should skip UA check") + } + + // MARK: - Stream Deck Routes + + func testStreamDeck_ping() async throws { + let (data, response) = try await httpGet("/ping", userAgent: "neewerlite.sdPlugin/1.0") + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 200) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(json?["status"] as? String, "ok") + XCTAssertNotNil(json?["lights"]) + } + + func testStreamDeck_listLights() async throws { + let (data, response) = try await httpGet("/listLights", userAgent: "neewerlite.sdPlugin/1.0") + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 200) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertNotNil(json?["lights"], "Response should contain 'lights' key") + } + + func testStreamDeck_switchInvalidJSON() async throws { + let (data, response) = try await httpPost("/switch", body: Data("not json".utf8), userAgent: "neewerlite.sdPlugin/1.0") + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 400) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertNotNil(json?["error"]) + } + + // MARK: - MCP Protocol (Streamable HTTP) + + func testMCP_initialize() async throws { + let body = mcpJSON(id: 1, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "test-client", "version": "1.0"] + ] as [String: Any]) + let (json, httpResponse) = try await mcpPostJSON(body) + XCTAssertEqual(httpResponse.statusCode, 200) + + XCTAssertEqual(json?["jsonrpc"] as? String, "2.0") + XCTAssertEqual(json?["id"] as? Int, 1) + let result = json?["result"] as? [String: Any] + XCTAssertNotNil(result?["serverInfo"]) + let capabilities = result?["capabilities"] as? [String: Any] + XCTAssertNotNil(capabilities) + XCTAssertNotNil(capabilities?["tools"], "initialize should advertise tools capability so clients proceed with tools/list") + + let serverInfo = result?["serverInfo"] as? [String: Any] + XCTAssertEqual(serverInfo?["name"] as? String, "NeewerLite") + } + + func testMCP_initializeThenListTools() async throws { + // Step 1: Initialize and get session ID + let initBody = mcpJSON(id: 1, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "test", "version": "1.0"] + ] as [String: Any]) + let (_, initHTTP) = try await mcpPostJSON(initBody) + XCTAssertEqual(initHTTP.statusCode, 200) + let sessionId = initHTTP.value(forHTTPHeaderField: "Mcp-Session-Id") + + // Step 2: Send initialized notification + let notifBody = mcpJSON(id: nil, method: "notifications/initialized", params: [:] as [String: Any]) + let _ = try await httpPostMCP(notifBody, sessionId: sessionId) + + // Step 3: List tools + let toolsBody = mcpJSON(id: 2, method: "tools/list", params: [:] as [String: Any]) + let (json, toolsHTTP) = try await mcpPostJSON(toolsBody, sessionId: sessionId) + XCTAssertEqual(toolsHTTP.statusCode, 200) + + let result = json?["result"] as? [String: Any] + let tools = result?["tools"] as? [[String: Any]] + XCTAssertEqual(tools?.count, 11, "Expected 11 tools from tools/list") + + let names = Set(tools?.compactMap { $0["name"] as? String } ?? []) + XCTAssertTrue(names.contains("list_lights")) + XCTAssertTrue(names.contains("scan_lights")) + XCTAssertTrue(names.contains("list_sources")) + XCTAssertTrue(names.contains("list_gels")) + } + + func testMCP_sseResponsesDoNotContainEmptyDataFrames() async throws { + let initBody = mcpJSON(id: 1, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "sse-test", "version": "1.0"] + ] as [String: Any]) + let (initData, initResp) = try await httpPostMCP(initBody) + let initHTTP = try XCTUnwrap(initResp as? HTTPURLResponse) + XCTAssertEqual(initHTTP.statusCode, 200) + XCTAssertFalse(containsEmptySSEDataFrame(initData), "initialize emitted an empty SSE data frame") + + let sessionId = try XCTUnwrap(initHTTP.value(forHTTPHeaderField: "Mcp-Session-Id")) + + let toolsBody = mcpJSON(id: 2, method: "tools/list", params: [:] as [String: Any]) + let (toolsData, toolsResp) = try await httpPostMCP(toolsBody, sessionId: sessionId) + let toolsHTTP = try XCTUnwrap(toolsResp as? HTTPURLResponse) + XCTAssertEqual(toolsHTTP.statusCode, 200) + XCTAssertFalse(containsEmptySSEDataFrame(toolsData), "tools/list emitted an empty SSE data frame") + } + + func testMCP_getWithoutSession_returnsAsyncNotificationProbeStream() async throws { + var request = URLRequest(url: URL(string: baseURL + "/mcp")!) + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(httpResponse.statusCode, 200) + XCTAssertEqual(httpResponse.value(forHTTPHeaderField: "Content-Type"), "text/event-stream") + + let body = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(body.contains(": awaiting session initialization")) + } + + func testMCP_toolCallListLights() async throws { + // Initialize + let initBody = mcpJSON(id: 1, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "test", "version": "1.0"] + ] as [String: Any]) + let (_, initHTTP) = try await mcpPostJSON(initBody) + let sessionId = initHTTP.value(forHTTPHeaderField: "Mcp-Session-Id") + + // Notification + let notifBody = mcpJSON(id: nil, method: "notifications/initialized", params: [:] as [String: Any]) + let _ = try await httpPostMCP(notifBody, sessionId: sessionId) + + // Call list_lights + let callBody = mcpJSON(id: 3, method: "tools/call", params: [ + "name": "list_lights", + "arguments": [:] as [String: Any] + ] as [String: Any]) + let (json, callHTTP) = try await mcpPostJSON(callBody, sessionId: sessionId) + XCTAssertEqual(callHTTP.statusCode, 200) + + let result = json?["result"] as? [String: Any] + let content = result?["content"] as? [[String: Any]] + XCTAssertNotNil(content, "tools/call should return content array") + XCTAssertEqual(content?.first?["type"] as? String, "text") + } + + func testMCP_reinitAfterDelete() async throws { + try await runDeleteSessionThenReinitializeFlow() + } + + func testMCP_deleteIsolationAcrossClients() async throws { + try await runDeleteOneClientSessionIsolationFlow() + } + + func testMCP_deleteSessionThenReinitialize() async throws { + try await runDeleteSessionThenReinitializeFlow() + } + + func testMCP_deleteOneClientSession_doesNotBreakOtherClient() async throws { + try await runDeleteOneClientSessionIsolationFlow() + } + + private func runDeleteSessionThenReinitializeFlow() async throws { + // Initialize and capture session id. + let initBody = mcpJSON(id: 1, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "test", "version": "1.0"] + ] as [String: Any]) + let (_, initHTTP) = try await mcpPostJSON(initBody) + XCTAssertEqual(initHTTP.statusCode, 200) + + let oldSessionID = try XCTUnwrap(initHTTP.value(forHTTPHeaderField: "Mcp-Session-Id")) + + // Delete current session. + var deleteRequest = URLRequest(url: URL(string: baseURL + "/mcp")!) + deleteRequest.httpMethod = "DELETE" + deleteRequest.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + deleteRequest.setValue(oldSessionID, forHTTPHeaderField: "Mcp-Session-Id") + + let (_, deleteResp) = try await URLSession.shared.data(for: deleteRequest) + let deleteHTTP = try XCTUnwrap(deleteResp as? HTTPURLResponse) + XCTAssertEqual(deleteHTTP.statusCode, 200) + + // Reinitialize should succeed without restarting the app. + let reinitBody = mcpJSON(id: 2, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "test-reinit", "version": "1.0"] + ] as [String: Any]) + let (_, reinitHTTP) = try await mcpPostJSON(reinitBody) + XCTAssertEqual(reinitHTTP.statusCode, 200) + + let newSessionID = try XCTUnwrap(reinitHTTP.value(forHTTPHeaderField: "Mcp-Session-Id")) + XCTAssertNotEqual(newSessionID, oldSessionID) + } + + private func runDeleteOneClientSessionIsolationFlow() async throws { + let initA = mcpJSON(id: 1, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "vscode", "version": "1.0"] + ] as [String: Any]) + let (_, initHTTPA) = try await mcpPostJSON(initA, userAgent: "VSCode-Test/1.0") + let sessionA = try XCTUnwrap(initHTTPA.value(forHTTPHeaderField: "Mcp-Session-Id")) + + let initB = mcpJSON(id: 2, method: "initialize", params: [ + "protocolVersion": "2025-03-26", + "capabilities": [:], + "clientInfo": ["name": "cursor", "version": "1.0"] + ] as [String: Any]) + let (_, initHTTPB) = try await mcpPostJSON(initB, userAgent: "Cursor-Test/1.0") + let sessionB = try XCTUnwrap(initHTTPB.value(forHTTPHeaderField: "Mcp-Session-Id")) + + let notif = mcpJSON(id: nil, method: "notifications/initialized", params: [:] as [String: Any]) + _ = try await httpPostMCP(notif, sessionId: sessionA, userAgent: "VSCode-Test/1.0") + _ = try await httpPostMCP(notif, sessionId: sessionB, userAgent: "Cursor-Test/1.0") + + var deleteRequest = URLRequest(url: URL(string: baseURL + "/mcp")!) + deleteRequest.httpMethod = "DELETE" + deleteRequest.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + deleteRequest.setValue(sessionA, forHTTPHeaderField: "Mcp-Session-Id") + deleteRequest.setValue("VSCode-Test/1.0", forHTTPHeaderField: "User-Agent") + let (_, deleteResp) = try await URLSession.shared.data(for: deleteRequest) + XCTAssertEqual((deleteResp as? HTTPURLResponse)?.statusCode, 200) + + let toolsBody = mcpJSON(id: 3, method: "tools/list", params: [:] as [String: Any]) + let (toolsJSON, toolsHTTP) = try await mcpPostJSON(toolsBody, sessionId: sessionB, userAgent: "Cursor-Test/1.0") + XCTAssertEqual(toolsHTTP.statusCode, 200) + XCTAssertNotNil((toolsJSON?["result"] as? [String: Any])?["tools"]) + } + + // MARK: - HTTP Helpers + + private func httpGet(_ path: String, userAgent: String? = nil) async throws -> (Data, URLResponse) { + var request = URLRequest(url: URL(string: baseURL + path)!) + if let ua = userAgent { request.setValue(ua, forHTTPHeaderField: "User-Agent") } + return try await URLSession.shared.data(for: request) + } + + private func httpPost(_ path: String, body: Data, userAgent: String? = nil) async throws -> (Data, URLResponse) { + var request = URLRequest(url: URL(string: baseURL + path)!) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let ua = userAgent { request.setValue(ua, forHTTPHeaderField: "User-Agent") } + return try await URLSession.shared.data(for: request) + } + + private func httpPostMCP(_ body: Data, sessionId: String? = nil, userAgent: String? = nil) async throws -> (Data, URLResponse) { + var request = URLRequest(url: URL(string: baseURL + "/mcp")!) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + if let sid = sessionId { request.setValue(sid, forHTTPHeaderField: "Mcp-Session-Id") } + if let ua = userAgent { request.setValue(ua, forHTTPHeaderField: "User-Agent") } + return try await URLSession.shared.data(for: request) + } + + /// Posts to MCP and parses the response as JSON, handling both plain JSON + /// and SSE-wrapped responses (`event: message\ndata: {...}\n\n`). + private func mcpPostJSON(_ body: Data, sessionId: String? = nil, userAgent: String? = nil) async throws -> ([String: Any]?, HTTPURLResponse) { + let (data, resp) = try await httpPostMCP(body, sessionId: sessionId, userAgent: userAgent) + let httpResp = resp as! HTTPURLResponse + let contentType = httpResp.value(forHTTPHeaderField: "Content-Type") ?? "" + if contentType.contains("text/event-stream") { + // Parse SSE: extract last "data:" line payload + let text = String(data: data, encoding: .utf8) ?? "" + let jsonPayload = text.components(separatedBy: "\n") + .filter { $0.hasPrefix("data:") } + .last + .map { String($0.dropFirst("data:".count)).trimmingCharacters(in: .whitespaces) } + if let payload = jsonPayload, let payloadData = payload.data(using: .utf8) { + let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] + return (json, httpResp) + } + return (nil, httpResp) + } else { + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + return (json, httpResp) + } + } + + private func mcpJSON(id: Int?, method: String, params: [String: Any]) -> Data { + var dict: [String: Any] = ["jsonrpc": "2.0", "method": method, "params": params] + if let id { dict["id"] = id } + return try! JSONSerialization.data(withJSONObject: dict) + } + + private func containsEmptySSEDataFrame(_ data: Data) -> Bool { + let text = String(data: data, encoding: .utf8) ?? "" + return text + .components(separatedBy: "\n") + .contains { $0.trimmingCharacters(in: .whitespacesAndNewlines) == "data:" } + } +} diff --git a/NeewerLite/NeewerLiteTests/NeewerLiteTests.swift b/NeewerLite/NeewerLiteTests/NeewerLiteTests.swift index b9ca145..bd21230 100644 --- a/NeewerLite/NeewerLiteTests/NeewerLiteTests.swift +++ b/NeewerLite/NeewerLiteTests/NeewerLiteTests.swift @@ -164,6 +164,21 @@ class NeewerLiteTests: XCTestCase { XCTAssertEqual(cmd[0], 0x78) XCTAssertEqual(cmd[1], 0x91) } + + func testNormalizeHSIInput_convertsPercentInputsToUnitRange() { + let normalized = normalizeHSIInput(hueDegrees: 120, saturation: 100, brightness: 100) + XCTAssertEqual(normalized.hueDegrees, 120) + XCTAssertEqual(normalized.saturationUnit, 1.0, accuracy: 0.0001) + XCTAssertEqual(normalized.brightnessUnit ?? -1, 1.0, accuracy: 0.0001) + } + + func testNormalizeHSIInput_preservesUnitInputs() { + let normalized = normalizeHSIInput(hueDegrees: 120, saturation: 0.75, brightness: 0.5) + XCTAssertEqual(normalized.hueDegrees, 120) + XCTAssertEqual(normalized.saturationUnit, 0.75, accuracy: 0.0001) + XCTAssertEqual(normalized.brightnessUnit ?? -1, 0.5, accuracy: 0.0001) + } + func testPerformanceExample() throws { // This is an example of a performance test case. self.measure { diff --git a/NeewerLite/Package.resolved b/NeewerLite/Package.resolved index a87aab9..b29d376 100644 --- a/NeewerLite/Package.resolved +++ b/NeewerLite/Package.resolved @@ -1,6 +1,60 @@ { - "originHash" : "ac4f26d6932bf28584e6e0db8d964ed5898c9251b6c0951b854eb278f7eea86e", + "originHash" : "a992f51b701176e35b2e4748a9bf7314263ea987f59b1dd125b176a5c3157be0", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9", + "version" : "1.22.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", + "version" : "4.16.0" + } + }, + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/eventsource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -10,6 +64,33 @@ "version" : "1.27.3" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", @@ -20,12 +101,192 @@ } }, { - "identity" : "swifter", + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "5aa1c0d1bc204908df47c2075bdbb39573d05e8d", + "version" : "1.19.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "476538ccb827f2dd18efc5de754cc87d77127a47", + "version" : "4.4.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "c6593dac9d65f2517280d88c430dadffdf259737", + "version" : "2.10.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd6710454f25733900e133c6caf5188952763c36", + "version" : "2.98.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "9d4e67af1eea85967c7de778ad73e7776e5f1f22", + "version" : "1.27.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", + "state" : { + "revision" : "6132fd4b5b4217ce4717c4775e4607f5c3120129", + "version" : "0.12.0" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "cfd8f434843ac7850e2d97f46c1aa5ddb906cf1c", + "version" : "4.121.4" + } + }, + { + "identity" : "websocket-kit", "kind" : "remoteSourceControl", - "location" : "https://github.com/httpswift/swifter.git", + "location" : "https://github.com/vapor/websocket-kit.git", "state" : { - "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", - "version" : "1.5.0" + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" } } ], diff --git a/NeewerLite/Package.swift b/NeewerLite/Package.swift index 39e665e..76a88b0 100644 --- a/NeewerLite/Package.swift +++ b/NeewerLite/Package.swift @@ -13,7 +13,8 @@ let package = Package( .library(name: "NeewerLite", targets: ["NeewerLite"]), ], dependencies: [ - .package(url: "https://github.com/httpswift/swifter.git", from: "1.5.0"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.89.0"), + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0"), .package(url: "https://github.com/sparkle-project/Sparkle.git", from: "1.27.3"), .package(url: "https://github.com/apple/swift-atomics.git", from: "1.3.0") ], @@ -21,7 +22,8 @@ let package = Package( .target( name: "NeewerLite", dependencies: [ - .product(name: "Swifter", package: "swifter"), + .product(name: "Vapor", package: "vapor"), + .product(name: "MCP", package: "swift-sdk"), .product(name: "Sparkle", package: "Sparkle"), .product(name: "Atomics", package: "swift-atomics") ],