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
6 changes: 6 additions & 0 deletions cyberpunkunicorn-os/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ User-tunable values go in `boot.conf` → `-D` macros via `PLATFORMIO_BUILD_FLAG
FM, LoRa and Ethernet menus and the PN532 BLE/UART, Chameleon and Amiibolink RFID
options (the CYD + hat have none of them; about 208 KB). build.sh refuses a
`STARTUP_APP` of "PN532 BLE"/"PN532 UART" while it is on
- `0018` lossless screen stream: while `display start` is on, every draw goes to the
serial queue (waiting up to 50 ms for room) before the log's de-duplication, so the
website's live screen gets whole frames instead of fragments
- `0019` PN532 `readSelected()` / `writeNdefSelected()`: read or write the card the
caller already selected (Frequency's card.cpp waits for RDY; Bruce's own read()
re-selects without waiting and could miss a tag held still)

(No 0002: the nm-rf-hat cat scene was dropped for the unicorn.) Macros in 0003–0004
keep their `NM_` prefix from `nm-rf-hat/`; renaming them is churn.
Expand Down
33 changes: 24 additions & 9 deletions cyberpunkunicorn-os/apps/frequency/card.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include "../hat/hat_probe.h"
#include "card.h"
#include "flash.h"
#include <freertos/semphr.h>
#include <vector>

Expand Down Expand Up @@ -140,15 +141,21 @@ bool selectCard(uint16_t waitMs) {
return true;
}

// Bruce's read(), retried: we already know a card is there.
// The card is selected; read it, selecting it again if a page read dropped.
// SUCCESS once any select worked, as Bruce's read() did: a tag whose pages
// can't be read (not an NTAG) is still a tag, and fill() says what it is.
int readFull() {
int r = RFIDInterface::TAG_NOT_PRESENT;
for (int i = 0; i < 4; i++) {
r = rfid->read();
if (r == RFIDInterface::SUCCESS) return r;
delay(15);
bool seen = false;
for (int i = 0; i < 3; i++) {
if (i) {
delay(15);
if (!selectCard(200)) continue;
}
seen = true;
rfid->readSelected(); // patch 0019: reads on from this select
if (rfid->pageReadSuccess || rfid->uid.sak != PN532::PICC_TYPE_MIFARE_UL) break;
}
return r;
return seen ? RFIDInterface::SUCCESS : RFIDInterface::TAG_NOT_PRESENT;
}

void fill(CardInfo &out) {
Expand Down Expand Up @@ -209,7 +216,9 @@ void readerClose() {

CardStatus pollCard(CardInfo &out) {
if (!held || !rfid) return CARD_NO_READER;
if (!selectCard(40)) return CARD_NONE;
// 150 ms: long enough for the PN532's "no card" answer after its 5 tries, so
// the next poll never talks over a command still running.
if (!selectCard(150)) return CARD_NONE;
if (readFull() != RFIDInterface::SUCCESS) return CARD_NONE;
fill(out);
return CARD_OK;
Expand Down Expand Up @@ -246,7 +255,12 @@ CardStatus writeCard(const String &uri, CardInfo &out) {
}
int r = RFIDInterface::FAILURE;
for (int i = 0; i < 4 && r != RFIDInterface::SUCCESS; i++) {
r = rfid->write_ndef();
// write_ndef() would select again without waiting for RDY (patch 0019).
if (!selectCard(200)) {
delay(20);
continue;
}
r = rfid->writeNdefSelected();
if (r == RFIDInterface::TAG_NOT_MATCH) break;
if (r != RFIDInterface::SUCCESS) delay(20);
}
Expand All @@ -257,6 +271,7 @@ CardStatus writeCard(const String &uri, CardInfo &out) {
if (!selectCard(200) || readFull() != RFIDInterface::SUCCESS) return CARD_VERIFY_FAILED;
fill(out);
if (out.uid != before.uid || out.uri != uri) return CARD_VERIFY_FAILED;
flashSuccess(); // no speaker on this board: the light says it worked
return CARD_OK;
}

Expand Down
54 changes: 54 additions & 0 deletions cyberpunkunicorn-os/apps/frequency/flash.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#include "flash.h"

#include "../cyd_pins.h"
#include <Arduino.h>

namespace freq {

namespace {

volatile bool running = false;

void setLed(bool r, bool g, bool b) {
// Common anode: LOW lights a colour (cyd_pins.h).
digitalWrite(CYD_LED_R, r == CYD_LED_ACTIVE_LOW ? LOW : HIGH);
digitalWrite(CYD_LED_G, g == CYD_LED_ACTIVE_LOW ? LOW : HIGH);
digitalWrite(CYD_LED_B, b == CYD_LED_ACTIVE_LOW ? LOW : HIGH);
}

void flashTask(void *) {
pinMode(CYD_LED_R, OUTPUT);
pinMode(CYD_LED_G, OUTPUT);
pinMode(CYD_LED_B, OUTPUT);
// Red, yellow, green, cyan, blue, magenta, twice round, then a white blink.
static const bool COLOURS[][3] = {{1, 0, 0}, {1, 1, 0}, {0, 1, 0}, {0, 1, 1}, {0, 0, 1}, {1, 0, 1}};
for (int lap = 0; lap < 2; lap++)
for (const auto &c : COLOURS) {
setLed(c[0], c[1], c[2]);
vTaskDelay(pdMS_TO_TICKS(70));
setLed(false, false, false);
vTaskDelay(pdMS_TO_TICKS(40));
}
for (int i = 0; i < 3; i++) {
setLed(true, true, true);
vTaskDelay(pdMS_TO_TICKS(90));
setLed(false, false, false);
vTaskDelay(pdMS_TO_TICKS(70));
}
// Back to undriven, as the pins were before (nothing else uses them).
pinMode(CYD_LED_R, INPUT);
pinMode(CYD_LED_G, INPUT);
pinMode(CYD_LED_B, INPUT);
running = false;
vTaskDelete(nullptr);
}

} // namespace

void flashSuccess() {
if (running) return;
running = true;
if (xTaskCreate(flashTask, "freq_flash", 2048, nullptr, 1, nullptr) != pdPASS) running = false;
}

} // namespace freq
11 changes: 11 additions & 0 deletions cyberpunkunicorn-os/apps/frequency/flash.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// The CYD's onboard RGB LED as the "it worked" signal: there is no speaker to beep.
#pragma once

namespace freq {

// Rainbow flashes, about 1.5 s, on its own task: returns at once, so a remote
// command answers without waiting for the light. A second call while one is
// running is ignored.
void flashSuccess();

} // namespace freq
74 changes: 59 additions & 15 deletions cyberpunkunicorn-os/apps/hat/hat_probe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,63 @@ void recoverI2C() {
pinMode(scl, INPUT);
}

// Both lines idle high once the pull-ups have a moment. One held low means a chip
// is stretching the clock or stuck mid-byte, and every address would time out.
bool linesIdle() {
int sda = bruceConfigPins.i2c_bus.sda, scl = bruceConfigPins.i2c_bus.scl;
if (sda < 0 || scl < 0) return true;
pinMode(sda, INPUT_PULLUP);
pinMode(scl, INPUT_PULLUP);
delayMicroseconds(20);
bool idle = digitalRead(sda) && digitalRead(scl);
pinMode(sda, INPUT);
pinMode(scl, INPUT);
return idle;
}

// Per-address timeout, and the most one scan may take. An absent address NACKs in
// well under a millisecond; Wire's own 50 ms timeout times 112 addresses, six scans
// at boot, kept the serial task from starting until after the website gave up.
constexpr uint16_t I2C_PROBE_TIMEOUT_MS = 10;
constexpr uint32_t I2C_SCAN_BUDGET_MS = 250;
constexpr uint8_t WIRE_TIMEOUT = 5; // endTransmission(): ESP_ERR_TIMEOUT

void probeI2C(HatProbeResult &r) {
recoverI2C();
if (!linesIdle()) {
r.i2cStuck = true;
return;
}
TwoWire *wire = acquireI2CBus();
for (uint8_t addr = 0x08; addr < 0x78; addr++) {
uint16_t prevTimeout = wire->getTimeOut();
wire->setTimeOut(I2C_PROBE_TIMEOUT_MS);
auto ask = [wire](uint8_t addr) {
wire->beginTransmission(addr);
uint8_t err = wire->endTransmission();
if (err != 0 && addr == PN532_I2C_ADDR) {
// A sleeping PN532 can NACK the address byte that wakes it: ask again.
delay(2);
wire->beginTransmission(addr);
err = wire->endTransmission();
return wire->endTransmission();
};

// The PN532 first, so a slow bus still finds the reader before the budget runs out.
uint8_t err = ask(PN532_I2C_ADDR);
if (err != 0 && err != WIRE_TIMEOUT) {
// A sleeping PN532 can NACK the address byte that wakes it: ask again.
delay(2);
err = ask(PN532_I2C_ADDR);
}
if (err == 0) r.pn532 = true;
else if (err == WIRE_TIMEOUT) r.i2cStuck = true;

uint32_t started = millis();
for (uint8_t addr = 0x08; addr < 0x78 && !r.i2cStuck; addr++) {
if (addr == PN532_I2C_ADDR) continue;
if (millis() - started > I2C_SCAN_BUDGET_MS) {
r.i2cStuck = true;
break;
}
if (err != 0) continue;
if (addr == PN532_I2C_ADDR) r.pn532 = true;
else r.i2cOther.push_back(addr);
err = ask(addr);
if (err == WIRE_TIMEOUT) r.i2cStuck = true;
else if (err == 0) r.i2cOther.push_back(addr);
}
wire->setTimeOut(prevTimeout);
releaseI2CBus();
}

Expand Down Expand Up @@ -182,6 +223,7 @@ String HatProbeResult::summary() const {
}

String HatProbeResult::advice() const {
if (i2cStuck) return "The card reader is holding the bus. Unplug for 10 s, DIP 3 only.";
int n = liveCount();
if (n == 0) return "Nothing answered. Turn ON one of DIP 1-5.";
if (n > 1) return "More than one module is on. Only one of DIP 1-5 at a time.";
Expand All @@ -199,18 +241,19 @@ HatProbeResult probeHat() { return probeHat(true); }
HatProbeResult probeHat(bool allowSpi) {
HatProbeResult r;
probeI2C(r);
if (!r.pn532) {
if (!r.pn532 && !r.i2cStuck) {
// Ask the I2C bus once more before driving 22/27 as SPI: a PN532 that was
// asleep answers the second time, and SPI traffic under a half-awake one
// can wedge it until the next power cycle.
delay(20);
r.i2cOther.clear();
probeI2C(r);
}
// The SPI radios sit on the same two pins as I2C. If the PN532 answered,
// driving those pins as SPI would fight it, so stop here.
if (allowSpi && !r.pn532) probeSPI(r);
if (allowSpi && r.liveCount() == 0) probeRxLine(r);
// The SPI radios sit on the same two pins as I2C. If the PN532 answered, or
// something is holding the lines, driving them as SPI would fight it: stop here.
if (r.i2cStuck) Serial.println("[hat] I2C bus held low or timing out; skipped the rest of the probe");
if (allowSpi && !r.pn532 && !r.i2cStuck) probeSPI(r);
if (allowSpi && !r.i2cStuck && r.liveCount() == 0) probeRxLine(r);
lastResult = r;
lastAt = millis();
haveLast = true;
Expand Down Expand Up @@ -242,6 +285,7 @@ std::vector<String> hatReportLines(const HatProbeResult &r) {
" aw " + hex2(r.nrfSetupAw));
}
l.push_back(String("PN532 ") + (r.pn532 ? "yes" : "no") + " (I2C 0x24)");
if (r.i2cStuck) l.push_back("I2C bus stuck: scan stopped early");
if (r.rxChecked)
l.push_back("RX pin " + String(r.rxTransitions) + " edges, " + String(r.rxHighPercent) + "% high");
else l.push_back("RX pin not checked (a module already answered)");
Expand Down
3 changes: 3 additions & 0 deletions cyberpunkunicorn-os/apps/hat/hat_probe.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ struct HatProbeResult {
// PN532 (DIP 3) and anything else on the Grove I2C bus (SDA 27 / SCL 22).
bool pn532 = false;
std::vector<uint8_t> i2cOther; // addresses that ACKed, excluding the PN532
// SDA or SCL stayed low, or an address timed out: something (usually a wedged
// PN532) is holding the bus. The scan stopped there and SPI was not tried.
bool i2cStuck = false;

// CC1101 (DIP 1): PARTNUM / VERSION status registers.
bool cc1101 = false;
Expand Down
2 changes: 1 addition & 1 deletion cyberpunkunicorn-os/apps/remote/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Bruce's `serialDevice` for a buffer while the command runs. Limits:
"live_count":1,"summary":"PN532 (DIP 3)","advice":"Ready."}
```

All `HatProbeResult` fields (snake_case) plus `live_count`, `summary`, `advice`.
All `HatProbeResult` fields (snake_case) plus `live_count`, `summary`, `advice`. `i2c_stuck` true means SDA or SCL was held low or an address timed out (usually a wedged PN532): the scan stopped early, SPI was skipped, and only a power cycle clears it.
`busy` while Light Show runs, since the probe would drive the LED pin. Takes up to ~1 s.

### `hardware.list` *
Expand Down
3 changes: 3 additions & 0 deletions cyberpunkunicorn-os/apps/remote/app_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include "../cyd_pins.h"
#include "../frequency/backend.h"
#include "../frequency/card.h"
#include "../frequency/flash.h"
#include "../frequency/store.h"
#include "../sensors/sensor_hub.h"

Expand Down Expand Up @@ -137,6 +138,7 @@ void cmdScan(remote::Request &r) {
if (freq::cableAvailable()) {
freq::SignedScan sc = freq::signScan(u.token, card.uid, remote::trustedEpoch());
freq::putRelay(r.data["relay"].to<JsonObject>(), sc);
freq::flashSuccess();
return;
}
freq::ScanReply s = freq::postScan(u.token, card.uid);
Expand All @@ -149,6 +151,7 @@ void cmdScan(remote::Request &r) {
return r.fail("offline", "queued; will retry when online");
default: return r.fail("rejected", s.reason);
}
freq::flashSuccess();
r.data["member"]["display_name"] = s.name;
r.data["zapsAwarded"] = s.zaps;
if (s.hasLoyalty) {
Expand Down
2 changes: 2 additions & 0 deletions cyberpunkunicorn-os/apps/remote/remote.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ void cmdSystemStatus(Request &r) {
if (hp.pn532) live.add("pn532");
if (hp.irLikely) live.add("ir");
if (hp.ookLikely) live.add("ook433");
if (hp.i2cStuck) hat["i2c_stuck"] = true;
hat["probed_s_ago"] = (millis() - at) / 1000;
} else {
hat["probed_s_ago"] = nullptr; // never probed this boot: run hat.probe
Expand Down Expand Up @@ -273,6 +274,7 @@ void cmdHatProbe(Request &r) {
r.data["pn532"] = hp.pn532;
JsonArray other = r.data["i2c_other"].to<JsonArray>();
for (uint8_t a : hp.i2cOther) other.add(a);
r.data["i2c_stuck"] = hp.i2cStuck;
r.data["cc1101"] = hp.cc1101;
r.data["cc_partnum"] = hp.ccPartnum;
r.data["cc_version"] = hp.ccVersion;
Expand Down
2 changes: 1 addition & 1 deletion cyberpunkunicorn-os/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ PIO_ENV=CYD-2432S028
PLATFORMIO_VERSION=6.2.0
ESPTOOL_VERSION=5.4.0
OS_NAME="CyberUnicorn 5000"
OS_VERSION=0.1.1
OS_VERSION=0.1.2

source ./boot.conf
for var in BOOT_TITLE BOOT_TAGLINE; do
Expand Down
24 changes: 24 additions & 0 deletions cyberpunkunicorn-os/patches/0018-lossless-screen-stream.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
diff --git a/src/core/tftLogger/tftLogger.cpp b/src/core/tftLogger/tftLogger.cpp
index 5d6f2c8..21efac8 100644
--- a/src/core/tftLogger/tftLogger.cpp
+++ b/src/core/tftLogger/tftLogger.cpp
@@ -208,6 +208,11 @@ bool tft_logger::isLogEqual(const tftLog &a, const tftLog &b) {

void tft_logger::pushLogIfUnique(const tftLog &l) {
if (!log) return;
+ // CyberUnicorn: a live mirror needs every draw, in order. Before, a packet
+ // already in the log (an erase redrawn in the same colour) was never sent, and
+ // a full queue dropped the rest, so a busy screen arrived as fragments. Wait
+ // for room instead, bounded so a stalled sender can't freeze the screen.
+ if (async_serial && asyncSerialQueue) { xQueueSend(asyncSerialQueue, &l, pdMS_TO_TICKS(50)); }
for (int i = 0; i < logCount; i++) {
if (isLogEqual(log[i], l)) {
return; // Entry already exists
@@ -216,7 +221,6 @@ void tft_logger::pushLogIfUnique(const tftLog &l) {
memcpy(log[logWriteIndex].data, l.data, l.data[1]);
logWriteIndex = (logWriteIndex + 1) % MAX_LOG_ENTRIES;
if (logCount < MAX_LOG_ENTRIES) ++logCount;
- if (async_serial && asyncSerialQueue) { xQueueSend(asyncSerialQueue, &l, 0); }
}

bool tft_logger::removeLogEntriesInsideRect(int rx, int ry, int rw, int rh) {
26 changes: 26 additions & 0 deletions cyberpunkunicorn-os/patches/0019-pn532-selected-card-access.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
diff --git a/src/modules/rfid/PN532.h b/src/modules/rfid/PN532.h
index 4e78a91..57da318 100644
--- a/src/modules/rfid/PN532.h
+++ b/src/modules/rfid/PN532.h
@@ -53,6 +53,21 @@ public:
int save(const String &filename);
String emulationCaveat() const override;

+ // CyberUnicorn: read or write the card the caller has just selected and waited
+ // for (apps/frequency/card.cpp). read() and write_ndef() select it again and
+ // read the answer without waiting for RDY, which over I2C can miss every try.
+ int readSelected() {
+ format_data();
+ set_uid();
+ pageReadStatus = read_data_blocks();
+ pageReadSuccess = pageReadStatus == SUCCESS;
+ return SUCCESS;
+ }
+ int writeNdefSelected() {
+ set_uid();
+ return write_ndef_blocks();
+ }
+
private:
bool _use_i2c;
CONNECTION_TYPE _connection_type;
Loading