2026-03-05 16:56:14 +07:00
|
|
|
|
/**
|
2026-05-13 00:26:33 +07:00
|
|
|
|
* I2C Bus Manager — virtual I2C bus shared between an MCU peripheral
|
|
|
|
|
|
* (avr8js AVRTWI or rp2040js RPI2C) and a set of JavaScript virtual
|
|
|
|
|
|
* devices, with optional cross-board bridging for multi-board sims.
|
2026-03-05 16:56:14 +07:00
|
|
|
|
*
|
|
|
|
|
|
* Each device registers at a 7-bit I2C address. When the Arduino sketch
|
|
|
|
|
|
* does Wire.beginTransmission(addr) / Wire.requestFrom(addr, ...), the
|
2026-05-13 00:26:33 +07:00
|
|
|
|
* MCU peripheral's event handler routes events to the matching virtual
|
|
|
|
|
|
* device on the LOCAL bus, OR — if a bridge to another board's bus is
|
|
|
|
|
|
* installed and the address is registered THERE — to the remote device.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Bridges are installed by Interconnect when both SDA and SCL of two
|
|
|
|
|
|
* boards are wired together. This lets two physical-style boards
|
|
|
|
|
|
* exchange I2C transactions without requiring slave-mode emulation
|
|
|
|
|
|
* inside avr8js / rp2040js (neither library supports it natively).
|
2026-03-05 16:56:14 +07:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
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 */
|
2026-04-22 02:45:45 +07:00
|
|
|
|
writeByte(value: number): boolean; // return true for ACK
|
2026-03-05 16:56:14 +07:00
|
|
|
|
/** Called when master requests a byte from this device (read mode) */
|
|
|
|
|
|
readByte(): number;
|
|
|
|
|
|
/** Optional: called on STOP condition */
|
|
|
|
|
|
stop?(): void;
|
2026-05-13 02:55:15 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Optional snapshot of the device's 256-byte register state. Used by
|
|
|
|
|
|
* the cross-board I2C proxy path (Interconnect → Esp32Bridge) to mirror
|
|
|
|
|
|
* the device into a backend `ProxySlave` so ESP32 firmware running in
|
|
|
|
|
|
* QEMU can read it synchronously. Devices that don't have a register
|
|
|
|
|
|
* map (write-only sinks, time-based responders) can omit this.
|
|
|
|
|
|
*/
|
|
|
|
|
|
dumpRegisters?(): Uint8Array;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 00:26:33 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Minimal contract the I2C bus needs from the MCU peripheral that is
|
|
|
|
|
|
* driving it as master. Both avr8js AVRTWI and rp2040js RPI2C
|
|
|
|
|
|
* implement this shape verbatim.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface I2CMaster {
|
|
|
|
|
|
completeStart(): void;
|
|
|
|
|
|
completeStop(): void;
|
|
|
|
|
|
completeConnect(ack: boolean): void;
|
|
|
|
|
|
completeWrite(ack: boolean): void;
|
|
|
|
|
|
completeRead(value: number): void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── I2C Bus Manager (implements TWIEventHandler for avr8js) ────────────────
|
2026-03-05 16:56:14 +07:00
|
|
|
|
|
|
|
|
|
|
export class I2CBusManager implements TWIEventHandler {
|
|
|
|
|
|
private devices: Map<number, I2CDevice> = new Map();
|
|
|
|
|
|
private activeDevice: I2CDevice | null = null;
|
|
|
|
|
|
private writeMode = true;
|
|
|
|
|
|
|
2026-05-13 00:26:33 +07:00
|
|
|
|
/** Peer buses that this bus can forward transactions to when the requested address is not local. */
|
|
|
|
|
|
private bridges: I2CBusManager[] = [];
|
|
|
|
|
|
/** When the master-side transaction was routed to a peer, this holds it. */
|
|
|
|
|
|
private activeExternal: I2CBusManager | null = null;
|
|
|
|
|
|
/** When this bus is acting as the target of an external peer's master, this holds the addressed device. */
|
|
|
|
|
|
private externalActiveDevice: I2CDevice | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Construct a bus bound to an `I2CMaster`. For backward
|
|
|
|
|
|
* compatibility, if the master has a settable `eventHandler`
|
|
|
|
|
|
* property (the AVRTWI shape), it is wired to `this` automatically
|
|
|
|
|
|
* so existing AVRSimulator code continues to work unchanged. For
|
|
|
|
|
|
* peripherals with per-callback wiring (RPI2C), the caller is
|
|
|
|
|
|
* responsible for routing each master event into the bus's methods.
|
|
|
|
|
|
*/
|
|
|
|
|
|
constructor(private master: I2CMaster) {
|
|
|
|
|
|
this.bindEventHandler(master);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private bindEventHandler(master: I2CMaster): void {
|
|
|
|
|
|
if (
|
|
|
|
|
|
master !== null &&
|
|
|
|
|
|
typeof master === 'object' &&
|
|
|
|
|
|
'eventHandler' in (master as object)
|
|
|
|
|
|
) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
(master as { eventHandler: TWIEventHandler }).eventHandler = this;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* setter rejected — caller will wire events manually */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Swap the master peripheral this bus drives. Used when the
|
|
|
|
|
|
* I2CBusManager is constructed early (so cross-board bridges and
|
|
|
|
|
|
* device registration can happen before firmware loads) and the
|
|
|
|
|
|
* real MCU peripheral becomes available later (e.g. after loadHex).
|
|
|
|
|
|
* Local devices and bridges are preserved.
|
|
|
|
|
|
*/
|
|
|
|
|
|
attachMaster(master: I2CMaster): void {
|
|
|
|
|
|
this.master = master;
|
|
|
|
|
|
this.bindEventHandler(master);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Backward-compat accessor for the underlying AVRTWI, when constructed from one. */
|
|
|
|
|
|
get twi(): AVRTWI {
|
|
|
|
|
|
return this.master as AVRTWI;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 02:55:15 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Snapshot of currently-registered local devices. Used by Interconnect
|
|
|
|
|
|
* to enumerate which addresses to mirror as proxies on a bridged ESP32.
|
|
|
|
|
|
*/
|
|
|
|
|
|
listDevices(): I2CDevice[] {
|
|
|
|
|
|
return Array.from(this.devices.values());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Read-only view of bridges currently attached to this bus. Used by
|
|
|
|
|
|
* the cross-board proxy sync to walk transitive peers (BFS). Not
|
|
|
|
|
|
* meant for mutation — call {@link attachBridge} / {@link detachBridge}.
|
|
|
|
|
|
*/
|
|
|
|
|
|
getBridges(): readonly I2CBusManager[] {
|
|
|
|
|
|
return this.bridges;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 00:26:33 +07:00
|
|
|
|
// ── Cross-board bridging ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Install a peer bus that this bus will forward unresolved master
|
|
|
|
|
|
* transactions to. The pair is one-directional — to make traffic
|
|
|
|
|
|
* flow in both directions, call attachBridge symmetrically on both
|
|
|
|
|
|
* buses. Idempotent.
|
|
|
|
|
|
*/
|
|
|
|
|
|
attachBridge(peer: I2CBusManager): void {
|
|
|
|
|
|
if (peer === this) return;
|
|
|
|
|
|
if (!this.bridges.includes(peer)) this.bridges.push(peer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Detach a previously-installed peer bus. */
|
|
|
|
|
|
detachBridge(peer: I2CBusManager): void {
|
|
|
|
|
|
this.bridges = this.bridges.filter((b) => b !== peer);
|
|
|
|
|
|
if (this.activeExternal === peer) this.activeExternal = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether this bus is currently acting as a slave to an external
|
|
|
|
|
|
* master. Exposed for diagnostics + tests.
|
|
|
|
|
|
*/
|
|
|
|
|
|
isHandlingExternal(): boolean {
|
|
|
|
|
|
return this.externalActiveDevice !== null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── TWIEventHandler implementation (master-side events from the local MCU) ──
|
2026-03-05 16:56:14 +07:00
|
|
|
|
|
|
|
|
|
|
start(_repeated: boolean): void {
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.master.completeStart();
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stop(): void {
|
2026-05-13 00:26:33 +07:00
|
|
|
|
if (this.activeExternal) {
|
|
|
|
|
|
this.activeExternal.handleExternalStop();
|
|
|
|
|
|
this.activeExternal = null;
|
|
|
|
|
|
} else if (this.activeDevice?.stop) {
|
|
|
|
|
|
this.activeDevice.stop();
|
|
|
|
|
|
}
|
2026-03-05 16:56:14 +07:00
|
|
|
|
this.activeDevice = null;
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.master.completeStop();
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
connectToSlave(addr: number, write: boolean): void {
|
2026-05-13 00:26:33 +07:00
|
|
|
|
// 1. Local devices win — fastest path and what single-board sketches expect.
|
|
|
|
|
|
const local = this.devices.get(addr);
|
|
|
|
|
|
if (local) {
|
|
|
|
|
|
this.activeDevice = local;
|
|
|
|
|
|
this.activeExternal = null;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
this.writeMode = write;
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.master.completeConnect(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
|
// 2. Walk the bridge graph (BFS) until a peer ACKs `addr`. The
|
|
|
|
|
|
// visited Set starts with `this` so we don't bounce back into
|
|
|
|
|
|
// ourselves through a peer that has us in its own bridge list.
|
|
|
|
|
|
// Each peer recurses into its own bridges via
|
|
|
|
|
|
// `handleExternalConnect`, also passing visited.
|
|
|
|
|
|
const visited = new Set<I2CBusManager>([this]);
|
2026-05-13 00:26:33 +07:00
|
|
|
|
for (const bridge of this.bridges) {
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
|
if (visited.has(bridge)) continue;
|
|
|
|
|
|
if (bridge.handleExternalConnect(addr, write, visited)) {
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.activeExternal = bridge;
|
|
|
|
|
|
this.activeDevice = null;
|
|
|
|
|
|
this.writeMode = write;
|
|
|
|
|
|
this.master.completeConnect(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
|
// 3. NACK — no device anywhere in the topology knows this address.
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.activeDevice = null;
|
|
|
|
|
|
this.activeExternal = null;
|
|
|
|
|
|
this.master.completeConnect(false);
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
writeByte(value: number): void {
|
|
|
|
|
|
if (this.activeDevice) {
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.master.completeWrite(this.activeDevice.writeByte(value));
|
|
|
|
|
|
} else if (this.activeExternal) {
|
|
|
|
|
|
this.master.completeWrite(this.activeExternal.handleExternalWrite(value));
|
2026-03-05 16:56:14 +07:00
|
|
|
|
} else {
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.master.completeWrite(false);
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
readByte(_ack: boolean): void {
|
|
|
|
|
|
if (this.activeDevice) {
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.master.completeRead(this.activeDevice.readByte());
|
|
|
|
|
|
} else if (this.activeExternal) {
|
|
|
|
|
|
this.master.completeRead(this.activeExternal.handleExternalRead());
|
2026-03-05 16:56:14 +07:00
|
|
|
|
} else {
|
2026-05-13 00:26:33 +07:00
|
|
|
|
this.master.completeRead(0xff);
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-13 00:26:33 +07:00
|
|
|
|
|
|
|
|
|
|
// ── External-master inbound handlers (called by a bridged peer bus) ────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
|
* Attempt to address `addr` on behalf of an external master. Returns
|
|
|
|
|
|
* true when SOMEONE in the reachable bridge graph has a device at the
|
|
|
|
|
|
* address, false otherwise (NACK).
|
|
|
|
|
|
*
|
|
|
|
|
|
* The `visited` Set tracks buses already consulted so we never loop
|
|
|
|
|
|
* back through cycles in the bridge graph. When the device is on a
|
|
|
|
|
|
* deeper hop (e.g. A→B→C with the device on C), this bus simply
|
|
|
|
|
|
* delegates: it records the bridge that resolved the address as its
|
|
|
|
|
|
* `externalActiveDevice`-proxy via a forwarding device shim, so the
|
|
|
|
|
|
* subsequent write/read/stop calls walk the same chain.
|
2026-05-13 00:26:33 +07:00
|
|
|
|
*/
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
|
handleExternalConnect(addr: number, write: boolean, visited?: Set<I2CBusManager>): boolean {
|
|
|
|
|
|
const v = visited ?? new Set<I2CBusManager>();
|
|
|
|
|
|
if (v.has(this)) return false;
|
|
|
|
|
|
v.add(this);
|
|
|
|
|
|
|
|
|
|
|
|
// First try local devices.
|
|
|
|
|
|
const local = this.devices.get(addr);
|
|
|
|
|
|
if (local) {
|
|
|
|
|
|
this.externalActiveDevice = local;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Then recurse into peers (BFS). If one of them ACKs, install a
|
|
|
|
|
|
// forwarder so this bus's read/write/stop calls delegate down the
|
|
|
|
|
|
// chain transparently.
|
|
|
|
|
|
for (const bridge of this.bridges) {
|
|
|
|
|
|
if (v.has(bridge)) continue;
|
|
|
|
|
|
if (bridge.handleExternalConnect(addr, write, v)) {
|
|
|
|
|
|
this.externalActiveDevice = createForwarderDevice(addr, bridge);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
2026-05-13 00:26:33 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** External master is sending a byte to the previously-addressed device. */
|
|
|
|
|
|
handleExternalWrite(value: number): boolean {
|
|
|
|
|
|
return this.externalActiveDevice?.writeByte(value) ?? false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** External master is requesting the next byte from the previously-addressed device. */
|
|
|
|
|
|
handleExternalRead(): number {
|
|
|
|
|
|
return this.externalActiveDevice?.readByte() ?? 0xff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** External master issued STOP — release the active device and call its lifecycle hook. */
|
|
|
|
|
|
handleExternalStop(): void {
|
|
|
|
|
|
this.externalActiveDevice?.stop?.();
|
|
|
|
|
|
this.externalActiveDevice = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A no-op `I2CMaster` used as a placeholder before the real MCU
|
|
|
|
|
|
* peripheral has been constructed. Lets `I2CBusManager` be created
|
|
|
|
|
|
* up-front so cross-board bridges and device registrations can land
|
|
|
|
|
|
* before firmware loads, then swapped to the real peripheral via
|
|
|
|
|
|
* `attachMaster()`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function nullI2CMaster(): I2CMaster {
|
|
|
|
|
|
return {
|
|
|
|
|
|
completeStart() {},
|
|
|
|
|
|
completeStop() {},
|
|
|
|
|
|
completeConnect(_ack: boolean) {},
|
|
|
|
|
|
completeWrite(_ack: boolean) {},
|
|
|
|
|
|
completeRead(_value: number) {},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Internal: build a transparent `I2CDevice` shim that forwards every
|
|
|
|
|
|
* write/read/stop down the bridge chain to a peer bus. Used by
|
|
|
|
|
|
* `handleExternalConnect` when the requested address resolves to a
|
|
|
|
|
|
* device living two or more hops away — the intermediate bus stores
|
|
|
|
|
|
* one of these shims as its `externalActiveDevice` so the existing
|
|
|
|
|
|
* `handleExternalWrite/Read/Stop` machinery routes through without
|
|
|
|
|
|
* needing per-method visited tracking.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function createForwarderDevice(addr: number, downstream: I2CBusManager): I2CDevice {
|
|
|
|
|
|
return {
|
|
|
|
|
|
address: addr,
|
|
|
|
|
|
writeByte(value: number): boolean {
|
|
|
|
|
|
return downstream.handleExternalWrite(value);
|
|
|
|
|
|
},
|
|
|
|
|
|
readByte(): number {
|
|
|
|
|
|
return downstream.handleExternalRead();
|
|
|
|
|
|
},
|
|
|
|
|
|
stop(): void {
|
|
|
|
|
|
downstream.handleExternalStop();
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 00:26:33 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Wire a non-AVR I2C master (e.g. rp2040js RPI2C) into an
|
|
|
|
|
|
* `I2CBusManager`. Returns the bus, with the master peripheral's
|
|
|
|
|
|
* `onStart` / `onConnect` / `onWriteByte` / `onReadByte` / `onStop`
|
|
|
|
|
|
* callbacks routed to `bus.start` etc. Matches the per-callback
|
|
|
|
|
|
* pattern RPI2C uses (it does not have a single `eventHandler`).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function wireRpI2cToBus(
|
|
|
|
|
|
master: I2CMaster & {
|
|
|
|
|
|
onStart?: () => void;
|
|
|
|
|
|
onConnect?: (address: number, mode?: number) => void;
|
|
|
|
|
|
onWriteByte?: (value: number) => void;
|
|
|
|
|
|
onReadByte?: (ack?: boolean) => void;
|
|
|
|
|
|
onStop?: () => void;
|
|
|
|
|
|
},
|
|
|
|
|
|
bus: I2CBusManager,
|
|
|
|
|
|
): void {
|
|
|
|
|
|
master.onStart = () => bus.start(false);
|
|
|
|
|
|
master.onConnect = (addr: number, mode?: number) =>
|
|
|
|
|
|
bus.connectToSlave(addr, mode === undefined ? true : mode === 0);
|
|
|
|
|
|
master.onWriteByte = (v: number) => bus.writeByte(v);
|
|
|
|
|
|
master.onReadByte = (ack?: boolean) => bus.readByte(ack ?? true);
|
|
|
|
|
|
master.onStop = () => bus.stop();
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPointer = (this.regPointer + 1) & 0xff;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
return true; // ACK
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
readByte(): number {
|
|
|
|
|
|
const value = this.registers[this.regPointer];
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPointer = (this.regPointer + 1) & 0xff;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
return value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stop(): void {
|
|
|
|
|
|
this.firstByte = true;
|
|
|
|
|
|
}
|
2026-05-13 02:55:15 +07:00
|
|
|
|
|
|
|
|
|
|
/** Return the full 256-byte register snapshot for cross-board proxying. */
|
|
|
|
|
|
dumpRegisters(): Uint8Array {
|
|
|
|
|
|
return new Uint8Array(this.registers);
|
|
|
|
|
|
}
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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 {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
return ((Math.floor(n / 10) & 0xf) << 4) | ((n % 10) & 0xf);
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 02:55:15 +07:00
|
|
|
|
/** Snapshot of the 7-byte time + 1-byte control register set. */
|
|
|
|
|
|
dumpRegisters(): Uint8Array {
|
|
|
|
|
|
const buf = new Uint8Array(256);
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
buf[0] = this.toBCD(now.getSeconds());
|
|
|
|
|
|
buf[1] = this.toBCD(now.getMinutes());
|
|
|
|
|
|
buf[2] = this.toBCD(now.getHours());
|
|
|
|
|
|
buf[3] = this.toBCD(now.getDay() + 1);
|
|
|
|
|
|
buf[4] = this.toBCD(now.getDate());
|
|
|
|
|
|
buf[5] = this.toBCD(now.getMonth() + 1);
|
|
|
|
|
|
buf[6] = this.toBCD(now.getFullYear() % 100);
|
|
|
|
|
|
return buf;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 16:56:14 +07:00
|
|
|
|
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) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPointer = (this.regPointer + 1) & 0x3f;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
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) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPointer = (this.regPointer + 1) & 0xff;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
return val;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stop(): void {
|
|
|
|
|
|
this.firstByte = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Virtual BMP280 barometric pressure / temperature sensor.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Supports I2C addresses 0x76 (SDO=0) or 0x77 (SDO=1).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Register map (subset):
|
|
|
|
|
|
* 0x88–0x9F Calibration data (trimming parameters)
|
2026-04-15 03:27:30 +07:00
|
|
|
|
* 0xD0 chip_id = 0x58 (BMP280 production; BME280 = 0x60)
|
2026-03-11 22:14:18 +07:00
|
|
|
|
* 0xF3 status = 0x00 (measurement complete, no NVM copy)
|
|
|
|
|
|
* 0xF4 ctrl_meas (mode, osrs_t, osrs_p) — writable
|
|
|
|
|
|
* 0xF5 config — writable
|
|
|
|
|
|
* 0xF7–0xF9 press_msb / press_lsb / press_xlsb (20-bit ADC)
|
|
|
|
|
|
* 0xFA–0xFC 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);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
private regPtr = 0;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
private firstByte = true;
|
|
|
|
|
|
|
|
|
|
|
|
// ── BMP280 datasheet Section 8.2 example calibration ───────────────────
|
2026-04-22 02:45:45 +07:00
|
|
|
|
private readonly DIG_T1 = 27504;
|
|
|
|
|
|
private readonly DIG_T2 = 26435;
|
|
|
|
|
|
private readonly DIG_T3 = -1000;
|
|
|
|
|
|
private readonly DIG_P1 = 36477;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
private readonly DIG_P2 = -10685;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
private readonly DIG_P8 = -14600;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
private readonly DIG_P9 = 6000;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
private _temperatureC = 25.0;
|
|
|
|
|
|
private _pressureHPa = 1013.25;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
|
|
|
|
|
constructor(address = 0x76) {
|
|
|
|
|
|
this.address = address;
|
|
|
|
|
|
this.initCalibration();
|
|
|
|
|
|
this.updateMeasurements();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Public configurable properties ──────────────────────────────────────
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
get temperatureC(): number {
|
|
|
|
|
|
return this._temperatureC;
|
|
|
|
|
|
}
|
|
|
|
|
|
set temperatureC(v: number) {
|
|
|
|
|
|
this._temperatureC = v;
|
|
|
|
|
|
this.updateMeasurements();
|
|
|
|
|
|
}
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
get pressureHPa(): number {
|
|
|
|
|
|
return this._pressureHPa;
|
|
|
|
|
|
}
|
|
|
|
|
|
set pressureHPa(v: number) {
|
|
|
|
|
|
this._pressureHPa = v;
|
|
|
|
|
|
this.updateMeasurements();
|
|
|
|
|
|
}
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
|
|
|
|
|
// ── I2CDevice interface ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
writeByte(value: number): boolean {
|
|
|
|
|
|
if (this.firstByte) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPtr = value;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
this.firstByte = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Writable registers (ctrl_meas, config) — store them
|
|
|
|
|
|
this.registers[this.regPtr] = value;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPtr = (this.regPtr + 1) & 0xff;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
readByte(): number {
|
|
|
|
|
|
const val = this.registers[this.regPtr];
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPtr = (this.regPtr + 1) & 0xff;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
return val;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stop(): void {
|
|
|
|
|
|
this.firstByte = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 02:55:15 +07:00
|
|
|
|
/** Snapshot the full 256-byte register file (calibration + ADC results). */
|
|
|
|
|
|
dumpRegisters(): Uint8Array {
|
|
|
|
|
|
return new Uint8Array(this.registers);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 22:14:18 +07:00
|
|
|
|
// ── 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;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const sub = (adcT >> 4) - this.DIG_T1;
|
|
|
|
|
|
const var2 = (((sub * sub) >> 12) * this.DIG_T3) >> 14;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
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 {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
if (var1 === 0) return 0;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
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 {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
let lo = 0,
|
|
|
|
|
|
hi = (1 << 20) - 1;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
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 {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
let lo = 0,
|
|
|
|
|
|
hi = (1 << 20) - 1;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
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] {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
return [(val >> 12) & 0xff, (val >> 4) & 0xff, (val & 0xf) << 4];
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Register initialisation ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private initCalibration(): void {
|
|
|
|
|
|
const r = this.registers;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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);
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
|
|
|
|
|
wu16(0x88, this.DIG_T1);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
ws16(0x8a, this.DIG_T2);
|
|
|
|
|
|
ws16(0x8c, this.DIG_T3);
|
|
|
|
|
|
wu16(0x8e, this.DIG_P1);
|
2026-03-11 22:14:18 +07:00
|
|
|
|
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);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
ws16(0x9a, this.DIG_P7);
|
|
|
|
|
|
ws16(0x9c, this.DIG_P8);
|
|
|
|
|
|
ws16(0x9e, this.DIG_P9);
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Recompute raw ADC registers from current temperature / pressure. */
|
|
|
|
|
|
private updateMeasurements(): void {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const targetT = Math.round(this._temperatureC * 100);
|
|
|
|
|
|
const targetP = this._pressureHPa * 100; // hPa → Pa
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.registers[0xf7] = pMsb;
|
|
|
|
|
|
this.registers[0xf8] = pLsb;
|
|
|
|
|
|
this.registers[0xf9] = pXlsb;
|
|
|
|
|
|
this.registers[0xfa] = tMsb;
|
|
|
|
|
|
this.registers[0xfb] = tLsb;
|
|
|
|
|
|
this.registers[0xfc] = tXlsb;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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, 0–59)
|
|
|
|
|
|
* 0x01 Minutes (BCD, 0–59)
|
|
|
|
|
|
* 0x02 Hours (BCD, 0–23, 24-hour mode)
|
|
|
|
|
|
* 0x03 Day (BCD, 1–7, 1=Sunday)
|
|
|
|
|
|
* 0x04 Date (BCD, 1–31)
|
|
|
|
|
|
* 0x05 Month (BCD, 1–12)
|
|
|
|
|
|
* 0x06 Year (BCD, 0–99)
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
private regPtr = 0;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
private firstByte = true;
|
|
|
|
|
|
|
|
|
|
|
|
private toBCD(n: number): number {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
return ((Math.floor(n / 10) & 0xf) << 4) | ((n % 10) & 0xf);
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private readRegister(reg: number): number {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
switch (reg) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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
|
2026-03-11 22:14:18 +07:00
|
|
|
|
case 0x11: {
|
|
|
|
|
|
// Temperature MSB: signed integer degrees C
|
|
|
|
|
|
const intTemp = Math.trunc(this.temperatureC);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
return intTemp & 0xff;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
case 0x12: {
|
|
|
|
|
|
// Temperature LSB: fractional in bits 7:6, 0.25°C resolution
|
|
|
|
|
|
const frac = this.temperatureC - Math.trunc(this.temperatureC);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const q = Math.round(frac / 0.25) & 0x03;
|
|
|
|
|
|
return (q << 6) & 0xff;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
default:
|
|
|
|
|
|
return 0x00;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
writeByte(value: number): boolean {
|
|
|
|
|
|
if (this.firstByte) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPtr = value;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
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.
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.regPtr = (this.regPtr + 1) & 0x1f;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
readByte(): number {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const val = this.readRegister(this.regPtr);
|
|
|
|
|
|
this.regPtr = (this.regPtr + 1) & 0x1f;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
return val;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stop(): void {
|
|
|
|
|
|
this.firstByte = true;
|
|
|
|
|
|
}
|
2026-05-13 02:55:15 +07:00
|
|
|
|
|
|
|
|
|
|
/** Snapshot the current register state (time + temperature). */
|
|
|
|
|
|
dumpRegisters(): Uint8Array {
|
|
|
|
|
|
const buf = new Uint8Array(256);
|
|
|
|
|
|
for (let r = 0; r < 0x20; r++) buf[r] = this.readRegister(r);
|
|
|
|
|
|
return buf;
|
|
|
|
|
|
}
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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: 0x20–0x27 (PCF8574) or 0x38–0x3F (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). */
|
2026-04-22 02:45:45 +07:00
|
|
|
|
public portState = 0xff;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
|
|
|
|
|
/** Output latch: bits the Arduino last wrote. 1 = released (input/Hi-Z), 0 = driven LOW. */
|
2026-04-22 02:45:45 +07:00
|
|
|
|
public outputLatch = 0xff;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
|
|
|
|
|
|
/** 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
|
2026-04-22 02:45:45 +07:00
|
|
|
|
return this.portState & this.outputLatch & 0xff;
|
2026-03-11 22:14:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stop(): void {
|
|
|
|
|
|
// PCF8574 is stateless (no register pointer) — explicit no-op for interface clarity
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|