$8 Macro Keyboard


I built a small macro keyboard some years ago but have been unhappy with the number of keys I put on the project. It works great, click click click. eventually I added a shift key to the macro keyboard which doubled the number of macro keys, but if you think about it, defeats the whole purpose of a macro keyboard! It does have other cool features like the ability to read IR signals and translate them to keystrokes….a feature I use for specific applications. I also uses a single analog input line to distinguish the keys from each other. The project got me well versed in using HID enabled Arduino (Leonardo compatible ProMicro in this case) as well as the keyboard.h library.

Meanwhile, I have these slightly older iPads that I’ve been wanting to repurpose. Actually, I just want them to do anything other than sit on a shelf unused. It then occurred to me (doubtful it’s an original thought) to use the iPad as a macro keyboard. I could use a wifi and HID capable Arduino to translate a reasonable GUI over private wifi to the arduino to do all the heavy lifting and translations into the incantations needed to push keystrokes up USB to the target PC. At a functional level the Arduino (esp) will be a full web server and a wifi router with DHCP along with the USB connection to the PC host for sending keystrokes…. the esp connected to the usb port is is the complete ‘backend’ and the iPad is the front end. It is a simple and classic client/server model. BUT… rather than continue describing it here, let me share the requirements I gave to ChatGPT to get the project rolling…..

Macro Keyboard (MK) project prompt

Overview: ESP HID feature used to send keystrokes to the “PC” via USB. Uses an ipad for user interaction via webpages served over a private wifi (SSID: macro, PSWD: ******). ESP addressed at 10.10.10.1 which is the address the ipad will use to connect to the MK (esp) wifi. The ipad has 1) macro screen and 2) config screen both served by the esp/MK device via wifi. the macro screen is a matrix of squares 6 squares horizontal and 4 squares vertical. perhaps only fyi the ipad is expected to be used in landscape orientation. Each “button” will have a configured color, text color black or white, label, and of course its associated keystroke sequence (more info in config discussion). each button will have a number 1-24 for config purposes starting in top left, row by row, bottom right is 24. the numbers are not visible on the main button screen. the numbers are for config purposes and internal design. button #1 will invoke the CONFIG screen.

Config: users will be served a table of attributes for a single mkey (macro key) input is collected as follows…. 1)mkey number, 2) text color [B or W] for black or white, 3) key color entered as thre integers for RGB [eg 123,0,45] and 4) the ascii string to be sent to the PC when the mkey is pressed. buttons on the bottom on the screen are 1) cancel 2) save, either of which returns the user to the main MK screen. ALL config info SHALL be saved in persistent memory on the ESP/MK; not on the PC and not on the ipad.

config consideration: we need a way for the user to specify special keys. I suggest use of keyboard.h definitions and standard ascii values. We should use the native encoding (decimal) to minimize impact on code (no requirement to map names to numbers). if we use this simple method of treating all keys as simple keys, we will need a way to “hold” modifier keys such as SHIFT and a separate mechanism to “unhold” all keys. So we need a scheme such as “\128\a\^\bc” to demarcate the ascii code “\” and use “^” to release the held keys. in this example code sent to the PC host would be “Abc”. these mechanism are for user configiguration and an attempt to keep the code lightweight and relatively easy for the user. if an alternative mechanism preserves those requirements may be used. minimal error checking on user following the parsing rules is acceptable especially with early code drafts.

Bias considerations: I really want to keep the software stack simple. Avoid additional software packages unless doing so would cause an unreasonable amount of additional coding. additional layers in the esp software stack tend to be difficult to configure and result in more brittle code.

What did I miss? What questions do you have?

CPP code generated by ChatGPT

// Macro Keyboard (MK) Project
// ESP32 + USB HID + Wi-Fi Web Interface (6x4 layout)
// Config stored as JSON file in SPIFFS

#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>
#include <FS.h>
#include <SPIFFS.h>

#if defined(ARDUINO_USB_MODE) || defined(ESP32S3) || defined(ESP32S2)
  #include <USB.h>
  #include <USBHIDKeyboard.h>
  USBHIDKeyboard Keyboard;
#else
  #define Keyboard Serial
#endif

#define MACRO_WIFI_SSID     "macro"
#define MACRO_WIFI_PASS     "********"
#define MACRO_WIFI_CHANNEL  1
#define MACRO_IP            10,10,10,1
#define MACRO_GATEWAY       10,10,10,1
#define MACRO_SUBNET        255,255,255,0

#define CONFIG_PATH         "/config.json"
#define NUM_BUTTONS         30

struct ButtonConfig {
  uint8_t id;
  char label[9]; // 8 chars max + null
  char textColor; // 'B' or 'W'
  char color[13]; // RGB string: "255,255,255"
  char seq[64];   // HID sequence string
};

ButtonConfig buttons[NUM_BUTTONS];
WebServer server(80);

const char* defaultConfig = R"rawliteral([
  {"id":1,"label":"a","text":"B","color":"100,10,100","seq":""},
  {"id":2,"label":"b","text":"W","color":"2,0,0","seq":"\\129a\\^"},
  {"id":3,"label":"c","text":"B","color":"0,255,0","seq":"r"}
   ])rawliteral";

void loadDefaultConfig() {
  StaticJsonDocument<2048> doc;
  deserializeJson(doc, defaultConfig);
  for (JsonObject obj : doc.as<JsonArray>()) {
    uint8_t id = obj["id"].as<uint8_t>();
    if (id < 1 || id > NUM_BUTTONS) continue;
    ButtonConfig& b = buttons[id - 1];
    b.id = id;
    strlcpy(b.label, obj["label"], sizeof(b.label));
    const char* tc = obj["text"];
    b.textColor = (tc && strlen(tc) > 0) ? tc[0] : 'B';
    strlcpy(b.color, obj["color"], sizeof(b.color));
    strlcpy(b.seq, obj["seq"], sizeof(b.seq)); }}

bool loadConfigFromSPIFFS() {
  if (!SPIFFS.exists(CONFIG_PATH)) return false;
  File f = SPIFFS.open(CONFIG_PATH, "r");
  if (!f) return false;
  StaticJsonDocument<4096> doc;
  DeserializationError err = deserializeJson(doc, f);
  f.close();
  if (err) return false;
  for (JsonObject obj : doc.as<JsonArray>()) {
    uint8_t id = obj["id"].as<uint8_t>();
    if (id < 1 || id > NUM_BUTTONS) continue;
    ButtonConfig& b = buttons[id - 1];
    b.id = id;
    strlcpy(b.label, obj["label"], sizeof(b.label));
    const char* tc = obj["text"];
    b.textColor = (tc && strlen(tc) > 0) ? tc[0] : 'B';
    strlcpy(b.color, obj["color"], sizeof(b.color));
    strlcpy(b.seq, obj["seq"], sizeof(b.seq));
  }
  return true;}

void saveConfigToSPIFFS() {
  StaticJsonDocument<4096> doc;
  for (int i = 0; i < NUM_BUTTONS; i++) {
    JsonObject obj = doc.createNestedObject();
    obj["id"] = buttons[i].id;
    obj["label"] = buttons[i].label;
    obj["text"] = String(buttons[i].textColor);
    obj["color"] = buttons[i].color;
    obj["seq"] = buttons[i].seq; }
    
  File f = SPIFFS.open(CONFIG_PATH, "w");
  if (!f) return;
  serializeJson(doc, f);
  f.close();}

void sendSequence(const char* seq) {
  const char* p = seq;
  while (*p) {
    if (*p == '\\') {
      p++;
      if (*p == '^') {
        Keyboard.releaseAll();
        p++;
      } else if (isdigit(*p)) {
        int code = 0;
        while (isdigit(*p)) {
          code = code * 10 + (*p - '0');
          p++;
        }
        Keyboard.press(code); delay(5); }
    } else {
      Keyboard.press(*p);
      delay(5);
      Keyboard.release(*p);
      p++;
    }}
  Keyboard.releaseAll();}

void handleConfigPage() {
  String html = "<html><head><style>";
  html += "textarea,input,button{font-size:48px;} body{margin:20px;}";
  html += ".top-buttons{position:fixed;top:20px;right:20px;}";
  html += "a.linkbtn{font-size:48px;margin-left:20px;text-decoration:none;}";
  html += "</style></head><body><form method='POST'>";
  html += "<h1>Configuration <a href='/' style='text-decoration:none;color:#333;'>[CANCEL]</a> <a href='#' onclick='this.closest(\"form\").submit();return false;' style='text-decoration:none;color:#333;'>[SAVE]</a></h1>";
  html += "<textarea name='cfg' rows='30' cols='80'>";
  StaticJsonDocument<4096> doc;
  for (int i = 0; i < NUM_BUTTONS; i++) {
    JsonObject obj = doc.createNestedObject();
    obj["id"] = buttons[i].id;
    obj["label"] = buttons[i].label;
    obj["text"] = String(buttons[i].textColor);
    obj["color"] = buttons[i].color;
    obj["seq"] = buttons[i].seq;}
  String jsonOut;
  serializeJson(doc, jsonOut);
  jsonOut.replace("},", "},\n");
  html += jsonOut;
  html += "</textarea></form></body></html>";
  server.send(200, "text/html", html);}

void handleConfigSave() {
  if (server.hasArg("cfg")) {
    StaticJsonDocument<4096> doc;
    DeserializationError err = deserializeJson(doc, server.arg("cfg"));
    if (!err) {
      for (JsonObject obj : doc.as<JsonArray>()) {
        uint8_t id = obj["id"].as<uint8_t>();
        if (id < 1 || id > NUM_BUTTONS) continue;
        ButtonConfig& b = buttons[id - 1];
        b.id = id;
        strlcpy(b.label, obj["label"], sizeof(b.label));
        const char* tc = obj["text"];
        b.textColor = (tc && strlen(tc) > 0) ? tc[0] : 'B';
        strlcpy(b.color, obj["color"], sizeof(b.color));
        strlcpy(b.seq, obj["seq"], sizeof(b.seq));
      }
      saveConfigToSPIFFS();
    }
  }
  server.sendHeader("Location", "/"); server.send(303);}

void handlePress() {
  if (!server.hasArg("id")) return;
  int id = server.arg("id").toInt();
  if (id < 1 || id > NUM_BUTTONS) return;
  ButtonConfig& b = buttons[id - 1];
  sendSequence(b.seq);
  server.sendHeader("Location", "/");
  server.send(303); }

void handleMainPage() {
  String html = "<html><head><title>Macro Keyboard</title><style>";
  html += "body{font-family:sans-serif;} .grid{display:grid;grid-template-columns:repeat(6,1fr);gap:5px;} ";    //font size below 
  html += ".btn{display:flex;justify-content:center;align-items:center;height:100px;border-radius:6px;font-size:24px;font-weight:bold;text-align:center;text-decoration:none;}";
  html += "#cfg{position:absolute;top:20px;right:20px;width:120px;height:40px;background:#333;color:#fff;display:flex;justify-content:center;align-items:center;text-decoration:none;border-radius:6px;font-size:16px;font-weight:bold;}";
  html += "</style></head><body><script>setInterval(() => { fetch('/ping').catch(() => {}); }, 5000);</script><div class='grid'>";

  for (int i = 0; i < NUM_BUTTONS; i++) {
    ButtonConfig& b = buttons[i];
    String fg = (b.textColor == 'W') ? "white" : "black";
    html += "<a href='#' onclick='press(" + String(b.id) + ")' class=\"btn\" style=\"background-color:rgb(" + String(b.color) + ");color:" + fg + ";\">";
    html += b.label; html += "</a>";  }
    html += "<h3 style='margin-bottom:20px;'><a href='/config' style='text-decoration:none;color:#333;'>[CONFIG]</a></h3>";
    html += "<script>\n";
    html += "function press(id){\n";
    html += " fetch('/press?id=' + id).catch(()=>{});\n";
    html += "}\n";
    html += "</script>\n";
    html += "</body></html>";

  server.send(200, "text/html", html);}

void setupWiFi() {WiFi.setSleep(false);
  WiFi.softAP(MACRO_WIFI_SSID, MACRO_WIFI_PASS, MACRO_WIFI_CHANNEL);
  delay(100);
  WiFi.softAPConfig(IPAddress(MACRO_IP), IPAddress(MACRO_GATEWAY), IPAddress(MACRO_SUBNET)); }

void setup() {
  #if defined(ARDUINO_USB_MODE) || defined(ESP32S3) || defined(ESP32S2)
    USB.begin();
    Keyboard.begin();
  #else
    Keyboard.println("[MK] HID not available on this board. Using Serial simulation.");
  #endif

  SPIFFS.begin(true);
  setupWiFi();

  if (!loadConfigFromSPIFFS()) {loadDefaultConfig(); saveConfigToSPIFFS(); }
  server.on("/ping", HTTP_GET, []() {server.send(200, "text/plain", "pong");});
  server.on("/config", HTTP_GET, handleConfigPage);
  server.on("/config", HTTP_POST, handleConfigSave);
  server.on("/press", HTTP_GET, handlePress);
  server.on("/", HTTP_GET, handleMainPage);
  server.begin();}

void loop() {server.handleClient();delay(1);}

Persistent JSON Configuration

ChatGPT proposed capturing the config in JSON using SPIFFS for storage on the esp32. I have not done much with JSON so was nervous at first and never heard of SPIFFS. The end combination worked out just fine. As mentioned above the user can switch to the config screen and edit the json config file, save it to the esp, and immediately return to the MK. But its ‘just’ and edit window so I did not bother to include it in this writeup. I expanded the size of the layout (number of keys) and the JSON which is managed via the config screen looks like this…..

JSON Configuration file

The hardware & heart of it all

The ESP32-S3-DevKitC-1-N16R8 Development Board Integrated 2.4GHz WiFi Bluetooth 5.0 ESP32-S3-WROOM-1 Microcontroller Processor Dual Type C Interface for Arduino (https://www.amazon.com/dp/B0DKF4DSQ4) proved to be ideal for this project. Fast, ample memory, wifi, HID, Bluetooth (not used on this project. Reset and Boot buttons. Most importantly it has 2 USB ports, one for HID and one for Serial interface and downloading. Two ports is a big deal because with the single port esp32 Arduinos once the HID is programed you are unable to use the USB port for downloading. At this point you have to restore (reset) the esp so that the serial port shows up in your IDE so you can download changes which will inevitably disable the serial port again. It’s a real pain in the ass. No such problems with this dual ported esp32. Note I am saying ESP. I never had these problems with my pro micro (Atmel) which has been my favorite platform for many years.

And it looks like this