diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b48c4bd7..0e071169 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -21,6 +21,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up QEMU (for multi-arch builds) + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -50,6 +53,7 @@ jobs: with: context: . file: Dockerfile.standalone + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile.standalone b/Dockerfile.standalone index b40262de..3dcc8129 100644 --- a/Dockerfile.standalone +++ b/Dockerfile.standalone @@ -1,24 +1,33 @@ # ---- Stage 0: QEMU .so + ROM binaries ---- -# Two modes: -# 1. Local: place files in prebuilt/qemu/ (from build-qemu.sh or manual copy) -# 2. CI/CD: downloads from GitHub Release (requires qemu-lcgamboa repo to be public) -# The COPY always runs; the RUN only downloads missing files. +# Downloads arch-specific .so from GitHub Release (e.g. libqemu-xtensa-amd64.so) +# and renames to libqemu-xtensa.so so the backend needs no changes. +# Local prebuilt files (prebuilt/qemu/) are used if present. FROM ubuntu:22.04 AS qemu-provider RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* +ARG TARGETARCH ARG QEMU_RELEASE_URL=https://github.com/davidmonterocrespo24/velxio/releases/download/qemu-prebuilt # Copy the prebuilt directory (may contain .so+ROM files or just the .gitkeep) RUN mkdir -p /qemu COPY prebuilt/qemu/ /qemu/ -# Download any missing files from GitHub Release +# Download arch-specific .so and arch-independent ROM files RUN cd /qemu \ - && for f in libqemu-xtensa.so libqemu-riscv32.so esp32-v3-rom.bin esp32-v3-rom-app.bin esp32c3-rom.bin; do \ + && for base in libqemu-xtensa libqemu-riscv32; do \ + f="${base}.so" ; \ if [ ! -f "$f" ]; then \ - echo "Downloading $f from release..." ; \ + echo "Downloading ${base}-${TARGETARCH}.so → $f ..." ; \ + curl -fSL -o "$f" "${QEMU_RELEASE_URL}/${base}-${TARGETARCH}.so" ; \ + else \ + echo "Using local $f ($(stat -c%s "$f") bytes)" ; \ + fi ; \ + done \ + && for f in esp32-v3-rom.bin esp32-v3-rom-app.bin esp32c3-rom.bin; do \ + if [ ! -f "$f" ]; then \ + echo "Downloading $f ..." ; \ curl -fSL -o "$f" "${QEMU_RELEASE_URL}/$f" ; \ else \ echo "Using local $f ($(stat -c%s "$f") bytes)" ; \ diff --git a/frontend/src/__tests__/simulation-parts.test.ts b/frontend/src/__tests__/simulation-parts.test.ts index f6de6d60..cdfc998c 100644 --- a/frontend/src/__tests__/simulation-parts.test.ts +++ b/frontend/src/__tests__/simulation-parts.test.ts @@ -120,26 +120,51 @@ describe('PartSimulationRegistry — registration', () => { // ─── LED ───────────────────────────────────────────────────────────────────── -describe('LED — onPinStateChange', () => { - it('sets element.value = true when anode pin goes HIGH', () => { +describe('LED — attachEvents (anode + cathode check)', () => { + it('LED turns on when anode HIGH and cathode wired to GND', () => { const logic = PartSimulationRegistry.get('led')!; const el = makeElement({ value: false }); - logic.onPinStateChange!('A', true, el); + const sim = makeSimulator(); + // A → GPIO pin 13, C → GND (-1) + logic.attachEvents!(el, sim as any, pinMap({ 'A': 13, 'C': -1 }), 'led-1'); + + // pinManager.onPinChange should be called for anode (pin 13) + const calls = sim.pinManager.onPinChange.mock.calls; + const anodeCall = calls.find((c: any) => c[0] === 13); + expect(anodeCall).toBeDefined(); + + // Simulate anode going HIGH + anodeCall![1](13, true); expect((el as any).value).toBe(true); }); - it('sets element.value = false when anode pin goes LOW', () => { + it('LED stays off when anode HIGH but cathode not wired', () => { const logic = PartSimulationRegistry.get('led')!; - const el = makeElement({ value: true }); - logic.onPinStateChange!('A', false, el); + const el = makeElement({ value: false }); + const sim = makeSimulator(); + // A → pin 13, C → not wired (null) + logic.attachEvents!(el, sim as any, pinMap({ 'A': 13 }), 'led-2'); + + const calls = sim.pinManager.onPinChange.mock.calls; + const anodeCall = calls.find((c: any) => c[0] === 13); + expect(anodeCall).toBeDefined(); + + // Simulate anode going HIGH — should NOT light up + anodeCall![1](13, true); expect((el as any).value).toBe(false); }); - it('ignores non-anode pins (e.g. cathode K)', () => { + it('LED turns off when anode goes LOW', () => { const logic = PartSimulationRegistry.get('led')!; const el = makeElement({ value: false }); - logic.onPinStateChange!('K', true, el); - expect((el as any).value).toBe(false); // unchanged + const sim = makeSimulator(); + logic.attachEvents!(el, sim as any, pinMap({ 'A': 13, 'C': -1 }), 'led-3'); + + const anodeCall = sim.pinManager.onPinChange.mock.calls.find((c: any) => c[0] === 13); + anodeCall![1](13, true); + expect((el as any).value).toBe(true); + anodeCall![1](13, false); + expect((el as any).value).toBe(false); }); }); diff --git a/frontend/src/simulation/parts/BasicParts.ts b/frontend/src/simulation/parts/BasicParts.ts index b4633614..e50bce7b 100644 --- a/frontend/src/simulation/parts/BasicParts.ts +++ b/frontend/src/simulation/parts/BasicParts.ts @@ -125,14 +125,49 @@ PartSimulationRegistry.register('dip-switch-8', { }); /** - * Basic LED implementation + * Basic LED implementation. + * + * An LED lights up only when current can flow: anode HIGH **and** cathode + * connected to GND (or a LOW GPIO). If the cathode is not wired at all the + * LED stays off regardless of the anode state. */ PartSimulationRegistry.register('led', { - onPinStateChange: (pinName, state, element) => { - if (pinName === 'A') { // Anode - (element as any).value = state; + attachEvents: (element, simulator, getArduinoPinHelper) => { + const pinManager = (simulator as any).pinManager; + if (!pinManager) return () => {}; + + const el = element as any; + const unsubs: (() => void)[] = []; + let anodeHigh = false; + let cathodeLow = false; + + const update = () => { el.value = anodeHigh && cathodeLow; }; + + // Cathode pin: -1 means wired to GND (always LOW), >=0 means GPIO + const cathodePin = getArduinoPinHelper('C'); + if (cathodePin === -1) { + // Wired to GND — always LOW + cathodeLow = true; + } else if (cathodePin !== null && cathodePin >= 0) { + // Wired to a GPIO — track its state + unsubs.push(pinManager.onPinChange(cathodePin, (_: number, state: boolean) => { + cathodeLow = !state; // cathode needs to be LOW for current to flow + update(); + })); } - } + // cathodePin === null → not wired → cathodeLow stays false → LED off + + // Anode pin + const anodePin = getArduinoPinHelper('A'); + if (anodePin !== null && anodePin >= 0) { + unsubs.push(pinManager.onPinChange(anodePin, (_: number, state: boolean) => { + anodeHigh = state; + update(); + })); + } + + return () => { unsubs.forEach(u => u()); }; + }, }); /** diff --git a/wokwi-libs/qemu-lcgamboa b/wokwi-libs/qemu-lcgamboa index 4694eb50..092ad433 160000 --- a/wokwi-libs/qemu-lcgamboa +++ b/wokwi-libs/qemu-lcgamboa @@ -1 +1 @@ -Subproject commit 4694eb508ed17d7166b51f5e62c04167f3f8cc19 +Subproject commit 092ad4333d148ac57d28035ee5ae3e8f9b819313