qmk_firmware

QMK firmware for my keyboards (Corne, Sweep Ferris) and trackball (Ploopy Adept)
Log | Files | Refs | Submodules | LICENSE

bluefruit_le.cpp (20165B)


      1 #include "bluefruit_le.h"
      2 
      3 #include <stdio.h>
      4 #include <stdlib.h>
      5 #include <alloca.h>
      6 #include "debug.h"
      7 #include "timer.h"
      8 #include "gpio.h"
      9 #include "ringbuffer.hpp"
     10 #include <string.h>
     11 #include "spi_master.h"
     12 #include "wait.h"
     13 #include "analog.h"
     14 #include "progmem.h"
     15 
     16 // These are the pin assignments for the 32u4 boards.
     17 // You may define them to something else in your config.h
     18 // if yours is wired up differently.
     19 #ifndef BLUEFRUIT_LE_RST_PIN
     20 #    define BLUEFRUIT_LE_RST_PIN D4
     21 #endif
     22 
     23 #ifndef BLUEFRUIT_LE_CS_PIN
     24 #    define BLUEFRUIT_LE_CS_PIN B4
     25 #endif
     26 
     27 #ifndef BLUEFRUIT_LE_IRQ_PIN
     28 #    define BLUEFRUIT_LE_IRQ_PIN E6
     29 #endif
     30 
     31 #ifndef BLUEFRUIT_LE_SCK_DIVISOR
     32 #    define BLUEFRUIT_LE_SCK_DIVISOR 2 // 4MHz SCK/8MHz CPU, calculated for Feather 32U4 BLE
     33 #endif
     34 
     35 #define ConnectionUpdateInterval 1000 /* milliseconds */
     36 
     37 static struct {
     38     bool is_connected;
     39     bool initialized;
     40     bool configured;
     41 
     42 #define ProbedEvents 1
     43 #define UsingEvents 2
     44     bool event_flags;
     45 
     46     uint16_t last_connection_update;
     47 } state;
     48 
     49 // Commands are encoded using SDEP and sent via SPI
     50 // https://github.com/adafruit/Adafruit_BluefruitLE_nRF51/blob/master/SDEP.md
     51 
     52 #define SdepMaxPayload 16
     53 struct sdep_msg {
     54     uint8_t type;
     55     uint8_t cmd_low;
     56     uint8_t cmd_high;
     57     struct __attribute__((packed)) {
     58         uint8_t len : 7;
     59         uint8_t more : 1;
     60     };
     61     uint8_t payload[SdepMaxPayload];
     62 } __attribute__((packed));
     63 
     64 // The recv latency is relatively high, so when we're hammering keys quickly,
     65 // we want to avoid waiting for the responses in the matrix loop.  We maintain
     66 // a short queue for that.  Since there is quite a lot of space overhead for
     67 // the AT command representation wrapped up in SDEP, we queue the minimal
     68 // information here.
     69 
     70 enum queue_type {
     71     QTKeyReport, // 1-byte modifier + 6-byte key report
     72     QTConsumer,  // 16-bit key code
     73     QTMouseMove, // 4-byte mouse report
     74 };
     75 
     76 struct queue_item {
     77     enum queue_type queue_type;
     78     uint16_t        added;
     79     union __attribute__((packed)) {
     80         struct __attribute__((packed)) {
     81             uint8_t modifier;
     82             uint8_t keys[6];
     83         } key;
     84 
     85         uint16_t consumer;
     86         struct __attribute__((packed)) {
     87             int8_t  x, y, scroll, pan;
     88             uint8_t buttons;
     89         } mousemove;
     90     };
     91 };
     92 
     93 // Items that we wish to send
     94 static RingBuffer<queue_item, 40> send_buf;
     95 // Pending response; while pending, we can't send any more requests.
     96 // This records the time at which we sent the command for which we
     97 // are expecting a response.
     98 static RingBuffer<uint16_t, 2> resp_buf;
     99 
    100 static bool process_queue_item(struct queue_item *item, uint16_t timeout);
    101 
    102 enum sdep_type {
    103     SdepCommand       = 0x10,
    104     SdepResponse      = 0x20,
    105     SdepAlert         = 0x40,
    106     SdepError         = 0x80,
    107     SdepSlaveNotReady = 0xFE, // Try again later
    108     SdepSlaveOverflow = 0xFF, // You read more data than is available
    109 };
    110 
    111 enum ble_cmd {
    112     BleInitialize = 0xBEEF,
    113     BleAtWrapper  = 0x0A00,
    114     BleUartTx     = 0x0A01,
    115     BleUartRx     = 0x0A02,
    116 };
    117 
    118 enum ble_system_event_bits {
    119     BleSystemConnected    = 0,
    120     BleSystemDisconnected = 1,
    121     BleSystemUartRx       = 8,
    122     BleSystemMidiRx       = 10,
    123 };
    124 
    125 #define SdepTimeout 150             /* milliseconds */
    126 #define SdepShortTimeout 10         /* milliseconds */
    127 #define SdepBackOff 25              /* microseconds */
    128 #define BatteryUpdateInterval 10000 /* milliseconds */
    129 
    130 static bool at_command(const char *cmd, char *resp, uint16_t resplen, bool verbose, uint16_t timeout = SdepTimeout);
    131 static bool at_command_P(const char *cmd, char *resp, uint16_t resplen, bool verbose = false);
    132 
    133 // Send a single SDEP packet
    134 static bool sdep_send_pkt(const struct sdep_msg *msg, uint16_t timeout) {
    135     spi_start(BLUEFRUIT_LE_CS_PIN, false, 0, BLUEFRUIT_LE_SCK_DIVISOR);
    136     uint16_t timerStart = timer_read();
    137     bool     success    = false;
    138     bool     ready      = false;
    139 
    140     do {
    141         ready = spi_write(msg->type) != SdepSlaveNotReady;
    142         if (ready) {
    143             break;
    144         }
    145 
    146         // Release it and let it initialize
    147         spi_stop();
    148         wait_us(SdepBackOff);
    149         spi_start(BLUEFRUIT_LE_CS_PIN, false, 0, BLUEFRUIT_LE_SCK_DIVISOR);
    150     } while (timer_elapsed(timerStart) < timeout);
    151 
    152     if (ready) {
    153         // Slave is ready; send the rest of the packet
    154         spi_transmit(&msg->cmd_low, sizeof(*msg) - (1 + sizeof(msg->payload)) + msg->len);
    155         success = true;
    156     }
    157 
    158     spi_stop();
    159 
    160     return success;
    161 }
    162 
    163 static inline void sdep_build_pkt(struct sdep_msg *msg, uint16_t command, const uint8_t *payload, uint8_t len, bool moredata) {
    164     msg->type     = SdepCommand;
    165     msg->cmd_low  = command & 0xFF;
    166     msg->cmd_high = command >> 8;
    167     msg->len      = len;
    168     msg->more     = (moredata && len == SdepMaxPayload) ? 1 : 0;
    169 
    170     static_assert(sizeof(*msg) == 20, "msg is correctly packed");
    171 
    172     memcpy(msg->payload, payload, len);
    173 }
    174 
    175 // Read a single SDEP packet
    176 static bool sdep_recv_pkt(struct sdep_msg *msg, uint16_t timeout) {
    177     bool     success    = false;
    178     uint16_t timerStart = timer_read();
    179     bool     ready      = false;
    180 
    181     do {
    182         ready = gpio_read_pin(BLUEFRUIT_LE_IRQ_PIN);
    183         if (ready) {
    184             break;
    185         }
    186         wait_us(1);
    187     } while (timer_elapsed(timerStart) < timeout);
    188 
    189     if (ready) {
    190         spi_start(BLUEFRUIT_LE_CS_PIN, false, 0, BLUEFRUIT_LE_SCK_DIVISOR);
    191 
    192         do {
    193             // Read the command type, waiting for the data to be ready
    194             msg->type = spi_read();
    195             if (msg->type == SdepSlaveNotReady || msg->type == SdepSlaveOverflow) {
    196                 // Release it and let it initialize
    197                 spi_stop();
    198                 wait_us(SdepBackOff);
    199                 spi_start(BLUEFRUIT_LE_CS_PIN, false, 0, BLUEFRUIT_LE_SCK_DIVISOR);
    200                 continue;
    201             }
    202 
    203             // Read the rest of the header
    204             spi_receive(&msg->cmd_low, sizeof(*msg) - (1 + sizeof(msg->payload)));
    205 
    206             // and get the payload if there is any
    207             if (msg->len <= SdepMaxPayload) {
    208                 spi_receive(msg->payload, msg->len);
    209             }
    210             success = true;
    211             break;
    212         } while (timer_elapsed(timerStart) < timeout);
    213 
    214         spi_stop();
    215     }
    216     return success;
    217 }
    218 
    219 static void resp_buf_read_one(bool greedy) {
    220     uint16_t last_send;
    221     if (!resp_buf.peek(last_send)) {
    222         return;
    223     }
    224 
    225     if (gpio_read_pin(BLUEFRUIT_LE_IRQ_PIN)) {
    226         struct sdep_msg msg;
    227 
    228     again:
    229         if (sdep_recv_pkt(&msg, SdepTimeout)) {
    230             if (!msg.more) {
    231                 // We got it; consume this entry
    232                 resp_buf.get(last_send);
    233                 dprintf("recv latency %dms\n", TIMER_DIFF_16(timer_read(), last_send));
    234             }
    235 
    236             if (greedy && resp_buf.peek(last_send) && gpio_read_pin(BLUEFRUIT_LE_IRQ_PIN)) {
    237                 goto again;
    238             }
    239         }
    240 
    241     } else if (timer_elapsed(last_send) > SdepTimeout * 2) {
    242         dprintf("waiting_for_result: timeout, resp_buf size %d\n", (int)resp_buf.size());
    243 
    244         // Timed out: consume this entry
    245         resp_buf.get(last_send);
    246     }
    247 }
    248 
    249 static void send_buf_send_one(uint16_t timeout = SdepTimeout) {
    250     struct queue_item item;
    251 
    252     // Don't send anything more until we get an ACK
    253     if (!resp_buf.empty()) {
    254         return;
    255     }
    256 
    257     if (!send_buf.peek(item)) {
    258         return;
    259     }
    260     if (process_queue_item(&item, timeout)) {
    261         // commit that peek
    262         send_buf.get(item);
    263         dprintf("send_buf_send_one: have %d remaining\n", (int)send_buf.size());
    264     } else {
    265         dprint("failed to send, will retry\n");
    266         wait_ms(SdepTimeout);
    267         resp_buf_read_one(true);
    268     }
    269 }
    270 
    271 static void resp_buf_wait(const char *cmd) {
    272     bool didPrint = false;
    273     while (!resp_buf.empty()) {
    274         if (!didPrint) {
    275             dprintf("wait on buf for %s\n", cmd);
    276             didPrint = true;
    277         }
    278         resp_buf_read_one(true);
    279     }
    280 }
    281 
    282 void bluefruit_le_init(void) {
    283     state.initialized  = false;
    284     state.configured   = false;
    285     state.is_connected = false;
    286 
    287     gpio_set_pin_input(BLUEFRUIT_LE_IRQ_PIN);
    288 
    289     spi_init();
    290 
    291     // Perform a hardware reset
    292     gpio_set_pin_output(BLUEFRUIT_LE_RST_PIN);
    293     gpio_write_pin_high(BLUEFRUIT_LE_RST_PIN);
    294     gpio_write_pin_low(BLUEFRUIT_LE_RST_PIN);
    295     wait_ms(10);
    296     gpio_write_pin_high(BLUEFRUIT_LE_RST_PIN);
    297 
    298     wait_ms(1000); // Give it a second to initialize
    299 
    300     state.initialized = true;
    301 }
    302 
    303 static inline uint8_t min(uint8_t a, uint8_t b) {
    304     return a < b ? a : b;
    305 }
    306 
    307 static bool read_response(char *resp, uint16_t resplen, bool verbose) {
    308     char *dest = resp;
    309     char *end  = dest + resplen;
    310 
    311     while (true) {
    312         struct sdep_msg msg;
    313 
    314         if (!sdep_recv_pkt(&msg, 2 * SdepTimeout)) {
    315             dprint("sdep_recv_pkt failed\n");
    316             return false;
    317         }
    318 
    319         if (msg.type != SdepResponse) {
    320             *resp = 0;
    321             return false;
    322         }
    323 
    324         uint8_t len = min(msg.len, end - dest);
    325         if (len > 0) {
    326             memcpy(dest, msg.payload, len);
    327             dest += len;
    328         }
    329 
    330         if (!msg.more) {
    331             // No more data is expected!
    332             break;
    333         }
    334     }
    335 
    336     // Ensure the response is NUL terminated
    337     *dest = 0;
    338 
    339     // "Parse" the result text; we want to snip off the trailing OK or ERROR line
    340     // Rewind past the possible trailing CRLF so that we can strip it
    341     --dest;
    342     while (dest > resp && (dest[0] == '\n' || dest[0] == '\r')) {
    343         *dest = 0;
    344         --dest;
    345     }
    346 
    347     // Look back for start of preceeding line
    348     char *last_line = strrchr(resp, '\n');
    349     if (last_line) {
    350         ++last_line;
    351     } else {
    352         last_line = resp;
    353     }
    354 
    355     bool              success       = false;
    356     static const char kOK[] PROGMEM = "OK";
    357 
    358     success = !strcmp_P(last_line, kOK);
    359 
    360     if (verbose || !success) {
    361         dprintf("result: %s\n", resp);
    362     }
    363     return success;
    364 }
    365 
    366 static bool at_command(const char *cmd, char *resp, uint16_t resplen, bool verbose, uint16_t timeout) {
    367     const char     *end = cmd + strlen(cmd);
    368     struct sdep_msg msg;
    369 
    370     if (verbose) {
    371         dprintf("ble send: %s\n", cmd);
    372     }
    373 
    374     if (resp) {
    375         // They want to decode the response, so we need to flush and wait
    376         // for all pending I/O to finish before we start this one, so
    377         // that we don't confuse the results
    378         resp_buf_wait(cmd);
    379         *resp = 0;
    380     }
    381 
    382     // Fragment the command into a series of SDEP packets
    383     while (end - cmd > SdepMaxPayload) {
    384         sdep_build_pkt(&msg, BleAtWrapper, (uint8_t *)cmd, SdepMaxPayload, true);
    385         if (!sdep_send_pkt(&msg, timeout)) {
    386             return false;
    387         }
    388         cmd += SdepMaxPayload;
    389     }
    390 
    391     sdep_build_pkt(&msg, BleAtWrapper, (uint8_t *)cmd, end - cmd, false);
    392     if (!sdep_send_pkt(&msg, timeout)) {
    393         return false;
    394     }
    395 
    396     if (resp == NULL) {
    397         uint16_t now = timer_read();
    398         while (!resp_buf.enqueue(now)) {
    399             resp_buf_read_one(false);
    400         }
    401         uint16_t later = timer_read();
    402         if (TIMER_DIFF_16(later, now) > 0) {
    403             dprintf("waited %dms for resp_buf\n", TIMER_DIFF_16(later, now));
    404         }
    405         return true;
    406     }
    407 
    408     return read_response(resp, resplen, verbose);
    409 }
    410 
    411 bool at_command_P(const char *cmd, char *resp, uint16_t resplen, bool verbose) {
    412     char *cmdbuf = (char *)alloca(strlen_P(cmd) + 1);
    413     strcpy_P(cmdbuf, cmd);
    414     return at_command(cmdbuf, resp, resplen, verbose);
    415 }
    416 
    417 bool bluefruit_le_is_connected(void) {
    418     return state.is_connected;
    419 }
    420 
    421 bool bluefruit_le_enable_keyboard(void) {
    422     char resbuf[128];
    423 
    424     if (!state.initialized) {
    425         return false;
    426     }
    427 
    428     state.configured = false;
    429 
    430     // Disable command echo
    431     static const char kEcho[] PROGMEM = "ATE=0";
    432     // Make the advertised name match the keyboard
    433     static const char kGapDevName[] PROGMEM = "AT+GAPDEVNAME=" PRODUCT;
    434     // Turn on keyboard support
    435     static const char kHidEnOn[] PROGMEM = "AT+BLEHIDEN=1";
    436 
    437     // Adjust intervals to improve latency.  This causes the "central"
    438     // system (computer/tablet) to poll us every 10-30 ms.  We can't
    439     // set a smaller value than 10ms, and 30ms seems to be the natural
    440     // processing time on my macbook.  Keeping it constrained to that
    441     // feels reasonable to type to.
    442     static const char kGapIntervals[] PROGMEM = "AT+GAPINTERVALS=10,30,,";
    443 
    444     // Reset the device so that it picks up the above changes
    445     static const char kATZ[] PROGMEM = "ATZ";
    446 
    447     // Turn down the power level a bit
    448     static const char  kPower[] PROGMEM             = "AT+BLEPOWERLEVEL=-12";
    449     static PGM_P const configure_commands[] PROGMEM = {
    450         kEcho, kGapIntervals, kGapDevName, kHidEnOn, kPower, kATZ,
    451     };
    452 
    453     uint8_t i;
    454     for (i = 0; i < sizeof(configure_commands) / sizeof(configure_commands[0]); ++i) {
    455         PGM_P cmd;
    456         memcpy_P(&cmd, configure_commands + i, sizeof(cmd));
    457 
    458         if (!at_command_P(cmd, resbuf, sizeof(resbuf))) {
    459             dprintf("failed BLE command: %S: %s\n", cmd, resbuf);
    460             goto fail;
    461         }
    462     }
    463 
    464     state.configured = true;
    465 
    466     // Check connection status in a little while; allow the ATZ time
    467     // to kick in.
    468     state.last_connection_update = timer_read();
    469 fail:
    470     return state.configured;
    471 }
    472 
    473 static void set_connected(bool connected) {
    474     if (connected != state.is_connected) {
    475         if (connected) {
    476             dprint("BLE connected\n");
    477         } else {
    478             dprint("BLE disconnected\n");
    479         }
    480         state.is_connected = connected;
    481 
    482         // TODO: if modifiers are down on the USB interface and
    483         // we cut over to BLE or vice versa, they will remain stuck.
    484         // This feels like a good point to do something like clearing
    485         // the keyboard and/or generating a fake all keys up message.
    486         // However, I've noticed that it takes a couple of seconds
    487         // for macOS to to start recognizing key presses after BLE
    488         // is in the connected state, so I worry that doing that
    489         // here may not be good enough.
    490     }
    491 }
    492 
    493 void bluefruit_le_task(void) {
    494     char resbuf[48];
    495 
    496     if (!state.configured && !bluefruit_le_enable_keyboard()) {
    497         return;
    498     }
    499     resp_buf_read_one(true);
    500     send_buf_send_one(SdepShortTimeout);
    501 
    502     if (resp_buf.empty() && (state.event_flags & UsingEvents) && gpio_read_pin(BLUEFRUIT_LE_IRQ_PIN)) {
    503         // Must be an event update
    504         if (at_command_P(PSTR("AT+EVENTSTATUS"), resbuf, sizeof(resbuf))) {
    505             uint32_t mask = strtoul(resbuf, NULL, 16);
    506 
    507             if (mask & BleSystemConnected) {
    508                 set_connected(true);
    509             } else if (mask & BleSystemDisconnected) {
    510                 set_connected(false);
    511             }
    512         }
    513     }
    514 
    515     if (timer_elapsed(state.last_connection_update) > ConnectionUpdateInterval) {
    516         bool shouldPoll = true;
    517         if (!(state.event_flags & ProbedEvents)) {
    518             // Request notifications about connection status changes.
    519             // This only works in SPIFRIEND firmware > 0.6.7, which is why
    520             // we check for this conditionally here.
    521             // Note that at the time of writing, HID reports only work correctly
    522             // with Apple products on firmware version 0.6.7!
    523             // https://forums.adafruit.com/viewtopic.php?f=8&t=104052
    524             if (at_command_P(PSTR("AT+EVENTENABLE=0x1"), resbuf, sizeof(resbuf))) {
    525                 at_command_P(PSTR("AT+EVENTENABLE=0x2"), resbuf, sizeof(resbuf));
    526                 state.event_flags |= UsingEvents;
    527             }
    528             state.event_flags |= ProbedEvents;
    529 
    530             // leave shouldPoll == true so that we check at least once
    531             // before relying solely on events
    532         } else {
    533             shouldPoll = false;
    534         }
    535 
    536         static const char kGetConn[] PROGMEM = "AT+GAPGETCONN";
    537         state.last_connection_update         = timer_read();
    538 
    539         if (at_command_P(kGetConn, resbuf, sizeof(resbuf))) {
    540             set_connected(atoi(resbuf));
    541         }
    542     }
    543 }
    544 
    545 static bool process_queue_item(struct queue_item *item, uint16_t timeout) {
    546     char cmdbuf[48];
    547     char fmtbuf[64];
    548 
    549     // Arrange to re-check connection after keys have settled
    550     state.last_connection_update = timer_read();
    551 
    552 #if 1
    553     if (TIMER_DIFF_16(state.last_connection_update, item->added) > 0) {
    554         dprintf("send latency %dms\n", TIMER_DIFF_16(state.last_connection_update, item->added));
    555     }
    556 #endif
    557 
    558     switch (item->queue_type) {
    559         case QTKeyReport:
    560             strcpy_P(fmtbuf, PSTR("AT+BLEKEYBOARDCODE=%02x-00-%02x-%02x-%02x-%02x-%02x-%02x"));
    561             snprintf(cmdbuf, sizeof(cmdbuf), fmtbuf, item->key.modifier, item->key.keys[0], item->key.keys[1], item->key.keys[2], item->key.keys[3], item->key.keys[4], item->key.keys[5]);
    562             return at_command(cmdbuf, NULL, 0, true, timeout);
    563 
    564 #ifdef EXTRAKEY_ENABLE
    565         case QTConsumer:
    566             strcpy_P(fmtbuf, PSTR("AT+BLEHIDCONTROLKEY=0x%04x"));
    567             snprintf(cmdbuf, sizeof(cmdbuf), fmtbuf, item->consumer);
    568             return at_command(cmdbuf, NULL, 0, true, timeout);
    569 #endif
    570 
    571 #ifdef MOUSE_ENABLE
    572         case QTMouseMove:
    573             strcpy_P(fmtbuf, PSTR("AT+BLEHIDMOUSEMOVE=%d,%d,%d,%d"));
    574             snprintf(cmdbuf, sizeof(cmdbuf), fmtbuf, item->mousemove.x, item->mousemove.y, item->mousemove.scroll, item->mousemove.pan);
    575             if (!at_command(cmdbuf, NULL, 0, true, timeout)) {
    576                 return false;
    577             }
    578             strcpy_P(cmdbuf, PSTR("AT+BLEHIDMOUSEBUTTON="));
    579             if (item->mousemove.buttons & MOUSE_BTN1) {
    580                 strcat(cmdbuf, "L");
    581             }
    582             if (item->mousemove.buttons & MOUSE_BTN2) {
    583                 strcat(cmdbuf, "R");
    584             }
    585             if (item->mousemove.buttons & MOUSE_BTN3) {
    586                 strcat(cmdbuf, "M");
    587             }
    588             if (item->mousemove.buttons == 0) {
    589                 strcat(cmdbuf, "0");
    590             }
    591             return at_command(cmdbuf, NULL, 0, true, timeout);
    592 #endif
    593         default:
    594             return true;
    595     }
    596 }
    597 
    598 void bluefruit_le_send_keyboard(report_keyboard_t *report) {
    599     struct queue_item item;
    600 
    601     item.queue_type   = QTKeyReport;
    602     item.key.modifier = report->mods;
    603     item.key.keys[0]  = report->keys[0];
    604     item.key.keys[1]  = report->keys[1];
    605     item.key.keys[2]  = report->keys[2];
    606     item.key.keys[3]  = report->keys[3];
    607     item.key.keys[4]  = report->keys[4];
    608     item.key.keys[5]  = report->keys[5];
    609 
    610     while (!send_buf.enqueue(item)) {
    611         send_buf_send_one();
    612     }
    613 }
    614 
    615 void bluefruit_le_send_consumer(uint16_t usage) {
    616     struct queue_item item;
    617 
    618     item.queue_type = QTConsumer;
    619     item.consumer   = usage;
    620 
    621     while (!send_buf.enqueue(item)) {
    622         send_buf_send_one();
    623     }
    624 }
    625 
    626 void bluefruit_le_send_mouse(report_mouse_t *report) {
    627     struct queue_item item;
    628 
    629     item.queue_type        = QTMouseMove;
    630     item.mousemove.x       = report->x;
    631     item.mousemove.y       = report->y;
    632     item.mousemove.scroll  = report->v;
    633     item.mousemove.pan     = report->h;
    634     item.mousemove.buttons = report->buttons;
    635 
    636     while (!send_buf.enqueue(item)) {
    637         send_buf_send_one();
    638     }
    639 }
    640 
    641 bool bluefruit_le_set_mode_leds(bool on) {
    642     if (!state.configured) {
    643         return false;
    644     }
    645 
    646     // The "mode" led is the red blinky one
    647     at_command_P(on ? PSTR("AT+HWMODELED=1") : PSTR("AT+HWMODELED=0"), NULL, 0);
    648 
    649     // Pin 19 is the blue "connected" LED; turn that off too.
    650     // When turning LEDs back on, don't turn that LED on if we're
    651     // not connected, as that would be confusing.
    652     at_command_P(on && state.is_connected ? PSTR("AT+HWGPIO=19,1") : PSTR("AT+HWGPIO=19,0"), NULL, 0);
    653     return true;
    654 }
    655 
    656 // https://learn.adafruit.com/adafruit-feather-32u4-bluefruit-le/ble-generic#at-plus-blepowerlevel
    657 bool bluefruit_le_set_power_level(int8_t level) {
    658     char cmd[46];
    659     if (!state.configured) {
    660         return false;
    661     }
    662     snprintf(cmd, sizeof(cmd), "AT+BLEPOWERLEVEL=%d", level);
    663     return at_command(cmd, NULL, 0, false);
    664 }