Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
using System.Security.Cryptography;

namespace RP2040Sharp.IntegrationTests.Infrastructure;

/// <summary>
/// Downloads and caches MicroPython and CircuitPython UF2 firmware images.
/// Firmware is stored in a local cache directory so subsequent test runs are offline-capable.
///
/// Images listed in <see cref="FirmwareManifest"/> are verified by SHA-256 on every use: a cached or
/// freshly-downloaded file whose hash does not match is deleted and a hard error is raised (upstream
/// reissued the build, or the cache is corrupt) — never a silent skip that would boot the wrong bytes.
/// </summary>
public static class FirmwareCache
{
Expand All @@ -18,15 +24,16 @@ public static class FirmwareCache
{
Directory.CreateDirectory(CacheDir);

var path = Path.Combine(CacheDir, $"micropython-{version}.uf2");
var key = $"micropython-{version}";
var path = Path.Combine(CacheDir, $"{key}.uf2");
if (File.Exists(path) && new FileInfo(path).Length > 0)
return path;
return VerifyOrThrow(path, key);

// Prefer firmware embedded in the test assembly — offline and free of network flakiness.
if (TryLoadEmbedded($"micropython-{version}") is { } embedded)
if (TryLoadEmbedded(key) is { } embedded)
{
await File.WriteAllBytesAsync(path, embedded);
return path;
return VerifyOrThrow(path, key);
}

try
Expand All @@ -40,7 +47,7 @@ public static class FirmwareCache

var bytes = await http.GetByteArrayAsync(url);
await File.WriteAllBytesAsync(path, bytes);
return path;
return VerifyOrThrow(path, key);
}
catch
{
Expand Down Expand Up @@ -106,16 +113,17 @@ public static class FirmwareCache
{
Directory.CreateDirectory(CacheDir);

var path = Path.Combine(CacheDir, $"circuitpython-{version}.uf2");
var key = $"circuitpython-{version}";
var path = Path.Combine(CacheDir, $"{key}.uf2");
if (File.Exists(path) && new FileInfo(path).Length > 0)
return path;
return VerifyOrThrow(path, key);

// Prefer firmware embedded in the test assembly — offline and free of network flakiness.
var embeddedTag = version.StartsWith('v') ? version[1..] : version;
if (TryLoadEmbedded($"circuitpython-{embeddedTag}") is { } embedded)
{
await File.WriteAllBytesAsync(path, embedded);
return path;
return VerifyOrThrow(path, key);
}

try
Expand All @@ -130,7 +138,7 @@ public static class FirmwareCache

var bytes = await http.GetByteArrayAsync(url);
await File.WriteAllBytesAsync(path, bytes);
return path;
return VerifyOrThrow(path, key);
}
catch
{
Expand Down Expand Up @@ -161,4 +169,65 @@ public static class FirmwareCache
var path = Path.Combine(CacheDir, $"circuitpython-{tag}.uf2");
return File.Exists(path) && new FileInfo(path).Length > 0 ? path : null;
}

/// <summary>
/// Returns the local path to the wireless RPI_PICO_W MicroPython UF2 for <paramref name="version"/>
/// (bundles the CYW43 WLAN/BT firmware the Pico-W tests need), downloading it from the pinned
/// <see cref="FirmwareManifest.PicoWUrl"/> if not cached. Returns <c>null</c> when the version has no
/// known URL or the download fails (offline). Throws on a SHA-256 mismatch.
/// </summary>
public static async Task<string?> GetMicroPythonPicoWAsync(string version)
{
Directory.CreateDirectory(CacheDir);

var key = $"micropython-picow-{version}";
var path = Path.Combine(CacheDir, $"{key}.uf2");
if (File.Exists(path) && new FileInfo(path).Length > 0)
return VerifyOrThrow(path, key);

var url = FirmwareManifest.PicoWUrl(version);
if (url is null) return null;

try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("RP2040Sharp-IntegrationTests/1.0");
var bytes = await http.GetByteArrayAsync(url);
await File.WriteAllBytesAsync(path, bytes);
return VerifyOrThrow(path, key);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
if (File.Exists(path)) File.Delete(path);
return null;
}
}

/// <summary>
/// Verifies a cached file against its <see cref="FirmwareManifest"/> pin. Unpinned images pass through
/// unchecked (forward-compatibility). A mismatch deletes the bad copy and throws — it must be loud, not
/// a silent skip, because the file is not the build the tests were written against.
/// </summary>
private static string VerifyOrThrow(string path, string cacheKey)
{
var pin = FirmwareManifest.PinFor(cacheKey);
if (pin is null) return path; // not pinned — download unverified

var actual = Sha256OfFile(path);
if (!actual.Equals(pin.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(path);
throw new InvalidOperationException(
$"Firmware upstream changed for '{cacheKey}': expected SHA-256 {pin.Sha256} but got " +
$"{actual}. Re-pin the hash/size in FirmwareManifest after reviewing the new build " +
"(deleted the bad cache copy).");
}
return path;
}

private static string Sha256OfFile(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexStringLower(SHA256.HashData(stream));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace RP2040Sharp.IntegrationTests.Infrastructure;

/// <summary>
/// SHA-256 + size pin for one cached firmware image, keyed by its cache-file stem
/// (e.g. "micropython-v1.21.0", "circuitpython-9.2.1", "micropython-picow-v1.21.0").
/// </summary>
public sealed record FirmwarePin(string Sha256, long SizeBytes);

/// <summary>
/// The versioned manifest of firmware images <see cref="FirmwareCache"/> downloads. Every hash was
/// computed from a real GET of the pinned build; a cached/downloaded file whose SHA-256 does not match
/// is rejected loudly (upstream reissued the build, or the cache is corrupt) rather than booted silently.
///
/// A version that is not listed here downloads unverified (kept for forward-compatibility when a test
/// bumps to a new version before its hash is pinned) — pin it here to get verification.
/// </summary>
public static class FirmwareManifest
{
private static readonly Dictionary<string, FirmwarePin> Pins = new()
{
// MicroPython, RPI_PICO board — https://micropython.org/download/RPI_PICO/
["micropython-v1.19.1"] = new("958ad98a21a036a529c0b17bad1e0223e80cdd4b089cd5596d14966760ab3c3f", 609792),
["micropython-v1.20.0"] = new("d9d97d8b495da476006125e73dc203a428ce7b6be27a5a76eda1dd85b2efe99d", 638464),
["micropython-v1.21.0"] = new("a1166281fd87886e5d755e577e8eaf207881e20dac1d76f82161f37133547be3", 636928),
// MicroPython, RPI_PICO_W board (bundles the CYW43 WLAN/BT firmware).
["micropython-picow-v1.21.0"] = new("1c7deb8409da29974e8cfb06d3798fd137ea682ca0785e5aec84bdbf0c017c96", 1604608),
// CircuitPython, raspberry_pi_pico board — https://circuitpython.org/board/raspberry_pi_pico/
["circuitpython-9.2.1"] = new("b23e50784711101d6fd9958778f5541ea0781e9eb317dfe1d775959e80512bd6", 1769472),
};

// Official RPI_PICO_W MicroPython UF2 (per version) for the CYW43 tests, which need the wireless build.
private static readonly Dictionary<string, string> PicoWUrls = new()
{
["v1.21.0"] = "https://micropython.org/resources/firmware/RPI_PICO_W-20231005-v1.21.0.uf2",
};

/// <summary>The pin for a cache-file stem, or null if that image is not pinned (downloads unverified).</summary>
public static FirmwarePin? PinFor(string cacheKey) => Pins.GetValueOrDefault(cacheKey);

/// <summary>The canonical RPI_PICO_W UF2 URL for a MicroPython version, or null if none is known.</summary>
public static string? PicoWUrl(string version) => PicoWUrls.GetValueOrDefault(version);
}
6 changes: 3 additions & 3 deletions tests/RP2040Sharp.IntegrationTests/Tests/Cyw43BleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ public void OnInstruction(uint pc, ushort opcode, long cycles)
/// </summary>
public class Cyw43BleTests(ITestOutputHelper output)
{
private const string PicoW = "/Users/begeistert/Repos/micropython/ports/rp2/build-RPI_PICO_W/firmware.uf2";
private static readonly string? PicoW = FirmwareCache.GetMicroPythonPicoWAsync("v1.21.0").GetAwaiter().GetResult();

[Fact(Skip = "Diagnostic-only USB-DCD trace.")]
public void Diag_usb_timeline()
{
if (!File.Exists(PicoW)) { output.WriteLine("skip"); return; }
using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW)));
using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW!)));
sim.Rp2040.Pio0.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
sim.Rp2040.Pio1.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
sim.Rp2040.Sio.OnGpioChanged += () => sim.Rp2040.IoBank0.NotifyPads(0xFFFFFFFFu);
Expand All @@ -87,7 +87,7 @@ public void Diag_usb_timeline()
public void Ble_controller_brings_up()
{
if (!File.Exists(PicoW)) { output.WriteLine("skip"); return; }
using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW)));
using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW!)));
sim.Rp2040.Pio0.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
sim.Rp2040.Pio1.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
sim.Rp2040.Sio.OnGpioChanged += () => sim.Rp2040.IoBank0.NotifyPads(0xFFFFFFFFu);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ namespace RP2040Sharp.IntegrationTests.Tests;
/// </summary>
public class Cyw43HttpRxTests(ITestOutputHelper output)
{
private const string PicoW = "/Users/begeistert/Repos/micropython/ports/rp2/build-RPI_PICO_W/firmware.uf2";
private static readonly string? PicoW = FirmwareCache.GetMicroPythonPicoWAsync("v1.21.0").GetAwaiter().GetResult();

[Fact]
public void Guest_http_server_is_reachable_over_the_virtual_network()
{
if (!File.Exists(PicoW)) { output.WriteLine("skip"); return; }
using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW)));
using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW!)));
sim.Rp2040.Pio0.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
sim.Rp2040.Pio1.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
sim.Rp2040.Sio.OnGpioChanged += () => sim.Rp2040.IoBank0.NotifyPads(0xFFFFFFFFu);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ namespace RP2040Sharp.IntegrationTests.Tests;
/// </summary>
public class Cyw43WifiTests(ITestOutputHelper output)
{
// WLAN bring-up completes only against a specific local RPI_PICO_W build, not the official
// micropython.org UF2 (see Skip below), so this keeps the local-build path rather than a download.
private const string PicoW = "/Users/begeistert/Repos/micropython/ports/rp2/build-RPI_PICO_W/firmware.uf2";

[Fact]
[Fact(Skip = "WiFi bring-up is incomplete: against the official RPI_PICO_W v1.21.0 UF2, WLAN.active(True) " +
"returns False (the GSpiSlave host-wake / CLM-load path is still in progress). It only " +
"passes against a local build, so it is not wired to a download it would fail on. BLE and " +
"HTTP-RX on the same chip do pass against the official firmware (see Cyw43BleTests / Cyw43HttpRxTests).")]
public void Wlan_brings_up_and_scans()
{
if (!File.Exists(PicoW)) { output.WriteLine("skip"); return; }
Expand Down
Loading