├── .gitignore ├── _config.yml ├── .eslintrc.js ├── package.json ├── examples ├── imageconverter-html.html ├── logtofile.html ├── imageconverter.html ├── puck.html ├── imageconverter-simple.html ├── uart.html ├── uartUploadZIP.html └── fontconverter.html ├── README.md ├── cli └── fontconverter.js ├── puck.js ├── imageconverter.js ├── fontconverter.js └── uart.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["eslint:recommended"], 3 | env: { 4 | browser: true, 5 | node: true, 6 | es6: true, 7 | }, 8 | 9 | parserOptions: { 10 | ecmaVersion: 11, 11 | "requireConfigFile": false 12 | }, 13 | "parser": "@babel/eslint-parser", 14 | 15 | globals: { 16 | }, 17 | 18 | rules: { 19 | "no-undef": "warn", 20 | "no-extra-semi": "warn", 21 | "no-redeclare": "warn", 22 | "no-var": "off", 23 | "no-unused-vars": ["warn", { args: "none" }], 24 | "no-control-regex": "off", 25 | "brace-style": ["warn", "1tbs", { "allowSingleLine": true }] 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "espruinowebtools", 3 | "version": "0.01", 4 | "description": "Lightweight tools for use on websites with Espruino-based microcontrollers", 5 | "author": "Gordon Williams (http://espruino.com)", 6 | "license": "Apache-2.0", 7 | "bugs": { 8 | "url": "https://github.com/espruino/EspruinoWebTools/issues" 9 | }, 10 | "homepage": "https://github.com/espruino/EspruinoWebTools#readme", 11 | "scripts": { 12 | "lint": "eslint uart.js puck.js imageconverter.js fontconverter.js" 13 | }, 14 | "dependencies": { 15 | "btoa": "^1.2.1", 16 | "pngjs": "^7.0.0" 17 | }, 18 | "devDependencies": { 19 | "@babel/eslint-parser": "^7.27.0", 20 | "@types/node": "^22.14.1", 21 | "eslint": "^8.57.1", 22 | "typescript": "^5.8.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/imageconverter-html.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

Converting HTML to an image and sending it to Espruino...

11 |
12 |
13 | This is some text
14 | Big text 15 |
16 |
17 |
18 |
19 |
20 | 21 | 22 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | EspruinoWebTools 2 | ================ 3 | 4 | Tools/utilities for accessing Espruino devices from websites. 5 | 6 | [Read me on GitHub.io](https://espruino.github.io/EspruinoWebTools/) 7 | 8 | ## uart.js 9 | 10 | Super-simple library for accessing Bluetooth LE, Serial and USB 11 | Espruino devices straight from the web browser. 12 | 13 | ``` 14 | UART.write('LED1.set();\n'); 15 | ``` 16 | 17 | * [Simple test](https://espruino.github.io/EspruinoWebTools/examples/uart.html) 18 | * [Log data to a file](https://espruino.github.io/EspruinoWebTools/examples/logtofile.html) 19 | 20 | 21 | ## imageconverter.js 22 | 23 | Library to help converting images into a format suitable for Espruino. 24 | 25 | ``` 26 | var img = document.getElementById("image"); 27 | var jscode = imageconverter.imagetoString(img, {mode:"1bit", diffusion:"error"}); 28 | ``` 29 | try out: 30 | 31 | * [Online Image converter](https://espruino.github.io/EspruinoWebTools/examples/imageconverter.html) 32 | * [Simple Image conversion](https://espruino.github.io/EspruinoWebTools/examples/imageconverter-simple.html) 33 | * [Send HTML to Espruino as an Image](https://espruino.github.io/EspruinoWebTools/examples/imageconverter-html.html) 34 | 35 | ## heatshrink.js 36 | 37 | JavaScript port of the [heatshrink library](https://github.com/atomicobject/heatshrink) 38 | for use with the heatshrink compression library inside Espruino. 39 | 40 | ``` 41 | var data = new Uint8Array(...);; 42 | var compressed = heatshrink.compress(data); 43 | data = heatshrink.decompress(compressed); 44 | ``` 45 | 46 | ## puck.js 47 | 48 | Super-simple library for accessing Bluetooth LE. It's recommended 49 | you use `uart.js` now as it supports more communication types. 50 | 51 | ``` 52 | Puck.write('LED1.set();\n'); 53 | ``` 54 | 55 | [try it out](https://espruino.github.io/EspruinoWebTools/examples/puck.html) 56 | -------------------------------------------------------------------------------- /examples/logtofile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Log to file

6 |

This page connects to an Espruino device, calls a 7 | function called getData() (which you should 8 | have created previously) and then stored all data received 9 | in the text box below. You can click 'Save Data' to save it 10 | to a file. 11 |

12 | 13 | Status:
14 | Received data:
15 | 17 | 18 | 19 | 20 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /cli/fontconverter.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/node 2 | 3 | var fontconverter = require("../fontconverter.js"); 4 | var RANGES = fontconverter.getRanges(); 5 | 6 | var fontInfo = {}; 7 | var options = {}; 8 | 9 | // Scan Args 10 | for (var i=2;i 0) 100 | space.width = options.spaceWidth; 101 | space.xEnd = options.spaceWidth-1; 102 | space.advance = options.spaceWidth; 103 | } 104 | 105 | if (options.debug) { 106 | font.debugChars(); 107 | font.debugPixelsUsed(); 108 | } 109 | if (options.tests) 110 | options.tests.forEach(test => font.printString(test)); 111 | if (options.ojs) 112 | require("fs").writeFileSync(options.ojs, Buffer.from(font.getJS())) 113 | if (options.oh) 114 | require("fs").writeFileSync(options.oh, Buffer.from(font.getHeaderFile())) 115 | if (options.opbf) 116 | require("fs").writeFileSync(options.opbf, Buffer.from(font.getPBF())) 117 | if (options.opbff) 118 | require("fs").writeFileSync(options.opbff, Buffer.from(font.getPBFF())) 119 | if (options.opbfc) 120 | font.getPBFAsC({ 121 | name:options.opbfc, 122 | filename:"jswrap_font_"+options.opbfc, 123 | createdBy:"EspruinoWebTools/cli/fontconverter.js "+process.argv.slice(2).join(" ") 124 | }); 125 | -------------------------------------------------------------------------------- /examples/imageconverter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

An online image converter for Espruino...

9 | 10 |
11 | Use Compression?
12 | Transparency to Color
13 | Transparency?
14 | Inverted?
15 | Crop?
16 | Diffusion:
17 | 18 | Brightness: 19 |
20 | Contrast: 21 |
22 | Scale: 23 |
24 | Colours:
25 | Output As:
27 | 28 | 29 | 30 |

Result

31 |

...

32 | 33 | 34 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /examples/puck.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

We now recommend that you use uart.js as it provides both Bluetooth and Serial communications

6 | 7 | 8 | 9 |
10 |
11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/imageconverter-simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

Converting an image to a format ready for Espruino...

9 | 10 |
11 |
12 | 13 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/uart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/uartUploadZIP.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 |
18 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /examples/fontconverter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

Converting a font to a format ready for Espruino...

9 | 10 |
11 | 12 | 13 | 14 | 29 | 43 | 44 |
15 |

Use an external font:

16 |
    17 |
  • Go to https://fonts.google.com/, find a font
  • 18 |
  • Click Select This Style
  • 19 |
  • Copy the <link href=... line
  • 20 |
  • Paste it into the text box below
  • 21 |
  • Or use a link to .otf or .woff file, or the name of a font installed on your computer
  • 22 |
23 |

24 | 27 |

28 |
30 |

OR use a font file:

31 |
    32 |
  • Select a .otf or .woff font file on your computer
  • 33 |
  • Enter a name for your font in the text box below
  • 34 |
35 |

36 | 37 |

38 |

39 | Font name: 40 | 41 |

42 |
45 | 46 |

Set font options:

47 |
48 | Size : 16
50 | BPP :
55 | Range :
58 | Align to increase sharpness :
59 | Use compression :
60 |
61 |
62 | 64 |
65 |
66 | 67 | 68 |

69 | 71 |

72 | 437 | 438 | 439 | -------------------------------------------------------------------------------- /puck.js: -------------------------------------------------------------------------------- 1 | /* 2 | -------------------------------------------------------------------- 3 | Puck.js BLE Interface library for Nordic UART 4 | Copyright 2021 Gordon Williams (gw@pur3.co.uk) 5 | https://github.com/espruino/EspruinoWebTools 6 | -------------------------------------------------------------------- 7 | This Source Code Form is subject to the terms of the Mozilla Public 8 | License, v. 2.0. If a copy of the MPL was not distributed with this 9 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | -------------------------------------------------------------------- 11 | This creates a 'Puck' object that can be used from the Web Browser. 12 | 13 | Simple usage: 14 | 15 | Puck.write("LED1.set()\n") 16 | 17 | Execute expression and return the result: 18 | 19 | Puck.eval("BTN.read()", function(d) { 20 | alert(d); 21 | }); 22 | 23 | Or write and wait for a result - this will return all characters, 24 | including echo and linefeed from the REPL so you may want to send 25 | `echo(0)` and use `console.log` when doing this. 26 | 27 | Puck.write("1+2\n", function(d) { 28 | alert(d); 29 | }); 30 | 31 | Both `eval` and `write` will return a promise if no callback 32 | function is given as an argument. 33 | 34 | alert( await Puck.eval("BTN.read()") ) 35 | 36 | alert( await Puck.write("1+2\n") ) 37 | 38 | 39 | Or more advanced usage with control of the connection 40 | - allows multiple connections 41 | 42 | Puck.connect(function(connection) { 43 | if (!connection) throw "Error!"; 44 | connection.on('data', function(d) { ... }); 45 | connection.on('close', function() { ... }); 46 | connection.write("1+2\n", function() { 47 | connection.close(); 48 | }); 49 | }); 50 | 51 | ChangeLog: 52 | 53 | ... 54 | 1.02: Puck.write/eval now wait until they have received data with a newline in (if requested) 55 | and return the LAST received line, rather than the first (as before) 56 | Added configurable timeouts for write/etc 57 | 1.01: Raise default Chunk Size to 20 58 | Auto-adjust chunk size up if we receive >20 bytes in a packet 59 | 1.00: Added Promises to write/eval 60 | 61 | */ 62 | (function (root, factory) { 63 | /* global define */ 64 | if (typeof define === 'function' && define.amd) { 65 | // AMD. Register as an anonymous module. 66 | define([], factory); 67 | } else if (typeof module === 'object' && module.exports) { 68 | // Node. Does not work with strict CommonJS, but 69 | // only CommonJS-like environments that support module.exports, 70 | // like Node. 71 | module.exports = factory(); 72 | } else { 73 | // Browser globals (root is window) 74 | root.Puck = factory(); 75 | } 76 | }(typeof self !== 'undefined' ? self : this, function () { 77 | 78 | if (typeof navigator == "undefined") return; 79 | 80 | var isBusy; 81 | var queue = []; 82 | 83 | function checkIfSupported() { 84 | // Hack for windows 85 | if (navigator.platform.indexOf("Win")>=0 && 86 | (navigator.userAgent.indexOf("Chrome/54")>=0 || 87 | navigator.userAgent.indexOf("Chrome/55")>=0 || 88 | navigator.userAgent.indexOf("Chrome/56")>=0) 89 | ) { 90 | console.warn("Chrome <56 in Windows has navigator.bluetooth but it's not implemented properly"); 91 | if (confirm("Web Bluetooth on Windows is not yet available.\nPlease click Ok to see other options for using Web Bluetooth")) 92 | window.location = "https://www.espruino.com/Puck.js+Quick+Start"; 93 | return false; 94 | } 95 | if (navigator.bluetooth) return true; 96 | console.warn("No Web Bluetooth on this platform"); 97 | var iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 98 | if (iOS) { 99 | if (confirm("To use Web Bluetooth on iOS you'll need the WebBLE App.\nPlease click Ok to go to the App Store and download it.")) 100 | window.location = "https://itunes.apple.com/us/app/webble/id1193531073"; 101 | } else { 102 | if (confirm("This Web Browser doesn't support Web Bluetooth.\nPlease click Ok to see instructions for enabling it.")) 103 | window.location = "https://www.espruino.com/Quick+Start+BLE#with-web-bluetooth"; 104 | } 105 | return false; 106 | } 107 | 108 | var NORDIC_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; 109 | var NORDIC_TX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; 110 | var NORDIC_RX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; 111 | var DEFAULT_CHUNKSIZE = 20; 112 | 113 | function log(level, s) { 114 | if (puck.log) puck.log(level, s); 115 | } 116 | 117 | function ab2str(buf) { 118 | return String.fromCharCode.apply(null, new Uint8Array(buf)); 119 | } 120 | 121 | function str2ab(str) { 122 | var buf = new ArrayBuffer(str.length); 123 | var bufView = new Uint8Array(buf); 124 | for (var i=0, strLen=str.length; i Device UUIDs: ' + device.uuids.join('\n' + ' '.repeat(21))); 229 | device.addEventListener('gattserverdisconnected', function() { 230 | log(1, "Disconnected (gattserverdisconnected)"); 231 | connection.close(); 232 | }); 233 | connection.device = device; 234 | connection.reconnect(callback); 235 | }).catch(function(error) { 236 | log(1, 'ERROR: ' + error); 237 | connection.close(); 238 | }); 239 | 240 | connection.reconnect = function(callback) { 241 | connection.device.gatt.connect().then(function(server) { 242 | log(1, "Connected"); 243 | btServer = server; 244 | return server.getPrimaryService(NORDIC_SERVICE); 245 | }).then(function(service) { 246 | log(2, "Got service"); 247 | btService = service; 248 | return btService.getCharacteristic(NORDIC_RX); 249 | }).then(function (characteristic) { 250 | rxCharacteristic = characteristic; 251 | log(2, "RX characteristic:"+JSON.stringify(rxCharacteristic)); 252 | rxCharacteristic.addEventListener('characteristicvaluechanged', function(event) { 253 | var dataview = event.target.value; 254 | var data = ab2str(dataview.buffer); 255 | if (puck.increaseMTU && (data.length > chunkSize)) { 256 | log(2, "Received packet of length "+data.length+", increasing chunk size"); 257 | chunkSize = data.length; 258 | } 259 | if (puck.flowControl) { 260 | for (var i=0;i pause upload"); 265 | flowControlXOFF = true; 266 | } else if (ch==17) {// XON 267 | log(2,"XON received => resume upload"); 268 | flowControlXOFF = false; 269 | } else 270 | remove = false; 271 | if (remove) { // remove character 272 | data = data.substr(0,i-1)+data.substr(i+1); 273 | i--; 274 | } 275 | } 276 | } 277 | log(3, "Received "+JSON.stringify(data)); 278 | connection.emit('data', data); 279 | }); 280 | return rxCharacteristic.startNotifications(); 281 | }).then(function() { 282 | return btService.getCharacteristic(NORDIC_TX); 283 | }).then(function (characteristic) { 284 | txCharacteristic = characteristic; 285 | log(2, "TX characteristic:"+JSON.stringify(txCharacteristic)); 286 | }).then(function() { 287 | connection.txInProgress = false; 288 | connection.isOpen = true; 289 | connection.isOpening = false; 290 | isBusy = false; 291 | queue = []; 292 | callback(connection); 293 | connection.emit('open'); 294 | // if we had any writes queued, do them now 295 | connection.write(); 296 | }).catch(function(error) { 297 | log(1, 'ERROR: ' + error); 298 | connection.close(); 299 | }); 300 | }; 301 | 302 | return connection; 303 | } 304 | 305 | // ---------------------------------------------------------- 306 | var connection; 307 | /* convenience function... Write data, call the callback with data: 308 | callbackNewline = false => if no new data received for ~0.2 sec 309 | callbackNewline = true => after a newline */ 310 | function write(data, callback, callbackNewline) { 311 | if (!checkIfSupported()) return; 312 | 313 | let result; 314 | /// If there wasn't a callback function, then promisify 315 | if (typeof callback !== 'function') { 316 | callbackNewline = callback; 317 | 318 | result = new Promise((resolve, reject) => callback = (value, err) => { 319 | if (err) reject(err); 320 | else resolve(value); 321 | }); 322 | } 323 | 324 | if (isBusy) { 325 | log(3, "Busy - adding Puck.write to queue"); 326 | queue.push({type:"write", data:data, callback:callback, callbackNewline:callbackNewline}); 327 | return result; 328 | } 329 | 330 | var cbTimeout; 331 | function onWritten() { 332 | if (callbackNewline) { 333 | connection.cb = function(d) { 334 | // if we hadn't got a newline this time (even if we had one before) 335 | // then ignore it (https://github.com/espruino/BangleApps/issues/3771) 336 | if (!d.includes("\n")) return; 337 | // now return the LAST received non-empty line 338 | var lines = connection.received.split("\n"); 339 | var idx = lines.length-1; 340 | while (lines[idx].trim().length==0 && idx>0) idx--; // skip over empty lines 341 | var line = lines.splice(idx,1)[0]; // get the non-empty line 342 | connection.received = lines.join("\n"); // put back other lines 343 | // remove handler and return 344 | connection.cb = undefined; 345 | if (cbTimeout) clearTimeout(cbTimeout); 346 | cbTimeout = undefined; 347 | if (callback) 348 | callback(line); 349 | isBusy = false; 350 | handleQueue(); 351 | }; 352 | } 353 | // wait for any received data if we have a callback... 354 | var maxTime = puck.timeoutMax; // Max time we wait in total, even if getting data 355 | var dataWaitTime = callbackNewline ? puck.timeoutNewline : puck.timeoutNormal; 356 | var maxDataTime = dataWaitTime; // max time we wait after having received data 357 | const POLLINTERVAL = 100; 358 | cbTimeout = setTimeout(function timeout() { 359 | cbTimeout = undefined; 360 | if (maxTime>0) maxTime-=POLLINTERVAL; 361 | if (maxDataTime>0) maxDataTime-=POLLINTERVAL; 362 | if (connection.hadData) maxDataTime=dataWaitTime; 363 | if (maxDataTime>0 && maxTime>0) { 364 | cbTimeout = setTimeout(timeout, POLLINTERVAL); 365 | } else { 366 | connection.cb = undefined; 367 | if (callback) 368 | callback(connection.received); 369 | isBusy = false; 370 | handleQueue(); 371 | connection.received = ""; 372 | } 373 | connection.hadData = false; 374 | }, POLLINTERVAL); 375 | } 376 | 377 | if (connection && (connection.isOpen || connection.isOpening)) { 378 | if (!connection.txInProgress) connection.received = ""; 379 | isBusy = true; 380 | connection.write(data, onWritten); 381 | return result 382 | } 383 | 384 | connection = connect(function(puck) { 385 | if (!puck) { 386 | connection = undefined; 387 | if (callback) callback(null); 388 | return; 389 | } 390 | connection.received = ""; 391 | connection.on('data', function(d) { 392 | connection.received += d; 393 | connection.hadData = true; 394 | if (connection.cb) connection.cb(d); 395 | }); 396 | connection.on('close', function(d) { 397 | connection = undefined; 398 | }); 399 | isBusy = true; 400 | connection.write(data, onWritten); 401 | }); 402 | 403 | return result 404 | } 405 | 406 | // ---------------------------------------------------------- 407 | 408 | var puck = { 409 | version : "1.02", 410 | /// Are we writing debug information? 0 is no, 1 is some, 2 is more, 3 is all. 411 | debug : 1, 412 | /** When we receive more than 20 bytes, should we increase the chunk size we use 413 | for writing to match it? Normally this is fine but it seems some phones have 414 | a broken bluetooth implementation that doesn't allow it. */ 415 | increaseMTU : true, 416 | /// Should we use flow control? Default is true 417 | flowControl : true, 418 | /// timeout (in ms) in .write when waiting for any data to return 419 | timeoutNormal : 300, 420 | /// timeout (in ms) in .write/.eval when waiting for a newline 421 | timeoutNewline : 10000, 422 | /// timeout (in ms) to wait at most 423 | timeoutMax : 30000, 424 | /// Used internally to write log information - you can replace this with your own function 425 | log : function(level, s) { if (level <= this.debug) console.log(" "+s)}, 426 | /// Called with the current send progress or undefined when done - you can replace this with your own function 427 | writeProgress : function (charsSent, charsTotal) { 428 | //console.log(charsSent + "/" + charsTotal); 429 | }, 430 | /** Connect to a new device - this creates a separate 431 | connection to the one `write` and `eval` use. */ 432 | connect : connect, 433 | /// Write to Puck.js and call back when the data is written. Creates a connection if it doesn't exist 434 | write : write, 435 | /// Evaluate an expression and call cb with the result. Creates a connection if it doesn't exist 436 | eval : function(expr, cb) { 437 | const response = write('\x10Bluetooth.println(JSON.stringify(' + expr + '))\n', true) 438 | .then(function (d) { 439 | try { 440 | return JSON.parse(d); 441 | } catch (e) { 442 | log(1, "Unable to decode " + JSON.stringify(d) + ", got " + e.toString()); 443 | return Promise.reject(d); 444 | } 445 | }); 446 | if (cb) { 447 | return void response.then(cb, (err) => cb(null, err)); 448 | } else { 449 | return response; 450 | } 451 | 452 | }, 453 | /// Write the current time to the Puck 454 | setTime : function(cb) { 455 | var d = new Date(); 456 | var cmd = 'setTime('+(d.getTime()/1000)+');'; 457 | // in 1v93 we have timezones too 458 | cmd += 'if (E.setTimeZone) E.setTimeZone('+d.getTimezoneOffset()/-60+');\n'; 459 | write(cmd, cb); 460 | }, 461 | /// Did `write` and `eval` manage to create a connection? 462 | isConnected : function() { 463 | return connection!==undefined; 464 | }, 465 | /// get the connection used by `write` and `eval` 466 | getConnection : function() { 467 | return connection; 468 | }, 469 | /// Close the connection used by `write` and `eval` 470 | close : function() { 471 | if (connection) 472 | connection.close(); 473 | }, 474 | /** Utility function to fade out everything on the webpage and display 475 | a window saying 'Click to continue'. When clicked it'll disappear and 476 | 'callback' will be called. This is useful because you can't initialise 477 | Web Bluetooth unless you're doing so in response to a user input.*/ 478 | modal : function(callback) { 479 | var e = document.createElement('div'); 480 | e.style = 'position:absolute;top:0px;left:0px;right:0px;bottom:0px;opacity:0.5;z-index:100;background:black;'; 481 | e.innerHTML = '
Click to Continue...
'; 482 | e.onclick = function(evt) { 483 | callback(); 484 | evt.preventDefault(); 485 | document.body.removeChild(e); 486 | }; 487 | document.body.appendChild(e); 488 | } 489 | }; 490 | return puck; 491 | })); 492 | -------------------------------------------------------------------------------- /imageconverter.js: -------------------------------------------------------------------------------- 1 | /* Copyright 2024 Gordon Williams, gw@pur3.co.uk 2 | https://github.com/espruino/EspruinoWebTools 3 | */ 4 | (function (root, factory) { 5 | if (typeof module === 'object' && module.exports) { 6 | // Node. Does not work with strict CommonJS, but 7 | // only CommonJS-like environments that support module.exports, 8 | // like Node. 9 | module.exports = factory(root.heatshrink); 10 | } else { 11 | // Browser globals (root is window) 12 | root.imageconverter = factory(root.heatshrink); 13 | } 14 | }(typeof self !== 'undefined' ? self : this, function (heatshrink) { 15 | 16 | const PALETTE = { 17 | VGA: [0x000000, 0x0000a8, 0x00a800, 0x00a8a8, 0xa80000, 0xa800a8, 0xa85400, 0xa8a8a8, 0x545454, 0x5454fc, 0x54fc54, 0x54fcfc, 0xfc5454, 0xfc54fc, 0xfcfc54, 0xfcfcfc, 0x000000, 0x141414, 0x202020, 0x2c2c2c, 0x383838, 0x444444, 0x505050, 0x606060, 0x707070, 0x808080, 0x909090, 0xa0a0a0, 0xb4b4b4, 0xc8c8c8, 0xe0e0e0, 0xfcfcfc, 0x0000fc, 0x4000fc, 0x7c00fc, 0xbc00fc, 0xfc00fc, 0xfc00bc, 0xfc007c, 0xfc0040, 0xfc0000, 0xfc4000, 0xfc7c00, 0xfcbc00, 0xfcfc00, 0xbcfc00, 0x7cfc00, 0x40fc00, 0x00fc00, 0x00fc40, 0x00fc7c, 0x00fcbc, 0x00fcfc, 0x00bcfc, 0x007cfc, 0x0040fc, 0x7c7cfc, 0x9c7cfc, 0xbc7cfc, 0xdc7cfc, 0xfc7cfc, 0xfc7cdc, 0xfc7cbc, 0xfc7c9c, 0xfc7c7c, 0xfc9c7c, 0xfcbc7c, 0xfcdc7c, 0xfcfc7c, 0xdcfc7c, 0xbcfc7c, 0x9cfc7c, 0x7cfc7c, 0x7cfc9c, 0x7cfcbc, 0x7cfcdc, 0x7cfcfc, 0x7cdcfc, 0x7cbcfc, 0x7c9cfc, 0xb4b4fc, 0xc4b4fc, 0xd8b4fc, 0xe8b4fc, 0xfcb4fc, 0xfcb4e8, 0xfcb4d8, 0xfcb4c4, 0xfcb4b4, 0xfcc4b4, 0xfcd8b4, 0xfce8b4, 0xfcfcb4, 0xe8fcb4, 0xd8fcb4, 0xc4fcb4, 0xb4fcb4, 0xb4fcc4, 0xb4fcd8, 0xb4fce8, 0xb4fcfc, 0xb4e8fc, 0xb4d8fc, 0xb4c4fc, 0x000070, 0x1c0070, 0x380070, 0x540070, 0x700070, 0x700054, 0x700038, 0x70001c, 0x700000, 0x701c00, 0x703800, 0x705400, 0x707000, 0x547000, 0x387000, 0x1c7000, 0x007000, 0x00701c, 0x007038, 0x007054, 0x007070, 0x005470, 0x003870, 0x001c70, 0x383870, 0x443870, 0x543870, 0x603870, 0x703870, 0x703860, 0x703854, 0x703844, 0x703838, 0x704438, 0x705438, 0x706038, 0x707038, 0x607038, 0x547038, 0x447038, 0x387038, 0x387044, 0x387054, 0x387060, 0x387070, 0x386070, 0x385470, 0x384470, 0x505070, 0x585070, 0x605070, 0x685070, 0x705070, 0x705068, 0x705060, 0x705058, 0x705050, 0x705850, 0x706050, 0x706850, 0x707050, 0x687050, 0x607050, 0x587050, 0x507050, 0x507058, 0x507060, 0x507068, 0x507070, 0x506870, 0x506070, 0x505870, 0x000040, 0x100040, 0x200040, 0x300040, 0x400040, 0x400030, 0x400020, 0x400010, 0x400000, 0x401000, 0x402000, 0x403000, 0x404000, 0x304000, 0x204000, 0x104000, 0x004000, 0x004010, 0x004020, 0x004030, 0x004040, 0x003040, 0x002040, 0x001040, 0x202040, 0x282040, 0x302040, 0x382040, 0x402040, 0x402038, 0x402030, 0x402028, 0x402020, 0x402820, 0x403020, 0x403820, 0x404020, 0x384020, 0x304020, 0x284020, 0x204020, 0x204028, 0x204030, 0x204038, 0x204040, 0x203840, 0x203040, 0x202840, 0x2c2c40, 0x302c40, 0x342c40, 0x3c2c40, 0x402c40, 0x402c3c, 0x402c34, 0x402c30, 0x402c2c, 0x40302c, 0x40342c, 0x403c2c, 0x40402c, 0x3c402c, 0x34402c, 0x30402c, 0x2c402c, 0x2c4030, 0x2c4034, 0x2c403c, 0x2c4040, 0x2c3c40, 0x2c3440, 0x2c3040, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0xFFFFFF], 18 | WEB : [0x000000,0x000033,0x000066,0x000099,0x0000cc,0x0000ff,0x003300,0x003333,0x003366,0x003399,0x0033cc,0x0033ff,0x006600,0x006633,0x006666,0x006699,0x0066cc,0x0066ff,0x009900,0x009933,0x009966,0x009999,0x0099cc,0x0099ff,0x00cc00,0x00cc33,0x00cc66,0x00cc99,0x00cccc,0x00ccff,0x00ff00,0x00ff33,0x00ff66,0x00ff99,0x00ffcc,0x00ffff,0x330000,0x330033,0x330066,0x330099,0x3300cc,0x3300ff,0x333300,0x333333,0x333366,0x333399,0x3333cc,0x3333ff,0x336600,0x336633,0x336666,0x336699,0x3366cc,0x3366ff,0x339900,0x339933,0x339966,0x339999,0x3399cc,0x3399ff,0x33cc00,0x33cc33,0x33cc66,0x33cc99,0x33cccc,0x33ccff,0x33ff00,0x33ff33,0x33ff66,0x33ff99,0x33ffcc,0x33ffff,0x660000,0x660033,0x660066,0x660099,0x6600cc,0x6600ff,0x663300,0x663333,0x663366,0x663399,0x6633cc,0x6633ff,0x666600,0x666633,0x666666,0x666699,0x6666cc,0x6666ff,0x669900,0x669933,0x669966,0x669999,0x6699cc,0x6699ff,0x66cc00,0x66cc33,0x66cc66,0x66cc99,0x66cccc,0x66ccff,0x66ff00,0x66ff33,0x66ff66,0x66ff99,0x66ffcc,0x66ffff,0x990000,0x990033,0x990066,0x990099,0x9900cc,0x9900ff,0x993300,0x993333,0x993366,0x993399,0x9933cc,0x9933ff,0x996600,0x996633,0x996666,0x996699,0x9966cc,0x9966ff,0x999900,0x999933,0x999966,0x999999,0x9999cc,0x9999ff,0x99cc00,0x99cc33,0x99cc66,0x99cc99,0x99cccc,0x99ccff,0x99ff00,0x99ff33,0x99ff66,0x99ff99,0x99ffcc,0x99ffff,0xcc0000,0xcc0033,0xcc0066,0xcc0099,0xcc00cc,0xcc00ff,0xcc3300,0xcc3333,0xcc3366,0xcc3399,0xcc33cc,0xcc33ff,0xcc6600,0xcc6633,0xcc6666,0xcc6699,0xcc66cc,0xcc66ff,0xcc9900,0xcc9933,0xcc9966,0xcc9999,0xcc99cc,0xcc99ff,0xcccc00,0xcccc33,0xcccc66,0xcccc99,0xcccccc,0xccccff,0xccff00,0xccff33,0xccff66,0xccff99,0xccffcc,0xccffff,0xff0000,0xff0033,0xff0066,0xff0099,0xff00cc,0xff00ff,0xff3300,0xff3333,0xff3366,0xff3399,0xff33cc,0xff33ff,0xff6600,0xff6633,0xff6666,0xff6699,0xff66cc,0xff66ff,0xff9900,0xff9933,0xff9966,0xff9999,0xff99cc,0xff99ff,0xffcc00,0xffcc33,0xffcc66,0xffcc99,0xffcccc,0xffccff,0xffff00,0xffff33,0xffff66,0xffff99,0xffffcc,0xffffff], 19 | MAC16 : [ 20 | 0x000000, 0x444444, 0x888888, 0xBBBBBB, 21 | 0x996633, 0x663300, 0x006600, 0x00aa00, 22 | 0x0099ff, 0x0000cc, 0x330099, 0xff0099, 23 | 0xdd0000, 0xff6600, 0xffff00, 0xffffff], 24 | lookup : function(palette,r,g,b,a, transparentCol) { 25 | if (isFinite(transparentCol) && a<128) return transparentCol; 26 | var maxd = 0xFFFFFF; 27 | var c = 0; 28 | palette.forEach(function(p,n) { 29 | var pr=(p>>16)&255; 30 | var pg=(p>>8)&255; 31 | var pb=p&255; 32 | var pa=(p>>24)&255; 33 | if (transparentCol=="palette" && pa<128) { 34 | // if this is a transparent palette entry, 35 | // either use it or ignore it depending on pixel transparency 36 | if (a<128) { 37 | maxd = 0; 38 | c = n; 39 | } 40 | return; 41 | } 42 | var dr = r-pr; 43 | var dg = g-pg; 44 | var db = b-pb; 45 | var d = dr*dr + dg*dg + db*db; 46 | if (dthresh; 63 | },toRGBA:function(c) { 64 | return c ? 0xFFFFFFFF : 0xFF000000; 65 | } 66 | }, 67 | "2bitbw":{ 68 | bpp:2,name:"2 bit greyscale", 69 | fromRGBA:function(r,g,b) { 70 | var c = (r+g+b) / 3; 71 | c += 31; // rounding 72 | if (c>255)c=255; 73 | return c>>6; 74 | },toRGBA:function(c) { 75 | c = c&3; 76 | c = c | (c<<2) | (c<<4) | (c<<6); 77 | return 0xFF000000|(c<<16)|(c<<8)|c; 78 | } 79 | }, 80 | "4bitbw":{ 81 | bpp:4,name:"4 bit greyscale", 82 | fromRGBA:function(r,g,b) { 83 | var c = (r+g+b) / 3; 84 | c += 7; // rounding 85 | if (c>255)c=255; 86 | return c>>4; 87 | },toRGBA:function(c) { 88 | c = c&15; 89 | c = c | (c<<4); 90 | return 0xFF000000|(c<<16)|(c<<8)|c; 91 | } 92 | }, 93 | "8bitbw":{ 94 | bpp:8,name:"8 bit greyscale", 95 | fromRGBA:function(r,g,b) { 96 | var c = (r+g+b)/3; 97 | if (c>255) c=255; 98 | return c; 99 | },toRGBA:function(c) { 100 | c = c&255; 101 | return 0xFF000000|(c<<16)|(c<<8)|c; 102 | } 103 | }, 104 | "3bit":{ 105 | bpp:3,name:"3 bit RGB", 106 | fromRGBA:function(r,g,b) { 107 | var thresh = 128; 108 | return ( 109 | ((r>thresh)?4:0) | 110 | ((g>thresh)?2:0) | 111 | ((b>thresh)?1:0)); 112 | },toRGBA:function(c) { 113 | return ((c&1 ? 0x0000FF : 0x000000) | 114 | (c&2 ? 0x00FF00 : 0x000000) | 115 | (c&4 ? 0xFF0000 : 0x000000) | 116 | 0xFF000000); 117 | } 118 | }, 119 | "4bit":{ 120 | bpp:4,name:"4 bit ABGR", 121 | fromRGBA:function(r,g,b,a) { 122 | var thresh = 128; 123 | return ( 124 | ((r>thresh)?1:0) | 125 | ((g>thresh)?2:0) | 126 | ((b>thresh)?4:0) | 127 | ((a>thresh)?8:0)); 128 | },toRGBA:function(c) { 129 | if (!(c&8)) return 0; 130 | return ((c&1 ? 0xFF0000 : 0x000000) | 131 | (c&2 ? 0x00FF00 : 0x000000) | 132 | (c&4 ? 0x0000FF : 0x000000) | 133 | 0xFF000000); 134 | } 135 | }, 136 | "4bitmac":{ 137 | bpp:4,name:"4 bit Mac palette", 138 | fromRGBA:function(r,g,b,a) { 139 | return PALETTE.lookup(PALETTE.MAC16,r,g,b,a, undefined /* no transparency */); 140 | },toRGBA:function(c) { 141 | return 0xFF000000|PALETTE.MAC16[c]; 142 | } 143 | }, 144 | "vga":{ 145 | bpp:8,name:"8 bit VGA palette", 146 | fromRGBA:function(r,g,b,a) { 147 | return PALETTE.lookup(PALETTE.VGA,r,g,b,a, TRANSPARENT_8BIT); 148 | },toRGBA:function(c) { 149 | if (c==TRANSPARENT_8BIT) return 0; 150 | return 0xFF000000|PALETTE.VGA[c]; 151 | } 152 | }, 153 | "web":{ 154 | bpp:8,name:"8 bit Web palette", 155 | fromRGBA:function(r,g,b,a) { 156 | return PALETTE.lookup(PALETTE.WEB,r,g,b,a, TRANSPARENT_8BIT); 157 | },toRGBA:function(c) { 158 | if (c==TRANSPARENT_8BIT) return 0; 159 | return 0xFF000000|PALETTE.WEB[c]; 160 | } 161 | }, 162 | "rgb565":{ 163 | bpp:16,name:"16 bit RGB565", 164 | fromRGBA:function(r,g,b,a) { 165 | return ( 166 | ((r&0xF8)<<8) | 167 | ((g&0xFC)<<3) | 168 | ((b&0xF8)>>3)); 169 | },toRGBA:function(c) { 170 | var r = (c>>8)&0xF8; 171 | var g = (c>>3)&0xFC; 172 | var b = (c<<3)&0xF8; 173 | return 0xFF000000|(r<<16)|(g<<8)|b; 174 | } 175 | }, 176 | "opt1bit":{ 177 | bpp:1, optimalPalette:true,name:"Optimal 1 bit", 178 | fromRGBA:function(r,g,b,a,palette) { 179 | return PALETTE.lookup(palette.rgb888,r,g,b,a, "palette"); 180 | },toRGBA:function(c,palette) { 181 | return palette.rgb888[c]; 182 | } 183 | }, 184 | "opt2bit":{ 185 | bpp:2, optimalPalette:true,name:"Optimal 2 bit", 186 | fromRGBA:function(r,g,b,a,palette) { 187 | return PALETTE.lookup(palette.rgb888,r,g,b,a, "palette"); 188 | },toRGBA:function(c,palette) { 189 | return palette.rgb888[c]; 190 | } 191 | }, 192 | "opt3bit":{ 193 | bpp:3, optimalPalette:true,name:"Optimal 3 bit", 194 | fromRGBA:function(r,g,b,a,palette) { 195 | return PALETTE.lookup(palette.rgb888,r,g,b,a, "palette"); 196 | },toRGBA:function(c,palette) { 197 | return palette.rgb888[c]; 198 | } 199 | }, 200 | "opt4bit":{ 201 | bpp:4, optimalPalette:true,name:"Optimal 4 bit", 202 | fromRGBA:function(r,g,b,a,palette) { 203 | return PALETTE.lookup(palette.rgb888,r,g,b,a, "palette"); 204 | },toRGBA:function(c,palette) { 205 | return palette.rgb888[c]; 206 | } 207 | } 208 | }; 209 | // What Espruino uses by default 210 | const BPP_TO_COLOR_FORMAT = { 211 | 1 : "1bit", 212 | 2 : "2bitbw", 213 | 3 : "3bit", 214 | 4 : "4bitmac", 215 | 8 : "web", 216 | 16 : "rgb565" 217 | }; 218 | 219 | const DIFFUSION_TYPES = { 220 | "none" : "Nearest color (flat)", 221 | "random1":"Random small", 222 | "random2":"Random large", 223 | "error":"Error Diffusion", 224 | "errorrandom":"Randomised Error Diffusion", 225 | "bayer2":"2x2 Bayer", 226 | "bayer4":"4x4 Bayer", 227 | "comic":"Comic book", 228 | "floyd":"Floyd-Steinberg" 229 | }; 230 | 231 | const DITHER = { 232 | BAYER2 : [ 233 | [ 0, 2 ], 234 | [ 3, 1 ] 235 | ], 236 | BAYER4 : [ 237 | [ 0, 8, 2,10], 238 | [12, 4,14, 6], 239 | [ 3,11, 1, 9], 240 | [15, 7,13, 5] 241 | ], 242 | COMICR : [ 243 | [-18,-16,-13,-15,-14,-9,-9,-15], 244 | [-12,-4,6,-4,-12,-4,6,-4], 245 | [-6,6,21,6,-6,6,21,6], 246 | [-11,0,13,0,-11,-4,6,-4], 247 | [-14,0,13,1,-10,-9,-9,-15], 248 | [-13,6,21,10,3,6,-4,-16], 249 | [-16,-4,6,3,10,21,6,-13], 250 | [-19,-16,-13,-12,-3,6,-4,-16] 251 | ], 252 | COMICG : [ 253 | [6,-13,-16,-4,6,3,10,21], 254 | [-4,-16,-19,-16,-13,-12,-3,6], 255 | [-9,-15,-18,-16,-13,-15,-14,-9], 256 | [6,-4,-12,-4,6,-4,-12,-4], 257 | [21,6,-6,6,21,6,-6,6], 258 | [6,-4,-11,0,13,0,-11,-4], 259 | [-9,-15,-14,0,13,1,-10,-9], 260 | [-4,-16,-13,6,21,10,3,6] 261 | ], 262 | COMICB : [ 263 | [-13,-20,-20,-16,3,32,37,10], 264 | [-16,-20,-20,-19,-12,3,10,-3], 265 | [-18,-16,-13,-16,-18,-16,-13,-16], 266 | [-12,3,10,-3,-16,-20,-20,-19], 267 | [3,32,37,10,-13,-20,-20,-16], 268 | [10,37,32,4,-12,-13,-16,-12], 269 | [-2,10,3,-8,-2,10,3,-8], 270 | [-12,-13,-16,-12,10,37,32,4] 271 | ], 272 | }; 273 | 274 | /* 275 | // to make the COMIC dither pattern 276 | // the idea is this is a pattern of blobs a bit like 277 | // you might get in a newspaper - hexagonal-ish, and different patterns for R,G and B 278 | let G = [ // gaussian 279 | [ 1, 4, 7, 4, 1], 280 | [ 4,16,26,16, 4], 281 | [ 7,26,41,26, 7], 282 | [ 4,16,26,16, 4], 283 | [ 1, 4, 7, 4, 1], 284 | ]; 285 | 286 | let NR = [], NG = [], NB = []; 287 | for (var i=0;i<8;i++) { 288 | NR[i] = [0,0,0,0,0,0,0,0]; 289 | NG[i] = [0,0,0,0,0,0,0,0]; 290 | NB[i] = [0,0,0,0,0,0,0,0]; 291 | } 292 | function blob(x,y,ox,oy) { 293 | NR[(y+oy)&7][(x+ox)&7] += G[y][x]; 294 | NG[(y+oy+2)&7][(x+ox+2)&7] += G[y][x]; 295 | NB[(y+oy+10-ox)&7][(x+ox+oy)&7] += G[y][x]; 296 | } 297 | for (var y=0;yR.map(n=>n-offset)); 306 | NG = NG.map(R=>R.map(n=>n-offset)); 307 | NB = NB.map(R=>R.map(n=>n-offset)); 308 | console.log(" COMICR : [\n "+JSON.stringify(NR).replaceAll("],[","],\n [").substr(1)+",\n"+ 309 | " COMICG : [\n "+JSON.stringify(NG).replaceAll("],[","],\n [").substr(1)+",\n"+ 310 | " COMICB : [\n "+JSON.stringify(NB).replaceAll("],[","],\n [").substr(1)+",\n"); 311 | */ 312 | 313 | 314 | function clip(x) { 315 | if (x<0) return 0; 316 | if (x>255) return 255; 317 | return x; 318 | } 319 | 320 | // compare two RGB888 colors and give a squared distance value 321 | function compareRGBA8888(ca,cb) { 322 | var ar=(ca>>16)&255; 323 | var ag=(ca>>8)&255; 324 | var ab=ca&255; 325 | var aa=(ca>>24)&255; 326 | var br=(cb>>16)&255; 327 | var bg=(cb>>8)&255; 328 | var bb=cb&255; 329 | var ba=(cb>>24)&255; 330 | 331 | var dr = ar-br; 332 | var dg = ag-bg; 333 | var db = ab-bb; 334 | var da = aa-ba; 335 | return dr*dr + dg*dg + db*db + da*da; 336 | } 337 | 338 | // blend ca to cb, 0<=amt<=1 339 | function blendRGBA8888(ca,cb, amt) { 340 | var aa=(ca>>24)&255; 341 | var ar=(ca>>16)&255; 342 | var ag=(ca>>8)&255; 343 | var ab=ca&255; 344 | var ba=(cb>>24)&255; 345 | var br=(cb>>16)&255; 346 | var bg=(cb>>8)&255; 347 | var bb=cb&255; 348 | 349 | var namt = 1-amt; 350 | var da = Math.round(aa*namt + ba*amt); 351 | var dr = Math.round(ar*namt + br*amt); 352 | var dg = Math.round(ag*namt + bg*amt); 353 | var db = Math.round(ab*namt + bb*amt); 354 | return (da<<24)|(dr<<16)|(dg<<8)|db; 355 | } 356 | 357 | /* 358 | rgba = Uint8Array(width*height*4) 359 | See 'getOptions' for possible options 360 | */ 361 | function RGBAtoString(rgba, options) { 362 | options = options||{}; 363 | if (!rgba) throw new Error("No dataIn specified"); 364 | if (!options.width) throw new Error("No Width specified"); 365 | if (!options.height) throw new Error("No Height specified"); 366 | 367 | if (options.scale && options.scale!=1) 368 | rgba = rescale(rgba, options); 369 | if (options.autoCrop || options.autoCropCenter) 370 | rgba = autoCrop(rgba, options); 371 | 372 | 373 | if ("string"!=typeof options.diffusion) 374 | options.diffusion = "none"; 375 | options.compression = options.compression || false; 376 | options.brightness = options.brightness | 0; 377 | options.contrast = options.contrast | 0; 378 | options.mode = options.mode || "1bit"; 379 | options.output = options.output || "object"; 380 | options.inverted = options.inverted || false; 381 | options.transparent = !!options.transparent; 382 | var contrast = (259 * (options.contrast + 255)) / (255 * (259 - options.contrast)); 383 | 384 | var transparentCol = undefined; 385 | if (options.transparent) { 386 | if (options.mode=="4bit") 387 | transparentCol=0; 388 | if (options.mode=="vga" || options.mode=="web") 389 | transparentCol=TRANSPARENT_8BIT; 390 | } 391 | var fmt = FORMATS[options.mode]; 392 | if (fmt===undefined) throw new Error("Unknown image mode"); 393 | var bpp = fmt.bpp; 394 | var bitData = new Uint8Array(((options.width*options.height)*bpp+7)/8); 395 | var palette; 396 | if (fmt.optimalPalette) { 397 | let pixels = readImage(FORMATS["rgb565"]); 398 | palette = generatePalette(pixels, options); 399 | if (palette.transparentCol !== undefined) 400 | transparentCol = palette.transparentCol; 401 | } 402 | 403 | function readImage(fmt) { 404 | var pixels = new Int32Array(options.width*options.height); 405 | var n = 0; 406 | var er=0,eg=0,eb=0; 407 | // Floyd-Steinberg error diffusion buffers (current row / next row) 408 | var fs = (options.diffusion=="floyd"); 409 | var fsErrRRow, fsErrGRow, fsErrBRow, fsErrRNext, fsErrGNext, fsErrBNext; 410 | if (fs) { 411 | fsErrRRow = new Float32Array(options.width+2); 412 | fsErrGRow = new Float32Array(options.width+2); 413 | fsErrBRow = new Float32Array(options.width+2); 414 | fsErrRNext = new Float32Array(options.width+2); 415 | fsErrGNext = new Float32Array(options.width+2); 416 | fsErrBNext = new Float32Array(options.width+2); 417 | } 418 | for (var y=0; y>>24; - no error diffusion on alpha channel 489 | var or = (cr>>16)&255; 490 | var og = (cr>>8)&255; 491 | var ob = cr&255; 492 | if (fs && a>128) { 493 | // Floyd-Steinberg distribution 494 | var eR = r-or; 495 | var eG = g-og; 496 | var eB = b-ob; 497 | // indexes with +1 offset 498 | var ix = x+1; 499 | // current row, pixel to the right 500 | fsErrRRow[ix+1] += eR * (7/16); 501 | fsErrGRow[ix+1] += eG * (7/16); 502 | fsErrBRow[ix+1] += eB * (7/16); 503 | // next row (below left, below, below right) 504 | fsErrRNext[ix-1] += eR * (3/16); 505 | fsErrGNext[ix-1] += eG * (3/16); 506 | fsErrBNext[ix-1] += eB * (3/16); 507 | fsErrRNext[ix] += eR * (5/16); 508 | fsErrGNext[ix] += eG * (5/16); 509 | fsErrBNext[ix] += eB * (5/16); 510 | fsErrRNext[ix+1] += eR * (1/16); 511 | fsErrGNext[ix+1] += eG * (1/16); 512 | fsErrBNext[ix+1] += eB * (1/16); 513 | // no per-pixel carryover (er/eg/eb) used in FS mode 514 | er = eg = eb = 0; 515 | } else if (!fs && options.diffusion.startsWith("error") && a>128) { 516 | er = r-or; 517 | eg = g-og; 518 | eb = b-ob; 519 | } else { 520 | er = 0; 521 | eg = 0; 522 | eb = 0; 523 | } 524 | 525 | n++; 526 | } 527 | } 528 | return pixels; 529 | } 530 | function writeImage(pixels) { 531 | var n = 0; 532 | for (var y=0; y>3] |= c ? 128>>(n&7) : 0; 537 | else if (bpp==2) bitData[n>>2] |= c<<((3-(n&3))*2); 538 | else if (bpp==3) { 539 | c = c&7; 540 | var bitaddr = n*3; 541 | var a = bitaddr>>3; 542 | var shift = bitaddr&7; 543 | bitData[a] |= (c<<(8-shift)) >> 3; 544 | bitData[a+1] |= (c<<(16-shift)) >> 3; 545 | } else if (bpp==4) bitData[n>>1] |= c<<((n&1)?0:4); 546 | else if (bpp==8) bitData[n] = c; 547 | else if (bpp==16) { 548 | bitData[n<<1] = c>>8; 549 | bitData[1+(n<<1)] = c&0xFF; 550 | } else throw new Error("Unhandled BPP"); 551 | // Write preview 552 | var cr = fmt.toRGBA(c, palette); 553 | if (c===transparentCol) 554 | cr = ((((x>>2)^(y>>2))&1)?0xCCCCCC:0x555555); // pixel pattern 555 | //var oa = cr>>>24; - ignore alpha 556 | var or = (cr>>16)&255; 557 | var og = (cr>>8)&255; 558 | var ob = cr&255; 559 | if (options.rgbaOut) { 560 | options.rgbaOut[n*4] = or; 561 | options.rgbaOut[n*4+1]= og; 562 | options.rgbaOut[n*4+2]= ob; 563 | options.rgbaOut[n*4+3]=255; 564 | } 565 | n++; 566 | } 567 | } 568 | } 569 | 570 | let pixels = readImage(fmt); 571 | if (options.transparent && transparentCol===undefined && bpp<=16) { 572 | // we have no fixed transparent colour - pick one that's unused 573 | var colors = new Uint32Array(1<=0) 577 | colors[pixels[i]]++; 578 | // find an empty one 579 | for (let i=0;i>8); 610 | } 611 | } 612 | var imgData = new Uint8Array(header.length + bitData.length); 613 | imgData.set(header, 0); 614 | imgData.set(bitData, header.length); 615 | bitData = imgData; 616 | } 617 | if (options.compression) { 618 | bitData = heatshrink.compress(bitData); 619 | strPrefix = 'require("heatshrink").decompress('; 620 | strPostfix = ')'; 621 | } else { 622 | strPrefix = ''; 623 | strPostfix = ''; 624 | } 625 | var str = ""; 626 | for (let n=0; n>2)^(y>>2))&1)?0xCC:0x55); 657 | rgba[n*4] = rgba[n*4]*na + chequerboard*a; 658 | rgba[n*4+1] = rgba[n*4+1]*na + chequerboard*a; 659 | rgba[n*4+2] = rgba[n*4+2]*na + chequerboard*a; 660 | rgba[n*4+3] = 255; 661 | n++; 662 | } 663 | } 664 | } 665 | 666 | /* Given an image, try and work out a palette. 667 | Runs off a 32 bit array of pixels (actually just 1 bits) */ 668 | function generatePalette(pixels, options) { 669 | var fmt = FORMATS[options.mode]; 670 | var bpp = fmt.bpp; 671 | var bppRange = 1< maxUses=Math.max(maxUses, colorUses[col])); 683 | // work out scores 684 | var scores = {}; 685 | pixelCols.forEach(col => { 686 | // for each color... 687 | var uses = colorUses[col]; 688 | // work out how close it is to other 689 | // colors that have more pixels used 690 | var nearestDiff = 0xFFFFFF; 691 | pixelCols.forEach(c => { 692 | if (c==col || colorUses[c]<=uses) return; 693 | var diff = compareRGBA8888(col,c); 694 | if (diffscores[b]-scores[a]); 704 | pixelCols = pixelCols.slice(0,31); // for sanity 705 | //console.log("All Colors",pixelCols.map(c=>({col:0|c, cnt:colorUses[c], score:scores[c], rgb:(FORMATS["rgb565"].toRGBA(c)&0xFFFFFF).toString(16).padStart(6,"0")}))); 706 | // crop to how many palette items we're allowed 707 | pixelCols = pixelCols.slice(0,bppRange); 708 | 709 | //if the image has fewer colors than our palette we need to fill in the remaining entries 710 | while (pixelCols.length < bppRange) { 711 | pixelCols.push(0); 712 | } 713 | // debugging... 714 | //console.log("Palette",pixelCols.map(c=>({col:0|c, cnt:colorUses[c], score:scores[c], rgb:(FORMATS["rgb565"].toRGBA(c)&0xFFFFFF).toString(16).padStart(6,"0")}))); 715 | // Return palettes 716 | return { 717 | "rgb565" : new Uint16Array(pixelCols), 718 | "rgb888" : new Uint32Array(pixelCols.map(c=>c>=0 ? FORMATS["rgb565"].toRGBA(c) : 0)), 719 | transparentCol : pixelCols.findIndex(c=>c==-1) 720 | }; 721 | } 722 | 723 | /* Given an image attempt to automatically crop (use top left 724 | pixel color) */ 725 | function autoCrop(rgba, options) { 726 | var buf = new Uint32Array(rgba.buffer); 727 | var stride = options.width; 728 | var cropCol = buf[0]; 729 | var x1=options.width, x2=0, y1=options.height,y2=2; 730 | for (let y=0;yx2) x2=x; 736 | if (y>y2) y2=y; 737 | } 738 | } 739 | } 740 | // no data! might as well just send it all 741 | if (x1>x2 || y1>y2) return rgba; 742 | // if center, try and take the same off each side 743 | if (options.autoCropCenter) { 744 | x1 = Math.min(x1, (options.width-1)-x2); 745 | y1 = Math.min(y1, (options.height-1)-y2); 746 | x2 = (options.width-1)-x1; 747 | y2 = (options.height-1)-y1; 748 | } 749 | // ok, crop! 750 | var w = 1+x2-x1; 751 | var h = 1+y2-y1; 752 | var dst = new Uint32Array(w*h); 753 | for (let y=0;y> 3; 868 | // If it's the wrong length, it's not a bitmap or it's corrupt! 869 | if (data.length != p+bitmapSize) 870 | return undefined; 871 | // Ok, build the picture 872 | var canvas = document.createElement('canvas'); 873 | canvas.width = width; 874 | canvas.height = height; 875 | var ctx = canvas.getContext("2d"); 876 | var imageData = ctx.getImageData(0, 0, width, height); 877 | var rgba = imageData.data; 878 | var no = 0; 879 | var nibits = 0; 880 | var nidata = 0; 881 | for (var i=0;i>(nibits-bpp)) & ((1<>16)&255; // r 892 | rgba[no++] = (cr>>8)&255; // g 893 | rgba[no++] = cr&255; // b 894 | rgba[no++] = cr>>>24; // a 895 | } 896 | if (!options.transparent) 897 | RGBAtoCheckerboard(rgba, {width:width, height:height}); 898 | ctx.putImageData(imageData,0,0); 899 | return canvas.toDataURL(); 900 | } 901 | 902 | // decode an Espruino image string into an HTML string, return undefined if it's not valid. See stringToImageURL 903 | function stringToImageHTML(data, options) { 904 | var url = stringToImageURL(data, options); 905 | if (!url) return undefined; 906 | return ''; 907 | } 908 | 909 | function setFormatOptions(div) { 910 | div.innerHTML = Object.keys(FORMATS).map(id => ``).join("\n"); 911 | } 912 | 913 | function setDiffusionOptions(div) { 914 | div.innerHTML = Object.keys(DIFFUSION_TYPES).map(id => ``).join("\n"); 915 | } 916 | 917 | function setOutputOptions(div) { 918 | var outputStyles = getOptions().output; 919 | div.innerHTML = Object.keys(outputStyles).filter(id=>outputStyles[id].userFacing).map(id => ``).join("\n"); 920 | } 921 | 922 | // ======================================================= 923 | return { 924 | RGBAtoString : RGBAtoString, 925 | RGBAtoCheckerboard : RGBAtoCheckerboard, 926 | canvastoString : canvastoString, 927 | imagetoString : imagetoString, 928 | getOptions : getOptions, 929 | getFormats : function() { return FORMATS; }, 930 | setFormatOptions : setFormatOptions, 931 | setDiffusionOptions : setDiffusionOptions, 932 | setOutputOptions : setOutputOptions, 933 | 934 | stringToImageHTML : stringToImageHTML, 935 | stringToImageURL : stringToImageURL, 936 | 937 | setHeatShrink : hs => heatshrink=hs, 938 | }; 939 | })); 940 | -------------------------------------------------------------------------------- /fontconverter.js: -------------------------------------------------------------------------------- 1 | /* https://github.com/espruino/EspruinoWebTools/fontconverter.js 2 | 3 | Copyright (C) 2024 Gordon Williams 4 | 5 | This Source Code Form is subject to the terms of the Mozilla Public 6 | License, v. 2.0. If a copy of the MPL was not distributed with this 7 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | ---------------------------------------------------------------------------------------- 10 | Bitmap font creator for Espruino Graphics custom fonts 11 | 12 | Takes input as a PNG font map, PBFF, or bitfontmaker2 JSON 13 | 14 | Outputs in various formats to make a custom font 15 | ---------------------------------------------------------------------------------------- 16 | 17 | Requires: 18 | 19 | npm install btoa pngjs 20 | */ 21 | (function (root, factory) { 22 | /* global define */ 23 | if (typeof define === 'function' && define.amd) { 24 | // AMD. Register as an anonymous module. 25 | define(['heatshrink'], factory); 26 | } else if (typeof module === 'object' && module.exports) { 27 | // Node. Does not work with strict CommonJS, but 28 | // only CommonJS-like environments that support module.exports, 29 | // like Node. 30 | module.exports = factory(require('./heatshrink.js')); 31 | } else { 32 | // Browser globals (root is window) 33 | root.fontconverter = factory(root.heatshrink); 34 | } 35 | }(typeof self !== 'undefined' ? self : this, function(heatshrink) { 36 | 37 | // Node.js doesn't have btoa 38 | let btoaSafe = ("undefined" !== typeof btoa) ? btoa : function (input) { 39 | var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 40 | var out = ""; 41 | var i=0; 42 | while (i> 18) & 63] + 58 | b64[(triple >> 12) & 63] + 59 | ((padding>1)?'=':b64[(triple >> 6) & 63]) + 60 | ((padding>0)?'=':b64[triple & 63]); 61 | } 62 | return out; 63 | }; 64 | 65 | function bitsToBytes(bits, bpp) { 66 | let bytes = []; 67 | if (bpp==1) { 68 | for (let i=0;i { 148 | for (let ch=range.min; ch<=range.max; ch++) { 149 | let glyph = this.getGlyph(ch, (x,y) => getCharPixel(ch,x,y)); 150 | if (glyph) 151 | this.glyphs[ch] = glyph; 152 | } 153 | }); 154 | }; 155 | 156 | // Is the given char code (int) in a range? 157 | Font.prototype.isChInRange = function(ch) { 158 | return this.range.some(range => ch>=range.min && ch<=range.max); 159 | }; 160 | 161 | // Append the bits to define this glyph to the array 'bits' 162 | FontGlyph.prototype.appendBits = function(bits, info) { 163 | // glyphVertical : are glyphs scanned out vertically or horizontally? 164 | if (info.glyphVertical) { 165 | for (let x=this.xStart;x<=this.xEnd;x++) { 166 | for (let y=this.yStart;y<=this.yEnd;y++) { 167 | bits.push(this.getPixel(x,y)); 168 | } 169 | } 170 | } else { 171 | for (let y=this.yStart;y<=this.yEnd;y++) { 172 | for (let x=this.xStart;x<=this.xEnd;x++) { 173 | bits.push(this.getPixel(x,y)); 174 | } 175 | } 176 | } 177 | } 178 | 179 | FontGlyph.prototype.getImageData = function() { 180 | let bpp = this.font.bpp; 181 | let img = new ImageData(this.xEnd+1, this.yEnd+1); 182 | for (let y=0;y<=this.yEnd;y++) 183 | for (let x=0;x<=this.xEnd;x++) { 184 | let n = (x + y*img.width)*4; 185 | let c = this.getPixel(x,y); 186 | let prevCol = 255 - ((bpp==1) ? c*255 : (c << (8-bpp))); 187 | img.data[n] = img.data[n+1] = img.data[n+2] = prevCol; 188 | if (x>=this.xStart && y>=this.yStart) { 189 | img.data[n] = 128; 190 | img.data[n+3] = 255; 191 | } 192 | } 193 | return img; 194 | }; 195 | 196 | FontGlyph.prototype.debug = function() { 197 | var map = "░█"; 198 | if (this.font.bpp==2) map = "░▒▓█"; 199 | var debugText = []; 200 | for (var y=0;y=this.xStart && x<=this.xEnd && y>=this.yStart && y<=this.yEnd) 205 | px = map[this.getPixel(x,y)]; 206 | debugText[y] += px; 207 | } 208 | } 209 | console.log("charcode ", this.ch); 210 | console.log(debugText.join("\n")); 211 | }; 212 | 213 | /// Shift glyph up by the given amount (or down if negative) 214 | FontGlyph.prototype.shiftUp = function(yOffset) { 215 | this.yStart -= yOffset; 216 | this.yEnd -= yOffset; 217 | var gp = this.getPixel.bind(this); 218 | this.getPixel = (x,y) => gp(x,y+yOffset); 219 | }; 220 | 221 | 222 | /// Adjust glyph offsets so it fits within fontHeight 223 | FontGlyph.prototype.nudge = function() { 224 | var y = 0; 225 | if (this.yStart < 0) y = this.yStart; 226 | if (this.yEnd-y >= this.font.height) { 227 | if (y) { 228 | console.log(`Can't nudge Glyph ${this.ch} as it's too big to fit in font map`); 229 | return; 230 | } 231 | y = this.yEnd+1-this.font.height; 232 | } 233 | if (y) { 234 | console.log(`Nudging Glyph ${this.ch} ${(y>0)?"up":"down"} by ${Math.abs(y)}`); 235 | this.shiftUp(y); 236 | } 237 | }; 238 | 239 | Font.prototype.getGlyph = function(ch, getPixel) { 240 | // work out widths 241 | var glyph = new FontGlyph(this, ch, getPixel); 242 | var xStart, xEnd; 243 | var yStart = this.fmHeight, yEnd = 0; 244 | 245 | if (this.fixedWidth) { 246 | xStart = 0; 247 | xEnd = this.fmWidth-1; 248 | } else { 249 | xStart = this.fmWidth; 250 | xEnd = 0; 251 | for (var y=0;yxEnd) xEnd = x; 258 | // check Y max/min 259 | if (yyEnd) yEnd = y; 261 | } 262 | } 263 | } 264 | if (xStart>xEnd) { 265 | if (ch != 32) return undefined; // if it's empty and not a space, ignore it! 266 | xStart=0; 267 | xEnd = this.fmWidth >> 1; // treat spaces as half-width 268 | } 269 | } 270 | glyph.width = xEnd+1-xStart; 271 | glyph.xStart = xStart; 272 | glyph.xEnd = xEnd; 273 | glyph.advance = glyph.xEnd+1; 274 | glyph.advance += this.glyphPadX; // if not full width, add a space after 275 | //if (!this.glyphPadX) glyph.advance++; // hack - add once space of padding 276 | 277 | if (this.fullHeight) { 278 | yStart = 0; 279 | yEnd = this.fmHeight-1; 280 | } 281 | if (yStart>=yEnd) { 282 | yStart = 1; 283 | yEnd = 0; 284 | } 285 | glyph.yStart = yStart; 286 | glyph.yEnd = yEnd; 287 | glyph.height = yEnd+1-yStart; 288 | 289 | /* if (ch == 41) { 290 | glyph.debug(); 291 | console.log(glyph); 292 | process.exit(1); 293 | }*/ 294 | 295 | return glyph; 296 | }; 297 | 298 | /// Shift all glyphs up by the given amount (or down if negative) 299 | Font.prototype.shiftUp = function(y) { 300 | this.glyphs.forEach(glyph => glyph.shiftUp(y)); 301 | }; 302 | 303 | 304 | /// Adjust glyph offsets so it fits within fontHeight 305 | Font.prototype.nudge = function() { 306 | this.glyphs.forEach(glyph => glyph.nudge()); 307 | } 308 | 309 | /// Double the size of this font using a bitmap expandion algorithm 310 | Font.prototype.doubleSize = function(smooth) { 311 | this.glyphs.forEach(glyph => { 312 | glyph.xStart *= 2; 313 | glyph.yStart *= 2; 314 | glyph.xEnd = glyph.xEnd*2 + 1; 315 | glyph.yEnd = glyph.yEnd*2 + 1; 316 | glyph.advance *= 2; 317 | var gp = glyph.getPixel.bind(glyph); 318 | if (smooth) { 319 | glyph.getPixel = (x,y) => { 320 | var hx = x>>1; 321 | var hy = y>>1; 322 | /* A 323 | * C P B 324 | * D 325 | */ 326 | let A = gp(hx,hy-1); 327 | let C = gp(hx-1,hy); 328 | let P = gp(hx,hy); 329 | let B = gp(hx+1,hy); 330 | let D = gp(hx,hy+1); 331 | //AdvMAME2× 332 | let p1=P, p2=P, p3=P, p4=P; 333 | if ((C==A) && (C!=D) && (A!=B)) p1=A; 334 | if ((A==B) && (A!=C) && (B!=D)) p2=B; 335 | if ((D==C) && (D!=B) && (C!=A)) p3=C; 336 | if ((B==D) && (B!=A) && (D!=C)) p4=D; 337 | let pixels = [[p1, p3], [p2, p4]]; 338 | return pixels[x&1][y&1]; 339 | }; 340 | } else { 341 | glyph.getPixel = (x,y) => { 342 | return gp(x>>1,y>>1); 343 | }; 344 | } 345 | }); 346 | this.height *= 2; 347 | this.fmHeight *= 2; 348 | this.width *= 2; 349 | }; 350 | 351 | // Load a 16x16 charmap file (or mapWidth x mapHeight) 352 | function loadPNG(fontInfo) { 353 | var PNG = require("pngjs").PNG; 354 | var png = PNG.sync.read(require("fs").readFileSync(fontInfo.fn)); 355 | 356 | console.log(`Font map is ${png.width}x${png.height}`); 357 | fontInfo.fmWidth = Math.floor((png.width - fontInfo.mapOffsetX) / fontInfo.mapWidth); 358 | fontInfo.fmHeight = Math.floor((png.height - fontInfo.mapOffsetY) / fontInfo.mapHeight); 359 | console.log(`Font map char is ${fontInfo.fmWidth}x${fontInfo.fmHeight}`); 360 | 361 | function getPngPixel(x,y) { 362 | var o = (x + (y*png.width))*4; 363 | var c = png.data.readInt32LE(o); 364 | var a = (c>>24)&255; 365 | var b = (c>>16)&255; 366 | var g = (c>>8)&255; 367 | var r = c&255; 368 | if (a<128) return 0; // no alpha 369 | var avr = (r+g+b)/3; 370 | if (fontInfo.bpp==1) return 1-(avr>>7); 371 | if (fontInfo.bpp==2) return 3-(avr>>6); 372 | throw new Error("Unknown bpp"); 373 | //console.log(x,y,c.toString(16), a,r,g,b,"=>",(a>128) && ((r+g+b)<384)); 374 | } 375 | 376 | fontInfo.generateGlyphs(function(ch,x,y) { 377 | var chy = Math.floor(ch/fontInfo.mapWidth); 378 | var chx = ch - chy*fontInfo.mapWidth; 379 | var py = chy*fontInfo.fmHeight + y; 380 | if (py>=png.height) return false; 381 | return getPngPixel(fontInfo.mapOffsetX + chx*fontInfo.fmWidth + x, fontInfo.mapOffsetY + py); 382 | }); 383 | return fontInfo; 384 | } 385 | 386 | function loadJSON(fontInfo) { 387 | // format used by https://www.pentacom.jp/pentacom/bitfontmaker2/editfont.php import/export 388 | var font = JSON.parse(require("fs").readFileSync(fontInfo.fn).toString()); 389 | fontInfo.fmWidth = 16; 390 | fontInfo.fmHeight = 16; 391 | 392 | fontInfo.generateGlyphs(function(ch,x,y) { 393 | if (!font[ch]) return 0; 394 | return (((font[ch][y] >> x) & 1)!=0) ? 1 : 0; 395 | }); 396 | return fontInfo; 397 | } 398 | 399 | function loadPBFF(fontInfo) { 400 | // format used by https://github.com/pebble-dev/renaissance/tree/master/files 401 | fontInfo.fmWidth = 0; 402 | fontInfo.fmHeight = fontInfo.height; 403 | fontInfo.glyphPadX = 0; 404 | fontInfo.fullHeight = false; 405 | var current = { 406 | idx : 0, 407 | bmp : [] 408 | }; 409 | var font = []; 410 | require("fs").readFileSync(fontInfo.fn).toString().split("\n").forEach(l => { 411 | if (l.startsWith("version")) { 412 | // ignore 413 | } else if (l.startsWith("fallback")) { 414 | // ignore 415 | } else if (l.startsWith("line-height")) { 416 | if (!fontInfo.fmHeight) // if no height specified 417 | fontInfo.fmHeight = 0|l.split(" ")[1]; 418 | if (!fontInfo.height) // if no height specified 419 | fontInfo.height = 0|l.split(" ")[1]; 420 | } else if (l.startsWith("glyph")) { 421 | current = {}; 422 | current.idx = parseInt(l.trim().split(" ")[1]); 423 | current.bmp = []; 424 | font[current.idx] = current; 425 | } else if (l.trim().startsWith("-")) { 426 | // font line start/end 427 | if (l=="-") { 428 | //console.log(current); // end of glyph 429 | } else { 430 | var verticalOffset = parseInt(l.trim().split(" ")[1]); 431 | if (verticalOffset>0) while (verticalOffset--) current.bmp.push(""); 432 | } 433 | } else if (l.startsWith(" ") || l.startsWith("#") || l=="") { 434 | current.bmp.push(l); 435 | if (l.length > fontInfo.fmWidth) fontInfo.fmWidth = l.length; 436 | if (current.bmp.length > fontInfo.fmHeight) { 437 | console.log("Char "+current.idx+" bump height to "+current.bmp.length); 438 | fontInfo.fmHeight = current.bmp.length; 439 | } 440 | } else if (l!="" && !l.startsWith("//")) console.log(`Unknown line '${l}'`); 441 | }); 442 | 443 | fontInfo.generateGlyphs(function(ch,x,y) { 444 | if (!font[ch]) return 0; 445 | return (font[ch].bmp[y] && font[ch].bmp[y][x]=='#') ? 1 : 0; 446 | }); 447 | return fontInfo; 448 | } 449 | 450 | function loadBDF(fontInfo) { 451 | var fontCharSet = ""; 452 | var fontCharCode = 0; 453 | var fontBitmap = undefined; 454 | var fontBoundingBox = [0,0,0,0]; 455 | var charBoundingBox = [0,0,0,0]; 456 | var charAdvance = 0; 457 | var COMMENTS = "", FONTNAME = ""; 458 | var glyphs = []; 459 | // https://en.wikipedia.org/wiki/Glyph_Bitmap_Distribution_Format 460 | 461 | require("fs").readFileSync(fontInfo.fn).toString().split("\n").forEach((line,lineNo) => { 462 | // Font stuff 463 | if (line.startsWith("CHARSET_REGISTRY")) 464 | fontCharSet = JSON.parse(line.split(" ")[1].trim()); 465 | if (line.startsWith("COPYRIGHT")) 466 | COMMENTS += "// Copyright "+line.substr(9).trim()+"\n"; 467 | if (line.startsWith("COMMENT")) 468 | COMMENTS += "// "+line.substr(7).trim()+"\n"; 469 | if (line.startsWith("FONT")) 470 | FONTNAME += "// "+line.substr(4).trim(); 471 | if (line.startsWith("FONTBOUNDINGBOX")) { 472 | fontBoundingBox = line.split(" ").slice(1).map(x=>parseInt(x)); 473 | fontInfo.fmWidth = fontBoundingBox[0]; 474 | fontInfo.height = fontInfo.fmHeight = fontBoundingBox[1] - fontBoundingBox[3]; 475 | } 476 | // Character stuff 477 | if (line.startsWith("STARTCHAR")) { 478 | fontCharCode = undefined; 479 | charBoundingBox = [0,0,0,0]; 480 | charAdvance = 0; 481 | fontBitmap=undefined; 482 | } 483 | if (line.startsWith("ENCODING")) { 484 | fontCharCode = parseInt(line.substr("ENCODING".length).trim()); 485 | } 486 | if (line.startsWith("BBX ")) { // per character bounding box 487 | charBoundingBox = line.split(" ").slice(1).map(x=>parseInt(x)); 488 | } 489 | if (line.startsWith("DWIDTH ")) { // per character bounding box 490 | charAdvance = parseInt(line.split(" ")[1]); 491 | } 492 | if (line=="ENDCHAR" && fontBitmap) { 493 | if (fontBitmap && fontInfo.isChInRange(fontCharCode)) { 494 | // first we need to pad this out 495 | var blankLine = " ".repeat(fontInfo.fmWidth); 496 | var linesBefore = fontBoundingBox[1]-(charBoundingBox[3]+charBoundingBox[1]); 497 | for (var i=0;i { 504 | if (y<0 || y>=bmp.length) return 0; 505 | return bmp[y][x]=="1" ? 1 : 0; 506 | } ); 507 | if (glyph) { 508 | // glyph.advance = charAdvance; // overwrite calculated advance value with one from file 509 | glyphs.push(glyph); 510 | } 511 | } 512 | fontCharCode = -1; 513 | fontBitmap=undefined; 514 | } 515 | if (fontBitmap!==undefined) { 516 | let l = ""; 517 | for (let i=0;i a.ch - b.ch); 530 | glyphs.forEach(g => fontInfo.glyphs[g.ch] = g); 531 | return fontInfo; 532 | } 533 | 534 | 535 | function load(fontInfo) { 536 | fontInfo = new Font(fontInfo); 537 | if (fontInfo.fn && fontInfo.fn.endsWith("png")) return loadPNG(fontInfo); 538 | else if (fontInfo.fn && fontInfo.fn.endsWith("json")) return loadJSON(fontInfo); 539 | else if (fontInfo.fn && fontInfo.fn.endsWith("pbff")) return loadPBFF(fontInfo); 540 | else if (fontInfo.fn && fontInfo.fn.endsWith("bdf")) return loadBDF(fontInfo); 541 | else throw new Error("Unknown font type"); 542 | } 543 | 544 | 545 | 546 | Font.prototype.debugPixelsUsed = function() { 547 | var pixelsUsedInRow = new Array(this.height); 548 | pixelsUsedInRow.fill(0); 549 | this.glyphs.forEach(glyph => { 550 | for (var x=glyph.xStart;x<=glyph.xEnd;x++) { 551 | for (var y=0;y { 562 | glyph.debug(); 563 | console.log(); 564 | }); 565 | }; 566 | 567 | /* GNU unifont puts in placeholders for unimplemented chars - 568 | big filled blocks with the 4 digit char code. This detects these 569 | and removes them */ 570 | Font.prototype.removeUnifontPlaceholders = function() { 571 | this.glyphs.forEach(glyph => { 572 | if (glyph.xStart==1 && glyph.yStart==1 && glyph.xEnd==14 && glyph.yEnd==14) { 573 | let borderEmpty = true; 574 | let edgesFilled = true; 575 | for (let x=1;x<15;x++) { 576 | if (glyph.getPixel(x,0)) borderEmpty = false; 577 | if (!glyph.getPixel(x,1)) edgesFilled = false; 578 | if (!glyph.getPixel(x,7)) edgesFilled = false; 579 | if (!glyph.getPixel(x,8)) edgesFilled = false; 580 | if (!glyph.getPixel(x,14)) edgesFilled = false; 581 | if (glyph.getPixel(x,15)) borderEmpty = false; 582 | // console.log(x, glyph.getPixel(x,0), glyph.getPixel(x,1)) 583 | } 584 | for (let y=1;y<14;y++) { 585 | if (glyph.getPixel(0,y)) borderEmpty = false; 586 | if (!glyph.getPixel(1,y)) edgesFilled = false; 587 | if (!glyph.getPixel(2,y)) edgesFilled = false; 588 | if (!glyph.getPixel(7,y)) edgesFilled = false; 589 | if (!glyph.getPixel(8,y)) edgesFilled = false; 590 | if (!glyph.getPixel(13,y)) edgesFilled = false; 591 | if (!glyph.getPixel(14,y)) edgesFilled = false; 592 | } 593 | if (borderEmpty && edgesFilled) { 594 | // it's a placeholder! 595 | // glyph.debug(); 596 | delete this.glyphs[glyph.ch]; // remove it 597 | } 598 | } 599 | }); 600 | }; 601 | 602 | /* Outputs as JavaScript for a custom font. 603 | options = { 604 | compressed : bool 605 | } 606 | */ 607 | Font.prototype.getJS = function(options) { 608 | // options.compressed 609 | options = options||{}; 610 | this.glyphPadX = 1; 611 | var charCodes = this.glyphs.map(g=>g.ch).filter(c=>c!==undefined).sort((a,b)=>a-b); 612 | var charMin = charCodes[0]; 613 | var charMax = charCodes[charCodes.length-1]; 614 | console.log(`Outputting char range ${charMin}..${charMax}`); 615 | // stats 616 | var minY = this.height; 617 | var maxY = 0; 618 | // get an array of bits 619 | var bits = []; 620 | var charGlyphs = []; 621 | var fontWidths = new Array(charMax+1); 622 | fontWidths.fill(0); 623 | this.glyphs.forEach(glyph => { 624 | if (glyph.yEnd > maxY) maxY = glyph.yEnd; 625 | if (glyph.yStart < minY) minY = glyph.yStart; 626 | // all glyphs have go 0...advance-1 now as we have no way to offset 627 | glyph.xStart = 0; 628 | glyph.yStart = 0; 629 | glyph.xEnd = glyph.advance-1; 630 | glyph.yEnd = this.height-1; 631 | glyph.width = glyph.xEnd + 1 - glyph.xStart; 632 | glyph.height = this.height; 633 | glyph.appendBits(bits, {glyphVertical:true}); 634 | // create width array - widthBytes 635 | fontWidths[glyph.ch] = glyph.width; 636 | }); 637 | // compact array 638 | var fontData = bitsToBytes(bits, this.bpp); 639 | fontWidths = fontWidths.slice(charMin); // don't include chars before we're outputting 640 | var fixedWidth = fontWidths.every(w=>w==fontWidths[0]); 641 | 642 | var encodedFont; 643 | if (options.compressed) { 644 | const fontArray = new Uint8Array(fontData); 645 | const compressedFont = String.fromCharCode.apply(null, heatshrink.compress(fontArray)); 646 | encodedFont = 647 | "E.toString(require('heatshrink').decompress(atob('" + 648 | btoaSafe(compressedFont) + 649 | "')))"; 650 | } else { 651 | encodedFont = "atob('" + btoaSafe(String.fromCharCode.apply(null, fontData)) + "')"; 652 | } 653 | 654 | return `Graphics.prototype.setFont${this.id} = function() { 655 | // Actual height ${maxY+1-minY} (${maxY} - ${minY}) 656 | // ${this.bpp} BPP 657 | return this.setFontCustom( 658 | ${encodedFont}, 659 | ${charMin}, 660 | ${fixedWidth?fontWidths[0]:`atob("${btoaSafe(String.fromCharCode.apply(null,fontWidths))}")`}, 661 | ${this.height}|${this.bpp<<16} 662 | ); 663 | }\n`; 664 | } 665 | 666 | // Output to a C header file (only works for 6px wide) 667 | Font.prototype.getHeaderFile = function() { 668 | var name = this.fmWidth+"X"+this.height; 669 | 670 | var PACK_DEFINE, decl, packedChars, packedPixels, storageType; 671 | 672 | if (this.fmWidth>4) { 673 | PACK_DEFINE = "PACK_5_TO_32"; 674 | decl = `#define _____ 0 675 | #define ____X 1 676 | #define ___X_ 2 677 | #define ___XX 3 678 | #define __X__ 4 679 | #define __X_X 5 680 | #define __XX_ 6 681 | #define __XXX 7 682 | #define _X___ 8 683 | #define _X__X 9 684 | #define _X_X_ 10 685 | #define _X_XX 11 686 | #define _XX__ 12 687 | #define _XX_X 13 688 | #define _XXX_ 14 689 | #define _XXXX 15 690 | #define X____ 16 691 | #define X___X 17 692 | #define X__X_ 18 693 | #define X__XX 19 694 | #define X_X__ 20 695 | #define X_X_X 21 696 | #define X_XX_ 22 697 | #define X_XXX 23 698 | #define XX___ 24 699 | #define XX__X 25 700 | #define XX_X_ 26 701 | #define XX_XX 27 702 | #define XXX__ 28 703 | #define XXX_X 29 704 | #define XXXX_ 30 705 | #define XXXXX 31 706 | #define PACK_6_TO_32(A,B,C,D,E,F) ((A) | (B<<5) | (C<<10) | (D<<15) | (E<<20) | (F<<25))`; 707 | storageType = "unsigned int"; 708 | packedChars = 5; 709 | packedPixels = 6; 710 | } else { 711 | PACK_DEFINE = "PACK_5_TO_16"; 712 | decl = `#define ___ 0 713 | #define __X 1 714 | #define _X_ 2 715 | #define _XX 3 716 | #define X__ 4 717 | #define X_X 5 718 | #define XX_ 6 719 | #define XXX 7 720 | #define PACK_5_TO_16(A,B,C,D,E) ((A) | (B<<3) | (C<<6) | (D<<9) | (E<<12))`; 721 | storageType = "unsigned short"; 722 | packedChars = 5; 723 | packedPixels = 3; 724 | } 725 | 726 | 727 | var charCodes = Object.keys(this.glyphs).map(n=>0|n).sort((a,b) => a-b); 728 | var charMin = charCodes[0]; 729 | if (charMin==32) charMin++; // don't include space as it's a waste 730 | var charMax = charCodes[charCodes.length-1]; 731 | console.log(`Outputting chars ${charMin} -> ${charMax}`); 732 | 733 | 734 | function genChar(font, glyph) { 735 | var r = []; 736 | for (var y=0;y 750 | * 751 | * This Source Code Form is subject to the terms of the Mozilla Public 752 | * License, v. 2.0. If a copy of the MPL was not distributed with this 753 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 754 | * 755 | * ---------------------------------------------------------------------------- 756 | * ${name.toLowerCase()} LCD font (but with last column as spaces) 757 | * ---------------------------------------------------------------------------- 758 | */ 759 | 760 | #include "bitmap_font_${name.toLowerCase()}.h" 761 | 762 | ${decl} 763 | 764 | #define LCD_FONT_${name}_CHARS ${charMax+1-charMin} 765 | const ${storageType} LCD_FONT_${name}[] IN_FLASH_MEMORY = { // from ${charMin} up to ${charMax}\n`; 766 | var ch = charMin; 767 | while (ch <= charMax) { 768 | var chars = []; 769 | for (var i=0;i false }; 773 | chars.push(genChar(this, glyph)); 774 | ch++; 775 | } 776 | for (var cy=0;cy0) s+=" , "; 780 | s += chars[i][cy]; 781 | } 782 | s += " ),"; 783 | header += s+"\n"; 784 | } 785 | header += "\n"; 786 | } 787 | header += "};\n"; 788 | return header; 789 | } 790 | 791 | // Output as a PBF file, returns as a Uint8Array 792 | Font.prototype.getPBF = function() { 793 | // https://github.com/pebble-dev/wiki/wiki/Firmware-Font-Format 794 | // setup to ensure we're not writing entire glyphs 795 | this.glyphPadX = 0; 796 | this.fullHeight = false; // TODO: too late? 797 | // now go through all glyphs 798 | var glyphs = []; 799 | var hashtableSize = ((this.glyphs.length)>1000) ? 255 : 64; 800 | var hashes = []; 801 | for (var i=0;i { 806 | var bits = []; 807 | var glyph = this.glyphs[ch]; 808 | glyph.appendBits(bits, {glyphVertical:false}); 809 | glyph.bits = bits; 810 | glyph.bpp = this.bpp; 811 | // check if this glyph is just 1bpp - if so convert it 812 | if (glyph.bpp==2) { 813 | if (!glyph.bits.some(b => (b==1) || (b==2))) { 814 | //console.log(String.fromCharCode(glyph.ch)+" is 1bpp"); 815 | glyph.bpp=1; 816 | glyph.bits = glyph.bits.map(b => b>>1); 817 | } 818 | } 819 | glyphs.push(glyph); 820 | glyph.hash = ch%hashtableSize; 821 | glyph.dataOffset = dataOffset; 822 | if (dataOffset > 65535) 823 | allOffsetsFitIn16Bits = false; 824 | dataOffset += 5 + ((glyph.bits.length*glyph.bpp + 7)>>3); // supposedly we should be 4 byte aligned, but there seems no reason? 825 | hashes[glyph.hash].push(glyph); 826 | }); 827 | 828 | var useExtendedHashTableOffset = (6 * glyphs.length) > 65535; 829 | var use16BitOffsets = allOffsetsFitIn16Bits; 830 | var version = 3;//useExtendedHashTableOffset ? 3 : 2; 831 | if (version==2 && allOffsetsFitIn16Bits) throw new Error("16 bit offsets not supported in PBFv2"); 832 | if (version==2 && useExtendedHashTableOffset) throw new Error("24 bit hashtable offsets not supported in PBFv2"); 833 | console.log("Using PBF version "+version); 834 | console.log(" 16 Bit Offsets = "+use16BitOffsets); 835 | console.log(" 24 Bit HashTable = "+useExtendedHashTableOffset); 836 | 837 | var pbfOffsetTableEntrySize = use16BitOffsets ? 4 : 6; 838 | 839 | var pbfHeader = new DataView(new ArrayBuffer((version>=3) ? 10 : 8)); 840 | pbfHeader.setUint8(0, version); // version 841 | pbfHeader.setUint8(1, this.height); // height 842 | pbfHeader.setUint16(2, glyphs.length, true/*LE*/); // glyph count 843 | pbfHeader.setUint16(4, 0, true/*LE*/); // wildcard codepoint 844 | pbfHeader.setUint8(6, hashtableSize); // hashtable Size 845 | pbfHeader.setUint8(7, 2); // codepoint size 846 | if (version>=3) { 847 | pbfHeader.setUint8(8, pbfHeader.byteLength ); // header length / hashtable offset 848 | var features = 849 | (use16BitOffsets ? 1 : 0) | 850 | (useExtendedHashTableOffset ? 128 : 0); 851 | pbfHeader.setUint8(9, features); // features 852 | } 853 | 854 | console.log("offset table size "+(pbfOffsetTableEntrySize * glyphs.length)+", chars "+glyphs.length); 855 | 856 | var pbfHashTable = new DataView(new ArrayBuffer(4 * hashtableSize)); 857 | var n = 0, offsetSize = 0; 858 | hashes.forEach((glyphs,i) => { 859 | if (glyphs.length > 255) throw new Error("Too many hash entries!"); 860 | if (!useExtendedHashTableOffset && offsetSize > 65535) throw new Error("hashtable offset too big! "+offsetSize); 861 | // if useExtendedHashTableOffset (an Espruino hack) then we use the value as the extra 8 bits of offset 862 | pbfHashTable.setUint8(n+0, useExtendedHashTableOffset ? (offsetSize>>16) : i); // value - this is redundant by the look of it? 863 | pbfHashTable.setUint8(n+1, glyphs.length); // offset table size 864 | pbfHashTable.setUint16(n+2, offsetSize & 65535, true/*LE*/); // offset in pbfOffsetTable 865 | n +=4 ; 866 | offsetSize += pbfOffsetTableEntrySize*glyphs.length; 867 | }); 868 | 869 | var pbfOffsetTable = new DataView(new ArrayBuffer(pbfOffsetTableEntrySize * glyphs.length)); 870 | n = 0; 871 | hashes.forEach(glyphs => { 872 | glyphs.forEach(glyph => { 873 | pbfOffsetTable.setUint16(n+0, glyph.ch, true/*LE*/); // codepoint size = 2 874 | if (use16BitOffsets) 875 | pbfOffsetTable.setUint16(n+2, glyph.dataOffset, true/*LE*/); // offset in data 876 | else 877 | pbfOffsetTable.setUint32(n+2, glyph.dataOffset, true/*LE*/); // offset in data 878 | n += pbfOffsetTableEntrySize; 879 | }); 880 | }); 881 | 882 | var pbfGlyphTable = new DataView(new ArrayBuffer(dataOffset)); 883 | n = 0; 884 | glyphs.forEach(glyph => { 885 | pbfGlyphTable.setUint8(n+0, glyph.width); // width 886 | pbfGlyphTable.setUint8(n+1, glyph.height); // height 887 | pbfGlyphTable.setInt8(n+2, glyph.xStart); // left 888 | pbfGlyphTable.setInt8(n+3, glyph.yStart); // top 889 | pbfGlyphTable.setUint8(n+4, glyph.advance | (glyph.bpp==2?128:0)); // advance (actually a int8) 890 | n+=5; 891 | // now add data 892 | var bytes = bitsToBytes(glyph.bits, glyph.bpp); 893 | bytes.forEach(b => { 894 | pbfGlyphTable.setUint8(n++, parseInt(b.toString(2).padStart(8,0).split("").reverse().join(""),2)); 895 | }); 896 | }); 897 | 898 | // finally combine 899 | //if (1) { 900 | console.log(`Header :\t0\t${pbfHeader.byteLength}`); 901 | console.log(`HashTable: \t${pbfHeader.byteLength}\t${pbfHashTable.byteLength}`); 902 | console.log(`OffsetTable:\t${pbfHeader.byteLength+pbfHashTable.byteLength}\t${pbfOffsetTable.byteLength}`); 903 | console.log(`GlyphTable: \t${pbfHeader.byteLength+pbfHashTable.byteLength+pbfOffsetTable.byteLength}\t${pbfGlyphTable.byteLength}`); 904 | //} 905 | var fontFile = new Uint8Array(pbfHeader.byteLength + pbfHashTable.byteLength + pbfOffsetTable.byteLength + pbfGlyphTable.byteLength); 906 | fontFile.set(new Uint8Array(pbfHeader.buffer), 0); 907 | fontFile.set(new Uint8Array(pbfHashTable.buffer), pbfHeader.byteLength); 908 | fontFile.set(new Uint8Array(pbfOffsetTable.buffer), pbfHeader.byteLength + pbfHashTable.byteLength); 909 | fontFile.set(new Uint8Array(pbfGlyphTable.buffer), pbfHeader.byteLength + pbfHashTable.byteLength + pbfOffsetTable.byteLength); 910 | return fontFile; 911 | } 912 | 913 | /* Output PBF as a C file to include in the build 914 | 915 | options = { 916 | name : font name to use (no spaces!) 917 | path : path of output (with trailing slash) 918 | filename : filename (without .c/h) 919 | createdBy : string to use in "created by" line 920 | } 921 | */ 922 | Font.prototype.getPBFAsC = function(options) { 923 | var pbf = this.getPBF(); 924 | options = options||{}; 925 | if (!options.path) options.path=""; 926 | if (!options.createdBy) options.createdBy="EspruinoWebTools/fontconverter.js" 927 | require("fs").writeFileSync(options.path+options.filename+".h", `/* 928 | * This file is part of Espruino, a JavaScript interpreter for Microcontrollers 929 | * 930 | * Copyright (C) 2023 Gordon Williams 931 | * 932 | * This Source Code Form is subject to the terms of the Mozilla Public 933 | * License, v. 2.0. If a copy of the MPL was not distributed with this 934 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 935 | * 936 | * ---------------------------------------------------------------------------- 937 | * Generated by ${options.createdBy} 938 | * 939 | * Contains Custom Fonts 940 | * ---------------------------------------------------------------------------- 941 | */ 942 | 943 | #include "jsvar.h" 944 | 945 | JsVar *jswrap_graphics_setFont${options.name}(JsVar *parent, int scale); 946 | `); 947 | require("fs").writeFileSync(options.path+options.filename+".c", `/* 948 | * This file is part of Espruino, a JavaScript interpreter for Microcontrollers 949 | * 950 | * Copyright (C) 2023 Gordon Williams 951 | * 952 | * This Source Code Form is subject to the terms of the Mozilla Public 953 | * License, v. 2.0. If a copy of the MPL was not distributed with this 954 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 955 | * 956 | * ---------------------------------------------------------------------------- 957 | * This file is designed to be parsed during the build process 958 | * 959 | * Generated by ${options.createdBy} 960 | * 961 | * Contains Custom Fonts 962 | * ---------------------------------------------------------------------------- 963 | */ 964 | 965 | #include "${options.filename}.h" 966 | #include "jswrap_graphics.h" 967 | 968 | static const unsigned char pbfData[] = { 969 | ${pbf.map(b=>b.toString()).join(",").replace(/(............................................................................,)/g,"$1\n")} 970 | }; 971 | 972 | /*JSON{ 973 | "type" : "method", 974 | "class" : "Graphics", 975 | "name" : "setFont${options.name}", 976 | "generate" : "jswrap_graphics_setFont${options.name}", 977 | "params" : [ 978 | ["scale","int","The scale factor, default=1 (2=2x size)"] 979 | ], 980 | "return" : ["JsVar","The instance of Graphics this was called on, to allow call chaining"], 981 | "return_object" : "Graphics" 982 | } 983 | Set the current font 984 | */ 985 | JsVar *jswrap_graphics_setFont${options.name}(JsVar *parent, int scale) { 986 | JsVar *pbfVar = jsvNewNativeString(pbfData, sizeof(pbfData)); 987 | JsVar *r = jswrap_graphics_setFontPBF(parent, pbfVar, scale); 988 | jsvUnLock(pbfVar); 989 | return r; 990 | } 991 | `); 992 | }; 993 | 994 | // Output as a PBFF file as String 995 | Font.prototype.getPBFF = function() { 996 | var pbff = `version 2 997 | line-height ${this.height} 998 | `; 999 | //fallback 9647 1000 | // setup to ensure we're not writing entire glyphs 1001 | this.glyphPadX = 0; 1002 | this.fullHeight = false; // TODO: too late? 1003 | // now go through all glyphs 1004 | Object.keys(this.glyphs).forEach(ch => { 1005 | console.log(ch); 1006 | var g = this.glyphs[ch]; 1007 | 1008 | // glyph.appendBits(bits, {glyphVertical:false}); 1009 | pbff += `glyph ${ch} ${String.fromCharCode(ch)}\n`; 1010 | pbff += `${"-".repeat(g.advance+1)} ${g.yStart}\n`; 1011 | for (var y=g.yStart;y<=g.yEnd;y++) { 1012 | var l = ""; 1013 | for (var x=0;x<=g.xEnd;x++) { 1014 | var c = g.getPixel(x,y); 1015 | l += c?"#":" "; 1016 | } 1017 | pbff += l.trimEnd()+`\n`; 1018 | } 1019 | pbff += `-\n`; 1020 | }); 1021 | 1022 | return pbff; 1023 | } 1024 | 1025 | // Renders the given text to a on object { width, height, bpp:32, data : Uint32Array } 1026 | Font.prototype.renderString = function(text) { 1027 | // work out width 1028 | var width = 0; 1029 | for (let i=0;i>bpp; 1047 | c |= c>>(bpp*2); 1048 | c |= c>>(bpp*4); 1049 | let px = x+ox; 1050 | if ((px>=0) && (px < width) && (y>=0) && (y> 6; 1068 | l += "░▒▓█"[c]; 1069 | } 1070 | console.log(l); 1071 | } 1072 | console.log("-".repeat(img.width)); 1073 | } 1074 | 1075 | /* Outputs an object containing suggested sets of characters, with the following fields: 1076 | { 1077 | id : { 1078 | id : string, 1079 | range : [ {min:int,max:int}, ... ], 1080 | text : string // test string 1081 | charCount : // number of characters in range 1082 | } 1083 | } 1084 | */ 1085 | function getRanges() { 1086 | // https://arxiv.org/pdf/1801.07779.pdf#page=5 is handy to see what covers most writing 1087 | var ranges = { // https://www.unicode.org/charts/ 1088 | "ASCII" : {range : [{ min : 32, max : 127 }], text: "This is a test" }, 1089 | "ASCII Capitals" : {range : [{ min : 32, max : 93 }], text: "THIS IS A TEST" }, 1090 | "Numeric" : {range : [{ min : 46, max : 58 }], text:"0.123456789:/" }, 1091 | "ISO8859-1": {range : [{ min : 32, max : 255 }], text: "Thís îs ã tést" }, 1092 | "Extended": {range : [{ min : 32, max : 1111 }], text: "Thís îs ã tést" }, // 150 languages + Cyrillic 1093 | "All": {range : [{ min : 32, max : 0xFFFF }], text: "이것 îs ã 测试" }, 1094 | "Chinese": {range : [{ min : 32, max : 255 }, { min : 0x4E00, max : 0x9FAF }], text: "这是一个测试" }, 1095 | "Korean": {range : [{ min : 32, max : 255 }, { min : 0x1100, max : 0x11FF }, { min : 0x3130, max : 0x318F }, { min : 0xA960, max : 0xA97F }, { min : 0xAC00, max : 0xD7FF }], text: "이것은 테스트입니다" }, 1096 | "Japanese": {range : [{ min : 32, max : 255 }, { min : 0x3000, max : 0x30FF }, { min : 0x4E00, max : 0x9FAF }, { min : 0xFF00, max : 0xFFEF }], text: "これはテストです" }, 1097 | }; 1098 | for (var id in ranges) { 1099 | ranges[id].id = id; 1100 | ranges[id].charCount = ranges[id].range.reduce((a,r)=>a+r.max+1-r.min, 0); 1101 | } 1102 | return ranges; 1103 | } 1104 | 1105 | 1106 | /* load() loads a font. fontInfo should be: 1107 | { 1108 | fn : "font6x8.png", // currently a built-in font 1109 | height : 8, // actual used height of font map 1110 | range : [ min:32, max:255 ] 1111 | } 1112 | 1113 | or: 1114 | 1115 | { 1116 | fn : "renaissance_28.pbff", 1117 | height : 28, // actual used height of font map 1118 | range : [ min:32, max:255 ] 1119 | } 1120 | 1121 | or: 1122 | 1123 | { 1124 | fn : "font.bdf", // Linux bitmap font format 1125 | } 1126 | 1127 | or for a font made using https://www.pentacom.jp/pentacom/bitfontmaker2/ 1128 | 1129 | { 1130 | fn : "bitfontmaker2_14px.json", 1131 | height : 14, // actual used height of font map 1132 | range : [ min:32, max:255 ] 1133 | } 1134 | 1135 | 1136 | Afterwards returns a Font object populated with the args given, and 1137 | a `function getCharPixel(ch,x,y)` which can be used to get the font data 1138 | 1139 | 1140 | load returns a `Font` class which contains: 1141 | 1142 | 1143 | 'generateGlyphs', // used internally to create the `glyphs` array 1144 | 'getGlyph', // used internally to create the `glyphs` array 1145 | 'debugPixelsUsed', // show how many pixels used on each row 1146 | 'debugChars', // dump all loaded chars 1147 | 'removeUnifontPlaceholders' // for GNU unitfont, remove placeholder characters 1148 | 1149 | 'shiftUp' // move chars up by X pixels 1150 | 'nudge' // automatically move chars to fit in font box 1151 | 'doubleSize' // double the size of the font using a pixel doubling algorithm to smooth edges - font may still need touchup after this 1152 | 1153 | 'getJS', // return the font as JS - only works for <1000 chars 1154 | 'getHeaderFile', // return the font as a C header file (uses data for each char including blank ones) 1155 | 'getPBF', // return a binary PBF file 1156 | // eg. require("fs").writeFileSync("font.pbf", Buffer.from(font.getPBF())) 1157 | 'getPBFAsC' // return a binary PBF file, but as a C file that can be included in Espruino 1158 | 1159 | */ 1160 | 1161 | 1162 | // ======================================================= 1163 | return { 1164 | Font : Font, 1165 | load : load, // load a font from a file (see above) 1166 | getRanges : getRanges // get list of possible ranges of characters 1167 | }; 1168 | })); 1169 | -------------------------------------------------------------------------------- /uart.js: -------------------------------------------------------------------------------- 1 | /* 2 | -------------------------------------------------------------------- 3 | Web Bluetooth / Web Serial Interface library for Nordic UART 4 | Copyright 2021 Gordon Williams (gw@pur3.co.uk) 5 | https://github.com/espruino/EspruinoWebTools 6 | -------------------------------------------------------------------- 7 | This Source Code Form is subject to the terms of the Mozilla Public 8 | License, v. 2.0. If a copy of the MPL was not distributed with this 9 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | -------------------------------------------------------------------- 11 | This creates a 'Puck' object that can be used from the Web Browser. 12 | 13 | Simple usage: 14 | 15 | UART.write("LED1.set()\n") 16 | 17 | Execute expression and return the result: 18 | 19 | UART.eval("BTN.read()").then(function(d) { 20 | alert(d); 21 | }); 22 | // or old way: 23 | UART.eval("BTN.read()", function(d) { 24 | alert(d); 25 | }); 26 | 27 | Or write and wait for a result - this will return all characters, 28 | including echo and linefeed from the REPL so you may want to send 29 | `echo(0)` and use `console.log` when doing this. 30 | 31 | UART.write("1+2\n").then(function(d) { 32 | alert(d); 33 | }); 34 | // or old way 35 | UART.write("1+2\n", function(d) { 36 | alert(d); 37 | }); 38 | 39 | You can also specify options, eg to respond as soon as a newline is received: 40 | 41 | UART.write("\x10Bluetooth.println(1+2)\n", {waitNewline:true}).then(function(d) { 42 | alert(d); 43 | }); 44 | 45 | Or more advanced usage with control of the connection 46 | - allows multiple connections 47 | 48 | UART.connectAsync().then(function(connection) { 49 | if (!connection) throw "Error!"; 50 | connection.on('data', function(d) { ... }); 51 | connection.on('close', function() { ... }); 52 | connection.on('error', function() { ... }); 53 | connection.write("1+2\n", function() { 54 | connection.close(); 55 | }); 56 | }); 57 | 58 | Auto-connect to previously-used Web Serial devices when they're connected to USB: 59 | 60 | navigator.serial.addEventListener("connect", (event) => { 61 | const port = event.target; 62 | UART.connectAsync({serialPort:port}).then(connection=>console.log(connection)); 63 | }); 64 | 65 | ...or to a specific VID and PID when it is connected: 66 | 67 | navigator.serial.addEventListener("connect", async (event) => { 68 | const port = event.target; 69 | const portInfo = await port.getInfo(); 70 | if (portInfo.usbVendorId==0x0483 && portInfo.usbProductId==0xA4F1) { 71 | UART.connectAsync({serialPort:port}).then(connection=>console.log(connection)); 72 | } else { 73 | console.log("Unknown device connected"); 74 | } 75 | }); 76 | 77 | You can also configure before opening a connection (see the bottom of this file for more info): 78 | 79 | UART.ports = ["Web Serial"]; // force only Web Serial to be used 80 | UART.debug = 3; // show all debug messages 81 | etc... 82 | 83 | As of Espruino 2v25 you can also send files: 84 | 85 | UART.getConnection().espruinoSendFile("test.txt","This is a test of sending data to Espruino").then(_=>console.log("Done")) 86 | UART.getConnection().espruinoSendFile("test.txt","This is a test of sending data to Espruino's SD card",{fs:true}).then(_=>console.log("Done")) 87 | 88 | And receive them: 89 | 90 | UART.getConnection().espruinoReceiveFile("test.txt", {}).then(contents=>console.log("Received", JSON.stringify(contents))); 91 | 92 | Or evaluate JS on the device and return the response as a JS object: 93 | 94 | UART.getConnection().espruinoEval("1+2").then(res => console.log("=",res)); 95 | 96 | 97 | ChangeLog: 98 | 99 | ... 100 | 1.22: Fix issue where a .write call that falls at just the right time can cause 101 | "Cannot read properties of undefined (reading 'length')" 102 | 1.21: Fixed double-reporting of progress start events 103 | Allowed `UART.write(data, options)`, where before we needed `UART.write(data, callback, options)` 104 | 1.20: Added options as 3rd arg to UART.write instead of just callbackNewline, added noWait option 105 | 1.19: Add this.removeAllListeners 106 | 1.18: Add connection.on("line"...) event, export parseRJSON, handle 'NaN' in RJSON 107 | 1.17: Work around Linux Web Bluetooth Bug (connect-disconnect-connect to same device) 108 | 1.16: Misc reliability improvements (if connection fails during write or if BLE Characteristics reused) 109 | 1.15: Flow control and chunking moved to Connection class 110 | XON/XOFF flow control now works on Serial 111 | 1.14: Ignore 'backspace' character when searching for newlines 112 | Remove fs/noACK from espruinoSendFile if not needed 113 | Longer log messages 114 | Increase default delay to 450ms (to cope with devices in low speed 200ms connection interval mode reliably) 115 | 1.13: Ensure UART.eval waits for a newline for the result like the Puck.js lib (rather than just returning whatever there was) 116 | 1.12: Handle cases where platform doesn't support connection type better (reject with error message) 117 | 1.11: espruinoSendPacket now has a timeout (never timed out before) 118 | UART.writeProgress callback now correctly handles progress when sending a big file 119 | UART.writeProgress will now work in Web Serial when using espruinoSendFile 120 | espruinoReadfile has an optional progress callback 121 | Added UART.increaseMTU option for Web Bluetooth like Puck.js lib 122 | Added 'endpoint' field to the connection 123 | Fix port chooser formatting when spectre.css has changed some defaults 124 | 1.10: Add configurable timeouts 125 | 1.09: UART.write/eval now wait until they have received data with a newline in (if requested) 126 | and return the LAST received line, rather than the first (as before) 127 | 1.08: Add UART.getConnectionAsync() 128 | Add .espruinoEval(... {stmFix:true}) to work around occasional STM32 USB issue in 2v24 and earlier firmwares 129 | 1s->2s packet timeout 130 | connection.write now returns a promise 131 | 1.07: Added UART.getConnection().espruinoEval 132 | 1.06: Added optional serialPort parameter to UART.connect(), allowing a known Web Serial port to be used 133 | Added connectAsync, and write/eval now return promises 134 | 1.05: Better handling of Web Serial disconnects 135 | UART.connect without arguments now works 136 | Fix issue using UART.write/eval if UART was opened with UART.connect() 137 | UART.getConnection() now returns undefined/isConnected()=false if UART has disconnected 138 | 1.04: For packet uploads, add ability to ste chunk size, report progress or even skip searching for acks 139 | 1.03: Added options for restricting what devices appear 140 | Improve Web Serial Disconnection - didn't work before 141 | 1.02: Added better on/emit/removeListener handling 142 | Add .espruinoSendPacket 143 | 1.01: Add UART.ports to allow available to user to be restricted 144 | Add configurable baud rate 145 | Updated modal dialog look (with common fn for selector and modal) 146 | 1.00: Auto-adjust BLE chunk size up if we receive >20 bytes in a packet 147 | Drop UART.debug to 1 (less info printed) 148 | Fixed flow control on BLE 149 | 150 | To do: 151 | 152 | * move 'connection.received' handling into removeListener and add an upper limit (100k?) 153 | * add a 'line' event for each line of data that's received 154 | 155 | */ 156 | (function (root, factory) { 157 | /* global define */ 158 | if (typeof define === 'function' && define.amd) { 159 | // AMD. Register as an anonymous module. 160 | define([], factory); 161 | } else if (typeof module === 'object' && module.exports) { 162 | // Node. Does not work with strict CommonJS, but 163 | // only CommonJS-like environments that support module.exports, 164 | // like Node. 165 | module.exports = factory(); 166 | } else { 167 | // Browser globals (root is window) 168 | root.UART = factory(); 169 | } 170 | }(typeof self !== 'undefined' ? self : this, function () { 171 | 172 | const NORDIC_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; 173 | const NORDIC_TX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; 174 | const NORDIC_RX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; 175 | 176 | if (typeof navigator == "undefined") return; 177 | /// Are we busy, so new requests should be written to the queue (below)? 178 | var isBusy; 179 | /// A queue of operations to perform if UART.write/etc is called while busy 180 | var queue = []; 181 | 182 | function ab2str(buf) { 183 | return String.fromCharCode.apply(null, new Uint8Array(buf)); 184 | } 185 | function str2ab(str) { 186 | var buf = new ArrayBuffer(str.length); 187 | var bufView = new Uint8Array(buf); 188 | for (var i=0, strLen=str.length; i','?','[','{','}','(',',',';',':']; // based on Espruino jslex.c (may not match spec 100%) 208 | var ch; 209 | var idx = 0; 210 | var lineNumber = 1; 211 | var nextCh = function() { 212 | ch = str[idx++]; 213 | if (ch=="\n") lineNumber++; 214 | }; 215 | var backCh = function() { 216 | idx--; 217 | ch = str[idx-1]; 218 | }; 219 | nextCh(); 220 | var isIn = function(s,c) { return s.indexOf(c)>=0; } ; 221 | var lastToken = {}; 222 | var nextToken = function() { 223 | while (isIn(chWhiteSpace,ch)) { 224 | nextCh(); 225 | } 226 | if (ch==undefined) return undefined; 227 | if (ch=="/") { 228 | nextCh(); 229 | if (ch=="/") { 230 | // single line comment 231 | while (ch!==undefined && ch!="\n") nextCh(); 232 | return nextToken(); 233 | } else if (ch=="*") { 234 | nextCh(); 235 | var last = ch; 236 | nextCh(); 237 | // multiline comment 238 | while (ch!==undefined && !(last=="*" && ch=="/")) { 239 | last = ch; 240 | nextCh(); 241 | } 242 | nextCh(); 243 | return nextToken(); 244 | } else { 245 | backCh(); // push the char back 246 | } 247 | } 248 | var s = ""; 249 | var type, value; 250 | var startIdx = idx-1; 251 | if (isIn(chAlpha,ch)) { // ID 252 | type = "ID"; 253 | do { 254 | s+=ch; 255 | nextCh(); 256 | } while (isIn(chAlphaNum,ch)); 257 | } else if (isIn(chNum,ch)) { // NUMBER 258 | type = "NUMBER"; 259 | var chRange = chNum; 260 | if (ch=="0") { // Handle 261 | s+=ch; 262 | nextCh(); 263 | if ("xXoObB".indexOf(ch)>=0) { 264 | if (ch=="b" || ch=="B") chRange="01"; 265 | if (ch=="o" || ch=="O") chRange="01234567"; 266 | if (ch=="x" || ch=="X") chRange="0123456789ABCDEFabcdef"; 267 | s+=ch; 268 | nextCh(); 269 | } 270 | } 271 | while (isIn(chRange,ch) || ch==".") { 272 | s+=ch; 273 | nextCh(); 274 | } 275 | } else if (isIn("\"'`/",ch)) { // STRING or regex 276 | s+=ch; 277 | var q = ch; 278 | nextCh(); 279 | // Handle case where '/' is just a divide character, not RegEx 280 | if (s=='/' && (lastToken.type=="STRING" || lastToken.type=="NUMBER" || 281 | (lastToken.type=="ID" && !allowedRegExIDs.includes(lastToken.str)) || 282 | (lastToken.type=="CHAR" && !allowedRegExChars.includes(lastToken.str)) 283 | )) { 284 | // https://www-archive.mozilla.org/js/language/js20-2000-07/rationale/syntax.html#regular-expressions 285 | type = "CHAR"; 286 | } else { 287 | type = "STRING"; // should we report this as REGEX? 288 | value = ""; 289 | 290 | while (ch!==undefined && ch!=q) { 291 | if (ch=="\\") { // handle escape characters 292 | nextCh(); 293 | var escape = '\\'+ch; 294 | if (ch=="x") { 295 | nextCh();escape += ch; 296 | nextCh();escape += ch; 297 | value += String.fromCharCode(parseInt(escape.substr(2), 16)); 298 | } else if (ch=="u") { 299 | nextCh();escape += ch; 300 | nextCh();escape += ch; 301 | nextCh();escape += ch; 302 | nextCh();escape += ch; 303 | value += String.fromCharCode(parseInt(escape.substr(2), 16)); 304 | } else { 305 | try { 306 | value += JSON.parse('"'+escape+'"'); 307 | } catch (e) { 308 | value += escape; 309 | } 310 | } 311 | s += escape; 312 | } else { 313 | s+=ch; 314 | value += ch; 315 | } 316 | nextCh(); 317 | } 318 | if (ch!==undefined) s+=ch; 319 | nextCh(); 320 | } 321 | } else { 322 | type = "CHAR"; 323 | s+=ch; 324 | nextCh(); 325 | } 326 | if (value===undefined) value=s; 327 | return lastToken={type:type, str:s, value:value, startIdx:startIdx, endIdx:idx-1, lineNumber:lineNumber}; 328 | }; 329 | 330 | return { 331 | next : nextToken 332 | }; 333 | })(str); 334 | let tok = lex.next(); 335 | function match(s) { 336 | if (tok.str!=s) throw new Error("Expecting "+s+" got "+JSON.stringify(tok.str)); 337 | tok = lex.next(); 338 | } 339 | 340 | function recurse() { 341 | while (tok!==undefined) { 342 | if (tok.type == "NUMBER") { 343 | let v = parseFloat(tok.str); 344 | tok = lex.next(); 345 | return v; 346 | } 347 | if (tok.str == "-") { 348 | tok = lex.next(); 349 | let v = -parseFloat(tok.str); 350 | tok = lex.next(); 351 | return v; 352 | } 353 | if (tok.type == "STRING") { 354 | let v = tok.value; 355 | tok = lex.next(); 356 | return v; 357 | } 358 | if (tok.type == "ID") switch (tok.str) { 359 | case "true" : tok = lex.next(); return true; 360 | case "false" : tok = lex.next(); return false; 361 | case "null" : tok = lex.next(); return null; 362 | case "NaN" : tok = lex.next(); return NaN; 363 | } 364 | if (tok.str == "[") { 365 | tok = lex.next(); 366 | let arr = []; 367 | while (tok.str != ']') { 368 | arr.push(recurse()); 369 | if (tok.str != ']') match(","); 370 | } 371 | match("]"); 372 | return arr; 373 | } 374 | if (tok.str == "{") { 375 | tok = lex.next(); 376 | let obj = {}; 377 | while (tok.str != '}') { 378 | let key = tok.type=="STRING" ? tok.value : tok.str; 379 | tok = lex.next(); 380 | match(":"); 381 | obj[key] = recurse(); 382 | if (tok.str != '}') match(","); 383 | } 384 | match("}"); 385 | return obj; 386 | } 387 | match("EOF"); 388 | } 389 | } 390 | 391 | let json = undefined; 392 | try { 393 | json = recurse(); 394 | } catch (e) { 395 | console.log("RJSON parse error", e); 396 | } 397 | return json; 398 | } 399 | 400 | function handleQueue() { 401 | if (!queue.length) return; 402 | var q = queue.shift(); 403 | log(3,"Executing "+JSON.stringify(q)+" from queue"); 404 | if (q.type=="eval") uart.eval(q.expr, q.cb).then(q.resolve, q.reject); 405 | else if (q.type=="write") uart.write(q.data, q.callback, q.options).then(q.resolve, q.reject); 406 | else log(1,"Unknown queue item "+JSON.stringify(q)); 407 | } 408 | 409 | function log(level, s) { 410 | if (uart.log) uart.log(level, s); 411 | } 412 | 413 | /// Base connection class - BLE/Serial add writeLowLevel/closeLowLevel/etc on top of this 414 | class Connection { 415 | endpoint = undefined; // Set to the endpoint used for this connection - eg maybe endpoint.name=="Web Bluetooth" 416 | // on/emit work for close/data/open/error/ack/nak/packet events 417 | on(evt,cb) { let e = "on"+evt; if (!this[e]) this[e]=[]; this[e].push(cb); } // on only works with a single handler 418 | emit(evt,data1,data2) { let e = "on"+evt; if (this[e]) this[e].forEach(fn=>fn(data1,data2)); } 419 | removeListener(evt,callback) { let e = "on"+evt; if (this[e]) this[e]=this[e].filter(fn=>fn!=callback); } 420 | removeAllListeners(evt) { let e = "on"+evt; delete this[e]; } 421 | // on("open", () => ... ) connection opened 422 | // on("close", () => ... ) connection closed 423 | // on("data", (data) => ... ) when data is received (as string) 424 | // on("line", (line) => ... ) when a line of data is received (as string), uses /r OR /n for lines 425 | // on("packet", (type,data) => ... ) when a packet is received (if .parsePackets=true) 426 | // on("ack", () => ... ) when an ACK is received (if .parsePackets=true) 427 | // on("nak", () => ... ) when an ACK is received (if .parsePackets=true) 428 | // writeLowLevel(string)=>Promise to be provided by implementor 429 | // closeLowLevel() to be provided by implementor 430 | // cb(dataStr) called if defined 431 | isOpen = false; // is the connection actually open? 432 | isOpening = true; // in the process of opening a connection? 433 | txInProgress = false; // is transmission in progress? 434 | txDataQueue = []; // queue of {data,callback,maxLength,resolve} 435 | chunkSize = 20; // Default size of chunks to split transmits into (BLE = 20, Serial doesn't care) 436 | parsePackets = false; // If set we parse the input stream for Espruino packet data transfers 437 | received = ""; // The data we've received so far - this gets reset by .write/eval/etc 438 | hadData = false; // used when waiting for a block of data to finish being received 439 | flowControlWait = 0; // If this is nonzero, we should hold off sending for that number of milliseconds (wait and decrement each time) 440 | rxDataHandlerLastCh = 0; // used by rxDataHandler - last received character 441 | rxDataHandlerPacket = undefined; // used by rxDataHandler - used for parsing 442 | rxDataHandlerTimeout = undefined; // timeout for unfinished packet 443 | rxLine = ""; // current partial line for on("line" event 444 | progressAmt = 0; // When sending a file, how many bytes through are we? 445 | progressMax = 0; // When sending a file, how long is it in bytes? 0 if not sending a file 446 | 447 | /// Called when sending data, and we take this (along with progressAmt/progressMax) and create a more detailed progress report 448 | updateProgress(chars, charsMax) { 449 | if (chars===undefined) return uart.writeProgress(); 450 | if (this.progressMax) 451 | uart.writeProgress(this.progressAmt+chars, this.progressMax); 452 | else 453 | uart.writeProgress(chars, charsMax); 454 | } 455 | 456 | /** Called when characters are received. This processes them and passes them on to event listeners */ 457 | rxDataHandler(data) { 458 | if (!(data instanceof ArrayBuffer)) console.warn("Serial port implementation is not returning ArrayBuffers"); 459 | data = ab2str(data); // now a string! 460 | log(3, "Received "+JSON.stringify(data)); 461 | if (this.parsePackets) { 462 | for (var i=0;i=2 && rxLen>=(len+2)) { 472 | log(3, "Got packet end"); 473 | if (this.rxDataHandlerTimeout) { 474 | clearTimeout(this.rxDataHandlerTimeout); 475 | this.rxDataHandlerTimeout = undefined; 476 | } 477 | this.emit("packet", flags&0xE000, this.rxDataHandlerPacket.substring(2)); 478 | this.rxDataHandlerPacket = undefined; // stop packet reception 479 | } 480 | } else if (ch=="\x06") { // handle individual control chars 481 | log(3, "Got ACK"); 482 | this.emit("ack"); 483 | ch = undefined; 484 | } else if (ch=="\x15") { 485 | log(3, "Got NAK"); 486 | this.emit("nak"); 487 | ch = undefined; 488 | } else if (uart.flowControl && ch=="\x11") { // 17 -> XON 489 | log(2,"XON received => resume upload"); 490 | this.flowControlWait = 0; 491 | } else if (uart.flowControl && ch=="\x13") { // 19 -> XOFF 492 | log(2,"XOFF received => pause upload (10s)"); 493 | this.flowControlWait = 10000; 494 | } else if (ch=="\x10") { // DLE - potential start of packet (ignore) 495 | this.rxDataHandlerLastCh = "\x10"; 496 | ch = undefined; 497 | } else if (ch=="\x01" && this.rxDataHandlerLastCh=="\x10") { // SOH 498 | log(3, "Got packet start"); 499 | this.rxDataHandlerPacket = ""; 500 | this.rxDataHandlerTimeout = setTimeout(()=>{ 501 | this.rxDataHandlerTimeout = undefined; 502 | log(0, "Packet timeout (2s)"); 503 | this.rxDataHandlerPacket = undefined; 504 | }, 2000); 505 | ch = undefined; 506 | } 507 | if (ch===undefined) { // if we're supposed to remove the char, do it 508 | data = data.substring(0,i)+data.substring(i+1); 509 | i--; 510 | } else 511 | this.rxDataHandlerLastCh = ch; 512 | } 513 | } 514 | this.hadData = true; 515 | if (data.length>0) { 516 | // keep track of received data 517 | if (this.received.length < 100000) // ensure we're not creating a memory leak 518 | this.received += data; 519 | // forward any data 520 | if (this.cb) this.cb(data); 521 | this.emit('data', data); 522 | // look for newlines and send out a 'line' event 523 | let lines = (this.rxLine + data).split(/\r\n/); 524 | while (lines.length>1) 525 | this.emit('line', lines.shift()); 526 | this.rxLine = lines[0]; 527 | if (this.rxLine.length > 10000) // only store last 10k characters 528 | this.rxLine = this.rxLine.slice(-10000); 529 | } 530 | } 531 | 532 | /** Called when the connection is opened */ 533 | openHandler() { 534 | log(1, "Connected"); 535 | this.txInProgress = false; 536 | this.isOpen = true; 537 | this.isOpening = false; 538 | this.received = ""; 539 | this.hadData = false; 540 | this.flowControlWait = 0; 541 | this.rxDataHandlerLastCh = 0; 542 | this.rxLine = ""; 543 | if (!this.isOpen) { 544 | this.isOpen = true; 545 | this.emit("open"); 546 | } 547 | // if we had any writes queued, do them now 548 | this.write(); 549 | } 550 | 551 | /** Called when the connection is closed - resets any stored info/rejects promises */ 552 | closeHandler() { 553 | this.isOpening = false; 554 | this.txInProgress = false; 555 | this.txDataQueue = []; 556 | this.hadData = false; 557 | if (this.isOpen) { 558 | log(1, "Disconnected"); 559 | this.isOpen = false; 560 | this.emit("close"); 561 | } 562 | } 563 | 564 | /** Called to close the connection */ 565 | close() { 566 | this.closeLowLevel(); 567 | this.closeHandler(); 568 | } 569 | 570 | /** Call this to send data, this splits data, handles queuing and flow control, and calls writeLowLevel to actually write the data. 571 | * 'callback' can optionally return a promise, in which case writing only continues when the promise resolves 572 | * @param {string} data 573 | * @param {() => Promise|void} callback 574 | * @returns {Promise} 575 | */ 576 | write(data, callback) { 577 | let connection = this; 578 | return new Promise((resolve,reject) => { 579 | if (data) connection.txDataQueue.push({data:data,callback:callback,maxLength:data.length,resolve:resolve}); 580 | if (connection.isOpen && !connection.txInProgress) writeChunk(); 581 | 582 | function writeChunk() { 583 | if (connection.flowControlWait) { // flow control - try again later 584 | if (connection.flowControlWait>50) connection.flowControlWait-=50; 585 | else { 586 | log(2,"Flow Control timeout"); 587 | connection.flowControlWait=0; 588 | } 589 | setTimeout(writeChunk, 50); 590 | return; 591 | } 592 | if (!connection.txDataQueue.length) { // we're finished! 593 | connection.txInProgress = false; 594 | connection.updateProgress(); 595 | return; 596 | } 597 | connection.txInProgress = true; 598 | var chunk, txItem = connection.txDataQueue[0]; 599 | connection.updateProgress(txItem.maxLength - (txItem.data?txItem.data.length:0), txItem.maxLength); 600 | if (txItem.data.length <= connection.chunkSize) { 601 | chunk = txItem.data; 602 | txItem.data = undefined; 603 | } else { 604 | chunk = txItem.data.substr(0,connection.chunkSize); 605 | txItem.data = txItem.data.substr(connection.chunkSize); 606 | } 607 | log(2, "Sending "+ JSON.stringify(chunk)); 608 | connection.writeLowLevel(chunk).then(function() { 609 | log(3, "Sent"); 610 | let promise = undefined; 611 | if (!txItem.data) { 612 | connection.txDataQueue.shift(); // remove this element 613 | if (txItem.callback) 614 | promise = txItem.callback(); 615 | if (txItem.resolve) 616 | txItem.resolve(); 617 | } 618 | if (!(promise instanceof Promise)) 619 | promise = Promise.resolve(); 620 | promise.then(writeChunk); // if txItem.callback() returned a promise, wait until it completes before continuing 621 | }, function(error) { 622 | log(1, 'SEND ERROR: ' + error); 623 | connection.updateProgress(); 624 | connection.txDataQueue = []; 625 | connection.close(); 626 | }); 627 | } 628 | }); 629 | } 630 | 631 | /* Send a packet of type "RESPONSE/EVAL/EVENT/FILE_SEND/DATA" to Espruino 632 | options = { 633 | noACK : bool (don't wait to acknowledgement - default=false) 634 | timeout : int (optional, milliseconds, default=5000) if noACK=false 635 | } 636 | */ 637 | espruinoSendPacket(pkType, data, options) { 638 | options = options || {}; 639 | if (!options.timeout) options.timeout=5000; 640 | if ("string"!=typeof data) throw new Error("'data' must be a String"); 641 | if (data.length>0x1FFF) throw new Error("'data' too long"); 642 | const PKTYPES = { 643 | RESPONSE : 0, // Response to an EVAL packet 644 | EVAL : 0x2000, // execute and return the result as RESPONSE packet 645 | EVENT : 0x4000, // parse as JSON and create `E.on('packet', ...)` event 646 | FILE_SEND : 0x6000, // called before DATA, with {fn:"filename",s:123} 647 | DATA : 0x8000, // Sent after FILE_SEND with blocks of data for the file 648 | FILE_RECV : 0xA000 // receive a file - returns a series of PT_TYPE_DATA packets, with a final zero length packet to end 649 | } 650 | if (!(pkType in PKTYPES)) throw new Error("'pkType' not one of "+Object.keys(PKTYPES)); 651 | let connection = this; 652 | return new Promise((resolve,reject) => { 653 | let timeout; 654 | function tidy() { 655 | if (timeout) { 656 | clearTimeout(timeout); 657 | timeout = undefined; 658 | } 659 | connection.removeListener("ack",onACK); 660 | connection.removeListener("nak",onNAK); 661 | } 662 | function onACK(ok) { 663 | tidy(); 664 | setTimeout(resolve,0); 665 | } 666 | function onNAK(ok) { 667 | tidy(); 668 | setTimeout(reject,0,"NAK while sending packet"); 669 | } 670 | if (!options.noACK) { 671 | connection.parsePackets = true; 672 | connection.on("ack",onACK); 673 | connection.on("nak",onNAK); 674 | } 675 | let flags = data.length | PKTYPES[pkType]; 676 | connection.write(String.fromCharCode(/*DLE*/16,/*SOH*/1,(flags>>8)&0xFF,flags&0xFF)+data, function() { 677 | // write complete 678 | if (options.noACK) { 679 | setTimeout(resolve,0); // if not listening for acks, just resolve immediately 680 | } else { 681 | timeout = setTimeout(function() { 682 | timeout = undefined; 683 | tidy(); 684 | reject(`Timeout (${options.timeout}ms) while sending packet`); 685 | }, options.timeout); 686 | } 687 | }, err => { 688 | tidy(); 689 | reject(err); 690 | }); 691 | }); 692 | } 693 | /* Send a file to Espruino using 2v25 packets. 694 | options = { // mainly passed to Espruino 695 | fs : true // optional -> write using require("fs") (to SD card) 696 | noACK : bool // (don't wait to acknowledgements) 697 | chunkSize : int // size of chunks to send (default 1024) for safety this depends on how big your device's input buffer is if there isn't flow control 698 | progress : (chunkNo,chunkCount)=>{} // callback to report upload progress 699 | timeout : int (optional, milliseconds, default=1000) 700 | } */ 701 | espruinoSendFile(filename, data, options) { 702 | if ("string"!=typeof data) throw new Error("'data' must be a String"); 703 | let CHUNK = 1024; 704 | options = options||{}; 705 | options.fn = filename; 706 | options.s = data.length; 707 | let packetOptions = {}; 708 | let progressHandler = (chunkNo,chunkCount)=>{}; 709 | if (options.noACK !== undefined) { 710 | packetOptions.noACK = !!options.noACK; 711 | delete options.noACK; 712 | } 713 | if (options.chunkSize) { 714 | CHUNK = options.chunkSize; 715 | delete options.chunkSize; 716 | } 717 | if (options.progress) { 718 | progressHandler = options.progress; 719 | delete options.progress; 720 | } 721 | options.fs = options.fs?1:0; // .fs => use SD card 722 | if (!options.fs) delete options.fs; // default=0, so just remove if it's not set 723 | let connection = this; 724 | let packetCount = 0, packetTotal = Math.ceil(data.length/CHUNK)+1; 725 | connection.progressAmt = 0; 726 | connection.progressMax = 100 + data.length; 727 | // always ack the FILE_SEND 728 | progressHandler(0, packetTotal); 729 | return connection.espruinoSendPacket("FILE_SEND",JSON.stringify(options)).then(sendData, err=> { 730 | connection.progressAmt = 0; 731 | connection.progressMax = 0; 732 | throw err; 733 | }); 734 | // but if noACK don't ack for data 735 | function sendData() { 736 | connection.progressAmt += connection.progressAmt?CHUNK:100; 737 | progressHandler(++packetCount, packetTotal); 738 | if (data.length==0) { 739 | connection.progressAmt = 0; 740 | connection.progressMax = 0; 741 | return Promise.resolve(); 742 | } 743 | let packet = data.substring(0, CHUNK); 744 | data = data.substring(CHUNK); 745 | return connection.espruinoSendPacket("DATA", packet, packetOptions).then(sendData, err=> { 746 | connection.progressAmt = 0; 747 | connection.progressMax = 0; 748 | throw err; 749 | }); 750 | } 751 | } 752 | /* Receive a file from Espruino using 2v25 packets. 753 | options = { // mainly passed to Espruino 754 | fs : true // optional -> write using require("fs") (to SD card) 755 | timeout : int // milliseconds timeout (default=2000) 756 | progress : (bytes)=>{} // callback to report upload progress 757 | } 758 | } */ 759 | espruinoReceiveFile(filename, options) { 760 | options = options||{}; 761 | options.fn = filename; 762 | if (!options.progress) 763 | options.progress = (bytes)=>{}; 764 | let connection = this; 765 | return new Promise((resolve,reject) => { 766 | let fileContents = "", timeout; 767 | function scheduleTimeout() { 768 | if (timeout) clearTimeout(timeout); 769 | timeout = setTimeout(() => { 770 | timeout = undefined; 771 | cleanup(); 772 | reject("espruinoReceiveFile Timeout"); 773 | }, options.timeout || 2000); 774 | } 775 | function cleanup() { 776 | connection.removeListener("packet", onPacket); 777 | if (timeout) { 778 | clearTimeout(timeout); 779 | timeout = undefined; 780 | } 781 | } 782 | function onPacket(type,data) { 783 | if (type!=0x8000) return; // ignore things that are not DATA packet 784 | if (data.length==0) { // 0 length packet = EOF 785 | cleanup(); 786 | setTimeout(resolve,0,fileContents); 787 | } else { 788 | fileContents += data; 789 | options.progress(fileContents.length); 790 | scheduleTimeout(); 791 | } 792 | } 793 | connection.parsePackets = true; 794 | connection.on("packet", onPacket); 795 | scheduleTimeout(); 796 | options.progress(0); 797 | connection.espruinoSendPacket("FILE_RECV",JSON.stringify(options)).then(()=>{ 798 | // now wait... 799 | }, err => { 800 | cleanup(); 801 | reject(err); 802 | }); 803 | }); 804 | } 805 | /* Send a JS expression to be evaluated on Espruino using using 2v25 packets. 806 | options = { 807 | timeout : int // milliseconds timeout (default=1000) 808 | stmFix : bool // if set, this works around an issue in Espruino STM32 2v24 and earlier where USB could get in a state where it only sent small chunks of data at a time 809 | }*/ 810 | espruinoEval(expr, options) { 811 | options = options || {}; 812 | if ("string"!=typeof expr) throw new Error("'expr' must be a String"); 813 | let connection = this; 814 | return new Promise((resolve,reject) => { 815 | let prodInterval; 816 | 817 | function cleanup() { 818 | connection.removeListener("packet", onPacket); 819 | if (timeout) { 820 | clearTimeout(timeout); 821 | timeout = undefined; 822 | } 823 | if (prodInterval) { 824 | clearInterval(prodInterval); 825 | prodInterval = undefined; 826 | } 827 | } 828 | function onPacket(type,data) { 829 | if (type!=0) return; // ignore things that are not a response 830 | cleanup(); 831 | setTimeout(resolve,0, parseRJSON(data)); 832 | } 833 | connection.parsePackets = true; 834 | connection.on("packet", onPacket); 835 | let timeout = setTimeout(() => { 836 | timeout = undefined; 837 | cleanup(); 838 | reject("espruinoEval Timeout"); 839 | }, options.timeout || 1000); 840 | connection.espruinoSendPacket("EVAL",expr,{noACK:options.stmFix}).then(()=>{ 841 | // resolved/rejected with 'packet' event or timeout 842 | if (options.stmFix) 843 | prodInterval = setInterval(function() { 844 | connection.write(" \x08") // space+backspace 845 | .catch(err=>{ 846 | console.error("Error sending STM fix:",err); 847 | cleanup(); 848 | }); 849 | }, 50); 850 | }, err => { 851 | cleanup(); 852 | reject(err); 853 | }); 854 | }); 855 | } 856 | } // End of Connection class 857 | 858 | 859 | /// Endpoints for each connection method 860 | var endpoints = []; 861 | endpoints.push({ 862 | name : "Web Bluetooth", 863 | description : "Bluetooth LE devices", 864 | svg : '', 865 | isSupported : function() { 866 | if (navigator.platform.indexOf("Win")>=0 && 867 | (navigator.userAgent.indexOf("Chrome/54")>=0 || 868 | navigator.userAgent.indexOf("Chrome/55")>=0 || 869 | navigator.userAgent.indexOf("Chrome/56")>=0) 870 | ) 871 | return "Chrome <56 in Windows has navigator.bluetooth but it's not implemented properly"; 872 | if (window && window.location && window.location.protocol=="http:" && 873 | window.location.hostname!="localhost") 874 | return "Serving off HTTP (not HTTPS) - Web Bluetooth not enabled"; 875 | if (navigator.bluetooth) return true; 876 | var iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 877 | if (iOS) { 878 | return "To use Web Bluetooth on iOS you'll need the WebBLE App.\nPlease go to https://itunes.apple.com/us/app/webble/id1193531073 to download it."; 879 | } else { 880 | return "This Web Browser doesn't support Web Bluetooth.\nPlease see https://www.espruino.com/Puck.js+Quick+Start"; 881 | } 882 | }, 883 | connect : function(connection, options) { 884 | options = options || {}; 885 | /* options = { 886 | // nothing yet... 887 | } 888 | */ 889 | var btServer = undefined; 890 | var btService; 891 | var txCharacteristic; 892 | var rxCharacteristic; 893 | 894 | // Called when RX charactersistic changed (= data received) 895 | function bleRxListener(event) { 896 | /* work around Linux Web Bluetooth bug (https://github.com/espruino/BangleApps/issues/3850) where 897 | doing removeEventListener on an old connection and addEventListenerlistener on a new one causes 898 | the new one not to be called. We have to leave the original listener after the connection closes 899 | and then just update where we send the data based on the GattServer which *doesn't* change! */ 900 | let conn = event.target.service.device.gatt.uartConnection; // btServer.uartConnection 901 | // actually process data 902 | var dataview = event.target.value; 903 | // if received data longer than our recorded MTU it means we can also send data this long 904 | // ... we have to do this as there's no other way to check the MTU 905 | if (uart.increaseMTU && (dataview.byteLength > conn.chunkSize)) { 906 | log(2, "Received packet of length "+dataview.byteLength+", increasing chunk size"); 907 | conn.chunkSize = dataview.byteLength; 908 | } 909 | // Finally pass the data to our connection 910 | conn.rxDataHandler(dataview.buffer); 911 | } 912 | 913 | connection.closeLowLevel = function() { 914 | txCharacteristic = undefined; 915 | rxCharacteristic = undefined; 916 | btService = undefined; 917 | if (btServer) { 918 | btServer.uartConnection = undefined; 919 | btServer.disconnect(); 920 | btServer = undefined; 921 | } 922 | }; 923 | connection.writeLowLevel = function(data) { 924 | return txCharacteristic.writeValue(str2ab(data)); 925 | }; 926 | connection.chunkSize = 20; // set a starting chunk size of Bluetooth LE (this is the max if it hasn't been negotiated higher) 927 | 928 | return navigator.bluetooth.requestDevice(uart.optionsBluetooth).then(function(device) { 929 | log(1, 'Device Name: ' + device.name); 930 | log(1, 'Device ID: ' + device.id); 931 | // Was deprecated: Should use getPrimaryServices for this in future 932 | //log('BT> Device UUIDs: ' + device.uuids.join('\n' + ' '.repeat(21))); 933 | device.addEventListener('gattserverdisconnected', function() { 934 | log(1, "Disconnected (gattserverdisconnected)"); 935 | connection.close(); 936 | }); 937 | return device.gatt.connect(); 938 | }).then(function(server) { 939 | log(2, "BLE Connected"); 940 | btServer = server; 941 | btServer.uartConnection = connection; 942 | return server.getPrimaryService(NORDIC_SERVICE); 943 | }).then(function(service) { 944 | log(2, "Got service"); 945 | btService = service; 946 | return btService.getCharacteristic(NORDIC_RX); 947 | }).then(function (characteristic) { 948 | rxCharacteristic = characteristic; 949 | log(2, "RX characteristic:"+JSON.stringify(rxCharacteristic)); 950 | rxCharacteristic.addEventListener('characteristicvaluechanged', bleRxListener); 951 | return rxCharacteristic.startNotifications(); 952 | }).then(function() { 953 | return btService.getCharacteristic(NORDIC_TX); 954 | }).then(function (characteristic) { 955 | txCharacteristic = characteristic; 956 | log(2, "TX characteristic:"+JSON.stringify(txCharacteristic)); 957 | }).then(function() { 958 | connection.openHandler(); 959 | isBusy = false; 960 | queue = []; 961 | return connection; 962 | }).catch(function(error) { 963 | log(1, 'ERROR: ' + error); 964 | connection.close(); 965 | return Promise.reject(error); 966 | }); 967 | } 968 | }); 969 | endpoints.push({ 970 | name : "Web Serial", 971 | description : "USB connected devices", 972 | svg : '', 973 | isSupported : function() { 974 | if (!navigator.serial) 975 | return "No navigator.serial - Web Serial not enabled"; 976 | if (window && window.location && window.location.protocol=="http:" && 977 | window.location.hostname!="localhost") 978 | return "Serving off HTTP (not HTTPS) - Web Serial not enabled"; 979 | return true; 980 | }, 981 | connect : function(connection, options) { 982 | options = options || {}; 983 | /* options = { 984 | serialPort : force a serialport, otherwise pop up a menu 985 | } 986 | */ 987 | let serialPort, reader, writer; 988 | function disconnected() { 989 | connection.closeHandler(); 990 | } 991 | 992 | connection.closeLowLevel = function(callback) { 993 | if (writer) { 994 | writer.close(); 995 | writer = undefined; 996 | } 997 | if (reader) { 998 | reader.cancel(); 999 | } 1000 | // readLoop will finish and *that* calls disconnect and cleans up 1001 | }; 1002 | connection.writeLowLevel = function(data, callback, alreadyRetried) { 1003 | return new Promise((resolve, reject) => { 1004 | if (!serialPort || !serialPort.writable) return reject ("Not connected"); 1005 | if (serialPort.writable.locked) { 1006 | if (alreadyRetried) 1007 | return reject("Writable stream is locked"); 1008 | log(0,'Writable stream is locked - retry in 500ms'); 1009 | setTimeout(()=>{ this.write(data, callback, true).then(resolve, reject); }, 500); 1010 | return; 1011 | } 1012 | writer = serialPort.writable.getWriter(); 1013 | writer.write(str2ab(data)).then(function() { 1014 | writer.releaseLock(); 1015 | writer = undefined; 1016 | if (callback) callback(); 1017 | resolve(); 1018 | }).catch(function(error) { 1019 | if (writer) { 1020 | writer.releaseLock(); 1021 | writer.close(); 1022 | } 1023 | writer = undefined; 1024 | log(0,'SEND ERROR: ' + error); 1025 | reject(error); 1026 | }); 1027 | }); 1028 | }; 1029 | 1030 | return (options.serialPort ? 1031 | Promise.resolve(options.serialPort) : 1032 | navigator.serial.requestPort(uart.optionsSerial)).then(function(port) { 1033 | log(1, "Connecting to serial port"); 1034 | serialPort = port; 1035 | return port.open({ baudRate: uart.baud }); 1036 | }).then(function () { 1037 | function readLoop() { 1038 | reader = serialPort.readable.getReader(); 1039 | reader.read().then(function ({ value, done }) { 1040 | reader.releaseLock(); 1041 | reader = undefined; 1042 | if (value) 1043 | connection.rxDataHandler(value.buffer); 1044 | if (done) { // connection is closed 1045 | if (serialPort) { 1046 | serialPort.close(); 1047 | serialPort = undefined; 1048 | } 1049 | disconnected(); 1050 | } else { // else continue reading 1051 | readLoop(); 1052 | } 1053 | }, function(error) { // read() rejected... 1054 | reader.releaseLock(); 1055 | log(0, 'ERROR: ' + error); 1056 | if (serialPort) { 1057 | serialPort.close(); 1058 | serialPort = undefined; 1059 | } 1060 | disconnected(); 1061 | }); 1062 | } 1063 | connection.openHandler(); 1064 | readLoop(); 1065 | return connection; 1066 | }).catch(function(error) { 1067 | log(0, 'ERROR: ' + error); 1068 | disconnected(); 1069 | return Promise.reject(error); 1070 | }); 1071 | } 1072 | }); 1073 | // ====================================================================== 1074 | /* Create a modal window. 1075 | options = { 1076 | title : string 1077 | contents = DomElement | string 1078 | onClickBackground : function 1079 | onClickMenu : function 1080 | } 1081 | returns { 1082 | remove : function(); // remove menu 1083 | } 1084 | */ 1085 | function createModal(options) { 1086 | // modal 1087 | var e = document.createElement('div'); 1088 | e.style = 'position:absolute;top:0px;left:0px;right:0px;bottom:0px;opacity:0.5;z-index:100;background:black;'; 1089 | // menu 1090 | var menu = document.createElement('div'); 1091 | menu.style = 'position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-family: Sans-Serif;z-index:101;min-width:300px'; 1092 | var menutitle = document.createElement('div'); 1093 | menutitle.innerText = options.title; 1094 | menutitle.style = 'color:#fff;background:#000;padding:8px 8px 4px 8px;font-weight:bold;'; 1095 | menu.appendChild(menutitle); 1096 | 1097 | var items = document.createElement('div'); 1098 | items.style = 'color:#000;background:#fff;padding:4px 8px 4px 8px;min-height:4em'; 1099 | if ("string" == typeof options.contents) 1100 | items.innerHTML = options.contents; 1101 | else 1102 | items.appendChild(options.contents); 1103 | menu.appendChild(items); 1104 | document.body.appendChild(e); 1105 | document.body.appendChild(menu); 1106 | e.onclick = function(evt) { // clicked modal -> remove 1107 | evt.preventDefault(); 1108 | result.remove(); 1109 | if (options.onClickBackground) 1110 | options.onClickBackground(); 1111 | }; 1112 | menu.onclick = function(evt) { // clicked menu 1113 | evt.preventDefault(); 1114 | if (options.onClickMenu) 1115 | options.onClickMenu(); 1116 | }; 1117 | 1118 | var result = { 1119 | remove : function() { 1120 | document.body.removeChild(menu); 1121 | document.body.removeChild(e); 1122 | } 1123 | }; 1124 | return result; 1125 | } 1126 | // ====================================================================== 1127 | var connection; 1128 | function connect(options) { 1129 | connection = new Connection(); 1130 | return new Promise((resolve, reject) => { 1131 | if (uart.ports.length==0) { 1132 | console.error(`UART: No ports in uart.ports`); 1133 | return reject(`UART: No ports in uart.ports`); 1134 | } 1135 | if (uart.ports.length==1) { 1136 | var endpoint = endpoints.find(ep => ep.name == uart.ports[0]); 1137 | if (endpoint===undefined) { 1138 | return reject(`UART: Port Named "${uart.ports[0]}" not found`); 1139 | } 1140 | var supported = endpoint.isSupported(); 1141 | if (supported!==true) 1142 | return reject(endpoint.name+" is not supported on this platform: "+supported); 1143 | return endpoint.connect(connection, options).then(resolve, reject); 1144 | } 1145 | 1146 | var items = document.createElement('div'); 1147 | var supportedEndpoints = 0; 1148 | uart.ports.forEach(function(portName) { 1149 | var endpoint = endpoints.find(ep => ep.name == portName); 1150 | if (endpoint===undefined) { 1151 | console.error(`UART: Port Named "${portName}" not found`); 1152 | return; 1153 | } 1154 | var supported = endpoint.isSupported(); 1155 | if (supported!==true) { 1156 | log(0, endpoint.name+" not supported, "+supported); 1157 | return; 1158 | } 1159 | var ep = document.createElement('div'); 1160 | ep.style = 'width:300px;height:60px;background:#ccc;margin:4px 0px 4px 0px;padding:0px 0px 0px 68px;cursor:pointer;line-height: normal;'; 1161 | ep.innerHTML = '
'+endpoint.svg+'
'+ 1162 | '
'+endpoint.name+'
'+ 1163 | '
'+endpoint.description+'
'; 1164 | ep.onclick = function(evt) { 1165 | connection.endpoint = endpoint; 1166 | endpoint.connect(connection, options).then(resolve, reject); 1167 | evt.preventDefault(); 1168 | menu.remove(); 1169 | }; 1170 | items.appendChild(ep); 1171 | supportedEndpoints++; 1172 | }); 1173 | if (supportedEndpoints==0) 1174 | return reject(`No connection methods (${uart.ports.join(", ")}) supported on this platform`); 1175 | 1176 | var menu = createModal({ 1177 | title:"SELECT A PORT...", 1178 | contents:items, 1179 | onClickBackground:function() { 1180 | uart.log(1,"User clicked outside modal - cancelling connect"); 1181 | connection.isOpening = false; 1182 | connection.emit('error', "Model closed."); 1183 | } 1184 | }); 1185 | }); 1186 | } 1187 | 1188 | // Push the given operation to the queue, return a promise 1189 | function pushToQueue(operation) { 1190 | log(3, `Busy - adding ${operation.type} to queue`); 1191 | return new Promise((resolve,reject) => { 1192 | operation.resolve = resolve; 1193 | operation.reject = reject; 1194 | queue.push(operation); 1195 | }); 1196 | } 1197 | // ====================================================================== 1198 | /* convenience function... Write data, call the callback(and/or promise) with data: 1199 | write(data, callback, options) => Promise 1200 | write(data, options) => Promise 1201 | 1202 | options = true/false => same as setting {waitNewline:true/false} 1203 | options.waitNewline = false => return if no new data received for ~0.2 sec 1204 | true => return only after a newline 1205 | options.noWait : bool => don't wait for any response, just return immediately 1206 | */ 1207 | function write(data, callback, options) { 1208 | if ("object" == typeof callback) { 1209 | options = callback; 1210 | callback = undefined; 1211 | } 1212 | if ("boolean" == typeof options) 1213 | options = {waitNewline:options}; 1214 | else if (options === undefined) 1215 | options = {}; 1216 | 1217 | if (isBusy) 1218 | return pushToQueue({type:"write", data:data, callback:callback, options:options}); 1219 | 1220 | return new Promise((resolve,reject) => { 1221 | var cbTimeout; 1222 | function onWritten() { 1223 | if (options.waitNewline) { 1224 | connection.cb = function(d) { 1225 | // if we hadn't got a newline this time (even if we had one before) 1226 | // then ignore it (https://github.com/espruino/BangleApps/issues/3771) 1227 | if (!d.includes("\n")) return; 1228 | // now return the LAST received non-empty line 1229 | var lines = connection.received.split("\n"); 1230 | var idx = lines.length-1; 1231 | while (lines[idx].replaceAll("\b","").trim().length==0 && idx>0) idx--; // skip over empty lines (incl backspace \b) 1232 | var line = lines.splice(idx,1)[0]; // get the non-empty line 1233 | connection.received = lines.join("\n"); // put back other lines 1234 | // remove handler and return 1235 | connection.cb = undefined; 1236 | if (cbTimeout) clearTimeout(cbTimeout); 1237 | cbTimeout = undefined; 1238 | if (callback) 1239 | callback(line); 1240 | resolve(line); 1241 | isBusy = false; 1242 | handleQueue(); 1243 | }; 1244 | } 1245 | // wait for any received data if we have a callback... 1246 | var maxTime = uart.timeoutMax; // Max time we wait in total, even if getting data 1247 | var dataWaitTime = options.waitNewline ? uart.timeoutNewline : uart.timeoutNormal; 1248 | var maxDataTime = dataWaitTime; // max time we wait after having received data 1249 | const POLLINTERVAL = 100; 1250 | if (options.noWait) { // just return immediately, as soon as written 1251 | maxTime = dataWaitTime = maxDataTime = 0; 1252 | } 1253 | cbTimeout = setTimeout(function timeout() { 1254 | cbTimeout = undefined; 1255 | if (connection===undefined) { 1256 | if (callback) callback(""); 1257 | return reject("Disconnected"); 1258 | } 1259 | if (maxTime>0) maxTime-=POLLINTERVAL; 1260 | if (maxDataTime>0) maxDataTime-=POLLINTERVAL; 1261 | if (connection.hadData) maxDataTime=dataWaitTime; 1262 | if (maxDataTime>0 && maxTime>0) { 1263 | cbTimeout = setTimeout(timeout, 100); 1264 | } else { 1265 | connection.cb = undefined; 1266 | if (options.waitNewline) 1267 | log(2, "write waiting for newline timed out"); 1268 | if (callback) 1269 | callback(connection.received); 1270 | resolve(connection.received); 1271 | isBusy = false; 1272 | connection.received = ""; 1273 | handleQueue(); 1274 | } 1275 | connection.hadData = false; 1276 | }, 100); 1277 | } 1278 | 1279 | if (connection && connection.isOpen) { 1280 | if (!connection.txInProgress) connection.received = ""; 1281 | isBusy = true; 1282 | return connection.write(data, onWritten); 1283 | } 1284 | 1285 | return connect().then(function(_connection) { 1286 | if (_connection !== connection) console.warn("Resolved Connection doesn't match current connection!"); 1287 | isBusy = true; 1288 | return connection.write(data, onWritten/*calls resolve*/); 1289 | }, function(error) { 1290 | isBusy = false; 1291 | reject(error); 1292 | }); 1293 | }); 1294 | } 1295 | 1296 | function evaluate(expr, cb) { 1297 | if (isBusy) 1298 | return pushToQueue({type:"eval", expr:expr, cb:cb}); 1299 | return write('\x10eval(process.env.CONSOLE).println(JSON.stringify('+expr+'))\n',undefined,true/*callback on newline*/).then(function(d) { 1300 | try { 1301 | var json = JSON.parse(d.trim()); 1302 | if (cb) cb(json); 1303 | return json; 1304 | } catch (e) { 1305 | let err = "Unable to decode "+JSON.stringify(d)+", got "+e.toString(); 1306 | log(1, err); 1307 | if (cb) cb(null, err); 1308 | return Promise.reject(err); 1309 | } 1310 | }, {waitNewline:true}); 1311 | } 1312 | 1313 | // ---------------------------------------------------------- 1314 | 1315 | var uart = { 1316 | version : "1.22", 1317 | /// Are we writing debug information? 0 is no, 1 is some, 2 is more, 3 is all. 1318 | debug : 1, 1319 | /// Should we use flow control? Default is true 1320 | flowControl : true, 1321 | /// Which ports should be offer to the user? If only one is specified no modal menu is created 1322 | ports : ["Web Bluetooth","Web Serial"], 1323 | /// Baud rate for Web Serial connections (Official Espruino devices use 9600, Espruino-on-ESP32/etc use 115200) 1324 | baud : 115200, 1325 | /// timeout (in ms) in .write when waiting for any data to return 1326 | timeoutNormal : 450, // 450ms is enough time that with a slower 200ms connection interval and a delay we should be ok 1327 | /// timeout (in ms) in .write/.eval when waiting for a newline 1328 | timeoutNewline : 10000, 1329 | /// timeout (in ms) to wait at most 1330 | timeoutMax : 30000, 1331 | /** Web Bluetooth: When we receive more than 20 bytes, should we increase the chunk size we use 1332 | for writing to match it? Normally this is fine but it seems some phones have a broken bluetooth implementation that doesn't allow it. */ 1333 | increaseMTU : true, 1334 | /// Used internally to write log information - you can replace this with your own function 1335 | log : function(level, s) { if (level <= this.debug) console.log(" "+s)}, 1336 | /// Called with the current send progress or undefined when done - you can replace this with your own function 1337 | writeProgress : function (charsSent, charsTotal) { 1338 | //console.log(charsSent + "/" + charsTotal); 1339 | }, 1340 | /** Connect to a new device - this creates a separate 1341 | connection to the one `write` and `eval` use. */ 1342 | connectAsync : connect, // connectAsync(options) 1343 | connect : (callback, options) => { // for backwards compatibility 1344 | connect(options).then(callback, err => callback(null,err)); 1345 | return connection; 1346 | }, 1347 | /// Write to a device and callback when the data is written (returns promise, or can take callback). Creates a connection if it doesn't exist. NOTE: If the device is constantly sending and we're not waiting for a newline, this can take up to UART.timeoutMax to return 1348 | write : write, // write(string, callback, callbackForNewline) -> Promise 1349 | /// Evaluate an expression and call cb with the result (returns promise, or can take callback). Creates a connection if it doesn't exist 1350 | eval : evaluate, // eval(expr_as_string, callback) -> Promise 1351 | /// Write the current time to the device 1352 | setTime : function(cb) { 1353 | var d = new Date(); 1354 | var cmd = 'setTime('+(d.getTime()/1000)+');'; 1355 | // in 1v93 we have timezones too 1356 | cmd += 'if (E.setTimeZone) E.setTimeZone('+d.getTimezoneOffset()/-60+');\n'; 1357 | write(cmd, cb); 1358 | }, 1359 | /// Did `write` and `eval` manage to create a connection? 1360 | isConnected : function() { 1361 | return connection!==undefined && connection.isOpen; 1362 | }, 1363 | /// get the connection used by `write` and `eval`, or return undefined 1364 | getConnection : function() { 1365 | return connection; 1366 | }, 1367 | /// Return a promise with the connection used by `write` and `eval`, and if there's no connection attempt to get one 1368 | getConnectionAsync : function() { 1369 | return connection ? Promise.resolve(connection) : uart.connectAsync(); 1370 | }, 1371 | /// Close the connection used by `write` and `eval` 1372 | close : function() { 1373 | if (connection) 1374 | connection.close(); 1375 | }, 1376 | /** Utility function to fade out everything on the webpage and display 1377 | a window saying 'Click to continue'. When clicked it'll disappear and 1378 | 'callback' will be called. This is useful because you can't initialise 1379 | Web Bluetooth unless you're doing so in response to a user input.*/ 1380 | modal : function(callback) { 1381 | var menu = createModal({ 1382 | title : "Connection", 1383 | contents : '
Please click to connect
', 1384 | onClickBackground : callback, 1385 | onClickMenu : function() { 1386 | menu.remove(); 1387 | callback(); 1388 | } 1389 | }); 1390 | }, 1391 | /* This is the list of 'drivers' for Web Bluetooth/Web Serial. It's possible to add to these 1392 | and also change 'ports' in order to add your own custom endpoints (eg WebSockets) */ 1393 | endpoints : endpoints, 1394 | /* options passed to navigator.serial.requestPort. You can change this to: 1395 | {filters:[{ usbVendorId: 0x1234 }]} to restrict the serial ports that are shown */ 1396 | optionsSerial : {}, 1397 | /* options passed to navigator.bluetooth.requestDevice. You can change this to 1398 | allow more devices to connect (or restrict the ones that are shown) */ 1399 | optionsBluetooth : { 1400 | filters:[ 1401 | { namePrefix: 'Puck.js' }, 1402 | { namePrefix: 'Pixl.js' }, 1403 | { namePrefix: 'Jolt.js' }, 1404 | { namePrefix: 'MDBT42Q' }, 1405 | { namePrefix: 'Bangle' }, 1406 | { namePrefix: 'RuuviTag' }, 1407 | { namePrefix: 'iTracker' }, 1408 | { namePrefix: 'Thingy' }, 1409 | { namePrefix: 'Espruino' }, 1410 | { services: [ NORDIC_SERVICE ] } 1411 | ], optionalServices: [ NORDIC_SERVICE ]}, 1412 | /* function(string) => object - parse relaxed JSON (eg {a:1} rather than {"a":1}) or throw exception if invalid */ 1413 | parseRJSON : parseRJSON 1414 | }; 1415 | return uart; 1416 | })); 1417 | --------------------------------------------------------------------------------