feat: multi-arch Docker (amd64+arm64) and fix LED ground check

Docker multi-arch:
- Dockerfile downloads arch-specific QEMU .so via TARGETARCH
- docker-publish.yml adds setup-qemu-action and platforms: linux/amd64,linux/arm64
- qemu-lcgamboa submodule updated (matrix build for both architectures)

LED fix:
- LEDs now require cathode wired to GND (or LOW GPIO) to light up
- Previously LEDs turned on with anode HIGH regardless of cathode connection
- Updated tests to verify anode+cathode behavior
This commit is contained in:
David Montero Crespo 2026-04-07 03:58:52 -03:00
parent 97f8f6cd52
commit a3db014d2d
5 changed files with 95 additions and 22 deletions

View File

@ -21,6 +21,9 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU (for multi-arch builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@ -50,6 +53,7 @@ jobs:
with: with:
context: . context: .
file: Dockerfile.standalone file: Dockerfile.standalone
platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View File

@ -1,24 +1,33 @@
# ---- Stage 0: QEMU .so + ROM binaries ---- # ---- Stage 0: QEMU .so + ROM binaries ----
# Two modes: # Downloads arch-specific .so from GitHub Release (e.g. libqemu-xtensa-amd64.so)
# 1. Local: place files in prebuilt/qemu/ (from build-qemu.sh or manual copy) # and renames to libqemu-xtensa.so so the backend needs no changes.
# 2. CI/CD: downloads from GitHub Release (requires qemu-lcgamboa repo to be public) # Local prebuilt files (prebuilt/qemu/) are used if present.
# The COPY always runs; the RUN only downloads missing files.
FROM ubuntu:22.04 AS qemu-provider FROM ubuntu:22.04 AS qemu-provider
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
ARG TARGETARCH
ARG QEMU_RELEASE_URL=https://github.com/davidmonterocrespo24/velxio/releases/download/qemu-prebuilt 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) # Copy the prebuilt directory (may contain .so+ROM files or just the .gitkeep)
RUN mkdir -p /qemu RUN mkdir -p /qemu
COPY prebuilt/qemu/ /qemu/ COPY prebuilt/qemu/ /qemu/
# Download any missing files from GitHub Release # Download arch-specific .so and arch-independent ROM files
RUN cd /qemu \ 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 \ 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" ; \ curl -fSL -o "$f" "${QEMU_RELEASE_URL}/$f" ; \
else \ else \
echo "Using local $f ($(stat -c%s "$f") bytes)" ; \ echo "Using local $f ($(stat -c%s "$f") bytes)" ; \

View File

@ -120,26 +120,51 @@ describe('PartSimulationRegistry — registration', () => {
// ─── LED ───────────────────────────────────────────────────────────────────── // ─── LED ─────────────────────────────────────────────────────────────────────
describe('LED — onPinStateChange', () => { describe('LED — attachEvents (anode + cathode check)', () => {
it('sets element.value = true when anode pin goes HIGH', () => { it('LED turns on when anode HIGH and cathode wired to GND', () => {
const logic = PartSimulationRegistry.get('led')!; const logic = PartSimulationRegistry.get('led')!;
const el = makeElement({ value: false }); 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); 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 logic = PartSimulationRegistry.get('led')!;
const el = makeElement({ value: true }); const el = makeElement({ value: false });
logic.onPinStateChange!('A', false, el); 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); 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 logic = PartSimulationRegistry.get('led')!;
const el = makeElement({ value: false }); const el = makeElement({ value: false });
logic.onPinStateChange!('K', true, el); const sim = makeSimulator();
expect((el as any).value).toBe(false); // unchanged 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);
}); });
}); });

View File

@ -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', { PartSimulationRegistry.register('led', {
onPinStateChange: (pinName, state, element) => { attachEvents: (element, simulator, getArduinoPinHelper) => {
if (pinName === 'A') { // Anode const pinManager = (simulator as any).pinManager;
(element as any).value = state; 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()); };
},
}); });
/** /**

@ -1 +1 @@
Subproject commit 4694eb508ed17d7166b51f5e62c04167f3f8cc19 Subproject commit 092ad4333d148ac57d28035ee5ae3e8f9b819313