velxio/frontend/src/simulation/I2CBusManager.ts

543 lines
18 KiB
TypeScript
Raw Normal View History

/**
* I2C Bus Manager virtual I2C devices that attach to avr8js AVRTWI
*
* Each device registers at a 7-bit I2C address. When the Arduino sketch
* does Wire.beginTransmission(addr) / Wire.requestFrom(addr, ...), the
* TWI event handler routes events to the matching virtual device.
*/
import type { AVRTWI, TWIEventHandler } from 'avr8js';
// ── Virtual I2C device interface ────────────────────────────────────────────
export interface I2CDevice {
/** 7-bit I2C address (e.g. 0x27 for PCF8574 LCD backpack, 0x3C for SSD1306) */
address: number;
/** Called when master sends a byte after addressing this device for write */
writeByte(value: number): boolean; // return true for ACK
/** Called when master requests a byte from this device (read mode) */
readByte(): number;
/** Optional: called on STOP condition */
stop?(): void;
}
// ── I2C Bus Manager (TWIEventHandler for avr8js) ───────────────────────────
export class I2CBusManager implements TWIEventHandler {
private devices: Map<number, I2CDevice> = new Map();
private activeDevice: I2CDevice | null = null;
private writeMode = true;
constructor(private twi: AVRTWI) {
twi.eventHandler = this;
}
/** Register a virtual I2C device on the bus */
addDevice(device: I2CDevice): void {
this.devices.set(device.address, device);
}
/** Remove a device by address */
removeDevice(address: number): void {
this.devices.delete(address);
}
// ── TWIEventHandler implementation ──────────────────────────────────────
start(_repeated: boolean): void {
this.twi.completeStart();
}
stop(): void {
if (this.activeDevice?.stop) this.activeDevice.stop();
this.activeDevice = null;
this.twi.completeStop();
}
connectToSlave(addr: number, write: boolean): void {
const device = this.devices.get(addr);
if (device) {
this.activeDevice = device;
this.writeMode = write;
this.twi.completeConnect(true); // ACK
} else {
this.activeDevice = null;
this.twi.completeConnect(false); // NACK — no such address
}
}
writeByte(value: number): void {
if (this.activeDevice) {
const ack = this.activeDevice.writeByte(value);
this.twi.completeWrite(ack);
} else {
this.twi.completeWrite(false);
}
}
readByte(_ack: boolean): void {
if (this.activeDevice) {
const value = this.activeDevice.readByte();
this.twi.completeRead(value);
} else {
this.twi.completeRead(0xff);
}
}
}
// ── Built-in virtual I2C devices ───────────────────────────────────────────
/**
* Generic I2C memory / register device.
* Emulates a device with 256 byte registers.
* First write byte = register address, subsequent bytes = data.
* Reads return register contents sequentially.
*
* Used to test I2C communication without a specific device implementation.
*/
export class I2CMemoryDevice implements I2CDevice {
public registers = new Uint8Array(256);
private regPointer = 0;
private firstByte = true;
/** Callback fired whenever a register is written */
public onRegisterWrite: ((reg: number, value: number) => void) | null = null;
constructor(public address: number) {}
writeByte(value: number): boolean {
if (this.firstByte) {
this.regPointer = value;
this.firstByte = false;
} else {
this.registers[this.regPointer] = value;
if (this.onRegisterWrite) {
this.onRegisterWrite(this.regPointer, value);
}
this.regPointer = (this.regPointer + 1) & 0xFF;
}
return true; // ACK
}
readByte(): number {
const value = this.registers[this.regPointer];
this.regPointer = (this.regPointer + 1) & 0xFF;
return value;
}
stop(): void {
this.firstByte = true;
}
}
/**
* Virtual DS1307 RTC returns system time via I2C (address 0x68).
* Supports Wire.requestFrom(0x68, 7) to read seconds..year in BCD.
*/
export class VirtualDS1307 implements I2CDevice {
public address = 0x68;
private regPointer = 0;
private firstByte = true;
private toBCD(n: number): number {
return ((Math.floor(n / 10) & 0xF) << 4) | (n % 10 & 0xF);
}
writeByte(value: number): boolean {
if (this.firstByte) {
this.regPointer = value;
this.firstByte = false;
}
return true;
}
readByte(): number {
const now = new Date();
let val = 0;
switch (this.regPointer) {
case 0: val = this.toBCD(now.getSeconds()); break; // seconds
case 1: val = this.toBCD(now.getMinutes()); break; // minutes
case 2: val = this.toBCD(now.getHours()); break; // hours (24h)
case 3: val = this.toBCD(now.getDay() + 1); break; // day of week (1=Sun)
case 4: val = this.toBCD(now.getDate()); break; // date
case 5: val = this.toBCD(now.getMonth() + 1); break; // month
case 6: val = this.toBCD(now.getFullYear() % 100); break; // year
default: val = 0;
}
this.regPointer = (this.regPointer + 1) & 0x3F;
return val;
}
stop(): void {
this.firstByte = true;
}
}
/**
* Virtual temperature / humidity sensor (address 0x48).
* Returns fixed temperature (configurable) and humidity.
*/
export class VirtualTempSensor implements I2CDevice {
public address = 0x48;
private regPointer = 0;
private firstByte = true;
/** Temperature in degrees C * 100 (e.g. 2350 = 23.50 C) */
public temperature = 2350;
/** Humidity in % * 100 */
public humidity = 5500;
writeByte(value: number): boolean {
if (this.firstByte) {
this.regPointer = value;
this.firstByte = false;
}
return true;
}
readByte(): number {
let val = 0;
// Register 0: temp high byte, 1: temp low byte, 2: humidity high, 3: humidity low
switch (this.regPointer) {
case 0: val = (this.temperature >> 8) & 0xFF; break;
case 1: val = this.temperature & 0xFF; break;
case 2: val = (this.humidity >> 8) & 0xFF; break;
case 3: val = this.humidity & 0xFF; break;
default: val = 0xFF;
}
this.regPointer = (this.regPointer + 1) & 0xFF;
return val;
}
stop(): void {
this.firstByte = true;
}
}
/**
* Virtual BMP280 barometric pressure / temperature sensor.
*
* Supports I2C addresses 0x76 (SDO=0) or 0x77 (SDO=1).
*
* Register map (subset):
* 0x880x9F Calibration data (trimming parameters)
* 0xD0 chip_id = 0x58 (BMP280 production; BME280 = 0x60)
* 0xF3 status = 0x00 (measurement complete, no NVM copy)
* 0xF4 ctrl_meas (mode, osrs_t, osrs_p) writable
* 0xF5 config writable
* 0xF70xF9 press_msb / press_lsb / press_xlsb (20-bit ADC)
* 0xFA0xFC temp_msb / temp_lsb / temp_xlsb (20-bit ADC)
*
* The calibration parameters are the BMP280 datasheet example values (Section 8.2).
* They produce T 25°C, P 1006 hPa from the corresponding raw ADC values.
*
* Setting `temperature` (°C) and `pressure` (hPa) properties recomputes raw ADC
* registers using a binary search over the Bosch compensation formulas so that
* Arduino sketches using the Adafruit_BMP280 / Bosch driver get realistic values.
*/
export class VirtualBMP280 implements I2CDevice {
public address: number;
private readonly registers = new Uint8Array(256);
private regPtr = 0;
private firstByte = true;
// ── BMP280 datasheet Section 8.2 example calibration ───────────────────
private readonly DIG_T1 = 27504;
private readonly DIG_T2 = 26435;
private readonly DIG_T3 = -1000;
private readonly DIG_P1 = 36477;
private readonly DIG_P2 = -10685;
private readonly DIG_P3 = 3024;
private readonly DIG_P4 = 2855;
private readonly DIG_P5 = 140;
private readonly DIG_P6 = -7;
private readonly DIG_P7 = 15500;
private readonly DIG_P8 = -14600;
private readonly DIG_P9 = 6000;
private _temperatureC = 25.0;
private _pressureHPa = 1013.25;
constructor(address = 0x76) {
this.address = address;
this.initCalibration();
this.updateMeasurements();
}
// ── Public configurable properties ──────────────────────────────────────
get temperatureC(): number { return this._temperatureC; }
set temperatureC(v: number) { this._temperatureC = v; this.updateMeasurements(); }
get pressureHPa(): number { return this._pressureHPa; }
set pressureHPa(v: number) { this._pressureHPa = v; this.updateMeasurements(); }
// ── I2CDevice interface ─────────────────────────────────────────────────
writeByte(value: number): boolean {
if (this.firstByte) {
this.regPtr = value;
this.firstByte = false;
} else {
// Writable registers (ctrl_meas, config) — store them
this.registers[this.regPtr] = value;
this.regPtr = (this.regPtr + 1) & 0xFF;
}
return true;
}
readByte(): number {
const val = this.registers[this.regPtr];
this.regPtr = (this.regPtr + 1) & 0xFF;
return val;
}
stop(): void {
this.firstByte = true;
}
// ── Compensation formulas (Bosch 32-bit integer + double precision) ────
/** Compute t_fine from a 20-bit raw temperature ADC value. */
private tFine(adcT: number): number {
const var1 = (((adcT >> 3) - (this.DIG_T1 << 1)) * this.DIG_T2) >> 11;
const sub = (adcT >> 4) - this.DIG_T1;
const var2 = ((sub * sub >> 12) * this.DIG_T3) >> 14;
return var1 + var2;
}
/** Compute temperature in 0.01 °C from a 20-bit raw ADC value. */
private compensateT(adcT: number): number {
return (this.tFine(adcT) * 5 + 128) >> 8;
}
/**
* Compute pressure in Pa (double precision) from raw ADC values.
* Uses the Bosch floating-point compensation formula.
*/
private compensateP(adcP: number, adcT: number): number {
const tf = this.tFine(adcT);
let var1 = tf / 2.0 - 64000.0;
let var2 = var1 * var1 * this.DIG_P6 / 32768.0;
var2 = var2 + var1 * this.DIG_P5 * 2.0;
var2 = var2 / 4.0 + this.DIG_P4 * 65536.0;
var1 = (this.DIG_P3 * var1 * var1 / 524288.0 + this.DIG_P2 * var1) / 524288.0;
var1 = (1.0 + var1 / 32768.0) * this.DIG_P1;
if (var1 === 0) return 0;
let p = 1048576.0 - adcP;
p = (p - var2 / 4096.0) * 6250.0 / var1;
const v1b = this.DIG_P9 * p * p / 2147483648.0;
const v2b = p * this.DIG_P8 / 32768.0;
return p + (v1b + v2b + this.DIG_P7) / 16.0;
}
/**
* Binary-search for the 20-bit raw ADC value that produces the target
* temperature (in 0.01 °C units after integer compensation).
*/
private findAdcT(targetCentidegrees: number): number {
let lo = 0, hi = (1 << 20) - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.compensateT(mid) < targetCentidegrees) lo = mid + 1;
else hi = mid;
}
return lo;
}
/**
* Binary-search for the 20-bit raw ADC value that produces the target
* pressure (in Pa). Pressure is monotonically decreasing in adcP.
*/
private findAdcP(targetPa: number, adcT: number): number {
let lo = 0, hi = (1 << 20) - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.compensateP(mid, adcT) > targetPa) lo = mid + 1;
else hi = mid;
}
return lo;
}
/** Encode a 20-bit ADC value into three register bytes (msb, lsb, xlsb). */
private static encodeAdc20(val: number): [number, number, number] {
return [(val >> 12) & 0xFF, (val >> 4) & 0xFF, (val & 0xF) << 4];
}
// ── Register initialisation ─────────────────────────────────────────────
private initCalibration(): void {
const r = this.registers;
const wu16 = (a: number, v: number) => { r[a] = v & 0xFF; r[a+1] = (v >> 8) & 0xFF; };
const ws16 = (a: number, v: number) => wu16(a, v & 0xFFFF);
r[0xD0] = 0x58; // chip_id BMP280 (production silicon; BME280 uses 0x60)
r[0xF3] = 0x00; // status (measurement done)
r[0xF4] = 0x00; // ctrl_meas default
r[0xF5] = 0x00; // config default
wu16(0x88, this.DIG_T1);
ws16(0x8A, this.DIG_T2);
ws16(0x8C, this.DIG_T3);
wu16(0x8E, this.DIG_P1);
ws16(0x90, this.DIG_P2);
ws16(0x92, this.DIG_P3);
ws16(0x94, this.DIG_P4);
ws16(0x96, this.DIG_P5);
ws16(0x98, this.DIG_P6);
ws16(0x9A, this.DIG_P7);
ws16(0x9C, this.DIG_P8);
ws16(0x9E, this.DIG_P9);
}
/** Recompute raw ADC registers from current temperature / pressure. */
private updateMeasurements(): void {
const targetT = Math.round(this._temperatureC * 100);
const targetP = this._pressureHPa * 100; // hPa → Pa
const adcT = this.findAdcT(targetT);
const adcP = this.findAdcP(targetP, adcT);
const [pMsb, pLsb, pXlsb] = VirtualBMP280.encodeAdc20(adcP);
const [tMsb, tLsb, tXlsb] = VirtualBMP280.encodeAdc20(adcT);
this.registers[0xF7] = pMsb;
this.registers[0xF8] = pLsb;
this.registers[0xF9] = pXlsb;
this.registers[0xFA] = tMsb;
this.registers[0xFB] = tLsb;
this.registers[0xFC] = tXlsb;
}
}
/**
* Virtual DS3231 real-time clock with on-chip temperature sensor.
*
* Address: 0x68 (fixed same package as DS1307, one or the other per bus).
*
* Register map (subset):
* 0x00 Seconds (BCD, 059)
* 0x01 Minutes (BCD, 059)
* 0x02 Hours (BCD, 023, 24-hour mode)
* 0x03 Day (BCD, 17, 1=Sunday)
* 0x04 Date (BCD, 131)
* 0x05 Month (BCD, 112)
* 0x06 Year (BCD, 099)
* 0x0E Control (writable)
* 0x0F Status = 0x00 (OSF cleared, no alarms)
* 0x11 Temp MSB = integer degrees C (signed)
* 0x12 Temp LSB = fractional in bits 7:6 (0.25°C steps)
*
* Time is taken from the host browser clock.
* Temperature defaults to 25°C and is configurable via `temperatureC`.
*/
export class VirtualDS3231 implements I2CDevice {
public readonly address = 0x68;
public temperatureC = 25.0;
private regPtr = 0;
private firstByte = true;
private toBCD(n: number): number {
return ((Math.floor(n / 10) & 0xF) << 4) | (n % 10 & 0xF);
}
private readRegister(reg: number): number {
const now = new Date();
switch (reg) {
case 0x00: return this.toBCD(now.getSeconds());
case 0x01: return this.toBCD(now.getMinutes());
case 0x02: return this.toBCD(now.getHours());
case 0x03: return this.toBCD(now.getDay() + 1); // 1=Sunday
case 0x04: return this.toBCD(now.getDate());
case 0x05: return this.toBCD(now.getMonth() + 1);
case 0x06: return this.toBCD(now.getFullYear() % 100);
case 0x0E: return 0x00; // Control: oscillator enabled, no alarm outputs
case 0x0F: return 0x00; // Status: OSF=0 (no oscillator stop), alarms cleared
case 0x11: {
// Temperature MSB: signed integer degrees C
const intTemp = Math.trunc(this.temperatureC);
return intTemp & 0xFF;
}
case 0x12: {
// Temperature LSB: fractional in bits 7:6, 0.25°C resolution
const frac = this.temperatureC - Math.trunc(this.temperatureC);
const q = Math.round(frac / 0.25) & 0x03;
return (q << 6) & 0xFF;
}
default: return 0x00;
}
}
writeByte(value: number): boolean {
if (this.firstByte) {
this.regPtr = value;
this.firstByte = false;
} else {
// Accept writes to control registers (0x0E, 0x0F, alarm registers, etc.)
// We simply ignore the written value since this is a read-only time source.
this.regPtr = (this.regPtr + 1) & 0x1F;
}
return true;
}
readByte(): number {
const val = this.readRegister(this.regPtr);
this.regPtr = (this.regPtr + 1) & 0x1F;
return val;
}
stop(): void {
this.firstByte = true;
}
}
/**
* Virtual PCF8574 8-bit I/O expander.
*
* The PCF8574 exposes a single 8-bit quasi-bidirectional I/O port over I2C.
* - Writing one byte sets the output latch (pins driven LOW for 0, HIGH/HiZ for 1).
* - Reading one byte returns the current pin state (output latch AND-ed with external input).
*
* This is the most common I2C interface for 4-bit LCD backpacks.
*
* Configurable address: 0x200x27 (PCF8574) or 0x380x3F (PCF8574A).
* Default: 0x27 (all address pins HIGH, typical for LCD backpacks).
*
* `portState` holds the current 8-bit port value read back by the Arduino.
* Sketch writes update `outputLatch`; reads return `portState & outputLatch` (open-drain).
*/
export class VirtualPCF8574 implements I2CDevice {
public address: number;
/** Current state of the 8 I/O pins as seen from the outside (external input). */
public portState = 0xFF;
/** Output latch: bits the Arduino last wrote. 1 = released (input/Hi-Z), 0 = driven LOW. */
public outputLatch = 0xFF;
/** Optional callback when the Arduino writes to the port (e.g. to update an LCD visual). */
public onWrite: ((value: number) => void) | null = null;
constructor(address = 0x27) {
this.address = address;
}
writeByte(value: number): boolean {
this.outputLatch = value;
if (this.onWrite) this.onWrite(value);
return true;
}
readByte(): number {
// Open-drain: pin reads HIGH only when both outputLatch and portState are HIGH
return (this.portState & this.outputLatch) & 0xFF;
}
stop(): void {
// PCF8574 is stateless (no register pointer) — explicit no-op for interface clarity
}
}