diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b735ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work diff --git a/README.md b/README.md new file mode 100644 index 0000000..0912e55 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# 3x3 Macropad: PCB, Firmware, STL, Programmer + +Self-designed, hacked-together 3x3 macropad. + +![ml8\_9.jpg](ml8_9.jpg) + +Features: + +* 4 layers configurable with [via](https://www.caniusevia.com/). +* OLED display to display per-layer text (programmable with tool in + [`kbp/`](kbp/README.md)). +* Rotary encoder with volume control and layer select button. +* 256Kb EEPROM (24LC256). + +Project layout: + +* [`kbp/`](kbp/README.md) - tool to program keyboard (primarily to change OLED text) +* [`firmware/`](firmware/README.md) - [QMK](https://qmk.fm/)-based firmware (TODO: merge into QMK) +* [`via/`](via/README.md) - Via json config (TODO: merge into via) that can be + used for programming (once merged into via, this should no longer be + necessary). +* [`hardware/`](hardware/README.md) Gerber file for PCB and STL/3mf for + enclosure. diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..58ff048 --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,15 @@ +# Firmware + +You can download the firmware for the macropad from the releases in this +repository. Use the [QMK toolbox](https://github.com/qmk/qmk_toolbox/releases) +to flash the firmware. + +If you want to build it yourself and modify it, you'll need to install +[QMK](https://qmk.fm). Then, you can copy or simlink the `keyboards/ml8` +directory into the QMK repo's `keyboards` directory and build normally. + +``` +[qmk_firmware/] $ make ml8/ml8_9:via +``` + +Firmware readme [here](keyboards/ml8/ml8_9/readme.md). diff --git a/firmware/keyboards/ml8/ml8_9/.gitignore b/firmware/keyboards/ml8/ml8_9/.gitignore new file mode 100644 index 0000000..b3cd5cf --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/.gitignore @@ -0,0 +1 @@ +.ycm_extra_conf.py diff --git a/firmware/keyboards/ml8/ml8_9/base.c b/firmware/keyboards/ml8/ml8_9/base.c new file mode 100644 index 0000000..ace9099 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/base.c @@ -0,0 +1,65 @@ +#include "base.h" + +#include "config.h" +#include "persistence.h" + +#include +#include + +#include "action.h" +#include "action_layer.h" +#include "debug.h" +#include "print.h" +#include "wait.h" + +bool g_post_init = 0; // true iff initialization is complete + +bool is_post_init(void) { + return g_post_init; +} + +void keyboard_post_init_user(void) { +#if defined(CONSOLE_ENABLE) + debug_matrix = true; + debug_enable = true; + // when debug mode/console is active, wait a bit to connect. + wait_ms(1000); + dprint("hi 0v0\n"); +#endif + + // initialize layer labels + persistence_init(); + + g_post_init = 1; +} + +// Move to next layer +void cycle_layer(void) { + uint8_t curr = get_highest_layer(layer_state); + if (curr < 0 || curr >= LAYER_COUNT) { + return; + } + layer_move((curr + 1) % LAYER_COUNT); +} + +// Listen for custom keycode +bool process_record_user(uint16_t keycode, keyrecord_t *record) { + switch (keycode) { + case KC_CYCLE_LAYERS: + if (!record->event.pressed) { + return false; + } + cycle_layer(); + return false; + default: + return true; + } +} + +uint16_t keycode_config(uint16_t keycode) { + return keycode; +} + +uint8_t mod_config(uint8_t mod) { + return mod; +} diff --git a/firmware/keyboards/ml8/ml8_9/base.h b/firmware/keyboards/ml8/ml8_9/base.h new file mode 100644 index 0000000..00de242 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/base.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include "keycodes.h" + +// Layer names, for convenience. +enum layers { L_MEDIA, L_ZOOM, L_NUM, L_UNDEF }; +// Keycode for cycling between layers. +enum keycodes { + KC_CYCLE_LAYERS = QK_USER, +}; + +bool is_post_init(void); diff --git a/firmware/keyboards/ml8/ml8_9/config.h b/firmware/keyboards/ml8/ml8_9/config.h new file mode 100644 index 0000000..9911d30 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/config.h @@ -0,0 +1,27 @@ +#pragma once + +// Reduce firmware size +#undef LOCKING_SUPPORT_ENABLE +#undef LOCKING_RESYNC_ENABLE +#define NO_ACTION_ONESHOT +#define NO_ACTION_TAPPING +#define NO_MUSIC_MODE +// Reduce firmware size but limit to 8 layers +#define LAYER_STATE_8BIT + +// clang-format off +#define ENCODERS_PAD_A { B5 } +#define ENCODERS_PAD_B { B4 } +#define ENCODER_RESOLUTION 4 +// clang-format on + +// Enable external EEPROM +#define EEPROM_I2C_24LC256 +// reserve 8k for our use +#define VIA_EEPROM_CUSTOM_CONFIG_SIZE (1 << 13) + +// Layer config +#define LAYER_COUNT 4 + +// Enable storing configuration in eeprom +#define EEPROM_CFG diff --git a/firmware/keyboards/ml8/ml8_9/hid_codes.h b/firmware/keyboards/ml8/ml8_9/hid_codes.h new file mode 100644 index 0000000..731b88a --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/hid_codes.h @@ -0,0 +1,26 @@ +#pragma once + +#define HID_CODE_HEADER 0x6d6c + +enum hid_commands { + // Basic protocol definitions. + HID_CMD_NOOP = 0x00, + HID_CMD_ERR = 0x01, + HID_CMD_ACK = 0x02, + HID_CMD_NACK = 0x03, + HID_CMD_CONT = 0x04, + HID_CMD_ABORT = 0x05, + HID_CMD_COMPLETE = 0x06, + + // Debug commands; hello and echo + HID_CMD_HELLO = 0x30, // 0 + HID_CMD_ECHO = 0x31, + + // Control commands + HID_CMD_OLED_OFF = 0x40, // @ + HID_CMD_OLED_ON = 0x41, + + // OLED programming commands + HID_CMD_OLED_UPDATE = 0x50, // P + HID_CMD_OLED_RESET = 0x51, +}; diff --git a/firmware/keyboards/ml8/ml8_9/hid_handlers.c b/firmware/keyboards/ml8/ml8_9/hid_handlers.c new file mode 100644 index 0000000..d962033 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/hid_handlers.c @@ -0,0 +1,254 @@ +#include "hid_handlers.h" + +#include +#include +#include + +#include "config.h" +#include "hid_codes.h" +#include "oled_handlers.h" +#include "persistence.h" + +#include "debug.h" +#include "print.h" +#include "raw_hid.h" + +// Transfer state -- transfers via hid are chunked at 32 bytes +struct transfer_state { + uint8_t cur_operation; // inflight operation + uint8_t cur_layer; // layer operation applies to + uint8_t buffer_offset; // next offset to write to + oled_text_t buffer; // buffer to use for chunked operations +} g_transfer_state; + +// Clear transfer state +void reset_transfer_state(void) { + g_transfer_state.buffer_offset = 0; + g_transfer_state.cur_operation = HID_CMD_NOOP; +} + +// Send a nack +void nack_hid_message(void) { + dprintf("sending nack.\n"); + uint8_t buffer[32]; // 32-byte buffer required for sends + buffer[0] = HID_CMD_NACK; + raw_hid_send(buffer, 32); +} + +// Send an ack +void ack_hid_message(uint8_t cmd) { + dprintf("sending ack for cmd %d\n", cmd); + uint8_t buffer[32]; // 32-byte buffer required for sends + buffer[0] = HID_CMD_ACK; + buffer[1] = cmd; + raw_hid_send(buffer, 32); +} + +// Complete an in-flight layer text update. +void complete_oled_layer_update(void) { + g_transfer_state.buffer[g_transfer_state.buffer_offset] = '\0'; + oled_layer_update(g_transfer_state.cur_layer, g_transfer_state.buffer, g_transfer_state.buffer_offset); + reset_transfer_state(); +} + +// Begin or continue an oled layer update. +// oled layer update messages have the following format: < L N data... > where +// L is the layer number and N is the length of the data that follows. +bool start_or_continue_oled_layer_update(uint8_t cmd, uint8_t *buffer) { + uint8_t layer = buffer[0]; + uint8_t len = buffer[1]; + dprintf("layer %d; text len %d\n", layer, len); + + if (len > 27) { + // 32 bytes minus 5 byte header + dprintf("message malformed; length of %d\n", len); + return false; + } + + if (layer < 0 || layer >= LAYER_COUNT) { + dprintf("invalid layer number %d\n", layer); + return false; + } + + // validate command + switch (cmd) { + case HID_CMD_OLED_UPDATE: + // a new transfer; consider previous aborted. + dprintf("new transfer; considering any in-flight aborted.\n"); + reset_transfer_state(); + g_transfer_state.cur_operation = HID_CMD_OLED_UPDATE; + break; + + case HID_CMD_CONT: + case HID_CMD_COMPLETE: + // an inflight transfer; validate that the transfer is known and + // consistent. + if (g_transfer_state.cur_layer != layer) { + // layer changed mid-transfer! + dprintf("layer has changed from %d to %d\n", g_transfer_state.cur_layer, layer); + + return false; + } + break; + default: + dprintf("unsupported command %d!\n", cmd); + // TODO here and below: send HID_CMD_ERR + return false; + } + + if (cmd == HID_CMD_COMPLETE) { + dprintf("completing update\n"); + // complete the update + complete_oled_layer_update(); + reset_transfer_state(); + return true; + } + + g_transfer_state.cur_layer = layer; + + if (len < 1) { + // no data to buffer + dprintf("empty message\n"); + } else if (len + g_transfer_state.buffer_offset > sizeof(g_transfer_state.buffer)) { + // TODO move this check when cleaning up code. + // too much data to buffer. + dprintf("buffered data too large: %d\n", len + g_transfer_state.buffer_offset); + return false; + } else { + // buffer data + dprintf("buffering %d bytes...\n", len); + strncpy(&g_transfer_state.buffer[g_transfer_state.buffer_offset], (char *)&buffer[2], len); + g_transfer_state.buffer_offset += len; + // null terminate for debug XXX + g_transfer_state.buffer[g_transfer_state.buffer_offset] = '\0'; + dprintf("current buffer: %s\n", g_transfer_state.buffer); + } + return true; +} + +// Handle a hid command that may require multiple rounds. +void start_or_continue_hid_command(uint8_t cmd, uint8_t *buffer) { + // Only chunked transfer is oled for now. + if (!start_or_continue_oled_layer_update(cmd, buffer)) { + dprintf("transfer failed\n"); + reset_transfer_state(); + nack_hid_message(); + return; + } + + ack_hid_message(cmd); + return; +} + +// Echo a message +void hid_echo(uint8_t *buffer) { + uint8_t snd[32]; + snd[0] = HID_CMD_ACK; + // 2 byte header + 1 byte cmd + 1 byte ACK -> 28 bytes remaining. + memcpy(&snd[1], buffer, 28); + raw_hid_send(snd, 32); +} + +// Say hi +void hid_hello(void) { + uint8_t snd[32]; + snd[0] = HID_CMD_ACK; + snd[1] = HID_CMD_HELLO; + // 30 bytes remaining + strncpy((char *)&snd[2], "hello world", 30); + raw_hid_send(snd, 32); +} + +// Given a hid command and associated data, dispatch to handler. +bool handle_hid_command(int16_t cmd, uint8_t *buffer) { + bool oled_on = true; + switch (cmd) { + case HID_CMD_HELLO: + hid_hello(); + break; + + case HID_CMD_ECHO: + hid_echo(buffer); + break; + + case HID_CMD_OLED_OFF: + oled_on = false; + // fallthrough intentional. + case HID_CMD_OLED_ON: + set_oled_state(oled_on); + dprintf("Setting oled state to on = %d\n", oled_on); + ack_hid_message(cmd); + break; + + case HID_CMD_OLED_RESET: + dprintf("resetting oled state\n"); + reset_layer_labels(); + ack_hid_message(cmd); + break; + + case HID_CMD_OLED_UPDATE: + case HID_CMD_COMPLETE: + case HID_CMD_CONT: + dprintf("starting or continuing OLED update; current command %d, inflight " + "operation %d\n", + cmd, g_transfer_state.cur_operation); + start_or_continue_hid_command(cmd, buffer); + break; + + default: + // TODO: should this be passed to via to handle? i.e. return false. + dprintf("unknown command %d\n", cmd); + nack_hid_message(); + break; + } + + return true; +} + +// Returns -1 on error or parsed command +int16_t validate_hid_message(uint8_t *data) { + uint16_t header = data[0] << 8 | data[1]; // fix message endianness + dprintf("Header was: %d \n", header); + dprintf("Data is: %d %d\n", data[0], data[1]); + if (header != HID_CODE_HEADER) { + return -1; + } + + if (debug_enable) { + dprintf("Got: "); + for (int i = 0; i < 32; i++) { + dprintf("%d ", data[i]); + } + dprintf("\n"); + } + + uint8_t cmd = data[2]; + dprintf("cmd %d\n", cmd); + + return cmd; +} + +// Returns false iff via should handle the message. +// Message format is , where C is a command from hid_codes.h +bool user_hid_receive(uint8_t *data, uint8_t length) { + // length is meaningless here, it will always be a 32-byte frame. + dprintf("received hid message.\n"); + int16_t cmd = validate_hid_message(data); + if (cmd < 0) { + // not for us. + return false; + } + return handle_hid_command(cmd, &data[3]); +} + +#if defined(VIA_ENABLE) +// When via is enabled, we get a callback from via. +bool via_command_kb(uint8_t *data, uint8_t length) { + return user_hid_receive(data, length); +} +#else +// When via is disabled, we own hid messages. +void raw_hid_receive(uint8_t *data, uint8_t length) { + user_hid_receive(data, length); +} +#endif diff --git a/firmware/keyboards/ml8/ml8_9/hid_handlers.h b/firmware/keyboards/ml8/ml8_9/hid_handlers.h new file mode 100644 index 0000000..6f70f09 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/hid_handlers.h @@ -0,0 +1 @@ +#pragma once diff --git a/firmware/keyboards/ml8/ml8_9/info.json b/firmware/keyboards/ml8/ml8_9/info.json new file mode 100644 index 0000000..275fa58 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/info.json @@ -0,0 +1,40 @@ +{ + "manufacturer": "Marion Lang", + "keyboard_name": "ml8_9", + "maintainer": "ml8", + "bootloader": "caterina", + "diode_direction": "COL2ROW", + "features": { + "bootmagic": false, + "nkro": true + }, + "matrix_pins": { + "cols": ["B6", "B2", "B3"], + "rows": ["E6", "D7", "C6", "D4"] + }, + "processor": "atmega32u4", + "url": "", + "usb": { + "device_version": "1.0.0", + "pid": "0x3333", + "vid": "0x6D6C" + }, + "layouts": { + "LAYOUT_ortho_3x4": { + "layout": [ + {"matrix": [0, 0], "x": 0, "y": 0}, + {"matrix": [0, 1], "x": 1, "y": 0}, + {"matrix": [0, 2], "x": 2, "y": 0}, + {"matrix": [1, 0], "x": 0, "y": 1}, + {"matrix": [1, 1], "x": 1, "y": 1}, + {"matrix": [1, 2], "x": 2, "y": 1}, + {"matrix": [2, 0], "x": 0, "y": 2}, + {"matrix": [2, 1], "x": 1, "y": 2}, + {"matrix": [2, 2], "x": 2, "y": 2}, + {"matrix": [3, 0], "x": 0, "y": 3}, + {"matrix": [3, 1], "x": 1, "y": 3}, + {"matrix": [3, 2], "x": 2, "y": 3} + ] + } + } +} diff --git a/firmware/keyboards/ml8/ml8_9/keymaps/default/keymap.c b/firmware/keyboards/ml8/ml8_9/keymaps/default/keymap.c new file mode 100644 index 0000000..ec36fb2 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/keymaps/default/keymap.c @@ -0,0 +1,36 @@ +#include "base.h" + +#include QMK_KEYBOARD_H + +#if defined(ENCODER_MAP_ENABLE) +const uint16_t PROGMEM encoder_map[][NUM_ENCODERS][NUM_DIRECTIONS] = {[0] = {ENCODER_CCW_CW(KC_VOLU, KC_VOLD)}}; +#endif + +const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = { + // clang-format off + [L_MEDIA] = LAYOUT_ortho_3x4( + KC_MEDIA_PREV_TRACK, KC_MEDIA_PLAY_PAUSE, KC_MEDIA_NEXT_TRACK, + KC_MEDIA_STOP, KC_AUDIO_MUTE, KC_UP, + KC_LEFT, KC_RIGHT, KC_DOWN, + KC_CYCLE_LAYERS, KC_NO, KC_NO + ), + [L_ZOOM] = LAYOUT_ortho_3x4( + KC_SPACE, SGUI(KC_A), SGUI(KC_V), + SGUI(KC_F), LGUI(KC_W), KC_ENTER, + LOPT(KC_Y), KC_NO, KC_NO, + KC_CYCLE_LAYERS, KC_NO, KC_NO + ), + [L_NUM] = LAYOUT_ortho_3x4( + KC_1, KC_2, KC_3, + KC_4, KC_5, KC_6, + KC_7, KC_8, KC_9, + KC_CYCLE_LAYERS, KC_NO, KC_NO + ), + [L_UNDEF] = LAYOUT_ortho_3x4( + KC_NO, KC_NO, KC_NO, + KC_NO, KC_NO, KC_NO, + KC_NO, KC_NO, KC_NO, + KC_CYCLE_LAYERS, KC_NO, KC_NO + ) + // clang-format on +}; diff --git a/firmware/keyboards/ml8/ml8_9/keymaps/via/keymap.c b/firmware/keyboards/ml8/ml8_9/keymaps/via/keymap.c new file mode 100644 index 0000000..ec36fb2 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/keymaps/via/keymap.c @@ -0,0 +1,36 @@ +#include "base.h" + +#include QMK_KEYBOARD_H + +#if defined(ENCODER_MAP_ENABLE) +const uint16_t PROGMEM encoder_map[][NUM_ENCODERS][NUM_DIRECTIONS] = {[0] = {ENCODER_CCW_CW(KC_VOLU, KC_VOLD)}}; +#endif + +const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = { + // clang-format off + [L_MEDIA] = LAYOUT_ortho_3x4( + KC_MEDIA_PREV_TRACK, KC_MEDIA_PLAY_PAUSE, KC_MEDIA_NEXT_TRACK, + KC_MEDIA_STOP, KC_AUDIO_MUTE, KC_UP, + KC_LEFT, KC_RIGHT, KC_DOWN, + KC_CYCLE_LAYERS, KC_NO, KC_NO + ), + [L_ZOOM] = LAYOUT_ortho_3x4( + KC_SPACE, SGUI(KC_A), SGUI(KC_V), + SGUI(KC_F), LGUI(KC_W), KC_ENTER, + LOPT(KC_Y), KC_NO, KC_NO, + KC_CYCLE_LAYERS, KC_NO, KC_NO + ), + [L_NUM] = LAYOUT_ortho_3x4( + KC_1, KC_2, KC_3, + KC_4, KC_5, KC_6, + KC_7, KC_8, KC_9, + KC_CYCLE_LAYERS, KC_NO, KC_NO + ), + [L_UNDEF] = LAYOUT_ortho_3x4( + KC_NO, KC_NO, KC_NO, + KC_NO, KC_NO, KC_NO, + KC_NO, KC_NO, KC_NO, + KC_CYCLE_LAYERS, KC_NO, KC_NO + ) + // clang-format on +}; diff --git a/firmware/keyboards/ml8/ml8_9/keymaps/via/rules.mk b/firmware/keyboards/ml8/ml8_9/keymaps/via/rules.mk new file mode 100644 index 0000000..1e5b998 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/keymaps/via/rules.mk @@ -0,0 +1 @@ +VIA_ENABLE = yes diff --git a/firmware/keyboards/ml8/ml8_9/oled_handlers.c b/firmware/keyboards/ml8/ml8_9/oled_handlers.c new file mode 100644 index 0000000..6d8da6e --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/oled_handlers.c @@ -0,0 +1,116 @@ +#include "oled_handlers.h" + +#include + +#include "base.h" +#include "config.h" +#include "persistence.h" + +#include "action_layer.h" +#include "debug.h" +#include "oled_driver.h" +#include "print.h" + +// clang-format off +oled_text_t g_sys_layer_text[LAYER_COUNT] = { + "Media\nPrev | Play | Next \nStop | Mute | ^\n < | > | v", + "Zoom\nTalk | Mic | Video \nFull | Quit | Enter\nHand | |", + "Numpad\n 1 | 2 | 3\n 4 | 5 | 6\n 7 | 8 | 9", + "Undefined" +}; +// clang-format on + +uint8_t g_last_layer = LAYER_COUNT; // Not a valid layer +oled_text_t g_layer_text[LAYER_COUNT]; +bool g_oled_on = true; + +oled_text_t *system_layer_labels(void) { + return g_sys_layer_text; +} +oled_text_t *user_layer_labels(void) { + return g_layer_text; +} + +// Update the text for the given layer. +void oled_layer_update(uint8_t layer, char *data, uint8_t length) { + // validate that the layer is in scope + if (layer >= '0') { + layer -= '0'; + } + + dprintf("layer: %d\n", layer); + if (layer < 0 || layer >= LAYER_COUNT) { + dprint("invalid layer\n"); + return; + } + + dprintf("updating layer text with string:\n%s\n", data); + strncpy(g_layer_text[layer], data, sizeof(oled_text_t)); + g_layer_text[layer][strnlen(data, sizeof(oled_text_t))] = '\0'; + persist_user_layer_labels(); +#if defined(OLED_ENABLE) + if (layer == get_highest_layer(layer_state)) { + // only need to update if it's the current layer. + oled_update(layer, true); + } +#endif +} + +// To reduce firmware size. +#if defined(OLED_ENABLE) + +oled_rotation_t oled_init_user(oled_rotation_t rotation) { + return OLED_ROTATION_180; +} + +// Update OLED display. Update NOW if force_dirty is true. +void oled_update(uint8_t layer, bool force_dirty) { + if (layer < 0 || layer >= LAYER_COUNT) { + dprintf("Request to render invalid layer\n"); + return; + } + const char *txt = g_layer_text[layer]; + oled_clear(); + oled_set_cursor(0, 0); + oled_write(txt, false); + if (force_dirty) { + oled_render(); + } +} + +// Periodically update OLED display. Only redraw if the layer has changed. +bool oled_task_user(void) { + if (!is_post_init() || !g_oled_on) { + return true; + } + // render status + uint8_t curr = get_highest_layer(layer_state); + if (curr == g_last_layer) { + return true; + } + oled_clear(); + g_last_layer = curr; + + if (curr < 0 || curr >= LAYER_COUNT) { + // don't write anything + dprint("oled; invalid layer...\n"); + return true; + } + oled_update(curr, false); + return true; +} +#endif + +// Called from hid protocol. +void set_oled_state(bool on) { + g_oled_on = on; +#if defined(OLED_ENABLE) + if (!on) { + // clear oled + oled_clear(); + oled_render(); + } else { + oled_update(get_highest_layer(layer_state), true); + } +#endif +} diff --git a/firmware/keyboards/ml8/ml8_9/oled_handlers.h b/firmware/keyboards/ml8/ml8_9/oled_handlers.h new file mode 100644 index 0000000..ce28fcb --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/oled_handlers.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +// OLED supports 4 lines of 21 chars (+4 for newlines and final null terminator) +typedef char oled_text_t[4 * 21 + 3]; + +// Update the text for a given layer of the OLED. +void oled_layer_update(uint8_t layer, char *data, uint8_t length); +// Update the display, optionally forcing it to be rendered. +void oled_update(uint8_t layer, bool force_dirty); +// Turn on/off the oled. +void set_oled_state(bool on); + +oled_text_t *system_layer_labels(void); +oled_text_t *user_layer_labels(void); diff --git a/firmware/keyboards/ml8/ml8_9/persistence.c b/firmware/keyboards/ml8/ml8_9/persistence.c new file mode 100644 index 0000000..87327f8 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/persistence.c @@ -0,0 +1,234 @@ +#include "persistence.h" + +#include "config.h" +#include "oled_handlers.h" + +#include +#include +#include + +#include "action_layer.h" +#include "debug.h" +#include "eeprom.h" +#include "print.h" +#include "via.h" + +#define EEPROM_MAGIC_WORD 0xdead +#define PERSISTENCE_VERSION 666 +#define EEPROM_OLED_VALID_CFG 0x55 +#define EEPROM_OLED_INVALID_CFG 0xff + +#define EEPROM_BASE_ADDR (void *)VIA_EEPROM_CUSTOM_CONFIG_ADDR +#define EEPROM_MAGIC_ADDR (void *)(0 + EEPROM_BASE_ADDR) +#define EEPROM_VERSION_ADDR (void *)(2 + EEPROM_BASE_ADDR) +#define EEPROM_OLED_RESTORE_ADDR (void *)(4 + EEPROM_BASE_ADDR) +#define EEPROM_OLED_CFG_ADDR (void *)(4 + (8 * sizeof(oled_text_t)) + EEPROM_BASE_ADDR) + +struct oled_cfg { + char valid; // whether the data is valid; set to 0x55 + // char data_start; + // unused; start of data for layers. data is stored + // contiguously with the layout d0 d1 d2 ... where + // d0 is sizeof(oled_text_t) and contains the data for layer 0 (and so on). +}; + +// EEPROM layout (version 666) +// +--------+ EEPROM_BASE_ADDR/EEPROM_MAGIC_ADDR +// | dead | - magic +// +--------+ +2 +// | 1 | - version +// +--------+ +4 +// | layer0 | - restore data (for up to 8 layers) +// | ... | +// | ... | +// | layer1 | +// | ... | +// | ... | +// | ... | +// +--------+ +4 + 8*sizeof(oled_text_t) +// | 55 | - valid oled text +// | layer0 | - layer 0 data +// | ... | +// | ... | +// | layer1 | - layer 1 data +// | ... | +// | ... | +// | | +// | | +// +--------+ +// + +#if defined(EEPROM_CFG) +bool eeprom_is_init(void) { + uint16_t magic; + eeprom_read_block(&magic, EEPROM_MAGIC_ADDR, sizeof(magic)); + dprintf("eeprom magic %d\n", magic); + return magic == EEPROM_MAGIC_WORD; +} + +uint16_t eeprom_version(void) { + uint16_t v; + eeprom_read_block(&v, EEPROM_VERSION_ADDR, sizeof(v)); + dprintf("eeprom persistence version %d\n", v); + return v; +} + +// Persist system layers. Read upon reset of layer text. +void eeprom_persist_system_layers(void) { + // TODO -- config is not really useful/used so far, refactor + // persistence/recovery for system and user layers. + dprint("persisting system layer labels...\n"); + oled_text_t buffer; + void *p = EEPROM_OLED_RESTORE_ADDR; + for (int i = 0; i < LAYER_COUNT; i++) { + dprintf("\tlayer %d\n", i); + strncpy(buffer, system_layer_labels()[i], sizeof(buffer)); + buffer[sizeof(buffer) - 1] = '\0'; + // write at p, then increment pointer offset + eeprom_write_block(buffer, p, sizeof(buffer)); + p += sizeof(buffer); + } + dprint("done\n"); +} + +// Recover system layers. +void eeprom_restore_system_layers(void) { + oled_text_t buffer; + void *p = EEPROM_OLED_RESTORE_ADDR; + dprintf("restoring system layer labels...\n"); + for (int i = 0; i < LAYER_COUNT; i++) { + dprintf("\tlayer %d\n", i); + eeprom_read_block(buffer, p, sizeof(buffer)); + strncpy(user_layer_labels()[i], buffer, sizeof(oled_text_t)); + p += sizeof(buffer); + } + dprint("done\n"); +} + +// Persist user layers. Read at start. +void eeprom_persist_user_layers(void) { + oled_text_t buffer; + struct oled_cfg config; + void *p = EEPROM_OLED_CFG_ADDR; + + dprint("persisting user layer labels...\n"); + dprint("\theader...\n"); + config.valid = EEPROM_OLED_VALID_CFG; + eeprom_write_block(&config, p, sizeof(config)); + p += sizeof(struct oled_cfg); + for (int i = 0; i < LAYER_COUNT; i++) { + dprintf("\tlayer %d...\n", i); + strncpy(buffer, user_layer_labels()[i], sizeof(buffer)); + buffer[sizeof(buffer) - 1] = '\0'; + eeprom_write_block(buffer, p, sizeof(buffer)); + p += sizeof(oled_text_t); + } + dprint("done\n"); +} + +// Clear persisted user layers. Destory valid marker. +void eeprom_clear_user_layers(void) { + struct oled_cfg config; + config.valid = EEPROM_OLED_INVALID_CFG; + void *p = EEPROM_OLED_CFG_ADDR; + dprintf("voiding user layer labels\n"); + eeprom_write_block(&config, p, sizeof(config)); + dprintf("done\n"); +} + +// Restore user layers. Called on recovery. +void eeprom_restore_user_layers(void) { + oled_text_t buffer; + struct oled_cfg config; + void *p = EEPROM_OLED_CFG_ADDR; + dprint("looking for user layers in eeprom...\n"); + eeprom_read_block(&config, p, sizeof(config)); + dprintf("\tfound marker %d (wanted %d)\n", config.valid, EEPROM_OLED_VALID_CFG); + if (config.valid != EEPROM_OLED_VALID_CFG) { + dprintf("no layers to restore\n"); + return; + } + p += sizeof(struct oled_cfg); + for (int i = 0; i < LAYER_COUNT; i++) { + dprintf("\tlayer %d...\n", i); + eeprom_read_block(buffer, p, sizeof(oled_text_t)); + strncpy(user_layer_labels()[i], buffer, sizeof(oled_text_t)); + p += sizeof(oled_text_t); + } + dprintf("done!\n"); +} + +// Initialize eeprom +void eeprom_config_init(uint16_t version) { + uint16_t buff[2] = {EEPROM_MAGIC_WORD, version}; + dprintf("initializing eeprom\n"); + eeprom_write_block(&buff, EEPROM_MAGIC_ADDR, sizeof(buff)); + if (!eeprom_is_init()) { + dprint("eeprom write failed\n"); + } else { + dprint("eeprom initialized\n"); + } + // persist system layers when initializing eeprom + eeprom_persist_system_layers(); +} +#endif + +// Write user layer labels to persistence +void persist_user_layer_labels(void) { +#if defined(EEPROM_CFG) + eeprom_persist_user_layers(); +#else + dprintf("no recovery medium; not persisting.\n"); +#endif +} + +void recover_from_in_mem_system_layer_labels(void) { + for (int i = 0; i < LAYER_COUNT; i++) { + strncpy(user_layer_labels()[i], system_layer_labels()[i], sizeof(oled_text_t)); + } +} + +// Restore user layer labels from persistence +void restore_user_layer_labels(void) { +#if defined(EEPROM_CFG) + eeprom_restore_user_layers(); + oled_update(get_highest_layer(layer_state), true); +#else + dprintf("no recovery medium; using default.\n"); + recover_from_in_mem_system_layer_labels(); +#endif +} + +// Reset layer labels to initial values +void reset_layer_labels(void) { + // reset to initial config. +#if defined(EEPROM_CFG) + eeprom_restore_system_layers(); + oled_update(get_highest_layer(layer_state), true); + eeprom_clear_user_layers(); +#else + dprintf("no recover medium; using default.\n"); + recover_from_in_mem_system_layer_labels(); +#endif +} + +void persistence_init(void) { + recover_from_in_mem_system_layer_labels(); + +#if defined(EEPROM_CFG) + // check if eeprom is initialized. + if (eeprom_is_init()) { + dprint("eeprom initialized!\n"); + uint16_t version = eeprom_version(); + if (version != PERSISTENCE_VERSION) { + // TODO: migrate persistence. + dprint("persistence mismatch!\n"); + eeprom_config_init(PERSISTENCE_VERSION); + } else { + eeprom_restore_user_layers(); + } + } else { + eeprom_config_init(PERSISTENCE_VERSION); + } +#endif +} diff --git a/firmware/keyboards/ml8/ml8_9/persistence.h b/firmware/keyboards/ml8/ml8_9/persistence.h new file mode 100644 index 0000000..dc8f0a0 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/persistence.h @@ -0,0 +1,6 @@ +#pragma once + +void persist_system_layer_labels(void); +void persist_user_layer_labels(void); +void reset_layer_labels(void); +void persistence_init(void); diff --git a/firmware/keyboards/ml8/ml8_9/readme.md b/firmware/keyboards/ml8/ml8_9/readme.md new file mode 100644 index 0000000..a37a661 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/readme.md @@ -0,0 +1,28 @@ +# ml8/ml8\_9 + +![ml8/ml8\_9](https://github.com/ml8/3x3/blob/main/ml8_9.jpg) + +*Firmware for 3x3 macropad with rotary encoder and OLED. Supports via and +reprogramming OLED text via hid programming.* + +* Keyboard Maintainer: [Marion Lang](https://github.com/ml8) +* Hardware Supported: [ml8\_9](https://github.com/ml8/3x3) +* Hardware Availability: [open source](https://github.com/ml8/3x3) + +Make example for this keyboard (after setting up your build environment): + + make ml8/ml8_9:default + +Flashing example for this keyboard: + + make ml8/ml8_9:default:flash + +See the [build environment setup](https://docs.qmk.fm/#/getting_started_build_tools) and the [make instructions](https://docs.qmk.fm/#/getting_started_make_guide) for more information. Brand new to QMK? Start with our [Complete Newbs Guide](https://docs.qmk.fm/#/newbs). + +## Bootloader + +Enter the bootloader in 3 ways: + +* **Bootmagic reset**: Hold down the key at (0,0) in the matrix (usually the top left key or Escape) and plug in the keyboard +* **Physical reset button**: Briefly press the button on the top of the PCB +* **Keycode in layout**: Press the key mapped to `QK_BOOT` if it is available diff --git a/firmware/keyboards/ml8/ml8_9/rules.mk b/firmware/keyboards/ml8/ml8_9/rules.mk new file mode 100644 index 0000000..67b0883 --- /dev/null +++ b/firmware/keyboards/ml8/ml8_9/rules.mk @@ -0,0 +1,26 @@ +LTO_ENABLE = yes + +ENCODER_ENABLE = yes +OLED_ENABLE = yes +EXTRAFLAGS+=-flto +EXTRAKEY_ENABLE = yes + +# enable debugging +CONSOLE_ENABLE = yes +DEBUG_EEPROM_OUTPUT = yes + +# enable external eeprom +EEPROM_DRIVER = i2c + +# disable unused features +AUDIO_ENABLE = no +MOUSEKEY_ENABLE = no +SPACE_CADET_ENABLE = no +GRAVE_ESC_ENABLE = no +MAGIC_ENABLE = no +AUDIO_ENABLE = no + +AVR_USE_MINIMAL_PRINTF = yes + +# include all sources +SRC += base.c hid_handlers.c oled_handlers.c persistence.c diff --git a/hardware/3mf/body.3mf b/hardware/3mf/body.3mf new file mode 100644 index 0000000..75a44c8 Binary files /dev/null and b/hardware/3mf/body.3mf differ diff --git a/hardware/3mf/plate.3mf b/hardware/3mf/plate.3mf new file mode 100644 index 0000000..f320ddf Binary files /dev/null and b/hardware/3mf/plate.3mf differ diff --git a/hardware/3mf/top.3mf b/hardware/3mf/top.3mf new file mode 100644 index 0000000..e6259f1 Binary files /dev/null and b/hardware/3mf/top.3mf differ diff --git a/hardware/README.md b/hardware/README.md new file mode 100644 index 0000000..f1adbb2 --- /dev/null +++ b/hardware/README.md @@ -0,0 +1,62 @@ +# Hardware + +This contains the Gerber for the PCB and STL/3mf files for the enclosure. + +## BOM + +All components are through-hole except for the hot-swap sockets. + +| Component | Quantity | Note | +| --------- | -------- | ---- | +| Arduino Pro Micro | 1 | | +| 0.91" 128x32 OLED | 1 | requires hand-wiring | +| 24LC256 EEPROM | 1 | | +| 6x6 tactile momentary switch | 1 | 5mm or 4.3mm height | +| EC11 Rotary Encoder (clickable) | 1 | + knob | +| 1N4148 diodes | 10 | | +| MX-style switches | 10 | + keycaps | +| 1k resistor | 1 | | +| MX hotswap sockets | 10 | | +| m2x8 screws | 4 | for enclosure | +| m2x4 screws | 4 | for enclosure | +| m2 5mm standoffs | 4 | for enclosure | +| m2 nuts | 4 | for enclosure | + +## Assembly Notes + +1. Begin by mounting 3x3 matrix diodes (D1-D9) and hotswap sockets to underside + of board (where their labels are). Solder these in place. + * The D10 diode will be soldered on top of the board. + * It's easier to solder the hotswap sockets first, so that you don't have to + worry about the board sitting stably while soldering these. +2. Next, mount the D10 diode, the resistor (R-RESET) to top of board. Solder in + place. +3. Mount the EEPROM, reset switch, and rotary encoder on top of the board; solder + them in place. +4. Solder headers facing __upward__ on Pro Micro, then mount to bottom of + PCB (see completed picture). +5. Use wires to connect sda/scl/vcc/gnd to the corresponding pins on the 128x32 + OLED. The OLED will be mounted to the inside of the enclosure, so make sure + the wires are long enough to reach, but short enough to fit under the OLED + once it is mounted. +6. Assemble the bottom of the enclosure, mounting the PCB to the standoffs. + * Put the nuts in the bottom of the case (you may have to push hard to push + them in--they should be snug). + * Screw in the standoffs, then slide the PCB into the bottom of the + enclosure. + * Screw 4 m2x4 screws to mount the PCB inm place. +7. Put the switch plate on (do __not__ screw in place) and mount switches in + hotswap sockets. +8. Program the firmware, if the pro micro is not pre-programmed. Follow the + instructions in [`firmware/`](../firmware/README.md). +9. Tape or otherwise affix the OLED to the inside of the top of the enclosure, + then mount the enclosure. Screw it in place with 4 m2x8 screws. Put the + encoder knob on. + +## Assembly Photos + +![top](./assembly-top.jpg) + +![bottom](./assembly-bottom.jpg) + +![side](./assembly-side.jpg) diff --git a/hardware/assembly-bottom.jpg b/hardware/assembly-bottom.jpg new file mode 100644 index 0000000..3a99cc0 Binary files /dev/null and b/hardware/assembly-bottom.jpg differ diff --git a/hardware/assembly-side.jpg b/hardware/assembly-side.jpg new file mode 100644 index 0000000..48d2e87 Binary files /dev/null and b/hardware/assembly-side.jpg differ diff --git a/hardware/assembly-top.jpg b/hardware/assembly-top.jpg new file mode 100644 index 0000000..1c21931 Binary files /dev/null and b/hardware/assembly-top.jpg differ diff --git a/hardware/gerber_3x3_macropad.zip b/hardware/gerber_3x3_macropad.zip new file mode 100644 index 0000000..82c73e0 Binary files /dev/null and b/hardware/gerber_3x3_macropad.zip differ diff --git a/hardware/stl/body.stl b/hardware/stl/body.stl new file mode 100644 index 0000000..3d2a2df Binary files /dev/null and b/hardware/stl/body.stl differ diff --git a/hardware/stl/plate.stl b/hardware/stl/plate.stl new file mode 100644 index 0000000..16e9c7a Binary files /dev/null and b/hardware/stl/plate.stl differ diff --git a/hardware/stl/top.stl b/hardware/stl/top.stl new file mode 100644 index 0000000..526d9fd Binary files /dev/null and b/hardware/stl/top.stl differ diff --git a/kbp/Makefile b/kbp/Makefile new file mode 100644 index 0000000..748e4e3 --- /dev/null +++ b/kbp/Makefile @@ -0,0 +1,7 @@ +all: kb + +kb: kb.go cmd/kbp/main.go + go build ./cmd/kbp + +clean: + rm kbp diff --git a/kbp/README.md b/kbp/README.md new file mode 100644 index 0000000..9be9390 --- /dev/null +++ b/kbp/README.md @@ -0,0 +1,31 @@ +# CLI programming tool + +Requires golang to be installed. + +The programming tool uses device id to distinguish between other QMK keyboards +that may be attached to the same machine. If you only have one QMK keyboard, you +can leave off the device string. + +Compile: + +``` +$ make +``` + +To program layer text: + +``` +$ ./kbp -device ::6d6c::: -layer [0-3] -text "TEXT" +``` + +You can use `\n` for new lines. All text will be written to a single wrapped +line, otherwise. + +To reset the layer text: + +``` +$ ./kbp -device ::6d6c::: -reset +``` + +Other commands (turning off the OLED, etc.) can be found by using the `-help` +flag. diff --git a/kbp/cmd/kbp/main.go b/kbp/cmd/kbp/main.go new file mode 100644 index 0000000..55ffcbd --- /dev/null +++ b/kbp/cmd/kbp/main.go @@ -0,0 +1,286 @@ +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "os" + "strconv" + "strings" + + "github.com/golang/glog" + kbp "github.com/ml8/3x3/kbp" +) + +var ( + cmd = flag.String("cmd", "", "one of: ls, deviceinfo, raw") + reset = flag.Bool("reset", false, "Reset layer text to device-initialized text") + layer = flag.Int("layer", -1, "Layer number (0-3) to update text for") + text = flag.String("text", "", "Text to set for the given layer") + dev = flag.String("device", ":::::", "Name of device as reported by -cmd=ls") + fn = flag.String("file", "", "name of file to read; alt. use stdin") + oled = flag.String("oled", "", "Turn oled on/off; value must be \"on\" or \"off\"") + hi = flag.Bool("hi", false, "Debug: hello message") + echo = flag.String("echo", "", "Text to echo") +) + +func usage() { + exe, _ := os.Executable() + fmt.Printf(` +To program layer text: + %[1]v -layer LAYER_NUM -text TEXT + + Set the layer text to TEXT for the given LAYER_NUM. + Use \\n for new lines; text will not wrap otherwise. + +To reset layer text to device-default text: + %[1]v -reset + +To turn oled off/on: + %[1]v -oled on|off + +For raw programming and other utilities: + %[1]v -cmd=[ls,deviceinfo,prog] + +To use echo/hello debug functions: + %[1]v -echo TEXT + %[1]v -hi + +To specific a device (for all commands), supply a device string: + %[1]v -device DEVICE_STRING + + DEVICE_STRING format - VENDOR_NAME:PRODUCT_NAME:VENDOR_ID:PRODUCT_ID:USAGE_ID:USAGE_PAGE + + Any field above may be empty, e.g., "::6d6c:3333::" specifies a vendor and product id only, + whereas "Marion Lang:ml8_9:::0001:" specifies vendor and product name as well as usage id. + Exact matches are required for any populated field. All ID/PAGE fields must be base 16. + By default: usage id and usage page set to generic hid interface for QMK devices, if you + wish to use other id/pages, or any, use 0 for both. +`, exe) +} + +func ls() { + kbp.Ls() +} + +func parse(dev string) *kbp.DeviceQueryParams { + props := strings.Split(dev, ":") + if len(props) != 6 { + glog.Errorf("Incorrect query string %s, parsed %d fields", dev, len(props)) + return nil + } + d := kbp.NewQueryParams() + if props[0] != "" { + d.VendorName = props[0] + } + if props[1] != "" { + d.ProductName = props[1] + } + if props[2] != "" { + t, _ := strconv.ParseUint(props[2], 16, 16) + d.VendorID = uint16(t) + } + if props[3] != "" { + t, _ := strconv.ParseUint(props[3], 16, 16) + d.ProductID = uint16(t) + } + if props[4] != "" { + t, _ := strconv.ParseUint(props[4], 16, 16) + d.UsageID = uint16(t) + } + if props[5] != "" { + t, _ := strconv.ParseUint(props[5], 16, 16) + d.UsagePage = uint16(t) + } + glog.Infof("Using query params: %s", d) + return d +} + +func deviceinfo(dev string) { + infos, e := kbp.Query(parse(dev)) + if e != nil { + fmt.Printf("No device with spec %s found; use cmd=ls\n", dev) + return + } + for _, info := range infos { + fmt.Printf("%s\n", info) + } +} + +func getDev(dev string) (device kbp.DeviceInfo, err error) { + devs, err := kbp.Query(parse(dev)) + if len(devs) != 1 || err != nil { + fmt.Printf("No unique device found; found %d devices with spec %s. Errors: %v", len(devs), dev, err) + usage() + return + } + device = devs[0] + return +} + +func raw(dev string) { + device, err := getDev(dev) + if err != nil { + return + } + data, e := readData() + if e != nil { + fmt.Println("Error reading data", e) + return + } + resp, e := kbp.SendRaw(device, data) + if e != nil { + fmt.Println("Error sending data", e) + } else { + fmt.Println("OK") + fmt.Printf("Response: %v\n", string(resp)) + } +} + +func program(dev string, layer int, text string) { + device, err := getDev(dev) + if err != nil { + return + } + fmt.Printf("Programming layer %d with %s\n", layer, text) + text = strings.ReplaceAll(text, "\\n", "\n") + err = kbp.SendLayerUpdate(device, uint8(layer), text) + if err != nil { + fmt.Println("Error programming layer data", err) + } else { + fmt.Println("OK") + } +} + +func resetOled(dev string) { + device, err := getDev(dev) + if err != nil { + return + } + fmt.Printf("Resetting OLED text\n") + err = kbp.SendLayerReset(device) + if err != nil { + fmt.Printf("Error resetting layer text: %v", err) + } else { + fmt.Println("OK") + } +} + +func hello(dev string) { + device, err := getDev(dev) + if err != nil { + return + } + fmt.Printf("Sending hello message\n") + err = kbp.SendHello(device) + if err != nil { + fmt.Printf("Error sending hello: %v", err) + } else { + fmt.Println("OK") + } +} + +func ping(dev string, txt string) { + device, err := getDev(dev) + if err != nil { + return + } + fmt.Printf("Sending echo message\n") + err = kbp.SendEcho(device, txt) + if err != nil { + fmt.Printf("Error sending echo: %v", err) + } else { + fmt.Println("OK") + } +} + +func toggleOled(dev string, on bool) { + device, err := getDev(dev) + if err != nil { + return + } + fmt.Printf("Sending oled message, on = %v\n", on) + err = kbp.SendOledState(device, on) + if err != nil { + fmt.Printf("Error sending oled state toggle: %v", err) + } else { + fmt.Println("OK") + } +} + +func readData() (bytes []byte, e error) { + if *fn != "" { + bytes, e = os.ReadFile(*fn) + if e != nil { + return + } + } else { + bytes, e = ioutil.ReadAll(os.Stdin) + if e != nil { + return + } + } + return +} + +func main() { + flag.Parse() + kbp.Init() + defer kbp.Exit() + + if *reset { + resetOled(*dev) + return + } + + if *oled != "" && !(*oled == "on" || *oled == "off") { + fmt.Printf("Invalid value for -oled") + usage() + return + } else if *oled != "" { + on := true + if *oled == "off" { + on = false + } + toggleOled(*dev, on) + return + } + + if *hi { + hello(*dev) + return + } + + if *echo != "" { + ping(*dev, *echo) + return + } + + if *layer != -1 || *text != "" { + if *layer == -1 || *text == "" { + fmt.Printf("When programming layer text, both -layer and -text must be supplied.\n") + usage() + return + } + if *layer < 0 || *layer > 3 { + fmt.Printf("Device only supports 4 layers numbered 0-4\n") + return + } + program(*dev, *layer, *text) + return + } + + // other tools + switch *cmd { + case "ls": + ls() + break + case "deviceinfo": + deviceinfo(*dev) + break + case "raw": + raw(*dev) + default: + usage() + } +} diff --git a/kbp/go.mod b/kbp/go.mod new file mode 100644 index 0000000..eda4e54 --- /dev/null +++ b/kbp/go.mod @@ -0,0 +1,10 @@ +module github.com/ml8/3x3/kbp + +go 1.21 + +require ( + github.com/golang/glog v1.2.0 + github.com/sstallion/go-hid v0.14.1 +) + +require golang.org/x/sys v0.8.0 // indirect diff --git a/kbp/go.sum b/kbp/go.sum new file mode 100644 index 0000000..b42d7ae --- /dev/null +++ b/kbp/go.sum @@ -0,0 +1,6 @@ +github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= +github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/sstallion/go-hid v0.14.1 h1:shbZlKqv5fr1KnxwqtLEPGkOoA6OSUWTx9TblegATvc= +github.com/sstallion/go-hid v0.14.1/go.mod h1:fPKp4rqx0xuoTV94gwKojsPG++KNKhxuU88goGuGM7I= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/kbp/hid.go b/kbp/hid.go new file mode 100644 index 0000000..e951b91 --- /dev/null +++ b/kbp/hid.go @@ -0,0 +1,183 @@ +package kbp + +import ( + "errors" + "fmt" + + "github.com/golang/glog" + hid "github.com/sstallion/go-hid" +) + +var ( + NoUniqueDeviceFound = errors.New("Device is not unique") + NoDeviceFound = errors.New("No such device") +) + +type DeviceQueryParams struct { + UsageID uint16 + UsagePage uint16 + VendorID uint16 + ProductID uint16 + ProductName string + VendorName string +} + +func (d *DeviceQueryParams) String() string { + return fmt.Sprintf("(vendor name/id: %s/%04x, product name/id: %s/%04x, usage id/page %04x/%04x)", + d.VendorName, d.VendorID, d.ProductName, d.ProductID, d.UsageID, d.UsagePage) +} + +func Init() error { + e := hid.Init() + glog.Infof("Initializing hid, error: %v", e) + return e +} + +func Ls() { + fmt.Println("path: ID vendorid productid manufacturer devicename") + hid.Enumerate(hid.VendorIDAny, hid.ProductIDAny, func(info *hid.DeviceInfo) error { + fmt.Printf("%s: ID %04x:%04x-%d %s mfr: \"%s\" prod: \"%s\" (s/n: %s) (iface no: %d, usage id: %04x, usage page: %04x)\n", + info.Path, + info.VendorID, + info.ProductID, + info.ReleaseNbr, + info.BusType.String(), + info.MfrStr, + info.ProductStr, + info.SerialNbr, + info.InterfaceNbr, + info.Usage, + info.UsagePage) + return nil + }) +} + +type DeviceInfo struct { + hid.DeviceInfo +} + +func openDevice(device DeviceInfo) (dev *hid.Device, err error) { + glog.Infof("Using device %v", device) + devs, err := openDevices(device) + glog.Infof("Found %d devices", len(devs)) + if err != nil { + return + } + if len(devs) > 1 { + err = NoUniqueDeviceFound + return + } + if len(devs) == 0 { + err = NoDeviceFound + return + } + dev, err = hid.OpenPath(devs[0].Path) + return +} + +func (d DeviceInfo) String() string { + return fmt.Sprintf("Vendor: %s/%04x, Product: %s/%04x, Usage id/page: %04x/%04x, Path: %s", d.MfrStr, d.VendorID, d.ProductStr, d.ProductID, d.Usage, d.UsagePage, d.Path) +} + +func from(i []*hid.DeviceInfo) []DeviceInfo { + result := make([]DeviceInfo, 0, len(i)) + for _, dev := range i { + result = append(result, DeviceInfo{*dev}) + } + return result +} + +func NewQueryParams() *DeviceQueryParams { + return &DeviceQueryParams{ + VendorID: hid.VendorIDAny, + ProductID: hid.ProductIDAny, + UsageID: DefaultUsageID, + UsagePage: DefaultUsagePage, + VendorName: "", + ProductName: "", + } +} + +func (p *DeviceQueryParams) WithProductID(pid uint16) *DeviceQueryParams { + p.VendorID = pid + return p +} +func (p *DeviceQueryParams) WithVendorID(vid uint16) *DeviceQueryParams { + p.VendorID = vid + return p +} +func (p *DeviceQueryParams) WithUsageID(usage uint16) *DeviceQueryParams { + p.UsageID = usage + return p +} +func (p *DeviceQueryParams) WithUsagePage(usage uint16) *DeviceQueryParams { + p.UsagePage = usage + return p +} + +func (p *DeviceQueryParams) match(d *hid.DeviceInfo) bool { + if p.ProductID != hid.ProductIDAny && p.ProductID != d.ProductID { + // product doesn't match + return false + } + if p.VendorID != hid.VendorIDAny && p.VendorID != d.VendorID { + // vendor doesn't match + return false + } + if p.UsageID != 0 && p.UsageID != d.Usage { + // usage id doesn't match + return false + } + if p.UsagePage != 0 && p.UsagePage != d.UsagePage { + // usage page doesn't match + return false + } + if p.ProductName != "" && p.ProductName != d.ProductStr { + // product doesn't match + return false + } + if p.VendorName != "" && p.VendorName != d.MfrStr { + // vendor doesn't match + return false + } + return true +} + +func openDevices(d DeviceInfo) (results []*hid.DeviceInfo, e error) { + q := &DeviceQueryParams{ + VendorName: d.MfrStr, + ProductName: d.ProductStr, + VendorID: d.VendorID, + ProductID: d.ProductID, + UsageID: d.Usage, + UsagePage: d.UsagePage, + } + return openQuery(q) +} + +func openQuery(p *DeviceQueryParams) (results []*hid.DeviceInfo, e error) { + glog.Infof("Querying with %v", p) + results = make([]*hid.DeviceInfo, 0) + hid.Enumerate(p.VendorID, p.ProductID, func(info *hid.DeviceInfo) error { + if p.match(info) { + results = append(results, info) + glog.Infof("Found device... %04x:%04x\n", info.VendorID, info.ProductID) + } + return nil + }) + return +} + +func Query(p *DeviceQueryParams) (d []DeviceInfo, e error) { + dev, e := openQuery(p) + if dev != nil { + d = from(dev) + } + return +} + +func Exit() error { + e := hid.Exit() + glog.Infof("Shutting down hid, error: %v", e) + return e +} diff --git a/kbp/kb.go b/kbp/kb.go new file mode 100644 index 0000000..fddb077 --- /dev/null +++ b/kbp/kb.go @@ -0,0 +1,238 @@ +package kbp + +import ( + "errors" + "fmt" + "time" + + "github.com/golang/glog" + hid "github.com/sstallion/go-hid" +) + +var ( + UnsupportedCommand = errors.New("Unsupported command") + TransferAborted = errors.New("Transfer aborted") +) + +const ( + DefaultUsageID = 0x0061 + DefaultUsagePage = 0xff60 +) + +const ( + CMD_NOOP = 0x00 + CMD_ERR = 0x01 + CMD_ACK = 0x02 + CMD_NACK = 0x03 + CMD_CONT = 0x04 + CMD_ABORT = 0x05 + CMD_COMPLETE = 0x06 + + // Debug commands; hello and echo + CMD_HELLO = 0x30 // 0 + CMD_ECHO = 0x31 + + // Control commands + CMD_OLED_OFF = 0x40 // @ + CMD_OLED_ON = 0x41 + + // OLED programming commands + CMD_OLED_UPDATE = 0x50 // P + CMD_OLED_RESET = 0x51 +) + +func prepareMessage(buffer []byte, cmd uint8, data []byte) { + buffer[0] = 'm' + buffer[1] = 'l' + buffer[2] = cmd + if len(data) > 0 { + copy(buffer[3:], data) + } +} + +func prepareLayerMessage(buffer []byte, data []byte, cmd uint8, layer uint8, length uint8) { + prepareMessage(buffer, cmd, nil) + buffer[3] = layer + buffer[4] = length + if length > 0 { + copy(buffer[5:], data[:length]) + } +} + +func prepareStartMessage(buffer []byte, data []byte, cmd uint8, layer uint8, length uint8) { + prepareLayerMessage(buffer, data, cmd, layer, length) +} + +func prepareContinueMessage(buffer []byte, data []byte, layer uint8, length uint8) { + prepareLayerMessage(buffer, data, CMD_CONT, layer, length) +} + +func prepareCompleteMessage(buffer []byte, layer uint8) { + prepareLayerMessage(buffer, nil, CMD_COMPLETE, layer, 0) +} + +func handleAckOrNack(dev *hid.Device) (response []byte, err error) { + recv := make([]byte, 32) + got, err := dev.ReadWithTimeout(recv, time.Duration(10*time.Second)) + glog.Infof("Received message: %v", recv) + switch { + case got <= 0: + glog.Infof("Got <=0 response (%d); err: %v", got, err) + err = TransferAborted + return + case err != nil: + glog.Errorf("Got error %v", err) + err = TransferAborted + return + case recv[0] != CMD_ACK: + glog.Errorf("Got response code %v", recv[0]) + err = TransferAborted + return + default: + glog.Infof("Got ACK") + response = recv + } + return +} + +func sendSegmented(dev *hid.Device, cmd uint8, layer uint8, data []byte) error { + buf := make([]byte, 32) + + switch cmd { + case CMD_OLED_UPDATE: + glog.Infof("Sending OLED update") + default: + return UnsupportedCommand + } + + var tot uint8 = uint8(len(data)) + var l uint8 = 0 + var err error + isStart := true + + for tot > 0 { + glog.Infof("Writing %s", data) + + l = tot + if tot > 25 { + // 2 byte header, 1 byte command, 1 byte layer, 1 byte length + l = 25 + glog.Infof("Chunking %d of %d\n", l, tot) + } else { + glog.Infof("No chunking necessary, total bytes %d\n", tot) + } + + if isStart { + prepareStartMessage(buf, data[:l], CMD_OLED_UPDATE, layer, l) + } else { + prepareContinueMessage(buf, data[:l], layer, l) + } + data = data[l:] + tot -= l + + glog.Infof("Sending %v", buf) + _, err = dev.Write(buf) + isStart = false + if err != nil { + return err + } + + _, err = handleAckOrNack(dev) + if err != nil { + return err + } + glog.Infof("Wrote %d; %d bytes remaining", l, tot) + } + // Prepare and send completion message. + prepareCompleteMessage(buf, layer) + glog.Infof("Sending completion message %v", buf) + dev.Write(buf) + _, err = handleAckOrNack(dev) + if err != nil { + return err + } + + return nil +} + +func sendCommand(device DeviceInfo, buffer []byte) error { + glog.Infof("Sending %x: %v", buffer[2], buffer) + _, err := SendRaw(device, buffer) + if err != nil { + return err + } + return nil +} + +func SendLayerReset(device DeviceInfo) error { + buffer := make([]byte, 32) + prepareMessage(buffer, CMD_OLED_RESET, nil) + return sendCommand(device, buffer) +} + +func SendOledState(device DeviceInfo, on bool) error { + buffer := make([]byte, 32) + var cmd uint8 = CMD_OLED_OFF + if on { + cmd = CMD_OLED_ON + } + prepareMessage(buffer, cmd, nil) + return sendCommand(device, buffer) +} + +func SendHello(device DeviceInfo) error { + buffer := make([]byte, 32) + prepareMessage(buffer, CMD_HELLO, nil) + glog.Infof("Sending hello message %v", buffer) + if resp, err := SendRaw(device, buffer); err != nil { + return err + } else { + // resp should be: < ACK HELLO message > + txt := string(resp[2:]) + fmt.Printf("Got response: %s\n", txt) + } + return nil +} + +func SendEcho(device DeviceInfo, txt string) error { + buffer := make([]byte, 32) + // send buffer will be < m l ECHO txt >, so txt has to be < 29 bytes. + bytes := []byte(txt) + if len(bytes) > 29 { + bytes = bytes[:29] + } + prepareMessage(buffer, CMD_ECHO, []byte(txt)) + glog.Infof("Sending echo message %v", buffer) + if resp, err := SendRaw(device, buffer); err != nil { + return err + } else { + // resp should be: < ACK message > + echo := string(resp[1:]) + fmt.Printf("Got response: %s\n", echo) + } + return nil +} + +func SendLayerUpdate(device DeviceInfo, layer uint8, txt string) error { + dev, err := openDevice(device) + if err != nil { + return err + } + defer dev.Close() + return sendSegmented(dev, CMD_OLED_UPDATE, layer, []byte(txt)) +} + +func SendRaw(device DeviceInfo, data []byte) (response []byte, err error) { + dev, err := openDevice(device) + defer dev.Close() + if err != nil { + return + } + t, err := dev.Write(data) + if err != nil { + return + } + glog.Infof("Sent %d bytes", t) + response, err = handleAckOrNack(dev) + return +} diff --git a/ml8_9.jpg b/ml8_9.jpg new file mode 100644 index 0000000..50ceb2e Binary files /dev/null and b/ml8_9.jpg differ diff --git a/via/README.md b/via/README.md new file mode 100644 index 0000000..4246965 --- /dev/null +++ b/via/README.md @@ -0,0 +1,14 @@ +# Customizing keys with via + +The pad supports configuring the keymap with VIA. You can use +[usevia.app](https://usevia.app/) to configure the keymap. However, until the +keyboard config is merged in via's repo, you'll need to upload the JSON +manually. + +* To do this, go the "Settings" menu in via and click "Show Design tab." +* This will allow you to upload the JSON from this directory (go to the design + tab, then select "Load Draft Definition". +* Select the `ml8_9.json` file from this directory. +* You can now use via normally (click on the first tab to program the pad). +* After you're done, if you want to update the layer text for the OLED, use the + included [programmer](../kbp/README.md). diff --git a/via/ml8_9.json b/via/ml8_9.json new file mode 100644 index 0000000..490d7b8 --- /dev/null +++ b/via/ml8_9.json @@ -0,0 +1,28 @@ +{ + "name": "ml8_9", + "vendorId": "0x6D6C", + "productId": "0x3333", + "matrix": { + "rows": 4, + "cols": 3 + }, + "layouts": { + "keymap": [ + [ + "0,0", + "0,1", + "0,2" + ], + [ + "1,0", + "1,1", + "1,2" + ], + [ + "2,0", + "2,1", + "2,2" + ] + ] + } +}