velxio/frontend/src/simulation/piSlaveScanner.ts

178 lines
5.8 KiB
TypeScript

/**
* piSlaveScanner — turn canvas wires into pi_attach_slave commands.
*
* When the user wires a virtual I2C/SPI/UART component (e.g. BMP280)
* to a Raspberry Pi's protocol pins on the canvas, this helper detects
* the connection and emits the backend WebSocket frame that tells the
* pro overlay to instantiate the corresponding slave model in the
* PiSlaveRegistry.
*
* The mapping from a wokwi component metadata ID to a backend
* model_id lives in COMPONENT_TO_MODEL. New devices are added there
* plus a new file under pro/backend/app/pro/services/pi_slaves/.
*/
import type { RaspberryPi3Bridge } from './RaspberryPi3Bridge';
type CanvasWire = {
start: { componentId: string; pinName: string };
end: { componentId: string; pinName: string };
};
/** Shape we depend on from useSimulatorStore.Component. */
type CanvasComponent = {
id: string;
metadataId: string;
properties?: Record<string, unknown>;
};
// Raspberry Pi 40-pin header → bus assignment. Keys are physical pin
// numbers as strings (matching the wire endpoint pinName format).
// - SDA1/SCL1 → I2C bus 1
// - MOSI/MISO/SCLK → SPI bus 0 (CE0/CE1 distinguish slaves)
// - TXD/RXD → primary UART (port 0)
const I2C_PINS = new Set(['3', '5']);
const SPI_DATA_PINS = new Set(['19', '21', '23']);
const SPI_CE0_PIN = '24';
const SPI_CE1_PIN = '26';
const UART_PINS = new Set(['8', '10']);
// Map wokwi component metadata IDs / element types → backend model_id.
// Lowercase. Components not in this table get skipped silently.
const COMPONENT_TO_MODEL: Record<string, string> = {
'wokwi-bmp280': 'bme280', // same chip family, treat as superset
'velxio-bmp280': 'bme280',
'wokwi-bme280': 'bme280',
};
const DEFAULT_I2C_ADDRESS: Record<string, number> = {
bme280: 0x76,
};
function modelIdFor(component: CanvasComponent): string | null {
const t = component.metadataId?.toLowerCase();
return t ? COMPONENT_TO_MODEL[t] ?? null : null;
}
function i2cAddressFor(component: CanvasComponent, modelId: string): number {
const propAddr = component.properties?.address;
if (typeof propAddr === 'number') return propAddr;
if (typeof propAddr === 'string' && propAddr.length > 0) {
const parsed = Number(propAddr);
if (!Number.isNaN(parsed)) return parsed;
}
return DEFAULT_I2C_ADDRESS[modelId] ?? 0x76;
}
function configFor(component: CanvasComponent): Record<string, unknown> {
// Initial pass: only forward known keys so backend ctor surface is
// narrow. Each model declares its own kwargs.
const out: Record<string, unknown> = {};
const p = component.properties ?? {};
for (const key of ['temperature_c', 'humidity_pct', 'pressure_pa']) {
if (typeof p[key] === 'number') out[key] = p[key];
}
return out;
}
/**
* Returns the (bus_kind, identifying_pin) describing how `pinName`
* on the Pi is being used, or null if it isn't a protocol pin.
*/
function classifyPiPin(pinName: string): {
bus_kind: 'i2c' | 'spi' | 'uart';
bus_num: number;
} | null {
if (I2C_PINS.has(pinName)) return { bus_kind: 'i2c', bus_num: 1 };
if (SPI_DATA_PINS.has(pinName) || pinName === SPI_CE0_PIN || pinName === SPI_CE1_PIN)
return { bus_kind: 'spi', bus_num: 0 };
if (UART_PINS.has(pinName)) return { bus_kind: 'uart', bus_num: 0 };
return null;
}
/**
* Iterate every wire; if one side is a Pi protocol pin and the other
* side is a known I2C/SPI/UART component, emit pi_attach_slave.
*
* Returns the list of attach specs emitted (for tests).
*/
export function attachSlavesFromCanvas(
piBoardId: string,
bridge: Pick<RaspberryPi3Bridge, 'attachSlave'>,
components: CanvasComponent[],
wires: CanvasWire[],
): Array<Parameters<RaspberryPi3Bridge['attachSlave']>[0]> {
const componentsById = new Map(components.map((c) => [c.id, c]));
// dedupe attaches by (bus_kind, bus_num, address_or_cs)
const seen = new Set<string>();
const emitted: Array<Parameters<RaspberryPi3Bridge['attachSlave']>[0]> = [];
for (const wire of wires) {
let piEndpoint: { pinName: string } | null = null;
let otherEndpoint: { componentId: string } | null = null;
if (wire.start.componentId === piBoardId) {
piEndpoint = wire.start;
otherEndpoint = wire.end;
} else if (wire.end.componentId === piBoardId) {
piEndpoint = wire.end;
otherEndpoint = wire.start;
} else {
continue;
}
const bus = classifyPiPin(piEndpoint.pinName);
if (!bus) continue;
const peer = componentsById.get(otherEndpoint.componentId);
if (!peer) continue;
const modelId = modelIdFor(peer);
if (!modelId) continue;
let spec: Parameters<RaspberryPi3Bridge['attachSlave']>[0];
if (bus.bus_kind === 'i2c') {
const address = i2cAddressFor(peer, modelId);
spec = {
bus_kind: 'i2c',
bus_num: bus.bus_num,
address,
model_id: modelId,
config: configFor(peer),
};
} else if (bus.bus_kind === 'spi') {
// CS line determines logical slave index. Treat MOSI/MISO/SCLK
// wires as informational only — the CE wire is the one that
// pins down which slave gets attached.
let cs: number;
if (piEndpoint.pinName === SPI_CE0_PIN) cs = 0;
else if (piEndpoint.pinName === SPI_CE1_PIN) cs = 1;
else continue;
spec = {
bus_kind: 'spi',
bus_num: bus.bus_num,
cs,
model_id: modelId,
config: configFor(peer),
};
} else {
spec = {
bus_kind: 'uart',
bus_num: bus.bus_num,
model_id: modelId,
config: configFor(peer),
};
}
const key =
spec.bus_kind === 'i2c'
? `i2c:${spec.bus_num}:${spec.address}`
: spec.bus_kind === 'spi'
? `spi:${spec.bus_num}:${spec.cs}`
: `uart:${spec.bus_num}`;
if (seen.has(key)) continue;
seen.add(key);
bridge.attachSlave(spec);
emitted.push(spec);
}
return emitted;
}