diff --git a/include/opendisplay_structs.h b/include/opendisplay_structs.h index 0ec81a0..f8b0cb2 100644 --- a/include/opendisplay_structs.h +++ b/include/opendisplay_structs.h @@ -69,6 +69,10 @@ * Unreleased (since 2.1) * - LedFlags bit4: OD_LED_FLAG_BUTTON_PRESS (short LED flash on physical * button press / button-wake synthetic click; default off). + * - LedFlags bit5: OD_LED_FLAG_NFC (short white LED flash on NFC field + * detect rising edge; default off). + * - LedFlags bit6: OD_LED_FLAG_NFC_FANCY (multi-colour LED blink on NFC + * field detect; default off; takes precedence over bit5 when both set). * - BuzzerFlags bit1: OD_BUZZER_FLAG_BUTTON_PRESS (short chirp on physical * button press / button-wake synthetic click; default off). * - MsdStatusBits bit3: OD_MSD_STATUS_ENCRYPTION_ENABLED (1 = app-layer @@ -750,12 +754,14 @@ enum LedType { OD_LED_TYPE_FOUR_SEPARATE = 3 /**< @doc "four separate LEDs" */ }; -/* LedConfig.led_flags @bits LedFlags (bits 5-7 reserved). */ +/* LedConfig.led_flags @bits LedFlags (bit 7 reserved). */ #define OD_LED_FLAG_LED1_INVERT (1u << 0) /* @doc "invert LED channel 1 polarity" */ #define OD_LED_FLAG_LED2_INVERT (1u << 1) /* @doc "invert LED channel 2 polarity" */ #define OD_LED_FLAG_LED3_INVERT (1u << 2) /* @doc "invert LED channel 3 polarity" */ #define OD_LED_FLAG_LED4_INVERT (1u << 3) /* @doc "invert LED channel 4 polarity" */ #define OD_LED_FLAG_BUTTON_PRESS (1u << 4) /* @doc "short flash on physical button press (incl. button-wake synthetic click); default off" */ +#define OD_LED_FLAG_NFC (1u << 5) /* @doc "short white flash on NFC field-detect rising edge; default off" */ +#define OD_LED_FLAG_NFC_FANCY (1u << 6) /* @doc "multi-colour blink on NFC field-detect rising edge; default off; overrides OD_LED_FLAG_NFC when both set" */ /** @struct LedConfig @packet 0x21 @repeatable max=4 * @doc "LED channel pins + invert flags. Up to 4 instances. 22 bytes. NOTE: the diff --git a/src/ble_transport.h b/src/ble_transport.h index 6db368c..1da900e 100644 --- a/src/ble_transport.h +++ b/src/ble_transport.h @@ -124,10 +124,9 @@ class BleTransport { // Each implementation keeps its own restart semantics (see the .cpp). void setManufacturerData(const uint8_t* msd, uint8_t len); - // True where the stack re-arms advertising by itself after a disconnect - // (nRF: Bluefruit.Advertising.restartOnDisconnect(true)). Where it is false - // the application must schedule the restart itself. A genuine capability - // difference, stated as a query so callers need no target #ifdef. + // True where the stack re-arms advertising by itself after a disconnect. + // nRF and ESP32 both return false: the app owns slow re-arm so a disconnect + // cannot inherit a SoftDevice "fast" advertising phase. bool restartsAdvertisingOnDisconnect() const; // --- link policy (no-op where the stack does not support it) --- diff --git a/src/ble_transport_esp32.cpp b/src/ble_transport_esp32.cpp index 1cbc43a..2b3c204 100644 --- a/src/ble_transport_esp32.cpp +++ b/src/ble_transport_esp32.cpp @@ -413,8 +413,15 @@ void BleTransport::startAdvertising() { void BleTransport::restartAdvertising() { // Unconditional by contract: the caller owns the "still connected / mid-EPD // refresh / stack not up" deferral policy (see serviceBleAdvertisingRestart - // in main.cpp). The delay mirrors the historical sequence. + // in main.cpp). Force slow intervals so a disconnect restart never inherits + // a prior button-boost burst. delay(100); + s_advBoostUntil = 0; + BLEAdvertising* pAdvertising = (s_server != nullptr) ? s_server->getAdvertising() + : BLEDevice::getAdvertising(); + if (pAdvertising != nullptr) { + applyAdvInterval(pAdvertising, false); + } BLEDevice::startAdvertising(); od_log_info("BLE advertising restarted"); } @@ -587,6 +594,10 @@ void BleTransport::setManufacturerData(const uint8_t* msd, uint8_t len) { // Called from loop() when the connect event is consumed, not from the connect // callback -- these are host-stack calls, which the callback contract excludes. void BleTransport::requestFastLink() { + // Connected: drop any button boost so a later advertising restart does not + // inherit 20–30 ms intervals from a still-open boost window. + s_advBoostUntil = 0; + // Tune the OWNER's link. This used to read the single s_connHandle scalar, // which the newest connect overwrote -- so with a contender attached, link // tuning targeted the wrong link (one of the shared-scalar defects R3 names). diff --git a/src/ble_transport_nrf.cpp b/src/ble_transport_nrf.cpp index 7927900..d2fbdb5 100644 --- a/src/ble_transport_nrf.cpp +++ b/src/ble_transport_nrf.cpp @@ -59,22 +59,25 @@ static volatile bool s_instanceSubscribed = false; static volatile uint32_t s_instanceDecidedWord = 0; // --- advertising interval policy -------------------------------------------- +// Bluefruit always restarts advertising after disconnect in its "fast" phase +// (setFastTimeout). Use the slow interval for both phases so a disconnect does +// not burn a multi-second high-rate burst. Button wakes still call +// boostAdvertising() for a short 20–30 ms window. static uint32_t s_advBoostUntil = 0; -static constexpr uint16_t NRF_ADV_INTERVAL_MIN = 256; // 160 ms static constexpr uint16_t NRF_ADV_INTERVAL_MAX = 1600; // 1000 ms static constexpr uint16_t NRF_ADV_BOOST_MIN = 32; // 20 ms static constexpr uint16_t NRF_ADV_BOOST_MAX = 48; // 30 ms static constexpr uint32_t NRF_ADV_BOOST_MS = 3000; static void applyAdvInterval() { - if (!globalConfig.loaded) { - Bluefruit.Advertising.setInterval(NRF_ADV_BOOST_MIN, NRF_ADV_BOOST_MAX); - } else if (s_advBoostUntil != 0 && millis() < s_advBoostUntil) { + if (!globalConfig.loaded || (s_advBoostUntil != 0 && millis() < s_advBoostUntil)) { Bluefruit.Advertising.setInterval(NRF_ADV_BOOST_MIN, NRF_ADV_BOOST_MAX); } else { s_advBoostUntil = 0; - Bluefruit.Advertising.setInterval(NRF_ADV_INTERVAL_MIN, NRF_ADV_INTERVAL_MAX); + // Identical fast/slow intervals: restartOnDisconnect starts in "fast" + // mode but at the slow rate — no post-disconnect advertising burst. + Bluefruit.Advertising.setInterval(NRF_ADV_INTERVAL_MAX, NRF_ADV_INTERVAL_MAX); } } @@ -249,6 +252,9 @@ bool BleTransport::begin(const char* deviceName) { od_log_info("BLE callbacks registered"); Bluefruit.setName(deviceName); od_log_info("Device name set to: %s", deviceName); + // Preferred supervision timeout 1 s (was Bluefruit default 2 s). Central may + // still negotiate longer; shortens unclean link-loss radio hang when accepted. + Bluefruit.Periph.setConnSupervisionTimeoutMS(1000); od_log_info("Configuring power management..."); sd_power_mode_set(NRF_POWER_MODE_LOWPWR); sd_power_dcdc_mode_set(NRF_POWER_DCDC_ENABLE); @@ -263,20 +269,29 @@ void BleTransport::startAdvertising() { Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); Bluefruit.Advertising.addName(); // Deliberately kept inside this sequence rather than hoisted to the caller: - // updatemsdata() lands in setManufacturerData() below, which itself sets - // setFastTimeout(1). Calling it after the setFastTimeout(10) line would leave - // the fast-advertising window at 1 s instead of 10 s -- a real behaviour - // change. Phase 1 keeps the historical order byte-for-byte. + // updatemsdata() lands in setManufacturerData() below. updatemsdata(); - Bluefruit.Advertising.restartOnDisconnect(true); + // App-owned restart after disconnect (see restartAdvertising): SoftDevice + // auto-restart always began a timed "fast" phase and fought our slow policy. + Bluefruit.Advertising.restartOnDisconnect(false); + s_advBoostUntil = 0; applyAdvInterval(); - Bluefruit.Advertising.setFastTimeout(10); + // duration 0 + equal slow/slow intervals → continuous slow advertising, no + // timed burst phase after start or (via restartAdvertising) after disconnect. + Bluefruit.Advertising.setFastTimeout(0); od_log_info("Starting BLE advertising..."); Bluefruit.Advertising.start(0); } void BleTransport::restartAdvertising() { - Bluefruit.Advertising.stop(); + // Slow-only re-arm after disconnect. Do not call updatemsdata() here — that + // stop/start + possible MSD rebuild is what reintroduced radio bursts. + s_advBoostUntil = 0; + applyAdvInterval(); + Bluefruit.Advertising.setFastTimeout(0); + if (Bluefruit.Advertising.isRunning()) { + Bluefruit.Advertising.stop(); + } Bluefruit.Advertising.start(0); } @@ -368,7 +383,16 @@ void BleTransport::setManufacturerData(const uint8_t* msd, uint8_t len) { Bluefruit.Advertising.addName(); Bluefruit.Advertising.addData(BLE_GAP_AD_TYPE_MANUFACTURER_SPECIFIC_DATA, msd, len); applyAdvInterval(); - Bluefruit.Advertising.setFastTimeout(1); + // Boost window: short timed phase at boost intervals. Otherwise continuous + // slow (fast timeout 0 with equal slow/slow intervals). + if (s_advBoostUntil != 0 && millis() < s_advBoostUntil) { + Bluefruit.Advertising.setFastTimeout((uint16_t)((NRF_ADV_BOOST_MS + 999) / 1000)); + } else { + Bluefruit.Advertising.setFastTimeout(0); + } + // Only touch the radio while disconnected. Rebuilding MSD while connected + // used to stop/start advertising and could leave a burst after the link dropped. + if (Bluefruit.connected()) return; Bluefruit.Advertising.stop(); Bluefruit.Advertising.start(0); } @@ -384,6 +408,11 @@ void BleTransport::setManufacturerData(const uint8_t* msd, uint8_t len) { // nothing -- the central's own request arrives later than this either way, which // is why the diagnostics below log twice. void BleTransport::requestFastLink() { + // Connected: drop any button boost so the next restartOnDisconnect does not + // inherit 20–30 ms intervals from a still-open boost window. + s_advBoostUntil = 0; + Bluefruit.Advertising.setInterval(NRF_ADV_INTERVAL_MAX, NRF_ADV_INTERVAL_MAX); + BLEConnection* conn = Bluefruit.Connection(s_connHandle); if (conn == nullptr) return; @@ -427,7 +456,8 @@ void BleTransport::tick() { } was_boosted = false; s_advBoostUntil = 0; - Bluefruit.Advertising.setInterval(NRF_ADV_INTERVAL_MIN, NRF_ADV_INTERVAL_MAX); + Bluefruit.Advertising.setInterval(NRF_ADV_INTERVAL_MAX, NRF_ADV_INTERVAL_MAX); + Bluefruit.Advertising.setFastTimeout(0); Bluefruit.Advertising.stop(); Bluefruit.Advertising.start(0); } @@ -476,10 +506,8 @@ bool BleTransport::takeDisconnectedEvent(uint16_t* reason, uint32_t* instanceWor } bool BleTransport::restartsAdvertisingOnDisconnect() const { - // Bluefruit.Advertising.restartOnDisconnect(true) in startAdvertising(): the - // SoftDevice re-arms the radio itself, so the application must not also - // schedule a restart or the two fight over the advertising state. - return true; + // restartOnDisconnect(false): loop() owns slow re-arm via restartAdvertising(). + return false; } const char* BleTransport::addressString() { diff --git a/src/communication.h b/src/communication.h index eac34b6..9cc17b6 100644 --- a/src/communication.h +++ b/src/communication.h @@ -76,9 +76,8 @@ void resetAuthAbuseCounter(void); /// transport that OWNS the transfer is confirmed gone. void requestTransferSessionCleanup(void); -/// Re-arm BLE advertising when it is safe to. A no-op on targets whose stack -/// re-arms itself (see BleTransport::restartsAdvertisingOnDisconnect), so -/// callers need no target guard. +/// Re-arm BLE advertising when it is safe to. Schedules application-owned slow +/// re-arm on both targets (see BleTransport::restartsAdvertisingOnDisconnect). void requestAdvertisingRestart(void); #endif diff --git a/src/display_service.cpp b/src/display_service.cpp index 9e78b03..592e5ec 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -343,12 +343,17 @@ static void pwrmgmLockGive(void) { // refresh looked correct, then the image darkened in over the following minutes. static const uint16_t EPD_POST_SLEEP_BLEED_MS = 200; +static bool panel_is_ep426(void) { + return bbep.type == EP426_800x480 || bbep.type == EP426_800x480_4GRAY; +} + // SSD16xx bbepSleep() only sends deep-sleep; it does not run the analog/HV -// shutdown. Force EOPT discharge frames + the GoodDisplay/GxEPD2 power-off -// sequence (enable clock → disable analog → disable OSC) while SPI is still up. +// shutdown. The EOPT + 0x83 discharge ("gray fix") is required on EP42B 400x300 +// but burns ~1 s @ high current on EP426 800x480 — skip the whole sequence there. static void epdSsd16xxPowerOffDischarge(void) { if (bbep.chip_type != BBEP_CHIP_SSD16xx) return; if (!bbep.is_awake) return; + if (panel_is_ep426()) return; bbepCMD2(&bbep, 0x3F, 0x22); // EOPT: TFT discharge frames + sequenced VCOM/HV bbepCMD2(&bbep, SSD1608_DISP_CTRL2, 0x83); bbepWriteCmd(&bbep, SSD1608_MASTER_ACTIVATE); @@ -376,7 +381,7 @@ static void epdSessionForceOffLocked(void) { odWatchdogFeed(); // reload before entering bb_epaper (may block ~240 s) epdSsd16xxPowerOffDischarge(); bbepSleep(&bbep, 1); - delay(EPD_POST_SLEEP_BLEED_MS); + if (!panel_is_ep426()) delay(EPD_POST_SLEEP_BLEED_MS); } pwrmgm(false); // -> PWR_OFF, clears deadline epdPlanesPrepared = false; @@ -3381,9 +3386,53 @@ static bool partial_write_stream_bytes(uint8_t* data, uint32_t len) { return true; } +// EP426 (SSD1677) OTP Mode-2 under-drives W→B. Register LUT follows EP426B's +// VS row layout (white on row1 / black on row2). Single ~50-frame phase — +// dual 0x32/0x32 looked settled after the first HV spike, so the second +// phase was mostly wasted energy. Activate with 0xDC (register LUT, no OTP). +static const uint8_t epd426_partial_lut[105] = { + // VS: same channel order as epd426b_init_part (not WW/BW/WB labels) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // TP/RP groups 0..9 — one ~50-frame pulse (was 0x32,0x32) + 0x32, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x22, 0x22, 0x22, 0x22, 0x22, +}; + +static void epd426_load_custom_partial_lut(void) { + uint8_t ctrl1[2] = {0x00, 0x00}; + uint8_t src[3] = {0x41, 0xa8, 0x32}; + bbepWriteCmd(&bbep, SSD1608_WRITE_LUT); + bbepWriteData(&bbep, (uint8_t *)epd426_partial_lut, (int)sizeof(epd426_partial_lut)); + bbepCMD2(&bbep, SSD1608_GATE_VOLTAGE, 0x17); + bbepWriteCmd(&bbep, SSD1608_SOURCE_VOLTAGE); + bbepWriteData(&bbep, src, 3); + bbepCMD2(&bbep, SSD1608_WRITE_VCOM, 0x40); // EP426B partial VCOM (0x30 drifts white→gray) + bbepWriteCmd(&bbep, SSD1608_DISP_CTRL1); + bbepWriteData(&bbep, ctrl1, 2); +} + static bool partial_trigger_refresh(int refreshMode) { if (refreshMode < 0 || refreshMode > 3) refreshMode = REFRESH_PARTIAL; if (panel_skips_reinit_on_partial_refresh(&bbep)) { + if (refreshMode == REFRESH_PARTIAL && bbep.type == EP426_800x480) { + epd426_load_custom_partial_lut(); + bbepCMD2(&bbep, SSD1608_DISP_CTRL2, 0xdc); + bbepWriteCmd(&bbep, SSD1608_MASTER_ACTIVATE); + return waitforrefresh(60); + } if (panel_uses_ep397_y_decrement(&bbep)) { static const uint8_t u8CMDz3[4] = {0xf7, 0xd7, 0xff, 0}; bbepCMD2(&bbep, SSD1608_DISP_CTRL2, u8CMDz3[refreshMode]); diff --git a/src/main.cpp b/src/main.cpp index ec2f546..1f1b322 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -530,13 +530,8 @@ static void serviceBleDisconnectCleanup() { // "not yet" paths so a later pass retries. static void serviceBleAdvertisingRestart() { if (!s_advertisingRestartPending) return; - // Capability gate, and the reason this helper is safe for ANY caller to - // raise the flag: where the stack re-arms advertising itself (nRF's - // restartOnDisconnect(true)), driving our own stop()/start() would fight it. - // Refusing here rather than at each raise site means a new raiser -- the - // post-refresh hook in display_service.cpp, or a future portable - // requestAdvertisingRestart() -- cannot reintroduce that conflict by - // forgetting a target guard. + // Capability gate: where the stack re-arms advertising itself, driving our + // own stop()/start() would fight it. if (ble.restartsAdvertisingOnDisconnect()) { s_advertisingRestartPending = false; return; @@ -548,8 +543,8 @@ static void serviceBleAdvertisingRestart() { } if (epdRefreshInProgress) return; // never mid-refresh s_advertisingRestartPending = false; + // Slow re-arm only — no updatemsdata() (that stop/starts advertising again). ble.restartAdvertising(); - updatemsdata(); } // Translate the transport's consume-once connect/disconnect events into the