diff --git a/ats-mini/BleHidCentral.cpp b/ats-mini/BleHidCentral.cpp index 6fb17239..f0303bcd 100644 --- a/ats-mini/BleHidCentral.cpp +++ b/ats-mini/BleHidCentral.cpp @@ -237,7 +237,10 @@ void BleHidCentral::onScanStart() char statusLine[40]; uint8_t maxAttempts = MAX_SCAN_ATTEMPTS; - drawScreen(); + // Draw the status once and return. The previous code also drew a blank screen + // first (a visible flash) and blocked the cooperative loop with delay(500) per + // attempt (up to 1.5 s total). The message persists until the next redraw, so + // no delay is needed and the loop stays responsive while scanning. if (maxAttempts) { snprintf(statusLine, sizeof(statusLine), "Scanning for BLE HID %u/%u...", scanAttempts, maxAttempts); @@ -245,8 +248,6 @@ void BleHidCentral::onScanStart() } else drawScreen("Scanning for BLE HID..."); - - delay(500); } bool BleHidCentral::acceptsAdvertisement(BLEAdvertisedDevice& device) diff --git a/ats-mini/BleHidCentral.h b/ats-mini/BleHidCentral.h index bfebff95..20c8d958 100644 --- a/ats-mini/BleHidCentral.h +++ b/ats-mini/BleHidCentral.h @@ -3,7 +3,13 @@ #include "BleCentral.h" -#define BLE_SCAN_INTERVAL 100 +// Milliseconds (the BLEScan API divides by 0.625 to get hardware units). A 400 ms +// interval with a 100 ms window is a 25% scan duty cycle instead of the previous +// 100% (interval == window): it cuts the RF the 2.4 GHz radio radiates next to +// the SI4732 antenna (lower SW/AM noise floor and current draw while scanning) +// and adds at most ~300 ms of discovery latency per cycle, imperceptible for a +// one-time HID pairing. +#define BLE_SCAN_INTERVAL 400 #define BLE_SCAN_WINDOW 100 struct BleHidState { diff --git a/ats-mini/BleMode.cpp b/ats-mini/BleMode.cpp index 7b2453cb..cb4bb7cb 100644 --- a/ats-mini/BleMode.cpp +++ b/ats-mini/BleMode.cpp @@ -82,8 +82,17 @@ int bleLoop(uint8_t bleMode) if (bleMode == BLE_ADHOC) { if (BLESerial.isConnected()) + { remoteTickTime(&BLESerial, &remoteBLEState); - if (!BLESerial.isConnected()) return 0; + remoteMemoryDumpTick(&BLESerial, &remoteBLEState); + } + else + { + // Scope a chunked "$" dump to a single connection: drop any in-progress + // dump so it does not resume and stream unsolicited slots to a new client. + remoteBLEState.memoryDumpSlot = -1; + return 0; + } if (BLESerial.available()) return remoteDoCommand(&BLESerial, &remoteBLEState, BLESerial.read()); return 0; @@ -92,12 +101,11 @@ int bleLoop(uint8_t bleMode) if (bleMode != BLE_HID) return 0; + // Show the connecting status without the previous blank-screen flash and + // blocking delay(500). The message persists until the next redraw, and + // BLEHid.loop() below drives the actual connect on this same tick. if (BLEHid.isStarted() && !BLEHid.isConnected() && BLEHid.isConnectPending() && BLEHid.peerName()) - { - drawScreen(); drawScreen("Connecting BLE HID", BLEHid.peerName()); - delay(500); - } BLEHid.loop(); if (!BLEHid.isConnected()) return 0; diff --git a/ats-mini/Remote.cpp b/ats-mini/Remote.cpp index ed137528..b422efb7 100644 --- a/ats-mini/Remote.cpp +++ b/ats-mini/Remote.cpp @@ -23,6 +23,15 @@ static void remoteCaptureScreen(Stream* stream) uint16_t width = spr.width(); uint16_t height = spr.height(); + // Read the sprite framebuffer directly instead of calling readPixel()+printf() + // 54,400 times. spr.getPointer() returns the raw 16bpp framebuffer base; the + // sprite is created unrotated as 320x170 with no sub-viewport, so pixel (x,y) + // is at fb[x + y*width]. The stored word equals htons(spr.readPixel(x,y)), so + // emitting it as four lowercase hex digits is byte-for-byte identical to the + // previous per-pixel printf("%04x", htons(...)) output. + const uint16_t *fb = (const uint16_t *)spr.getPointer(); + if(!fb) return; + // 14 bytes of BMP header stream->println(""); stream->print("424d"); // BM @@ -46,14 +55,90 @@ static void remoteCaptureScreen(Stream* stream) stream->print("e0070000"); // Green mask stream->println("1f000000"); // Blue mask - // Image data + // Image data: build each row in one buffer and emit it with a single write(). + // The 320x170 sprite yields 1280 hex chars + CRLF per row; rowBuf lives in + // .bss (not on the cooperative loop's stack) and is safe because this path is + // single-entrant (reached only from remoteDoCommand()'s 'C' case). + static const char hex[] = "0123456789abcdef"; + static char rowBuf[320 * 4 + 2]; for(int y=height-1 ; y>=0 ; y--) { + const uint16_t *row = fb + (uint32_t)y * width; + char *p = rowBuf; for(int x=0 ; xprintf("%04x", htons(spr.readPixel(x, y))); + uint16_t v = row[x]; // == htons(spr.readPixel(x, y)) + *p++ = hex[(v >> 12) & 0xF]; + *p++ = hex[(v >> 8) & 0xF]; + *p++ = hex[(v >> 4) & 0xF]; + *p++ = hex[ v & 0xF]; } - stream->println(""); + *p++ = '\r'; *p++ = '\n'; // matches println("") + stream->write((const uint8_t *)rowBuf, p - rowBuf); + } + stream->flush(); +} + +// +// Capture the screen as a raw little-endian RGB565 BMP (command 'c'). +// +// Additive, opt-in alternative to 'C': it emits ~2x fewer bytes (a binary BMP +// instead of ASCII hex) and does no per-pixel formatting. The decoded image is +// byte-for-byte identical to xxd-decoding the 'C' output. A leading +// "BMP:\r\n" ASCII frame lets line-oriented consumers find the binary +// start and pre-allocate. +// +static void remoteCaptureScreenBinary(Stream* stream) +{ + uint16_t width = spr.width(); + uint16_t height = spr.height(); + const uint16_t *fb = (const uint16_t *)spr.getPointer(); + if(!fb) return; + + uint32_t fileSize = 14 + 40 + 12 + (uint32_t)width * height * 2; + uint32_t pixOffset = 14 + 40 + 12; + uint32_t hsz = 40, compr = 3; + uint32_t w = width, ht = height; + uint32_t rm = 0xF800, gm = 0x07E0, bm = 0x001F; + uint16_t planes = 1, bpp = 16; + + // BMP fields are little-endian; on the little-endian ESP32 write them in + // native order (do NOT reuse the htonl() trick the hex 'C' path uses). + uint8_t h[66]; + h[0] = 'B'; h[1] = 'M'; + memcpy(h + 2, &fileSize, 4); + memset(h + 6, 0, 4); + memcpy(h + 10, &pixOffset, 4); + memcpy(h + 14, &hsz, 4); + memcpy(h + 18, &w, 4); + memcpy(h + 22, &ht, 4); + memcpy(h + 26, &planes, 2); + memcpy(h + 28, &bpp, 2); + memcpy(h + 30, &compr, 4); + memset(h + 34, 0, 20); + memcpy(h + 54, &rm, 4); + memcpy(h + 58, &gm, 4); + memcpy(h + 62, &bm, 4); + + stream->printf("BMP:%u\r\n", (unsigned int)fileSize); + stream->write(h, sizeof(h)); + + // Pixels, bottom-up. The framebuffer stores each RGB565 word byte-swapped + // (TFT_eSprite::drawPixel does color=(color>>8)|(color<<8) at 16bpp), so emit + // the high byte first to produce the little-endian RGB565 a BI_BITFIELDS BMP + // expects. These bytes equal xxd-decoding the 'C' output. + static uint8_t bmpRow[320 * 2]; + for(int y=height-1 ; y>=0 ; y--) + { + const uint16_t *row = fb + (uint32_t)y * width; + uint8_t *p = bmpRow; + for(int x=0 ; x> 8); + *p++ = (uint8_t)(v & 0xFF); + } + stream->write(bmpRow, p - bmpRow); } stream->flush(); } @@ -62,20 +147,50 @@ char remoteReadChar(Stream* stream) { char key; - while (!stream->available()); + // Bound the wait so a BLE disconnect mid-command (e.g. during '#' or '^') + // cannot spin the cooperative loop forever and trip the task watchdog. The + // deadline never fires on USB or on a well-behaved BLE host that sends the + // whole command at once; delay(1) yields to the scheduler while waiting. + uint32_t deadline = millis() + 2000; + while (!stream->available()) + { + if ((int32_t)(millis() - deadline) >= 0) return 0; // abort signal + delay(1); + } key = stream->read(); stream->print(key); return key; } +// Wait until a byte is available, then return it (via peek(), without consuming) +// as an int; return -1 if none arrives within the deadline. Multi-byte commands +// can dribble in over several loop ticks when typed in a terminal, so the parsers +// must block here rather than give up on a momentarily empty buffer. The deadline +// + delay(1) replace the previous unbounded busy-spin (`while(peek()==0xFF)`), +// which on this toolchain (char is unsigned on Xtensa, so peek()==-1 became 0xFF) +// would loop forever on a BLE disconnect and trip the task watchdog. +static int remotePeekBlocking(Stream* stream) +{ + uint32_t deadline = millis() + 2000; + int peeked; + while ((peeked = stream->peek()) < 0) + { + if ((int32_t)(millis() - deadline) >= 0) return -1; + delay(1); + } + return peeked; +} + long int remoteReadInteger(Stream* stream) { long int result = 0; while (true) { - char ch = stream->peek(); - if (ch == 0xFF) { - continue; - } else if ((ch >= '0') && (ch <= '9')) { + int peeked = remotePeekBlocking(stream); + if (peeked < 0) { + return result; + } + char ch = (char)peeked; + if ((ch >= '0') && (ch <= '9')) { ch = remoteReadChar(stream); // Can overflow, but it's ok result = result * 10 + (ch - '0'); @@ -89,28 +204,23 @@ void remoteReadString(Stream* stream, char *bufStr, uint8_t bufLen) { uint8_t length = 0; while (true) { - char ch = stream->peek(); - if (ch == 0xFF) { - continue; - } else if (ch == ',' || ch < ' ') { + int peeked = remotePeekBlocking(stream); + if (peeked < 0 || (char)peeked == ',' || (char)peeked < ' ') { + bufStr[length] = '\0'; + return; + } + char ch = remoteReadChar(stream); + bufStr[length] = ch; + if (++length >= bufLen - 1) { bufStr[length] = '\0'; return; - } else { - ch = remoteReadChar(stream); - bufStr[length] = ch; - if (++length >= bufLen - 1) { - bufStr[length] = '\0'; - return; - } } } } static bool expectNewline(Stream* stream) { - char ch; - while ((ch = stream->peek()) == 0xFF); - if (ch == '\r') { + if (remotePeekBlocking(stream) == '\r') { stream->read(); return true; } @@ -119,8 +229,10 @@ static bool expectNewline(Stream* stream) static bool remoteShowError(Stream* stream, const char *message) { - // Consume the remaining input - while (stream->available()) remoteReadChar(stream); + // Drain the remaining input without echoing it (remoteReadChar() echoes each + // byte, which over BLE means an extra write() per leftover byte before the + // error message, and pollutes the stream for line-oriented consumers). + while (stream->available()) stream->read(); stream->printf("\r\nError: %s\r\n", message); return false; } @@ -155,13 +267,24 @@ static bool remoteSetFrequency(Stream *stream) return true; } -static void remoteGetMemories(Stream* stream) +// +// Emit one populated memory slot per call. The "$" command starts the dump by +// setting state->memoryDumpSlot = 0; this is then driven once per loop tick so +// a BLE transfer (~2 KB/s) hands out one ~35-byte slot at a time instead of +// blocking the cooperative loop for ~1.7 s. The wire format is unchanged. +// +void remoteMemoryDumpTick(Stream* stream, RemoteState* state) { - for (uint8_t i = 0; i < getTotalMemories(); i++) { + if (state->memoryDumpSlot < 0) return; + + while (state->memoryDumpSlot < getTotalMemories()) { + uint8_t i = state->memoryDumpSlot++; if (memories[i].freq) { stream->printf("#%02d,%s,%ld,%s\r\n", i + 1, bands[memories[i].band].bandName, memories[i].freq, bandModeDesc[memories[i].mode]); + return; } } + state->memoryDumpSlot = -1; } static bool remoteSetMemory(Stream* stream) @@ -328,10 +451,11 @@ void remotePrintStatus(Stream* stream, RemoteState* state) // void remoteTickTime(Stream* stream, RemoteState* state) { - if(state->remoteLogOn && (millis() - state->remoteTimer >= 500)) + uint32_t now = millis(); + if(state->remoteLogOn && (now - state->remoteTimer >= 500)) { // Mark time and increment diagnostic sequence number - state->remoteTimer = millis(); + state->remoteTimer = now; state->remoteSeqnum++; // Show status remotePrintStatus(stream, state); @@ -435,12 +559,17 @@ int remoteDoCommand(Stream* stream, RemoteState* state, char key) state->remoteLogOn = false; remoteCaptureScreen(stream); break; + case 'c': + state->remoteLogOn = false; + remoteCaptureScreenBinary(stream); + break; case 't': state->remoteLogOn = !state->remoteLogOn; break; case '$': - remoteGetMemories(stream); + // Start a chunked dump; slots are emitted by remoteMemoryDumpTick(). + state->memoryDumpSlot = 0; break; case '#': if (remoteSetMemory(stream)) @@ -475,6 +604,7 @@ static int serialLoop(Stream* stream, RemoteState* state, uint8_t usbMode) if(usbMode == USB_OFF) return 0; remoteTickTime(stream, state); + remoteMemoryDumpTick(stream, state); if (stream->available()) return remoteDoCommand(stream, state, stream->read()); diff --git a/ats-mini/Remote.h b/ats-mini/Remote.h index 5f356f47..8c6be75e 100644 --- a/ats-mini/Remote.h +++ b/ats-mini/Remote.h @@ -5,9 +5,13 @@ typedef struct { uint32_t remoteTimer = millis(); uint8_t remoteSeqnum = 0; bool remoteLogOn = false; + // Cursor for the chunked "$" memory dump (-1 = inactive). The dump emits one + // populated slot per loop tick so a BLE transfer does not block the main loop. + int16_t memoryDumpSlot = -1; } RemoteState; void remoteTickTime(Stream* stream, RemoteState* state); +void remoteMemoryDumpTick(Stream* stream, RemoteState* state); int remoteDoCommand(Stream* stream, RemoteState* state, char key); int serialLoop(uint8_t usbMode); bool serialConsumeAbortPending(uint8_t usbMode); diff --git a/ats-mini/ats-mini.ino b/ats-mini/ats-mini.ino index 6405ebe0..f5496843 100644 --- a/ats-mini/ats-mini.ino +++ b/ats-mini/ats-mini.ino @@ -103,7 +103,13 @@ SI4735_fixed rx; // void setup() { - // Enable serial port + // Enable serial port. + // Both build profiles use USBMode=hwcdc, so the 115200 baud argument is only a + // CDC line-coding label and does not limit throughput (USB FS runs much + // faster). Enlarging the TX ring (default 256 B) lets bulk output such as the + // screenshot drain more smoothly with fewer producer stalls; it must precede + // begin() and is drawn from internal DRAM, not the PSRAM sprite framebuffer. + Serial.setTxBufferSize(4096); Serial.begin(115200); // Encoder pins. Enable internal pull-ups diff --git a/changelog/+ble-scan-rf.changed.md b/changelog/+ble-scan-rf.changed.md new file mode 100644 index 00000000..b70f188e --- /dev/null +++ b/changelog/+ble-scan-rf.changed.md @@ -0,0 +1 @@ +Reduce Bluetooth RF interference and improve responsiveness during BLE HID pairing: scanning now uses a 25% duty cycle instead of 100%, and the blocking `delay()` calls and a screen flash during HID scan/connect were removed. diff --git a/changelog/+remote-protocol-robustness.fixed.md b/changelog/+remote-protocol-robustness.fixed.md new file mode 100644 index 00000000..653663ed --- /dev/null +++ b/changelog/+remote-protocol-robustness.fixed.md @@ -0,0 +1 @@ +Fix several USB/Bluetooth remote-control issues: a Bluetooth LE disconnect in the middle of a command no longer spins the main loop until the watchdog reboots the device; `$` memory dumps now stream one slot per tick instead of freezing the UI over Bluetooth; and error replies no longer echo leftover input bytes. diff --git a/changelog/+screenshot-binary.added.md b/changelog/+screenshot-binary.added.md new file mode 100644 index 00000000..ef1cc543 --- /dev/null +++ b/changelog/+screenshot-binary.added.md @@ -0,0 +1 @@ +Add a compact binary screenshot command `c` (raw little-endian RGB565 BMP). It is about half the size on the wire of the hex `C` command and decodes to a byte-identical image, which helps especially over the slower Bluetooth LE link. diff --git a/changelog/+screenshot-speed.changed.md b/changelog/+screenshot-speed.changed.md new file mode 100644 index 00000000..6eebc003 --- /dev/null +++ b/changelog/+screenshot-speed.changed.md @@ -0,0 +1 @@ +Greatly speed up screenshot capture (`C`): the framebuffer is now read directly and each row is sent in a single write instead of 54,400 per-pixel `printf` calls, freeing the main loop much sooner. The output is byte-for-byte identical. The USB TX buffer was also enlarged for smoother bulk transfers. diff --git a/docs/source/remote.md b/docs/source/remote.md index d9928917..cd0bbc9f 100644 --- a/docs/source/remote.md +++ b/docs/source/remote.md @@ -105,6 +105,7 @@ The ad hoc protocol is the main remote-control protocol. It can be used over: | o | Sleep Off | | | t | Toggle Log | Toggle the receiver monitor (log) on and off | | C | Screenshot | Capture a screenshot and print it as a BMP image in HEX format | +| c | Screenshot (binary) | Capture a screenshot as a raw little-endian RGB565 BMP (about half the bytes of `C`) | | $ | Show Memory Slots | Show memory slots in a format suitable for restoring them after the reset | | # | Set Memory Slot | Example `#01,VHF,107900000,FM` (slot, band, frequency, mode). Set freq to 0 to clear a slot. | | F | Set Frequency | Example `F107900000`. Frequency is in Hz and must stay within the current band. In SSB modes, sub-kHz digits set the BFO. | @@ -142,12 +143,47 @@ In SSB mode, the "Display" frequency (Hz) = (currentFrequency x 1000) + currentB #### Making screenshots -The screenshot function is intended for interface and theme designers, as well as for the documentation writers. It dumps the screen to the remote console as a BMP image in HEX format. To convert it to an image file, you need to convert the HEX string to binary format. +The screenshot function is intended for interface and theme designers, as well as for the documentation writers. There are two commands, both capturing the same 320×170 image; they differ only in how the pixels are encoded on the wire: + +| Command | Encoding | Size on the wire | Notes | +|--------------|----------------|------------------|-------------------------------------------| +| C | ASCII hex BMP | ~218 KB | Terminal-safe, widest tool support | +| c | Raw binary BMP | ~108 KB | About half of `C`; a standard `.bmp` file | + +##### `C` — hex BMP A quick one-liner for macOS and Linux over the **USB Serial** transport (change the `/dev/cu.usbmodem14401` serial port name as needed): ```shell -echo -n C | socat stdio /dev/cu.usbmodem14401,echo=0,raw | xxd -r -p > /tmp/screenshot.bmp +echo -n C | socat -T5 stdio /dev/cu.usbmodem14401,echo=0,raw | xxd -r -p > /tmp/screenshot.bmp +``` + +The `-T5` idle timeout makes `socat` exit a few seconds after the transfer finishes, instead of waiting until you press Ctrl-C. `xxd -r -p` ignores whitespace, so the carriage returns between rows are harmless. + +The `C` output format is stable and may be relied upon by tools: + +* A leading CRLF, then a single 132-hex-character header line (a 66-byte little-endian BMP header: 14-byte file header + 40-byte `BITMAPINFOHEADER` + 12-byte `BI_BITFIELDS` masks). +* 170 rows of exactly 1280 lowercase hex characters (320 px × 4), bottom-up, each terminated by CRLF. +* Geometry 320×170, 16 bpp RGB565, masks R = `0xF800`, G = `0x07E0`, B = `0x001F`. + +##### `c` — binary BMP + +`c` writes the same image as a raw little-endian BMP, preceded by an ASCII frame `BMP:\r\n` so a reader can find the start of the binary and pre-allocate. The decoded file is byte-for-byte identical to `xxd`-decoding the `C` output, in half the bytes: + +```shell +echo -n c | socat -T5 stdio /dev/cu.usbmodem14401,echo=0,raw \ + | python3 -c "import sys; d = sys.stdin.buffer.read(); sys.stdout.buffer.write(d.split(b'\r\n', 1)[1])" \ + > /tmp/screenshot.bmp +``` + +The `BMP:\r\n` frame is variable-length (the size has a varying number of digits), so the reader must discard everything up to and including the first `\r\n` rather than skip a fixed number of bytes; the snippet above splits on the first `\r\n` and writes the remaining bytes verbatim. + +```{note} +On the LILYGO T-Embed SI473X variant the panel uses BGR colour order, so the red and blue channels are swapped in both formats (this matches the existing `C` behaviour and is not specific to `c`). +``` + +```{note} +Screenshots over Bluetooth LE work but are slow because the link runs at roughly 2 KB/s; prefer the `c` binary format, or use the USB Serial connection. The raw `socat` one-liners above are written for the USB Serial transport. ``` ### Bluetooth HID protocol