1
0
Files
msu2-usb-monitor-controller/assets/main.js

941 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.addEventListener('DOMContentLoaded', () => {
/** 显示模式列表 */
const DISPLAY_MODES = {
data: {
isDefault: true,
label: '数据显示',
},
spectrum: {
isDefault: false,
label: '频谱显示',
},
};
/** 分辨率配置选项 */
const RESOLUTION_LIST = {
'160x80': {
isDefault: true,
label: '160x80',
lcdX: 160, lcdY: 80,
showWidth: 320, showHeight: 240,
},
'320x172': {
isDefault: false,
label: '320x172',
lcdX: 320, lcdY: 172,
showWidth: 320, showHeight: 240,
},
'320x240': {
isDefault: false,
label: '320x240',
lcdX: 320, lcdY: 240,
showWidth: 320, showHeight: 240,
},
};
/** @type {HTMLButtonElement} */
const connectButton = document.getElementById('connect-button');
/** @type {HTMLButtonElement} */
const disconnectButton = document.getElementById('disconnect-button');
/** @type {HTMLButtonElement} */
const startButton = document.getElementById('start-button');
/** @type {HTMLButtonElement} */
const stopButton = document.getElementById('stop-button');
/** @type {HTMLSelectElement} */
const displayModeSelector = document.getElementById('display-mode-selector');
/** @type {HTMLSelectElement} */
const resolutionSelector = document.getElementById('resolution-selector');
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas-element');
const ctx = canvas.getContext('2d');
/**
* @desc 串口对象
* @type {SerialPort}
*/
let serialPort = null;
/**
* @desc 串口读取器
* @type {ReturnType<typeof serialPort.readable.getReader>}
*/
let serialReader = null;
/**
* @desc 串口写入器
* @type {ReturnType<typeof serialPort.writable.getWriter>}
*/
let serialWriter = null;
/** 连接状态0: 未连接, 1: 已连接 */
let deviceState = 0;
/** 渲染间隔,毫秒 */
let renderInterval = 100;
/** 发送间隔,毫秒 */
let sendInterval = 100;
/** 最后一次渲染的时间戳 */
let lastRenderTime = 0;
/** 最后一次发送的时间戳 */
let lastSendTime = 0;
/** 渲染循环 requestAnimationFrame ID */
let renderAnimationId = null;
/** 发送循环 requestAnimationFrame ID */
let sendAnimationId = null;
/**
* @desc 显示模式
* @type {keyof (typeof DISPLAY_MODES)}
*/
let displayMode = 'data';
// 音频相关变量
/** @type {AudioContext} */
let audioContext = null;
/** @type {ReturnType<typeof audioContext.createAnalyser>} */
let audioAnalyser = null;
/** @type {number} */
let audioBufferLength = null;
/** @type {Uint8Array} */
let audioDataArray = null;
/** @type {ReturnType<typeof audioContext.createMediaStreamSource>} */
let microphone = null;
/** 音频是否已初始化 */
let isAudioReady = false;
// 显示参数
let CANVAS_WIDTH = 0; // 画布显示宽度
let CANVAS_HEIGHT = 0; // 画布显示高度
let LCD_WIDTH = 0; // 设备实际宽度
let LCD_HEIGHT = 0; // 设备实际高度
/** 数据文本渲染类 */
class DataTextRender {
constructor() {
this.monitorData = {};
this.font = '32px Arial';
}
updateDataText() {
let date = new Date();
this.monitorData = {
row_1: {
value: `${String(date.getFullYear()).padStart(2, '0')}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`,
label: '日期',
unit: '',
detail: '',
color: '#FFFF00',
icon: 'I',
},
row_2: {
value: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`,
label: '时间',
unit: '',
detail: '',
color: '#00FFFF',
icon: 'I',
},
row_3: {
value: `${String(date.getFullYear()).padStart(2, '0')}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`,
label: '日期',
unit: '',
detail: '',
color: '#FFFF00',
icon: 'I',
},
row_4: {
value: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`,
label: '时间',
unit: '',
detail: '',
color: '#00FFFF',
icon: 'I',
},
};
return this.monitorData;
}
formatDisplayText(key, showIcon = false) {
let item = this.monitorData[key];
if (!item) {
return '';
}
let icon = showIcon ? `[${item.icon}] ` : '';
let detail = item.detail || '';
if (detail) {
return `${icon}${item.label}: ${item.value}${item.unit} ${detail}`;
} else {
return `${icon}${item.label}: ${item.value}${item.unit}`;
}
}
createDisplayImage() {
// 更新数据
this.updateDataText();
// 清空画布
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// 绘制网格布局
this.drawGridLayout();
}
drawGridLayout() {
let layouts = [
{ key: 'row_1', position: [10, 10] },
{ key: 'row_2', position: [10, 50] },
{ key: 'row_3', position: [10, 90] },
{ key: 'row_4', position: [10, 130] },
];
ctx.font = this.font;
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
layouts.forEach(layout => {
if (this.monitorData[layout.key]) {
let item = this.monitorData[layout.key];
let text = this.formatDisplayText(layout.key, false);
ctx.fillStyle = item['color'];
ctx.fillText(text, layout.position[0], layout.position[1]);
}
});
}
}
/** 数据文本渲染实例 */
const dataTextRender = new DataTextRender();
/** 开始音频分析,初始化音频上下文和麦克风 */
async function audioAnalyserStart() {
try {
// 创建音频上下文
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// 请求麦克风访问
let stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
// 创建麦克风源
microphone = audioContext.createMediaStreamSource(stream);
// 创建分析器
audioAnalyser = audioContext.createAnalyser();
audioAnalyser.fftSize = 256;
audioAnalyser.smoothingTimeConstant = 0.25;
// 连接麦克风到分析器
microphone.connect(audioAnalyser);
// 获取分析器数据
audioBufferLength = audioAnalyser.frequencyBinCount;
audioDataArray = new Uint8Array(audioBufferLength);
// 更新状态
isAudioReady = true;
console.info('音频初始化成功,音频分析已开始');
} catch (error) {
console.error('音频初始化失败:', error);
isAudioReady = false;
}
}
/** 停止音频分析 */
function audioAnalyserStop() {
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (microphone) {
microphone.disconnect();
microphone = null;
}
if (audioAnalyser) {
audioAnalyser.disconnect();
audioAnalyser = null;
}
isAudioReady = false;
console.info('音频分析已停止');
}
/** 将 32 位整数拆分为 4 个字节 */
function convertDigitToInts(di) {
return [(di >> 24) & 0xFF, (di >> 16) & 0xFF, (di >> 8) & 0xFF, di & 0xFF];
}
/**
* @description 将 RGB565 颜色转换为 RGB 颜色
* @param {number} color565 RGB565 颜色值
* @returns {number[]} RGB 颜色值数组,格式:`[r, g, b]`
*/
function convertRgb565ToRgb(color565) {
const r = ((color565 >> 11) & 0x1F) << 3;
const g = ((color565 >> 5) & 0x3F) << 2;
const b = (color565 & 0x1F) << 3;
return [r, g, b];
}
/**
* @description 将 RGB888 转换为 RGB565 格式
* @param {ImageData} imageData 包含 RGB888 数据的 ImageData 对象
* @returns {number[]} 包含 RGB565 数据的数组
*/
function convertRgb888ToRgb565(imageData) {
let rgb565Array = [];
for (let i = 0; i < imageData.data.length; i += 4) {
let r = imageData.data[i];
let g = imageData.data[i + 1];
let b = imageData.data[i + 2];
let rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
rgb565Array.push(rgb565);
}
return rgb565Array;
}
/**
* 对图像数据进行压缩处理,减少传输数据量
* - 基于 `compaction.c` 中的 `MSN_compaction` 算法移植
*/
function convertDisplayData(imageData = []) {
let totalDataSize = imageData.length;
let dataPerPage = 128;
let hexUse = [];
let dataIndex = 0;
// -- 按页处理数据,每页 128 个像素 --
let totalPages = Math.floor(totalDataSize / dataPerPage);
for (let page = 0; page < totalPages; page++) {
let i = 0;
while (i < 128) {
let a = 0;
let a_data = imageData[dataIndex];
// 统计第 1 种颜色的连续出现次数(最多 15 次)
for (let s = 0; s < 15; s++) {
if (i < 128 && a_data === imageData[dataIndex]) {
a++;
i++;
dataIndex++;
} else {
break;
}
}
let b = 0;
let b_data = 0;
// 统计第 2 种颜色的连续出现次数(最多 15 次)
if (i < 128) {
b_data = imageData[dataIndex];
for (let s = 0; s < 15; s++) {
if (i < 128 && b_data === imageData[dataIndex]) {
b++;
i++;
dataIndex++;
} else {
break;
}
}
}
// 输出压缩数据:
// [9, a*16+b, a_data 高字节, a_data 低字节, b_data 高字节, b_data 低字节]
hexUse.push(9);
hexUse.push(a * 16 + b);
hexUse.push((a_data >> 8) & 0xFF);
hexUse.push(a_data & 0xFF);
hexUse.push((b_data >> 8) & 0xFF);
hexUse.push(b_data & 0xFF);
}
}
// -- 处理剩余数据 --
let remainingDataSize = totalDataSize % dataPerPage;
if (remainingDataSize !== 0) {
let remainingData = imageData.slice(totalDataSize - remainingDataSize);
// 补全数据到 128 字节
while (remainingData.length < dataPerPage) {
remainingData.push(0xFFFF);
}
let i = 0;
let dataIndex = 0;
while (i < 128) {
let a = 0;
let a_data = remainingData[dataIndex];
for (let s = 0; s < 15; s++) {
if (i < 128 && a_data === remainingData[dataIndex]) {
a++;
i++;
dataIndex++;
} else {
break;
}
}
let b = 0;
let b_data = 0;
if (i < 128) {
b_data = remainingData[dataIndex];
for (let s = 0; s < 15; s++) {
if (i < 128 && b_data === remainingData[dataIndex]) {
b++;
i++;
dataIndex++;
} else {
break;
}
}
}
hexUse.push(9);
hexUse.push(a * 16 + b);
hexUse.push((a_data >> 8) & 0xFF);
hexUse.push(a_data & 0xFF);
hexUse.push((b_data >> 8) & 0xFF);
hexUse.push(b_data & 0xFF);
}
}
return hexUse;
}
/** 设置 LCD 显示起始坐标 */
function lcdSetDisplayXY(lcdD0 = 0, lcdD1 = 0) {
let hexUse = [];
hexUse.push(2); // LCD 多次写入命令
hexUse.push(0); // 设置起始位置指令
hexUse.push(Math.floor(lcdD0 / 256)); // X 坐标高字节
hexUse.push(lcdD0 % 256); // X 坐标低字节
hexUse.push(Math.floor(lcdD1 / 256)); // Y 坐标高字节
hexUse.push(lcdD1 % 256); // Y 坐标低字节
return hexUse;
}
/** 设置 LCD 显示区域大小 */
function lcdSetDisplaySize(lcdD0 = 0, lcdD1 = 0) {
let hexUse = [];
hexUse.push(2); // LCD 多次写入命令
hexUse.push(1); // 设置大小指令
hexUse.push(Math.floor(lcdD0 / 256)); // 宽度高字节
hexUse.push(lcdD0 % 256); // 宽度低字节
hexUse.push(Math.floor(lcdD1 / 256)); // 高度高字节
hexUse.push(lcdD1 % 256); // 高度低字节
return hexUse;
}
/**
* @description 设置 LCD 显示区域并准备写入数据
* @param {number} lcdX - 显示区域起始 X 坐标
* @param {number} lcdY - 显示区域起始 Y 坐标
* @param {number} lcdW - 显示区域宽度
* @param {number} lcdH - 显示区域高度
*/
async function lcdSetDisplaySend(lcdX = 0, lcdY = 0, lcdW = 0, lcdH = 0) {
let hexUse = [];
hexUse.push(...lcdSetDisplayXY(lcdX, lcdY));
hexUse.push(...lcdSetDisplaySize(lcdW, lcdH));
hexUse.push(2); // LCD 多次写入命令
hexUse.push(3); // 设置指令
hexUse.push(7); // 载入地址
hexUse.push(0, 0, 0);
await sendData(hexUse);
}
/**
* @description 监听串口数据
* @param {typeof serialReader} reader
* @param {TextDecoder} decoder
*/
async function listenSerialData(reader, decoder) {
try {
while (true) {
let { value, done } = await reader.read();
// 端口已关闭
if (done) {
console.info('串口已关闭');
deviceState = 0;
break;
}
// 处理接收到的数据
let data = decoder.decode(value);
console.debug('接收数据:', data);
// 检测是否为 MSN 设备
if (data.length > 5) {
for (let n = 0; n < data.length - 5; n++) {
if (data.charCodeAt(n) === 0) {
if (data.substring(n + 1, n + 4) === 'MSN') {
// 确认是 MSN 设备
console.info('检测到 MSN 设备');
deviceState = 1;
// 检查是否收到连接确认
if (data.substring(n + 1, n + 6) === 'MSNCN') {
console.info('MSN 设备连接完成');
}
break;
}
}
}
}
}
} catch (error) {
console.error('读取串口数据失败:', error);
deviceState = 0;
}
}
/** 循环发送画布内容到设备 */
async function sendCanvas(timestamp = 0) {
// 限制发送频率
if (timestamp - lastSendTime >= sendInterval) {
// 更新时间戳
lastSendTime = timestamp;
// 未连接,结束后续处理
if (!deviceState) {
sendAnimationId = requestAnimationFrame(sendCanvas);
return;
}
// 创建一个临时画布用于图像缩放
let tempCanvas = document.createElement('canvas');
let tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = LCD_WIDTH;
tempCanvas.height = LCD_HEIGHT;
// 将原始画布内容缩放到设备实际分辨率
tempCtx.drawImage(canvas, 0, 0, LCD_WIDTH, LCD_HEIGHT);
// 获取缩放后的图像数据
let imageData = tempCtx.getImageData(0, 0, LCD_WIDTH, LCD_HEIGHT);
// 转换为 RGB565 格式
let rgb565 = convertRgb888ToRgb565(imageData);
// 压缩数据
let hexUse = convertDisplayData(rgb565);
// 设置显示区域
await lcdSetDisplaySend(0, 0, LCD_WIDTH, LCD_HEIGHT);
// 发送数据到设备
await sendData(hexUse);
}
// 循环发送
sendAnimationId = requestAnimationFrame(sendCanvas);
}
/** 发送数据到设备 */
async function sendData(dataArray = []) {
if (!serialWriter) {
return;
}
try {
let data = new Uint8Array(dataArray);
await serialWriter.write(data);
console.debug('发送数据成功:', dataArray.length, '字节');
} catch (error) {
console.error('发送数据失败:', error);
}
}
/** 发送文本到设备 */
async function sendText(text = '') {
if (!serialWriter) {
return;
}
try {
// 构建消息
let data = new Uint8Array(text.length + 1);
// 添加消息头
data[0] = 0x00;
// 处理消息内容
for (let i = 0; i < text.length; i++) {
data[i + 1] = text.charCodeAt(i);
}
await serialWriter.write(data);
console.debug('发送文本成功:', text);
} catch (error) {
console.error('发送文本失败:', error);
}
}
/** 循环渲染画布内容 */
async function renderCanvas(timestamp = 0) {
// 限制渲染频率
if (timestamp - lastRenderTime >= renderInterval) {
// 更新时间戳
lastRenderTime = timestamp;
// 更新画布内容
switch (displayMode) {
case 'data':
await renderDataText();
break;
case 'spectrum':
await renderAudioSpectrum();
break;
default:
break;
}
}
// 循环渲染
renderAnimationId = requestAnimationFrame(renderCanvas);
}
/** 渲染音频频谱内容 */
async function renderAudioSpectrum() {
// 初始化音频
if (!isAudioReady) {
await audioAnalyserStart();
}
// 清空画布
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
if (!audioAnalyser) {
ctx.fillStyle = 'white';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText('音频未初始化', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2);
return;
}
// 获取频谱数据
audioAnalyser.getByteFrequencyData(audioDataArray);
// 计算柱状图宽度和间距
let barWidth = (CANVAS_WIDTH / audioBufferLength) * 2.5;
let x = 0;
// 绘制频谱柱状图
for (let i = 0; i < audioBufferLength; i++) {
// 计算柱状图高度
let barHeight = (audioDataArray[i] / 255) * CANVAS_HEIGHT;
// 生成渐变颜色
let r = Math.floor((i / audioBufferLength) * 255);
let g = Math.floor((audioDataArray[i] / 255) * 255);
let b = 150;
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
// 绘制柱状图
ctx.fillRect(x, CANVAS_HEIGHT - barHeight, barWidth, barHeight);
// 移动到下一个柱状图位置
x += barWidth + 1;
}
}
/** 渲染数据文本内容 */
async function renderDataText() {
// 更新画布内容
dataTextRender.createDisplayImage();
}
// 初始化下拉选项
(function () {
let displayModeElements = [];
let resolutionElements = [];
// 显示模式
for (let key in DISPLAY_MODES) {
/** @type {typeof DISPLAY_MODES['data']} */
let item = DISPLAY_MODES[key];
let element = document.createElement('option');
element.value = key;
element.textContent = item.label;
displayModeElements.push(element);
if (item.isDefault) {
element.selected = true;
}
}
// 分辨率
for (let key in RESOLUTION_LIST) {
/** @type {typeof RESOLUTION_LIST['160x80']} */
let item = RESOLUTION_LIST[key];
let element = document.createElement('option');
element.value = key;
element.textContent = item.label;
resolutionElements.push(element);
if (item.isDefault) {
element.selected = true;
// 更新画布属性
canvas.width = item.showWidth;
canvas.height = item.showHeight;
// 更新显示参数
CANVAS_WIDTH = item.showWidth;
CANVAS_HEIGHT = item.showHeight;
LCD_WIDTH = item.lcdX;
LCD_HEIGHT = item.lcdY;
}
}
// 添加到下拉列表
displayModeSelector.append.apply(displayModeSelector, displayModeElements);
resolutionSelector.append.apply(resolutionSelector, resolutionElements);
})();
// 初始化事件
(function () {
// 点击连接设备按钮
connectButton.addEventListener('click', async () => {
try {
// 请求用户授权访问串口设备
serialPort = await navigator.serial.requestPort();
// 打开串口
await serialPort.open({
baudRate: 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'hardware',
});
const encoder = new TextEncoder();
const decoder = new TextDecoder('gbk');
serialReader = serialPort.readable.getReader();
serialWriter = serialPort.writable.getWriter();
// 开始监听串口数据
listenSerialData(serialReader, decoder);
console.info('MSN 设备连接中...');
// 发送连接确认消息
await sendText('MSNCN');
// 连接成功后自动开始发送
setTimeout(() => {
if (!sendAnimationId) {
console.info('自动开始发送');
sendAnimationId = requestAnimationFrame(sendCanvas);
}
}, 100);
} catch (error) {
console.error('连接设备失败:', error);
}
});
// 点击断开连接按钮
disconnectButton.addEventListener('click', async () => {
// 断开连接前自动停止发送
if (sendAnimationId) {
console.info('自动停止发送');
cancelAnimationFrame(sendAnimationId);
sendAnimationId = null;
}
if (serialReader) {
await serialReader.cancel();
serialReader.releaseLock();
serialReader = null;
}
if (serialWriter) {
await serialWriter.close();
serialWriter.releaseLock();
serialWriter = null;
}
if (serialPort) {
await serialPort.close();
serialPort = null;
}
deviceState = 0;
console.info('断开连接成功');
});
// 点击开始渲染按钮
startButton.addEventListener('click', () => {
if (!renderAnimationId) {
console.info('开始渲染');
renderAnimationId = requestAnimationFrame(renderCanvas);
}
});
// 点击停止渲染按钮
stopButton.addEventListener('click', () => {
// 停止循环渲染
if (renderAnimationId) {
console.info('停止渲染');
cancelAnimationFrame(renderAnimationId);
renderAnimationId = null;
}
// 停止音频分析
audioAnalyserStop();
});
// 显示模式选择
displayModeSelector.addEventListener('change', () => {
displayMode = displayModeSelector.value;
let info = DISPLAY_MODES[displayMode];
if (info) {
console.info(`显示模式切换成功: ${info.label}`);
} else {
console.error('显示模式切换失败:模式无效');
displayMode = '';
return;
}
});
// 分辨率选择
resolutionSelector.addEventListener('change', () => {
let value = resolutionSelector.value;
let config = RESOLUTION_LIST[value];
// 更新画布属性
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
// 更新显示参数
CANVAS_WIDTH = config.showWidth;
CANVAS_HEIGHT = config.showHeight;
LCD_WIDTH = config.lcdX;
LCD_HEIGHT = config.lcdY;
// 显示信息
console.info(`分辨率切换成功:${value}`);
});
})();
});