├── .eslintrc.js ├── .gitignore ├── README.md ├── _config.yml ├── cli └── fontconverter.js ├── examples ├── fontconverter.html ├── imageconverter-html.html ├── imageconverter-simple.html ├── imageconverter.html ├── logtofile.html ├── puck.html ├── uart.html └── uartUploadZIP.html ├── fontconverter.js ├── heatshrink.js ├── imageconverter.js ├── package.json ├── puck.js └── uart.js /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /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/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 | 27 | 41 | 42 |
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 | 25 |

26 |
28 |

OR use a font file:

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

34 | 35 |

36 |

37 | Font name: 38 | 39 |

40 |
43 | 44 |

Set font options:

45 |
46 | Size : 16
47 | BPP :
52 | Range :
59 | Align to increase sharpness :
60 | Use compression :
61 |
62 |
63 |
64 | 65 |
66 | 67 | 68 |

69 | 70 |

71 | 72 | 399 | 400 | 401 | 402 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | Colours:
23 | Output As:
25 | 26 | 27 | 28 |

Result

29 |

...

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