├── .gitignore ├── font.bmp ├── package.json ├── .vscode └── launch.json ├── LICENSE ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /font.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlankSourceCode/qmk-hid-display/HEAD/font.bmp -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qmk-hid-display", 3 | "version": "1.0.0", 4 | "description": "A small node script that will communicate with a qmk keyboard over raw hid", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "author": "James Lissiak", 10 | "license": "MIT", 11 | "dependencies": { 12 | "microstats": "0.1.2", 13 | "node-hid": "0.7.8", 14 | "perfmon": "0.2.0", 15 | "request": "2.88.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}\\index.js" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | qmk-hid-display 2 | 3 | Copyright (c) James Lissiak 4 | 5 | All rights reserved. 6 | 7 | MIT License 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QMK-HID-Display 2 | 3 | A small node script that will collect data and send updates to a qmk enabled keyboard to show on the OLED display. 4 | 5 | ## Pre-requisites 6 | * To use this script you must have a QMK enabled keyboard with OLED displays such as the Lily58. 7 | * You also need to have flashed the keyboard with custom QMK firmware that has the corresponding OLED update code 8 | * The update code can be found on my qmk_firmware fork in the lily58 branch. 9 | * Specifically this commit - [3ae097783d65e71062606906f7b4be639d9d321d](https://github.com/BlankSourceCode/qmk_firmware/commit/3ae097783d65e71062606906f7b4be639d9d321d 10 | "qmk_firmware oled update code") 11 | * You will also need to have copied over my custom font from this repo 12 | * This font can be found in the qmk_firmware fork here: [glcdfont.c](https://github.com/BlankSourceCode/qmk_firmware/blob/lily58/keyboards/lily58/keymaps/blanksourcecode/glcdfont.c "font firmware file") 13 | * ![font.bmp](font.bmp "Custom OLED font") 14 | 15 | ## Development Setup 16 | * Clone this repo 17 | * Run `npm install` in '/qmk-hid-display' 18 | * Plug in your QMK enabled keyboard that has the custom firmware (linked above) 19 | * Open '/qmk-hid-display' in `VS Code` 20 | * Press `F5` to start debugging 21 | 22 | ## Using the script 23 | * Clone this repo 24 | * Run `npm install` in '/qmk-hid-display' 25 | * Plug in your QMK enabled keyboard that has the custom firmware (linked above) 26 | * Run `npm run start` 27 | 28 | ## How it works 29 | * The script simply calls various node api/packages to collect data that we want to use for the display on the keyboard oled screen 30 | * The examples included are: 31 | * Perf - shows stats for cpu, memory usage (mem), disk activity (dsk), and network bandwidth use (net). As a little bar graph. 32 | * Stock - shows current stock price of 4 tech stocks I added, MSFT, AAPL, GOOG, and FB. 33 | * Weather - shows current weather forecast for the Seattle area. 34 | * But essentially you can use anything that fits into the 21x4 character screen and is available in the font image 35 | * Once per second, the script sends over the screen data to the keyboard one line at a time which is then sent to the slave oled on the keyboard 36 | * The keyboard can also send data back to the script to indicate which data it would like to show. 37 | * Data must be sent in the form of an 84 (21*4) length byte array, where each byte corresponds to an index in the font image 38 | * Warning - I don't know that there is sufficient array length checks in the example firmware, so do be sure not to send the wrong amount of data 39 | * The keyboard already takes care of mapping normal ascii chars to the font data index, so you can just convert an 84 length string into char codes (using `charCodeAt`) and send that. 40 | * The script does some slightly fancy stuff to map to different parts of the font image where I've drawn sideways titles so that it reads correctly on the oled (but those can only be 4 spaces long due to the orientation of the oled displays) 41 | * Since all the processing is done in the script and the keyboard just displays whatever you send, you don't need to re-flash the firmware at all 42 | * This means you could do fancy effects like scrolling the text if it was too long by just sending a different string in each update (see the `startWeatherMonitor()`) 43 | 44 | To anyone brave enough to use this - Good Luck! 45 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const hid = require('node-hid'); 4 | const perfmon = require('perfmon'); 5 | const request = require('request'); 6 | 7 | // Keyboard info 8 | const KEYBOARD_NAME = "Lily58"; 9 | const KEYBOARD_USAGE_ID = 0x61; 10 | const KEYBOARD_USAGE_PAGE = 0xFF60; 11 | const KEYBOARD_UPDATE_TIME = 1000; 12 | 13 | // Info screen types 14 | const SCREEN_PERF = 0; 15 | const SCREEN_STOCK = 1; 16 | const SCREEN_WEATHER = 2; 17 | const screens = ["", "", ""]; 18 | let currentScreenIndex = 0; 19 | 20 | let keyboard = null; 21 | let screenBuffer = null; 22 | let screenLastUpdate = null; 23 | 24 | // Helper function to wait a few milliseconds using a promise 25 | function wait(ms) { 26 | return new Promise((resolve) => { 27 | setTimeout(resolve, ms); 28 | }) 29 | } 30 | 31 | function startPerfMonitor() { 32 | // Set the perf counter that we need for the performance screen 33 | const counters = new Map(); 34 | counters.set('cpu', '\\Processor(_Total)\\% Processor Time'); 35 | counters.set('mem', '\\Memory\\% Committed Bytes In Use'); 36 | counters.set('dsk', '\\PhysicalDisk(_Total)\\% Disk Time'); 37 | counters.set('net_used', '\\Network Interface(*)\\Bytes Total/sec'); 38 | counters.set('net_total', '\\Network Interface(*)\\Current Bandwidth'); 39 | 40 | function getStat(name, data) { 41 | // Convert the counter data into a value 1-10 that we can use to generate a bar graph 42 | const value = Math.floor(data.counters[counters.get(name)] / 100.0 * 10); 43 | return Math.min(10, Math.max(1, value)); 44 | } 45 | 46 | function getNetwork(data) { 47 | // Calculate the network usage and turn it into a value 1-10 that we can use to generate a bar graph 48 | const used = data.counters[counters.get('net_used')] * 8.0; 49 | const total = data.counters[counters.get('net_total')]; 50 | const value = Math.floor(used / total * 10); 51 | return Math.min(10, Math.max(1, value)); 52 | } 53 | 54 | perfmon([...counters.values()], function (err, data) { 55 | if (!data || Object.getOwnPropertyNames(data.counters).length < counters.size) { 56 | // Sometimes perfmon doesn't get all the counters working, no idea why. 57 | // Let's just restart to try it again 58 | console.log("Could not find all perf counters, restarting perfmon..."); 59 | perfmon.stop(); 60 | perfmon.start(); 61 | return; 62 | } 63 | 64 | // Get the value for each stat 65 | const cpu = getStat('cpu', data); 66 | const mem = getStat('mem', data); 67 | const dsk = getStat('dsk', data); 68 | const net = getNetwork(data); 69 | 70 | // Create a screen with the data 71 | const screen = 72 | `cpu: ${'\u0008'.repeat(cpu)}${' '.repeat(Math.max(0, 10 - cpu))} | ${title(0, 0)} ` + 73 | `mem: ${'\u0008'.repeat(mem)}${' '.repeat(Math.max(0, 10 - mem))} | ${title(1, 0)} ` + 74 | `dsk: ${'\u0008'.repeat(dsk)}${' '.repeat(Math.max(0, 10 - dsk))} | ${title(2, 0)} ` + 75 | `net: ${'\u0008'.repeat(net)}${' '.repeat(Math.max(0, 10 - net))} | ${title(3, 0)} `; 76 | 77 | // Set this to be the latest performance info 78 | screens[SCREEN_PERF] = screen; 79 | }); 80 | } 81 | 82 | async function startStockMonitor() { 83 | // Set the stocks that we want to show 84 | const stocks = new Map(); 85 | stocks.set('MSFT', 0); 86 | stocks.set('AAPL', 0); 87 | stocks.set('GOOG', 0); 88 | stocks.set('FB', 0); 89 | 90 | // The regex used to grab the price from the yahoo stocks page 91 | const priceRegex = /"currentPrice":({[^}]+})/; 92 | 93 | function getStocks() { 94 | const promises = []; 95 | for (const [key, value] of stocks) { 96 | promises.push(new Promise((resolve) => { 97 | // Get the stock price page for the current stock 98 | request(`https://finance.yahoo.com/quote/${key}/`, (err, res, body) => { 99 | // Parse out the price and update the map 100 | const result = priceRegex.exec(body); 101 | if (result && result.length > 1) { 102 | let price = JSON.parse(result[1]).raw; 103 | price = price.toFixed(2); 104 | stocks.set(key, price); 105 | } 106 | resolve(); 107 | }); 108 | })); 109 | } 110 | 111 | // Wait for all the stocks to be updated 112 | return Promise.all(promises); 113 | }; 114 | 115 | // Just keep updating the data forever 116 | while (true) { 117 | // Get the current stock prices 118 | await getStocks(); 119 | 120 | // Create a screen using the stock data 121 | const lines = []; 122 | for (const [key, value] of stocks) { 123 | const line = `${key.padEnd(5)}: $${value}`; 124 | lines.push(`${line}${' '.repeat(16 - line.length)}| ${title(lines.length, 1)} `); 125 | } 126 | 127 | // Set this to be the latest stock info 128 | screens[SCREEN_STOCK] = lines.join(''); 129 | 130 | // Pause a bit before requesting more info 131 | await wait(KEYBOARD_UPDATE_TIME); 132 | } 133 | } 134 | 135 | async function startWeatherMonitor() { 136 | // Regex's for reading out the weather info from the yahoo page 137 | const tempRegex = /"temperature":({[^}]+})/; 138 | const condRegex = /"conditionDescription":"([^"]+)"/; 139 | const rainRegex = /"precipitationProbability":([^,]+),/; 140 | 141 | function getWeather() { 142 | return new Promise((resolve) => { 143 | request(`https://www.yahoo.com/news/weather/united-states/seattle/seattle-2490383`, (err, res, body) => { 144 | const weather = {}; 145 | const temp = tempRegex.exec(body); 146 | if (temp && temp.length > 1) { 147 | weather.temp = JSON.parse(temp[1]); 148 | } 149 | 150 | const cond = condRegex.exec(body); 151 | if (cond && cond.length > 1) { 152 | weather.desc = cond[1]; 153 | } 154 | 155 | const rain = rainRegex.exec(body); 156 | if (rain && rain.length > 1) { 157 | weather.rain = rain[1]; 158 | } 159 | resolve(weather); 160 | }); 161 | }); 162 | } 163 | 164 | // Used for scrolling long weather descriptions 165 | let lastWeather = null; 166 | let lastWeatherDescIndex = 0; 167 | 168 | // Just keep updating the data forever 169 | while (true) { 170 | // Get the current weather for Seattle 171 | const weather = await getWeather(); 172 | if (weather && weather.temp && weather.desc && weather.rain) { 173 | let description = weather.desc; 174 | 175 | // If we are trying to show the same weather description more than once, and it is longer than 9 176 | // Which is all that will fit in our space, lets scroll it. 177 | if (lastWeather && weather.desc == lastWeather.desc && weather.desc.length > 9) { 178 | // Move the string one character over 179 | lastWeatherDescIndex++; 180 | description = description.slice(lastWeatherDescIndex, lastWeatherDescIndex + 9); 181 | if (lastWeatherDescIndex > weather.desc.length - 9) { 182 | // Restart back at the beginning 183 | lastWeatherDescIndex = -1; // minus one since we increment before we show 184 | } 185 | } else { 186 | lastWeatherDescIndex = 0; 187 | } 188 | lastWeather = weather; 189 | 190 | // Create the new screen 191 | const screen = 192 | `desc: ${description}${' '.repeat(Math.max(0, 9 - ('' + description).length))} | ${title(0, 2)} ` + 193 | `temp: ${weather.temp.now}${' '.repeat(Math.max(0, 9 - ('' + weather.temp.now).length))} | ${title(1, 2)} ` + 194 | `high: ${weather.temp.high}${' '.repeat(Math.max(0, 9 - ('' + weather.temp.high).length))} | ${title(2, 2)} ` + 195 | `rain: ${weather.rain}%${' '.repeat(Math.max(0, 8 - ('' + weather.rain).length))} | ${title(3, 2)} `; 196 | 197 | // Set this to be the latest weather info 198 | screens[SCREEN_WEATHER] = screen; 199 | } 200 | 201 | // Pause a bit before requesting more info 202 | await wait(KEYBOARD_UPDATE_TIME); 203 | } 204 | } 205 | 206 | function title(i, titleIndex) { 207 | // Return the character that indicates the title part from the font data 208 | if (i === 3) { 209 | return '\u00DE'; 210 | } 211 | return String.fromCharCode((0x9A - titleIndex) + i * 32); 212 | } 213 | 214 | async function sendToKeyboard(screen) { 215 | // If we are already buffering a screen to the keyboard just quit early. 216 | // Or if there is no update from what we sent last time. 217 | if (screenBuffer || screenLastUpdate === screen) { 218 | return; 219 | } 220 | 221 | screenLastUpdate = screen; 222 | 223 | // Convert the screen string into raw bytes 224 | screenBuffer = []; 225 | for (let i = 0; i < screen.length; i++) { 226 | screenBuffer.push(screen.charCodeAt(i)); 227 | } 228 | 229 | // Split the bytes into 4 lines that we will send one at a time 230 | // This is to prevent hitting the 32 length limit on the connection 231 | const lines = []; 232 | lines.push([0].concat(screenBuffer.slice(0, 21))); 233 | lines.push([0].concat(screenBuffer.slice(21, 42))); 234 | lines.push([0].concat(screenBuffer.slice(42, 63))); 235 | lines.push([0].concat(screenBuffer.slice(63, 84))); 236 | 237 | // Loop through and send each line after a small delay to allow the 238 | // keyboard to store it ready to send to the slave side once full. 239 | let index = 0; 240 | for (const line of lines) { 241 | keyboard.write(line); 242 | await wait(10); 243 | } 244 | 245 | // We have sent the screen data, so clear it ready for the next one 246 | screenBuffer = null; 247 | } 248 | 249 | function updateKeyboardScreen() { 250 | // If we don't have a connection to a keyboard yet, look now 251 | if (!keyboard) { 252 | // Search all devices for a matching keyboard 253 | const devices = hid.devices(); 254 | for (const d of devices) { 255 | if (d.product === KEYBOARD_NAME && d.usage === KEYBOARD_USAGE_ID && d.usagePage === KEYBOARD_USAGE_PAGE) { 256 | // Create a new connection and store it as the keyboard 257 | keyboard = new hid.HID(d.path); 258 | console.log(`Keyboard connection established.`); 259 | 260 | // Listen for data from the keyboard which indicates the screen to show 261 | keyboard.on('data', (e) => { 262 | // Check that the data is a valid screen index and update the current one 263 | if (e[0] >= 1 && e[0] <= screens.length) { 264 | currentScreenIndex = e[0] - 1; 265 | console.log(`Keyboard requested screen index: ${currentScreenIndex}`); 266 | } 267 | }); 268 | 269 | // On the initial connection write our special sequence 270 | // 1st byte - unused and thrown away on windows see bug in node-hid 271 | // 2nd byte - 1 to indicate a new connection 272 | // 3rd byte - number of screens the keyboard can scroll through 273 | keyboard.write([0, 1, screens.length]); 274 | break; 275 | } 276 | } 277 | } 278 | 279 | // If we have a connection to a keyboard and a valid screen 280 | if (keyboard && screens[currentScreenIndex].length === 84) { 281 | // Send that data to the keyboard 282 | sendToKeyboard(screens[currentScreenIndex]); 283 | } 284 | } 285 | 286 | // Start the monitors that collect the info to display 287 | startPerfMonitor(); 288 | startStockMonitor(); 289 | startWeatherMonitor(); 290 | 291 | // Update the data on the keyboard with the current info screen every second 292 | setInterval(updateKeyboardScreen, KEYBOARD_UPDATE_TIME); 293 | --------------------------------------------------------------------------------