初始
This commit is contained in:
31
.gitignore
vendored
31
.gitignore
vendored
@@ -1,11 +1,24 @@
|
|||||||
# ---> Vue
|
# Logs
|
||||||
# gitignore template for Vue.js projects
|
logs
|
||||||
#
|
*.log
|
||||||
# Recommended template: Node.gitignore
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# TODO: where does this rule come from?
|
node_modules
|
||||||
docs/_book
|
dist
|
||||||
|
dist-ssr
|
||||||
# TODO: where does this rule come from?
|
*.local
|
||||||
test/
|
|
||||||
|
|
||||||
|
# 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