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
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 }}

View File

@ -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)" ; \

View File

@ -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);
});
});

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', {
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()); };
},
});
/**

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