├── .gitignore ├── img └── sample.png ├── package.json ├── LICENSE ├── src ├── config.sample.js ├── helloworld.js ├── dither.js └── BLEComm.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | **/node_modules 3 | /src/config.js -------------------------------------------------------------------------------- /img/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Grassboy/ULANI.node.js/HEAD/img/sample.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "canvas": "^2.11.2", 4 | "ditherjs": "^0.10.0", 5 | "noble-winrt": "^0.1.1" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 yobssarG m'I 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/config.sample.js: -------------------------------------------------------------------------------- 1 | console.log('請注意,請將 /src/config.sample.js 複製成 /src/config.js, 並將 bluetooth_name 改成你在 windows 下配對到的 ulani 裝置名稱'); 2 | //如果你已存成 config.js 了,上面這行就可以刪除了 3 | 4 | if(require.main !== module) { 5 | module.exports = { 6 | is_debug: true, //是否顯示偵錯訊息 7 | bluetooth_name: 'ULANI CalendarC0FFEE', //藍牙裝置名稱 8 | frame_setting: { //相框設定 9 | 10 | background_color: '#00000066', //若圖片沒有填滿相框,要填入的背景顏色為何?可接受 rgb() rgba() #RRGGBB #RRGGBBAA 或顏色保留字 11 | cover_background: true, //若圖片沒有填滿相框,是否要用該圖片刷一層背景 (background_color 帶有透明度時才看得到) 12 | rotate_deg: 0, //是否要旋轉相框,可接受的值為 0 90 180 270 (若要放直式相片建議調成 90 or 270) 13 | 14 | display_time: true, //是否顯示圖片傳輸的時間點(以便確認相框上次換圖是何時) 15 | //若 display_time == true 則可決定文字樣式 16 | font_color: 'black', //顏色,可接受 rgb() rgba() #RRGGBB #RRGGBBAA 或顏色保留字 17 | font_size: 64, //文字大小(px) 18 | font_family: 'Georgia', //字型 19 | font_left: 20, //x 位置 20 | font_top: 40 //y 位置(注意,這是以文字底線的垂直位置) 21 | 22 | } 23 | }; 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/helloworld.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const config = require(fs.existsSync('./config.js')?'./config.js':'./config.sample.js'); 3 | const BLEComm = require('./BLEComm.js'); 4 | 5 | var comm = new BLEComm(config); 6 | comm.bind('ulaniready', async function(){ 7 | console.log('取得電量等級', await comm.getBatteryLevel() ); 8 | var slot = await comm.getActiveImageSlot(); 9 | console.log(slot); 10 | if(slot.startsWith('0c')) { 11 | comm.current_slot = parseInt(slot[3], 10); 12 | } 13 | console.log('當前顯示第', comm.current_slot, '個相框'); 14 | 15 | //先前好像幾秒後沒連線就會藍牙斷線,所以加了10秒 ack 機制 16 | setInterval(async function(){ 17 | console.log('binary ack'); 18 | await comm.binaryAck(); 19 | }, 10000); 20 | 21 | var has_failed = 0; 22 | setTimeout(async function(){ 23 | while(true){ 24 | console.log('開始測試換圖'); 25 | var r = await comm.startSendImage(comm.current_slot, '../img/sample.png'); 26 | console.log('結果: ', r); 27 | if(r != '0200') { 28 | await comm.setActiveImageSlot(1); 29 | } else { 30 | console.log('成功換圖 bye'); 31 | process.exit(); 32 | } 33 | if(r != '0200' && has_failed == 0) { //換圖失敗的話就重試 34 | has_failed++; 35 | } else if(has_failed >= 2) { //失敗2次就跳出 36 | break; 37 | } else if(has_failed == 1){ //失敗一次再加一次 38 | has_failed++; 39 | } 40 | await new Promise((r)=>setTimeout(r,60000)); 41 | } 42 | }, 15000); 43 | }); 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ULANI Node.js 函式庫 2 | 3 | ## 碎碎念 4 | 5 | 會開發這個玩意,是因為我希望相框呈現的資訊不只是預設的日期 6 | 我希望這個相框能夠顯示"當天每三小時天氣"以及"未來一週天氣", 7 | 並且能夠隨機挑出我自己精選的照片每小時換圖(精選照片可能不少) 8 | 9 | 所以便透過自己所學,搭配 ChatGPT 去一步步摸索 ULANI 的資料傳輸模式 10 | 希望能夠不透過官方 App 就能達到換圖的目的 11 | 12 | 然後更進一步的,就是製作作一個「非官方的 ULANI 操作網頁」 13 | 讓它除了換圖之外,也能夠更快的讓我在相框上加入額外的疊加資訊(ex: 天氣), 14 | 之後是不是也可以讓其他人開發更多疊加資訊,讓 ULANI 不單純只是日曆功能? 15 | 16 | 所以,除了早就開發好的 Node.js 函式庫外, 17 | 較符合一般人操作習慣的網頁(個人是命名 UlaniX 啦XD)也在開發中…如下圖XD 18 | ![圖片](https://github.com/user-attachments/assets/8edef350-a3ff-41cd-a2c9-c73d6b6af468) 19 | 20 | 不過…因為各種理由…小弟其實沒有太多時間&動力寫 UlaniX 了0rz... 21 | 想寫的東西其實不少,現在我的 ULANI 可以抓天氣&隨機挑照片我就很開心了… 22 | 23 | 所以,我想 UlaniX 應該之後會石沉大海吧… 24 | 不過,ULANI Node.js 函式庫的出現,應該會讓未來想開發類似功能的人,省下不少時間… 25 | 所以,在我 Node.js 函式庫開發完快兩年,以及 ULANI 官方看起來已經不在了的現在… 26 | 我決定把這份可以跑的函式庫 Release 出來~ 27 | 28 | 之後 UlaniX 的 code (但 code 很醜喲…) 有需要&有心力的話,可能也會丟上來看有沒有人要接手XDD 29 | 30 | ## 系統需求&使用門檻需求 31 | 32 | * Windows 10: 因為我只在 Windows 10 試過可以跑 33 | * 一台有支援藍牙的電腦: 桌機筆電都行,我的桌機原本沒有藍牙,蝦皮買個藍牙發射器就能用了 34 | * 一台 ULANI: 經實測,一台電腦只能控制一台 ULANI,不知是不是 ULANI 當初的限制 35 | * 你要具備 Node.js 基礎: 這個專案只有 Node.js 函式庫,沒有任何 GUI 介面 36 | 37 | ## 開始安裝 38 | * git clone 這個專案,先複製一份 ```/src/config.sample.js``` 到 ```/src/config.js``` 39 | * 更改 ```/src/config.js``` 裡的 config,最重要的應該是第七行的 40 | ``` 41 | bluetooth_name: 'ULANI CalendarC0FFEE', //藍牙裝置名稱 42 | ``` 43 | 要改成你自己的 ULANI 的藍牙裝置名稱 44 | * 到 windows 的藍芽管理畫面新增藍芽裝置→找到剛才填的裝置名稱→配對,如下圖 45 | ![圖片](https://github.com/user-attachments/assets/f86e7290-e776-40c1-b8db-b1eded69311c) 46 | * 到專案資料夾下跑 ```npm install``` 將所需的其他 node.js 套件下載下來 47 | * ```/src/helloworld.js``` 有附一份可以跑起來的 code,它會示範幫你把第一個相框的圖換掉,換成 ```/img/sample.png``` 的內容 48 | 49 | ## 具體實作了哪些功能? 50 | 51 | hmm...我應該是最關注換圖的功能…所以換圖相關的函式我都盡量包好了, 52 | 具體的可至 ```/src/BLEComm.js``` 277~339 看有哪些函式可用 53 | 然後可至 ```/src/helloworld.js``` 看看範例用法 54 | 55 | ## 和官方 App 的差異? 56 | 57 | 其實我已經很久沒裝 App,不知道 ULANI 目前官方 App 好用到什麼程度, 58 | 不過有幾個功能是這個函式庫自認比官方 App 強大的地方 59 | 60 | * 改善顏色呈現的演算法,同一張圖在相框上的呈現應該比官方 App 順眼,詳見 [先前官方社團的討論](https://www.facebook.com/groups/535715677955824/posts/650182433175814/) 61 | * 支援相框旋轉功能,可直接指定 ULANI 是直立相框,讓傳進去的圖片都以直立比例呈現 ![圖片](https://github.com/user-attachments/assets/a54fad6d-5a31-444b-af7c-e29b3dcc034b) 62 | 63 | 64 | ## 結論 65 | 66 | hmm...其實最理想的狀態就是全部包得好好的讓一般的 ULANI 用戶也能快速上手 67 | 不過我想我是作不出來了XDD 所以把最核心的函式庫放出來降子… 68 | 其實也不知道現在還有哪些人的 ULANI 還在服役啦~ 69 | 希望能貢獻一點所學囉~ 70 | -------------------------------------------------------------------------------- /src/dither.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Image Dither Processor 3 | * 4 | * */ 5 | 6 | 7 | const {createCanvas, loadImage} = require('canvas'); 8 | 9 | const DitherJS = require('ditherjs/server'); 10 | const fs = require('fs'); 11 | const config = require(fs.existsSync('./config.js')?'./config.js':'./config.sample.js'); 12 | 13 | var ditherjs = new DitherJS(); 14 | 15 | var options = { 16 | "step": 1, // The step for the pixel quantization n = 1,2,3... 17 | "palette": 18 | [ 19 | [ 0, 0, 0 ], 20 | [ 209, 208, 202 ], 21 | [ 69, 121, 81 ], 22 | [ 82, 91, 151 ], 23 | [ 175, 76, 74 ], 24 | [ 207, 194, 88 ], 25 | [ 192, 99, 30 ] 26 | ], 27 | skip_reload: true, 28 | "algorithm": "diffusion" // one of ["ordered", "diffusion", "atkinson"] 29 | }; 30 | var CRC16 = function(paramShort, paramArrayOfByte, paramLong1, paramLong2) { 31 | var i = paramLong1; 32 | var j; 33 | for (j = paramShort; paramLong2 > 0; j = k) { 34 | var k = (j ^ paramArrayOfByte[i] << 8) & 0xFFFF; 35 | for (paramShort = 0; paramShort < 8; paramShort++) { 36 | if (k & 0x8000) { 37 | k = (k << 1 ^ 0x1021) & 0xFFFF; 38 | } else { 39 | k = (k << 1) & 0xFFFF; 40 | } 41 | } 42 | i++; 43 | paramLong2--; 44 | } 45 | return j.toString(16); 46 | }; 47 | var hexToUint8Array = function(hex) { 48 | var array = []; 49 | for (var i = 0; i < hex.length; i += 2) { 50 | array.push(parseInt(hex.substr(i, 2), 16)); 51 | } 52 | //console.log(array); 53 | return new Uint8Array(array); 54 | }; 55 | var calcBoundingBox = function(width, height, box_width, box_height){ 56 | var result; 57 | if(width / height > box_width / box_height) { //垂直置中 58 | result = [0, (box_height - (box_width / width * height))/2, box_width, box_width / width * height ]; 59 | result.push(result[2]*(1 - box_height/result[3]) / 2, 0, result[2]/result[3]*box_height, box_height); 60 | } else { //水平置中 61 | result = [(box_width - (box_height / height * width))/2, 0, box_height / height * width, box_height]; 62 | result.push(0, result[3]*(1 - box_width/result[2]) / 2, box_width, result[3]/result[2]*box_width); 63 | } 64 | return result; 65 | }; 66 | var getDitherData = function(img_data){ 67 | var map = {}; 68 | options.palette.forEach(function(pair, i){ 69 | map[pair.join(',')] = i; 70 | }); 71 | var getXY = function(x, y){ //取 canvas x,y 的相素對應值 72 | var p = (y*800+x)*4; 73 | var search = img_data.slice(p, p+3); 74 | if(map[search] === undefined){ 75 | console.log(search, x, y); 76 | throw '找不到'+search; 77 | } 78 | return map[search]; 79 | }; 80 | var result = []; 81 | var str = ''; 82 | for(var y = 0; y < 480; y++) { 83 | for(var x = 0; x < 800; x++ ) { 84 | str+= getXY(x, y); 85 | if(str.length == 460) { 86 | result.push(str); 87 | str = ''; 88 | } 89 | } 90 | } 91 | result.push(str); 92 | var crc16 = CRC16(0, hexToUint8Array(result.join('')), 0, 192000); 93 | result.splice(0,0,crc16); 94 | return result; 95 | }; 96 | 97 | /* 98 | * dither process 99 | * resolve format: 100 | * [ 101 | * [CRC16], ...[pixcel data series] 102 | * ] 103 | * */ 104 | var getDitherImage = function(file, opts){ 105 | opts = Object.assign({}, config.frame_setting, (opts || {})); 106 | return new Promise(function(resolve, reject){ 107 | var buffer = fs.readFileSync(file); 108 | loadImage(buffer).then(function(img){ 109 | var canvas = createCanvas(800, 480); 110 | var ctx = canvas.getContext('2d'); 111 | ctx.font = opts.font_size+'px '+opts.font_family; 112 | ctx.save(); 113 | switch(opts.rotate_deg) { 114 | case 0: 115 | box = calcBoundingBox(img.naturalWidth, img.naturalHeight, 800, 480); 116 | break; 117 | case 90: 118 | ctx.translate(0, canvas.height); // 平移畫布 119 | ctx.rotate(-Math.PI / 2); // 向左旋轉90度 (逆時針) 120 | box = calcBoundingBox(img.naturalWidth, img.naturalHeight, 480, 800); 121 | break; 122 | case 180: 123 | ctx.translate(canvas.width, canvas.height); // 平移基準點到右下角,然後旋轉180度 124 | ctx.rotate(Math.PI); // 旋轉180度 125 | box = calcBoundingBox(img.naturalWidth, img.naturalHeight, 800, 480); 126 | break; 127 | case 270: 128 | ctx.translate(canvas.width, 0); // 移動基準點到右上角 129 | ctx.rotate(Math.PI / 2); 130 | box = calcBoundingBox(img.naturalWidth, img.naturalHeight, 480, 800); 131 | break; 132 | } 133 | 134 | if(opts.cover_background) { 135 | ctx.drawImage.apply(ctx, [img, ...(box.slice(4))]); 136 | } 137 | ctx.fillStyle = opts.background_color; 138 | ctx.fillRect(0,0,800,800); 139 | ctx.drawImage.apply(ctx, [img, ...(box.slice(0,4))]); 140 | if(opts.display_time) { 141 | ctx.fillStyle = opts.font_color; 142 | ctx.textBaseline = 'top'; 143 | ctx.fillText(new Date().toString().split(' ')[4], opts.font_left, opts.font_top); 144 | } 145 | 146 | ctx.restore(); 147 | var buffer2 = ditherjs.dither(canvas.toBuffer('image/png'),options); 148 | loadImage(buffer2).then(function(img){ 149 | var canvas = createCanvas(800, 480); 150 | var ctx = canvas.getContext('2d'); 151 | ctx.drawImage(img, 0, 0, 800, 480); 152 | /* 153 | // 將 Canvas 匯出為 PNG 圖片並寫入檔案系統中 154 | const out = fs.createWriteStream('./output.png'); 155 | const stream = canvas.createPNGStream(); 156 | stream.pipe(out); 157 | */ 158 | resolve(getDitherData(ctx.getImageData(0,0,800,480).data)); 159 | }); 160 | }); 161 | 162 | }); 163 | }; 164 | 165 | this.getDitherImage = getDitherImage; 166 | if (require.main === module) { 167 | var main = async function(global){ 168 | var result = await getDitherImage('../img/sample_ai_building.png'); 169 | console.log(result); 170 | } 171 | main(this); 172 | } 173 | -------------------------------------------------------------------------------- /src/BLEComm.js: -------------------------------------------------------------------------------- 1 | //Ulani BLEComm implement 2 | //issue: 3 | // characteristic ondata 事件會重複 trigger 且無法避免 4 | // reconnect 會抓不到 characteristic 必需整個 process 重啟 5 | // 6 | const fs = require('fs'); 7 | const config = require(fs.existsSync('./config.js')?'./config.js':'./config.sample.js'); 8 | const device_name_prefix = 'ULANI Calendar'; 9 | const getDitherImage = require('./dither.js').getDitherImage; 10 | 11 | var DEVICE_SERVICE = '1234a200-7cbc-11e9-8f9e-2a86e4085a59'; 12 | var CHARAC_201_UUID = '1234a201-7cbc-11e9-8f9e-2a86e4085a59'; 13 | var CHARAC_202_UUID = '1234a202-7cbc-11e9-8f9e-2a86e4085a59'; 14 | var CHARAC_201; 15 | var CHARAC_202; 16 | 17 | var BLEComm = function(opts){ 18 | var noble = this.noble = require('noble-winrt'); 19 | var that = this; 20 | that.events = {}; 21 | that._job_done = false; 22 | that._event_handler = {}; 23 | that._previous_op_time = (new Date()).getTime(); 24 | if(opts.is_debug == true) { 25 | that.is_debug = true; 26 | } 27 | that.events.stateChange = function(state){ 28 | that.trigger('statechange', {state:state}); 29 | if (state === 'poweredOn') { 30 | that.nobleStartScanning(); 31 | } else { 32 | noble.stopScanning(); 33 | } 34 | }; 35 | that.events.discover = (peripheral) => { 36 | const device_name = peripheral.advertisement.localName; 37 | var device_info = { 38 | device_name: device_name, 39 | peripheral_id: peripheral.id 40 | }; 41 | //device_name && console.log(device_name); 42 | if (!opts.bluetooth_name && device_name.startsWith(device_name_prefix)) { 43 | that.trigger('discover', device_info); 44 | } else if (device_name && device_name == opts.bluetooth_name) { 45 | that.trigger('ulanifound', device_info); 46 | noble.stopScanning(); 47 | (async function(){ 48 | var connectDevice = function(peripheral){ 49 | return new Promise(function(resolve, reject){ 50 | peripheral.connect((error) => { 51 | if(error) { 52 | reject(error); 53 | } else { 54 | resolve(peripheral); 55 | } 56 | }); 57 | }); 58 | }; 59 | var discoverService = function(peripheral){ 60 | return new Promise(function(resolve, reject){ 61 | peripheral.discoverServices([], (error, services) => { 62 | if (error) { 63 | reject(error); 64 | } else { 65 | services.forEach((service) => { 66 | if(service.uuid == DEVICE_SERVICE) { 67 | that.trigger('servicefound', {service: service}); 68 | resolve(service); 69 | } 70 | }); 71 | reject('service not found'); 72 | } 73 | }); 74 | }); 75 | }; 76 | var subscribeCharacteristics = function(service){ 77 | return new Promise(function(resolve, reject){ 78 | service.discoverCharacteristics([], async (error, characteristics) => { 79 | if (error) { 80 | reject(error); 81 | return; 82 | } 83 | 84 | if(characteristics.length == 0){ 85 | //console.log('characteristic not found try again'); 86 | //reject('characteristics not found'); 87 | resolve(false); 88 | return; 89 | } 90 | for(var i = 0; i < characteristics.length; i++) { 91 | var characteristic = characteristics[i]; 92 | if(characteristic.uuid == CHARAC_201_UUID) { 93 | that.op_charac = characteristic; 94 | await that._subscribeCharac(characteristic, false); 95 | that.trigger('opchannelready', {characteristic: characteristic}); 96 | } else if (characteristic.uuid == CHARAC_202_UUID) { 97 | that.data_charac = characteristic; 98 | await that._subscribeCharac(characteristic, true); 99 | that.trigger('datachannelready', {characteristic: characteristic}); 100 | } else { 101 | //console.log('Unknown UUID', characteristic.uuid); 102 | } 103 | } 104 | resolve(true); 105 | }); 106 | }); 107 | }; 108 | 109 | // 監聽 peripheral 的連接狀態變化 110 | var onPeripheralConnect = async function() { 111 | that.trigger('connect', device_info); 112 | var service = await discoverService(peripheral); 113 | while(true) { 114 | var ready = await subscribeCharacteristics(service); 115 | if(ready) { 116 | break; 117 | } else { 118 | throw 'cannot subscribe all characteristics'; 119 | } 120 | } 121 | that.trigger('ulaniready',{}); 122 | }; 123 | var onPeripheralDisconnect = async function() { 124 | that.trigger('disconnect', device_info); 125 | peripheral.removeListener('connect', onPeripheralConnect); 126 | peripheral.removeListener('disconnect', onPeripheralDisconnect); 127 | that.destroy(); 128 | }; 129 | peripheral.on('connect', onPeripheralConnect); 130 | peripheral.on('disconnect', onPeripheralDisconnect); 131 | that.peripheral = peripheral; 132 | await connectDevice(peripheral); 133 | })(); 134 | } 135 | }; 136 | noble.on('stateChange', that.events.stateChange); 137 | if(noble.state == 'poweredOn') { 138 | console.log('重新 Scanning', that.nobleStartScanning()); 139 | } 140 | noble.on('discover', that.events.discover); 141 | }; 142 | BLEComm.prototype = { 143 | constructor: BLEComm, 144 | _subscribeCharac: function(characteristic, force){ 145 | var got_data = force; 146 | var that = this; 147 | return new Promise(function(resolve, reject){ 148 | var dataHnalderGenerator = function(characteristic){ 149 | var seed = characteristic.__seed = (new Date()).getTime().toString()+parseInt(Math.random()*1000, 10); 150 | characteristic.__dataHandler = function(data, isNotification){ 151 | if(seed != characteristic.__seed) { 152 | console.log('ignore handler'); 153 | return; 154 | } 155 | if(seed != characteristic.__seed) return; 156 | if(that.is_debug) { 157 | console.log(`Received data "${data.toString('hex')}" from characteristic "${characteristic.uuid}"`, isNotification); 158 | } 159 | got_data = true; 160 | var data_hex = data.toString('hex'); 161 | var op = data_hex.substr(0, 2) 162 | characteristic['__resolve'+op] && characteristic['__resolve'+op](data_hex); 163 | }; 164 | return characteristic.__dataHandler; 165 | }; 166 | var doSubscribe = function(){ 167 | characteristic.subscribe((error)=>{ 168 | if (error) { 169 | console.error(`Error subscribing to characteristic: ${error}`); 170 | reject(); 171 | return; 172 | } 173 | //console.log(`Subscribed to characteristic "${characteristic.uuid}"`); 174 | if(got_data) { 175 | //console.log('Got response', characteristic.uuid); 176 | resolve(); 177 | } else { 178 | //console.log('still not response'); 179 | characteristic.unsubscribe((error)=>{ 180 | if (error) { 181 | console.error(`Error unsubscribing to characteristic: ${error}`); 182 | return; 183 | } 184 | doSubscribe(); 185 | }); 186 | } 187 | }); 188 | characteristic.__dataHandler && characteristic.removeListener('data', characteristic.__dataHandler); 189 | characteristic.on('data', dataHnalderGenerator(characteristic)); 190 | }; 191 | doSubscribe(); 192 | }); 193 | }, 194 | setOpValue: function(value, is_silent){ 195 | var that = this; 196 | if(!is_silent) { 197 | that._previous_op_time = (new Date()).getTime(); 198 | } 199 | var characteristic = that.op_charac; 200 | if(!characteristic) { 201 | throw "Characteristic not ready!!"; 202 | } 203 | return new Promise(function(resolve, reject){ 204 | var op = value.substr(0, 2) 205 | characteristic['__getValue'+op] = new Promise(function(r){ 206 | //console.log('設定新的 resolve function'); 207 | characteristic['__resolve'+op] = r; 208 | setTimeout(function(){ 209 | r(op+'9999'); //op value 理論上10秒內一定會有回應 210 | }, 10000); 211 | var valueToWrite = hexToUint8Array(value); 212 | characteristic.write(valueToWrite, false, (error) => { 213 | if (error) { 214 | console.error(`Error writing to characteristic: ${error}`); 215 | return; 216 | } 217 | //console.log(`Wrote value "${valueToWrite.toString('hex')}" to characteristic "${characteristic.uuid}"`); 218 | resolve({p: characteristic['__getValue'+op]}); 219 | }); 220 | }); 221 | }); 222 | }, 223 | setDataValue: function(data){ 224 | var that = this; 225 | var characteristic = that.data_charac; 226 | if(!characteristic) { 227 | throw "Characteristic not ready!!"; 228 | } 229 | return new Promise(function(resolve, reject){ 230 | var valueToWrite = hexToUint8Array(data); 231 | characteristic.write(valueToWrite, false, (error) => { 232 | if (error) { 233 | console.error(`Error writing to characteristic: ${error}`); 234 | return; 235 | } 236 | //console.log(`Wrote value "${valueToWrite.toString('hex')}" to characteristic "${characteristic.uuid}"`); 237 | resolve(); 238 | }); 239 | }); 240 | }, 241 | nobleStartScanning: function(){ 242 | //要 delay 個兩秒後重新 scanning 243 | //不然有時候會被前一個 stopScanning 影響到 244 | var that = this; 245 | setTimeout(function(){ 246 | that.noble.startScanning([], true); 247 | }, 2000); 248 | }, 249 | trigger: function(event_type, data){ 250 | var that = this; 251 | if(that.is_debug) { 252 | console.log("Event: ", event_type, data); 253 | } 254 | that._event_handler[event_type] && that._event_handler[event_type].forEach(function(fn){ 255 | fn.apply(that, [{ 256 | type: event_type, 257 | data: data 258 | }]); 259 | }); 260 | }, 261 | bind: function(event_type, handler){ 262 | var that = this; 263 | that._event_handler[event_type] = that._event_handler[event_type] || []; 264 | that._event_handler[event_type].push(handler); 265 | }, 266 | nextSlot: function(){ 267 | return (this.current_slot % 4) + 1; 268 | }, 269 | binaryAck: async function(){ 270 | var that = this; 271 | if((new Date()).getTime() - that._previous_op_time > 300000) { //如果超過五分鐘沒有任何操作,中斷連線 272 | return that.askForDisconnect(); 273 | } else { 274 | return that.setOpValue('0600', true); //因為是 ack 所以不需要 await 275 | } 276 | }, 277 | startSendImage: async function(slot, img_path, job_id = null, opts = config.frame_setting){ 278 | var that = this; 279 | var img_data = await getDitherImage(img_path); 280 | await that.checkCustomerID(); 281 | var p = (await that.setOpValue('010002ee000'+slot+'02' + ((new Date()).getTime().toString(16).substr(-8)) + img_data[0])).p; 282 | var result = await p; 283 | var data_charac_result = null; 284 | return new Promise(async function(resolve, reject){ 285 | that.data_charac.__resolve02 = function(r){ 286 | that.trigger('sendimagedone', {rsp: r, slot: slot, job_id: job_id}); 287 | data_charac_result = r; 288 | resolve(r); 289 | }; 290 | if(result == '0100'){ 291 | that.trigger('sendimagestart', {}); 292 | for(var i = 1; i < img_data.length; i++){ 293 | if(data_charac_result) break; 294 | await that.setDataValue(img_data[i]); 295 | await new Promise((r)=>setTimeout(r,20)); 296 | if(i % 40 == 0 || ( i == img_data.length - 1 )) { 297 | that.trigger('imageprogress', { 298 | total: img_data.length, 299 | slot: slot, 300 | now: i, 301 | job_id: job_id || null, 302 | percentage: ((i+1)*100) / img_data.length 303 | }); 304 | } 305 | //if(i == img_data.length - 10) break; //故意失敗測試 0201 306 | } 307 | } else { 308 | resolve(result); 309 | } 310 | }); 311 | }, 312 | checkCustomerID: async function(){ 313 | var that = this; 314 | return (await that.setOpValue('044e42')).p; 315 | }, 316 | getBatteryLevel: async function(){ 317 | var that = this; 318 | return (await that.setOpValue('0600')).p; 319 | }, 320 | askForDisconnect: async function(){ 321 | var that = this; 322 | return (await that.setOpValue('0903')).p; 323 | }, 324 | setActiveImageSlot: async function(slot){ 325 | var that = this; 326 | var t = (await that.setOpValue('0b0'+slot)).p; 327 | return t.then(function(r){ 328 | if(r == '0b00') { 329 | that.trigger('slotchange', {slot: slot} ); 330 | that.current_slot = slot; 331 | } 332 | return r; 333 | }); 334 | }, 335 | getActiveImageSlot: async function(){ 336 | var that = this; 337 | return (await that.setOpValue('0c00')).p; 338 | }, 339 | destroy: function(){ 340 | var that = this; 341 | that.trigger('destroy'); 342 | that.noble.stopScanning(); 343 | that.noble.removeListener('stateChange', that.events.stateChange); 344 | that.noble.removeListener('discover', that.events.discover); 345 | that.peripheral.disconnect(() => { 346 | process.exit(that._job_done?0:-1); 347 | }); 348 | } 349 | } 350 | 351 | 352 | var queue = []; 353 | function hexToUint8Array(hex) { 354 | var array = []; 355 | for (var i = 0; i < hex.length; i += 2) { 356 | array.push(parseInt(hex.substr(i, 2), 16)); 357 | } 358 | //console.log(array); 359 | return Buffer.from(new Uint8Array(array)); 360 | } 361 | if (require.main !== module) { 362 | module.exports = BLEComm; 363 | } 364 | --------------------------------------------------------------------------------