diff --git a/cyberpunkunicorn-os/CLAUDE.md b/cyberpunkunicorn-os/CLAUDE.md index be772d0..500f49c 100644 --- a/cyberpunkunicorn-os/CLAUDE.md +++ b/cyberpunkunicorn-os/CLAUDE.md @@ -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. diff --git a/cyberpunkunicorn-os/apps/frequency/card.cpp b/cyberpunkunicorn-os/apps/frequency/card.cpp index c62a598..7027d6b 100644 --- a/cyberpunkunicorn-os/apps/frequency/card.cpp +++ b/cyberpunkunicorn-os/apps/frequency/card.cpp @@ -5,6 +5,7 @@ #include "../hat/hat_probe.h" #include "card.h" +#include "flash.h" #include #include @@ -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) { @@ -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; @@ -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); } @@ -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; } diff --git a/cyberpunkunicorn-os/apps/frequency/flash.cpp b/cyberpunkunicorn-os/apps/frequency/flash.cpp new file mode 100644 index 0000000..80896b2 --- /dev/null +++ b/cyberpunkunicorn-os/apps/frequency/flash.cpp @@ -0,0 +1,54 @@ +#include "flash.h" + +#include "../cyd_pins.h" +#include + +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 diff --git a/cyberpunkunicorn-os/apps/frequency/flash.h b/cyberpunkunicorn-os/apps/frequency/flash.h new file mode 100644 index 0000000..a53cb8a --- /dev/null +++ b/cyberpunkunicorn-os/apps/frequency/flash.h @@ -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 diff --git a/cyberpunkunicorn-os/apps/hat/hat_probe.cpp b/cyberpunkunicorn-os/apps/hat/hat_probe.cpp index dbf7163..9bcffec 100644 --- a/cyberpunkunicorn-os/apps/hat/hat_probe.cpp +++ b/cyberpunkunicorn-os/apps/hat/hat_probe.cpp @@ -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(); } @@ -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."; @@ -199,7 +241,7 @@ 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. @@ -207,10 +249,11 @@ HatProbeResult probeHat(bool allowSpi) { 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; @@ -242,6 +285,7 @@ std::vector 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)"); diff --git a/cyberpunkunicorn-os/apps/hat/hat_probe.h b/cyberpunkunicorn-os/apps/hat/hat_probe.h index a8edbbf..e0f0353 100644 --- a/cyberpunkunicorn-os/apps/hat/hat_probe.h +++ b/cyberpunkunicorn-os/apps/hat/hat_probe.h @@ -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 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; diff --git a/cyberpunkunicorn-os/apps/remote/PROTOCOL.md b/cyberpunkunicorn-os/apps/remote/PROTOCOL.md index 3bf4bd2..7b9b6b7 100644 --- a/cyberpunkunicorn-os/apps/remote/PROTOCOL.md +++ b/cyberpunkunicorn-os/apps/remote/PROTOCOL.md @@ -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` * diff --git a/cyberpunkunicorn-os/apps/remote/app_handlers.cpp b/cyberpunkunicorn-os/apps/remote/app_handlers.cpp index edeaf09..6fad72c 100644 --- a/cyberpunkunicorn-os/apps/remote/app_handlers.cpp +++ b/cyberpunkunicorn-os/apps/remote/app_handlers.cpp @@ -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" @@ -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(), sc); + freq::flashSuccess(); return; } freq::ScanReply s = freq::postScan(u.token, card.uid); @@ -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) { diff --git a/cyberpunkunicorn-os/apps/remote/remote.cpp b/cyberpunkunicorn-os/apps/remote/remote.cpp index 7b0ec8e..c380fbd 100644 --- a/cyberpunkunicorn-os/apps/remote/remote.cpp +++ b/cyberpunkunicorn-os/apps/remote/remote.cpp @@ -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 @@ -273,6 +274,7 @@ void cmdHatProbe(Request &r) { r.data["pn532"] = hp.pn532; JsonArray other = r.data["i2c_other"].to(); 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; diff --git a/cyberpunkunicorn-os/build.sh b/cyberpunkunicorn-os/build.sh index 3ea7eea..9608925 100755 --- a/cyberpunkunicorn-os/build.sh +++ b/cyberpunkunicorn-os/build.sh @@ -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 diff --git a/cyberpunkunicorn-os/patches/0018-lossless-screen-stream.patch b/cyberpunkunicorn-os/patches/0018-lossless-screen-stream.patch new file mode 100644 index 0000000..7fef242 --- /dev/null +++ b/cyberpunkunicorn-os/patches/0018-lossless-screen-stream.patch @@ -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) { diff --git a/cyberpunkunicorn-os/patches/0019-pn532-selected-card-access.patch b/cyberpunkunicorn-os/patches/0019-pn532-selected-card-access.patch new file mode 100644 index 0000000..80cde6d --- /dev/null +++ b/cyberpunkunicorn-os/patches/0019-pn532-selected-card-access.patch @@ -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;