diff --git a/html/index.html b/html/index.html index c4c06d6..a2f55fe 100644 --- a/html/index.html +++ b/html/index.html @@ -4,7 +4,7 @@ 电子墨水屏蓝牙控制器 - + @@ -198,9 +198,9 @@ - - - + + + \ No newline at end of file diff --git a/html/js/main.js b/html/js/main.js index bd58595..de2c636 100644 --- a/html/js/main.js +++ b/html/js/main.js @@ -94,31 +94,7 @@ async function write(cmd, data, withResponse = true) { return true; } -async function epdWrite(cmd, data) { - const chunkSize = document.getElementById('mtusize').value - 1; - const interleavedCount = document.getElementById('interleavedcount').value; - const count = Math.round(data.length / chunkSize); - let chunkIdx = 0; - let noReplyCount = interleavedCount; - - if (typeof data == 'string') data = hex2bytes(data); - - await write(EpdCmd.SEND_CMD, [cmd]); - for (let i = 0; i < data.length; i += chunkSize) { - let currentTime = (new Date().getTime() - startTime) / 1000.0; - setStatus(`命令:0x${cmd.toString(16)}, 数据块: ${chunkIdx + 1}/${count + 1}, 总用时: ${currentTime}s`); - if (noReplyCount > 0) { - await write(EpdCmd.SEND_DATA, data.slice(i, i + chunkSize), false); - noReplyCount--; - } else { - await write(EpdCmd.SEND_DATA, data.slice(i, i + chunkSize), true); - noReplyCount = interleavedCount; - } - chunkIdx++; - } -} - -async function epdWriteImage(data, step = 'bw') { +async function writeImage(data, step = 'bw') { const chunkSize = document.getElementById('mtusize').value - 2; const interleavedCount = document.getElementById('interleavedcount').value; const count = Math.round(data.length / chunkSize); @@ -201,38 +177,24 @@ async function sendimg() { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const processedData = processImageData(imageData); - let dataSent = true; updateButtonStatus(true); - if (appVersion < 0x16) { - const driver = epdDriverSelect.value; - if (ditherMode === 'threeColor') { - const halfLength = Math.floor(processedData.length / 2); - await epdWrite(driver === "02" ? 0x24 : 0x10, processedData.slice(0, halfLength)); - await epdWrite(driver === "02" ? 0x26 : 0x13, processedData.slice(halfLength)); - } else if (ditherMode === 'blackWhiteColor') { - await epdWrite(driver === "04" ? 0x24 : 0x13, processedData); - } else { - addLog("当前固件不支持此颜色模式。"); - dataSent = false; - } + + if (ditherMode === 'fourColor') { + await writeImage(processedData, 'color'); + } else if (ditherMode === 'threeColor') { + const halfLength = Math.floor(processedData.length / 2); + await writeImage(processedData.slice(0, halfLength), 'bw'); + await writeImage(processedData.slice(halfLength), 'red'); + } else if (ditherMode === 'blackWhiteColor') { + await writeImage(processedData, 'bw'); } else { - if (ditherMode === 'fourColor') { - await epdWriteImage(processedData, 'color'); - } else if (ditherMode === 'threeColor') { - const halfLength = Math.floor(processedData.length / 2); - await epdWriteImage(processedData.slice(0, halfLength), 'bw'); - await epdWriteImage(processedData.slice(halfLength), 'red'); - } else if (ditherMode === 'blackWhiteColor') { - await epdWriteImage(processedData, 'bw'); - } else { - addLog("当前固件不支持此颜色模式。"); - dataSent = false; - } + addLog("当前固件不支持此颜色模式。"); + updateButtonStatus(); + return; } - updateButtonStatus(); - if (!dataSent) return; await write(EpdCmd.REFRESH); + updateButtonStatus(); const sendTime = (new Date().getTime() - startTime) / 1000.0; addLog(`发送完成!耗时: ${sendTime}s`); @@ -382,6 +344,15 @@ async function connect() { appVersion = 0x15; } + if (appVersion < 0x16) { + const oldURL = "https://tsl0922.github.io/EPD-nRF5/v1.5"; + alert("!!!注意!!!\n当前固件版本过低,可能无法正常使用部分功能,建议升级到最新版本。"); + if (confirm('是否访问旧版本上位机?')) location.href = oldURL; + setTimeout(() => { + addLog(`如遇到问题,可访问旧版本上位机: ${oldURL}`); + }, 500); + } + try { await epdCharacteristic.startNotifications(); epdCharacteristic.addEventListener('characteristicvaluechanged', (event) => { diff --git a/html/v1.5/css/main.css b/html/v1.5/css/main.css new file mode 100644 index 0000000..e6e4441 --- /dev/null +++ b/html/v1.5/css/main.css @@ -0,0 +1,370 @@ +:root { + --primary-color: #0d6efd; + --primary-hover: #0b5ed7; + --secondary-color: #6c757d; + --secondary-hover: #5c636a; + + --dark-bg: #121212; + --dark-text: #e0e0e0; + --dark-fieldset-bg: #1e1e1e; + --dark-border: #333; + --dark-code-bg: #2d2d2d; + --dark-log-bg: #2a2a2a; + --dark-input-bg: #2d2d2d; + --dark-input-text: #e0e0e0; +} + +body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, sans-serif; + overflow-x: hidden; +} + +.debug { + display: none !important; +} + +body.debug-mode .debug { + display: flex !important; +} + +body.debug-mode { + background-color: var(--dark-bg); + color: var(--dark-text); +} + +body.debug-mode .main { + background-color: var(--dark-bg); + color: var(--dark-text); +} + +body.debug-mode fieldset { + background-color: var(--dark-fieldset-bg); + box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.5); +} + +body.debug-mode h3 { + border-bottom: 1px solid var(--dark-border); + color: var(--dark-text); +} + +body.debug-mode code { + background: var(--dark-code-bg); + color: #ff9800; +} + +body.debug-mode #log { + background: var(--dark-log-bg); + border: 1px solid var(--dark-border); +} + +body.debug-mode #log .time { + color: #8bc34a; +} + +body.debug-mode #log .action { + color: #03a9f4; +} + +body.debug-mode input[type=text], +body.debug-mode input[type=number], +body.debug-mode select { + background-color: var(--dark-input-bg); + color: var(--dark-input-text); + border-color: var(--dark-border); +} + +body.debug-mode input[type=file] { + color: var(--dark-input-text); + background-color: transparent; + border-color: var(--dark-border); +} + +body.debug-mode input[type=file]::file-selector-button { + background-color: var(--dark-fieldset-bg); + color: var(--dark-input-text); + border-color: var(--dark-border); +} + +body.debug-mode input[type=file]::file-selector-button:hover { + background-color: #333; + border-color: #444; +} + +body.debug-mode fieldset legend { + color: #64b5f6; +} + +.main { + width: 100%; + max-width: 950px; + margin: 0 auto; + padding: 0 1rem; + background: #fff; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + box-sizing: border-box; +} + +.footer { + display: flex; + gap: 10px; + font-size: 0.8rem; + color: #666; + flex-wrap: wrap; + margin: 1rem 0; +} + +.footer .links { + display: flex; + align-items: center; +} + +.footer .links a { + color: #666; + text-decoration: none; + position: relative; + padding: 0 8px; +} + +.footer .links a:first-child { + padding-left: 0; +} + +.footer .links a:not(:last-child)::after { + content: "•"; + position: absolute; + right: -4px; + color: #999; +} + +.footer a:hover { + color: #0d6efd; + text-decoration: underline; +} + +body.debug-mode .footer .links a:not(:last-child)::after { + color: #666; +} + +body.debug-mode .footer { + color: #999; +} + +body.debug-mode .footer a { + color: #999; +} + +body.debug-mode .footer a:hover { + color: #64b5f6; +} + +h3 { + padding-bottom: .3em; + border-bottom: 1px solid #CCC; + text-align: center; +} + +fieldset { + border: none; + box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.2); + background-color: #f8f9fa; + padding: 10px; + margin-bottom: 16px; + border-radius: 4px; +} + +fieldset legend { + font-weight: bold; + color: rgba(0, 0, 255, 0.6); +} + +code { + padding: .2em .4em; + margin: 0; + font-size: 85%; + background: #CCC; + border-radius: 3px; +} + +.flex-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.flex-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +#status { + margin: 10px 0; +} + +#log { + width: 100%; + min-height: 100px; + max-height: 300px; + margin: 0; + padding: 5px; + background: #DDD; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + box-sizing: border-box; +} + +#log .time { + color: #333; +} + +#log .action { + color: #666; +} + +#canvas-box { + margin-top: 10px; + width: 100%; +} + +#canvas { + border: black solid 1px; + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; +} + +button { + padding: 0.375rem 0.75rem; + border: 1px solid var(--primary-color); + border-radius: 0.375rem; + margin-bottom: 5px; + white-space: nowrap; + cursor: pointer; + font-size: 0.9rem; +} + +button:disabled { + opacity: 0.65; +} + +button.primary { + color: #fff; + background-color: var(--primary-color); +} + +button.primary:hover { + color: #fff; + border-color: var(--primary-hover); + background-color: var(--primary-hover); +} + +button.secondary { + color: #fff; + background-color: var(--secondary-color); + border-color: var(--secondary-color); +} + +button.secondary:hover { + color: #fff; + border-color: var(--secondary-hover); + background-color: var(--secondary-hover); +} + +input[type=text], +input[type=number], +select { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: .2rem .75rem; + max-width: 100%; + box-sizing: border-box; +} + +input[type=file] { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + max-width: 100%; +} + +input::file-selector-button { + font-size: 0.9rem; + font-weight: 400; + line-height: 1.5; + border: 1px solid var(--primary-color); + border-radius: 0.375rem; + cursor: pointer; +} + +select { + padding: .3rem 2.25rem .3rem .75rem; +} + +input:focus, +select:focus { + border: 1px solid #86b7fe; + box-shadow: 0 0 4px rgba(0, 120, 215, 0.8); + outline: 0; +} + +label { + margin-right: 4px; + white-space: nowrap; +} + +.status-bar { + display: none; + font-size: 85%; + color: #666; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px dotted #AAA; +} + +@media (max-width: 768px) { + .flex-container { + flex-direction: column; + } + + .left-controls, + .right-controls { + margin-left: 0; + width: 100%; + } + + .canvas-log-container { + flex-direction: column; + } + + #log { + height: 150px; + margin-top: 10px; + } + + fieldset { + padding: 8px; + } + + button { + width: auto; + } + + input[type=text], + input[type=number], + select { + max-width: 100%; + margin-bottom: 5px; + } +} \ No newline at end of file diff --git a/html/v1.5/index.html b/html/v1.5/index.html new file mode 100644 index 0000000..cae241e --- /dev/null +++ b/html/v1.5/index.html @@ -0,0 +1,106 @@ + + + + + + 4.2 寸电子墨水屏蓝牙控制器 + + + + +
+

4.2 寸电子墨水屏蓝牙控制器

+
+ 蓝牙连接 +
+
+ + + +
+
+ + +
+
+ + + +
+
+ + + +
+
+ + +
+
+
+
+ +
+ 蓝牙传图 +
+ +
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
状态:
+
+
+ + +
+
+ +
+ +
+ + + + + + \ No newline at end of file diff --git a/html/v1.5/js/dithering.js b/html/v1.5/js/dithering.js new file mode 100644 index 0000000..9be0141 --- /dev/null +++ b/html/v1.5/js/dithering.js @@ -0,0 +1,212 @@ +const bwrPalette = [ + [0, 0, 0, 255], + [255, 255, 255, 255], + [255, 0, 0, 255] +] + +const bwPalette = [ + [0, 0, 0, 255], + [255, 255, 255, 255], +] + +function dithering(ctx, width, height, threshold, type) { + const bayerThresholdMap = [ + [ 15, 135, 45, 165 ], + [ 195, 75, 225, 105 ], + [ 60, 180, 30, 150 ], + [ 240, 120, 210, 90 ] + ]; + + const lumR = []; + const lumG = []; + const lumB = []; + for (let i=0; i<256; i++) { + lumR[i] = i*0.299; + lumG[i] = i*0.587; + lumB[i] = i*0.114; + } + const imageData = ctx.getImageData(0, 0, width, height); + + const imageDataLength = imageData.data.length; + + // Greyscale luminance (sets r pixels to luminance of rgb) + for (let i = 0; i <= imageDataLength; i += 4) { + imageData.data[i] = Math.floor(lumR[imageData.data[i]] + lumG[imageData.data[i+1]] + lumB[imageData.data[i+2]]); + } + + const w = imageData.width; + let newPixel, err; + + for (let currentPixel = 0; currentPixel <= imageDataLength; currentPixel+=4) { + if (type === "gray") { + const factor = 255 / (threshold - 1); + imageData.data[currentPixel] = Math.round(imageData.data[currentPixel] / factor) * factor; + } else if (type ==="none") { + // No dithering + imageData.data[currentPixel] = imageData.data[currentPixel] < threshold ? 0 : 255; + } else if (type ==="bayer") { + // 4x4 Bayer ordered dithering algorithm + var x = currentPixel/4 % w; + var y = Math.floor(currentPixel/4 / w); + var map = Math.floor( (imageData.data[currentPixel] + bayerThresholdMap[x%4][y%4]) / 2 ); + imageData.data[currentPixel] = (map < threshold) ? 0 : 255; + } else if (type ==="floydsteinberg") { + // Floyda€"Steinberg dithering algorithm + newPixel = imageData.data[currentPixel] < 129 ? 0 : 255; + err = Math.floor((imageData.data[currentPixel] - newPixel) / 16); + imageData.data[currentPixel] = newPixel; + + imageData.data[currentPixel + 4 ] += err*7; + imageData.data[currentPixel + 4*w - 4 ] += err*3; + imageData.data[currentPixel + 4*w ] += err*5; + imageData.data[currentPixel + 4*w + 4 ] += err*1; + } else { + // Bill Atkinson's dithering algorithm + newPixel = imageData.data[currentPixel] < threshold ? 0 : 255; + err = Math.floor((imageData.data[currentPixel] - newPixel) / 8); + imageData.data[currentPixel] = newPixel; + + imageData.data[currentPixel + 4 ] += err; + imageData.data[currentPixel + 8 ] += err; + imageData.data[currentPixel + 4*w - 4 ] += err; + imageData.data[currentPixel + 4*w ] += err; + imageData.data[currentPixel + 4*w + 4 ] += err; + imageData.data[currentPixel + 8*w ] += err; + } + + // Set g and b pixels equal to r + imageData.data[currentPixel + 1] = imageData.data[currentPixel + 2] = imageData.data[currentPixel]; + } + + ctx.putImageData(imageData, 0, 0); +} + +// white: 1, black/red: 0 +function canvas2bytes(canvas, type='bw', invert = false) { + const ctx = canvas.getContext("2d"); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + const arr = []; + let buffer = []; + + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width; x++) { + const i = (canvas.width * y + x) * 4; + if (type !== 'red') { + buffer.push(imageData.data[i] === 0 && imageData.data[i+1] === 0 && imageData.data[i+2] === 0 ? 0 : 1); + } else { + buffer.push(imageData.data[i] > 0 && imageData.data[i+1] === 0 && imageData.data[i+2] === 0 ? 0 : 1); + } + + if (buffer.length === 8) { + const data = parseInt(buffer.join(''), 2); + arr.push(invert ? ~data : data); + buffer = []; + } + } + } + return arr; +} + +function getColorDistance(rgba1, rgba2) { + const [r1, b1, g1] = rgba1; + const [r2, b2, g2] = rgba2; + + const rm = (r1 + r2 ) / 2; + + const r = r1 - r2; + const g = g1 - g2; + const b = b1 - b2; + + return Math.sqrt((2 + rm / 256) * r * r + 4 * g * g + (2 + (255 - rm) / 256) * b * b); +} + +function getNearColor(pixel, palette) { + let minDistance = 255 * 255 * 3 + 1; + let paletteIndex = 0; + + for (let i = 0; i < palette.length; i++) { + const targetColor = palette[i]; + const distance = getColorDistance(pixel, targetColor); + if (distance < minDistance) { + minDistance = distance; + paletteIndex = i; + } + } + + return palette[paletteIndex]; +} + + +function getNearColorV2(color, palette) { + let minDistanceSquared = 255*255 + 255*255 + 255*255 + 1; + + let bestIndex = 0; + for (let i = 0; i < palette.length; i++) { + let rdiff = (color[0] & 0xff) - (palette[i][0] & 0xff); + let gdiff = (color[1] & 0xff) - (palette[i][1] & 0xff); + let bdiff = (color[2] & 0xff) - (palette[i][2] & 0xff); + let distanceSquared = rdiff*rdiff + gdiff*gdiff + bdiff*bdiff; + if (distanceSquared < minDistanceSquared) { + minDistanceSquared = distanceSquared; + bestIndex = i; + } + } + return palette[bestIndex]; + +} + + +function updatePixel(imageData, index, color) { + imageData[index] = color[0]; + imageData[index+1] = color[1]; + imageData[index+2] = color[2]; + imageData[index+3] = color[3]; +} + +function getColorErr(color1, color2, rate) { + const res = []; + for (let i = 0; i < 3; i++) { + res.push(Math.floor((color1[i] - color2[i]) / rate)); + } + return res; +} + +function updatePixelErr(imageData, index, err, rate) { + imageData[index] += err[0] * rate; + imageData[index+1] += err[1] * rate; + imageData[index+2] += err[2] * rate; +} + +function ditheringCanvasByPalette(canvas, palette, type) { + palette = palette || bwrPalette; + + const ctx = canvas.getContext('2d'); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const w = imageData.width; + + for (let currentPixel = 0; currentPixel <= imageData.data.length; currentPixel+=4) { + const newColor = getNearColorV2(imageData.data.slice(currentPixel, currentPixel+4), palette); + + if (type === "bwr_floydsteinberg") { + const err = getColorErr(imageData.data.slice(currentPixel, currentPixel+4), newColor, 16); + + updatePixel(imageData.data, currentPixel, newColor); + updatePixelErr(imageData.data, currentPixel +4, err, 7); + updatePixelErr(imageData.data, currentPixel + 4*w - 4, err, 3); + updatePixelErr(imageData.data, currentPixel + 4*w, err, 5); + updatePixelErr(imageData.data, currentPixel + 4*w + 4, err, 1); + } else { + const err = getColorErr(imageData.data.slice(currentPixel, currentPixel+4), newColor, 8); + + updatePixel(imageData.data, currentPixel, newColor); + updatePixelErr(imageData.data, currentPixel +4, err, 1); + updatePixelErr(imageData.data, currentPixel +8, err, 1); + updatePixelErr(imageData.data, currentPixel +4 * w - 4, err, 1); + updatePixelErr(imageData.data, currentPixel +4 * w, err, 1); + updatePixelErr(imageData.data, currentPixel +4 * w + 4, err, 1); + updatePixelErr(imageData.data, currentPixel +8 * w, err, 1); + } + } + ctx.putImageData(imageData, 0, 0); +} \ No newline at end of file diff --git a/html/v1.5/js/main.js b/html/v1.5/js/main.js new file mode 100644 index 0000000..1971db5 --- /dev/null +++ b/html/v1.5/js/main.js @@ -0,0 +1,369 @@ +let bleDevice, gattServer; +let epdService, epdCharacteristic; +let startTime, msgIndex; +let canvas, ctx, textDecoder; + +const EpdCmd = { + SET_PINS: 0x00, + INIT: 0x01, + CLEAR: 0x02, + SEND_CMD: 0x03, + SEND_DATA: 0x04, + REFRESH: 0x05, + SLEEP: 0x06, + + SET_TIME: 0x20, + + SET_CONFIG: 0x90, + SYS_RESET: 0x91, + SYS_SLEEP: 0x92, + CFG_ERASE: 0x99, +}; + +function resetVariables() { + gattServer = null; + epdService = null; + epdCharacteristic = null; + msgIndex = 0; + document.getElementById("log").value = ''; +} + +async function write(cmd, data, withResponse=true) { + if (!epdCharacteristic) { + addLog("服务不可用,请检查蓝牙连接"); + return false; + } + let payload = [cmd]; + if (data) { + if (typeof data == 'string') data = hex2bytes(data); + if (data instanceof Uint8Array) data = Array.from(data); + payload.push(...data) + } + addLog(` ${bytes2hex(payload)}`); + try { + if (withResponse) + await epdCharacteristic.writeValueWithResponse(Uint8Array.from(payload)); + else + await epdCharacteristic.writeValueWithoutResponse(Uint8Array.from(payload)); + } catch (e) { + console.error(e); + if (e.message) addLog("write: " + e.message); + return false; + } + return true; +} + +async function epdWrite(cmd, data) { + const chunkSize = document.getElementById('mtusize').value - 1; + const interleavedCount = document.getElementById('interleavedcount').value; + const count = Math.round(data.length / chunkSize); + let chunkIdx = 0; + let noReplyCount = interleavedCount; + + if (typeof data == 'string') data = hex2bytes(data); + + await write(EpdCmd.SEND_CMD, [cmd]); + for (let i = 0; i < data.length; i += chunkSize) { + let currentTime = (new Date().getTime() - startTime) / 1000.0; + setStatus(`命令:0x${cmd.toString(16)}, 数据块: ${chunkIdx+1}/${count+1}, 总用时: ${currentTime}s`); + if (noReplyCount > 0) { + await write(EpdCmd.SEND_DATA, data.slice(i, i + chunkSize), false); + noReplyCount--; + } else { + await write(EpdCmd.SEND_DATA, data.slice(i, i + chunkSize), true); + noReplyCount = interleavedCount; + } + chunkIdx++; + } +} + +async function setDriver() { + await write(EpdCmd.SET_PINS, document.getElementById("epdpins").value); + await write(EpdCmd.INIT, document.getElementById("epddriver").value); +} + +async function syncTime(mode) { + const timestamp = new Date().getTime() / 1000; + const data = new Uint8Array([ + (timestamp >> 24) & 0xFF, + (timestamp >> 16) & 0xFF, + (timestamp >> 8) & 0xFF, + timestamp & 0xFF, + -(new Date().getTimezoneOffset() / 60), + mode + ]); + if(await write(EpdCmd.SET_TIME, data)) { + addLog("时间已同步!"); + } +} + +async function clearScreen() { + if(confirm('确认清除屏幕内容?')) { + await write(EpdCmd.CLEAR); + } +} + +async function sendcmd() { + const cmdTXT = document.getElementById('cmdTXT').value; + if (cmdTXT == '') return; + const bytes = hex2bytes(cmdTXT); + await write(bytes[0], bytes.length > 1 ? bytes.slice(1) : null); +} + +async function sendimg() { + const status = document.getElementById("status"); + const driver = document.getElementById("epddriver").value; + const mode = document.getElementById('dithering').value; + + if (mode === '') { + alert('请选择一种取模算法!'); + return; + } + + startTime = new Date().getTime(); + status.parentElement.style.display = "block"; + + if (mode.startsWith('bwr')) { + const invert = (driver === '02') || (driver === '05'); + await epdWrite(driver === "02" ? 0x24 : 0x10, canvas2bytes(canvas, 'bw')); + await epdWrite(driver === "02" ? 0x26 : 0x13, canvas2bytes(canvas, 'red', invert)); + } else { + await epdWrite(driver === "04" ? 0x24 : 0x13, canvas2bytes(canvas, 'bw')); + } + + await write(EpdCmd.REFRESH); + + const sendTime = (new Date().getTime() - startTime) / 1000.0; + addLog(`发送完成!耗时: ${sendTime}s`); + setStatus(`发送完成!耗时: ${sendTime}s`); + setTimeout(() => { + status.parentElement.style.display = "none"; + }, 5000); +} + +function updateButtonStatus() { + const connected = gattServer != null && gattServer.connected; + const status = connected ? null : 'disabled'; + document.getElementById("reconnectbutton").disabled = (gattServer == null || gattServer.connected) ? 'disabled' : null; + document.getElementById("sendcmdbutton").disabled = status; + document.getElementById("calendarmodebutton").disabled = status; + document.getElementById("clockmodebutton").disabled = status; + document.getElementById("clearscreenbutton").disabled = status; + document.getElementById("sendimgbutton").disabled = status; + document.getElementById("setDriverbutton").disabled = status; +} + +function disconnect() { + updateButtonStatus(); + resetVariables(); + addLog('已断开连接.'); + document.getElementById("connectbutton").innerHTML = '连接'; +} + +async function preConnect() { + if (gattServer != null && gattServer.connected) { + if (bleDevice != null && bleDevice.gatt.connected) { + bleDevice.gatt.disconnect(); + } + } + else { + resetVariables(); + try { + bleDevice = await navigator.bluetooth.requestDevice({ + optionalServices: ['62750001-d828-918d-fb46-b6c11c675aec'], + acceptAllDevices: true + }); + } catch (e) { + console.error(e); + if (e.message) addLog("requestDevice: " + e.message); + addLog("请检查蓝牙是否已开启,且使用的浏览器支持蓝牙!建议使用以下浏览器:"); + addLog("• 电脑: Chrome/Edge"); + addLog("• Android: Chrome/Edge"); + addLog("• iOS: Bluefy 浏览器"); + return; + } + + await bleDevice.addEventListener('gattserverdisconnected', disconnect); + setTimeout(async function () { await connect(); }, 300); + } +} + +async function reConnect() { + if (bleDevice != null && bleDevice.gatt.connected) + bleDevice.gatt.disconnect(); + resetVariables(); + addLog("正在重连"); + setTimeout(async function () { await connect(); }, 300); +} + +function handleNotify(value, idx) { + const data = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + if (idx == 0) { + addLog(`收到配置:${bytes2hex(data)}`); + const epdpins = document.getElementById("epdpins"); + const epddriver = document.getElementById("epddriver"); + epdpins.value = bytes2hex(data.slice(0, 7)); + if (data.length > 10) epdpins.value += bytes2hex(data.slice(10, 11)); + epddriver.value = bytes2hex(data.slice(7, 8)); + filterDitheringOptions(); + } else { + if (textDecoder == null) textDecoder = new TextDecoder(); + const msg = textDecoder.decode(data); + addLog(` ${msg}`); + } +} + +async function connect() { + if (bleDevice == null || epdCharacteristic != null) return; + + try { + addLog("正在连接: " + bleDevice.name); + gattServer = await bleDevice.gatt.connect(); + addLog(' 找到 GATT Server'); + epdService = await gattServer.getPrimaryService('62750001-d828-918d-fb46-b6c11c675aec'); + addLog(' 找到 EPD Service'); + epdCharacteristic = await epdService.getCharacteristic('62750002-d828-918d-fb46-b6c11c675aec'); + addLog(' 找到 Characteristic'); + } catch (e) { + console.error(e); + if (e.message) addLog("connect: " + e.message); + disconnect(); + return; + } + + try { + await epdCharacteristic.startNotifications(); + epdCharacteristic.addEventListener('characteristicvaluechanged', (event) => { + handleNotify(event.target.value, msgIndex++); + }); + } catch (e) { + console.error(e); + if (e.message) addLog("startNotifications: " + e.message); + } + + await write(EpdCmd.INIT); + + document.getElementById("connectbutton").innerHTML = '断开'; + updateButtonStatus(); +} + +function setStatus(statusText) { + document.getElementById("status").innerHTML = statusText; +} + +function addLog(logTXT) { + const log = document.getElementById("log"); + const now = new Date(); + const time = String(now.getHours()).padStart(2, '0') + ":" + + String(now.getMinutes()).padStart(2, '0') + ":" + + String(now.getSeconds()).padStart(2, '0') + " "; + log.innerHTML += '' + time + '' + logTXT + '
'; + log.scrollTop = log.scrollHeight; + while ((log.innerHTML.match(/
/g) || []).length > 20) { + var logs_br_position = log.innerHTML.search("
"); + log.innerHTML = log.innerHTML.substring(logs_br_position + 4); + log.scrollTop = log.scrollHeight; + } +} + +function clearLog() { + document.getElementById("log").innerHTML = ''; +} + +function hex2bytes(hex) { + for (var bytes = [], c = 0; c < hex.length; c += 2) + bytes.push(parseInt(hex.substr(c, 2), 16)); + return new Uint8Array(bytes); +} + +function bytes2hex(data) { + return new Uint8Array(data).reduce( + function (memo, i) { + return memo + ("0" + i.toString(16)).slice(-2); + }, ""); +} + +function intToHex(intIn) { + let stringOut = ("0000" + intIn.toString(16)).substr(-4) + return stringOut.substring(2, 4) + stringOut.substring(0, 2); +} + +async function update_image() { + let image = new Image();; + const image_file = document.getElementById('image_file'); + if (image_file.files.length > 0) { + const file = image_file.files[0]; + image.src = URL.createObjectURL(file); + } else { + image.src = document.getElementById('demo-img').src; + } + + image.onload = function(event) { + URL.revokeObjectURL(this.src); + ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height); + convert_dithering() + } +} + +function clear_canvas() { + if(confirm('确认清除画布内容?')) { + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } +} + +function convert_dithering() { + const mode = document.getElementById('dithering').value; + if (mode === '') return; + + if (mode.startsWith('bwr')) { + ditheringCanvasByPalette(canvas, bwrPalette, mode); + } else { + dithering(ctx, canvas.width, canvas.height, parseInt(document.getElementById('threshold').value), mode); + } +} + +function filterDitheringOptions() { + const driver = document.getElementById('epddriver').value; + const dithering = document.getElementById('dithering'); + for (let optgroup of dithering.getElementsByTagName('optgroup')) { + const drivers = optgroup.getAttribute('data-driver').split('|'); + const show = drivers.includes(driver); + for (option of optgroup.getElementsByTagName('option')) { + if (show) + option.removeAttribute('disabled'); + else + option.setAttribute('disabled', 'disabled'); + } + } + dithering.value = ''; +} + +function checkDebugMode() { + const link = document.getElementById('debug-toggle'); + const urlParams = new URLSearchParams(window.location.search); + const debugMode = urlParams.get('debug'); + + if (debugMode === 'true') { + document.body.classList.add('debug-mode'); + link.innerHTML = '正常模式'; + link.setAttribute('href', window.location.pathname); + addLog("注意:开发模式功能已开启!不懂请不要随意修改,否则后果自负!"); + } else { + document.body.classList.remove('debug-mode'); + link.innerHTML = '开发模式'; + link.setAttribute('href', window.location.pathname + '?debug=true'); + } +} + +document.body.onload = () => { + textDecoder = null; + canvas = document.getElementById('canvas'); + ctx = canvas.getContext("2d"); + + updateButtonStatus(); + update_image(); + filterDitheringOptions(); + + checkDebugMode(); +} \ No newline at end of file