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

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