feat: add per-second GCP-format trials with history, watch and HTTP access (FPGA v10)

This commit is contained in:
randogoth 2026-10-25 18:23:18 +02:00
parent d821b77d3b
commit 6bee626b7f
12 changed files with 475 additions and 29 deletions

View file

@ -13,6 +13,7 @@ Entropy comes from FPGA ring oscillators whose sampled timing jitter is XOR-comb
- Live statistics per trial: bias, z-score, random walk and lag-1 autocorrelation, with rolling and cumulative windows
- Configurable random-walk / majority-vote bias amplifier on a bias path and an autocorrelation path, with sub-trial voting
- Von Neumann extractor that can be toggled on the stream, statistics and amplifier paths
- Per-second trials in Global Consciousness Project format (200-bit XOR-masked sums per generator, plus full-rate sums), with Stouffer Z and network variance
- Raw ring captures and frequency measurement for characterization
The USB, HTTP and BLE API is documented in [arduino/strix/API.md](arduino/strix/API.md).

View file

@ -1,4 +1,4 @@
// STRIX FPGA register map (FPGA version 9). Mirrors the definitions in fpga/rtl/strix_core.v,
// STRIX FPGA register map (FPGA version 10). Mirrors the definitions in fpga/rtl/strix_core.v,
// which also documents the SPI protocol and snapshot layouts.
#pragma once
@ -6,7 +6,7 @@
namespace strix {
const uint8_t FPGA_VERSION = 9;
const uint8_t FPGA_VERSION = 10;
const uint8_t READ = 0x80; // command bit 7: read
enum Register : uint8_t {
@ -47,6 +47,7 @@ enum Register : uint8_t {
REG_STATS = 0x40,
REG_AMP = 0x42,
REG_STREAM = 0x44,
REG_GCP = 0x46,
REG_AMP_CTRL = 0x50,
REG_AMP_SOURCES = 0x51,
REG_AMP_SUB_MS = 0x52,
@ -71,6 +72,7 @@ const uint8_t GENERATORS = 4;
const uint8_t STATS_CHANNELS = GENERATORS + 1; // generators, then the combined channel
const uint8_t STATS_SNAPSHOT_BYTES = 72;
const uint8_t AMP_SNAPSHOT_BYTES = 44;
const uint8_t GCP_SNAPSHOT_BYTES = 36;
const size_t CAPTURE_BYTES = 32768;
} // namespace strix

View file

@ -22,9 +22,11 @@ The firmware speaks a line protocol over the board's USB serial port (115200 bau
| `stats` | Latest statistics trial: per channel (`g0`–`g3`, `combined`) bits, ones, bias z and lag-1 autocorrelation z for the trial, the rolling window and the cumulative total (with random walk position). Also trials counted and trials missed. |
| `stats reset` | Restarts trials and clears the rolling and cumulative windows |
| `amp` | Latest bias amplifier trial: per path (`bias`, `autocorr`) amplified bits, ones, z, sub-trial votes (`1`, `0`, `.` for no vote) and the trial result (`null` on a tie) |
| `gcp` | Latest per-second trial (see below) |
| `gcp <n>` | `{"records":[...]}`: all kept trials after second `n`, oldest first (the last 60 seconds are kept) |
| `get` | All settings |
| `set <key> <value>` | Changes a setting (see below) |
| `watch stats` / `watch amp` / `watch off` | Pushes the `stats` or `amp` reply after every new trial |
| `watch stats` / `watch amp` / `watch gcp` / `watch off` | Pushes the `stats` or `amp` reply after every new trial, or every new per-second trial as one line, to the transport that sent the command (USB, or BLE reply notifications). A BLE watch ends when the connection closes. |
## Settings
@ -47,6 +49,23 @@ The firmware speaks a line protocol over the board's USB serial port (115200 bau
Changing an `amp_*` setting restarts the amplifier.
## Per-second trials (GCP format)
Independent of all settings, the FPGA produces one trial per generator every second, in the form used by Global Consciousness Project style network analyses. Bit i of each second is inverted when i is odd (an XOR mask that cancels a constant bias), and:
- `sample`: the sum of the first 200 masked bits of the second, the GCP trial (expected 100, standard deviation 7.071);
- `full`: the sum of all masked bits of the second, about 24 million at the default `temporal`.
```
{"second":812,"healthy":15,
"sample":{"bits":200,"sums":[103,96,99,108],"z":[0.424,-0.566,-0.141,1.131],"stouffer":0.424,"netvar":0.180},
"full":{"bits":24000000,"sums":[...],"z":[...],"stouffer":...,"netvar":...}}
```
`second` counts seconds since the FPGA started; a gap means seconds were lost (the firmware also records seconds during long entropy transfers). `z` is (2 · sum − bits) / √bits, `stouffer` is Σz / √n over the generators that were healthy for the whole second (`healthy`, bit g), and `netvar` is its square, χ² with one degree of freedom. Unhealthy generators show `null`. The device has no clock, so clients timestamp the trials; the cumulative sum of `netvar` − 1 and its probability envelopes are left to the client.
The four generators share one chip and power supply, unlike the separate devices of a GCP network. While a radio runs, only the generators in `radio_generators` (default 2) take part.
## WiFi
Without a USB host and on external power (wall charger), the device starts WiFi by itself: an access point named `Strix` (WPA2, password `owl12345678`) at `192.168.4.1`, with an HTTP server on port 80. With a computer attached, WiFi stays off until `wifi start`. On weak USB power the radio's current bursts plus the ring oscillators can reset the board.
@ -58,10 +77,11 @@ Until WiFi is started, the FPGA holds the WiFi module in reset.
| `/entropy?bytes=<n>[&format=hex\|bin]` | `entropy <n> [hex\|bin]`. `bin` is sent as `application/octet-stream`, without the `BIN` line. |
| `/?length=<n>` | `entropy <n> hex` (compatible with the earlier STRIX sketch) |
| `/id`, `/health`, `/stats`, `/amp`, `/wifi` | same command |
| `/gcp[?since=<n>]` | `gcp [<n>]`. Polling with `since` set to the last second received loses no seconds. |
| `/config` | `get` |
| `/set?key=<key>&value=<value>` | `set <key> <value>` |
Replies have status 200 (errors are JSON `{"error":...}`), or 404 for unknown paths, and allow cross-origin requests. `watch` is USB-only.
Replies have status 200 (errors are JSON `{"error":...}`), or 404 for unknown paths, and allow cross-origin requests. `watch` works over USB and BLE only.
## Bluetooth LE
@ -72,7 +92,7 @@ Service `53545249-5800-4000-8000-000000000001` with three characteristics:
| Characteristic | UUID suffix | Properties | Use |
|---|---|---|---|
| command | `…0002` | write | One USB command per write (text, up to 128 bytes) |
| reply | `…0003` | notify | The command's reply in chunks of up to 20 bytes. Every reply ends with a newline, so concatenate notifications until `\n`. |
| reply | `…0003` | notify | The command's reply in chunks of up to 20 bytes. Every reply ends with a newline, so concatenate notifications until `\n`. Also carries `watch` output, such as one line per second after `watch gcp`. |
| entropy | `…0004` | read, notify | Each read returns 64 fresh bytes from the entropy stream. Subscribers receive new bytes about every 50 ms (each notification carries up to the connection's ATT payload, 20 bytes by default). An empty value means the stream had no fresh bytes; bytes are never sent twice. |
`tools/ble_check.py` exercises all three from a computer with Bluetooth.

View file

@ -1,6 +1,6 @@
// STRIX firmware for the Arduino MKR Vidor 4000: boots the STRIX FPGA image and serves
// entropy, health, live statistics and bias amplifier trials over USB serial, over HTTP on its
// own WiFi access point, and over Bluetooth LE.
// entropy, health, live statistics, bias amplifier trials and per-second GCP-style trials over
// USB serial, over HTTP on its own WiFi access point, and over Bluetooth LE.
//
// USB protocol: one command per line; replies are single JSON lines, except `entropy`, which
// replies with hex text or a "BIN <n>" header followed by n raw bytes. HTTP and BLE carry the
@ -13,7 +13,7 @@
using namespace strix;
const char FIRMWARE_VERSION[] = "0.3.0";
const char FIRMWARE_VERSION[] = "0.4.0";
const unsigned long POLL_MS = 10;
const unsigned long ENTROPY_TIMEOUT_MS = 2000;
const unsigned long USB_HOST_WAIT_MS = 1500;
@ -21,13 +21,16 @@ const unsigned long ENTROPY_NOTIFY_MS = 50; // BLE entropy stream interval
const size_t MAX_ENTROPY_BYTES = 65536;
const size_t CHUNK_BYTES = 256;
const uint8_t MAX_ROLLING = 50;
const uint8_t GCP_HISTORY = 60; // seconds kept for clients that poll
const char *CHANNEL_NAMES[STATS_CHANNELS] = {"g0", "g1", "g2", "g3", "combined"};
const char *PATH_NAMES[2] = {"bias", "autocorr"};
const char WIFI_SSID[] = "Strix";
const char WIFI_PASSWORD[] = "owl12345678"; // WPA2, at least 8 characters
// Replies go to *out: USB serial, or the HTTP response while one is being served.
// Replies go to *out: USB serial, or the HTTP response or BLE reply while one is being served.
Print *out = &Serial;
enum Transport { TRANSPORT_USB, TRANSPORT_HTTP, TRANSPORT_BLE };
Transport transport = TRANSPORT_USB; // where the command being handled came from
// USB binary entropy is announced with a "BIN <n>" line; HTTP sends the bytes alone.
bool binaryHeader = true;
@ -40,8 +43,6 @@ bool externalPower = true; // charger or USB power present at boot (from the ch
// more than the board's supply delivers. Two generators still far exceed what WiFi or BLE carry.
uint8_t radioGenerators = 0x03;
bool radioActive() { return wifiReady || bleReady; }
// BLE: commands are written to `command` as text lines; replies arrive as notifications on `reply`
// in chunks, each reply ending with a newline. `entropy` holds fresh bytes for every read and
// streams them to subscribers.
@ -99,11 +100,25 @@ struct AmpTrial {
AmpTrial latestAmp;
bool haveAmp = false;
enum Watch { WATCH_OFF, WATCH_STATS, WATCH_AMP };
// Per-second GCP-style trials, oldest first from gcpNext - gcpCount.
struct GcpSecond {
uint32_t second, bits;
uint8_t healthy, sampleBits;
uint32_t sampleSums[GENERATORS], ones[GENERATORS];
};
GcpSecond gcpHistory[GCP_HISTORY];
uint8_t gcpCount = 0, gcpNext = 0;
enum Watch { WATCH_OFF, WATCH_STATS, WATCH_AMP, WATCH_GCP };
Watch watching = WATCH_OFF;
Transport watchTransport = TRANSPORT_USB;
uint32_t watchedSecond = 0; // last second pushed by `watch gcp`
// Helpers
bool radioActive() { return wifiReady || bleReady; }
uint32_t word32(const uint8_t *bytes) {
return (uint32_t)bytes[0] << 24 | (uint32_t)bytes[1] << 16 | (uint32_t)bytes[2] << 8 | bytes[3];
}
@ -196,6 +211,7 @@ void printStats();
void printAmp();
void printWifi();
void printBle();
void emitWatched(void (*print)());
void pollStats() {
uint8_t raw[STATS_SNAPSHOT_BYTES];
@ -226,7 +242,7 @@ void pollStats() {
if (historyCount < MAX_ROLLING) historyCount++;
trialsCounted++;
haveStats = true;
if (watching == WATCH_STATS) printStats();
if (watching == WATCH_STATS) emitWatched(printStats);
}
void pollAmp() {
@ -253,7 +269,34 @@ void pollAmp() {
path.valid = b[13] & 2;
}
haveAmp = true;
if (watching == WATCH_AMP) printAmp();
if (watching == WATCH_AMP) emitWatched(printAmp);
}
const GcpSecond *gcpAt(uint8_t age) { return &gcpHistory[(gcpNext + GCP_HISTORY - 1 - age) % GCP_HISTORY]; }
// Records each completed second. The FPGA keeps only the latest, so this also runs during long
// entropy transfers.
void pollGcp() {
static unsigned long lastPoll = 0;
if (millis() - lastPoll < POLL_MS) return;
lastPoll = millis();
uint8_t raw[GCP_SNAPSHOT_BYTES];
fpga.readBurst(REG_GCP, raw, GCP_SNAPSHOT_BYTES);
uint32_t second = word32(raw);
if (second == 0 || second != word32(raw + GCP_SNAPSHOT_BYTES - 4)) return; // none yet, or torn
if (gcpCount && gcpAt(0)->second == second) return;
GcpSecond &record = gcpHistory[gcpNext];
record.second = second;
record.healthy = raw[4];
record.sampleBits = raw[5];
record.bits = word32(raw + 8);
for (uint8_t g = 0; g < GENERATORS; g++) {
record.sampleSums[g] = raw[12 + 5 * g];
record.ones[g] = word32(raw + 13 + 5 * g);
}
gcpNext = (gcpNext + 1) % GCP_HISTORY;
if (gcpCount < GCP_HISTORY) gcpCount++;
}
// Commands
@ -397,6 +440,93 @@ void printAmp() {
out->println("]}");
}
// One set of per-generator trial sums of `bits` bits each, with z-scores, the Stouffer Z of the
// healthy generators and the network variance Z^2. Unhealthy generators show null.
void printTrialSet(uint32_t bits, const uint32_t *sums, uint8_t healthy) {
double zSum = 0;
uint8_t used = 0;
out->print("{\"bits\":");
out->print(bits);
out->print(",\"sums\":[");
for (uint8_t g = 0; g < GENERATORS; g++) {
if (g) out->print(',');
if (healthy >> g & 1) {
out->print(sums[g]);
} else {
out->print("null");
}
}
out->print("],\"z\":[");
for (uint8_t g = 0; g < GENERATORS; g++) {
if (g) out->print(',');
if (healthy >> g & 1 && bits) {
double z = (2.0 * sums[g] - bits) / sqrt(bits);
zSum += z;
used++;
out->print(z, 3);
} else {
out->print("null");
}
}
out->print("],\"stouffer\":");
if (used) {
double stouffer = zSum / sqrt(used);
out->print(stouffer, 3);
out->print(",\"netvar\":");
out->print(stouffer * stouffer, 3);
} else {
out->print("null,\"netvar\":null");
}
out->print('}');
}
void printGcpSecond(const GcpSecond &record) {
out->print("{\"second\":");
out->print(record.second);
out->print(",\"healthy\":");
out->print(record.healthy);
out->print(",\"sample\":");
printTrialSet(record.sampleBits, record.sampleSums, record.healthy);
out->print(",\"full\":");
printTrialSet(record.bits, record.ones, record.healthy);
out->print('}');
}
// `gcp`: the latest second. `gcp <n>`: all kept seconds after second n, oldest first.
void printGcp(const String &since) {
if (!gcpCount) {
printError("no second completed yet");
return;
}
if (!since.length()) {
printGcpSecond(*gcpAt(0));
out->println();
return;
}
uint32_t after = strtoul(since.c_str(), nullptr, 10);
bool first = true;
out->print("{\"records\":[");
for (int age = gcpCount - 1; age >= 0; age--) {
const GcpSecond *record = gcpAt(age);
if (record->second <= after) continue;
if (!first) out->print(',');
first = false;
printGcpSecond(*record);
}
out->println("]}");
}
// Pushes the seconds completed since the last push, one line each.
void printNewGcp() {
for (int age = gcpCount - 1; age >= 0; age--) {
const GcpSecond *record = gcpAt(age);
if (record->second <= watchedSecond) continue;
printGcpSecond(*record);
out->println();
watchedSecond = record->second;
}
}
// Streams `count` entropy bytes as hex or binary. Stops early (binary: short output, hex: an
// error line) if the selected source stops delivering.
void sendEntropy(size_t count, bool binary) {
@ -425,8 +555,10 @@ void sendEntropy(size_t count, bool binary) {
}
return;
}
pollGcp();
continue;
}
pollGcp();
size_t length = count - sent;
if (length > level) length = level;
if (length > CHUNK_BYTES) length = CHUNK_BYTES;
@ -606,12 +738,20 @@ void handleCommand(String line) {
}
} else if (command == "amp") {
printAmp();
} else if (command == "gcp") {
printGcp(first);
} else if (command == "get") {
printConfig();
} else if (command == "set" && first.length() && rest.length()) {
setConfig(first, rest);
} else if (command == "watch" && (first == "stats" || first == "amp" || first == "off")) {
watching = first == "stats" ? WATCH_STATS : first == "amp" ? WATCH_AMP : WATCH_OFF;
} else if (command == "watch" && (first == "stats" || first == "amp" || first == "gcp" || first == "off")) {
if (transport == TRANSPORT_HTTP) {
printError("watch needs USB or BLE");
return;
}
watching = first == "stats" ? WATCH_STATS : first == "amp" ? WATCH_AMP : first == "gcp" ? WATCH_GCP : WATCH_OFF;
watchTransport = transport;
watchedSecond = gcpCount ? gcpAt(0)->second : 0;
printOk();
} else {
printError("unknown command");
@ -711,6 +851,7 @@ String httpCommand(const String &path, const String &query, const char *&type) {
if (path == "/id" || path == "/health" || path == "/stats" || path == "/amp" || path == "/wifi") {
return path.substring(1);
}
if (path == "/gcp") return "gcp " + queryParam(query, "since");
if (path == "/config") return "get";
if (path == "/set") return "set " + queryParam(query, "key") + " " + queryParam(query, "value");
return "";
@ -742,6 +883,7 @@ void serveHttp() {
response.print("\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n");
out = &response;
transport = TRANSPORT_HTTP;
binaryHeader = false;
if (command.length()) {
handleCommand(command);
@ -749,6 +891,7 @@ void serveHttp() {
printError("not found");
}
out = &Serial;
transport = TRANSPORT_USB;
binaryHeader = true;
response.flush();
client.stop();
@ -838,6 +981,21 @@ void startBle() {
bleReady = true;
}
// Sends watch output to the transport that started the watch; a BLE watch ends with the connection.
void emitWatched(void (*print)()) {
BleReply reply;
if (watchTransport == TRANSPORT_BLE) {
if (!bleReady || !BLE.connected()) {
watching = WATCH_OFF;
return;
}
out = &reply;
}
print();
out = &Serial;
reply.flush();
}
void printBle() {
out->print("{\"ble\":");
printBool(bleReady);
@ -856,9 +1014,11 @@ void serveBle() {
String command((const char *)bleCommand.value(), bleCommand.valueLength());
BleReply reply;
out = &reply;
transport = TRANSPORT_BLE;
binaryHeader = true;
handleCommand(command);
out = &Serial;
transport = TRANSPORT_USB;
reply.flush();
}
// Stream entropy to subscribers, one notification per interval.
@ -944,6 +1104,8 @@ void loop() {
lastPoll = millis();
pollStats();
pollAmp();
pollGcp();
if (watching == WATCH_GCP) emitWatched(printNewGcp);
// LED on while every enabled generator is healthy
uint8_t enabled = fpga.read(REG_GEN_ENABLE);
digitalWrite(LED_BUILTIN, enabled && (fpga.read(REG_HEALTHY) & enabled) == enabled);

View file

@ -22,7 +22,7 @@
],
"sim": [
"mkdir -p /tmp/strix-sim",
"iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/entropy_generator.v fpga/rtl/health_monitor.v fpga/rtl/stats_block.v fpga/rtl/entropy_stream.v fpga/rtl/bias_amplifier.v fpga/rtl/amp_path.v fpga/rtl/amp_stage.v fpga/rtl/freq_counter.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v",
"iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/entropy_generator.v fpga/rtl/health_monitor.v fpga/rtl/stats_block.v fpga/rtl/entropy_stream.v fpga/rtl/bias_amplifier.v fpga/rtl/amp_path.v fpga/rtl/amp_stage.v fpga/rtl/gcp_block.v fpga/rtl/freq_counter.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v",
"vvp -n /tmp/strix-sim/tb_strix_core",
"iverilog -g2005 -o /tmp/strix-sim/tb_amp_stage fpga/sim/tb_amp_stage.v fpga/rtl/amp_stage.v",
"vvp -n /tmp/strix-sim/tb_amp_stage"

131
fpga/rtl/gcp_block.v Normal file
View file

@ -0,0 +1,131 @@
// Per-second trials in the format of Global Consciousness Project (GCP) style network analyses,
// running alongside the other outputs. Every TRIAL_MS milliseconds (one second) each generator
// yields:
// - the sum of the first SAMPLE_BITS bits of the second, the GCP trial (expected 100, sd 7.07);
// - the sum of all its bits during the second, for a full-rate z-score.
// Both use XOR-masked bits: bit i of the second is inverted when i is odd, so a constant bias
// cancels. All generators deliver in lockstep, so one bit count serves them all; generators that
// were not healthy for the whole second are flagged in the snapshot, not excluded from counting.
//
// Snapshot layout (big-endian), latched at the end of each second:
// 0-3 sequence number, starting at 1 (0: no second completed yet)
// 4 generators healthy during the whole second (bit g)
// 5 SAMPLE_BITS
// 6-7 0
// 8-11 bits per generator during the second
// 12+5g generator g: sample sum (1), masked ones during the second (4)
// 32-35 sequence number again; a mismatch with bytes 0-3 means a torn read
module gcp_block #(
parameter GENERATORS = 4,
parameter SAMPLE_BITS = 200,
parameter TRIAL_MS = 1000,
parameter TICK_CYCLES = 120000 // clk cycles per millisecond
) (
input wire clk,
input wire reset,
input wire [GENERATORS-1:0] gen_bit,
input wire gen_valid,
input wire [GENERATORS-1:0] healthy,
input wire [7:0] read_index,
output reg [7:0] read_byte, // one cycle after read_index
output reg trial_end // pulse: a new snapshot is available
);
localparam SNAPSHOT_BYTES = 12 + 5 * GENERATORS + 4;
localparam [7:0] SAMPLE_SIZE = SAMPLE_BITS;
// Trial timing
reg [16:0] tick_count;
reg [15:0] ms_count;
wire tick = (tick_count == TICK_CYCLES - 1);
always @(posedge clk) begin
trial_end <= 1'b0;
if (reset) begin
tick_count <= 17'd0;
ms_count <= 16'd0;
end else begin
tick_count <= tick ? 17'd0 : tick_count + 17'd1;
if (tick && ms_count == TRIAL_MS - 1) begin
ms_count <= 16'd0;
trial_end <= 1'b1;
end else if (tick) begin
ms_count <= ms_count + 16'd1;
end
end
end
// Counters. As in stats_block, the bit arriving in the trial_end cycle opens the next second.
reg [31:0] bit_count;
reg [7:0] sampled; // bits taken into the sample sums this second
reg odd; // the next bit has an odd index in the second
reg [7:0] sample_sum [0:GENERATORS-1];
reg [31:0] masked_ones [0:GENERATORS-1];
reg [31:0] sequence_number;
reg [GENERATORS-1:0] healthy_during;
wire mask = trial_end ? 1'b0 : odd;
wire in_sample = trial_end || sampled < SAMPLE_BITS;
wire [GENERATORS-1:0] masked = gen_bit ^ {GENERATORS{mask}};
reg [8*SNAPSHOT_BYTES-1:0] snapshot_next;
reg [7:0] snapshot [0:SNAPSHOT_BYTES-1];
integer k;
always @(*) begin
snapshot_next = {SNAPSHOT_BYTES{8'h00}};
snapshot_next[8*SNAPSHOT_BYTES-1 -: 96] = {sequence_number,
{8-GENERATORS{1'b0}}, healthy_during & healthy,
SAMPLE_SIZE, 16'd0, bit_count};
for (k = 0; k < GENERATORS; k = k + 1)
snapshot_next[8*SNAPSHOT_BYTES-1-96-40*k -: 40] = {sample_sum[k], masked_ones[k]};
snapshot_next[31:0] = sequence_number;
end
integer g, b;
always @(posedge clk) begin
if (reset) begin
bit_count <= 32'd0;
sampled <= 8'd0;
odd <= 1'b0;
sequence_number <= 32'd1;
healthy_during <= {GENERATORS{1'b1}};
for (g = 0; g < GENERATORS; g = g + 1) begin
sample_sum[g] <= 8'd0;
masked_ones[g] <= 32'd0;
end
end else begin
if (gen_valid) begin
bit_count <= (trial_end ? 32'd0 : bit_count) + 32'd1;
sampled <= in_sample ? (trial_end ? 8'd0 : sampled) + 8'd1 : sampled;
odd <= ~mask;
for (g = 0; g < GENERATORS; g = g + 1) begin
sample_sum[g] <= (trial_end ? 8'd0 : sample_sum[g]) + (in_sample && masked[g]);
masked_ones[g] <= (trial_end ? 32'd0 : masked_ones[g]) + masked[g];
end
end else if (trial_end) begin
bit_count <= 32'd0;
sampled <= 8'd0;
odd <= 1'b0;
for (g = 0; g < GENERATORS; g = g + 1) begin
sample_sum[g] <= 8'd0;
masked_ones[g] <= 32'd0;
end
end
if (trial_end) begin
for (b = 0; b < SNAPSHOT_BYTES; b = b + 1)
snapshot[b] <= snapshot_next[8*(SNAPSHOT_BYTES-1-b) +: 8];
sequence_number <= sequence_number + 32'd1;
healthy_during <= healthy;
end else begin
healthy_during <= healthy_during & healthy;
end
end
end
always @(posedge clk)
read_byte <= (read_index < SNAPSHOT_BYTES) ? snapshot[read_index] : 8'h00;
endmodule

View file

@ -1,5 +1,5 @@
// Board-independent STRIX logic: entropy generators with health monitors, entropy byte
// stream, live statistics, bias amplifier, source selection, raw capture, frequency counter
// stream, live statistics, bias amplifier, per-second GCP-style trials, source selection, raw capture, frequency counter
// and the SPI register interface to the SAMD21.
//
// SPI protocol (mode 0, one transaction per ss_n low period): the first byte is a command,
@ -20,7 +20,8 @@
// Statistics channels 0-3 are the generators' processed outputs, channel 4 is the XOR of the
// healthy generators' outputs. Reading STATS returns the latest trial snapshot (layout in
// stats_block.v) byte by byte, starting at byte 0 in every transaction. AMP does the same for
// the bias amplifier's trial snapshot (layout in bias_amplifier.v). Reading STREAM returns
// the bias amplifier's trial snapshot (layout in bias_amplifier.v), and GCP for the per-second
// trial snapshot (layout in gcp_block.v). Reading STREAM returns
// entropy bytes from the stream FIFO, removing each byte once it has been clocked out; read
// at most STREAM_LEVEL bytes (an empty FIFO returns 0x00).
module strix_core #(
@ -28,7 +29,8 @@ module strix_core #(
parameter FREQ_GATE_BITS = 24,
parameter RINGS_PER_GEN = 90,
parameter DEAD_WINDOW_BITS = 16,
parameter TICK_CYCLES = 120000 // clk cycles per millisecond
parameter TICK_CYCLES = 120000, // clk cycles per millisecond
parameter GCP_TRIAL_MS = 1000
) (
input wire clk,
input wire reset,
@ -41,7 +43,7 @@ module strix_core #(
output reg nina_gpio0_in // GPIO0 is driven by the module (to the SAMD21), not to it
);
localparam [7:0] VERSION = 8'h09;
localparam [7:0] VERSION = 8'h0A;
localparam GENERATORS = 4;
localparam [6:0] REG_ID0 = 7'h00, // "STRX"
@ -93,6 +95,7 @@ module strix_core #(
REG_STATS = 7'h40, // burst read of the statistics trial snapshot
REG_STREAM = 7'h44, // burst read of entropy bytes
REG_AMP = 7'h42, // burst read of the amplifier trial snapshot
REG_GCP = 7'h46, // burst read of the per-second trial snapshot
REG_AMP_CTRL = 7'h50, // bit 0 von Neumann on the amplifier path;
// write bit 1: restart the amplifier, sequence 0
REG_AMP_SOURCES = 7'h51, // generators feeding the amplifier (healthy only)
@ -220,6 +223,18 @@ module strix_core #(
.trial_end(trial_end)
);
// Per-second GCP-style trials, fed from the same registered generator outputs
reg [7:0] gcp_index;
wire [7:0] gcp_byte;
wire gcp_trial_end;
gcp_block #(.GENERATORS(GENERATORS), .TRIAL_MS(GCP_TRIAL_MS), .TICK_CYCLES(TICK_CYCLES)) gcp (
.clk(clk), .reset(reset), .gen_bit(stats_bits[GENERATORS-1:0]), .gen_valid(stats_valid),
.healthy(stats_healthy), .read_index(gcp_index), .read_byte(gcp_byte),
.trial_end(gcp_trial_end)
);
// Source selection
reg [3:0] source_a, source_b;
@ -367,6 +382,7 @@ module strix_core #(
REG_DATA: burst_byte = capture_byte;
REG_STATS: burst_byte = stats_byte;
REG_AMP: burst_byte = amp_byte;
REG_GCP: burst_byte = gcp_byte;
REG_STREAM: burst_byte = stream_available ? stream_head : 8'h00;
default: begin
burst_byte = 8'h00;
@ -411,6 +427,7 @@ module strix_core #(
amp_stage_mode <= 3'b000;
amp_stage_param <= {16'd4, 16'd8, 16'd8}; // random walk 8 x 8 x 4 = bound 256
amp_index <= 8'd0;
gcp_index <= 8'd0;
stream_source <= 3'd4;
nina_enable <= 1'b0;
nina_gpio0_in <= 1'b0;
@ -425,6 +442,11 @@ module strix_core #(
else if (tx_loaded && is_read && reg_addr == REG_AMP)
amp_index <= amp_index + 8'd1;
if (!selected)
gcp_index <= 8'd0;
else if (tx_loaded && is_read && reg_addr == REG_GCP)
gcp_index <= gcp_index + 8'd1;
if (!selected)
stats_index <= 8'd0;
else if (tx_loaded && is_read && reg_addr == REG_STATS)

View file

@ -1,8 +1,8 @@
// Testbench for strix_core: register access, generator XOR logic checked against the sampled
// ring outputs, captures read back across transactions, generator enable, the frequency
// counter, the health monitors with injected faults, the statistics snapshots against a
// reference model, the bias amplifier's bit counts in several configurations, and the entropy
// byte stream against the FIFO contents. Rings are replaced by the SIM model in ring_osc.v.
// reference model, the bias amplifier's bit counts in several configurations, the entropy
// byte stream against the FIFO contents, and the per-second GCP trials against a reference model. Rings are replaced by the SIM model in ring_osc.v.
`timescale 1ns / 1ps
module tb_strix_core;
@ -21,14 +21,18 @@ module tb_strix_core;
REG_STATS_CTRL = 7'h28, REG_STATS = 7'h40, REG_AMP = 7'h42,
REG_AMP_CTRL = 7'h50, REG_AMP_SOURCES = 7'h51, REG_AMP_SUB_MS = 7'h52,
REG_AMP_SUBS = 7'h53, REG_AMP_STAGE = 7'h54, REG_STREAM_CTRL = 7'h29,
REG_LEVEL_HIGH = 7'h2A, REG_LEVEL_LOW = 7'h2B, REG_STREAM = 7'h44;
REG_LEVEL_HIGH = 7'h2A, REG_LEVEL_LOW = 7'h2B, REG_STREAM = 7'h44,
REG_GCP = 7'h46;
localparam AMP_SNAPSHOT_BYTES = 44;
localparam GCP_SNAPSHOT_BYTES = 36;
localparam SNAPSHOT_BYTES = 72;
localparam TICK_CYCLES = 1500; // 1 "ms" = 12.5 us: a 10 "ms" trial outlasts a 72-byte read
localparam TRIAL_BITS = 10 * TICK_CYCLES / 5; // 10 "ms" trials at temporal 5
// Amplifier trials in the tests: 3 sub-trials of 2 "ms" -> 9000 clk cycles -> 1800 output
// bits per generator at temporal 5
localparam AMP_GEN_BITS = 3 * 2 * TICK_CYCLES / 5;
localparam GCP_TRIAL_MS = 10; // shortened "second": GCP_TRIAL_BITS bits per generator
localparam GCP_TRIAL_BITS = GCP_TRIAL_MS * TICK_CYCLES / 5;
// Per-generator health registers
function [6:0] gen_reg(input integer generator, input integer index);
@ -44,7 +48,7 @@ module tb_strix_core;
wire miso, done;
strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH), .FREQ_GATE_BITS(14), .RINGS_PER_GEN(RINGS),
.DEAD_WINDOW_BITS(6), .TICK_CYCLES(TICK_CYCLES)) dut (
.DEAD_WINDOW_BITS(6), .TICK_CYCLES(TICK_CYCLES), .GCP_TRIAL_MS(GCP_TRIAL_MS)) dut (
.clk(clk), .reset(reset),
.spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso),
.capture_done(done)
@ -121,6 +125,48 @@ module tb_strix_core;
end
end
// Reference model of the GCP trials: bit i of a second is inverted when i is odd, and the
// first 200 masked bits form the sample sum.
reg [31:0] gcp_model_bits = 0;
reg [7:0] gcp_model_sum [0:3];
reg [31:0] gcp_model_ones [0:3];
reg [31:0] gcp_latched_bits;
reg [7:0] gcp_latched_sum [0:3];
reg [31:0] gcp_latched_ones [0:3];
integer gm;
initial for (gm = 0; gm < 4; gm = gm + 1) begin
gcp_model_sum[gm] = 0;
gcp_model_ones[gm] = 0;
end
always @(posedge clk) if (!reset) begin : gcp_model
reg [31:0] index;
reg masked;
if (dut.gcp_trial_end) begin
gcp_latched_bits <= gcp_model_bits;
for (gm = 0; gm < 4; gm = gm + 1) begin
gcp_latched_sum[gm] <= gcp_model_sum[gm];
gcp_latched_ones[gm] <= gcp_model_ones[gm];
end
end
if (dut.gcp.gen_valid) begin
index = dut.gcp_trial_end ? 0 : gcp_model_bits;
gcp_model_bits <= index + 1;
for (gm = 0; gm < 4; gm = gm + 1) begin
masked = dut.gcp.gen_bit[gm] ^ index[0];
gcp_model_ones[gm] <= (dut.gcp_trial_end ? 0 : gcp_model_ones[gm]) + masked;
gcp_model_sum[gm] <= (dut.gcp_trial_end ? 0 : gcp_model_sum[gm]) + (index < 200 && masked);
end
end else if (dut.gcp_trial_end) begin
gcp_model_bits <= 0;
for (gm = 0; gm < 4; gm = gm + 1) begin
gcp_model_sum[gm] <= 0;
gcp_model_ones[gm] <= 0;
end
end
end
reg [7:0] snapshot [0:SNAPSHOT_BYTES-1];
function [31:0] snapshot_word(input integer index);
@ -282,7 +328,7 @@ module tb_strix_core;
// Identification
read_reg(REG_ID0, v); expect(v, "S", "ID0");
read_reg(REG_ID3, v); expect(v, "X", "ID3");
read_reg(REG_VERSION, v); expect(v, 8'h09, "VERSION");
read_reg(REG_VERSION, v); expect(v, 8'h0A, "VERSION");
read_reg(REG_GENERATORS, v); expect(v, 8'd4, "GENERATORS");
read_reg(REG_RINGS, v); expect(v, RINGS, "RINGS");
read_reg(REG_TEMPORAL, v); expect(v, 8'd5, "TEMPORAL");
@ -459,6 +505,39 @@ module tb_strix_core;
read_reg(REG_LEVEL_LOW, v);
expect_range(v, 0, 15, "stream level after flush");
// GCP trials: a full second after the earlier tests, checked against the model
@(posedge dut.gcp_trial_end); #200;
begin : gcp_read
reg [7:0] gcp [0:GCP_SNAPSHOT_BYTES-1];
reg [7:0] dummy;
integer i;
ss_n = 1'b0; #SCK_HALF;
spi_byte({1'b1, REG_GCP}, dummy);
for (i = 0; i < GCP_SNAPSHOT_BYTES; i = i + 1) spi_byte(8'h00, gcp[i]);
#SCK_HALF ss_n = 1'b1; #(4 * SCK_HALF);
if ({gcp[0], gcp[1], gcp[2], gcp[3]} !== {gcp[32], gcp[33], gcp[34], gcp[35]}
|| {gcp[0], gcp[1], gcp[2], gcp[3]} < 32'd2) begin
$display("FAIL GCP sequence %0d", {gcp[0], gcp[1], gcp[2], gcp[3]}); errors = errors + 1;
end
expect(gcp[4], 8'h0F, "GCP healthy");
expect(gcp[5], 8'd200, "GCP sample bits");
if ({gcp[8], gcp[9], gcp[10], gcp[11]} !== gcp_latched_bits) begin
$display("FAIL GCP bits %0d, model %0d", {gcp[8], gcp[9], gcp[10], gcp[11]}, gcp_latched_bits);
errors = errors + 1;
end
expect_range({gcp[8], gcp[9], gcp[10], gcp[11]}, GCP_TRIAL_BITS - 1, GCP_TRIAL_BITS + 1, "GCP bits");
for (i = 0; i < 4; i = i + 1) begin
if (gcp[12 + 5 * i] !== gcp_latched_sum[i]
|| {gcp[13 + 5 * i], gcp[14 + 5 * i], gcp[15 + 5 * i], gcp[16 + 5 * i]} !== gcp_latched_ones[i]) begin
$display("FAIL GCP generator %0d: sum %0d ones %0d, model %0d / %0d", i, gcp[12 + 5 * i],
{gcp[13 + 5 * i], gcp[14 + 5 * i], gcp[15 + 5 * i], gcp[16 + 5 * i]},
gcp_latched_sum[i], gcp_latched_ones[i]);
errors = errors + 1;
end
expect_range(gcp[12 + 5 * i], 1, 199, "GCP sample sum");
end
end
if (xor_checks < 100) begin
$display("FAIL only %0d temporal XOR checks ran", xor_checks);
errors = errors + 1;

View file

@ -67,6 +67,7 @@ set_global_assignment -name VERILOG_FILE ../rtl/entropy_stream.v
set_global_assignment -name VERILOG_FILE ../rtl/bias_amplifier.v
set_global_assignment -name VERILOG_FILE ../rtl/amp_path.v
set_global_assignment -name VERILOG_FILE ../rtl/amp_stage.v
set_global_assignment -name VERILOG_FILE ../rtl/gcp_block.v
set_global_assignment -name VERILOG_FILE ../rtl/freq_counter.v
set_global_assignment -name VERILOG_FILE ../rtl/ring_osc.v
set_global_assignment -name VERILOG_FILE ../rtl/raw_capture.v

View file

@ -99,6 +99,25 @@ def main() -> None:
device.serial.reset_input_buffer()
check(trials == sorted(trials) and len(set(trials)) == 3, f"watch stats pushed trials {trials}")
check(device.json("watch gcp") == {"ok": True}, "watch gcp")
seconds = [json.loads(device.serial.readline()) for _ in range(3)]
device.json("watch off")
device.serial.reset_input_buffer()
numbers = [r.get("second") for r in seconds]
last = seconds[-1]
check(numbers == list(range(numbers[0], numbers[0] + 3)), f"watch gcp pushed consecutive seconds {numbers}")
check(last["healthy"] == 15 and last["sample"]["bits"] == 200
and all(0 <= s <= 200 for s in last["sample"]["sums"]),
f"gcp sample: sums {last['sample']['sums']}, Stouffer {last['sample']['stouffer']}")
check(abs(last["full"]["bits"] - 24_000_000) <= 1,
f"gcp full: {last['full']['bits']} bits, z {last['full']['z']}, Stouffer {last['full']['stouffer']}")
device.entropy_bin(65536)
time.sleep(1.1)
records = device.json(f"gcp {numbers[-1]}").get("records", [])
after = [r["second"] for r in records]
check(len(after) >= 1 and after == list(range(numbers[-1] + 1, numbers[-1] + 1 + len(after))),
f"gcp {numbers[-1]}: no gap across an entropy transfer, seconds {after}")
if args.onod.exists():
sample = Path("captures/api_entropy.bin")
sample.parent.mkdir(exist_ok=True)

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Checks the STRIX firmware's Bluetooth LE API (arduino/strix/API.md) from this computer:
finds the device, sends commands over the command characteristic, reads the replies from the
reply notifications, and reads and streams the entropy characteristic."""
reply notifications (including `watch gcp` output), and reads and streams the entropy characteristic."""
import argparse
import asyncio
@ -57,6 +57,15 @@ async def main() -> None:
print(f"{'stats':<12} -> trial {stats.get('trial')}, combined total z "
f"{stats['channels'][-1]['total']['z'] if 'channels' in stats else None}")
print(f"{'watch gcp':<12} -> {await command(client, replies, 'watch gcp')}")
records = [json.loads(await asyncio.wait_for(replies.lines.get(), timeout=3)) for _ in range(2)]
await client.write_gatt_char(COMMAND, b"watch off", response=True)
await asyncio.sleep(1.5)
while not replies.lines.empty():
replies.lines.get_nowait() # the watch reply and any second pushed before it
print(f"{'gcp seconds':<12} -> {[r['second'] for r in records]}, healthy {records[-1]['healthy']}, "
f"sample sums {records[-1]['sample']['sums']}")
first = await client.read_gatt_char(ENTROPY)
second = await client.read_gatt_char(ENTROPY)
print(f"entropy read -> {len(first)} bytes, then {len(second)} bytes, "

View file

@ -82,8 +82,8 @@ class Board:
while self.serial.readline():
pass
self.serial.timeout = 200
if not identity.startswith("STRX v9"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v9 FPGA image)")
if not identity.startswith("STRX v10"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v10 FPGA image)")
self.generators = self.read(REG_GENERATORS)
self.rings = self.read(REG_RINGS)
print(f"board: {identity}, {self.generators} generators x {self.rings} rings")