diff --git a/docs/wiki/component-metadata-generator.md b/docs/wiki/component-metadata-generator.md new file mode 100644 index 00000000..73eb9a68 --- /dev/null +++ b/docs/wiki/component-metadata-generator.md @@ -0,0 +1,384 @@ +# Component Metadata Generator + +Documentation of the component metadata auto-generation system and the override mechanism that preserves custom property configurations across regenerations. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [How It Works](#how-it-works) +3. [The Problem: Lost Customizations](#the-problem-lost-customizations) +4. [The Solution: component-overrides.json](#the-solution-component-overridesjson) +5. [Override File Format](#override-file-format) +6. [How Overrides Are Applied](#how-overrides-are-applied) +7. [Current Overrides](#current-overrides) +8. [Adding New Overrides](#adding-new-overrides) +9. [Generator Internals](#generator-internals) +10. [File Reference](#file-reference) + +--- + +## Overview + +Velxio uses 48+ electronic components from the [wokwi-elements](https://github.com/wokwi/wokwi-elements) library. Each component needs metadata (properties, categories, pin counts, controls) for the UI to render property dialogs, component picker, and simulation logic. + +Instead of maintaining this metadata manually, a **generator script** scans the wokwi-elements TypeScript source files, extracts `@property` decorators, `@customElement` tag names, and `pinInfo` getters, then produces `frontend/public/components-metadata.json`. + +--- + +## How It Works + +``` +wokwi-libs/wokwi-elements/src/*-element.ts + │ + ▼ +scripts/generate-component-metadata.ts ←── reads TypeScript AST + │ + ├── Extracts @customElement('wokwi-led') → id: "led" + ├── Extracts @property() color = 'red' → { name: "color", defaultValue: "red" } + ├── Counts pinInfo entries → pinCount: 2 + ├── Reads .stories.ts for display name → name: "LED" + ├── Maps id to category via CATEGORY_MAP → category: "output" + │ + ├── Applies overrides from component-overrides.json ← NEW + │ + ▼ +frontend/public/components-metadata.json +``` + +**Running the generator:** + +```bash +cd scripts +npx ts-node generate-component-metadata.ts +``` + +Or if `ts-node` is not available: + +```bash +npx tsx scripts/generate-component-metadata.ts +``` + +--- + +## The Problem: Lost Customizations + +The generator reads property types from the wokwi-elements source and infers a UI control: + +| TypeScript Type | Inferred Control | +|-----------------|-----------------| +| `boolean` | `boolean` | +| `number` | `range` | +| `string` | `text` | + +This is a reasonable default, but some components need **richer controls** that can't be inferred from the TypeScript source: + +- **LED `color`**: The wokwi-elements source declares `@property() color = 'red'` (a plain string). But in the UI, we want a **dropdown select** with predefined colors (`red`, `green`, `blue`, etc.), not a free-text input. + +- **SSD1306 `protocol`**: The wokwi-elements source has no `protocol` property at all — it's a Velxio-specific concept for choosing between I2C and SPI communication modes. We need to **inject an entirely new property** that doesn't exist in wokwi-elements. + +Previously, these customizations were made by hand-editing `components-metadata.json`. But every time the generator ran (e.g., after updating wokwi-libs), it would **overwrite the entire file** and the customizations would be lost. + +--- + +## The Solution: component-overrides.json + +A separate JSON file at `scripts/component-overrides.json` stores all custom property overrides. The generator reads this file **after** scanning wokwi-elements and applies the overrides before writing the output. + +``` +Auto-generated from wokwi-elements + component-overrides.json +(properties, defaults, pin counts) (custom controls, new properties) + │ │ + └──────────────┬─────────────────────────┘ + ▼ + components-metadata.json + (final merged output) +``` + +Since `component-overrides.json` is a separate file that the generator **reads but never writes**, customizations are preserved across any number of regenerations. + +--- + +## Override File Format + +```json +{ + "$comment": "Custom property overrides applied AFTER auto-generation...", + "": { + "properties": { + "": { + // Fields to patch on an existing property, or full definition for a new one + } + }, + "defaultValues": { + "": "" + } + } +} +``` + +### Patching an Existing Property + +To change the control type of an existing property (one that the generator already extracts from wokwi-elements): + +```json +{ + "led": { + "properties": { + "color": { + "control": "select", + "options": ["red", "green", "blue", "yellow", "orange", "white", "purple"] + } + } + } +} +``` + +This finds the existing `color` property on the LED component and patches it with `control: "select"` and `options: [...]`. All other fields (`name`, `type`, `defaultValue`) are kept from the auto-generated version. + +### Adding a New Property + +To inject a property that doesn't exist in the wokwi-elements source: + +```json +{ + "ssd1306": { + "properties": { + "protocol": { + "name": "protocol", + "type": "string", + "defaultValue": "i2c", + "control": "select", + "description": "Communication protocol", + "options": ["i2c", "spi"] + } + }, + "defaultValues": { + "protocol": "i2c" + } + } +} +``` + +Since no existing property named `protocol` exists on the SSD1306, the generator **appends** it to the properties array. The `defaultValues` merge ensures the default is set. + +--- + +## How Overrides Are Applied + +The `applyOverrides()` method in the generator works as follows: + +``` +For each component in the generated metadata: + 1. Check if component-overrides.json has an entry for this component ID + 2. If yes, for each property override: + a. Find existing property by name in the component's properties array + b. If found → Object.assign(existing, patch) (merge/overwrite fields) + c. If not found → push(patch) to properties array (add new property) + 3. Merge any defaultValues from the override into the component's defaultValues +``` + +The key behavior: + +| Scenario | Action | +|----------|--------| +| Override property exists in wokwi-elements | Patch: only overridden fields change, rest preserved | +| Override property does NOT exist in wokwi-elements | Add: full property definition appended | +| Override has `defaultValues` | Merge: `{ ...autoGenerated, ...override }` | + +--- + +## Current Overrides + +### LED Color Selector + +**Component:** `led` +**Property:** `color` +**Change:** `control: "text"` → `control: "select"` with 7 color options + +The wokwi LED element accepts any CSS color string, but in practice users want to pick from standard LED colors. The select dropdown provides: +- `red`, `green`, `blue`, `yellow`, `orange`, `white`, `purple` + +**UI result:** When clicking on an LED in the simulator, the property dialog shows a dropdown instead of a text input. + +### SSD1306 Protocol Selector + +**Component:** `ssd1306` +**Property:** `protocol` (new, not in wokwi-elements) +**Control:** `select` with `["i2c", "spi"]` +**Default:** `"i2c"` + +The real SSD1306 OLED display supports both I2C and SPI communication. In Velxio, the simulation logic reads this property to decide which bus to attach: + +- **I2C mode**: Registers as an `I2CDevice` on the simulator's I2C bus. Responds to address `0x3C`. Uses control byte (`0x00` = command, `0x40` = data). +- **SPI mode**: Hooks into the SPI `onByte` callback. Uses the DC (Data/Command) pin to distinguish commands from data. + +The simulation code in `PartSimulationRegistry` reads the protocol at attachment time: + +```typescript +PartSimulationRegistry.register('ssd1306', { + attachEvents: (element, simulator, getPin, componentId) => { + const comp = useSimulatorStore.getState().components.find(c => c.id === componentId); + const protocol = (comp?.properties?.protocol as string) ?? 'i2c'; + if (protocol === 'spi') return attachSSD1306SPI(element, simulator, getPin); + // I2C default... + }, +}); +``` + +**UI result:** The property dialog shows an I2C/SPI dropdown. A colored badge (blue = I2C, orange = SPI) appears next to the component label on the canvas. + +--- + +## Adding New Overrides + +### Step 1: Edit component-overrides.json + +Add a new entry keyed by the component's `id` (the part after `wokwi-` in the tag name): + +```json +{ + "resistor": { + "properties": { + "value": { + "control": "select", + "options": ["220", "330", "1000", "4700", "10000"] + } + } + } +} +``` + +### Step 2: Regenerate metadata + +```bash +npx tsx scripts/generate-component-metadata.ts +``` + +The generator will log: + +``` + 🔧 Applied overrides for resistor + +🔧 Applied overrides to 1 component(s) +``` + +### Step 3: Verify + +Open `frontend/public/components-metadata.json` and search for the component. The override fields should be present. + +### Common override patterns + +**Change a text input to a dropdown:** +```json +{ + "": { + "properties": { + "": { + "control": "select", + "options": ["option1", "option2", "option3"] + } + } + } +} +``` + +**Add a description to an existing property:** +```json +{ + "": { + "properties": { + "": { + "description": "Human-readable description shown in the property dialog" + } + } + } +} +``` + +**Add a completely new property with a default:** +```json +{ + "": { + "properties": { + "newProp": { + "name": "newProp", + "type": "string", + "defaultValue": "default", + "control": "select", + "description": "Description", + "options": ["default", "alt1", "alt2"] + } + }, + "defaultValues": { + "newProp": "default" + } + } +} +``` + +--- + +## Generator Internals + +### TypeScript AST Parsing + +The generator uses the TypeScript compiler API (`ts.createSourceFile`) to parse each `*-element.ts` file without executing it. It extracts: + +1. **Tag name**: From `@customElement('wokwi-led')` decorator → `wokwi-led` +2. **Properties**: From `@property() color = 'red'` decorators → `{ name: "color", type: "string", defaultValue: "red" }` +3. **Pin count**: By counting object literals `{ name: ... }` inside the `pinInfo` getter body +4. **Display name**: From the corresponding `.stories.ts` file's `title:` field + +### Category Assignment + +Components don't declare their own category. The generator uses a hardcoded `CATEGORY_MAP`: + +```typescript +const CATEGORY_MAP: Record = { + 'led': 'output', + 'pushbutton': 'input', + 'ssd1306': 'displays', + 'servo': 'motors', + 'resistor': 'passive', + // ... etc +}; +``` + +Components not in the map get `category: "other"`. + +### Control Inference + +The generator infers a UI control from the TypeScript property type: + +```typescript +private inferControl(tsType: string): 'text' | 'range' | 'color' | 'boolean' | 'select' { + if (tsType.includes('boolean')) return 'boolean'; + if (tsType.includes('number')) return 'range'; + return 'text'; // strings and everything else +} +``` + +This is why custom overrides are needed — the generator can't know that `color: string` should be a `select` with specific options. + +### Tag Generation + +Each component gets search tags derived from its ID and display name: + +``` +id: "led-bar-graph" → tags: ["led-bar-graph", "led bar graph", "led", "bar", "graph"] +``` + +--- + +## File Reference + +| File | Description | +|------|-------------| +| `scripts/generate-component-metadata.ts` | Generator script — scans wokwi-elements, applies overrides, writes JSON | +| `scripts/component-overrides.json` | Custom property overrides (survives regeneration) | +| `frontend/public/components-metadata.json` | Generated output — consumed by the frontend at runtime | +| `frontend/src/types/component-metadata.ts` | TypeScript interface for `ComponentMetadata` | +| `frontend/src/services/ComponentRegistry.ts` | Loads and indexes the metadata JSON at runtime | +| `wokwi-libs/wokwi-elements/src/*-element.ts` | Source files scanned by the generator | diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index 29ae02f7..691aa80e 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -247,9 +247,19 @@ "name": "imageData", "type": "string", "control": "text" + }, + { + "name": "protocol", + "type": "string", + "defaultValue": "i2c", + "control": "select", + "description": "Communication protocol", + "options": ["i2c", "spi"] } ], - "defaultValues": {}, + "defaultValues": { + "protocol": "i2c" + }, "pinCount": 0, "tags": [ "ssd1306" @@ -1326,7 +1336,8 @@ "name": "color", "type": "string", "defaultValue": "red", - "control": "text" + "control": "select", + "options": ["red", "green", "blue", "yellow", "orange", "white", "purple"] }, { "name": "lightColor", diff --git a/frontend/src/components/simulator/ComponentPropertyDialog.tsx b/frontend/src/components/simulator/ComponentPropertyDialog.tsx index d63f2a5b..e4780404 100644 --- a/frontend/src/components/simulator/ComponentPropertyDialog.tsx +++ b/frontend/src/components/simulator/ComponentPropertyDialog.tsx @@ -35,11 +35,11 @@ export const ComponentPropertyDialog: React.FC = ( const dialogRef = useRef(null); const [dialogPosition, setDialogPosition] = useState({ x: 0, y: 0 }); - // Calculate dialog position on mount + // Calculate dialog position on mount — clamp within canvas viewport useEffect(() => { if (!dialogRef.current) return; - const dialogWidth = 220; + const dialogWidth = dialogRef.current.offsetWidth || 220; const dialogHeight = dialogRef.current.offsetHeight || 200; const canvasElement = document.querySelector('.canvas-content'); if (!canvasElement) return; @@ -47,19 +47,20 @@ export const ComponentPropertyDialog: React.FC = ( const canvasWidth = canvasElement.clientWidth; const canvasHeight = canvasElement.clientHeight; - // Try positioning to the right of component - let x = position.x + 150; // Approximate component width + gap + // Position to the right of the component (screen coords already include pan+zoom) + let x = position.x + 120; let y = position.y; - // If off-screen right, position to left + // If off-screen right, position to the left if (x + dialogWidth > canvasWidth) { x = Math.max(10, position.x - dialogWidth - 10); } - // Keep within vertical bounds - if (y + dialogHeight > canvasHeight) { - y = Math.max(10, canvasHeight - dialogHeight - 10); - } + // Clamp horizontal + x = Math.max(10, Math.min(x, canvasWidth - dialogWidth - 10)); + + // Clamp vertical — ensure dialog stays fully visible + y = Math.max(10, Math.min(y, canvasHeight - dialogHeight - 10)); setDialogPosition({ x, y }); }, [position]); diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 4eee3ece..eca592ba 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -116,6 +116,10 @@ export const SimulatorCanvas = () => { const [sensorControlComponentId, setSensorControlComponentId] = useState(null); const [sensorControlMetadataId, setSensorControlMetadataId] = useState(null); + // Board built-in LED states (pin 13 for AVR, GPIO25 for RP2040, etc.) + // Tracks directly from pinManager — independent of any led-builtin component. + const [boardLedStates, setBoardLedStates] = useState>({}); + // Board context menu (right-click) const [boardContextMenu, setBoardContextMenu] = useState<{ boardId: string; x: number; y: number } | null>(null); // Board removal confirmation dialog @@ -555,7 +559,10 @@ export const SimulatorCanvas = () => { setSensorControlMetadataId(component.metadataId); } else { setPropertyDialogComponentId(touchId); - setPropertyDialogPosition({ x: component.x, y: component.y }); + setPropertyDialogPosition({ + x: component.x * zoomRef.current + panRef.current.x, + y: component.y * zoomRef.current + panRef.current.y, + }); setShowPropertyDialog(true); } } @@ -707,6 +714,38 @@ export const SimulatorCanvas = () => { }; }, [components, wires, boards, pinManager, updateComponentState]); + // Board built-in LED: subscribe directly to pinManager for the LED pin of each board. + // This works even when no external led-builtin component exists (e.g. basic Blink example). + useEffect(() => { + if (!pinManager) return; + const unsubs: (() => void)[] = []; + + boards.forEach((board) => { + // Determine which GPIO pin drives the board's built-in LED + let ledPin: number; + switch (board.boardKind) { + case 'raspberry-pi-pico': + case 'pi-pico-w': + case 'nano-rp2040': + ledPin = 25; // GPIO25 + break; + default: + ledPin = 13; // Pin 13 for Arduino Uno/Nano/Mega, ATtiny85, etc. + } + + unsubs.push( + pinManager.onPinChange(ledPin, (_pin, state) => { + setBoardLedStates((prev) => { + if (prev[board.id] === state) return prev; + return { ...prev, [board.id]: state }; + }); + }) + ); + }); + + return () => unsubs.forEach((u) => u()); + }, [boards, pinManager]); + // ESP32 input components: forward button presses and potentiometer values to QEMU useEffect(() => { const cleanups: (() => void)[] = []; @@ -968,7 +1007,10 @@ export const SimulatorCanvas = () => { setSensorControlMetadataId(component.metadataId); } else { setPropertyDialogComponentId(draggedComponentId); - setPropertyDialogPosition({ x: component.x, y: component.y }); + setPropertyDialogPosition({ + x: component.x * zoomRef.current + panRef.current.x, + y: component.y * zoomRef.current + panRef.current.y, + }); setShowPropertyDialog(true); } } @@ -1463,7 +1505,7 @@ export const SimulatorCanvas = () => { board={board} running={running} isActive={board.id === activeBoardId} - led13={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)} + led13={Boolean(boardLedStates[board.id])} onMouseDown={(e) => { setClickStartTime(Date.now()); setClickStartPos({ x: e.clientX, y: e.clientY }); diff --git a/scripts/component-overrides.json b/scripts/component-overrides.json new file mode 100644 index 00000000..e79b36a5 --- /dev/null +++ b/scripts/component-overrides.json @@ -0,0 +1,26 @@ +{ + "$comment": "Custom property overrides applied AFTER auto-generation from wokwi-elements. Add entries here to customize controls, add new properties, or change defaults. These survive metadata regeneration.", + "led": { + "properties": { + "color": { + "control": "select", + "options": ["red", "green", "blue", "yellow", "orange", "white", "purple"] + } + } + }, + "ssd1306": { + "properties": { + "protocol": { + "name": "protocol", + "type": "string", + "defaultValue": "i2c", + "control": "select", + "description": "Communication protocol", + "options": ["i2c", "spi"] + } + }, + "defaultValues": { + "protocol": "i2c" + } + } +} diff --git a/scripts/generate-component-metadata.ts b/scripts/generate-component-metadata.ts index 8ceac7e3..98926146 100644 --- a/scripts/generate-component-metadata.ts +++ b/scripts/generate-component-metadata.ts @@ -93,10 +93,12 @@ interface ParsedComponent { class MetadataGenerator { private wokwiElementsPath: string; private outputPath: string; + private overridesPath: string; constructor() { this.wokwiElementsPath = path.join(__dirname, '../wokwi-libs/wokwi-elements/src'); this.outputPath = path.join(__dirname, '../frontend/public/components-metadata.json'); + this.overridesPath = path.join(__dirname, 'component-overrides.json'); } /** @@ -129,6 +131,9 @@ class MetadataGenerator { } } + // Apply custom overrides from component-overrides.json + this.applyOverrides(components); + // Sort by category and name components.sort((a, b) => { if (a.category !== b.category) { @@ -154,6 +159,58 @@ class MetadataGenerator { console.log(`📄 Output: ${this.outputPath}`); } + /** + * Apply custom overrides from component-overrides.json. + * + * Overrides can: + * - Patch existing property fields (e.g. change control from "text" to "select") + * - Add entirely new properties to a component + * - Merge extra defaultValues + */ + private applyOverrides(components: ComponentMetadata[]): void { + if (!fs.existsSync(this.overridesPath)) return; + + let overrides: Record; + try { + overrides = JSON.parse(fs.readFileSync(this.overridesPath, 'utf-8')); + } catch (e) { + console.warn(`⚠️ Could not parse ${this.overridesPath}:`, e); + return; + } + + let applied = 0; + for (const comp of components) { + const ov = overrides[comp.id]; + if (!ov) continue; + + // Merge property-level overrides + if (ov.properties) { + for (const [propName, patch] of Object.entries(ov.properties)) { + const existing = comp.properties.find((p: any) => p.name === propName); + if (existing) { + // Patch existing property (e.g. change control, add options) + Object.assign(existing, patch); + } else { + // Add new property (e.g. SSD1306 "protocol") + comp.properties.push(patch); + } + } + } + + // Merge defaultValues + if (ov.defaultValues) { + comp.defaultValues = { ...comp.defaultValues, ...ov.defaultValues }; + } + + applied++; + console.log(` 🔧 Applied overrides for ${comp.id}`); + } + + if (applied > 0) { + console.log(`\n🔧 Applied overrides to ${applied} component(s)`); + } + } + /** * Find all *-element.ts files (excluding .stories.ts) */