初始
This commit is contained in:
31
.gitignore
vendored
31
.gitignore
vendored
@@ -1,11 +1,24 @@
|
||||
# ---> Vue
|
||||
# gitignore template for Vue.js projects
|
||||
#
|
||||
# Recommended template: Node.gitignore
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
docs/_book
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
test/
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>vue</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1995
package-lock.json
generated
Normal file
1995
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "vue",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"vue": "^3.5.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"daisyui": "^5.5.18",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
982
src/App.vue
Normal file
982
src/App.vue
Normal file
@@ -0,0 +1,982 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-300 text-base-content p-4 flex justify-center items-center font-sans">
|
||||
<div class="card w-full max-w-lg bg-base-100 shadow-xl">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="card-title justify-center text-primary text-2xl mb-4">DG-LAB Socket To V2</h2>
|
||||
|
||||
<!-- === 状态指示栏 === -->
|
||||
<div class="flex justify-between items-center bg-base-200 p-3 rounded-lg mb-4 shadow-inner">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="tooltip" :data-tip="bleDeviceName || '未连接'">
|
||||
<span class="badge" :class="bleConnected ? 'badge-success' : 'badge-error'">BLE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="tooltip" :data-tip="wsStatusText">
|
||||
<span class="badge" :class="wsConnected ? 'badge-success' : 'badge-error'">WS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 电池电量 -->
|
||||
<div class="flex items-center gap-1 tooltip tooltip-bottom" :data-tip="'上次刷新: ' + lastBatUpdate">
|
||||
<div class="text-lg">{{ getBatIcon(batteryLevel) }}</div>
|
||||
<span class="font-bold text-sm font-mono">{{ batteryLevel }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === 蓝牙连接 === -->
|
||||
<div v-if="!bleConnected" class="form-control mb-4 transition-all">
|
||||
<button class="btn btn-primary w-full shadow-lg" @click="scanAndConnectBLE" :disabled="isConnectingBLE">
|
||||
<span v-if="isConnectingBLE" class="loading loading-spinner"></span>
|
||||
{{ isConnectingBLE ? '正在建立通道...' : '搜索并连接郊狼 V2' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- === Socket 连接设置 (支持复杂QR码) === -->
|
||||
<div class="collapse collapse-arrow bg-base-200 mb-4 rounded-lg">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title font-medium">
|
||||
📡 远程控制 (扫码/粘贴)
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="form-control gap-3 mt-2">
|
||||
<label class="label pb-0"><span class="label-text">WS链接 / DG-LAB 二维码内容</span></label>
|
||||
|
||||
<div class="join w-full shadow-sm">
|
||||
<input type="text" v-model="wsUrlInput" placeholder="#DGLAB-SOCKET#ws://..." class="input input-bordered join-item w-full text-xs font-mono" />
|
||||
<button class="btn btn-square join-item btn-primary" @click="toggleScanner">
|
||||
<span v-if="!showScanner">📷</span><span v-else>❌</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 扫码区域 -->
|
||||
<div v-if="showScanner" class="w-full mt-2 rounded-lg overflow-hidden border border-base-300 relative bg-black h-56">
|
||||
<div id="reader" class="w-full h-full"></div>
|
||||
<div class="absolute bottom-1 right-2 text-xs text-gray-400 bg-black/50 px-1 rounded pointer-events-none">Scanner Active</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button class="btn btn-secondary flex-1 btn-sm" @click="connectSocket" :disabled="wsConnected">连接 WS</button>
|
||||
<button class="btn btn-warning flex-1 btn-sm" @click="disconnectSocket" :disabled="!wsConnected">断开</button>
|
||||
</div>
|
||||
|
||||
<div class="text-[10px] mt-2 flex justify-between opacity-60 font-mono">
|
||||
<span>Client: {{ wsClientId || 'Waiting...' }}</span>
|
||||
<span>Target: {{ wsTargetId || 'Waiting...' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === 核心控制台 === -->
|
||||
<div class="grid grid-cols-2 gap-1 my-1">
|
||||
|
||||
<!-- A 通道面板 -->
|
||||
<div class="flex flex-col items-center gap-2 p-1.5 bg-base-200/50 rounded-xl border border-secondary/20 shadow-sm">
|
||||
<h3 class="font-bold text-lg text-secondary">Channel A</h3>
|
||||
|
||||
<!-- A 通道波形 - 柱状图 -->
|
||||
<div class="w-full h-20 bg-black rounded border border-secondary/30 overflow-hidden relative shadow-inner">
|
||||
<canvas ref="canvasA" width="200" height="64" class="w-full h-full block"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="radial-progress text-secondary border-4 border-base-200 bg-base-100 mt-2 hover:scale-105 transition-transform"
|
||||
:style="`--value:${calcPercent(dglab.powerLevel[0])}; --size:5rem;`"
|
||||
role="progressbar">
|
||||
<span class="text-xl font-bold font-mono">{{ dglab.powerLevel[0] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 上限滑块 A (修改部分) -->
|
||||
<div class="w-full px-1 mt-1">
|
||||
<label class="label p-0 justify-between items-center mb-1">
|
||||
<span class="label-text-alt text-xs">MaxLevel A</span>
|
||||
<!-- 将原来的 span 替换为 input -->
|
||||
<input type="number"
|
||||
min="0" max="200"
|
||||
v-model.number="maxStrengthA"
|
||||
class="input input-xs input-bordered w-16 text-center font-bold text-secondary focus:border-secondary" />
|
||||
</label>
|
||||
<input type="range" min="0" max="200" v-model.number="maxStrengthA" class="range range-xs range-secondary" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- B 通道面板 -->
|
||||
<div class="flex flex-col items-center gap-2 p-1.5 bg-base-200/50 rounded-xl border border-accent/20 shadow-sm">
|
||||
<h3 class="font-bold text-lg text-accent">Channel B</h3>
|
||||
|
||||
<!-- B 通道波形 - 柱状图 -->
|
||||
<div class="w-full h-20 bg-black rounded border border-accent/30 overflow-hidden relative shadow-inner">
|
||||
<canvas ref="canvasB" width="200" height="64" class="w-full h-full block"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="radial-progress text-accent border-4 border-base-200 bg-base-100 mt-2 hover:scale-105 transition-transform"
|
||||
:style="`--value:${calcPercent(dglab.powerLevel[1])}; --size:5rem;`"
|
||||
role="progressbar">
|
||||
<span class="text-xl font-bold font-mono">{{ dglab.powerLevel[1] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 上限滑块 B (修改部分) -->
|
||||
<div class="w-full px-1 mt-1">
|
||||
<label class="label p-0 justify-between items-center mb-1">
|
||||
<span class="label-text-alt text-xs">MaxLevel B</span>
|
||||
<!-- 将原来的 span 替换为 input -->
|
||||
<input type="number"
|
||||
min="0" max="200"
|
||||
v-model.number="maxStrengthB"
|
||||
class="input input-xs input-bordered w-16 text-center font-bold text-accent focus:border-accent" />
|
||||
</label>
|
||||
<input type="range" min="0" max="200" v-model.number="maxStrengthB" class="range range-xs range-accent" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === 反馈按钮 === -->
|
||||
<div class="divider text-xs opacity-50 my-2">App Feedback Simulation</div>
|
||||
<div class="flex justify-center flex-wrap gap-2">
|
||||
<div class="flex justify-center flex-wrap gap-2">
|
||||
<!-- A通道: i 从 1 到 5,对应 index 为 i-1 (0,1,2,3,4) -->
|
||||
<button v-for="i in 5" :key="`btn-a-${i}`" class="btn btn-xs btn-outline btn-secondary" @click="sendFeedback(i - 1)">
|
||||
A-{{i}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-center flex-wrap gap-2">
|
||||
<!-- B通道: i 从 1 到 5,对应 index 为 i+4 (5,6,7,8,9) -->
|
||||
<button v-for="i in 5" :key="`btn-b-${i}`" class="btn btn-xs btn-outline btn-accent" @click="sendFeedback(i + 4)">
|
||||
B-{{i}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === 日志 === -->
|
||||
<div class="collapse collapse-plus bg-black text-green-500 mt-4 rounded-box border border-base-300">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title text-xs font-mono min-h-[2rem] py-2">
|
||||
> System Logs
|
||||
</div>
|
||||
<div class="collapse-content px-0">
|
||||
<div class="h-32 overflow-y-auto text-[10px] font-mono p-2">
|
||||
<div v-for="(log, index) in logs" :key="index" class="border-b border-white/10 py-1 flex">
|
||||
<span class="opacity-50 mr-2 w-14 shrink-0">{{log.time}}</span>
|
||||
<span class="break-all">{{ log.msg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===安全连接配置提示弹窗 === -->
|
||||
<dialog class="modal" :class="{ 'modal-open': showInsecureModal }">
|
||||
<div class="modal-box border border-warning shadow-warning/20 max-w-2xl">
|
||||
<h3 class="font-bold text-lg text-warning flex items-center gap-2">
|
||||
⚠️ 允许不安全 WebSocket 连接
|
||||
</h3>
|
||||
<div class="py-3 text-sm">
|
||||
<p class="mb-2">检测到您正在 HTTPS 页面下尝试连接 <b>ws://</b> (非加密) 地址。现代浏览器出于安全策略会默认拦截。</p>
|
||||
|
||||
<div class="bg-base-200 p-3 rounded-lg text-xs space-y-3">
|
||||
<p class="font-bold text-base-content/70">请根据您的浏览器复制地址到地址栏回车:</p>
|
||||
|
||||
|
||||
<div class="mt-2 text-warning-content font-mono select-all bg-base-100 border border-base-content/20 p-2 rounded break-all">
|
||||
{{ 'chrome://flags/#unsafely-treat-insecure-origin-as-secure' }}
|
||||
</div>
|
||||
|
||||
<ol class="list-decimal list-inside space-y-2 mt-2">
|
||||
<li>将该选项设置为 <b class="text-success">Enabled</b>。</li>
|
||||
<li>在出现的输入框中,粘贴下方提取的 <b>ws://</b> 目标地址:</li>
|
||||
</ol>
|
||||
|
||||
<!-- 计算出的 WS Origin -->
|
||||
<div class="bg-warning/20 p-3 rounded border border-warning/30">
|
||||
<div class="text-[10px] opacity-60 mb-1 uppercase tracking-wider">Target Origin To Allow:</div>
|
||||
<div class="font-mono text-sm font-bold select-all break-all text-white">
|
||||
{{ insecureOriginList }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] opacity-70">
|
||||
* 设置完成后点击浏览器底部的 <span class="badge badge-xs">Relaunch</span> 重启浏览器即可连接。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog" @submit.prevent="showInsecureModal = false">
|
||||
<button class="btn btn-sm">已了解 / 关闭</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" @submit.prevent="showInsecureModal = false">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onUnmounted, nextTick, watch } from 'vue';
|
||||
import { Html5Qrcode } from "html5-qrcode";
|
||||
|
||||
|
||||
|
||||
// ============================================
|
||||
// 1. 全局状态与日志 (Thread Safety Simulation)
|
||||
// ============================================
|
||||
const logs = ref([{time: 'INIT', msg: 'System Ready.'}]);
|
||||
const addLog = (msg) => {
|
||||
console.log(msg);
|
||||
// 限制日志长度防止内存溢出
|
||||
if (logs.value.length > 50) logs.value.pop();
|
||||
logs.value.unshift({ time: new Date().toLocaleTimeString('en-GB'), msg });
|
||||
};
|
||||
const getBatIcon = (level) => {
|
||||
if(level > 90) return '🔋';
|
||||
if(level > 60) return '🔋';
|
||||
if(level > 30) return '🔋';
|
||||
return '🪫';
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// 2. 蓝牙层 (Producer-Consumer 模式)
|
||||
// ============================================
|
||||
// 蓝牙对象
|
||||
const rawBLE = {
|
||||
device: null,
|
||||
chars: { battery: null, ab: null, a: null, b: null }
|
||||
};
|
||||
const bleConnected = ref(false);
|
||||
const isConnectingBLE = ref(false);
|
||||
const bleDeviceName = ref("");
|
||||
const batteryLevel = ref(0);
|
||||
const lastBatUpdate = ref("-");
|
||||
const dglab = reactive({ powerLevel: [0, 0] });
|
||||
|
||||
// 互斥锁 与 指令队列
|
||||
let isGattBusy = false;
|
||||
const cmdQueue = []; // 命令队列,充当缓冲区
|
||||
|
||||
// ============================================
|
||||
// 3. 视觉层 (UI Thread decoupled)
|
||||
// ============================================
|
||||
// 波形历史数据
|
||||
const visHistory = reactive({ A: [], B: [] });
|
||||
const MAX_VIS_POINTS = 32; // 柱子数量
|
||||
const canvasA = ref(null);
|
||||
const canvasB = ref(null);
|
||||
|
||||
// 通道状态机
|
||||
const channelState = reactive({
|
||||
A: { queue: [], index: 0, active: false, nextTick: 0 },
|
||||
B: { queue: [], index: 0, active: false, nextTick: 0 }
|
||||
});
|
||||
const WAVE_INTERVAL = 100; // V2 脉冲间隔标准 100ms
|
||||
|
||||
// V2 协议映射 (Data + Strength)
|
||||
const parseV3Hex = (v3HexStr) => {
|
||||
if (!v3HexStr || v3HexStr.length < 10) return { payload: new Uint8Array([0,0,0]), strength: 0 };
|
||||
|
||||
// 解析频率和强度
|
||||
const freqVal = parseInt(v3HexStr.substring(0, 2), 16);
|
||||
const strengthVal = parseInt(v3HexStr.substring(8, 10), 16);
|
||||
|
||||
if (strengthVal === 0) return { payload: new Uint8Array([0, 0, 0]), strength: 0 };
|
||||
|
||||
// V2 映射算法 (核心)
|
||||
let z = Math.round(strengthVal / 4);
|
||||
if (z > 31) z = 31; if (z < 1) z = 1;
|
||||
|
||||
let freqHz = freqVal;
|
||||
if (freqHz < 10) freqHz = 10;
|
||||
const periodMs = 1000 / freqHz;
|
||||
|
||||
let x = Math.round(Math.sqrt(freqHz / 1000) * 15);
|
||||
if (x < 1) x = 1; if (x > 31) x = 31;
|
||||
|
||||
let y = Math.round(periodMs - x);
|
||||
if (y < 1) y = 1; if (y > 1023) y = 1023;
|
||||
|
||||
const value = (z << 15) | (y << 5) | x;
|
||||
const arr = new Uint8Array([(value & 0xFF), ((value >> 8) & 0xFF), ((value >> 16) & 0xFF)]);
|
||||
|
||||
return { payload: arr, strength: strengthVal };
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 4. 可视化绘图 (Render "Helper")
|
||||
// ============================================
|
||||
const updateVisualizer = (chStr, strength) => {
|
||||
const list = visHistory[chStr];
|
||||
list.push(strength);
|
||||
if (list.length > MAX_VIS_POINTS) list.shift();
|
||||
|
||||
const cvs = chStr === 'A' ? canvasA.value : canvasB.value;
|
||||
if (!cvs) return;
|
||||
|
||||
// 简单的 diff 检查,不过 Canvas 重绘代价低,这里每 100ms 一次,不是性能瓶颈
|
||||
const ctx = cvs.getContext('2d');
|
||||
const w = cvs.width;
|
||||
const h = cvs.height;
|
||||
|
||||
// 清空
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 基准线
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.moveTo(0, h); ctx.lineTo(w, h);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制柱状图
|
||||
const color = chStr === 'A' ? '#f000b8' : '#37cdbe'; // Pink / Teal
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.shadowColor = color;
|
||||
|
||||
const slotW = w / MAX_VIS_POINTS;
|
||||
const barW = slotW - 2; // 缝隙
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const val = Math.min(list[i], 100);
|
||||
if (val > 0) {
|
||||
const barH = (val / 100) * (h - 2); // 留一点顶空
|
||||
ctx.fillRect(i * slotW + 1, h - barH, barW, barH);
|
||||
}
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
};
|
||||
|
||||
|
||||
// ============================================
|
||||
// 5. Socket 与 QR 解析 (Network Layer)
|
||||
// ============================================
|
||||
const wsConnected = ref(false);
|
||||
const wsStatusText = ref("离线");
|
||||
const wsUrlInput = ref("");
|
||||
const wsClientId = ref("");
|
||||
const wsTargetId = ref("");
|
||||
const maxStrengthA = ref(10);
|
||||
const maxStrengthB = ref(10);
|
||||
const realMaxA = computed(() => maxStrengthA.value);
|
||||
const realMaxB = computed(() => maxStrengthB.value);
|
||||
const calcPercent = (val) => Math.min(100, Math.round((val / 200) * 100));
|
||||
|
||||
let socket = null;
|
||||
let bindMap = {};
|
||||
|
||||
// 监听强度上限变化,一旦改变立即向上同步
|
||||
watch([maxStrengthA, maxStrengthB], () => {
|
||||
syncStatusToSocket();
|
||||
});
|
||||
|
||||
// 🔥 新增:安全弹窗控制
|
||||
const showInsecureModal = ref(false);
|
||||
|
||||
const insecureOriginList = computed(() => {
|
||||
if (!wsUrlInput.value) return "等待输入 WS 地址...";
|
||||
|
||||
// 提取纯净 URL
|
||||
let urlToParse = wsUrlInput.value.trim();
|
||||
// 如果包含 DG-LAB 前缀,先分割
|
||||
if (urlToParse.includes("#DGLAB-SOCKET#")) {
|
||||
const parts = urlToParse.split('#');
|
||||
const found = parts.find(p => p.startsWith('ws://') || p.startsWith('wss://'));
|
||||
if (found) urlToParse = found;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(urlToParse);
|
||||
// 如果不是 ws:// 协议 (例如 wss://),通常不需要配置这个 flag,但如果是 ws:// 则需要
|
||||
if (urlObj.protocol === 'ws:') {
|
||||
// 返回 ws://host:port
|
||||
return urlObj.origin;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// 如果无法解析,返回提示
|
||||
return "请确保输入包含有效的 ws:// 地址";
|
||||
});
|
||||
|
||||
// ★核心解析逻辑:处理 #DGLAB-SOCKET#wss://...
|
||||
const parseWsUrl = (rawInput) => {
|
||||
if (!rawInput) return null;
|
||||
let url = rawInput.trim();
|
||||
let target = "";
|
||||
|
||||
// 格式 1: "https://...#DGLAB-SOCKET#wss://ws.dungeon-lab.cn/UUID"
|
||||
if (url.includes("#DGLAB-SOCKET#")) {
|
||||
const parts = url.split('#');
|
||||
// 寻找以 ws 开头的部分
|
||||
const wsPart = parts.find(p => p.startsWith('ws://') || p.startsWith('wss://'));
|
||||
if (wsPart) {
|
||||
url = wsPart;
|
||||
} else {
|
||||
return null; // 无法识别的格式
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 UUID (TargetId)
|
||||
// 通常在 URL 的最后一段
|
||||
const urlParts = url.split('/');
|
||||
if (urlParts.length > 0) {
|
||||
// 假设 UUID 是类似 136437dd-5043-49d4-82d8-52324b2eed42,长度 36
|
||||
const lastPart = urlParts[urlParts.length - 1];
|
||||
if (lastPart.length > 20) { // 简单检查
|
||||
target = lastPart;
|
||||
}
|
||||
}
|
||||
|
||||
return { finalUrl: url, targetId: target };
|
||||
};
|
||||
|
||||
const connectSocket = () => {
|
||||
const parseResult = parseWsUrl(wsUrlInput.value);
|
||||
if (!parseResult) {
|
||||
addLog("WS URL 格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
const { finalUrl, targetId } = parseResult;
|
||||
wsTargetId.value = targetId;
|
||||
addLog(`连接: ${finalUrl.substring(0,25)}...`);
|
||||
|
||||
if (socket) socket.close();
|
||||
|
||||
try {
|
||||
socket = new WebSocket(finalUrl);
|
||||
} catch(e) {
|
||||
addLog("Socket创建失败");
|
||||
// Chrome 在混合内容(Mixed Content)安全策略下可能直接在构造函数抛错
|
||||
if (finalUrl.startsWith("ws://")) {
|
||||
showInsecureModal.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
wsConnected.value = true;
|
||||
wsStatusText.value = "已连接等待绑定";
|
||||
addLog("WS Connected");
|
||||
resetToZero();
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
addLog("WS Error");
|
||||
wsConnected.value = false;
|
||||
// 如果是异步连接失败,且是 ws:// 开头,大概率被安全策略拦截
|
||||
if (finalUrl.startsWith("ws://")) {
|
||||
console.warn("WS connection failed, possibly due to Mixed Content blocker.");
|
||||
showInsecureModal.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
wsConnected.value = false;
|
||||
wsStatusText.value = "离线";
|
||||
// 🔥 WS 意外或被动断开时,触发重置
|
||||
resetToZero();
|
||||
};
|
||||
socket.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
handleWsMessage(data);
|
||||
} catch(err){ console.error(err); }
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const disconnectSocket = () => {
|
||||
if(socket) socket.close();
|
||||
// 手动断开时触发重置
|
||||
resetToZero();
|
||||
};
|
||||
|
||||
const sendMsg = (type, data = {}) => {
|
||||
addLog(`WS Send: ${type}, Data: ${JSON.stringify(data)}`);
|
||||
if(socket && wsConnected.value) socket.send(JSON.stringify({ type, targetId: wsClientId.value, ...data }));
|
||||
};
|
||||
|
||||
// 消息处理器
|
||||
const handleWsMessage = (data) => {
|
||||
// 1. 握手与绑定
|
||||
if (data.type === 'bind') {
|
||||
if (data.message === 'targetId') {
|
||||
wsClientId.value = data.clientId;
|
||||
// 收到 ClientID 后,如果你有 TargetID(来自扫码),立即发起绑定
|
||||
if(wsTargetId.value) {
|
||||
sendMsg('bind', { clientId: wsClientId.value, targetId: wsTargetId.value, message: 'DGLAB' });
|
||||
}
|
||||
} else if (data.message === '200') {
|
||||
bindMap[wsTargetId.value] = true;
|
||||
wsStatusText.value = "已绑定 (Ready)";
|
||||
addLog("绑定成功");
|
||||
syncStatusToSocket();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 接收控制指令
|
||||
if (data.type === 'msg' && data.message) {
|
||||
const msg = data.message;
|
||||
|
||||
// 停止指令
|
||||
if (msg.startsWith('clear-')) {
|
||||
clearWaveStatus(msg.split('-')[1]);
|
||||
}
|
||||
// 强度指令 (strength-1+2+10) = Channel A, Set/Add/Sub, Value
|
||||
else if(msg.startsWith('strength-')) {
|
||||
try {
|
||||
const parts = msg.substring(9).split('+');
|
||||
const ch = parseInt(parts[0]);
|
||||
const mode = parseInt(parts[1]);
|
||||
const val = parseInt(parts[2]);
|
||||
|
||||
let current = (ch === 1) ? dglab.powerLevel[0] : dglab.powerLevel[1];
|
||||
let target = 0;
|
||||
|
||||
if (mode === 0) target = Math.max(0, current - val); // 减
|
||||
else if (mode === 1) target = current + val; // 加
|
||||
else if (mode === 2) target = val; // 设
|
||||
|
||||
// 将指令推入蓝牙队列
|
||||
setDevicePower((ch===1)?target:dglab.powerLevel[0], (ch===2)?target:dglab.powerLevel[1]);
|
||||
} catch(e) {}
|
||||
}
|
||||
// 波形指令
|
||||
else if(msg.startsWith('pulse-')) {
|
||||
try {
|
||||
const parts = msg.split(':');
|
||||
const chStr = parts[0].split('-')[1];
|
||||
const waveStr = msg.substring(msg.indexOf(':')+1);
|
||||
const wave = JSON.parse(waveStr);
|
||||
if (Array.isArray(wave)) updateWaveData(chStr, wave);
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 心跳
|
||||
if (data.type === 'heartbeat') sendMsg('heartbeat', { message: "DGLAB" });
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 6. QR 扫码逻辑 (Separate Logic)
|
||||
// ============================================
|
||||
const showScanner = ref(false);
|
||||
let html5QrCode = null;
|
||||
|
||||
const toggleScanner = () => {
|
||||
if(showScanner.value) {
|
||||
// 关闭
|
||||
if(html5QrCode) {
|
||||
html5QrCode.stop().then(() => {
|
||||
html5QrCode.clear();
|
||||
showScanner.value = false;
|
||||
}).catch(err => console.error(err));
|
||||
} else {
|
||||
showScanner.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 开启
|
||||
showScanner.value = true;
|
||||
nextTick(() => {
|
||||
html5QrCode = new Html5Qrcode("reader");
|
||||
const config = {
|
||||
fps: 2, // ★ 设置每秒读取2次,节省性能
|
||||
qrbox: 240,
|
||||
aspectRatio: 1.0
|
||||
};
|
||||
|
||||
html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
config,
|
||||
(decodedText, decodedResult) => {
|
||||
// 成功回调
|
||||
const parsed = parseWsUrl(decodedText);
|
||||
if (parsed) {
|
||||
addLog("QR码识别成功");
|
||||
wsUrlInput.value = decodedText; // 显示原始数据给用户看
|
||||
// 自动连接
|
||||
toggleScanner(); // 关闭扫码
|
||||
connectSocket(); // 发起连接
|
||||
}
|
||||
},
|
||||
(errorMessage) => {
|
||||
// 每帧识别失败会在这里,可以忽略
|
||||
}
|
||||
).catch(err => {
|
||||
addLog("相机启动失败");
|
||||
showScanner.value = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 7. 蓝牙核心循环 (The "Main Thread" Loop)
|
||||
// ============================================
|
||||
let loopRunning = false;
|
||||
let statusTimer = null;
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const withTimeout = (p, ms=500) => Promise.race([p, new Promise((_,j)=>setTimeout(()=>j(new Error("Timeout")),ms))]);
|
||||
|
||||
// 封装安全的读写
|
||||
const safeBleWrite = async (char, data) => {
|
||||
if (isGattBusy) throw new Error("BUSY");
|
||||
isGattBusy = true;
|
||||
try { await withTimeout(char.writeValue(data), 300); }
|
||||
finally { isGattBusy = false; }
|
||||
};
|
||||
const safeBleRead = async (char) => {
|
||||
if (isGattBusy) throw new Error("BUSY");
|
||||
isGattBusy = true;
|
||||
try { return await withTimeout(char.readValue(), 300); }
|
||||
finally { isGattBusy = false; }
|
||||
};
|
||||
|
||||
// ★ 主循环:同时处理 波形 和 强度指令
|
||||
const processLoop = async () => {
|
||||
while (loopRunning && bleConnected.value) {
|
||||
try {
|
||||
if (isGattBusy) { await sleep(10); continue; }
|
||||
const now = Date.now();
|
||||
let actionTaken = false;
|
||||
|
||||
// 1. 处理 A 通道波形
|
||||
if (channelState.A.active && channelState.A.queue.length > 0) {
|
||||
if (now >= channelState.A.nextTick) {
|
||||
const item = channelState.A.queue[channelState.A.index];
|
||||
if (rawBLE.chars.a) {
|
||||
try {
|
||||
await safeBleWrite(rawBLE.chars.a, item.payload);
|
||||
updateVisualizer('A', item.strength);
|
||||
|
||||
// 更新时间戳
|
||||
if (channelState.A.nextTick === 0) channelState.A.nextTick = now + WAVE_INTERVAL;
|
||||
else {
|
||||
channelState.A.nextTick += WAVE_INTERVAL;
|
||||
// 防止时间漂移过大
|
||||
if (now - channelState.A.nextTick > 300) channelState.A.nextTick = now + WAVE_INTERVAL;
|
||||
}
|
||||
channelState.A.index = (channelState.A.index + 1) % channelState.A.queue.length;
|
||||
actionTaken = true;
|
||||
} catch (e) { if(e.message!=="BUSY") console.warn("WaveA skip"); }
|
||||
}
|
||||
}
|
||||
} else if (channelState.A.active) {
|
||||
// 队列为空但激活,画空线
|
||||
updateVisualizer('A', 0);
|
||||
}
|
||||
|
||||
// 2. 处理 B 通道波形
|
||||
if (!actionTaken && channelState.B.active && channelState.B.queue.length > 0) {
|
||||
if (now >= channelState.B.nextTick) {
|
||||
const item = channelState.B.queue[channelState.B.index];
|
||||
if (rawBLE.chars.b) {
|
||||
try {
|
||||
await safeBleWrite(rawBLE.chars.b, item.payload);
|
||||
updateVisualizer('B', item.strength);
|
||||
|
||||
if (channelState.B.nextTick === 0) channelState.B.nextTick = now + WAVE_INTERVAL;
|
||||
else {
|
||||
channelState.B.nextTick += WAVE_INTERVAL;
|
||||
if (now - channelState.B.nextTick > 300) channelState.B.nextTick = now + WAVE_INTERVAL;
|
||||
}
|
||||
channelState.B.index = (channelState.B.index + 1) % channelState.B.queue.length;
|
||||
actionTaken = true;
|
||||
} catch (e) { if(e.message!=="BUSY") console.warn("WaveB skip"); }
|
||||
}
|
||||
}
|
||||
} else if (channelState.B.active) {
|
||||
updateVisualizer('B', 0);
|
||||
}
|
||||
|
||||
// 3. 处理常规指令 (从队列取出一个)
|
||||
if (!actionTaken && cmdQueue.length > 0) {
|
||||
const task = cmdQueue[0];
|
||||
const char = rawBLE.chars[task.charKey];
|
||||
|
||||
if (char) {
|
||||
try {
|
||||
if (task.type === 'write') {
|
||||
if(task.tag === 'pwr') addLog(task.desc);
|
||||
await safeBleWrite(char, task.data);
|
||||
} else if (task.type === 'read') {
|
||||
if (task.tag === 'bat') {
|
||||
const val = await safeBleRead(char);
|
||||
batteryLevel.value = val.getUint8(0);
|
||||
lastBatUpdate.value = new Date().toLocaleTimeString();
|
||||
} else if (task.tag === 'status') {
|
||||
const val = await safeBleRead(char); // 读取 3 bytes
|
||||
if (val.byteLength >= 3) {
|
||||
// 24bit data: A(11) | B(11) | Reserved(2) - usually
|
||||
// 实际上 V2: 3 bytes mapping
|
||||
const raw = (val.getUint8(0)) | (val.getUint8(1) << 8) | (val.getUint8(2) << 16);
|
||||
const newA = Math.round(((raw >> 11) & 0x7FF) / 7);
|
||||
const newB = Math.round((raw & 0x7FF) / 7);
|
||||
|
||||
dglab.powerLevel[0] = newA;
|
||||
dglab.powerLevel[1] = newB;
|
||||
syncStatusToSocket();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 成功执行后移除
|
||||
cmdQueue.shift();
|
||||
actionTaken = true;
|
||||
} catch (e) {
|
||||
// 一般 BUSY 或 Timeout 重试,但如果是断连则会抛出 loop 外
|
||||
if (e.message !== "BUSY") {
|
||||
// 如果是严重错误,移除任务防止堵塞
|
||||
cmdQueue.shift();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cmdQueue.shift(); // 特征值不存在
|
||||
}
|
||||
}
|
||||
|
||||
// 动态休眠:如果有动作,休息短一点;如果空闲,休息久一点
|
||||
await sleep(actionTaken ? 15 : 20);
|
||||
|
||||
} catch (e) {
|
||||
isGattBusy = false;
|
||||
// 如果连接断了,在主逻辑处处理
|
||||
if (!bleConnected.value) break;
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 8. 业务逻辑 (Utils)
|
||||
// ============================================
|
||||
const clearWaveStatus = (target) => {
|
||||
const ch = (target == '1' || target == 'A') ? 'A' : 'B';
|
||||
channelState[ch].active = false;
|
||||
channelState[ch].queue = [];
|
||||
channelState[ch].nextTick = 0;
|
||||
updateVisualizer(ch, 0);
|
||||
addLog(`[Stop] Channel ${ch}`);
|
||||
};
|
||||
|
||||
const updateWaveData = (target, hexList) => {
|
||||
if (!bleConnected.value) return;
|
||||
const ch = (target == '1' || target == 'A') ? 'A' : 'B';
|
||||
const parsed = hexList.map(h => parseV3Hex(h));
|
||||
|
||||
channelState[ch].queue = parsed;
|
||||
channelState[ch].index = 0;
|
||||
channelState[ch].active = true;
|
||||
channelState[ch].nextTick = 0; // 重置计时器以便立即开始
|
||||
|
||||
addLog(`>> [PULSE] ${ch} loaded ${parsed.length} frames`);
|
||||
};
|
||||
|
||||
// 🔥 新增:全局重置状态 (归零强度 + 清空波形)
|
||||
const resetToZero = () => {
|
||||
// 1. 逻辑数据归零
|
||||
dglab.powerLevel = [0, 0];
|
||||
|
||||
// 2. 清空波形队列
|
||||
channelState.A.queue = [];
|
||||
channelState.A.active = false;
|
||||
channelState.A.index = 0;
|
||||
|
||||
channelState.B.queue = [];
|
||||
channelState.B.active = false;
|
||||
channelState.B.index = 0;
|
||||
|
||||
// 3. 更新可视化为空
|
||||
updateVisualizer('A', 0);
|
||||
updateVisualizer('B', 0);
|
||||
|
||||
// 4. 如果蓝牙已连接,发送物理归零指令
|
||||
if (bleConnected.value) {
|
||||
// 直接构造归零指令推入队列,不通过 setDevicePower 避免递归逻辑
|
||||
const arr = new Uint8Array([0, 0, 0]);
|
||||
// 清理旧的强度指令,确保归零优先
|
||||
for (let i=cmdQueue.length-1; i>=0; i--) {
|
||||
if (cmdQueue[i].tag === 'pwr') cmdQueue.splice(i, 1);
|
||||
}
|
||||
cmdQueue.push({
|
||||
type: 'write',
|
||||
charKey: 'ab',
|
||||
data: arr,
|
||||
tag: 'pwr',
|
||||
desc: `安全重置: 强度归零`
|
||||
});
|
||||
}
|
||||
|
||||
addLog(">>System Reset (Zero Strength & Clear Waves)<<");
|
||||
};
|
||||
|
||||
// ... existing code (setDevicePower, syncStatusToSocket etc.) ...
|
||||
|
||||
|
||||
|
||||
const setDevicePower = (a, b) => {
|
||||
// 限制
|
||||
const safeA = Math.min(a, realMaxA.value);
|
||||
const safeB = Math.min(b, realMaxB.value);
|
||||
|
||||
// 乐观更新 UI (本地数据源更新)
|
||||
dglab.powerLevel = [safeA, safeB];
|
||||
|
||||
// ============== 新增代码开始 ==============
|
||||
// 强度发生变化,立即向上方 Socket 同步最新状态
|
||||
syncStatusToSocket();
|
||||
// ============== 新增代码结束 ==============
|
||||
|
||||
// 构建指令
|
||||
// A (11bit) | B (11bit)
|
||||
const rawA = safeA * 7;
|
||||
const rawB = safeB * 7;
|
||||
const val = (rawA << 11) | rawB;
|
||||
const arr = new Uint8Array([(val & 0xFF), ((val >> 8) & 0xFF), ((val >> 16) & 0xFF)]);
|
||||
|
||||
// 优化队列:如果有未发送的强度指令,替换之,减少堆积
|
||||
for (let i=cmdQueue.length-1; i>=0; i--) {
|
||||
if (cmdQueue[i].tag === 'pwr') {
|
||||
cmdQueue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
cmdQueue.push({
|
||||
type: 'write',
|
||||
charKey: 'ab',
|
||||
data: arr,
|
||||
tag: 'pwr',
|
||||
desc: `强度 A${safeA} B${safeB}`
|
||||
});
|
||||
};
|
||||
|
||||
// 宽松版同步函数
|
||||
const syncStatusToSocket = () => {
|
||||
// 只要 WebSocket 是连着的,并且我们知道要发给谁 (TargetId),就直接发
|
||||
if(wsConnected.value && wsTargetId.value) {
|
||||
// 构造消息
|
||||
const payload = `strength-${dglab.powerLevel[0]}+${dglab.powerLevel[1]}+${realMaxA.value}+${realMaxB.value}`;
|
||||
|
||||
// 只有当数据真正发送时才打印日志,避免 console 刷屏
|
||||
// console.log("发送同步:", payload);
|
||||
|
||||
sendMsg("msg", { message: payload });
|
||||
}
|
||||
};
|
||||
|
||||
// 🔥 修复:Feedback 发送逻辑
|
||||
const sendFeedback = (index) => {
|
||||
// 必须连接 WS 才能发送反馈
|
||||
if(!wsConnected.value) {
|
||||
addLog("WS未连接,无法发送反馈");
|
||||
return;
|
||||
}
|
||||
|
||||
// 格式必须是 "feedback-x" (x 为 0-9)
|
||||
const msgPayload = `feedback-${index}`;
|
||||
|
||||
sendMsg("msg", { message: msgPayload });
|
||||
addLog(`Feedback sent: ${msgPayload} (Index: ${index})`);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 9. 连接建立
|
||||
// ============================================
|
||||
const scanAndConnectBLE = async () => {
|
||||
isConnectingBLE.value = true;
|
||||
|
||||
// 安全清理旧设备对象
|
||||
if (rawBLE.device) {
|
||||
if (rawBLE.device.gatt.connected) rawBLE.device.gatt.disconnect();
|
||||
rawBLE.device = null;
|
||||
}
|
||||
try {
|
||||
const device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ namePrefix: "D-LAB" }],
|
||||
optionalServices: [
|
||||
"955a180b-0fe2-f5aa-a094-84b8d4f3e8ad", // Service Basic
|
||||
"955a180a-0fe2-f5aa-a094-84b8d4f3e8ad" // Service Bat
|
||||
]
|
||||
});
|
||||
|
||||
device.addEventListener('gattserverdisconnected', onDisconnect);
|
||||
rawBLE.device = device;
|
||||
bleDeviceName.value = device.name;
|
||||
|
||||
addLog("正在连接 GATT...");
|
||||
const server = await device.gatt.connect();
|
||||
// 🔥🔥🔥 关键修复:增加 500ms 延时 🔥🔥🔥
|
||||
// 等待物理连接稳定,防止立即读取服务导致 "GATT Server is disconnected"
|
||||
await sleep(500);
|
||||
if (!server.connected) {
|
||||
throw new Error("连接不稳定,设备已断开,请重试");
|
||||
}
|
||||
|
||||
addLog("获取服务中...");
|
||||
|
||||
// Services
|
||||
const sBasic = await server.getPrimaryService("955a180b-0fe2-f5aa-a094-84b8d4f3e8ad");
|
||||
const sBat = await server.getPrimaryService("955a180a-0fe2-f5aa-a094-84b8d4f3e8ad");
|
||||
|
||||
// Characteristics
|
||||
rawBLE.chars.battery = await sBat.getCharacteristic("955a1500-0fe2-f5aa-a094-84b8d4f3e8ad");
|
||||
// 获取 Basic 下的特征值
|
||||
rawBLE.chars.ab = await sBasic.getCharacteristic("955a1504-0fe2-f5aa-a094-84b8d4f3e8ad");
|
||||
rawBLE.chars.a = await sBasic.getCharacteristic("955a1505-0fe2-f5aa-a094-84b8d4f3e8ad");
|
||||
rawBLE.chars.b = await sBasic.getCharacteristic("955a1506-0fe2-f5aa-a094-84b8d4f3e8ad");
|
||||
bleConnected.value = true;
|
||||
isGattBusy = false;
|
||||
loopRunning = true;
|
||||
|
||||
// 重置可视化
|
||||
visHistory.A = []; visHistory.B = [];
|
||||
for(let i=0;i<MAX_VIS_POINTS;i++) { visHistory.A.push(0); visHistory.B.push(0); }
|
||||
|
||||
addLog("Bluetooth Connected (Ready)");
|
||||
resetToZero();
|
||||
processLoop();
|
||||
|
||||
// 启动后台状态检查 (每2秒同步一次)
|
||||
if(statusTimer) clearInterval(statusTimer);
|
||||
statusTimer = setInterval(() => {
|
||||
if (bleConnected.value) {
|
||||
// 只有在空闲时才读取,避免冲突
|
||||
if (!isGattBusy) {
|
||||
cmdQueue.push({ type:'read', tag:'status', charKey:'ab' });
|
||||
// 10% 概率读电量
|
||||
if (Math.random() < 0.1) cmdQueue.push({ type:'read', tag:'bat', charKey:'battery' });
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
addLog("BLE Connect Fail: " + e.message);
|
||||
// 如果半路失败,清理连接状态
|
||||
if (rawBLE.device && rawBLE.device.gatt.connected) {
|
||||
rawBLE.device.gatt.disconnect();
|
||||
}
|
||||
} finally {
|
||||
isConnectingBLE.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDisconnect = () => {
|
||||
bleConnected.value = false;
|
||||
loopRunning = false;
|
||||
if(statusTimer) clearInterval(statusTimer);
|
||||
addLog("BLE Disconnected");
|
||||
};
|
||||
|
||||
// 生命周期清理
|
||||
onUnmounted(() => {
|
||||
loopRunning = false;
|
||||
if(socket) socket.close();
|
||||
if(html5QrCode) html5QrCode.stop();
|
||||
if(statusTimer) clearInterval(statusTimer);
|
||||
});
|
||||
</script>
|
||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
5
src/main.js
Normal file
5
src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
2
src/style.css
Normal file
2
src/style.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
7
vite.config.js
Normal file
7
vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), vue()],
|
||||
});
|
||||
Reference in New Issue
Block a user