feat: add protocol selection and editable properties in component dialogs

This commit is contained in:
David Montero Crespo 2026-04-06 23:44:24 -03:00
parent 5e372f1418
commit 97f8f6cd52
7 changed files with 264 additions and 70 deletions

View File

@ -247,9 +247,17 @@
"name": "imageData",
"type": "string",
"control": "text"
},
{
"name": "protocol",
"type": "select",
"control": "select",
"options": ["i2c", "spi"],
"defaultValue": "i2c",
"description": "Communication protocol"
}
],
"defaultValues": {},
"defaultValues": { "protocol": "i2c" },
"pinCount": 0,
"tags": [
"ssd1306"
@ -1324,9 +1332,11 @@
},
{
"name": "color",
"type": "string",
"type": "select",
"defaultValue": "red",
"control": "text"
"control": "select",
"options": ["red", "green", "blue", "yellow", "orange", "white", "purple"],
"description": "LED color"
},
{
"name": "lightColor",

View File

@ -266,11 +266,31 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
marginTop: '4px',
color: '#666',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
}}
>
{properties.pin !== undefined
? `Pin ${properties.pin}`
: metadata.name}
{properties.protocol && (
<span
style={{
fontSize: '9px',
padding: '1px 4px',
borderRadius: '3px',
backgroundColor: properties.protocol === 'spi' ? '#e67e22' : '#3498db',
color: '#fff',
fontWeight: 600,
textTransform: 'uppercase',
lineHeight: '1.2',
}}
>
{String(properties.protocol)}
</span>
)}
</div>
</div>
);

View File

@ -105,6 +105,56 @@
font-weight: 600;
}
.property-edit-section {
margin-bottom: 12px;
border-top: 1px solid #444;
padding-top: 10px;
}
.property-edit-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.property-edit-label {
font-size: 12px;
color: #aaa;
font-weight: 500;
white-space: nowrap;
}
.property-edit-select {
flex: 1;
max-width: 100px;
padding: 4px 8px;
font-size: 12px;
font-weight: 600;
color: #fff;
background-color: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
outline: none;
transition: border-color 0.2s;
}
.property-edit-select:hover {
border-color: #007acc;
}
.property-edit-select:focus {
border-color: #007acc;
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.25);
}
.property-edit-select option {
background-color: #2d2d2d;
color: #fff;
}
.property-actions {
display: flex;
gap: 8px;

View File

@ -18,6 +18,7 @@ interface ComponentPropertyDialogProps {
onClose: () => void;
onRotate: (componentId: string) => void;
onDelete: (componentId: string) => void;
onPropertyChange?: (componentId: string, propertyName: string, value: unknown) => void;
}
export const ComponentPropertyDialog: React.FC<ComponentPropertyDialogProps> = ({
@ -29,6 +30,7 @@ export const ComponentPropertyDialog: React.FC<ComponentPropertyDialogProps> = (
onClose,
onRotate,
onDelete,
onPropertyChange,
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const [dialogPosition, setDialogPosition] = useState({ x: 0, y: 0 });
@ -142,6 +144,36 @@ export const ComponentPropertyDialog: React.FC<ComponentPropertyDialogProps> = (
</div>
)}
{/* Editable Properties (select dropdowns) */}
{componentMetadata.properties
.filter((p: any) => p.control === 'select' && p.options)
.length > 0 && (
<div className="property-edit-section">
{componentMetadata.properties
.filter((p: any) => p.control === 'select' && p.options)
.map((prop: any) => (
<div key={prop.name} className="property-edit-row">
<label className="property-edit-label">
{prop.description || prop.name}
</label>
<select
className="property-edit-select"
value={String(componentProperties[prop.name] ?? prop.defaultValue ?? '')}
onChange={(e) =>
onPropertyChange?.(componentId, prop.name, e.target.value)
}
>
{prop.options.map((opt: string) => (
<option key={opt} value={opt}>
{opt.toUpperCase()}
</option>
))}
</select>
</div>
))}
</div>
)}
{/* Action Buttons */}
<div className="property-actions">
<button

View File

@ -1533,6 +1533,14 @@ export const SimulatorCanvas = () => {
removeComponent(id);
setShowPropertyDialog(false);
}}
onPropertyChange={(id, propName, value) => {
const comp = components.find((c) => c.id === id);
if (comp) {
updateComponent(id, {
properties: { ...comp.properties, [propName]: value },
});
}
}}
/>
);
})()}

View File

@ -22,6 +22,7 @@ import { PartSimulationRegistry } from './PartSimulationRegistry';
import { VirtualDS1307, VirtualBMP280, VirtualDS3231, VirtualPCF8574 } from '../I2CBusManager';
import type { I2CDevice } from '../I2CBusManager';
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
import { useSimulatorStore } from '../../store/useSimulatorStore';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@ -37,30 +38,24 @@ function removeI2CDevice(simulator: any, address: number): void {
// ─── SSD1306 OLED ────────────────────────────────────────────────────────────
/**
* Virtual SSD1306 OLED full I2C command & GDDRAM decoder.
* SSD1306Core shared GDDRAM buffer, command decoder, and rendering logic.
*
* Supported features:
* - Control byte 0x00 = command stream, 0x40 = GDDRAM data stream
* The SSD1306 command set is identical for I2C and SPI; only the transport
* differs. This core is used by both VirtualSSD1306 (I2C) and
* attachSSD1306SPI (SPI).
*
* Supported commands:
* - 0x20 Set Memory Addressing Mode (horizontal / vertical / page)
* - 0x21 Set Column Address
* - 0x22 Set Page Address
* - 0x400x7F Set Display Start Line
* - 0xAF Display ON / 0xAE Display OFF
* - All other single-byte and parameterized commands are parsed but ignored
* (contrast, charge pump, COM pin config, etc.)
*
* On STOP the 1024-byte framebuffer is written to element.buffer so that
* wokwi-ssd1306 renders the pixels.
* - All other parameterized commands are parsed but ignored.
*/
class VirtualSSD1306 implements I2CDevice {
address: number;
class SSD1306Core {
/** 1024-byte GDDRAM: 8 pages × 128 columns. Each byte = 8 vertical pixels. */
readonly buffer = new Uint8Array(128 * 8);
private ctrlByte = true; // waiting for control byte after I2C address
private isData = false; // true → GDDRAM write; false → command stream
// GDDRAM cursor
private col = 0;
private page = 0;
@ -72,14 +67,10 @@ class VirtualSSD1306 implements I2CDevice {
// Multi-byte command accumulation
private cmdBuf: number[] = [];
private cmdWant = 0; // remaining param bytes for current command
constructor(address: number, private element: HTMLElement) {
this.address = address;
}
private cmdWant = 0;
/** How many parameter bytes does this command require? */
private static cmdParams(cmd: number): number {
static cmdParams(cmd: number): number {
if (cmd === 0x20 || cmd === 0x81 || cmd === 0x8D ||
cmd === 0xA8 || cmd === 0xD3 || cmd === 0xD5 ||
cmd === 0xD8 || cmd === 0xD9 || cmd === 0xDA || cmd === 0xDB) return 1;
@ -87,35 +78,23 @@ class VirtualSSD1306 implements I2CDevice {
return 0;
}
writeByte(value: number): boolean {
// ── Control byte (first after I2C address) ──────────────────────────
if (this.ctrlByte) {
this.isData = (value & 0x40) !== 0;
this.ctrlByte = false;
this.cmdBuf = [];
this.cmdWant = 0;
return true;
}
/** Write a data byte to GDDRAM and advance cursor. */
writeData(value: number): void {
this.buffer[this.page * 128 + this.col] = value;
this.advanceCursor();
}
// ── GDDRAM write ────────────────────────────────────────────────────
if (this.isData) {
this.buffer[this.page * 128 + this.col] = value;
this.advanceCursor();
return true;
}
// ── Command stream ──────────────────────────────────────────────────
/** Feed a command or parameter byte. Multi-byte commands are accumulated. */
writeCommand(value: number): void {
if (this.cmdWant > 0) {
this.cmdBuf.push(value);
this.cmdWant--;
if (this.cmdWant === 0) this.applyCmd();
return true;
return;
}
this.cmdBuf = [value];
this.cmdWant = VirtualSSD1306.cmdParams(value);
this.cmdBuf = [value];
this.cmdWant = SSD1306Core.cmdParams(value);
if (this.cmdWant === 0) this.applyCmd();
return true;
}
private applyCmd(): void {
@ -133,8 +112,7 @@ class VirtualSSD1306 implements I2CDevice {
this.page = this.pageStart;
break;
default:
// Display start line 0x400x7F
if (cmd >= 0x40 && cmd <= 0x7F) { /* start line — visual, skip */ }
if (cmd >= 0x40 && cmd <= 0x7F) { /* display start line — visual, skip */ }
break;
}
}
@ -160,28 +138,17 @@ class VirtualSSD1306 implements I2CDevice {
}
}
readByte(): number { return 0xFF; }
stop(): void {
this.ctrlByte = true;
this.syncElement();
}
/**
* Push the 1-bit GDDRAM buffer to the wokwi-ssd1306 web component.
*
* wokwi-ssd1306 API:
* - `element.imageData` a 128×64 ImageData (RGBA, 4 bytes/pixel)
* - `element.redraw()` flushes imageData to the internal canvas
*
* GDDRAM layout: 8 pages × 128 columns.
* Each byte holds 8 vertical pixels; bit 0 = topmost pixel in the page.
*/
private syncElement(): void {
const el = this.element as any;
syncElement(element: HTMLElement): void {
const el = element as any;
if (!el) return;
// Obtain the ImageData object (initialised by wokwi-ssd1306 constructor)
let imgData: ImageData | undefined = el.imageData;
if (!imgData || imgData.width !== 128 || imgData.height !== 64) {
try {
@ -191,16 +158,15 @@ class VirtualSSD1306 implements I2CDevice {
}
}
const px = imgData.data; // Uint8ClampedArray, RGBA
const px = imgData.data;
for (let page = 0; page < 8; page++) {
for (let col = 0; col < 128; col++) {
const byte = this.buffer[page * 128 + col];
for (let bit = 0; bit < 8; bit++) {
const row = page * 8 + bit;
const lit = (byte >> bit) & 1;
const idx = (row * 128 + col) * 4;
// Lit pixel: bright cyan-white typical of OLED; off pixel: full black
const row = page * 8 + bit;
const lit = (byte >> bit) & 1;
const idx = (row * 128 + col) * 4;
px[idx] = lit ? 200 : 0; // R
px[idx + 1] = lit ? 230 : 0; // G
px[idx + 2] = lit ? 255 : 0; // B
@ -210,14 +176,122 @@ class VirtualSSD1306 implements I2CDevice {
}
el.imageData = imgData;
if (typeof el.redraw === 'function') {
el.redraw();
}
if (typeof el.redraw === 'function') el.redraw();
}
}
/**
* VirtualSSD1306 I2C wrapper around SSD1306Core.
*
* Handles the I2C control byte (0x00 = command stream, 0x40 = data stream)
* and delegates command/data writes to the shared core.
*/
class VirtualSSD1306 implements I2CDevice {
address: number;
private readonly core = new SSD1306Core();
private ctrlByte = true;
private isData = false;
constructor(address: number, private element: HTMLElement) {
this.address = address;
}
/** Expose core buffer for tests. */
get buffer(): Uint8Array { return this.core.buffer; }
writeByte(value: number): boolean {
if (this.ctrlByte) {
this.isData = (value & 0x40) !== 0;
this.ctrlByte = false;
return true;
}
if (this.isData) {
this.core.writeData(value);
} else {
this.core.writeCommand(value);
}
return true;
}
readByte(): number { return 0xFF; }
stop(): void {
this.ctrlByte = true;
this.core.syncElement(this.element);
}
}
/**
* Attach SSD1306 in SPI mode intercepts the AVR SPI bus.
*
* Follows the same pattern as ILI9341 (ComplexParts.ts): hook spi.onByte,
* track DC pin state via PinManager, and render GDDRAM to the element.
*/
function attachSSD1306SPI(
element: HTMLElement,
simulator: any,
getPin: (name: string) => number | null,
): () => void {
const pinManager = simulator.pinManager;
const spi = simulator.spi;
if (!pinManager || !spi) return () => {};
const core = new SSD1306Core();
let dcState = false;
const unsubs: (() => void)[] = [];
// Track DC pin (LOW = command, HIGH = data)
const pinDC = getPin('DC');
if (pinDC !== null) {
unsubs.push(
pinManager.onPinChange(pinDC, (_: number, s: boolean) => { dcState = s; }),
);
}
// Throttle rendering to ~60 fps
let dirty = false;
let rafId: number | null = null;
const scheduleSync = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
if (dirty) { core.syncElement(element); dirty = false; }
});
};
// Hook AVR SPI bus (onByte + completeTransfer)
const prevOnByte = spi.onByte;
spi.onByte = (value: number) => {
if (!dcState) {
core.writeCommand(value);
} else {
core.writeData(value);
dirty = true;
scheduleSync();
}
spi.completeTransfer(0xFF);
};
return () => {
spi.onByte = prevOnByte;
if (rafId !== null) cancelAnimationFrame(rafId);
unsubs.forEach(u => u());
};
}
PartSimulationRegistry.register('ssd1306', {
attachEvents: (element, simulator, _getPin) => {
attachEvents: (element, simulator, getPin, componentId) => {
// Read the protocol property from the component in the store
const { components } = useSimulatorStore.getState();
const comp = components.find(c => c.id === componentId);
const protocol = (comp?.properties?.protocol as string) ?? 'i2c';
if (protocol === 'spi') {
return attachSSD1306SPI(element, simulator, getPin);
}
// I2C mode (default)
const sim = simulator as any;
if (typeof sim.addI2CDevice !== 'function') return () => {};
const device = new VirtualSSD1306(0x3C, element);

@ -1 +1 @@
Subproject commit ab2c9f1868b22b78510e7d7f7b88036486bef4f7
Subproject commit 4694eb508ed17d7166b51f5e62c04167f3f8cc19