90 Commits

Author SHA1 Message Date
SikongJueluo
28ba709adf fix: 修复数码管时常无法使用的问题 2025-08-22 05:03:01 +08:00
SikongJueluo
6302489f3a feat: 增加示波器探测参数显示,增加旋转编码器按下的功能 2025-08-22 04:05:00 +08:00
SikongJueluo
7d3ef598de fix: 修复示波器修改后无法配置的问题;修复无法生成api的问题;feat: 新增全局控制七段数码管 2025-08-22 02:17:30 +08:00
SikongJueluo
8fbd30e69f fix: 修复一系列bug,包括热启动,前端模板,jtag; feat:修改矩阵键盘样式 2025-08-21 22:59:29 +08:00
SikongJueluo
78dcc5a629 fix: 修改commandID,并修复七段数码管的配置问题 2025-08-21 20:58:23 +08:00
SikongJueluo
e5b492247c fix: 修复Switch会在每次刷新后发送请求 2025-08-21 19:40:19 +08:00
SikongJueluo
e3b7cc4f63 fix: 修复由于基本的通信协议更改不完全导致的无法控制电源与jtag的问题 2025-08-21 19:19:56 +08:00
8ab55f411d fix: 修复编码器需要重新开关才能使用的问题 2025-08-20 21:04:54 +08:00
02af59c37e fix: 修复示波器与数码管无法关闭的问题 2025-08-20 17:20:52 +08:00
0932c8ba75 fix: 尝试修复hub的scantask的key的问题 2025-08-20 16:55:12 +08:00
4c9b9cd3d6 fix: 尝试修复示波器与旋转编码器无法工作的问题 2025-08-20 16:40:38 +08:00
62c16c016d fix: 尝试修复示波器无法关闭的问题 2025-08-20 16:02:58 +08:00
f23a8a9712 fix: 修复示波器WebSocket的问题 2025-08-20 15:28:58 +08:00
ec84eeeaa4 feat: 新增重置控制端的功能;前端可以显示提交记录 fix: 修复资源数据库sha256计算问题;修复资源数据库无法上传的问题 2025-08-20 10:20:30 +08:00
alivender
c8444d1d4e fix: 修改部分外设BASE偏移量;增加WS2812后端监控器;DSO寄 2025-08-20 01:37:27 +08:00
ca0322137b feat: 前端增加提交功能 2025-08-19 21:02:49 +08:00
2aef180ddb feat: 调整路由,实现页面跳转 2025-08-19 17:28:20 +08:00
228e87868d feat: 实现lazy load,加快加载速度;美化界面 2025-08-19 16:15:12 +08:00
3c73aa344a feat: 使用DDR读取Hdmi视频流 2025-08-19 15:20:17 +08:00
7e53b805ae feat: 使用SignalR实时发送示波器数据,并美化示波器界面 2025-08-19 12:55:18 +08:00
1b5b0e28e3 fix: 修复摄像头无法正常启动,以及关闭摄像头会导致后端崩溃的问题 2025-08-18 19:14:05 +08:00
alivender
7265b10870 Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-18 16:15:10 +08:00
alivender
f548462472 fix: 修改示波器后端 2025-08-18 16:15:08 +08:00
283bf2a956 fix: 适配usb摄像头,当似乎没有正常工作 2025-08-18 15:15:41 +08:00
3c52110a2f feat: 迁移信号发生器前端至底栏 2025-08-17 20:30:35 +08:00
cbb83d3dcd fix: 修复旋转编码器地址与控制问题 2025-08-17 17:09:42 +08:00
4a55143b8e feat: 完成前后端旋转编码器的数字孪生 2025-08-17 17:01:42 +08:00
alivender
cbf85165b7 Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-17 14:55:41 +08:00
alivender
fdfc5729ec feat&fix: 完善JPEGClient逻辑,前端新增编码器组件 2025-08-17 14:55:38 +08:00
8e69c96891 fix: 调整进度条的步幅 2025-08-17 14:23:35 +08:00
caa26c729e fix: 前端修复拨码开关第一个无法开关的问题;后端修复进度条停止在3%的问题 2025-08-17 13:46:14 +08:00
55edfd771e fix: 修复进度条的问题 2025-08-17 13:33:11 +08:00
97b86acfa8 fix: 修复拨码开关数字孪生无法正常工作的问题 2025-08-17 12:29:46 +08:00
b6720d867d feat: 实现拨动开关的数字孪生 2025-08-16 16:01:10 +08:00
a2ac1bcb3b fix: 修复比特流下载失败的问题 2025-08-16 15:55:14 +08:00
e61cf96c07 refactor: 使用更简洁的方式进行认证 2025-08-16 14:53:28 +08:00
c974de593a fix: 尝试去修复后端无法停止扫描数码管的问题 2025-08-16 14:21:26 +08:00
9bd3fb29e3 fix: 前后端修复七段数码管无法正常工作的问题 2025-08-16 13:05:01 +08:00
0a1e0982c2 feat: 前端七段数码管添加数字孪生功能 2025-08-16 11:56:27 +08:00
3644c75304 feat: 完成jpeg读取后端 2025-08-15 21:04:50 +08:00
774c9575d4 fix: 修复后端数码管无法正常读取/关闭的问题 2025-08-15 15:25:37 +08:00
a00cc84e48 fix: 修复数据库与SignalR无法连接的问题 2025-08-15 13:02:56 +08:00
6fa7fffa7f fix: 修复网络配置失败的问题 2025-08-14 20:31:43 +08:00
56eeb5dce3 feat: 完成数码管websocket通信 2025-08-14 20:25:32 +08:00
7bfc362b1f feat: 完成七段数码管后端 2025-08-14 15:21:18 +08:00
alivender
0e07a5996a feat: 合并冲突 2025-08-14 15:08:41 +08:00
alivender
4b2afe13db Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-14 15:04:57 +08:00
9af4546a11 fix: 修复调整resource manager接口导致无法过编译的问题 2025-08-14 14:22:06 +08:00
66bc5882af feat: 完成jpeg后端 2025-08-14 14:19:46 +08:00
e5dac3e731 feat: 完成提交作业的后端 2025-08-14 11:38:09 +08:00
24622d30cf feat: 使首页的教程placehold支持中文,同时使markdown编辑器同app主题变化 2025-08-14 11:37:30 +08:00
c4b3a09198 feat: 添加Markdown编辑器 2025-08-13 19:27:09 +08:00
7a59c29e06 feat: 实现可编辑已有的实验 2025-08-13 16:11:06 +08:00
76342553ad feat: 认证管理实时获取token 2025-08-13 14:36:01 +08:00
efcdee2109 chore: 移除无用的库 2025-08-13 14:34:50 +08:00
37156c937a fix: 修复signalR无法认证的问题 2025-08-13 14:32:41 +08:00
6e84953740 refactor: 重新调整exam页面 2025-08-11 17:01:24 +08:00
b09961473e fix: 修复主题无法保存的问题 2025-08-11 16:21:25 +08:00
ed9eacf33f fix: 修复数据库无法正常获取信息的问题 2025-08-11 13:34:21 +08:00
c1d641c20c refactor: 视频流前后端适配 2025-08-11 13:09:30 +08:00
b95a61c532 refactor: 重构数据库相关操作 2025-08-10 20:13:44 +08:00
079004c17d fix: 修复生成api时,缺失main.ts的问题 2025-08-10 20:13:12 +08:00
11ef4dfba6 refactor: 重构videostream; fix: 修复进度条guid无法生成的问题 2025-08-10 20:07:37 +08:00
bbde060d11 feat: 完成基本的Jpeg控制 2025-08-10 20:06:05 +08:00
0547bb5a02 refactor: video stream service; fix: progress tracker guid 2025-08-09 14:09:03 +08:00
771f5e8e9f feat: 完成基本的Jpeg控制 2025-08-08 18:38:16 +08:00
58378851bb fix: 修复progresstracker堆栈溢出的问题 2025-08-08 16:34:31 +08:00
ae50ba3b9f feat: 为Number添加更多处理方式 2025-08-08 16:33:48 +08:00
d2508f6484 feat: 修改视频流后端服务,使其适配jpeg格式 2025-08-07 15:16:18 +08:00
aff9da2a60 feat: 添加下载进度条 2025-08-04 20:00:02 +08:00
alivender
e0ac21d141 feat: 部分修复Hdmi再次启动启动不了的bug 2025-08-04 17:13:50 +08:00
8396b7aaea feat: 支持HDMI关闭传输 2025-08-04 17:00:31 +08:00
alivender
a331494fde add: 完善HDMI输入前后端,现在无法关闭 2025-08-04 16:35:42 +08:00
alivender
e86cd5464e add: 逻辑分析仪可设置采样频率 2025-08-04 14:31:58 +08:00
alivender
04b136117d Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-04 13:27:37 +08:00
alivender
5c87204ef6 feat: 逻辑分析仪深度可用户输入自定义数字 2025-08-04 13:27:35 +08:00
35647d21bb feat: 添加Hdmi视频串流后端 2025-08-04 13:26:20 +08:00
alivender
51b39cee07 add: 添加了HDMI视频流Client 2025-08-04 11:54:58 +08:00
alivender
0bd1ad8a0e add: 添加了960*540分辨率 2025-08-02 21:07:08 +08:00
alivender
f2c7c78b64 feat: JtaggetDR可以一次全部获取到 2025-08-02 16:01:07 +08:00
alivender
2f23ffe482 Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab into dpp 2025-08-02 13:15:07 +08:00
alivender
9904fecbee feat: 统一资源管理 2025-08-02 13:14:01 +08:00
cb229c2a30 fix: 修复jtag边界扫描前后端的bug:无法开始停止,无法通过认证,后端崩溃 2025-08-02 13:10:44 +08:00
alivender
e5f2be616c feat:删除刷新保存功能,大幅提升性能 2025-08-01 20:51:50 +08:00
alivender
2e9e378457 feat: 完善部分jtag边界扫描websocket代码 2025-08-01 20:21:32 +08:00
alivender
9fe0ee959f Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-01 20:00:00 +08:00
9adc5295f8 feat: 使用SignalR来控制jtag边界扫描 2025-08-01 19:55:55 +08:00
alivender
8047987935 Index界面可以隐藏NavBar 2025-08-01 13:40:21 +08:00
alivender
2d77706013 Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-01 12:57:33 +08:00
alivender
c564844673 add: 添加实验列表界面,实验增删完全依赖数据库实现 2025-08-01 12:57:30 +08:00
131 changed files with 26799 additions and 6828 deletions

2
.gitignore vendored
View File

@@ -29,7 +29,7 @@ DebuggerCmd.md
*.ntvs*
*.njsproj
*.sw?
prompt.md
*.tsbuildinfo
# Generated Files

13
TODO.md
View File

@@ -1,13 +0,0 @@
# TODO
1. 后端HTTP视频流
640*480, RGB565
0x0000_0000 + 25800
2. 信号发生器界面导入.dat文件
3. 示波器后端交互、前端界面
4. 逻辑分析仪后端交互、前端界面
5. 前端重构
6. 数据库 —— 用户登录、板卡资源分配、板卡IP地址分配

View File

@@ -7,21 +7,24 @@
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f {
pkgs = import nixpkgs {
pkgs = import nixpkgs {
inherit system;
config.permittedInsecurePackages = ["dotnet-sdk-6.0.428"];
config.permittedInsecurePackages = [
"dotnet-sdk-6.0.428"
"beekeeper-studio-5.2.9"
];
};
});
in
{
devShells = forEachSupportedSystem ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [
packages = with pkgs; [
# Frontend
nodejs
sqlite
sqls
sql-studio
beekeeper-studio
zlib
bash
# Backend
@@ -31,6 +34,8 @@
dotnetCorePackages.sdk_8_0
])
nuget
mono
vlc
# msbuild
omnisharp-roslyn
csharpier
@@ -39,10 +44,10 @@
typescript-language-server
];
shellHook = ''
export PATH=$PATH:
export PATH=$PATH:/home/sikongjueluo/.dotnet/tools
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:${pkgs.zlib}/lib
export DOTNET_ROOT=${pkgs.dotnetCorePackages.sdk_9_0}/share/dotnet
'';
};
});
};

4034
nohup.out Normal file

File diff suppressed because it is too large Load Diff

1015
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,20 +12,22 @@
"gen-api": "npx tsx scripts/GenerateWebAPI.ts"
},
"dependencies": {
"@microsoft/signalr": "^9.0.6",
"@svgdotjs/svg.js": "^3.2.4",
"@tanstack/vue-table": "^8.21.3",
"@types/lodash": "^4.17.16",
"@types/signalr": "^2.4.3",
"@vueuse/core": "^13.5.0",
"async-mutex": "^0.5.0",
"axios": "^1.11.0",
"echarts": "^5.6.0",
"highlight.js": "^11.11.1",
"konva": "^9.3.20",
"lodash": "^4.17.21",
"log-symbols": "^7.0.0",
"lucide-vue-next": "^0.525.0",
"marked": "^12.0.0",
"mathjs": "^14.4.0",
"md-editor-v3": "^5.8.4",
"pinia": "^3.0.1",
"reka-ui": "^2.3.1",
"ts-log": "^2.2.7",

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,42 @@
import { spawn, exec, ChildProcess } from 'child_process';
import { promisify } from 'util';
import fetch from 'node-fetch';
import * as fs from 'fs';
import { spawn, exec, ChildProcess } from "child_process";
import { promisify } from "util";
import fetch from "node-fetch";
import * as fs from "fs";
const execAsync = promisify(exec);
// Windows 支持函数
function getCommand(command: string): string {
// dotnet 在 Windows 上不需要 .cmd 后缀
if (command === 'dotnet') {
return 'dotnet';
if (command === "dotnet") {
return "dotnet";
}
return process.platform === 'win32' ? `${command}.cmd` : command;
return process.platform === "win32" ? `${command}.cmd` : command;
}
function getSpawnOptions() {
return process.platform === 'win32' ? { stdio: 'pipe', shell: true } : { stdio: 'pipe' };
return process.platform === "win32"
? { stdio: "pipe", shell: true }
: { stdio: "pipe" };
}
async function waitForServer(url: string, maxRetries: number = 30, interval: number = 1000): Promise<boolean> {
async function waitForServer(
url: string,
maxRetries: number = 30,
interval: number = 1000,
): Promise<boolean> {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
console.log('✓ Server is ready');
console.log("✓ Server is ready");
return true;
}
} catch (error) {
// Server not ready yet
}
console.log(`Waiting for server... (${i + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, interval));
await new Promise((resolve) => setTimeout(resolve, interval));
}
return false;
}
@@ -40,32 +46,39 @@ let serverProcess: ChildProcess | null = null;
let webProcess: ChildProcess | null = null;
async function startWeb(): Promise<ChildProcess> {
console.log('Starting Vite frontend...');
console.log("Starting Vite frontend...");
return new Promise((resolve, reject) => {
const process = spawn(getCommand('npm'), ['run', 'dev'], getSpawnOptions() as any);
const process = spawn(
getCommand("npm"),
["run", "dev"],
getSpawnOptions() as any,
);
let webStarted = false;
process.stdout?.on('data', (data) => {
process.stdout?.on("data", (data) => {
const output = data.toString();
console.log(`Web: ${output}`);
// 检查 Vite 是否已启动
if ((output.includes('Local:') || output.includes('ready in')) && !webStarted) {
if (
(output.includes("Local:") || output.includes("ready in")) &&
!webStarted
) {
webStarted = true;
resolve(process);
}
});
process.stderr?.on('data', (data) => {
process.stderr?.on("data", (data) => {
console.error(`Web Error: ${data}`);
});
process.on('error', (error) => {
process.on("error", (error) => {
reject(error);
});
process.on('exit', (code, signal) => {
process.on("exit", (code, signal) => {
console.log(`Web process exited with code ${code} and signal ${signal}`);
if (!webStarted) {
reject(new Error(`Web process exited unexpectedly with code ${code}`));
@@ -74,222 +87,211 @@ async function startWeb(): Promise<ChildProcess> {
// 存储进程引用
webProcess = process;
// 超时处理
setTimeout(() => {
if (!webStarted) {
reject(new Error('Web server failed to start within timeout'));
reject(new Error("Web server failed to start within timeout"));
}
}, 30000); // 30秒超时
}, 10000); // 10秒超时
});
}
async function startServer(): Promise<ChildProcess> {
console.log('Starting .NET server...');
console.log("Starting .NET server...");
return new Promise((resolve, reject) => {
const process = spawn(getCommand('dotnet'), ['run', '--property:Configuration=Release'], {
cwd: 'server',
...getSpawnOptions()
} as any);
const process = spawn(
getCommand("dotnet"),
["run", "--property:Configuration=Release"],
{
cwd: "server",
...getSpawnOptions(),
} as any,
);
let serverStarted = false;
process.stdout?.on('data', (data) => {
process.stdout?.on("data", (data) => {
const output = data.toString();
console.log(`Server: ${output}`);
// 检查服务器是否已启动
if (output.includes('Now listening on:') && !serverStarted) {
if (output.includes("Now listening on:") && !serverStarted) {
serverStarted = true;
resolve(process);
}
});
process.stderr?.on('data', (data) => {
process.stderr?.on("data", (data) => {
console.error(`Server Error: ${data}`);
});
process.on('error', (error) => {
process.on("error", (error) => {
reject(error);
});
process.on('exit', (code, signal) => {
console.log(`Server process exited with code ${code} and signal ${signal}`);
process.on("exit", (code, signal) => {
console.log(
`Server process exited with code ${code} and signal ${signal}`,
);
if (!serverStarted) {
reject(new Error(`Server process exited unexpectedly with code ${code}`));
reject(
new Error(`Server process exited unexpectedly with code ${code}`),
);
}
});
// 存储进程引用
serverProcess = process;
// 超时处理
setTimeout(() => {
if (!serverStarted) {
reject(new Error('Server failed to start within timeout'));
reject(new Error("Server failed to start within timeout"));
}
}, 30000); // 30秒超时
}, 10000); // 10秒超时
});
}
async function stopServer(): Promise<void> {
console.log('Stopping server...');
console.log("Stopping server...");
if (!serverProcess) {
console.log('No server process to stop');
console.log("No server process to stop");
return;
}
try {
// 检查进程是否还存在
if (serverProcess.killed || serverProcess.exitCode !== null) {
console.log('✓ Server process already terminated');
console.log("✓ Server process already terminated");
serverProcess = null;
return;
}
// 发送 SIGTERM 信号
const killed = serverProcess.kill('SIGTERM');
const killed = serverProcess.kill("SIGTERM");
if (!killed) {
console.warn('Failed to send SIGTERM to server process');
console.warn("Failed to send SIGTERM to server process");
return;
}
// 等待进程优雅退出
const exitPromise = new Promise<void>((resolve) => {
if (serverProcess) {
serverProcess.on('exit', () => {
console.log('✓ Server stopped gracefully');
resolve();
});
} else {
resolve();
}
});
// 设置超时,如果 3 秒内没有退出则强制终止
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
if (serverProcess && !serverProcess.killed && serverProcess.exitCode === null) {
console.log('Force killing server process...');
serverProcess.kill('SIGKILL');
if (
serverProcess &&
!serverProcess.killed &&
serverProcess.exitCode === null
) {
console.log("Force killing server process...");
serverProcess.kill("SIGKILL");
}
resolve();
}, 3000); // 减少超时时间到3秒
});
await Promise.race([exitPromise, timeoutPromise]);
await Promise.race([timeoutPromise]);
} catch (error) {
console.warn('Warning: Could not stop server process:', error);
console.warn("Warning: Could not stop server process:", error);
} finally {
serverProcess = null;
// 只有在进程可能没有正常退出时才执行清理
// 移除自动清理逻辑,因为正常退出时不需要
}
}
async function stopWeb(): Promise<void> {
console.log('Stopping web server...');
console.log("Stopping web server...");
if (!webProcess) {
console.log('No web process to stop');
console.log("No web process to stop");
return;
}
try {
// 检查进程是否还存在
if (webProcess.killed || webProcess.exitCode !== null) {
console.log('✓ Web process already terminated');
console.log("✓ Web process already terminated");
webProcess = null;
return;
}
// 发送 SIGTERM 信号
const killed = webProcess.kill('SIGTERM');
const killed = webProcess.kill("SIGTERM");
if (!killed) {
console.warn('Failed to send SIGTERM to web process');
console.warn("Failed to send SIGTERM to web process");
return;
}
// 等待进程优雅退出
const exitPromise = new Promise<void>((resolve) => {
if (webProcess) {
webProcess.on('exit', () => {
console.log('✓ Web server stopped gracefully');
resolve();
});
} else {
resolve();
}
});
// 设置超时,如果 3 秒内没有退出则强制终止
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
if (webProcess && !webProcess.killed && webProcess.exitCode === null) {
console.log('Force killing web process...');
webProcess.kill('SIGKILL');
console.log("Force killing web process...");
webProcess.kill("SIGKILL");
}
resolve();
}, 3000); // 减少超时时间到3秒
}, 3000);
});
await Promise.race([exitPromise, timeoutPromise]);
await Promise.race([timeoutPromise]);
} catch (error) {
console.warn('Warning: Could not stop web process:', error);
console.warn("Warning: Could not stop web process:", error);
} finally {
webProcess = null;
// 只有在进程可能没有正常退出时才执行清理
// 移除自动清理逻辑,因为正常退出时不需要
}
}
async function postProcessApiClient(): Promise<void> {
console.log('Post-processing API client...');
console.log("Post-processing API client...");
try {
const filePath = 'src/APIClient.ts';
const filePath = "src/APIClient.ts";
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
throw new Error(`API client file not found: ${filePath}`);
}
// 读取文件内容
let content = fs.readFileSync(filePath, 'utf8');
let content = fs.readFileSync(filePath, "utf8");
// 替换 ArgumentException 中的 message 属性声明
content = content.replace(
/(\s+)message!:\s*string;/g,
'$1declare message: string;'
"$1declare message: string;",
);
content = content.replace(
"{ AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, CancelToken }",
"{ AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type CancelToken }",
);
// 写回文件
fs.writeFileSync(filePath, content, 'utf8');
console.log('✓ API client post-processing completed');
fs.writeFileSync(filePath, content, "utf8");
console.log("✓ API client post-processing completed");
} catch (error) {
throw new Error(`Failed to post-process API client: ${error}`);
}
}
async function generateApiClient(): Promise<void> {
console.log('Generating API client...');
console.log("Generating API client...");
try {
const url = 'http://127.0.0.1:5000/GetAPIClientCode';
const url = "http://127.0.0.1:5000/GetAPIClientCode";
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch API client code: ${response.status} ${response.statusText}`);
throw new Error(
`Failed to fetch API client code: ${response.status} ${response.statusText}`,
);
}
const code = await response.text();
// 写入 APIClient.ts
const filePath = 'src/APIClient.ts';
fs.writeFileSync(filePath, code, 'utf8');
console.log('✓ API client code fetched and written successfully');
const filePath = "src/APIClient.ts";
fs.writeFileSync(filePath, code, "utf8");
console.log("✓ API client code fetched and written successfully");
// 添加后处理步骤
await postProcessApiClient();
@@ -298,35 +300,55 @@ async function generateApiClient(): Promise<void> {
}
}
async function generateSignalRClient(): Promise<void> {
console.log("Generating SignalR TypeScript client...");
try {
const { stdout, stderr } = await execAsync(
"dotnet tsrts --project ./server/server.csproj --output ./src/utils/signalR",
);
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
console.log("✓ SignalR TypeScript client generated successfully");
} catch (error) {
throw new Error(`Failed to generate SignalR client: ${error}`);
}
}
async function main(): Promise<void> {
try {
// Generate SignalR client
await generateSignalRClient();
console.log("✓ SignalR TypeScript client generated successfully");
// Start web frontend first
await startWeb();
console.log('✓ Frontend started');
console.log("✓ Frontend started");
// Wait a bit for frontend to fully initialize
await new Promise(resolve => setTimeout(resolve, 3000));
await new Promise((resolve) => setTimeout(resolve, 3000));
// Start server
await startServer();
console.log('✓ Backend started');
console.log("✓ Backend started");
// Wait for server to be ready (给服务器额外时间完全启动)
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
// Check if swagger endpoint is available
const serverReady = await waitForServer('http://localhost:5000/swagger/v1/swagger.json');
const serverReady = await waitForServer(
"http://localhost:5000/swagger/v1/swagger.json",
);
if (!serverReady) {
throw new Error('Server failed to start within the expected time');
throw new Error("Server failed to start within the expected time");
}
// Generate API client
await generateApiClient();
console.log('✓ API generation completed successfully');
console.log("✓ API generation completed successfully");
} catch (error) {
console.error('❌ Error:', error);
console.error("❌ Error:", error);
process.exit(1);
} finally {
// Always try to stop processes in order: server first, then web
@@ -340,80 +362,68 @@ let isCleaningUp = false;
const cleanup = async (signal: string) => {
if (isCleaningUp) {
console.log('Cleanup already in progress, ignoring signal');
console.log("Cleanup already in progress, ignoring signal");
return;
}
isCleaningUp = true;
console.log(`\nReceived ${signal}, cleaning up...`);
try {
await Promise.all([
stopServer(),
stopWeb()
]);
await Promise.all([stopServer(), stopWeb()]);
} catch (error) {
console.error('Error during cleanup:', error);
console.error("Error during cleanup:", error);
}
// 立即退出,不等待
process.exit(0);
};
process.on('SIGINT', () => cleanup('SIGINT'));
process.on('SIGTERM', () => cleanup('SIGTERM'));
process.on("SIGINT", () => cleanup("SIGINT"));
process.on("SIGTERM", () => cleanup("SIGTERM"));
// 处理未捕获的异常
process.on('uncaughtException', async (error) => {
process.on("uncaughtException", async (error) => {
if (isCleaningUp) return;
console.error('❌ Uncaught exception:', error);
console.error("❌ Uncaught exception:", error);
isCleaningUp = true;
try {
await Promise.all([
stopServer(),
stopWeb()
]);
await Promise.all([stopServer(), stopWeb()]);
} catch (cleanupError) {
console.error('Error during cleanup:', cleanupError);
console.error("Error during cleanup:", cleanupError);
}
process.exit(1);
});
process.on('unhandledRejection', async (reason, promise) => {
process.on("unhandledRejection", async (reason, promise) => {
if (isCleaningUp) return;
console.error('❌ Unhandled rejection at:', promise, 'reason:', reason);
console.error("❌ Unhandled rejection at:", promise, "reason:", reason);
isCleaningUp = true;
try {
await Promise.all([
stopServer(),
stopWeb()
]);
await Promise.all([stopServer(), stopWeb()]);
} catch (cleanupError) {
console.error('Error during cleanup:', cleanupError);
console.error("Error during cleanup:", cleanupError);
}
process.exit(1);
});
main().catch(async (error) => {
if (isCleaningUp) return;
console.error('❌ Unhandled error:', error);
console.error("❌ Unhandled error:", error);
isCleaningUp = true;
try {
await Promise.all([
stopServer(),
stopWeb()
]);
await Promise.all([stopServer(), stopWeb()]);
} catch (cleanupError) {
console.error('Error during cleanup:', cleanupError);
console.error("Error during cleanup:", cleanupError);
}
process.exit(1);
});
});

View File

@@ -35,57 +35,133 @@ public class NumberTest
}
/// <summary>
/// 测试 BytesToUInt64 的正常与异常情况
/// 测试 BytesToUInt64 的正常与异常情况,覆盖不同参数组合
/// </summary>
[Fact]
public void Test_BytesToUInt64()
{
// 正常大端
// 正常大端isLowNumHigh=false
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD, 0xEF, 0x01 };
var result = Number.BytesToUInt64((byte[])bytes.Clone(), false);
var result = Number.BytesToUInt64((byte[])bytes.Clone());
Assert.True(result.IsSuccessful);
Assert.Equal(0x12345678ABCDEF01UL, result.Value);
// 正常小端
// 正常小端isLowNumHigh=true
var bytes2 = new byte[] { 0x01, 0xEF, 0xCD, 0xAB, 0x78, 0x56, 0x34, 0x12 };
var result2 = Number.BytesToUInt64((byte[])bytes2.Clone(), true);
Assert.True(result2.IsSuccessful);
Assert.Equal(0x12345678ABCDEF01UL, result2.Value);
// 异常:长度超限
var result3 = Number.BytesToUInt64(new byte[9], false);
Assert.False(result3.IsSuccessful);
// 长度不足8字节numLength=4大端
var bytes3 = new byte[] { 0x12, 0x34, 0x56, 0x78 };
var result3 = Number.BytesToUInt64((byte[])bytes3.Clone(), 0, 4, false);
Assert.True(result3.IsSuccessful);
Assert.Equal(0x1234567800000000UL, result3.Value);
// 异常:不足8字节
var result4 = Number.BytesToUInt64(new byte[] { 0x01, 0x02 }, false);
Assert.False(result4.IsSuccessful); // BitConverter.ToUInt64 需要8字节
// 长度不足8字节numLength=4小端
var bytes4 = new byte[] { 0x78, 0x56, 0x34, 0x12 };
var result4 = Number.BytesToUInt64((byte[])bytes4.Clone(), 0, 4, true);
Assert.True(result4.IsSuccessful);
Assert.Equal(0x12345678UL, result4.Value);
// numLength=0
var bytes5 = new byte[] { 0x12, 0x34, 0x56, 0x78 };
var result5 = Number.BytesToUInt64((byte[])bytes5.Clone(), 0, 0, false);
Assert.True(result5.IsSuccessful);
Assert.Equal(0UL, result5.Value);
// offset测试
var bytes6 = new byte[] { 0x00, 0x00, 0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD, 0xEF, 0x01 };
var result6 = Number.BytesToUInt64(bytes6, 2, 8, false);
Assert.True(result6.IsSuccessful);
Assert.Equal(0x12345678ABCDEF01UL, result6.Value);
// numLength超限>8应返回异常
var bytes7 = new byte[9];
var result7 = Number.BytesToUInt64(bytes7, 0, 9, false);
Assert.False(result7.IsSuccessful);
// offset+numLength超限
var bytes8 = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var result8 = Number.BytesToUInt64(bytes8, 2, 4, false);
Assert.True(result8.IsSuccessful);
Assert.Equal(0x0304000000000000UL, result8.Value);
// bytes长度不足offset+numLength
var bytes9 = new byte[] { 0x01, 0x02 };
var result9 = Number.BytesToUInt64(bytes9, 1, 2, true);
Assert.True(result9.IsSuccessful);
Assert.Equal(0x02UL, result9.Value);
// 空数组
var result10 = Number.BytesToUInt64(new byte[0], 0, 0, false);
Assert.True(result10.IsSuccessful);
Assert.Equal(0UL, result10.Value);
}
/// <summary>
/// 测试 BytesToUInt32 的正常与异常情况
/// 测试 BytesToUInt32 的正常与异常情况,覆盖不同参数组合
/// </summary>
[Fact]
public void Test_BytesToUInt32()
{
// 正常大端
// 正常大端isLowNumHigh=false
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 };
var result = Number.BytesToUInt32((byte[])bytes.Clone(), false);
var result = Number.BytesToUInt32((byte[])bytes.Clone());
Assert.True(result.IsSuccessful);
Assert.Equal(0x12345678U, result.Value);
// 正常小端
// 正常小端isLowNumHigh=true
var bytes2 = new byte[] { 0x78, 0x56, 0x34, 0x12 };
var result2 = Number.BytesToUInt32((byte[])bytes2.Clone(), true);
Assert.True(result2.IsSuccessful);
Assert.Equal(0x12345678U, result2.Value);
// 异常:长度超限
var result3 = Number.BytesToUInt32(new byte[5], false);
Assert.False(result3.IsSuccessful);
// 长度不足4字节numLength=2大端
var bytes3 = new byte[] { 0x12, 0x34 };
var result3 = Number.BytesToUInt32((byte[])bytes3.Clone(), 0, 2, false);
Assert.True(result3.IsSuccessful);
Assert.Equal(0x12340000U, result3.Value);
// 异常:不足4字节
var result4 = Number.BytesToUInt32(new byte[] { 0x01, 0x02 }, false);
Assert.False(result4.IsSuccessful); // BitConverter.ToUInt32 需要4字节
// 长度不足4字节numLength=2小端
var bytes4 = new byte[] { 0x34, 0x12 };
var result4 = Number.BytesToUInt32((byte[])bytes4.Clone(), 0, 2, true);
Assert.True(result4.IsSuccessful);
Assert.Equal(0x1234U, result4.Value);
// numLength=0
var bytes5 = new byte[] { 0x12, 0x34, 0x56, 0x78 };
var result5 = Number.BytesToUInt32((byte[])bytes5.Clone(), 0, 0, false);
Assert.True(result5.IsSuccessful);
Assert.Equal(0U, result5.Value);
// offset测试
var bytes6 = new byte[] { 0x00, 0x00, 0x12, 0x34, 0x56, 0x78 };
var result6 = Number.BytesToUInt32(bytes6, 2, 4, false);
Assert.True(result6.IsSuccessful);
Assert.Equal(0x12345678U, result6.Value);
// numLength超限>4应返回异常
var bytes7 = new byte[5];
var result7 = Number.BytesToUInt32(bytes7, 0, 5, false);
Assert.False(result7.IsSuccessful);
// offset+numLength超限
var bytes8 = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var result8 = Number.BytesToUInt32(bytes8, 2, 2, false);
Assert.True(result8.IsSuccessful);
Assert.Equal(0x03040000U, result8.Value);
// bytes长度不足offset+numLength
var bytes9 = new byte[] { 0x01, 0x02 };
var result9 = Number.BytesToUInt32(bytes9, 1, 1, true);
Assert.True(result9.IsSuccessful);
Assert.Equal(0x02U, result9.Value);
// 空数组
var result10 = Number.BytesToUInt32(new byte[0], 0, 0, false);
Assert.True(result10.IsSuccessful);
Assert.Equal(0U, result10.Value);
}
/// <summary>
@@ -283,4 +359,28 @@ public class NumberTest
var reversed2 = Number.ReverseBits(new byte[0]);
Assert.Empty(reversed2);
}
/// <summary>
/// 测试 GetLength
/// </summary>
[Fact]
public void Test_GetLength()
{
Assert.Equal(5, Number.GetLength(12345));
Assert.Equal(4, Number.GetLength(-123));
Assert.Equal(1, Number.GetLength(0));
}
/// <summary>
/// 测试 IntPow
/// </summary>
[Fact]
public void Test_IntPow()
{
Assert.Equal(8, Number.IntPow(2, 3));
Assert.Equal(1, Number.IntPow(5, 0));
Assert.Equal(0, Number.IntPow(0, 5));
Assert.Equal(7, Number.IntPow(7, 1));
Assert.Equal(81, Number.IntPow(3, 4));
}
}

View File

@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.SignalR;
using Moq;
using server.Hubs;
using server.Services;
public class ProgressTrackerTest
{
[Fact]
public void Test_ProgressReporter_Basic()
{
int reportedValue = -1;
var reporter = new ProgressReporter(async v => { reportedValue = v; await Task.CompletedTask; }, 0, 100, 10);
// Report
reporter.Report(50);
Assert.Equal(50, reporter.Progress);
Assert.Equal(ProgressStatus.InProgress, reporter.Status);
Assert.Equal(50, reportedValue);
// Increase by step
reporter.Increase();
Assert.Equal(60, reporter.Progress);
// Increase by value
reporter.Increase(20);
Assert.Equal(80, reporter.Progress);
// Finish
reporter.Finish();
Assert.Equal(ProgressStatus.Completed, reporter.Status);
Assert.Equal(100, reporter.Progress);
// Cancel
reporter = new ProgressReporter(async v => { reportedValue = v; await Task.CompletedTask; }, 0, 100, 10);
reporter.Cancel();
Assert.Equal(ProgressStatus.Canceled, reporter.Status);
Assert.Equal("User Cancelled", reporter.ErrorMessage);
// Error
reporter = new ProgressReporter(async v => { reportedValue = v; await Task.CompletedTask; }, 0, 100, 10);
reporter.Error("Test Error");
Assert.Equal(ProgressStatus.Failed, reporter.Status);
Assert.Equal("Test Error", reporter.ErrorMessage);
// CreateChild
var parent = new ProgressReporter(async v => { await Task.CompletedTask; }, 10, 100, 5);
var child = parent.CreateChild(50, 5);
Assert.Equal(ProgressStatus.Pending, child.Status);
Assert.NotNull(child);
// Child Increase
child.Increase();
Assert.Equal(ProgressStatus.InProgress, child.Status);
Assert.Equal(20, child.ProgressPercent);
Assert.Equal(20, parent.Progress);
// Child Complete
child.Finish();
Assert.Equal(ProgressStatus.Completed, child.Status);
Assert.Equal(100, child.ProgressPercent);
Assert.Equal(60, parent.Progress);
}
[Fact]
public void Test_ProgressTrackerService_Basic()
{
// Mock SignalR HubContext
var mockHubContext = new Mock<IHubContext<ProgressHub, IProgressReceiver>>();
var service = new ProgressTrackerService(mockHubContext.Object);
// CreateTask
var (taskId, reporter) = service.CreateTask();
Assert.NotNull(taskId);
Assert.NotNull(reporter);
// GetReporter
var optReporter = service.GetReporter(taskId);
Assert.True(optReporter.HasValue);
Assert.Equal(reporter, optReporter.Value);
// GetProgressStatus
var optStatus = service.GetProgressStatus(taskId);
Assert.True(optStatus.HasValue);
Assert.Equal(ProgressStatus.Pending, optStatus.Value);
// BindTask
var bindResult = service.BindTask(taskId, "conn1");
Assert.True(bindResult);
// CancelTask
var cancelResult = service.CancelTask(taskId);
Assert.True(cancelResult);
// After cancel, status should be Cancelled
var optStatus2 = service.GetProgressStatus(taskId);
Assert.True(optStatus2.HasValue);
Assert.Equal(ProgressStatus.Canceled, optStatus2.Value);
}
}

View File

@@ -11,6 +11,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

3
server/.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Generate
obj
bin
bitstream
bsdl
data

View File

@@ -5,13 +5,13 @@ using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using NJsonSchema.CodeGeneration.TypeScript;
using NLog;
using NLog.Web;
using NSwag;
using NSwag.CodeGeneration.TypeScript;
using NSwag.Generation.Processors.Security;
using server.Services;
using TypedSignalR.Client.DevTools;
// Early init of NLog to allow startup and exception logging, before host is built
var logger = NLog.LogManager.Setup()
@@ -62,8 +62,42 @@ try
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("my secret key 1234567890my secret key 1234567890")),
};
options.Authority = $"http://{Global.localhost}:5000";
options.Authority = $"http://{Global.LocalHost}:5000";
options.RequireHttpsMetadata = false;
// We have to hook the OnMessageReceived event in order to
// allow the JWT authentication handler to read the access
// token from the query string when a WebSocket or
// Server-Sent Events request comes in.
// Sending the access token in the query string is required when using WebSockets or ServerSentEvents
// due to a limitation in Browser APIs. We restrict it to only calls to the
// SignalR hub in this code.
// See https://docs.microsoft.com/aspnet/core/signalr/security#access-token-logging
// for more information about security considerations when using
// the query string to transmit the access token.
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
// If the request is for our hub...
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && (
path.StartsWithSegments("/hubs/JtagHub") ||
path.StartsWithSegments("/hubs/ProgressHub") ||
path.StartsWithSegments("/hubs/DigitalTubesHub") ||
path.StartsWithSegments("/hubs/RotaryEncoderHub") ||
path.StartsWithSegments("/hubs/OscilloscopeHub")
))
{
// Read the token out of the query string
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
// Add JWT Token Authorization Policy
builder.Services.AddAuthorization(options =>
@@ -71,7 +105,7 @@ try
options.AddPolicy("Admin", policy =>
{
policy.RequireClaim(ClaimTypes.Role, new string[] {
Database.User.UserPermission.Admin.ToString(),
Database.UserPermission.Admin.ToString(),
});
});
});
@@ -95,8 +129,17 @@ try
.AllowAnyMethod()
.AllowAnyHeader()
);
options.AddPolicy("SignalR", policy => policy
.WithOrigins([$"http://{Global.LocalHost}:5173", "http://127.0.0.1:5173"])
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
);
});
// Use SignalR
builder.Services.AddSignalR();
// Add Swagger
builder.Services.AddSwaggerDocument(options =>
{
@@ -132,10 +175,14 @@ try
options.OperationProcessors.Add(new OperationSecurityScopeProcessor("Bearer"));
});
// 添加 HTTP 视频流服务
builder.Services.AddSingleton<HttpVideoStreamService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpVideoStreamService>());
builder.Services.AddSingleton<HttpHdmiVideoStreamService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpHdmiVideoStreamService>());
// 添加进度跟踪服务
builder.Services.AddSingleton<ProgressTracker>();
// Application Settings
var app = builder.Build();
@@ -168,6 +215,17 @@ try
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "log")),
RequestPath = "/log"
});
// Exam Files (实验静态资源)
if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), "exam")))
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "exam")),
RequestPath = "/exam"
});
}
app.MapFallbackToFile("index.html");
}
@@ -183,36 +241,35 @@ try
settings.PostProcess = (document, httpRequest) =>
{
document.Servers.Clear();
document.Servers.Add(new NSwag.OpenApiServer { Url = $"http://{Global.localhost}:5000" });
document.Servers.Add(new NSwag.OpenApiServer { Url = $"http://{Global.LocalHost}:5000" });
};
});
app.UseSwaggerUi();
// SignalR
app.UseWebSockets();
app.UseSignalRHubSpecification();
app.UseSignalRHubDevelopmentUI();
// Router
app.MapControllers();
app.MapHub<server.Hubs.JtagHub>("/hubs/JtagHub");
app.MapHub<server.Hubs.ProgressHub>("/hubs/ProgressHub");
app.MapHub<server.Hubs.DigitalTubesHub>("/hubs/DigitalTubesHub");
app.MapHub<server.Hubs.RotaryEncoderHub>("/hubs/RotaryEncoderHub");
app.MapHub<server.Hubs.OscilloscopeHub>("/hubs/OscilloscopeHub");
// Setup Program
MsgBus.Init();
// 扫描并更新实验数据库
try
{
using var db = new Database.AppDataConnection();
var examFolderPath = Path.Combine(Directory.GetCurrentDirectory(), "exam");
var updateCount = db.ScanAndUpdateExams(examFolderPath);
logger.Info($"实验数据库扫描完成,更新了 {updateCount} 个实验");
}
catch (Exception ex)
{
logger.Error($"扫描实验文件夹时出错: {ex.Message}");
}
var progressTracker = app.Services.GetRequiredService<ProgressTracker>();
MsgBus.SetProgressTracker(progressTracker);
// Generate API Client
app.MapGet("GetAPIClientCode", async (HttpContext context) =>
{
try
{
var document = await OpenApiDocument.FromUrlAsync($"http://{Global.localhost}:5000/swagger/v1/swagger.json");
var document = await OpenApiDocument.FromUrlAsync($"http://{Global.LocalHost}:5000/swagger/v1/swagger.json");
var settings = new TypeScriptClientGeneratorSettings
{
@@ -233,7 +290,7 @@ try
logger.Error(err);
return Results.Problem(err.ToString());
}
});
}).RequireCors("Development");
app.Run();
}
@@ -254,4 +311,3 @@ finally
// Close Program
MsgBus.Exit();
}

View File

@@ -1,37 +0,0 @@
# 实验001基础逻辑门电路
## 实验目的
本实验旨在帮助学生理解基础逻辑门的工作原理,包括与门、或门、非门等基本逻辑运算。
## 实验内容
### 1. 与门AND Gate
与门是一个基本的逻辑门当所有输入都为高电平1输出才为高电平1
### 2. 或门OR Gate
或门是另一个基本的逻辑门当任意一个输入为高电平1输出就为高电平1
### 3. 非门NOT Gate
非门是一个反相器,输入为高电平时输出为低电平,反之亦然。
## 实验步骤
1. 打开 FPGA 开发环境
2. 创建新的项目文件
3. 编写 Verilog 代码实现各种逻辑门
4. 进行仿真验证
5. 下载到 FPGA 板进行硬件验证
## 预期结果
通过本实验,学生应该能够:
- 理解基本逻辑门的真值表
- 掌握 Verilog 代码的基本语法
- 学会使用 FPGA 开发工具进行仿真
## 注意事项
- 确保输入信号的电平正确
- 注意时序的约束
- 验证结果时要仔细对比真值表

View File

@@ -1,35 +0,0 @@
# 实验002组合逻辑电路设计
## 实验目的
本实验旨在让学生学习如何设计和实现复杂的组合逻辑电路,掌握多个逻辑门的组合使用。
## 实验内容
### 1. 半加器设计
设计一个半加器电路,实现两个一位二进制数的加法运算。
### 2. 全加器设计
在半加器的基础上,设计全加器电路,考虑进位输入。
### 3. 编码器和译码器
实现简单的编码器和译码器电路。
## 实验要求
1. 使用 Verilog HDL 编写代码
2. 绘制逻辑电路图
3. 编写测试用例验证功能
4. 分析电路的延时特性
## 评估标准
- 电路功能正确性 (40%)
- 代码质量和规范性 (30%)
- 测试覆盖率 (20%)
- 实验报告 (10%)
## 参考资料
- 数字逻辑设计教材第3-4章
- Verilog HDL 语法参考手册

View File

@@ -11,12 +11,15 @@
<SpaRoot>../</SpaRoot>
<SpaProxyServerUrl>http://localhost:5173</SpaProxyServerUrl>
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
<NoWarn>CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ArpLookup" Version="2.0.3" />
<PackageReference Include="DotNext" Version="5.23.0" />
<PackageReference Include="DotNext.Threading" Version="5.23.0" />
<PackageReference Include="FlashCap" Version="1.11.0" />
<PackageReference Include="H264Sharp" Version="1.6.0" />
<PackageReference Include="Honoo.IO.Hashing.Crc" Version="1.3.3" />
<PackageReference Include="linq2db.AspNet" Version="5.4.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />
@@ -24,14 +27,23 @@
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="9.0.4" />
<PackageReference Include="Microsoft.OpenApi" Version="1.6.23" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.4.0" />
<PackageReference Include="NLog" Version="5.4.0" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.4.0" />
<PackageReference Include="NSwag.AspNetCore" Version="14.3.0" />
<PackageReference Include="NSwag.CodeGeneration.TypeScript" Version="14.4.0" />
<PackageReference Include="OpenCvSharp4" Version="4.11.0.20250507" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.20250507" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
<PackageReference Include="SharpRTSP" Version="1.8.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
<PackageReference Include="Tapper.Analyzer" Version="1.13.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="TypedSignalR.Client.DevTools" Version="1.2.4" />
<PackageReference Include="TypedSignalR.Client.TypeScript.Analyzer" Version="1.15.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="TypedSignalR.Client.TypeScript.Attributes" Version="1.15.0" />
</ItemGroup>
</Project>

View File

@@ -4,7 +4,8 @@ using System.Net.Sockets;
public static class Global
{
public static readonly string localhost = "127.0.0.1";
public static readonly string LocalHost = "127.0.0.1";
public static readonly string DataPath = Path.Combine(Environment.CurrentDirectory, "data");
public static string GetLocalIPAddress()
{

View File

@@ -72,7 +72,7 @@ public class Image
var b8 = (byte)((b5 * 255) / 31); // 5位扩展到8位
// 存储到 RGB24 数组
var rgb24Index = (i%2 == 0)?((i+1) * 3):((i-1) * 3);
var rgb24Index = (i % 2 == 0) ? ((i + 1) * 3) : ((i - 1) * 3);
rgb24Data[rgb24Index] = r8; // R
rgb24Data[rgb24Index + 1] = g8; // G
rgb24Data[rgb24Index + 2] = b8; // B
@@ -255,13 +255,222 @@ public class Image
return Encoding.ASCII.GetBytes("\r\n");
}
/// <summary>
/// 将原始 JPEG 数据补全 JPEG 头部,生成完整的 JPEG 图片
/// </summary>
/// <param name="jpegData">原始 JPEG 扫描数据(不含头尾)</param>
/// <param name="width">图像宽度</param>
/// <param name="height">图像高度</param>
/// <param name="quantizationTable">量化表数组Y0-Y63, Cb0-Cb63, Cr0-Cr63共192个值</param>
/// <returns>完整的 JPEG 图片数据</returns>
public static Result<byte[]> CompleteJpegData(byte[] jpegData, int width, int height, uint[] quantizationTable)
{
if (jpegData == null)
return new(new ArgumentNullException(nameof(jpegData)));
if (width <= 0 || height <= 0)
return new(new ArgumentException("Width and height must be positive"));
if (quantizationTable == null || quantizationTable.Length != 192)
return new(new ArgumentException("Quantization table must contain exactly 192 values (64 Y + 64 Cb + 64 Cr)"));
try
{
var jpegBytes = new List<byte>();
// SOI (Start of Image)
jpegBytes.AddRange(new byte[] { 0xFF, 0xD8 });
// APP0 段
jpegBytes.AddRange(new byte[] {
0xFF, 0xE0, // APP0 marker
0x00, 0x10, // Length (16 bytes)
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
0x01, 0x01, // Version 1.1
0x00, // Units: 0 = no units
0x00, 0x01, // X density (1)
0x00, 0x01, // Y density (1)
0x00, // Thumbnail width
0x00 // Thumbnail height
});
// DQT (Define Quantization Table) - Y table
jpegBytes.AddRange(new byte[] { 0xFF, 0xDB }); // DQT marker
jpegBytes.AddRange(new byte[] { 0x00, 0x43 }); // Length (67 bytes)
jpegBytes.Add(0x00); // Table ID (0 = Y table)
// 添加Y量化表 (quantizationTable[0-63])
for (int i = 0; i < 64; i++)
{
jpegBytes.Add((byte)Math.Min(255, quantizationTable[i]));
}
// DQT (Define Quantization Table) - CbCr table
jpegBytes.AddRange(new byte[] { 0xFF, 0xDB }); // DQT marker
jpegBytes.AddRange(new byte[] { 0x00, 0x43 }); // Length (67 bytes)
jpegBytes.Add(0x01); // Table ID (1 = CbCr table)
// 添加Cb量化表 (quantizationTable[64-127])但这里使用Cr表的数据作为CbCr共用
for (int i = 128; i < 192; i++) // 使用Cr量化表 (quantizationTable[128-191])
{
jpegBytes.Add((byte)Math.Min(255, quantizationTable[i]));
}
// SOF0 (Start of Frame)
jpegBytes.AddRange(new byte[] {
0xFF, 0xC0, // SOF0 marker
0x00, 0x11, // Length (17 bytes)
0x08, // Precision (8 bits)
(byte)((height >> 8) & 0xFF), (byte)(height & 0xFF), // Height
(byte)((width >> 8) & 0xFF), (byte)(width & 0xFF), // Width
0x03, // Number of components
0x01, 0x11, 0x00, // Y component
0x02, 0x11, 0x01, // Cb component
0x03, 0x11, 0x01 // Cr component
});
// DHT (Define Huffman Table) - DC Y table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0x1F, // Length
0x00, // Table class and ID (DC table 0)
// DC Y Huffman table
0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
});
// DHT (Define Huffman Table) - AC Y table (简化版)
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0xB5, // Length
0x10 // Table class and ID (AC table 0)
});
// AC Y Huffman table数据
jpegBytes.AddRange(new byte[] {
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0,
0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7,
0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5,
0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8,
0xF9, 0xFA
});
// DHT (Define Huffman Table) - DC CbCr table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0x1F, // Length
0x01, // Table class and ID (DC table 1)
// DC CbCr Huffman table
0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
});
// DHT (Define Huffman Table) - AC CbCr table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0xB5, // Length
0x11 // Table class and ID (AC table 1)
});
// AC CbCr Huffman table数据与AC Y table相同
jpegBytes.AddRange(new byte[] {
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0,
0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7,
0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5,
0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8,
0xF9, 0xFA
});
// SOS (Start of Scan)
jpegBytes.AddRange(new byte[] {
0xFF, 0xDA, // SOS marker
0x00, 0x0C, // Length (12 bytes)
0x03, // Number of components
0x01, 0x00, // Y component, DC/AC table
0x02, 0x11, // Cb component, DC/AC table
0x03, 0x11, // Cr component, DC/AC table
0x00, 0x3F, 0x00 // Start of spectral, End of spectral, Ah/Al
});
// 添加原始 JPEG 扫描数据
jpegBytes.AddRange(jpegData);
// EOI (End of Image)
jpegBytes.AddRange(new byte[] { 0xFF, 0xD9 });
return jpegBytes.ToArray();
}
catch (Exception ex)
{
return new(ex);
}
}
/// <summary>
/// 从 JPEG 数据生成 MJPEG 帧数据
/// </summary>
/// <param name="jpegData">完整的 JPEG 数据</param>
/// <param name="boundary">边界字符串(默认为"--boundary"</param>
/// <returns>MJPEG 帧数据</returns>
public static Result<(byte[] header, byte[] footer, byte[] data)> CreateMjpegFrameFromJpeg(
byte[] jpegData, string boundary = "--boundary")
{
if (jpegData == null)
return new(new ArgumentNullException(nameof(jpegData)));
// 验证是否为有效的 JPEG 数据
if (jpegData.Length < 4 || jpegData[0] != 0xFF || jpegData[1] != 0xD8)
{
return new(new ArgumentException("Invalid JPEG data: missing JPEG header"));
}
try
{
var header = CreateMjpegFrameHeader(jpegData.Length, boundary);
var footer = CreateMjpegFrameFooter();
var totalLength = header.Length + jpegData.Length + footer.Length;
var frameData = new byte[totalLength];
var offset = 0;
Array.Copy(header, 0, frameData, offset, header.Length);
offset += header.Length;
Array.Copy(jpegData, 0, frameData, offset, jpegData.Length);
offset += jpegData.Length;
Array.Copy(footer, 0, frameData, offset, footer.Length);
return (header, footer, frameData);
}
catch (Exception ex)
{
return new(ex);
}
}
/// <summary>
/// 创建完整的 MJPEG 帧数据
/// </summary>
/// <param name="jpegData">JPEG数据</param>
/// <param name="boundary">边界字符串(默认为"--boundary"</param>
/// <returns>完整的MJPEG帧数据</returns>
public static Result<byte[]> CreateMjpegFrame(byte[] jpegData, string boundary = "--boundary")
public static Result<(byte[] header, byte[] footer, byte[] data)> CreateMjpegFrame(
byte[] jpegData, string boundary = "--boundary")
{
if (jpegData == null)
return new(new ArgumentNullException(nameof(jpegData)));
@@ -283,7 +492,7 @@ public class Image
Array.Copy(footer, 0, frameData, offset, footer.Length);
return frameData;
return (header, footer, frameData);
}
catch (Exception ex)
{

View File

@@ -78,6 +78,56 @@ public class Number
return arr;
}
/// <summary>
/// 二进制字节数组转成64bits整数
/// </summary>
/// <param name="bytes">二进制字节数组</param>
/// <param name="offset">字节数组偏移量</param>
/// <param name="numLength">字节数组长度</param>
/// <param name="isLowNumHigh">是否高位在数组索引低</param>
/// <returns>整数</returns>
public static Result<UInt64> BytesToUInt64(byte[] bytes, int offset = 0, int numLength = 8, bool isLowNumHigh = false)
{
if (bytes.Length < offset)
{
return new(new ArgumentException($"The Length of bytes is less than offset"));
}
if (numLength > 8)
{
return new(new ArgumentException($"The Length of bytes is greater than 8"));
}
if (numLength <= 0) return 0;
try
{
byte[] numBytes = new byte[8]; // 8字节
int copyLen = Math.Min(numLength, bytes.Length - offset);
if (isLowNumHigh)
{
// 小端:拷贝到低位
Buffer.BlockCopy(bytes, offset, numBytes, 0, copyLen);
}
else
{
// 大端:拷贝到高位
byte[] temp = new byte[copyLen];
Buffer.BlockCopy(bytes, offset, temp, 0, copyLen);
Array.Reverse(temp);
Buffer.BlockCopy(temp, 0, numBytes, 8 - copyLen, copyLen);
}
UInt64 num = BitConverter.ToUInt64(numBytes, 0);
return num;
}
catch (Exception error)
{
return new(error);
}
}
/// <summary>
/// 二进制字节数组转成64bits整数
/// </summary>
@@ -86,25 +136,78 @@ public class Number
/// <returns>整数</returns>
public static Result<UInt64> BytesToUInt64(byte[] bytes, bool isLowNumHigh = false)
{
if (bytes.Length > 8)
return BytesToUInt64(bytes, 0, 8, isLowNumHigh);
}
/// <summary>
/// 二进制字节数组转成64bits整数
/// </summary>
/// <param name="bytes">二进制字节数组</param>
/// <returns>整数</returns>
public static Result<UInt64> BytesToUInt64(byte[] bytes)
{
return BytesToUInt64(bytes, 0, 8, false);
}
/// <summary>
/// 二进制字节数组转成32bits整数
/// </summary>
/// <param name="bytes">二进制字节数组</param>
/// <param name="offset">字节数组偏移量</param>
/// <param name="numLength">整形所占字节数组长度</param>
/// <param name="isLowNumHigh">是否高位在数组索引低</param>
/// <returns>整数</returns>
public static Result<UInt32> BytesToUInt32(byte[] bytes, int offset = 0, int numLength = 4, bool isLowNumHigh = false)
{
if (bytes.Length < offset)
{
return new(new ArgumentException(
"Unsigned long number can't over 8 bytes(64 bits).",
nameof(bytes)
));
return new(new ArgumentException($"The Length of bytes is less than offset"));
}
UInt64 num = 0;
int len = bytes.Length;
if (numLength > 4)
{
return new(new ArgumentException($"The Length of bytes is greater than 4"));
}
if (bytes.Length < offset)
{
return new(new ArgumentException($"The Length of bytes is less than offset"));
}
if (numLength > 4)
{
return new(new ArgumentException($"The Length of bytes is greater than 4"));
}
if (numLength <= 0) return 0;
try
{
if (!isLowNumHigh)
{
Array.Reverse(bytes);
}
num = BitConverter.ToUInt64(bytes, 0);
byte[] numBytes = new byte[4]; // 4字节
int copyLen = Math.Min(numLength, bytes.Length - offset);
if (copyLen < 0) copyLen = 0;
if (isLowNumHigh)
{
// 小端:拷贝到低位
if (copyLen > 0)
{
Buffer.BlockCopy(bytes, offset, numBytes, 0, copyLen);
}
}
else
{
// 大端:拷贝到高位
if (copyLen > 0)
{
byte[] temp = new byte[copyLen];
Buffer.BlockCopy(bytes, offset, temp, 0, copyLen);
Array.Reverse(temp);
Buffer.BlockCopy(temp, 0, numBytes, 4 - copyLen, copyLen);
}
}
UInt32 num = BitConverter.ToUInt32(numBytes, 0);
return num;
}
catch (Exception error)
@@ -121,32 +224,17 @@ public class Number
/// <returns>整数</returns>
public static Result<UInt32> BytesToUInt32(byte[] bytes, bool isLowNumHigh = false)
{
if (bytes.Length > 4)
{
return new(new ArgumentException(
"Unsigned long number can't over 4 bytes(32 bits).",
nameof(bytes)
));
}
UInt32 num = 0;
int len = bytes.Length;
try
{
if (!isLowNumHigh)
{
Array.Reverse(bytes);
}
num = BitConverter.ToUInt32(bytes, 0);
return num;
}
catch (Exception error)
{
return new(error);
}
return BytesToUInt32(bytes, 0, 4, isLowNumHigh);
}
/// <summary>
/// 二进制字节数组转成32bits整数
/// </summary>
/// <param name="bytes">二进制字节数组</param>
/// <returns>整数</returns>
public static Result<UInt32> BytesToUInt32(byte[] bytes)
{
return BytesToUInt32(bytes, 0, 4, false);
}
/// <summary>
@@ -348,4 +436,37 @@ public class Number
}
return dstBytes;
}
/// <summary>
/// 获取数字的长度
/// </summary>
/// <param name="number">数字</param>
/// <returns>数字的长度</returns>
public static int GetLength(int number)
{
// 将整数转换为字符串
string numberString = number.ToString();
// 返回字符串的长度
return numberString.Length;
}
/// <summary>
/// 计算整形的幂
/// </summary>
/// <param name="x">底数</param>
/// <param name="pow">幂</param>
/// <returns>计算结果</returns>
public static int IntPow(int x, int pow)
{
int ret = 1;
while (pow != 0)
{
if ((pow & 1) == 1)
ret *= x;
x *= x;
pow >>= 1;
}
return ret;
}
}

View File

@@ -17,4 +17,13 @@ public class String
return new string(charArray);
}
public static string BytesToString(byte[] bytes, string separator = "")
{
return BitConverter.ToString(bytes).Replace("-", separator.ToString());
}
public static string BytesToBase64(byte[] bytes)
{
return Convert.ToBase64String(bytes);
}
}

View File

@@ -17,40 +17,12 @@ namespace server.Controllers;
public class DataController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly Database.UserManager _userManager = new();
// 固定的实验板IP,端口,MAC地址
private const string BOARD_IP = "169.254.109.0";
/// <summary>
/// [TODO:description]
/// </summary>
public class UserInfo
{
/// <summary>
/// 用户的唯一标识符
/// </summary>
public Guid ID { get; set; }
/// <summary>
/// 用户的名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 用户的电子邮箱
/// </summary>
public required string EMail { get; set; }
/// <summary>
/// 用户关联的板卡ID
/// </summary>
public Guid BoardID { get; set; }
/// <summary>
/// 用户绑定板子的过期时间
/// </summary>
public DateTime? BoardExpireTime { get; set; }
}
/// <summary>
/// 获取本机IP地址优先选择与实验板同网段的IP
/// </summary>
@@ -112,8 +84,7 @@ public class DataController : ControllerBase
public IActionResult Login(string name, string password)
{
// 验证用户密码
using var db = new Database.AppDataConnection();
var ret = db.CheckUserPassword(name, password);
var ret = _userManager.CheckUserPassword(name, password);
if (!ret.IsSuccessful) return StatusCode(StatusCodes.Status500InternalServerError, "数据库操作失败");
if (!ret.Value.HasValue) return BadRequest("用户名或密码错误");
var user = ret.Value.Value;
@@ -188,8 +159,7 @@ public class DataController : ControllerBase
return Unauthorized("未找到用户名信息");
// Get User Info
using var db = new Database.AppDataConnection();
var ret = db.GetUserByName(userName);
var ret = _userManager.GetUserByName(userName);
if (!ret.IsSuccessful)
return StatusCode(StatusCodes.Status500InternalServerError, "数据库操作失败");
@@ -236,8 +206,7 @@ public class DataController : ControllerBase
try
{
using var db = new Database.AppDataConnection();
var ret = db.AddUser(name, email, password);
var ret = _userManager.AddUser(name, email, password);
return Ok(ret);
}
catch (Exception ex)
@@ -265,15 +234,14 @@ public class DataController : ControllerBase
if (string.IsNullOrEmpty(userName))
return Unauthorized("未找到用户名信息");
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return BadRequest("用户不存在");
var user = userRet.Value.Value;
var expireTime = DateTime.UtcNow.AddHours(durationHours);
var boardOpt = db.GetAvailableBoard(user.ID, expireTime);
var boardOpt = _userManager.GetAvailableBoard(user.ID, expireTime);
if (!boardOpt.HasValue)
return NotFound("没有可用的实验板");
@@ -309,13 +277,12 @@ public class DataController : ControllerBase
if (string.IsNullOrEmpty(userName))
return Unauthorized("未找到用户名信息");
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return BadRequest("用户不存在");
var user = userRet.Value.Value;
var result = db.UnbindUserFromBoard(user.ID);
var result = _userManager.UnbindUserFromBoard(user.ID);
return Ok(result > 0);
}
catch (Exception ex)
@@ -338,8 +305,7 @@ public class DataController : ControllerBase
{
try
{
using var db = new Database.AppDataConnection();
var ret = db.GetBoardByID(id);
var ret = _userManager.GetBoardByID(id);
if (!ret.IsSuccessful)
return StatusCode(StatusCodes.Status500InternalServerError, "数据库操作失败");
if (!ret.Value.HasValue)
@@ -375,8 +341,7 @@ public class DataController : ControllerBase
return BadRequest("板子名称不能为空");
try
{
using var db = new Database.AppDataConnection();
var ret = db.AddBoard(name);
var ret = _userManager.AddBoard(name);
return Ok(ret);
}
catch (Exception ex)
@@ -402,8 +367,7 @@ public class DataController : ControllerBase
try
{
using var db = new Database.AppDataConnection();
var ret = db.DeleteBoardByID(id);
var ret = _userManager.DeleteBoardByID(id);
return Ok(ret);
}
catch (Exception ex)
@@ -425,8 +389,7 @@ public class DataController : ControllerBase
{
try
{
using var db = new Database.AppDataConnection();
var boards = db.GetAllBoard();
var boards = _userManager.GetAllBoard();
return Ok(boards);
}
catch (Exception ex)
@@ -453,8 +416,7 @@ public class DataController : ControllerBase
return BadRequest("新名称不能为空");
try
{
using var db = new Database.AppDataConnection();
var result = db.UpdateBoardName(boardId, newName);
var result = _userManager.UpdateBoardName(boardId, newName);
return Ok(result);
}
catch (Exception ex)
@@ -473,14 +435,13 @@ public class DataController : ControllerBase
[ProducesResponseType(typeof(int), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult UpdateBoardStatus(Guid boardId, Database.Board.BoardStatus newStatus)
public IActionResult UpdateBoardStatus(Guid boardId, Database.BoardStatus newStatus)
{
if (boardId == Guid.Empty)
return BadRequest("板子Guid不能为空");
try
{
using var db = new Database.AppDataConnection();
var result = db.UpdateBoardStatus(boardId, newStatus);
var result = _userManager.UpdateBoardStatus(boardId, newStatus);
return Ok(result);
}
catch (Exception ex)
@@ -489,4 +450,54 @@ public class DataController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "更新失败,请稍后重试");
}
}
[HttpPost("AddEmptyBoard")]
[EnableCors("Development")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult AddEmptyBoard()
{
try
{
var boardId = _userManager.AddBoard("Test");
var result = _userManager.UpdateBoardStatus(boardId, Database.BoardStatus.Available);
return Ok();
}
catch (Exception ex)
{
logger.Error(ex, "新增板子时发生异常");
return StatusCode(StatusCodes.Status500InternalServerError, "新增失败,请稍后重试");
}
}
/// <summary>
/// [TODO:description]
/// </summary>
public class UserInfo
{
/// <summary>
/// 用户的唯一标识符
/// </summary>
public Guid ID { get; set; }
/// <summary>
/// 用户的名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 用户的电子邮箱
/// </summary>
public required string EMail { get; set; }
/// <summary>
/// 用户关联的板卡ID
/// </summary>
public Guid BoardID { get; set; }
/// <summary>
/// 用户绑定板子的过期时间
/// </summary>
public DateTime? BoardExpireTime { get; set; }
}
}

View File

@@ -15,78 +15,7 @@ public class DebuggerController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 表示单个信号通道的配置信息
/// </summary>
public class ChannelConfig
{
/// <summary>
/// 通道名称
/// </summary>
required public string name;
/// <summary>
/// 通道显示颜色(如前端波形显示用)
/// </summary>
required public string color;
/// <summary>
/// 通道信号线宽度(位数)
/// </summary>
required public UInt32 wireWidth;
/// <summary>
/// 信号线在父端口中的起始索引bit
/// </summary>
required public UInt32 wireStartIndex;
/// <summary>
/// 父端口编号
/// </summary>
required public UInt32 parentPort;
/// <summary>
/// 捕获模式(如上升沿、下降沿等)
/// </summary>
required public CaptureMode mode;
}
/// <summary>
/// 调试器整体配置信息
/// </summary>
public class DebuggerConfig
{
/// <summary>
/// 时钟频率
/// </summary>
required public UInt32 clkFreq;
/// <summary>
/// 总端口数量
/// </summary>
required public UInt32 totalPortNum;
/// <summary>
/// 捕获深度(采样点数)
/// </summary>
required public UInt32 captureDepth;
/// <summary>
/// 触发器数量
/// </summary>
required public UInt32 triggerNum;
/// <summary>
/// 所有信号通道的配置信息
/// </summary>
required public ChannelConfig[] channelConfigs;
}
/// <summary>
/// 单个通道的捕获数据
/// </summary>
public class ChannelCaptureData
{
/// <summary>
/// 通道名称
/// </summary>
required public string name;
/// <summary>
/// 通道捕获到的数据Base64编码的UInt32数组
/// </summary>
required public string data;
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取当前用户绑定的调试器实例
@@ -99,8 +28,7 @@ public class DebuggerController : ControllerBase
if (string.IsNullOrEmpty(userName))
return null;
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return null;
@@ -108,12 +36,12 @@ public class DebuggerController : ControllerBase
if (user.BoardID == Guid.Empty)
return null;
var boardRet = db.GetBoardByID(user.BoardID);
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return null;
var board = boardRet.Value.Value;
return new DebuggerClient(board.IpAddr, board.Port, 1);
return new DebuggerClient(board.IpAddr, board.Port, 7);
}
catch (Exception ex)
{
@@ -464,4 +392,77 @@ public class DebuggerController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
}
}
/// <summary>
/// 表示单个信号通道的配置信息
/// </summary>
public class ChannelConfig
{
/// <summary>
/// 通道名称
/// </summary>
required public string name;
/// <summary>
/// 通道显示颜色(如前端波形显示用)
/// </summary>
required public string color;
/// <summary>
/// 通道信号线宽度(位数)
/// </summary>
required public UInt32 wireWidth;
/// <summary>
/// 信号线在父端口中的起始索引bit
/// </summary>
required public UInt32 wireStartIndex;
/// <summary>
/// 父端口编号
/// </summary>
required public UInt32 parentPort;
/// <summary>
/// 捕获模式(如上升沿、下降沿等)
/// </summary>
required public CaptureMode mode;
}
/// <summary>
/// 调试器整体配置信息
/// </summary>
public class DebuggerConfig
{
/// <summary>
/// 时钟频率
/// </summary>
required public UInt32 clkFreq;
/// <summary>
/// 总端口数量
/// </summary>
required public UInt32 totalPortNum;
/// <summary>
/// 捕获深度(采样点数)
/// </summary>
required public UInt32 captureDepth;
/// <summary>
/// 触发器数量
/// </summary>
required public UInt32 triggerNum;
/// <summary>
/// 所有信号通道的配置信息
/// </summary>
required public ChannelConfig[] channelConfigs;
}
/// <summary>
/// 单个通道的捕获数据
/// </summary>
public class ChannelCaptureData
{
/// <summary>
/// 通道名称
/// </summary>
required public string name;
/// <summary>
/// 通道捕获到的数据Base64编码的UInt32数组
/// </summary>
required public string data;
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using DotNext;
using Database;
namespace server.Controllers;
@@ -14,73 +15,9 @@ public class ExamController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 实验信息类
/// </summary>
public class ExamInfo
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 实验文档内容Markdown格式
/// </summary>
public required string DocContent { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 实验最后更新时间
/// </summary>
public DateTime UpdatedTime { get; set; }
}
/// <summary>
/// 实验简要信息类(用于列表显示)
/// </summary>
public class ExamSummary
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 实验最后更新时间
/// </summary>
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 实验标题(从文档内容中提取)
/// </summary>
public string Title { get; set; } = "";
}
/// <summary>
/// 扫描结果类
/// </summary>
public class ScanResult
{
/// <summary>
/// 结果消息
/// </summary>
public required string Message { get; set; }
/// <summary>
/// 更新的实验数量
/// </summary>
public int UpdateCount { get; set; }
}
private readonly ExamManager _examManager = new();
private readonly ResourceManager _resourceManager = new();
private readonly UserManager _userManager = new();
/// <summary>
/// 获取所有实验列表
@@ -89,26 +26,19 @@ public class ExamController : ControllerBase
[Authorize]
[HttpGet("list")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ExamSummary[]), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ExamInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetExamList()
{
try
{
using var db = new Database.AppDataConnection();
var exams = db.GetAllExams();
var exams = _examManager.GetAllExams();
var examSummaries = exams.Select(exam => new ExamSummary
{
ID = exam.ID,
CreatedTime = exam.CreatedTime,
UpdatedTime = exam.UpdatedTime,
Title = ExtractTitleFromMarkdown(exam.DocContent)
}).ToArray();
var examInfos = exams.Select(exam => new ExamInfo(exam)).ToArray();
logger.Info($"成功获取实验列表,共 {examSummaries.Length} 个实验");
return Ok(examSummaries);
logger.Info($"成功获取实验列表,共 {examInfos.Length} 个实验");
return Ok(examInfos);
}
catch (Exception ex)
{
@@ -137,8 +67,7 @@ public class ExamController : ControllerBase
try
{
using var db = new Database.AppDataConnection();
var result = db.GetExamByID(examId);
var result = _examManager.GetExamByID(examId);
if (!result.IsSuccessful)
{
@@ -153,13 +82,7 @@ public class ExamController : ControllerBase
}
var exam = result.Value.Value;
var examInfo = new ExamInfo
{
ID = exam.ID,
DocContent = exam.DocContent,
CreatedTime = exam.CreatedTime,
UpdatedTime = exam.UpdatedTime
};
var examInfo = new ExamInfo(exam);
logger.Info($"成功获取实验信息: {examId}");
return Ok(examInfo);
@@ -172,60 +95,424 @@ public class ExamController : ControllerBase
}
/// <summary>
/// 重新扫描实验文件夹并更新数据库
/// 创建新实验
/// </summary>
/// <returns>更新结果</returns>
/// <param name="request">创建实验请求</param>
/// <returns>创建结果</returns>
[Authorize("Admin")]
[HttpPost("scan")]
[HttpPost("create")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ScanResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ExamInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult ScanExams()
public IActionResult CreateExam([FromBody] ExamDto request)
{
if (string.IsNullOrWhiteSpace(request.ID) || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Description))
return BadRequest("实验ID、名称和描述不能为空");
try
{
using var db = new Database.AppDataConnection();
var examFolderPath = Path.Combine(Directory.GetCurrentDirectory(), "exam");
var updateCount = db.ScanAndUpdateExams(examFolderPath);
var result = _examManager.CreateExam(request.ID, request.Name, request.Description, request.Tags, request.Difficulty, request.IsVisibleToUsers);
var result = new ScanResult
if (!result.IsSuccessful)
{
Message = $"扫描完成,更新了 {updateCount} 个实验",
UpdateCount = updateCount
};
if (result.Error.Message.Contains("已存在"))
return Conflict(result.Error.Message);
logger.Info($"手动扫描实验完成,更新了 {updateCount} 个实验");
return Ok(result);
logger.Error($"创建实验时出错: {result.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {result.Error.Message}");
}
var exam = result.Value;
var examInfo = new ExamInfo(exam);
logger.Info($"成功创建实验: {request.ID}");
return CreatedAtAction(nameof(GetExam), new { examId = request.ID }, examInfo);
}
catch (Exception ex)
{
logger.Error($"扫描实验时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"扫描实验失败: {ex.Message}");
logger.Error($"创建实验 {request.ID} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {ex.Message}");
}
}
/// <summary>
/// 从 Markdown 内容中提取标题
/// 更新实验信息
/// </summary>
/// <param name="markdownContent">Markdown 内容</param>
/// <returns>提取的标题</returns>
private static string ExtractTitleFromMarkdown(string markdownContent)
/// <param name="request">更新实验请求</param>
/// <returns>更新结果</returns>
[Authorize("Admin")]
[HttpPost("update")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ExamInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult UpdateExam([FromBody] ExamDto request)
{
if (string.IsNullOrEmpty(markdownContent))
return "";
var examId = request.ID;
var lines = markdownContent.Split('\n');
foreach (var line in lines)
try
{
var trimmedLine = line.Trim();
if (trimmedLine.StartsWith("# "))
// 首先检查实验是否存在
var existingExamResult = _examManager.GetExamByID(examId);
if (!existingExamResult.IsSuccessful)
{
return trimmedLine.Substring(2).Trim();
logger.Error($"检查实验是否存在时出错: {existingExamResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"检查实验失败: {existingExamResult.Error.Message}");
}
}
return "";
if (!existingExamResult.Value.HasValue)
{
logger.Warn($"要更新的实验不存在: {examId}");
return NotFound($"实验 {examId} 不存在");
}
// 执行更新
var updateResult = _examManager.UpdateExam(
examId,
request.Name,
request.Description,
request.Tags,
request.Difficulty,
request.IsVisibleToUsers
);
if (!updateResult.IsSuccessful)
{
logger.Error($"更新实验时出错: {updateResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"更新实验失败: {updateResult.Error.Message}");
}
// 获取更新后的实验信息并返回
var updatedExamResult = _examManager.GetExamByID(examId);
if (!updatedExamResult.IsSuccessful || !updatedExamResult.Value.HasValue)
{
logger.Error($"获取更新后的实验信息失败: {examId}");
return StatusCode(StatusCodes.Status500InternalServerError, "更新成功但获取更新后信息失败");
}
var updatedExam = updatedExamResult.Value.Value;
var examInfo = new ExamInfo(updatedExam);
logger.Info($"成功更新实验: {examId},更新记录数: {updateResult.Value}");
return Ok(examInfo);
}
catch (Exception ex)
{
logger.Error($"更新实验 {examId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"更新实验失败: {ex.Message}");
}
}
/// <summary>
/// 提交作业
/// </summary>
/// <param name="examId">实验ID</param>
/// <param name="file">提交的文件</param>
/// <returns>提交结果</returns>
[Authorize]
[HttpPost("commit/{examId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Commit(string examId, IFormFile file)
{
if (string.IsNullOrWhiteSpace(examId))
return BadRequest("实验ID不能为空");
if (file == null || file.Length == 0)
return BadRequest("文件不能为空");
try
{
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 检查实验是否存在
var examResult = _examManager.GetExamByID(examId);
if (!examResult.IsSuccessful)
{
logger.Error($"检查实验是否存在时出错: {examResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"检查实验失败: {examResult.Error.Message}");
}
if (!examResult.Value.HasValue)
{
logger.Warn($"实验不存在: {examId}");
return NotFound($"实验 {examId} 不存在");
}
// 读取文件内容
byte[] fileData;
using (var memoryStream = new MemoryStream())
{
await file.CopyToAsync(memoryStream);
fileData = memoryStream.ToArray();
}
// 提交作业
var commitResult = _resourceManager.AddResource(
user.ID, ResourceTypes.Compression, ResourcePurpose.Homework,
file.FileName, fileData, examId);
if (!commitResult.IsSuccessful)
{
logger.Error($"提交作业时出错: {commitResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"提交作业失败: {commitResult.Error.Message}");
}
var commit = new ResourceInfo(commitResult.Value);
logger.Info($"用户 {userName} 成功提交实验 {examId} 的作业Commit ID: {commit.ID}");
return CreatedAtAction(nameof(GetCommitsByExamId), new { examId = examId }, commit);
}
catch (Exception ex)
{
logger.Error($"提交实验 {examId} 作业时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"提交作业失败: {ex.Message}");
}
}
/// <summary>
/// 获取用户在指定实验中的提交记录
/// </summary>
/// <param name="examId">实验ID</param>
/// <returns>提交记录列表</returns>
[Authorize]
[HttpGet("commits/{examId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetCommitsByExamId(string examId)
{
if (string.IsNullOrWhiteSpace(examId))
return BadRequest("实验ID不能为空");
try
{
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 检查实验是否存在
var examResult = _examManager.GetExamByID(examId);
if (!examResult.IsSuccessful)
{
logger.Error($"检查实验是否存在时出错: {examResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"检查实验失败: {examResult.Error.Message}");
}
if (!examResult.Value.HasValue)
{
logger.Warn($"实验不存在: {examId}");
return NotFound($"实验 {examId} 不存在");
}
// 获取用户的提交记录
var commitsResult = _resourceManager.GetResourceListByType(
ResourceTypes.Compression, ResourcePurpose.Homework, examId);
if (!commitsResult.IsSuccessful)
{
logger.Error($"获取提交记录时出错: {commitsResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取提交记录失败: {commitsResult.Error.Message}");
}
var commits = commitsResult.Value.Select(x => new ResourceInfo(x)).ToArray();
logger.Info($"成功获取用户 {userName} 在实验 {examId} 中的提交记录,共 {commits.Length} 条");
return Ok(commits);
}
catch (Exception ex)
{
logger.Error($"获取实验 {examId} 提交记录时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取提交记录失败: {ex.Message}");
}
}
/// <summary>
/// 删除提交记录
/// </summary>
/// <param name="commitId">提交记录ID</param>
/// <returns>删除结果</returns>
[Authorize]
[HttpDelete("commit/{commitId}")]
[EnableCors("Users")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult DeleteCommit(Guid commitId)
{
try
{
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 检查是否是管理员
var isAdmin = user.Permission == UserPermission.Admin;
// 如果不是管理员,检查提交记录是否属于当前用户
if (!isAdmin)
{
var commitResult = _resourceManager.GetResourceById(commitId);
if (!commitResult.HasValue)
{
logger.Warn($"提交记录不存在: {commitId}");
return NotFound($"提交记录 {commitId} 不存在");
}
var commit = commitResult.Value;
if (commit.UserID != user.ID)
{
logger.Warn($"用户 {userName} 尝试删除不属于自己的提交记录: {commitId}");
return Forbid("您只能删除自己的提交记录");
}
}
// 执行删除
var deleteResult = _resourceManager.DeleteResource(commitId);
if (!deleteResult)
{
logger.Warn($"提交记录不存在: {commitId}");
return NotFound($"提交记录 {commitId} 不存在");
}
logger.Info($"用户 {userName} 成功删除提交记录: {commitId}");
return Ok($"提交记录 {commitId} 已成功删除");
}
catch (Exception ex)
{
logger.Error($"删除提交记录 {commitId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"删除提交记录失败: {ex.Message}");
}
}
}
/// <summary>
/// 实验信息
/// </summary>
public class ExamInfo
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
public string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 实验描述
/// </summary>
public string Description { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 实验最后更新时间
/// </summary>
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 实验标签
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// 实验难度1-5
/// </summary>
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
public bool IsVisibleToUsers { get; set; } = true;
public ExamInfo(Exam exam)
{
ID = exam.ID;
Name = exam.Name;
Description = exam.Description;
CreatedTime = exam.CreatedTime;
UpdatedTime = exam.UpdatedTime;
Tags = exam.GetTagsList();
Difficulty = exam.Difficulty;
IsVisibleToUsers = exam.IsVisibleToUsers;
}
}
/// <summary>
/// 统一的实验数据传输对象
/// </summary>
public class ExamDto
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 实验描述
/// </summary>
public required string Description { get; set; }
/// <summary>
/// 实验标签
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// 实验难度1-5
/// </summary>
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
public bool IsVisibleToUsers { get; set; } = true;
}

View File

@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using server.Services;
namespace server.Controllers;
[ApiController]
[Route("api/[controller]")]
[EnableCors("Users")]
public class HdmiVideoStreamController : ControllerBase
{
private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly HttpHdmiVideoStreamService _videoStreamService;
private readonly Database.UserManager _userManager = new();
public HdmiVideoStreamController(HttpHdmiVideoStreamService videoStreamService)
{
_videoStreamService = videoStreamService;
}
// 管理员获取所有板子的 endpoints
[HttpGet("AllEndpoints")]
[Authorize("Admin")]
public ActionResult<List<HdmiVideoStreamEndpoint>> GetAllEndpoints()
{
var endpoints = _videoStreamService.GetAllVideoEndpoints();
if (endpoints == null)
return NotFound("No boards found.");
return Ok(endpoints);
}
// 用户获取自己板子的 endpoint
[HttpGet("MyEndpoint")]
[Authorize]
public ActionResult<HdmiVideoStreamEndpoint> GetMyEndpoint()
{
var userName = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
return Unauthorized("User name not found in claims.");
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return NotFound("User not found.");
var user = userRet.Value.Value;
var boardId = user.BoardID;
if (boardId == Guid.Empty)
return NotFound("No board bound to this user.");
var boardRet = _userManager.GetBoardByID(boardId);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return NotFound("Board not found.");
var endpoint = _videoStreamService.GetVideoEndpoint(boardId.ToString());
return Ok(endpoint);
}
// 禁用指定板子的 HDMI 传输
[HttpPost("DisableHdmiTransmission")]
[Authorize]
public async Task<IActionResult> DisableHdmiTransmission()
{
var userName = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
return Unauthorized("User name not found in claims.");
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return NotFound("User not found.");
var user = userRet.Value.Value;
var boardId = user.BoardID;
if (boardId == Guid.Empty)
return NotFound("No board bound to this user.");
try
{
await _videoStreamService.DisableHdmiTransmissionAsync(boardId.ToString());
return Ok($"HDMI transmission for board {boardId} disabled.");
}
catch (Exception ex)
{
logger.Error(ex, $"Failed to disable HDMI transmission for board {boardId}");
return StatusCode(500, $"Error disabling HDMI transmission: {ex.Message}");
}
}
}

View File

@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Database;
using server.Services;
namespace server.Controllers;
@@ -14,6 +16,10 @@ public class JtagController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ProgressTracker _tracker = MsgBus.ProgressTracker;
private readonly UserManager _userManager = new();
private readonly ResourceManager _resourceManager = new();
private const string BITSTREAM_PATH = "bitstream/Jtag";
/// <summary>
@@ -112,116 +118,96 @@ public class JtagController : ControllerBase
}
}
/// <summary>
/// 上传比特流文件到服务器
/// </summary>
/// <param name="address">目标设备地址</param>
/// <param name="file">比特流文件</param>
/// <returns>上传结果</returns>
[HttpPost("UploadBitstream")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async ValueTask<IResult> UploadBitstream(string address, IFormFile file)
{
logger.Info($"User {User.Identity?.Name} uploading bitstream for device {address}");
if (file == null || file.Length == 0)
{
logger.Warn($"User {User.Identity?.Name} attempted to upload empty file for device {address}");
return TypedResults.BadRequest("未选择文件");
}
try
{
// 生成安全的文件名(避免路径遍历攻击)
var fileName = Path.GetRandomFileName();
var uploadsFolder = Path.Combine(Environment.CurrentDirectory, $"{BITSTREAM_PATH}/{address}");
// 如果存在文件,则删除原文件再上传
if (Directory.Exists(uploadsFolder))
{
Directory.Delete(uploadsFolder, true);
logger.Info($"User {User.Identity?.Name} removed existing bitstream folder for device {address}");
}
Directory.CreateDirectory(uploadsFolder);
var filePath = Path.Combine(uploadsFolder, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
logger.Info($"User {User.Identity?.Name} successfully uploaded bitstream for device {address}, file size: {file.Length} bytes");
return TypedResults.Ok(true);
}
catch (Exception ex)
{
logger.Error(ex, $"User {User.Identity?.Name} failed to upload bitstream for device {address}");
return TypedResults.InternalServerError(ex);
}
}
/// <summary>
/// 通过 JTAG 下载比特流文件到 FPGA 设备
/// </summary>
/// <param name="address">JTAG 设备地址</param>
/// <param name="port">JTAG 设备端口</param>
/// <returns>下载结果</returns>
/// <param name="bitstreamId">比特流ID</param>
/// <param name="cancelToken">取消令牌</param>
/// <returns>进度跟踪TaskID</returns>
[HttpPost("DownloadBitstream")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async ValueTask<IResult> DownloadBitstream(string address, int port)
public IResult DownloadBitstream(string address, int port, Guid bitstreamId, CancellationToken cancelToken)
{
logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port}");
// 检查文件
var fileDir = Path.Combine(Environment.CurrentDirectory, $"{BITSTREAM_PATH}/{address}");
if (!Directory.Exists(fileDir))
{
logger.Warn($"User {User.Identity?.Name} attempted to download non-existent bitstream for device {address}");
return TypedResults.BadRequest("Empty bitstream, Please upload it first");
}
logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port} using bitstream ID: {bitstreamId}");
try
{
// 读取文件
var filePath = Directory.GetFiles(fileDir)[0];
logger.Info($"User {User.Identity?.Name} reading bitstream file: {filePath}");
using (var fileStream = System.IO.File.Open(filePath, System.IO.FileMode.Open))
// 获取当前用户名
var username = User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
if (fileStream is null || fileStream.Length <= 0)
{
logger.Warn($"User {User.Identity?.Name} found invalid bitstream file for device {address}");
return TypedResults.BadRequest("Wrong bitstream, Please upload it again");
}
logger.Warn("Anonymous user attempted to download bitstream");
return TypedResults.Unauthorized();
}
logger.Info($"User {User.Identity?.Name} processing bitstream file of size: {fileStream.Length} bytes");
// 从数据库获取用户信息
var userResult = _userManager.GetUserByName(username);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
{
logger.Error($"User {username} not found in database");
return TypedResults.BadRequest("用户不存在");
}
// 从数据库获取比特流
var user = userResult.Value.Value;
var resourceRet = _resourceManager.GetResourceById(bitstreamId);
if (!resourceRet.HasValue)
{
logger.Warn($"User {username} attempted to download non-existent bitstream ID: {bitstreamId}");
return TypedResults.BadRequest("比特流不存在");
}
// 处理比特流数据
var resource = resourceRet.Value;
var bitstreamRet = _resourceManager.ReadBytesFromPath(resource.Path);
if (!bitstreamRet.IsSuccessful)
{
logger.Error($"User {username} failed to read bitstream file: {bitstreamRet.Error}");
return TypedResults.InternalServerError($"比特流读取失败: {bitstreamRet.Error?.Message}");
}
var fileBytes = bitstreamRet.Value;
if (fileBytes == null || fileBytes.Length == 0)
{
logger.Warn($"User {username} found empty bitstream data for ID: {bitstreamId}");
return TypedResults.BadRequest("比特流数据为空,请重新上传");
}
logger.Info($"User {username} processing bitstream file of size: {fileBytes.Length} bytes");
// 定义进度跟踪
var taskId = _tracker.CreateTask(8000);
_tracker.AdvanceProgress(taskId, 10);
_ = Task.Run(async () =>
{
// 定义缓冲区大小: 32KB
byte[] buffer = new byte[32 * 1024];
byte[] revBuffer = new byte[32 * 1024];
long totalBytesRead = 0;
long totalBytesProcessed = 0;
// 使用异步流读取文件
using (var memoryStream = new MemoryStream())
// 使用内存流处理文件
using (var inputStream = new MemoryStream(fileBytes))
using (var outputStream = new MemoryStream())
{
int bytesRead;
while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
while ((bytesRead = await inputStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
// 反转 32bits
var retBuffer = Common.Number.ReverseBytes(buffer, 4);
if (!retBuffer.IsSuccessful)
{
logger.Error($"User {User.Identity?.Name} failed to reverse bytes: {retBuffer.Error}");
return TypedResults.InternalServerError(retBuffer.Error);
logger.Error($"User {username} failed to reverse bytes: {retBuffer.Error}");
_tracker.FailProgress(taskId,
$"User {username} failed to reverse bytes: {retBuffer.Error}");
return;
}
revBuffer = retBuffer.Value;
@@ -230,34 +216,39 @@ public class JtagController : ControllerBase
revBuffer[i] = Common.Number.ReverseBits(revBuffer[i]);
}
await memoryStream.WriteAsync(revBuffer, 0, bytesRead);
totalBytesRead += bytesRead;
await outputStream.WriteAsync(revBuffer, 0, bytesRead);
totalBytesProcessed += bytesRead;
}
// 将所有数据转换为字节数组(注意:如果文件非常大,可能不适合完全加载到内存)
var fileBytes = memoryStream.ToArray();
logger.Info($"User {User.Identity?.Name} processed {totalBytesRead} bytes for device {address}");
// 获取处理后的数据
var processedBytes = outputStream.ToArray();
logger.Info($"User {username} processed {totalBytesProcessed} bytes for device {address}");
_tracker.AdvanceProgress(taskId, 20);
// 下载比特流
var jtagCtrl = new Peripherals.JtagClient.Jtag(address, port);
var ret = await jtagCtrl.DownloadBitstream(fileBytes);
var ret = await jtagCtrl.DownloadBitstream(processedBytes, taskId);
if (ret.IsSuccessful)
{
logger.Info($"User {User.Identity?.Name} successfully downloaded bitstream to device {address}");
return TypedResults.Ok(ret.Value);
logger.Info($"User {username} successfully downloaded bitstream '{resource.ResourceName}' to device {address}");
_tracker.CompleteProgress(taskId);
}
else
{
logger.Error($"User {User.Identity?.Name} failed to download bitstream to device {address}: {ret.Error}");
return TypedResults.InternalServerError(ret.Error);
logger.Error($"User {username} failed to download bitstream to device {address}: {ret.Error}");
_tracker.FailProgress(taskId,
$"User {username} failed to download bitstream to device {address}: {ret.Error}");
}
}
}
});
return TypedResults.Ok(taskId);
}
catch (Exception ex)
{
logger.Error(ex, $"User {User.Identity?.Name} encountered exception while downloading bitstream to device {address}");
logger.Error(ex, $"User encountered exception while downloading bitstream to device {address}");
return TypedResults.InternalServerError(ex);
}
}

View File

@@ -15,53 +15,7 @@ public class LogicAnalyzerController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 信号触发配置
/// </summary>
public class SignalTriggerConfig
{
/// <summary>
/// 信号索引 (0-7)
/// </summary>
public int SignalIndex { get; set; }
/// <summary>
/// 操作符
/// </summary>
public SignalOperator Operator { get; set; }
/// <summary>
/// 信号值
/// </summary>
public SignalValue Value { get; set; }
}
/// <summary>
/// 捕获配置
/// </summary>
public class CaptureConfig
{
/// <summary>
/// 全局触发模式
/// </summary>
public GlobalCaptureMode GlobalMode { get; set; }
/// <summary>
/// 捕获深度
/// </summary>
public int CaptureLength { get; set; } = 2048 * 32;
/// <summary>
/// 预采样深度
/// </summary>
public int PreCaptureLength { get; set; } = 2048;
/// <summary>
/// 有效通道
/// </summary>
public AnalyzerChannelDiv ChannelDiv { get; set; } = AnalyzerChannelDiv.EIGHT;
/// <summary>
/// 信号触发配置列表
/// </summary>
public SignalTriggerConfig[] SignalConfigs { get; set; } = Array.Empty<SignalTriggerConfig>();
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取逻辑分析仪实例
@@ -74,8 +28,7 @@ public class LogicAnalyzerController : ControllerBase
if (string.IsNullOrEmpty(userName))
return null;
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return null;
@@ -83,12 +36,12 @@ public class LogicAnalyzerController : ControllerBase
if (user.BoardID == Guid.Empty)
return null;
var boardRet = db.GetBoardByID(user.BoardID);
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return null;
var board = boardRet.Value.Value;
return new Analyzer(board.IpAddr, board.Port, 0);
return new Analyzer(board.IpAddr, board.Port, 11);
}
catch (Exception ex)
{
@@ -248,6 +201,7 @@ public class LogicAnalyzerController : ControllerBase
/// <param name="capture_length">深度</param>
/// <param name="pre_capture_length">预采样深度</param>
/// <param name="channel_div">有效通道(0-[1],1-[2],2-[4],3-[8],4-[16],5-[32])</param>
/// <param name="clock_div">采样时钟分频系数</param>
/// <returns>操作结果</returns>
[HttpPost("SetCaptureParams")]
[EnableCors("Users")]
@@ -255,11 +209,12 @@ public class LogicAnalyzerController : ControllerBase
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetCaptureParams(int capture_length, int pre_capture_length, AnalyzerChannelDiv channel_div)
public async Task<IActionResult> SetCaptureParams(int capture_length, int pre_capture_length, AnalyzerChannelDiv channel_div, AnalyzerClockDiv clock_div)
{
try
{
if (capture_length < 0 || capture_length > 2048*32)
//DDR深度为 32'h01000000 - 32'h0FFFFFFF
if (capture_length < 0 || capture_length > 0x10000000 - 0x01000000)
return BadRequest("采样深度设置错误");
if (pre_capture_length < 0 || pre_capture_length >= capture_length)
return BadRequest("预采样深度必须小于捕获深度");
@@ -268,18 +223,18 @@ public class LogicAnalyzerController : ControllerBase
if (analyzer == null)
return BadRequest("用户未绑定有效的实验板");
var result = await analyzer.SetCaptureParams(capture_length, pre_capture_length, channel_div);
var result = await analyzer.SetCaptureParams(capture_length, pre_capture_length, channel_div, clock_div);
if (!result.IsSuccessful)
{
logger.Error($"设置深度、预采样深度、有效通道失败: {result.Error}");
return StatusCode(StatusCodes.Status500InternalServerError, "设置深度、预采样深度、有效通道失败");
logger.Error($"设置深度、预采样深度、有效通道、时钟分频失败: {result.Error}");
return StatusCode(StatusCodes.Status500InternalServerError, "设置深度、预采样深度、有效通道、时钟分频失败");
}
return Ok(result.Value);
}
catch (Exception ex)
{
logger.Error(ex, "设置深度、预采样深度、有效通道失败时发生异常");
logger.Error(ex, "设置深度、预采样深度、有效通道、时钟分频失败时发生异常");
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
}
}
@@ -331,7 +286,7 @@ public class LogicAnalyzerController : ControllerBase
}
// 设置深度、预采样深度、有效通道
var paramsResult = await analyzer.SetCaptureParams(
config.CaptureLength, config.PreCaptureLength, config.ChannelDiv);
config.CaptureLength, config.PreCaptureLength, config.ChannelDiv, config.ClockDiv);
if (!paramsResult.IsSuccessful)
{
logger.Error($"设置深度、预采样深度、有效通道失败: {paramsResult.Error}");
@@ -416,4 +371,57 @@ public class LogicAnalyzerController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
}
}
/// <summary>
/// 信号触发配置
/// </summary>
public class SignalTriggerConfig
{
/// <summary>
/// 信号索引 (0-7)
/// </summary>
public int SignalIndex { get; set; }
/// <summary>
/// 操作符
/// </summary>
public SignalOperator Operator { get; set; }
/// <summary>
/// 信号值
/// </summary>
public SignalValue Value { get; set; }
}
/// <summary>
/// 捕获配置
/// </summary>
public class CaptureConfig
{
/// <summary>
/// 全局触发模式
/// </summary>
public GlobalCaptureMode GlobalMode { get; set; }
/// <summary>
/// 捕获深度
/// </summary>
public int CaptureLength { get; set; } = 2048 * 32;
/// <summary>
/// 预采样深度
/// </summary>
public int PreCaptureLength { get; set; } = 2048;
/// <summary>
/// 有效通道
/// </summary>
public AnalyzerChannelDiv ChannelDiv { get; set; } = AnalyzerChannelDiv.EIGHT;
/// <summary>
/// 时钟分频系数
/// </summary>
public AnalyzerClockDiv ClockDiv { get; set; } = AnalyzerClockDiv.DIV1;
/// <summary>
/// 信号触发配置列表
/// </summary>
public SignalTriggerConfig[] SignalConfigs { get; set; } = Array.Empty<SignalTriggerConfig>();
}
}

View File

@@ -21,8 +21,8 @@ public class NetConfigController : ControllerBase
private const int BOARD_PORT = 1234;
// 本机网络信息
private readonly IPAddress _localIP;
private readonly byte[] _localMAC;
private readonly IPAddress _localIP = IPAddress.Any;
private readonly byte[] _localMAC = new byte[6];
private readonly string _localIPString;
private readonly string _localMACString;
private readonly string _localInterface;

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Peripherals.OscilloscopeClient;
using server.Hubs;
namespace server.Controllers;
@@ -9,83 +10,19 @@ namespace server.Controllers;
/// 示波器API控制器 - 普通用户权限
/// </summary>
[ApiController]
[EnableCors("Development")]
[Route("api/[controller]")]
[Authorize]
public class OscilloscopeApiController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 示波器完整配置
/// </summary>
public class OscilloscopeFullConfig
{
/// <summary>
/// 是否启动捕获
/// </summary>
public bool CaptureEnabled { get; set; }
/// <summary>
/// 触发电平0-255
/// </summary>
public byte TriggerLevel { get; set; }
/// <summary>
/// 触发边沿true为上升沿false为下降沿
/// </summary>
public bool TriggerRisingEdge { get; set; }
/// <summary>
/// 水平偏移量0-1023
/// </summary>
public ushort HorizontalShift { get; set; }
/// <summary>
/// 抽样率0-1023
/// </summary>
public ushort DecimationRate { get; set; }
/// <summary>
/// 是否自动刷新RAM
/// </summary>
public bool AutoRefreshRAM { get; set; } = true;
}
/// <summary>
/// 示波器状态和数据
/// </summary>
public class OscilloscopeDataResponse
{
/// <summary>
/// AD采样频率
/// </summary>
public uint ADFrequency { get; set; }
/// <summary>
/// AD采样幅度
/// </summary>
public byte ADVpp { get; set; }
/// <summary>
/// AD采样最大值
/// </summary>
public byte ADMax { get; set; }
/// <summary>
/// AD采样最小值
/// </summary>
public byte ADMin { get; set; }
/// <summary>
/// 波形数据Base64编码
/// </summary>
public string WaveformData { get; set; } = string.Empty;
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取示波器实例
/// </summary>
private Oscilloscope? GetOscilloscope()
private OscilloscopeCtrl? GetOscilloscope()
{
try
{
@@ -93,8 +30,7 @@ public class OscilloscopeApiController : ControllerBase
if (string.IsNullOrEmpty(userName))
return null;
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return null;
@@ -102,12 +38,12 @@ public class OscilloscopeApiController : ControllerBase
if (user.BoardID == Guid.Empty)
return null;
var boardRet = db.GetBoardByID(user.BoardID);
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return null;
var board = boardRet.Value.Value;
return new Oscilloscope(board.IpAddr, board.Port);
return new OscilloscopeCtrl(board.IpAddr, board.Port);
}
catch (Exception ex)
{
@@ -122,12 +58,11 @@ public class OscilloscopeApiController : ControllerBase
/// <param name="config">示波器配置</param>
/// <returns>操作结果</returns>
[HttpPost("Initialize")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Initialize([FromBody] OscilloscopeFullConfig config)
public async Task<IActionResult> Initialize([FromBody] OscilloscopeConfig config)
{
try
{
@@ -184,16 +119,16 @@ public class OscilloscopeApiController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "设置抽样率失败");
}
// 刷新RAM
if (config.AutoRefreshRAM)
{
var refreshResult = await oscilloscope.RefreshRAM();
if (!refreshResult.IsSuccessful)
{
logger.Error($"刷新RAM失败: {refreshResult.Error}");
return StatusCode(StatusCodes.Status500InternalServerError, "刷新RAM失败");
}
}
// // 刷新RAM
// if (config.AutoRefreshRAM)
// {
// var refreshResult = await oscilloscope.RefreshRAM();
// if (!refreshResult.IsSuccessful)
// {
// logger.Error($"刷新RAM失败: {refreshResult.Error}");
// return StatusCode(StatusCodes.Status500InternalServerError, "刷新RAM失败");
// }
// }
// 设置捕获开关
var captureResult = await oscilloscope.SetCaptureEnable(config.CaptureEnabled);
@@ -217,7 +152,6 @@ public class OscilloscopeApiController : ControllerBase
/// </summary>
/// <returns>操作结果</returns>
[HttpPost("StartCapture")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -251,7 +185,6 @@ public class OscilloscopeApiController : ControllerBase
/// </summary>
/// <returns>操作结果</returns>
[HttpPost("StopCapture")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -285,7 +218,6 @@ public class OscilloscopeApiController : ControllerBase
/// </summary>
/// <returns>示波器数据和状态信息</returns>
[HttpGet("GetData")]
[EnableCors("Users")]
[ProducesResponseType(typeof(OscilloscopeDataResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -336,10 +268,10 @@ public class OscilloscopeApiController : ControllerBase
var response = new OscilloscopeDataResponse
{
ADFrequency = freqResult.Value,
ADVpp = vppResult.Value,
ADMax = maxResult.Value,
ADMin = minResult.Value,
AdFrequency = freqResult.Value,
AdVpp = vppResult.Value,
AdMax = maxResult.Value,
AdMin = minResult.Value,
WaveformData = Convert.ToBase64String(waveformResult.Value)
};
@@ -359,7 +291,6 @@ public class OscilloscopeApiController : ControllerBase
/// <param name="risingEdge">触发边沿true为上升沿false为下降沿</param>
/// <returns>操作结果</returns>
[HttpPost("UpdateTrigger")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -404,7 +335,6 @@ public class OscilloscopeApiController : ControllerBase
/// <param name="decimationRate">抽样率0-1023</param>
/// <returns>操作结果</returns>
[HttpPost("UpdateSampling")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -453,7 +383,6 @@ public class OscilloscopeApiController : ControllerBase
/// </summary>
/// <returns>操作结果</returns>
[HttpPost("RefreshRAM")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -481,4 +410,4 @@ public class OscilloscopeApiController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
}
}
}
}

View File

@@ -0,0 +1,355 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using DotNext;
using Database;
namespace server.Controllers;
/// <summary>
/// 资源控制器 - 提供统一的资源管理API
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class ResourceController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly UserManager _userManager = new();
private readonly ResourceManager _resourceManager = new();
/// <summary>
/// 添加资源(文件上传)
/// </summary>
/// <param name="request">添加资源请求</param>
/// <param name="file">资源文件</param>
/// <returns>添加结果</returns>
[Authorize]
[HttpPost]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AddResource([FromForm] AddResourceRequest request, IFormFile file)
{
if (string.IsNullOrWhiteSpace(request.ResourceType) || file == null)
return BadRequest("资源类型、资源用途和文件不能为空");
// 模板资源需要管理员权限
if (request.ResourcePurpose == ResourcePurpose.Template && !User.IsInRole("Admin"))
return Forbid("只有管理员可以添加模板资源");
try
{
// 获取当前用户ID
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 读取文件数据
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var fileData = memoryStream.ToArray();
var result = _resourceManager.AddResource(
user.ID, request.ResourceType, request.ResourcePurpose,
file.FileName, fileData, request.ExamID);
if (!result.IsSuccessful)
{
if (result.Error.Message.Contains("不存在"))
return NotFound(result.Error.Message);
logger.Error($"添加资源时出错: {result.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"添加资源失败: {result.Error.Message}");
}
var resourceInfo = new ResourceInfo(result.Value);
logger.Info($"成功添加资源: {request.ResourceType}/{request.ResourcePurpose}/{file.FileName} ID: {resourceInfo.ID}");
return CreatedAtAction(nameof(GetResourceById), new { resourceId = resourceInfo.ID }, resourceInfo);
}
catch (Exception ex)
{
logger.Error($"添加资源 {request.ResourceType}/{request.ResourcePurpose}/{file.FileName} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"添加资源失败: {ex.Message}");
}
}
/// <summary>
/// 获取资源列表
/// </summary>
/// <param name="examId">实验ID可选</param>
/// <param name="resourceType">资源类型(可选)</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <returns>资源列表</returns>
[Authorize]
[HttpGet]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetResourceList(
[FromQuery] string? examId = null,
[FromQuery] string? resourceType = null,
[FromQuery] ResourcePurpose? resourcePurpose = null)
{
try
{
// 获取当前用户ID
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
Result<List<Resource>> result;
// 管理员
if (user.Permission == UserPermission.Admin)
{
result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose);
}
// 用户
else if (resourcePurpose == ResourcePurpose.User)
{
result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose, user.ID);
}
// 模板
else if (resourcePurpose == ResourcePurpose.Template)
{
result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose);
}
// 其他
else
{
// 这种情况下需要分别查询并合并结果
var userResourcesResult = _resourceManager.GetFullResourceList(
examId, resourceType, ResourcePurpose.User, user.ID);
var templateResourcesResult = _resourceManager.GetFullResourceList(
examId, resourceType, ResourcePurpose.Template, null);
if (!userResourcesResult.IsSuccessful || !templateResourcesResult.IsSuccessful)
{
logger.Error($"获取资源列表时出错");
return StatusCode(StatusCodes.Status500InternalServerError, "获取资源列表失败");
}
var allResources = userResourcesResult.Value.Concat(templateResourcesResult.Value)
.OrderByDescending(r => r.UploadTime);
var mergedResourceInfos = allResources.Select(r => new ResourceInfo(r)).ToArray();
logger.Info($"成功获取资源列表,共 {mergedResourceInfos.Length} 个资源");
return Ok(mergedResourceInfos);
}
if (!result.IsSuccessful)
{
logger.Error($"获取资源列表时出错: {result.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源列表失败: {result.Error.Message}");
}
var resources = result.Value.Select(r => new ResourceInfo(r)).ToArray();
logger.Info($"成功获取资源列表,共 {resources.Length} 个资源");
return Ok(resources);
}
catch (Exception ex)
{
logger.Error($"获取资源列表时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源列表失败: {ex.Message}");
}
}
/// <summary>
/// 根据资源ID下载资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>资源文件</returns>
[HttpGet("{resourceId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetResourceById(Guid resourceId)
{
try
{
var result = _resourceManager.GetResourceById(resourceId);
if (!result.HasValue)
{
logger.Warn($"资源不存在: {resourceId}");
return NotFound($"资源 {resourceId} 不存在");
}
var resource = result.Value;
logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
var dataRet = _resourceManager.ReadBytesFromPath(resource.Path);
if (!dataRet.IsSuccessful)
{
logger.Error($"读取资源数据时出错: {dataRet.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"读取资源数据失败: {dataRet.Error.Message}");
}
return File(dataRet.Value, resource.MimeType ?? "application/octet-stream", resource.ResourceName);
}
catch (Exception ex)
{
logger.Error($"获取资源 {resourceId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {ex.Message}");
}
}
/// <summary>
/// 删除资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>删除结果</returns>
[Authorize]
[HttpDelete("{resourceId}")]
[EnableCors("Users")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult DeleteResource(Guid resourceId)
{
try
{
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 先获取资源信息以验证权限
var resourceResult = _resourceManager.GetResourceById(resourceId);
if (!resourceResult.HasValue)
{
logger.Warn($"资源不存在: {resourceId}");
return NotFound($"资源 {resourceId} 不存在");
}
var resource = resourceResult.Value;
// 权限检查:管理员可以删除所有资源,普通用户只能删除自己的用户资源
if (!User.IsInRole("Admin"))
{
if (resource.Purpose == ResourcePurpose.Template)
return Forbid("普通用户不能删除模板资源");
if (resource.UserID != user.ID)
return Forbid("只能删除自己的资源");
}
var deleteResult = _resourceManager.DeleteResource(resourceId);
if (!deleteResult.IsSuccessful)
{
logger.Error($"删除资源时出错: {deleteResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {deleteResult.Error.Message}");
}
logger.Info($"成功删除资源: {resourceId} ({resource.ResourceName})");
return NoContent();
}
catch (Exception ex)
{
logger.Error($"删除资源 {resourceId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {ex.Message}");
}
}
}
/// <summary>
/// 资源信息类
/// </summary>
public class ResourceInfo
{
/// <summary>
/// 资源ID
/// </summary>
public Guid ID { get; set; }
/// <summary>
/// 资源名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 资源类型
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// 资源用途template/user
/// </summary>
public ResourcePurpose Purpose { get; set; }
/// <summary>
/// 上传时间
/// </summary>
public DateTime UploadTime { get; set; }
/// <summary>
/// 所属实验ID可选
/// </summary>
public string? ExamID { get; set; }
/// <summary>
/// MIME类型
/// </summary>
public string? MimeType { get; set; }
public ResourceInfo(Resource resource)
{
ID = resource.ID;
Name = resource.ResourceName;
Type = resource.ResourceType;
Purpose = resource.Purpose;
UploadTime = resource.UploadTime;
ExamID = resource.ExamID;
MimeType = resource.MimeType;
}
}
/// <summary>
/// 添加资源请求类
/// </summary>
public class AddResourceRequest
{
/// <summary>
/// 资源类型
/// </summary>
public required string ResourceType { get; set; }
/// <summary>
/// 资源用途template/user
/// </summary>
public required ResourcePurpose ResourcePurpose { get; set; }
/// <summary>
/// 所属实验ID可选
/// </summary>
public string? ExamID { get; set; }
}

View File

@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
using Peripherals.SwitchClient;
namespace server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SwitchController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取示波器实例
/// </summary>
private SwitchCtrl? GetSwitchCtrl()
{
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return null;
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return null;
var user = userRet.Value.Value;
if (user.BoardID == Guid.Empty)
return null;
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return null;
var board = boardRet.Value.Value;
return new SwitchCtrl(board.IpAddr, board.Port, 0);
}
/// <summary>
/// 启用或禁用 Switch 外设
/// </summary>
/// <param name="enable">是否启用</param>
/// <returns>操作结果</returns>
[HttpPost("enable")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetEnable([FromQuery] bool enable)
{
var switchCtrl = GetSwitchCtrl();
if (switchCtrl == null)
return BadRequest("Can't get user or board info");
var result = await switchCtrl.SetEnable(enable);
if (!result.IsSuccessful)
{
logger.Error(result.Error, "SetEnable failed");
return StatusCode(500, result.Error);
}
return Ok(result.Value);
}
/// <summary>
/// 控制指定编号的 Switch 开关
/// </summary>
/// <param name="num">开关编号</param>
/// <param name="onOff">开/关</param>
/// <returns>操作结果</returns>
[HttpPost("switch")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ArgumentException), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetSwitchOnOff([FromQuery] int num, [FromQuery] bool onOff)
{
if (num <= 0 || num > 6)
return BadRequest(new ArgumentException($"Switch num should be 1~5, instead of {num}"));
var switchCtrl = GetSwitchCtrl();
if (switchCtrl == null)
return BadRequest("Can't get user or board info");
var result = await switchCtrl.SetSwitchOnOff(num, onOff);
if (!result.IsSuccessful)
{
logger.Error(result.Error, $"SetSwitchOnOff({num}, {onOff}) failed");
return StatusCode(500, result.Error);
}
return Ok(result.Value);
}
/// <summary>
/// 控制 Switch 开关
/// </summary>
/// <param name="keyStatus">开关状态</param>
/// <returns>操作结果</returns>
[HttpPost("MultiSwitch")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ArgumentException), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetMultiSwitchsOnOff(bool[] keyStatus)
{
if (keyStatus.Length == 0 || keyStatus.Length > 6) return BadRequest(
new ArgumentException($"Switch num should be 1~5, instead of {keyStatus.Length}"));
var switchCtrl = GetSwitchCtrl();
if (switchCtrl == null)
return BadRequest("Can't get user or board info");
for (int i = 0; i < keyStatus.Length; i++)
{
var result = await switchCtrl.SetSwitchOnOff(i + 1, keyStatus[i]);
if (!result.IsSuccessful)
{
logger.Error(result.Error, $"SetSwitchOnOff({i}, {keyStatus[i]}) failed");
return StatusCode(500, result.Error);
}
if (!result.Value) return Ok(false);
}
return Ok(true);
}
}

View File

@@ -1,121 +1,81 @@
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using DotNext;
using server.Services;
/// <summary>
/// 视频流控制器,支持动态配置摄像头连接
/// </summary>
[ApiController]
[Authorize]
[EnableCors("Users")]
[Route("api/[controller]")]
public class VideoStreamController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly server.Services.HttpVideoStreamService _videoStreamService;
/// <summary>
/// 视频流信息结构体
/// </summary>
public class StreamInfoResult
private readonly HttpVideoStreamService _videoStreamService;
private readonly Database.UserManager _userManager = new();
public class AvailableResolutionsResponse
{
/// <summary>
/// TODO:
/// </summary>
public int FrameRate { get; set; }
/// <summary>
/// TODO:
/// </summary>
public int FrameWidth { get; set; }
/// <summary>
/// TODO:
/// </summary>
public int FrameHeight { get; set; }
/// <summary>
/// TODO:
/// </summary>
public string Format { get; set; } = "MJPEG";
/// <summary>
/// TODO:
/// </summary>
public string HtmlUrl { get; set; } = "";
/// <summary>
/// TODO:
/// </summary>
public string MjpegUrl { get; set; } = "";
/// <summary>
/// TODO:
/// </summary>
public string SnapshotUrl { get; set; } = "";
/// <summary>
/// TODO:
/// </summary>
public string UsbCameraUrl { get; set; } = "";
}
/// <summary>
/// 摄像头配置请求模型
/// </summary>
public class CameraConfigRequest
{
/// <summary>
/// 摄像头地址
/// </summary>
[Required]
[RegularExpression(@"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", ErrorMessage = "IP地址")]
public string Address { get; set; } = "";
/// <summary>
/// 摄像头端口
/// </summary>
[Required]
[Range(1, 65535, ErrorMessage = "端口必须在1-65535范围内")]
public int Port { get; set; }
}
/// <summary>
/// 分辨率配置请求模型
/// </summary>
public class ResolutionConfigRequest
{
/// <summary>
/// 宽度
/// </summary>
[Required]
[Range(640, 1920, ErrorMessage = "宽度必须在640-1920范围内")]
public int Width { get; set; }
/// <summary>
/// 高度
/// </summary>
[Required]
[Range(480, 1080, ErrorMessage = "高度必须在480-1080范围内")]
public int Height { get; set; }
public string Name { get; set; } = string.Empty;
public string Value => $"{Width}x{Height}";
}
/// <summary>
/// 初始化HTTP视频流控制器
/// </summary>
/// <param name="videoStreamService">HTTP视频流服务</param>
public VideoStreamController(server.Services.HttpVideoStreamService videoStreamService)
public VideoStreamController(HttpVideoStreamService videoStreamService)
{
logger.Info("创建VideoStreamController命名空间{Namespace}", this.GetType().Namespace);
_videoStreamService = videoStreamService;
}
private Optional<string> TryGetBoardId()
{
var userName = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
{
logger.Error("User name not found in claims.");
return Optional<string>.None;
}
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
{
logger.Error("User not found.");
return Optional<string>.None;
}
var user = userRet.Value.Value;
var boardId = user.BoardID;
if (boardId == Guid.Empty)
{
logger.Error("No board bound to this user.");
return Optional<string>.None;
}
return boardId.ToString();
}
/// <summary>
/// 获取 HTTP 视频流服务状态
/// </summary>
/// <returns>服务状态信息</returns>
[HttpGet("Status")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[HttpGet("ServiceStatus")]
[ProducesResponseType(typeof(VideoStreamServiceStatus), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public IResult GetStatus()
public IResult GetServiceStatus()
{
try
{
logger.Info("GetStatus方法被调用控制器{Controller}路径api/VideoStream/Status", this.GetType().Name);
// 使用HttpVideoStreamService提供的状态信息
var status = _videoStreamService.GetServiceStatus();
@@ -129,101 +89,17 @@ public class VideoStreamController : ControllerBase
}
}
/// <summary>
/// 获取 HTTP 视频流信息
/// </summary>
/// <returns>流信息</returns>
[HttpGet("StreamInfo")]
[EnableCors("Users")]
[ProducesResponseType(typeof(StreamInfoResult), StatusCodes.Status200OK)]
[HttpGet("MyEndpoint")]
[ProducesResponseType(typeof(VideoStreamEndpoint), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public IResult GetStreamInfo()
public IResult MyEndpoint()
{
try
{
logger.Info("获取 HTTP 视频流信息");
var result = new StreamInfoResult
{
FrameRate = _videoStreamService.FrameRate,
FrameWidth = _videoStreamService.FrameWidth,
FrameHeight = _videoStreamService.FrameHeight,
Format = "MJPEG",
HtmlUrl = $"http://{Global.localhost}:{_videoStreamService.ServerPort}/video-feed.html",
MjpegUrl = $"http://{Global.localhost}:{_videoStreamService.ServerPort}/video-stream",
SnapshotUrl = $"http://{Global.localhost}:{_videoStreamService.ServerPort}/snapshot",
UsbCameraUrl = $"http://{Global.localhost}:{_videoStreamService.ServerPort}/usb-camera"
};
return TypedResults.Ok(result);
}
catch (Exception ex)
{
logger.Error(ex, "获取 HTTP 视频流信息失败");
return TypedResults.InternalServerError(ex.Message);
}
}
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var endpoint = _videoStreamService.GetVideoEndpoint(boardId);
/// <summary>
/// 配置摄像头连接参数
/// </summary>
/// <param name="config">摄像头配置</param>
/// <returns>配置结果</returns>
[HttpPost("ConfigureCamera")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public async Task<IResult> ConfigureCamera([FromBody] CameraConfigRequest config)
{
try
{
logger.Info("配置摄像头连接: {Address}:{Port}", config.Address, config.Port);
var success = await _videoStreamService.ConfigureCameraAsync(config.Address, config.Port);
if (success)
{
return TypedResults.Ok(new
{
success = true,
message = "摄像头配置成功",
cameraAddress = config.Address,
cameraPort = config.Port
});
}
else
{
return TypedResults.BadRequest(new
{
success = false,
message = "摄像头配置失败",
cameraAddress = config.Address,
cameraPort = config.Port
});
}
}
catch (Exception ex)
{
logger.Error(ex, "配置摄像头连接失败");
return TypedResults.InternalServerError(ex.Message);
}
}
/// <summary>
/// 获取当前摄像头配置
/// </summary>
/// <returns>摄像头配置信息</returns>
[HttpGet("CameraConfig")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public IResult GetCameraConfig()
{
try
{
logger.Info("获取摄像头配置");
var cameraStatus = _videoStreamService.GetCameraStatus();
return TypedResults.Ok(cameraStatus);
return TypedResults.Ok(endpoint);
}
catch (Exception ex)
{
@@ -232,60 +108,34 @@ public class VideoStreamController : ControllerBase
}
}
/// <summary>
/// 控制 HTTP 视频流服务开关
/// </summary>
/// <param name="enabled">是否启用服务</param>
/// <returns>操作结果</returns>
[HttpPost("SetEnabled")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public async Task<IResult> SetEnabled([FromQuery] bool enabled)
{
logger.Info("设置视频流服务开关: {Enabled}", enabled);
await _videoStreamService.SetEnable(enabled);
return TypedResults.Ok();
}
/// <summary>
/// 测试 HTTP 视频流连接
/// </summary>
/// <returns>连接测试结果</returns>
[HttpPost("TestConnection")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public async Task<IResult> TestConnection()
{
try
{
logger.Info("测试 HTTP 视频流连接");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var endpoint = _videoStreamService.GetVideoEndpoint(boardId);
// 尝试通过HTTP请求检查视频流服务是否可访问
bool isConnected = false;
using (var httpClient = new HttpClient())
{
httpClient.Timeout = TimeSpan.FromSeconds(2); // 设置较短的超时时间
var response = await httpClient.GetAsync($"http://{Global.localhost}:{_videoStreamService.ServerPort}/");
var response = await httpClient.GetAsync(endpoint.MjpegUrl);
// 只要能连接上就认为成功,不管返回状态
isConnected = response.IsSuccessStatusCode;
}
logger.Info("测试摄像头连接");
var ret = await _videoStreamService.TestCameraConnection(boardId);
var (isSuccess, message) = await _videoStreamService.TestCameraConnectionAsync();
return TypedResults.Ok(new
{
isConnected = isConnected,
success = isSuccess,
message = message,
cameraAddress = _videoStreamService.CameraAddress,
cameraPort = _videoStreamService.CameraPort,
timestamp = DateTime.Now
});
return TypedResults.Ok(ret);
}
catch (Exception ex)
{
@@ -295,6 +145,25 @@ public class VideoStreamController : ControllerBase
}
}
[HttpPost("SetVideoStreamEnable")]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> SetVideoStreamEnable(bool enable)
{
try
{
var boardId = TryGetBoardId().OrThrow(() => new ArgumentException("Board ID is required"));
await _videoStreamService.SetVideoStreamEnableAsync(boardId.ToString(), enable);
return Ok($"HDMI transmission for board {boardId} {enable.ToString()}.");
}
catch (Exception ex)
{
logger.Error(ex, $"Failed to disable HDMI transmission for board");
return StatusCode(500, $"Error disabling HDMI transmission: {ex.Message}");
}
}
/// <summary>
/// 设置视频流分辨率
/// </summary>
@@ -309,16 +178,16 @@ public class VideoStreamController : ControllerBase
{
try
{
logger.Info($"设置视频流分辨率为 {request.Width}x{request.Height}");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var (isSuccess, message) = await _videoStreamService.SetResolutionAsync(request.Width, request.Height);
var ret = await _videoStreamService.SetResolutionAsync(boardId, request.Width, request.Height);
if (isSuccess)
if (ret.IsSuccessful && ret.Value)
{
return TypedResults.Ok(new
{
success = true,
message = message,
message = $"成功设置分辨率为 {request.Width}x{request.Height}",
width = request.Width,
height = request.Height,
timestamp = DateTime.Now
@@ -329,7 +198,7 @@ public class VideoStreamController : ControllerBase
return TypedResults.BadRequest(new
{
success = false,
message = message,
message = ret.Error?.ToString() ?? "未知错误",
timestamp = DateTime.Now
});
}
@@ -341,70 +210,29 @@ public class VideoStreamController : ControllerBase
}
}
/// <summary>
/// 获取当前分辨率
/// </summary>
/// <returns>当前分辨率信息</returns>
[HttpGet("Resolution")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public IResult GetCurrentResolution()
{
try
{
logger.Info("获取当前视频流分辨率");
var (width, height) = _videoStreamService.GetCurrentResolution();
return TypedResults.Ok(new
{
width = width,
height = height,
resolution = $"{width}x{height}",
timestamp = DateTime.Now
});
}
catch (Exception ex)
{
logger.Error(ex, "获取当前分辨率失败");
return TypedResults.InternalServerError($"获取当前分辨率失败: {ex.Message}");
}
}
/// <summary>
/// 获取支持的分辨率列表
/// </summary>
/// <returns>支持的分辨率列表</returns>
[HttpGet("SupportedResolutions")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(AvailableResolutionsResponse[]), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public IResult GetSupportedResolutions()
{
try
// (640, 480, "640x480 (VGA)"),
// (960, 540, "960x540 (qHD)"),
// (1280, 720, "1280x720 (HD)"),
// (1280, 960, "1280x960 (SXGA)"),
// (1920, 1080, "1920x1080 (Full HD)")
return TypedResults.Ok(new AvailableResolutionsResponse[]
{
logger.Info("获取支持的分辨率列表");
var resolutions = _videoStreamService.GetSupportedResolutions();
return TypedResults.Ok(new
{
resolutions = resolutions.Select(r => new
{
width = r.Width,
height = r.Height,
name = r.Name,
value = $"{r.Width}x{r.Height}"
}),
timestamp = DateTime.Now
});
}
catch (Exception ex)
{
logger.Error(ex, "获取支持的分辨率列表失败");
return TypedResults.InternalServerError($"获取支持的分辨率列表失败: {ex.Message}");
}
new AvailableResolutionsResponse { Width = 640, Height = 480, Name = "640x480(VGA)" },
new AvailableResolutionsResponse { Width = 960, Height = 480, Name = "960x480(qHD)" },
new AvailableResolutionsResponse { Width = 1280, Height = 720, Name = "1280x720(HD)" },
new AvailableResolutionsResponse { Width = 1280, Height = 960, Name = "1280x960(SXGA)" },
new AvailableResolutionsResponse { Width = 1920, Height = 1080, Name = "1920x1080(Full HD)" }
});
}
/// <summary>
@@ -420,9 +248,9 @@ public class VideoStreamController : ControllerBase
{
try
{
logger.Info("收到初始化自动对焦请求");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var result = await _videoStreamService.InitAutoFocusAsync();
var result = await _videoStreamService.InitAutoFocusAsync(boardId);
if (result)
{
@@ -465,9 +293,9 @@ public class VideoStreamController : ControllerBase
{
try
{
logger.Info("收到执行自动对焦请求");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var result = await _videoStreamService.PerformAutoFocusAsync();
var result = await _videoStreamService.PerformAutoFocusAsync(boardId);
if (result)
{
@@ -498,59 +326,55 @@ public class VideoStreamController : ControllerBase
}
/// <summary>
/// 执行一次自动对焦 (GET方式)
/// 配置摄像头连接参数
/// </summary>
/// <returns>对焦结果</returns>
[HttpGet("Focus")]
/// <returns>配置结果</returns>
[HttpPost("ConfigureCamera")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public async Task<IResult> Focus()
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public async Task<IResult> ConfigureCamera()
{
try
{
logger.Info("收到执行一次对焦请求 (GET)");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
// 检查摄像头是否已配置
if (!_videoStreamService.IsCameraConfigured())
var ret = await _videoStreamService.ConfigureCameraAsync(boardId);
if (ret)
{
logger.Warn("摄像头未配置,无法执行对焦");
return TypedResults.BadRequest(new
{
success = false,
message = "摄像头未配置,请先配置摄像头连接",
timestamp = DateTime.Now
});
}
var result = await _videoStreamService.PerformAutoFocusAsync();
if (result)
{
logger.Info("对焦执行成功");
return TypedResults.Ok(new
{
success = true,
message = "对焦执行成功",
timestamp = DateTime.Now
});
return TypedResults.Ok(new { Message = "配置成功" });
}
else
{
logger.Warn("对焦执行失败");
return TypedResults.BadRequest(new
{
success = false,
message = "对焦执行失败",
timestamp = DateTime.Now
});
return TypedResults.BadRequest(new { Message = "配置失败" });
}
}
catch (Exception ex)
{
logger.Error(ex, "执行对焦时发生异常");
return TypedResults.InternalServerError($"执行对焦失败: {ex.Message}");
logger.Error(ex, "配置摄像头连接失败");
return TypedResults.InternalServerError(ex.Message);
}
}
/// <summary>
/// 分辨率配置请求模型
/// </summary>
public class ResolutionConfigRequest
{
/// <summary>
/// 宽度
/// </summary>
[Required]
[Range(640, 1920, ErrorMessage = "宽度必须在640-1920范围内")]
public int Width { get; set; }
/// <summary>
/// 高度
/// </summary>
[Required]
[Range(480, 1080, ErrorMessage = "高度必须在480-1080范围内")]
public int Height { get; set; }
}
}

View File

@@ -0,0 +1,98 @@
using DotNext;
using LinqToDB;
using LinqToDB.Data;
namespace Database;
/// <summary>
/// 应用程序数据连接类,用于与数据库交互
/// </summary>
public class AppDataConnection : DataConnection
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
static readonly string DATABASE_FILEPATH = $"{Global.DataPath}/Database.sqlite";
static readonly LinqToDB.DataOptions options =
new LinqToDB.DataOptions().UseSQLite($"Data Source={DATABASE_FILEPATH}");
/// <summary>
/// 用户表
/// </summary>
public ITable<User> UserTable => this.GetTable<User>();
/// <summary>
/// FPGA 板子表
/// </summary>
public ITable<Board> BoardTable => this.GetTable<Board>();
/// <summary>
/// 实验表
/// </summary>
public ITable<Exam> ExamTable => this.GetTable<Exam>();
/// <summary>
/// 资源表(统一管理实验资源、用户比特流等)
/// </summary>
public ITable<Resource> ResourceTable => this.GetTable<Resource>();
/// <summary>
/// 初始化应用程序数据连接
/// </summary>
public AppDataConnection() : base(options)
{
var filePath = Path.GetDirectoryName(DATABASE_FILEPATH);
if (!string.IsNullOrEmpty(filePath) && !Directory.Exists(filePath))
{
Directory.CreateDirectory(filePath);
}
if (!Path.Exists(DATABASE_FILEPATH))
{
logger.Info($"数据库文件不存在,正在创建新数据库: {DATABASE_FILEPATH}");
LinqToDB.DataProvider.SQLite.SQLiteTools.CreateDatabase(DATABASE_FILEPATH);
this.CreateAllTables();
var user = new User()
{
Name = "Admin",
EMail = "selfconfusion@gmail.com",
Password = "12345678",
Permission = Database.UserPermission.Admin,
};
this.Insert(user);
logger.Info("默认管理员用户已创建");
}
else
{
logger.Info($"数据库连接已建立: {DATABASE_FILEPATH}");
}
}
/// <summary>
/// 创建所有数据库表
/// </summary>
public void CreateAllTables()
{
logger.Info("正在创建数据库表...");
this.CreateTable<User>();
this.CreateTable<Board>();
this.CreateTable<Exam>();
this.CreateTable<Resource>();
logger.Info("数据库表创建完成");
}
/// <summary>
/// 删除所有数据库表
/// </summary>
public void DropAllTables()
{
logger.Warn("正在删除所有数据库表...");
this.DropTable<User>();
this.DropTable<Board>();
this.DropTable<Exam>();
this.DropTable<Resource>();
logger.Warn("所有数据库表已删除");
}
}

View File

@@ -0,0 +1,149 @@
using DotNext;
using LinqToDB;
using LinqToDB.Data;
namespace Database;
public class ExamManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private AppDataConnection _db = new();
/// <summary>
/// 创建新实验
/// </summary>
/// <param name="id">实验ID</param>
/// <param name="name">实验名称</param>
/// <param name="description">实验描述</param>
/// <param name="tags">实验标签</param>
/// <param name="difficulty">实验难度</param>
/// <param name="isVisibleToUsers">普通用户是否可见</param>
/// <returns>创建的实验</returns>
public Result<Exam> CreateExam(string id, string name, string description, string[]? tags = null, int difficulty = 1, bool isVisibleToUsers = true)
{
try
{
// 检查实验ID是否已存在
var existingExam = _db.ExamTable.Where(e => e.ID.ToString() == id).FirstOrDefault();
if (existingExam != null)
{
logger.Error($"实验ID已存在: {id}");
return new(new Exception($"实验ID已存在: {id}"));
}
var exam = new Exam
{
ID = id,
Name = name,
Description = description,
Difficulty = Math.Max(1, Math.Min(5, difficulty)),
IsVisibleToUsers = isVisibleToUsers,
CreatedTime = DateTime.Now,
UpdatedTime = DateTime.Now
};
if (tags != null)
{
exam.SetTagsList(tags);
}
_db.Insert(exam);
logger.Info($"新实验已创建: {id} ({name})");
return new(exam);
}
catch (Exception ex)
{
logger.Error($"创建实验时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 更新实验信息
/// </summary>
/// <param name="id">实验ID</param>
/// <param name="name">实验名称</param>
/// <param name="description">实验描述</param>
/// <param name="tags">实验标签</param>
/// <param name="difficulty">实验难度</param>
/// <param name="isVisibleToUsers">普通用户是否可见</param>
/// <returns>更新的记录数</returns>
public Result<int> UpdateExam(string id, string? name = null, string? description = null, string[]? tags = null, int? difficulty = null, bool? isVisibleToUsers = null)
{
try
{
int result = 0;
if (name != null)
{
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.Name, name).Update();
}
if (description != null)
{
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.Description, description).Update();
}
if (tags != null)
{
var tagsString = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim()));
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.Tags, tagsString).Update();
}
if (difficulty.HasValue)
{
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.Difficulty, Math.Max(1, Math.Min(5, difficulty.Value))).Update();
}
if (isVisibleToUsers.HasValue)
{
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.IsVisibleToUsers, isVisibleToUsers.Value).Update();
}
// 更新时间
_db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.UpdatedTime, DateTime.Now).Update();
logger.Info($"实验已更新: {id},更新记录数: {result}");
return new(result);
}
catch (Exception ex)
{
logger.Error($"更新实验时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 获取所有实验信息
/// </summary>
/// <returns>所有实验的数组</returns>
public Exam[] GetAllExams()
{
var exams = _db.ExamTable.OrderBy(e => e.ID).ToArray();
logger.Debug($"获取所有实验,共 {exams.Length} 个");
return exams;
}
/// <summary>
/// 根据实验ID获取实验信息
/// </summary>
/// <param name="examId">实验ID</param>
/// <returns>包含实验信息的结果,如果未找到则返回空</returns>
public Result<Optional<Exam>> GetExamByID(string examId)
{
var exams = _db.ExamTable.Where(exam => exam.ID.ToString() == examId).ToArray();
if (exams.Length > 1)
{
logger.Error($"数据库中存在多个相同ID的实验: {examId}");
return new(new Exception($"数据库中存在多个相同ID的实验: {examId}"));
}
if (exams.Length == 0)
{
logger.Info($"未找到ID对应的实验: {examId}");
return new(Optional<Exam>.None);
}
logger.Debug($"成功获取实验信息: {examId}");
return new(exams[0]);
}
}

View File

@@ -0,0 +1,350 @@
using DotNext;
using LinqToDB;
using LinqToDB.Data;
using System.Security.Cryptography;
namespace Database;
public class ResourceManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly AppDataConnection _db = new();
/// <summary>
/// 根据文件扩展名获取MIME类型
/// </summary>
/// <param name="extension">文件扩展名</param>
/// <param name="fileName">文件名(可选,用于特殊文件判断)</param>
/// <returns>MIME类型</returns>
private string GetMimeTypeFromExtension(string extension, string fileName = "")
{
// 特殊文件名处理
if (!string.IsNullOrEmpty(fileName) && fileName.Equals("diagram.json", StringComparison.OrdinalIgnoreCase))
{
return "application/json";
}
return extension.ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".bmp" => "image/bmp",
".svg" => "image/svg+xml",
".bit" => "application/octet-stream",
".sbit" => "application/octet-stream",
".bin" => "application/octet-stream",
".mcs" => "application/octet-stream",
".hex" => "text/plain",
".json" => "application/json",
".zip" => "application/zip",
".md" => "text/markdown",
_ => "application/octet-stream"
};
}
/// <summary>
/// 将二进制数据写入指定路径
/// </summary>
/// <param name="path">目标文件路径</param>
/// <param name="data">要写入的二进制数据</param>
/// <returns>写入是否成功</returns>
public Result<bool> WriteBytesToPath(string path, byte[] data)
{
try
{
var filePath = Path.Combine(Global.DataPath, path);
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllBytes(filePath, data);
logger.Info($"成功写入文件: {filePath},大小: {data.Length} bytes");
return new(true);
}
catch (Exception ex)
{
logger.Error($"写入文件时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 从指定路径读取二进制数据
/// </summary>
/// <param name="path">要读取的文件路径</param>
/// <returns>读取到的二进制数据</returns>
public Result<byte[]> ReadBytesFromPath(string path)
{
try
{
var filePath = Path.Combine(Global.DataPath, path);
if (!File.Exists(filePath))
{
logger.Error($"文件不存在: {filePath}");
return new(new Exception($"文件不存在: {filePath}"));
}
var data = File.ReadAllBytes(filePath);
logger.Info($"成功读取文件: {filePath},大小: {data.Length} bytes");
return new(data);
}
catch (Exception ex)
{
logger.Error($"读取文件时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 添加资源
/// </summary>
/// <param name="userId">上传用户ID</param>
/// <param name="resourceType">资源类型</param>
/// <param name="resourcePurpose">资源用途template 或 user</param>
/// <param name="resourceName">资源名称</param>
/// <param name="data">资源二进制数据</param>
/// <param name="examId">所属实验ID可选</param>
/// <param name="mimeType">MIME类型可选将根据文件扩展名自动确定</param>
/// <returns>创建的资源</returns>
public Result<Resource> AddResource(
Guid userId, string resourceType, ResourcePurpose resourcePurpose,
string resourceName, byte[] data, string? examId = null, string? mimeType = null)
{
try
{
// 验证用户是否存在
var user = _db.UserTable.Where(u => u.ID == userId).FirstOrDefault();
if (user == null)
{
logger.Error($"用户不存在: {userId}");
return new(new Exception($"用户不存在: {userId}"));
}
// 如果指定了实验ID验证实验是否存在
if (!string.IsNullOrEmpty(examId))
{
var exam = _db.ExamTable.Where(e => e.ID.ToString() == examId).FirstOrDefault();
if (exam == null)
{
logger.Error($"实验不存在: {examId}");
return new(new Exception($"实验不存在: {examId}"));
}
}
// 验证资源用途
if (resourcePurpose != ResourcePurpose.Template &&
resourcePurpose != ResourcePurpose.User &&
resourcePurpose != ResourcePurpose.Homework)
{
logger.Error($"无效的资源用途: {resourcePurpose}");
return new(new Exception($"无效的资源用途: {resourcePurpose}"));
}
// 如果未指定MIME类型根据文件扩展名自动确定
if (string.IsNullOrEmpty(mimeType))
{
var extension = Path.GetExtension(resourceName).ToLowerInvariant();
mimeType = GetMimeTypeFromExtension(extension, resourceName);
}
// 计算数据的SHA256
var sha256Bytes = SHA256.HashData(data);
var sha256 = Common.String.BytesToBase64(sha256Bytes);
if (string.IsNullOrEmpty(sha256))
{
logger.Error($"SHA256计算失败");
return new(new Exception("SHA256计算失败"));
}
var duplicateResource = _db.ResourceTable.Where(r => r.SHA256 == sha256).FirstOrDefault();
if (duplicateResource != null && duplicateResource.ResourceName == resourceName)
{
logger.Info($"资源已存在: {resourceName}, ID: {duplicateResource.ID}, UserID: {duplicateResource.UserID}");
return duplicateResource;
}
var nowTime = DateTime.Now;
var resource = new Resource
{
UserID = userId,
ExamID = examId,
ResourceType = resourceType,
Purpose = resourcePurpose,
ResourceName = resourceName,
Path = duplicateResource == null ?
Path.Combine(resourceType, nowTime.ToString("yyyyMMddHH"), resourceName) :
duplicateResource.Path,
SHA256 = sha256,
MimeType = mimeType,
UploadTime = nowTime
};
var insertedId = _db.Insert(resource);
var writeRet = WriteBytesToPath(resource.Path, data);
if (writeRet.IsSuccessful && writeRet.Value)
{
logger.Info($"新资源已添加: {userId}/{resourceType}/{resourcePurpose}/{resourceName} ({data.Length} bytes)" +
(examId != null ? $" [实验: {examId}]" : "") + $" [ID: {resource.ID}]");
return new(resource);
}
else
{
_db.ResourceTable.Where(r => r.ID == resource.ID).Delete();
logger.Error($"写入资源文件时出错: {writeRet.Error}");
return new(new Exception(writeRet.Error?.ToString() ?? $"写入失败"));
}
}
catch (Exception ex)
{
logger.Error($"添加资源时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 获取资源信息列表返回ID和名称
/// <param name="resourceType">资源类型</param>
/// <param name="examId">实验ID可选</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <param name="userId">用户ID可选</param>
/// </summary>
/// <returns>资源信息列表</returns>
public Result<Resource[]> GetResourceListByType(
string resourceType,
ResourcePurpose? resourcePurpose = null,
string? examId = null,
Guid? userId = null)
{
try
{
var query = _db.ResourceTable.Where(r => r.ResourceType == resourceType);
if (examId != null)
{
query = query.Where(r => r.ExamID == examId);
}
if (resourcePurpose != null)
{
query = query.Where(r => r.Purpose == resourcePurpose);
}
if (userId != null)
{
query = query.Where(r => r.UserID == userId);
}
var resources = query.ToArray();
logger.Info($"获取资源列表: {resourceType}" +
(examId != null ? $"/{examId}" : "") +
($"/{resourcePurpose.ToString()}") +
(userId != null ? $"/{userId}" : "") +
$",共 {resources.Length} 个资源");
return new(resources);
}
catch (Exception ex)
{
logger.Error($"获取资源列表时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 获取完整的资源列表
/// </summary>
/// <param name="examId">实验ID可选</param>
/// <param name="resourceType">资源类型(可选)</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <param name="userId">用户ID可选</param>
/// <returns>完整的资源对象列表</returns>
public Result<List<Resource>> GetFullResourceList(
string? examId = null,
string? resourceType = null,
ResourcePurpose? resourcePurpose = null,
Guid? userId = null)
{
try
{
var query = _db.ResourceTable.AsQueryable();
if (examId != null)
{
query = query.Where(r => r.ExamID == examId);
}
if (resourceType != null)
{
query = query.Where(r => r.ResourceType == resourceType);
}
if (resourcePurpose != null)
{
query = query.Where(r => r.Purpose == resourcePurpose);
}
if (userId != null)
{
query = query.Where(r => r.UserID == userId);
}
var resources = query.OrderByDescending(r => r.UploadTime).ToList();
logger.Info($"获取完整资源列表" +
(examId != null ? $" [实验: {examId}]" : "") +
(resourceType != null ? $" [类型: {resourceType}]" : "") +
($" [用途: {resourcePurpose.ToString()}]") +
(userId != null ? $" [用户: {userId}]" : "") +
$",共 {resources.Count} 个资源");
return new(resources);
}
catch (Exception ex)
{
logger.Error($"获取完整资源列表时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 根据资源ID获取资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>资源数据</returns>
public Optional<Resource> GetResourceById(Guid resourceId)
{
var resource = _db.ResourceTable.Where(r => r.ID == resourceId).FirstOrDefault();
if (resource == null)
{
logger.Info($"未找到资源: {resourceId}");
return new(null);
}
logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
return new(resource);
}
/// <summary>
/// 删除资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>删除的记录数</returns>
public Result<int> DeleteResource(Guid resourceId)
{
try
{
var result = _db.ResourceTable.Where(r => r.ID == resourceId).Delete();
logger.Info($"资源已删除: {resourceId},删除记录数: {result}");
return new(result);
}
catch (Exception ex)
{
logger.Error($"删除资源时出错: {ex.Message}");
return new(ex);
}
}
}

352
server/src/Database/Type.cs Normal file
View File

@@ -0,0 +1,352 @@
using DotNext;
using LinqToDB;
using LinqToDB.Mapping;
using Tapper;
namespace Database;
/// <summary>
/// 用户权限枚举
/// </summary>
public enum UserPermission
{
/// <summary>
/// 管理员权限,可以管理用户和实验板
/// </summary>
Admin,
/// <summary>
/// 普通用户权限,只能使用实验板
/// </summary>
Normal,
}
/// <summary>
/// 用户类,表示用户信息
/// </summary>
public class User
{
/// <summary>
/// 用户的唯一标识符
/// </summary>
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
/// <summary>
/// 用户的名称
/// </summary>
[NotNull]
public required string Name { get; set; }
/// <summary>
/// 用户的电子邮箱
/// </summary>
[NotNull]
public required string EMail { get; set; }
/// <summary>
/// 用户的密码(应该进行哈希处理)
/// </summary>
[NotNull]
public required string Password { get; set; }
/// <summary>
/// 用户权限等级
/// </summary>
[NotNull]
public required UserPermission Permission { get; set; }
/// <summary>
/// 绑定的实验板ID如果未绑定则为空
/// </summary>
[Nullable]
public Guid BoardID { get; set; }
/// <summary>
/// 用户绑定板子的过期时间
/// </summary>
[Nullable]
public DateTime? BoardExpireTime { get; set; }
}
/// <summary>
/// FPGA 板子状态枚举
/// </summary>
public enum BoardStatus
{
/// <summary>
/// 未启用状态,无法被使用
/// </summary>
Disabled,
/// <summary>
/// 繁忙状态,正在被用户使用
/// </summary>
Busy,
/// <summary>
/// 可用状态,可以被分配给用户
/// </summary>
Available,
}
/// <summary>
/// FPGA 板子类,表示板子信息
/// </summary>
public class Board
{
/// <summary>
/// FPGA 板子的唯一标识符
/// </summary>
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
/// <summary>
/// FPGA 板子的名称
/// </summary>
[NotNull]
public required string BoardName { get; set; }
/// <summary>
/// FPGA 板子的IP地址
/// </summary>
[NotNull]
public required string IpAddr { get; set; }
/// <summary>
/// FPGA 板子的MAC地址
/// </summary>
[NotNull]
public required string MacAddr { get; set; }
/// <summary>
/// FPGA 板子的通信端口
/// </summary>
[NotNull]
public int Port { get; set; } = 1234;
/// <summary>
/// FPGA 板子的当前状态
/// </summary>
[NotNull]
public required BoardStatus Status { get; set; }
/// <summary>
/// 占用该板子的用户的唯一标识符
/// </summary>
[Nullable]
public Guid OccupiedUserID { get; set; }
/// <summary>
/// 占用该板子的用户的用户名
/// </summary>
[Nullable]
public string? OccupiedUserName { get; set; }
/// <summary>
/// FPGA 板子的固件版本号
/// </summary>
[NotNull]
public string FirmVersion { get; set; } = "1.0.0";
}
/// <summary>
/// 实验类,表示实验信息
/// </summary>
public class Exam
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
[PrimaryKey]
public required string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
[NotNull]
public required string Name { get; set; }
/// <summary>
/// 实验描述
/// </summary>
[NotNull]
public required string Description { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
[NotNull]
public DateTime CreatedTime { get; set; } = DateTime.Now;
/// <summary>
/// 实验最后更新时间
/// </summary>
[NotNull]
public DateTime UpdatedTime { get; set; } = DateTime.Now;
/// <summary>
/// 实验标签(以逗号分隔的字符串)
/// </summary>
[NotNull]
public string Tags { get; set; } = "";
/// <summary>
/// 实验难度1-51为最简单
/// </summary>
[NotNull]
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
[NotNull]
public bool IsVisibleToUsers { get; set; } = true;
/// <summary>
/// 获取标签列表
/// </summary>
/// <returns>标签数组</returns>
public string[] GetTagsList()
{
if (string.IsNullOrWhiteSpace(Tags))
return Array.Empty<string>();
return Tags.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(tag => tag.Trim())
.Where(tag => !string.IsNullOrEmpty(tag))
.ToArray();
}
/// <summary>
/// 设置标签列表
/// </summary>
/// <param name="tags">标签数组</param>
public void SetTagsList(string[] tags)
{
Tags = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim()));
}
}
/// <summary>
/// 资源类型枚举
/// </summary>
[TranspilationSource]
public static class ResourceTypes
{
/// <summary>
/// 图片资源类型
/// </summary>
public const string Images = "images";
/// <summary>
/// Markdown文档资源类型
/// </summary>
public const string Markdown = "markdown";
/// <summary>
/// 比特流文件资源类型
/// </summary>
public const string Bitstream = "bitstream";
/// <summary>
/// 原理图资源类型
/// </summary>
public const string Diagram = "diagram";
/// <summary>
/// 项目文件资源类型
/// </summary>
public const string Project = "project";
/// <summary>
/// 压缩文件资源类型
/// </summary>
public const string Compression = "compression";
}
public enum ResourcePurpose : int
{
/// <summary>
/// 模板资源,通常由管理员上传,供用户参考
/// </summary>
Template,
/// <summary>
/// 用户上传的资源
/// </summary>
User,
/// <summary>
/// 用户提交的作业
/// </summary>
Homework
}
/// <summary>
/// 资源类,统一管理实验资源、用户比特流等各类资源
/// </summary>
public class Resource
{
/// <summary>
/// 资源的唯一标识符
/// </summary>
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
/// <summary>
/// 上传资源的用户ID
/// </summary>
[NotNull]
public required Guid UserID { get; set; }
/// <summary>
/// 所属实验ID可选如果不属于特定实验则为空
/// </summary>
[Nullable]
public string? ExamID { get; set; }
/// <summary>
/// 资源类型images, markdown, bitstream, diagram, project等
/// </summary>
[NotNull]
public required string ResourceType { get; set; }
/// <summary>
/// 资源用途template模板或 user用户上传
/// </summary>
[NotNull]
public required ResourcePurpose Purpose { get; set; }
/// <summary>
/// 资源名称(包含文件扩展名)
/// </summary>
[NotNull]
public required string ResourceName { get; set; }
/// <summary>
/// 资源路径(包含文件名和扩展名)
/// </summary>
[NotNull]
public required string Path { get; set; }
/// <summary>
/// 资源SHA256哈希值
/// </summary>
[NotNull]
public required string SHA256 { get; set; }
/// <summary>
/// 资源创建/上传时间
/// </summary>
[NotNull]
public DateTime UploadTime { get; set; } = DateTime.Now;
/// <summary>
/// 资源的MIME类型
/// </summary>
[NotNull]
public string MimeType { get; set; } = "application/octet-stream";
}

View File

@@ -1,247 +1,14 @@
using DotNext;
using LinqToDB;
using LinqToDB.Data;
using LinqToDB.Mapping;
namespace Database;
/// <summary>
/// 用户类,表示用户信息
/// </summary>
public class User
{
/// <summary>
/// 用户的唯一标识符
/// </summary>
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
/// <summary>
/// 用户的名称
/// </summary>
[NotNull]
public required string Name { get; set; }
/// <summary>
/// 用户的电子邮箱
/// </summary>
[NotNull]
public required string EMail { get; set; }
/// <summary>
/// 用户的密码(应该进行哈希处理)
/// </summary>
[NotNull]
public required string Password { get; set; }
/// <summary>
/// 用户权限等级
/// </summary>
[NotNull]
public required UserPermission Permission { get; set; }
/// <summary>
/// 绑定的实验板ID如果未绑定则为空
/// </summary>
[Nullable]
public Guid BoardID { get; set; }
/// <summary>
/// 用户绑定板子的过期时间
/// </summary>
[Nullable]
public DateTime? BoardExpireTime { get; set; }
/// <summary>
/// 用户权限枚举
/// </summary>
public enum UserPermission
{
/// <summary>
/// 管理员权限,可以管理用户和实验板
/// </summary>
Admin,
/// <summary>
/// 普通用户权限,只能使用实验板
/// </summary>
Normal,
}
}
/// <summary>
/// FPGA 板子类,表示板子信息
/// </summary>
public class Board
{
/// <summary>
/// FPGA 板子的唯一标识符
/// </summary>
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
/// <summary>
/// FPGA 板子的名称
/// </summary>
[NotNull]
public required string BoardName { get; set; }
/// <summary>
/// FPGA 板子的IP地址
/// </summary>
[NotNull]
public required string IpAddr { get; set; }
/// <summary>
/// FPGA 板子的MAC地址
/// </summary>
[NotNull]
public required string MacAddr { get; set; }
/// <summary>
/// FPGA 板子的通信端口
/// </summary>
[NotNull]
public int Port { get; set; } = 1234;
/// <summary>
/// FPGA 板子的当前状态
/// </summary>
[NotNull]
public required BoardStatus Status { get; set; }
/// <summary>
/// 占用该板子的用户的唯一标识符
/// </summary>
[Nullable]
public Guid OccupiedUserID { get; set; }
/// <summary>
/// 占用该板子的用户的用户名
/// </summary>
[Nullable]
public string? OccupiedUserName { get; set; }
/// <summary>
/// FPGA 板子的固件版本号
/// </summary>
[NotNull]
public string FirmVersion { get; set; } = "1.0.0";
/// <summary>
/// FPGA 板子状态枚举
/// </summary>
public enum BoardStatus
{
/// <summary>
/// 未启用状态,无法被使用
/// </summary>
Disabled,
/// <summary>
/// 繁忙状态,正在被用户使用
/// </summary>
Busy,
/// <summary>
/// 可用状态,可以被分配给用户
/// </summary>
Available,
}
}
/// <summary>
/// 实验类,表示实验信息
/// </summary>
public class Exam
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
[PrimaryKey]
public required string ID { get; set; }
/// <summary>
/// 实验文档内容Markdown格式
/// </summary>
[NotNull]
public required string DocContent { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
[NotNull]
public DateTime CreatedTime { get; set; } = DateTime.Now;
/// <summary>
/// 实验最后更新时间
/// </summary>
[NotNull]
public DateTime UpdatedTime { get; set; } = DateTime.Now;
}
/// <summary>
/// 应用程序数据连接类,用于与数据库交互
/// </summary>
public class AppDataConnection : DataConnection
public class UserManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
static readonly string DATABASE_FILEPATH = $"{Environment.CurrentDirectory}/Database.sqlite";
static readonly LinqToDB.DataOptions options =
new LinqToDB.DataOptions().UseSQLite($"Data Source={DATABASE_FILEPATH}");
/// <summary>
/// 初始化应用程序数据连接
/// </summary>
public AppDataConnection() : base(options)
{
if (!Path.Exists(DATABASE_FILEPATH))
{
logger.Info($"数据库文件不存在,正在创建新数据库: {DATABASE_FILEPATH}");
LinqToDB.DataProvider.SQLite.SQLiteTools.CreateDatabase(DATABASE_FILEPATH);
this.CreateAllTables();
var user = new User()
{
Name = "Admin",
EMail = "selfconfusion@gmail.com",
Password = "12345678",
Permission = Database.User.UserPermission.Admin,
};
this.Insert(user);
logger.Info("默认管理员用户已创建");
}
else
{
logger.Info($"数据库连接已建立: {DATABASE_FILEPATH}");
}
}
/// <summary>
/// 创建所有数据库表
/// </summary>
public void CreateAllTables()
{
logger.Info("正在创建数据库表...");
this.CreateTable<User>();
this.CreateTable<Board>();
this.CreateTable<Exam>();
logger.Info("数据库表创建完成");
}
/// <summary>
/// 删除所有数据库表
/// </summary>
public void DropAllTables()
{
logger.Warn("正在删除所有数据库表...");
this.DropTable<User>();
this.DropTable<Board>();
this.DropTable<Exam>();
logger.Warn("所有数据库表已删除");
}
private readonly AppDataConnection _db = new();
/// <summary>
/// 添加一个新的用户到数据库
@@ -257,9 +24,9 @@ public class AppDataConnection : DataConnection
Name = name,
EMail = email,
Password = password,
Permission = Database.User.UserPermission.Normal,
Permission = UserPermission.Normal,
};
var result = this.Insert(user);
var result = _db.Insert(user);
logger.Info($"新用户已添加: {name} ({email})");
return result;
}
@@ -271,7 +38,7 @@ public class AppDataConnection : DataConnection
/// <returns>包含用户信息的结果,如果未找到或出错则返回相应状态</returns>
public Result<Optional<User>> GetUserByName(string name)
{
var user = this.UserTable.Where((user) => user.Name == name).ToArray();
var user = _db.UserTable.Where((user) => user.Name == name).ToArray();
if (user.Length > 1)
{
@@ -296,7 +63,7 @@ public class AppDataConnection : DataConnection
/// <returns>包含用户信息的结果,如果未找到或出错则返回相应状态</returns>
public Result<Optional<User>> GetUserByEMail(string email)
{
var user = this.UserTable.Where((user) => user.EMail == email).ToArray();
var user = _db.UserTable.Where((user) => user.EMail == email).ToArray();
if (user.Length > 1)
{
@@ -322,7 +89,7 @@ public class AppDataConnection : DataConnection
/// <returns>如果密码正确返回用户信息,否则返回空</returns>
public Result<Optional<User>> CheckUserPassword(string name, string password)
{
var ret = this.GetUserByName(name);
var ret = GetUserByName(name);
if (!ret.IsSuccessful)
return new(ret.Error);
@@ -353,7 +120,7 @@ public class AppDataConnection : DataConnection
public int BindUserToBoard(Guid userId, Guid boardId, DateTime expireTime)
{
// 获取用户信息
var user = this.UserTable.Where(u => u.ID == userId).FirstOrDefault();
var user = _db.UserTable.Where(u => u.ID == userId).FirstOrDefault();
if (user == null)
{
logger.Error($"未找到用户: {userId}");
@@ -361,16 +128,16 @@ public class AppDataConnection : DataConnection
}
// 更新用户的板子绑定信息
var userResult = this.UserTable
var userResult = _db.UserTable
.Where(u => u.ID == userId)
.Set(u => u.BoardID, boardId)
.Set(u => u.BoardExpireTime, expireTime)
.Update();
// 更新板子的用户绑定信息
var boardResult = this.BoardTable
var boardResult = _db.BoardTable
.Where(b => b.ID == boardId)
.Set(b => b.Status, Board.BoardStatus.Busy)
.Set(b => b.Status, BoardStatus.Busy)
.Set(b => b.OccupiedUserID, userId)
.Set(b => b.OccupiedUserName, user.Name)
.Update();
@@ -387,11 +154,11 @@ public class AppDataConnection : DataConnection
public int UnbindUserFromBoard(Guid userId)
{
// 获取用户当前绑定的板子ID
var user = this.UserTable.Where(u => u.ID == userId).FirstOrDefault();
var user = _db.UserTable.Where(u => u.ID == userId).FirstOrDefault();
Guid boardId = user?.BoardID ?? Guid.Empty;
// 清空用户的板子绑定信息
var userResult = this.UserTable
var userResult = _db.UserTable
.Where(u => u.ID == userId)
.Set(u => u.BoardID, Guid.Empty)
.Set(u => u.BoardExpireTime, (DateTime?)null)
@@ -401,9 +168,9 @@ public class AppDataConnection : DataConnection
int boardResult = 0;
if (boardId != Guid.Empty)
{
boardResult = this.BoardTable
boardResult = _db.BoardTable
.Where(b => b.ID == boardId)
.Set(b => b.Status, Board.BoardStatus.Available)
.Set(b => b.Status, BoardStatus.Available)
.Set(b => b.OccupiedUserID, Guid.Empty)
.Set(b => b.OccupiedUserName, (string?)null)
.Update();
@@ -420,7 +187,7 @@ public class AppDataConnection : DataConnection
/// <returns>分配的IP地址字符串</returns>
public string AllocateIpAddr()
{
var usedIps = this.BoardTable.Select(b => b.IpAddr).ToArray();
var usedIps = _db.BoardTable.Select(b => b.IpAddr).ToArray();
for (int i = 1; i <= 254; i++)
{
string ip = $"169.254.109.{i}";
@@ -436,7 +203,7 @@ public class AppDataConnection : DataConnection
/// <returns>分配的MAC地址字符串</returns>
public string AllocateMacAddr()
{
var usedMacs = this.BoardTable.Select(b => b.MacAddr).ToArray();
var usedMacs = _db.BoardTable.Select(b => b.MacAddr).ToArray();
// 以 02-00-00-xx-xx-xx 格式分配02 表示本地管理地址
for (int i = 1; i <= 0xFFFFFF; i++)
{
@@ -464,9 +231,9 @@ public class AppDataConnection : DataConnection
BoardName = name,
IpAddr = AllocateIpAddr(),
MacAddr = AllocateMacAddr(),
Status = Database.Board.BoardStatus.Disabled,
Status = BoardStatus.Disabled,
};
var result = this.Insert(board);
var result = _db.Insert(board);
logger.Info($"新实验板已添加: {name} ({board.IpAddr}:{board.MacAddr})");
return board.ID;
}
@@ -479,7 +246,7 @@ public class AppDataConnection : DataConnection
public int DeleteBoardByName(string name)
{
// 先获取要删除的板子信息
var board = this.BoardTable.Where(b => b.BoardName == name).FirstOrDefault();
var board = _db.BoardTable.Where(b => b.BoardName == name).FirstOrDefault();
if (board == null)
{
logger.Warn($"未找到名称为 {name} 的实验板");
@@ -489,7 +256,7 @@ public class AppDataConnection : DataConnection
// 如果板子被占用,先解除绑定
if (board.OccupiedUserID != Guid.Empty)
{
this.UserTable
_db.UserTable
.Where(u => u.ID == board.OccupiedUserID)
.Set(u => u.BoardID, Guid.Empty)
.Set(u => u.BoardExpireTime, (DateTime?)null)
@@ -497,7 +264,7 @@ public class AppDataConnection : DataConnection
logger.Info($"已解除用户 {board.OccupiedUserID} 与实验板 {name} 的绑定");
}
var result = this.BoardTable.Where(b => b.BoardName == name).Delete();
var result = _db.BoardTable.Where(b => b.BoardName == name).Delete();
logger.Info($"实验板已删除: {name},删除记录数: {result}");
return result;
}
@@ -510,7 +277,7 @@ public class AppDataConnection : DataConnection
public int DeleteBoardByID(Guid id)
{
// 先获取要删除的板子信息
var board = this.BoardTable.Where(b => b.ID == id).FirstOrDefault();
var board = _db.BoardTable.Where(b => b.ID == id).FirstOrDefault();
if (board == null)
{
logger.Warn($"未找到ID为 {id} 的实验板");
@@ -520,7 +287,7 @@ public class AppDataConnection : DataConnection
// 如果板子被占用,先解除绑定
if (board.OccupiedUserID != Guid.Empty)
{
this.UserTable
_db.UserTable
.Where(u => u.ID == board.OccupiedUserID)
.Set(u => u.BoardID, Guid.Empty)
.Set(u => u.BoardExpireTime, (DateTime?)null)
@@ -528,7 +295,7 @@ public class AppDataConnection : DataConnection
logger.Info($"已解除用户 {board.OccupiedUserID} 与实验板 {id} 的绑定");
}
var result = this.BoardTable.Where(b => b.ID == id).Delete();
var result = _db.BoardTable.Where(b => b.ID == id).Delete();
logger.Info($"实验板已删除: {id},删除记录数: {result}");
return result;
}
@@ -540,7 +307,7 @@ public class AppDataConnection : DataConnection
/// <returns>包含实验板信息的结果,如果未找到则返回空</returns>
public Result<Optional<Board>> GetBoardByID(Guid id)
{
var boards = this.BoardTable.Where(board => board.ID == id).ToArray();
var boards = _db.BoardTable.Where(board => board.ID == id).ToArray();
if (boards.Length > 1)
{
@@ -558,13 +325,38 @@ public class AppDataConnection : DataConnection
return new(boards[0]);
}
/// <summary>
/// 根据用户名获取实验板信息
/// </summary>
/// <param name="userName">用户名</param>
/// <returns>包含实验板信息的结果,如果未找到则返回空</returns>
public Result<Optional<Board>> GetBoardByUserName(string userName)
{
var boards = _db.BoardTable.Where(board => board.OccupiedUserName == userName).ToArray();
if (boards.Length > 1)
{
logger.Error($"数据库中存在多个相同用户名的实验板: {userName}");
return new(new Exception($"数据库中存在多个相同用户名的实验板: {userName}"));
}
if (boards.Length == 0)
{
logger.Info($"未找到用户名对应的实验板: {userName}");
return new(Optional<Board>.None);
}
logger.Debug($"成功获取实验板信息: {userName}");
return new(boards[0]);
}
/// <summary>
/// 获取所有实验板信息
/// </summary>
/// <returns>所有实验板的数组</returns>
public Board[] GetAllBoard()
{
var boards = this.BoardTable.ToArray();
var boards = _db.BoardTable.ToArray();
logger.Debug($"获取所有实验板,共 {boards.Length} 块");
return boards;
}
@@ -577,8 +369,8 @@ public class AppDataConnection : DataConnection
/// <returns>可用的实验板,如果没有可用的板子则返回空</returns>
public Optional<Board> GetAvailableBoard(Guid userId, DateTime expireTime)
{
var boards = this.BoardTable.Where(
(board) => board.Status == Database.Board.BoardStatus.Available
var boards = _db.BoardTable.Where(
(board) => board.Status == BoardStatus.Available
).ToArray();
if (boards.Length == 0)
@@ -589,7 +381,7 @@ public class AppDataConnection : DataConnection
else
{
var board = boards[0];
var user = this.UserTable.Where(u => u.ID == userId).FirstOrDefault();
var user = _db.UserTable.Where(u => u.ID == userId).FirstOrDefault();
if (user == null)
{
@@ -598,21 +390,21 @@ public class AppDataConnection : DataConnection
}
// 更新板子状态和用户绑定信息
this.BoardTable
_db.BoardTable
.Where(target => target.ID == board.ID)
.Set(target => target.Status, Board.BoardStatus.Busy)
.Set(target => target.Status, BoardStatus.Busy)
.Set(target => target.OccupiedUserID, userId)
.Set(target => target.OccupiedUserName, user.Name)
.Update();
// 更新用户的板子绑定信息
this.UserTable
_db.UserTable
.Where(u => u.ID == userId)
.Set(u => u.BoardID, board.ID)
.Set(u => u.BoardExpireTime, expireTime)
.Update();
board.Status = Database.Board.BoardStatus.Busy;
board.Status = BoardStatus.Busy;
board.OccupiedUserID = userId;
board.OccupiedUserName = user.Name;
@@ -634,7 +426,7 @@ public class AppDataConnection : DataConnection
logger.Error("实验板名称非法,包含不允许的字符");
return 0;
}
var result = this.BoardTable
var result = _db.BoardTable
.Where(b => b.ID == boardId)
.Set(b => b.BoardName, newName)
.Update();
@@ -648,9 +440,9 @@ public class AppDataConnection : DataConnection
/// <param name="boardId">[TODO:parameter]</param>
/// <param name="newStatus">[TODO:parameter]</param>
/// <returns>[TODO:return]</returns>
public int UpdateBoardStatus(Guid boardId, Board.BoardStatus newStatus)
public int UpdateBoardStatus(Guid boardId, BoardStatus newStatus)
{
var result = this.BoardTable
var result = _db.BoardTable
.Where(b => b.ID == boardId)
.Set(b => b.Status, newStatus)
.Update();
@@ -658,126 +450,4 @@ public class AppDataConnection : DataConnection
return result;
}
/// <summary>
/// 用户表
/// </summary>
public ITable<User> UserTable => this.GetTable<User>();
/// <summary>
/// FPGA 板子表
/// </summary>
public ITable<Board> BoardTable => this.GetTable<Board>();
/// <summary>
/// 实验表
/// </summary>
public ITable<Exam> ExamTable => this.GetTable<Exam>();
/// <summary>
/// 扫描 exam 文件夹并更新实验数据库
/// </summary>
/// <param name="examFolderPath">exam 文件夹的路径</param>
/// <returns>更新的实验数量</returns>
public int ScanAndUpdateExams(string examFolderPath)
{
if (!Directory.Exists(examFolderPath))
{
logger.Warn($"实验文件夹不存在: {examFolderPath}");
return 0;
}
int updateCount = 0;
var subdirectories = Directory.GetDirectories(examFolderPath);
foreach (var examDir in subdirectories)
{
var examId = Path.GetFileName(examDir);
var docPath = Path.Combine(examDir, "doc.md");
if (!File.Exists(docPath))
{
logger.Warn($"实验 {examId} 缺少 doc.md 文件");
continue;
}
try
{
var docContent = File.ReadAllText(docPath);
var existingExam = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault();
if (existingExam == null)
{
// 创建新实验
var newExam = new Exam
{
ID = examId,
DocContent = docContent,
CreatedTime = DateTime.Now,
UpdatedTime = DateTime.Now
};
this.Insert(newExam);
logger.Info($"新实验已添加: {examId}");
updateCount++;
}
else
{
// 更新现有实验
var fileLastWrite = File.GetLastWriteTime(docPath);
if (fileLastWrite > existingExam.UpdatedTime)
{
this.ExamTable
.Where(e => e.ID == examId)
.Set(e => e.DocContent, docContent)
.Set(e => e.UpdatedTime, DateTime.Now)
.Update();
logger.Info($"实验已更新: {examId}");
updateCount++;
}
}
}
catch (Exception ex)
{
logger.Error($"处理实验 {examId} 时出错: {ex.Message}");
}
}
logger.Info($"实验扫描完成,共更新 {updateCount} 个实验");
return updateCount;
}
/// <summary>
/// 获取所有实验信息
/// </summary>
/// <returns>所有实验的数组</returns>
public Exam[] GetAllExams()
{
var exams = this.ExamTable.OrderBy(e => e.ID).ToArray();
logger.Debug($"获取所有实验,共 {exams.Length} 个");
return exams;
}
/// <summary>
/// 根据实验ID获取实验信息
/// </summary>
/// <param name="examId">实验ID</param>
/// <returns>包含实验信息的结果,如果未找到则返回空</returns>
public Result<Optional<Exam>> GetExamByID(string examId)
{
var exams = this.ExamTable.Where(exam => exam.ID == examId).ToArray();
if (exams.Length > 1)
{
logger.Error($"数据库中存在多个相同ID的实验: {examId}");
return new(new Exception($"数据库中存在多个相同ID的实验: {examId}"));
}
if (exams.Length == 0)
{
logger.Info($"未找到ID对应的实验: {examId}");
return new(Optional<Exam>.None);
}
logger.Debug($"成功获取实验信息: {examId}");
return new(exams[0]);
}
}

View File

@@ -0,0 +1,260 @@
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Cors;
using TypedSignalR.Client;
using Tapper;
using DotNext;
using Peripherals.SevenDigitalTubesClient;
using System.Collections.Concurrent;
#pragma warning disable 1998
namespace server.Hubs;
[Hub]
public interface IDigitalTubesHub
{
Task<bool> StartScan();
Task<bool> StopScan();
Task<bool> SetFrequency(int frequency);
Task<DigitalTubeTaskStatus?> GetStatus();
}
[Receiver]
public interface IDigitalTubesReceiver
{
Task OnReceive(byte[] data);
}
[TranspilationSource]
public class DigitalTubeTaskStatus
{
public int Frequency { get; set; } = 100;
public bool IsRunning { get; set; } = false;
}
class DigitalTubesScanTaskInfo
{
public string BoardID { get; set; }
public string ClientID { get; set; }
public Task? ScanTask { get; set; }
public SevenDigitalTubesCtrl TubeClient { get; set; }
public CancellationTokenSource CTS { get; set; } = new();
public int Frequency { get; set; } = 100;
public bool IsRunning { get; set; } = false;
public DigitalTubesScanTaskInfo(
string boardID, string clientID, SevenDigitalTubesCtrl client)
{
BoardID = boardID;
ClientID = clientID;
TubeClient = client;
}
public DigitalTubeTaskStatus ToDigitalTubeTaskStatus()
{
return new DigitalTubeTaskStatus
{
Frequency = Frequency,
IsRunning = IsRunning
};
}
}
[Authorize]
[EnableCors("SignalR")]
public class DigitalTubesHub : Hub<IDigitalTubesReceiver>, IDigitalTubesHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<DigitalTubesHub, IDigitalTubesReceiver> _hubContext;
private readonly Database.UserManager _userManager = new();
private static ConcurrentDictionary<string, DigitalTubesScanTaskInfo> _scanTasks = new();
public DigitalTubesHub(IHubContext<DigitalTubesHub, IDigitalTubesReceiver> hubContext)
{
_hubContext = hubContext;
}
private Optional<Database.Board> TryGetBoard()
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
{
logger.Error("User name is null or empty");
return null;
}
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
{
logger.Error($"User '{userName}' not found");
return null;
}
var user = userRet.Value.Value;
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Board not found");
return null;
}
return boardRet.Value.Value;
}
private Task ScanAllTubes(DigitalTubesScanTaskInfo scanInfo)
{
var token = scanInfo.CTS.Token;
return Task.Run(async () =>
{
var cntError = 0;
while (!token.IsCancellationRequested)
{
var beginTime = DateTime.Now;
var waitTime = TimeSpan.FromMilliseconds(1000 / scanInfo.Frequency);
var dataRet = await scanInfo.TubeClient.ScanAllTubes();
if (!dataRet.IsSuccessful)
{
logger.Error($"Failed to scan tubes: {dataRet.Error}");
cntError++;
if (cntError > 3)
{
logger.Error($"Too many errors, stopping scan");
break;
}
}
await _hubContext.Clients.Client(scanInfo.ClientID).OnReceive(dataRet.Value);
var processTime = DateTime.Now - beginTime;
if (processTime < waitTime)
{
await Task.Delay(waitTime - processTime, token);
}
}
scanInfo.IsRunning = false;
}, token)
.ContinueWith((task) =>
{
if (task.IsFaulted)
{
logger.Error(
$"Digital tubes scan operation failesj for board {task.Exception}");
}
else if (task.IsCanceled)
{
logger.Info(
$"Digital tubes scan operation cancelled for board {scanInfo.BoardID}");
}
else
{
logger.Info(
$"Digital tubes scan completed successfully for board {scanInfo.BoardID}");
}
});
}
public async Task<bool> StartScan()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = board.ID.ToString();
if (_scanTasks.TryGetValue(key, out var existing) && existing.IsRunning)
return true;
var cts = new CancellationTokenSource();
var scanTaskInfo = new DigitalTubesScanTaskInfo(
board.ID.ToString(), Context.ConnectionId,
new SevenDigitalTubesCtrl(board.IpAddr, board.Port, 6)
);
scanTaskInfo.ScanTask = ScanAllTubes(scanTaskInfo);
_scanTasks[key] = scanTaskInfo;
return true;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to start scan");
return false;
}
}
public async Task<bool> StopScan()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = board.ID.ToString();
if (_scanTasks.TryRemove(key, out var scanInfo))
{
scanInfo.IsRunning = false;
scanInfo.CTS.Cancel();
if (scanInfo.ScanTask != null)
await scanInfo.ScanTask;
scanInfo.CTS.Dispose();
}
return true;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to stop scan");
return false;
}
}
public async Task<bool> SetFrequency(int frequency)
{
try
{
if (frequency < 1 || frequency > 1000)
return false;
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = board.ID.ToString();
if (_scanTasks.TryGetValue(key, out var scanInfo) && scanInfo.IsRunning)
{
scanInfo.Frequency = frequency;
return true;
}
else
{
logger.Warn($"SetFrequency called but no running scan for board {board.ID} and client {Context.ConnectionId}");
return false;
}
}
catch (Exception ex)
{
logger.Error(ex, "Failed to set frequency");
return false;
}
}
public async Task<DigitalTubeTaskStatus?> GetStatus()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = board.ID.ToString();
if (_scanTasks.TryGetValue(key, out var scanInfo))
{
return scanInfo.ToDigitalTubeTaskStatus();
}
else
{
return null;
}
}
catch (Exception ex)
{
logger.Error(ex, "Failed to get status");
throw new Exception("Failed to get status", ex);
}
}
}

203
server/src/Hubs/JtagHub.cs Normal file
View File

@@ -0,0 +1,203 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using DotNext;
using System.Collections.Concurrent;
using TypedSignalR.Client;
using Tapper;
namespace server.Hubs;
[Hub]
public interface IJtagHub
{
Task<bool> SetBoundaryScanFreq(int freq);
Task<bool> StartBoundaryScan(int freq = 100);
Task<bool> StopBoundaryScan();
}
[Receiver]
public interface IJtagReceiver
{
Task OnReceiveBoundaryScanData(Dictionary<string, bool> msg);
}
[Authorize]
[EnableCors("SignalR")]
public class JtagHub : Hub<IJtagReceiver>, IJtagHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<JtagHub, IJtagReceiver> _hubContext;
private readonly Database.UserManager _userManager = new();
private static ConcurrentDictionary<string, int> FreqTable = new();
private static ConcurrentDictionary<string, CancellationTokenSource> CancellationTokenSourceTable = new();
public JtagHub(IHubContext<JtagHub, IJtagReceiver> hubContext)
{
_hubContext = hubContext;
}
private Optional<Peripherals.JtagClient.Jtag> GetJtagClient(string userName)
{
try
{
var board = _userManager.GetBoardByUserName(userName);
if (!board.IsSuccessful)
{
logger.Error($"Find Board {board.Value.Value.ID} failed because {board.Error}");
return new(null);
}
if (!board.Value.HasValue)
{
logger.Error($"Board {board.Value.Value.ID} not found");
return new(null);
}
var jtag = new Peripherals.JtagClient.Jtag(board.Value.Value.IpAddr, board.Value.Value.Port);
return new(jtag);
}
catch (Exception error)
{
logger.Error(error);
return new(null);
}
}
public async Task<bool> SetBoundaryScanFreq(int freq)
{
return await Task.Run(() =>
{
try
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (userName is null)
{
logger.Error("Can't get user info");
return false;
}
FreqTable.AddOrUpdate(userName, freq, (key, value) => freq);
return true;
}
catch (Exception error)
{
logger.Error(error);
return false;
}
});
}
public async Task<bool> StartBoundaryScan(int freq = 100)
{
try
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (userName is null)
{
logger.Error("No Such User");
return false;
}
await SetBoundaryScanFreq(freq);
var cts = new CancellationTokenSource();
CancellationTokenSourceTable.AddOrUpdate(userName, cts, (key, value) => cts);
_ = Task.Run(
() => BoundaryScanLogicPorts(Context.ConnectionId, userName, cts.Token),
cts.Token)
.ContinueWith((task) =>
{
if (task.IsFaulted)
{
// 遍历所有异常
foreach (var ex in task.Exception.InnerExceptions)
{
if (ex is OperationCanceledException)
{
logger.Info($"Boundary scan operation cancelled for user {userName}");
}
else
{
logger.Error($"Boundary scan operation failed for user {userName}: {ex}");
}
}
}
else if (task.IsCanceled)
{
logger.Info($"Boundary scan operation cancelled for user {userName}");
}
else
{
logger.Info($"Boundary scan completed successfully for user {userName}");
}
});
logger.Info($"Boundary scan started for user {userName}");
return true;
}
catch (Exception error)
{
logger.Error(error);
return false;
}
}
public async Task<bool> StopBoundaryScan()
{
return await Task.Run(() =>
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (userName is null)
{
logger.Error("No Such User");
return false;
}
if (!CancellationTokenSourceTable.TryGetValue(userName, out var cts))
{
return false;
}
cts.Cancel();
cts.Token.WaitHandle.WaitOne();
logger.Info($"Boundary scan stopped for user {userName}");
return true;
});
}
private async Task BoundaryScanLogicPorts(string connectionID, string userName, CancellationToken cancellationToken)
{
var jtagCtrl = GetJtagClient(userName).OrThrow(() => new InvalidOperationException("JTAG client not found"));
var cntFail = 0;
while (true && cntFail < 5)
{
cancellationToken.ThrowIfCancellationRequested();
var ret = await jtagCtrl.BoundaryScanLogicalPorts();
if (!ret.IsSuccessful)
{
logger.Error($"User {userName} boundary scan failed for device {jtagCtrl.address}: {ret.Error}");
cntFail++;
continue;
}
await _hubContext.Clients.Client(connectionID).OnReceiveBoundaryScanData(ret.Value);
// logger.Info($"User {userName} successfully completed boundary scan for device {jtagCtrl.address}");
await Task.Delay(FreqTable.TryGetValue(userName, out var freq) ? 1000 / freq : 1000 / 100, cancellationToken);
}
if (cntFail >= 5)
{
logger.Error($"User {userName} boundary scan failed for device {jtagCtrl.address} after 5 attempts");
throw new InvalidOperationException("Boundary scan failed");
}
}
}

View File

@@ -0,0 +1,403 @@
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Cors;
using TypedSignalR.Client;
using DotNext;
using Tapper;
using System.Collections.Concurrent;
using Peripherals.OscilloscopeClient;
#pragma warning disable 1998
namespace server.Hubs;
[Hub]
public interface IOscilloscopeHub
{
Task<bool> Initialize(OscilloscopeFullConfig config);
Task<bool> StartCapture();
Task<bool> StopCapture();
Task<OscilloscopeDataResponse?> GetData();
Task<bool> SetTrigger(byte level);
Task<bool> SetRisingEdge(bool risingEdge);
Task<bool> SetSampling(ushort decimationRate);
Task<bool> SetFrequency(int frequency);
}
[Receiver]
public interface IOscilloscopeReceiver
{
Task OnDataReceived(OscilloscopeDataResponse data);
}
[TranspilationSource]
public class OscilloscopeDataResponse
{
public uint AdFrequency { get; set; }
public byte AdVpp { get; set; }
public byte AdMax { get; set; }
public byte AdMin { get; set; }
public string WaveformData { get; set; } = "";
}
[TranspilationSource]
public class OscilloscopeFullConfig
{
public bool CaptureEnabled { get; set; }
public byte TriggerLevel { get; set; }
public bool TriggerRisingEdge { get; set; }
public ushort HorizontalShift { get; set; }
public ushort DecimationRate { get; set; }
public int CaptureFrequency { get; set; }
// public bool AutoRefreshRAM { get; set; }
public OscilloscopeConfig ToOscilloscopeConfig()
{
return new OscilloscopeConfig
{
CaptureEnabled = CaptureEnabled,
TriggerLevel = TriggerLevel,
TriggerRisingEdge = TriggerRisingEdge,
HorizontalShift = HorizontalShift,
DecimationRate = DecimationRate,
};
}
}
class OscilloscopeScanTaskInfo
{
public Task? ScanTask { get; set; }
public OscilloscopeCtrl Client { get; set; }
public CancellationTokenSource CTS { get; set; } = new CancellationTokenSource();
public int Frequency { get; set; } = 100;
public OscilloscopeScanTaskInfo(OscilloscopeCtrl client)
{
Client = client;
}
}
[Authorize]
[EnableCors("SignalR")]
public class OscilloscopeHub : Hub<IOscilloscopeReceiver>, IOscilloscopeHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<OscilloscopeHub, IOscilloscopeReceiver> _hubContext;
private readonly Database.UserManager _userManager = new();
private static ConcurrentDictionary<string, OscilloscopeScanTaskInfo> _scanTasks = new();
public OscilloscopeHub(IHubContext<OscilloscopeHub, IOscilloscopeReceiver> hubContext)
{
_hubContext = hubContext;
}
private Optional<Database.Board> TryGetBoard()
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
{
logger.Error("User name is null or empty");
return null;
}
var boardRet = _userManager.GetBoardByUserName(userName);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Board not found");
return null;
}
return boardRet.Value.Value;
}
private Optional<OscilloscopeCtrl> GetOscilloscope()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var client = new OscilloscopeCtrl(board.IpAddr, board.Port);
return client;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to get oscilloscope");
return null;
}
}
public async Task<bool> Initialize(OscilloscopeFullConfig config)
{
try
{
var client = GetOscilloscope().OrThrow(() => new Exception("Oscilloscope not found"));
var result = await client.Init(config.ToOscilloscopeConfig());
if (!result.IsSuccessful)
{
logger.Error(result.Error, "Initialize failed");
return false;
}
return result.Value;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to initialize oscilloscope");
return false;
}
}
private Task ScanTask(OscilloscopeScanTaskInfo taskInfo, string clientId)
{
var token = taskInfo.CTS.Token;
return Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
var data = await GetCaptureData(taskInfo.Client);
if (data == null)
{
logger.Error("GetData failed");
continue;
}
await _hubContext.Clients.Client(clientId).OnDataReceived(data);
await Task.Delay(1000 / taskInfo.Frequency, token);
}
}, token).ContinueWith(t =>
{
if (t.IsFaulted)
logger.Error(t.Exception, "ScanTask failed");
});
}
public async Task<bool> StartCapture()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = board.ID.ToString();
var client = GetOscilloscope().OrThrow(() => new Exception("Oscilloscope not found"));
if (_scanTasks.TryGetValue(key, out var existing))
return true;
var result = await client.SetCaptureEnable(true);
if (!result.IsSuccessful)
{
logger.Error(result.Error, "StartCapture failed");
return false;
}
var scanTaskInfo = new OscilloscopeScanTaskInfo(client);
scanTaskInfo.ScanTask = ScanTask(scanTaskInfo, Context.ConnectionId);
return _scanTasks.TryAdd(key, scanTaskInfo);
}
catch (Exception ex)
{
logger.Error(ex, "Failed to start capture");
return false;
}
}
public async Task<bool> StopCapture()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var client = GetOscilloscope().OrThrow(() => new Exception("Oscilloscope not found"));
var key = board.ID.ToString();
if (_scanTasks.TryRemove(key, out var taskInfo))
{
taskInfo.CTS.Cancel();
if (taskInfo.ScanTask != null) taskInfo.ScanTask.Wait();
var result = await client.SetCaptureEnable(false);
if (!result.IsSuccessful)
{
logger.Error(result.Error, "StopCapture failed");
return false;
}
return result.Value;
}
throw new Exception("Task not found");
}
catch (Exception ex)
{
logger.Error(ex, "Failed to stop capture");
return false;
}
}
private async Task<OscilloscopeDataResponse?> GetCaptureData(OscilloscopeCtrl oscilloscope)
{
try
{
var freqResult = await oscilloscope.GetADFrequency();
var vppResult = await oscilloscope.GetADVpp();
var maxResult = await oscilloscope.GetADMax();
var minResult = await oscilloscope.GetADMin();
var waveformResult = await oscilloscope.GetWaveformData();
if (!freqResult.IsSuccessful)
{
logger.Error($"获取AD采样频率失败: {freqResult.Error}");
throw new Exception($"获取AD采样频率失败: {freqResult.Error}");
}
if (!vppResult.IsSuccessful)
{
logger.Error($"获取AD采样幅度失败: {vppResult.Error}");
throw new Exception($"获取AD采样幅度失败: {vppResult.Error}");
}
if (!maxResult.IsSuccessful)
{
logger.Error($"获取AD采样最大值失败: {maxResult.Error}");
throw new Exception($"获取AD采样最大值失败: {maxResult.Error}");
}
if (!minResult.IsSuccessful)
{
logger.Error($"获取AD采样最小值失败: {minResult.Error}");
throw new Exception($"获取AD采样最小值失败: {minResult.Error}");
}
if (!waveformResult.IsSuccessful)
{
logger.Error($"获取波形数据失败: {waveformResult.Error}");
throw new Exception($"获取波形数据失败: {waveformResult.Error}");
}
var response = new OscilloscopeDataResponse
{
AdFrequency = freqResult.Value,
AdVpp = vppResult.Value,
AdMax = maxResult.Value,
AdMin = minResult.Value,
WaveformData = Convert.ToBase64String(waveformResult.Value)
};
return new OscilloscopeDataResponse
{
AdFrequency = freqResult.Value,
AdVpp = vppResult.Value,
AdMax = maxResult.Value,
AdMin = minResult.Value,
WaveformData = Convert.ToBase64String(waveformResult.Value)
};
}
catch (Exception ex)
{
logger.Error(ex, "获取示波器数据时发生异常");
return null;
}
}
public async Task<OscilloscopeDataResponse?> GetData()
{
try
{
var oscilloscope = GetOscilloscope().OrThrow(() => new Exception("Oscilloscope not found"));
var response = await GetCaptureData(oscilloscope);
return response;
}
catch (Exception ex)
{
logger.Error(ex, "获取示波器数据时发生异常");
return null;
}
}
public async Task<bool> SetTrigger(byte level)
{
try
{
var client = GetOscilloscope().OrThrow(() => new Exception("Oscilloscope not found"));
var ret = await client.SetTriggerLevel(level);
if (!ret.IsSuccessful)
{
logger.Error(ret.Error, "UpdateTrigger failed");
return false;
}
return ret.Value;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to update trigger");
return false;
}
}
public async Task<bool> SetRisingEdge(bool risingEdge)
{
try
{
var client = GetOscilloscope().OrThrow(() => new Exception("Oscilloscope not found"));
var ret = await client.SetTriggerEdge(risingEdge);
if (!ret.IsSuccessful)
{
logger.Error(ret.Error, "Update Rising Edge failed");
return false;
}
return ret.Value;
}
catch (Exception ex)
{
logger.Error(ex, "SetRisingEdge failed");
return false;
}
}
public async Task<bool> SetSampling(ushort decimationRate)
{
try
{
var client = GetOscilloscope().OrThrow(() => new Exception("Oscilloscope not found"));
var result = await client.SetDecimationRate(decimationRate);
if (!result.IsSuccessful)
{
logger.Error(result.Error, "UpdateSampling failed");
return false;
}
return result.Value;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to update sampling");
return false;
}
}
public async Task<bool> SetFrequency(int frequency)
{
try
{
if (frequency < 1 || frequency > 1000)
return false;
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = board.ID.ToString();
if (_scanTasks.TryGetValue(key, out var scanInfo))
{
scanInfo.Frequency = frequency;
return true;
}
else
{
logger.Warn($"SetFrequency called but no running scan for board {board.ID} and client {Context.ConnectionId}");
return false;
}
}
catch (Exception ex)
{
logger.Error(ex, "Failed to set frequency");
return false;
}
}
}

View File

@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Cors;
using TypedSignalR.Client;
using Tapper;
using server.Services;
#pragma warning disable 1998
namespace server.Hubs;
[Hub]
public interface IProgressHub
{
Task<bool> Join(string taskId);
Task<bool> Leave(string taskId);
Task<ProgressInfo?> GetProgress(string taskId);
}
[Receiver]
public interface IProgressReceiver
{
Task OnReceiveProgress(ProgressInfo message);
}
[TranspilationSource]
public enum ProgressStatus
{
Running,
Completed,
Canceled,
Failed
}
[TranspilationSource]
public class ProgressInfo
{
public required string TaskId { get; set; }
public required ProgressStatus Status { get; set; }
public required double ProgressPercent { get; set; }
public required string ErrorMessage { get; set; }
};
[Authorize]
[EnableCors("SignalR")]
public class ProgressHub : Hub<IProgressReceiver>, IProgressHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ProgressTracker _progressTracker = MsgBus.ProgressTracker;
public async Task<bool> Join(string taskId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, taskId);
// 发送当前状态(如果存在)
var task = _progressTracker.GetTask(taskId);
if (task != null)
{
await Clients.Caller.OnReceiveProgress(task.Value.ToProgressInfo());
}
logger.Info($"Client {Context.ConnectionId} joined task {taskId}");
return true;
}
public async Task<bool> Leave(string taskId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, taskId);
logger.Info($"Client {Context.ConnectionId} left task {taskId}");
return true;
}
public async Task<ProgressInfo?> GetProgress(string taskId)
{
return _progressTracker.GetTask(taskId)?.ToProgressInfo();
}
}

View File

@@ -0,0 +1,268 @@
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Cors;
using TypedSignalR.Client;
using DotNext;
using Peripherals.RotaryEncoderClient;
using System.Collections.Concurrent;
#pragma warning disable 1998
namespace server.Hubs;
[Hub]
public interface IRotaryEncoderHub
{
Task<bool> SetEnable(bool enable);
Task<bool> RotateEncoderOnce(int num, RotaryEncoderDirection direction);
Task<bool> PressEncoderOnce(int num, RotaryEncoderPressStatus press);
Task<bool> EnableCycleRotateEncoder(int num, RotaryEncoderDirection direction, int freq);
Task<bool> DisableCycleRotateEncoder();
}
[Receiver]
public interface IRotaryEncoderReceiver
{
Task OnReceiveRotate(int num, RotaryEncoderDirection direction);
}
public class CycleTaskInfo
{
public Task? CycleTask { get; set; }
public RotaryEncoderCtrl EncoderClient { get; set; }
public CancellationTokenSource CTS { get; set; } = new();
public int Freq { get; set; }
public int Num { get; set; }
public RotaryEncoderDirection Direction { get; set; }
public CycleTaskInfo(
RotaryEncoderCtrl client,
int num, int freq,
RotaryEncoderDirection direction)
{
EncoderClient = client;
Num = num;
Direction = direction;
Freq = freq;
}
}
[Authorize]
[EnableCors("SignalR")]
public class RotaryEncoderHub : Hub<IRotaryEncoderReceiver>, IRotaryEncoderHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<RotaryEncoderHub, IRotaryEncoderReceiver> _hubContext;
private readonly Database.UserManager _userManager = new();
private ConcurrentDictionary<(string, string), CycleTaskInfo> _cycleTasks = new();
public RotaryEncoderHub(IHubContext<RotaryEncoderHub, IRotaryEncoderReceiver> hubContext)
{
_hubContext = hubContext;
}
private Optional<Database.Board> TryGetBoard()
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
{
logger.Error("User name is null or empty");
return null;
}
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
{
logger.Error($"User '{userName}' not found");
return null;
}
var user = userRet.Value.Value;
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Board not found");
return null;
}
return boardRet.Value.Value;
}
public async Task<bool> SetEnable(bool enable)
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var encoderCtrl = new RotaryEncoderCtrl(board.IpAddr, board.Port, 0);
var result = await encoderCtrl.SetEnable(enable);
if (!result.IsSuccessful)
{
logger.Error(result.Error, "SetEnable failed");
return false;
}
return result.Value;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to set enable");
return false;
}
}
public async Task<bool> RotateEncoderOnce(int num, RotaryEncoderDirection direction)
{
try
{
if (num <= 0 || num > 4)
throw new ArgumentException($"RotaryEncoder num should be 1~3, instead of {num}");
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var encoderCtrl = new RotaryEncoderCtrl(board.IpAddr, board.Port, 0);
var result = await encoderCtrl.RotateEncoderOnce(num, direction);
if (!result.IsSuccessful)
{
logger.Error(result.Error, $"RotateEncoderOnce({num}, {direction}) failed");
return false;
}
return result.Value;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to rotate encoder once");
return false;
}
}
public async Task<bool> PressEncoderOnce(int num, RotaryEncoderPressStatus press)
{
try
{
if (num <= 0 || num > 4)
throw new ArgumentException($"RotaryEncoder num should be 1~3, instead of {num}");
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var encoderCtrl = new RotaryEncoderCtrl(board.IpAddr, board.Port, 0);
var result = await encoderCtrl.PressEncoderOnce(num, press);
if (!result.IsSuccessful)
{
logger.Error(result.Error, $"RotateEncoderOnce({num}, {press}) failed");
return false;
}
return result.Value;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to rotate encoder once");
return false;
}
}
public async Task<bool> EnableCycleRotateEncoder(int num, RotaryEncoderDirection direction, int freq)
{
try
{
if (num <= 0 || num > 4) throw new ArgumentException(
$"RotaryEncoder num should be 1~3, instead of {num}");
if (freq <= 0 || freq > 1000) throw new ArgumentException(
$"Frequency should be between 1 and 1000, instead of {freq}");
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
if (_cycleTasks.TryGetValue(key, out var existing))
await DisableCycleRotateEncoder();
var cts = new CancellationTokenSource();
var encoderCtrl = new RotaryEncoderCtrl(board.IpAddr, board.Port, 0);
var cycleTaskInfo = new CycleTaskInfo(encoderCtrl, num, freq, direction);
cycleTaskInfo.CycleTask = CycleRotate(cycleTaskInfo, Context.ConnectionId, board.ID.ToString());
_cycleTasks[key] = cycleTaskInfo;
return true;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to enable cycle rotate encoder");
return false;
}
}
public async Task<bool> DisableCycleRotateEncoder()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
if (_cycleTasks.TryRemove(key, out var taskInfo))
{
taskInfo.CTS.Cancel();
if (taskInfo.CycleTask != null)
await taskInfo.CycleTask;
taskInfo.CTS.Dispose();
}
return true;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to disable cycle rotate encoder");
return false;
}
}
private Task CycleRotate(CycleTaskInfo taskInfo, string clientId, string boardId)
{
var ctrl = taskInfo.EncoderClient;
var token = taskInfo.CTS.Token;
return Task.Run(async () =>
{
var cntError = 0;
while (!token.IsCancellationRequested)
{
var ret = await ctrl.RotateEncoderOnce(taskInfo.Num, taskInfo.Direction);
if (!ret.IsSuccessful)
{
logger.Error(
$"Failed to rotate encoder {taskInfo.Num} on board {boardId}: {ret.Error}");
cntError++;
if (cntError >= 3)
{
logger.Error(
$"Too many errors occurred while rotating encoder {taskInfo.Num} on board {boardId}");
break;
}
}
if (!ret.Value)
{
logger.Warn(
$"Encoder {taskInfo.Num} on board {boardId} is not responding");
continue;
}
await _hubContext.Clients
.Client(clientId)
.OnReceiveRotate(taskInfo.Num, taskInfo.Direction);
await Task.Delay(1000 / taskInfo.Freq, token);
}
}, token)
.ContinueWith((task) =>
{
if (task.IsFaulted)
{
logger.Error($"Rotary encoder cycle operation failed: {task.Exception}");
}
else if (task.IsCanceled)
{
logger.Info($"Rotary encoder cycle operation cancelled for board {boardId}");
}
else
{
logger.Info($"Rotary encoder cycle completed for board {boardId}");
}
});
}
}

View File

@@ -0,0 +1,138 @@
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Cors;
using TypedSignalR.Client;
using Tapper;
using DotNext;
using Peripherals.WS2812Client;
using System.Collections.Concurrent;
#pragma warning disable 1998
namespace server.Hubs;
[Hub]
public interface IWS2812Hub
{
Task<RGBColor[]?> GetAllLedColors();
Task<RGBColor?> GetLedColor(int ledIndex);
}
[Receiver]
public interface IWS2812Receiver
{
Task OnReceive(RGBColor[] data);
}
[TranspilationSource]
public class WS2812TaskStatus
{
public bool IsRunning { get; set; } = false;
}
class WS2812ScanTaskInfo
{
public string BoardID { get; set; }
public string ClientID { get; set; }
public Task? ScanTask { get; set; }
public WS2812Client LedClient { get; set; }
public CancellationTokenSource CTS { get; set; } = new();
public bool IsRunning { get; set; } = false;
public WS2812ScanTaskInfo(string boardID, string clientID, WS2812Client client)
{
BoardID = boardID;
ClientID = clientID;
LedClient = client;
}
public WS2812TaskStatus ToWS2812TaskStatus()
{
return new WS2812TaskStatus
{
IsRunning = IsRunning
};
}
}
[Authorize]
[EnableCors("SignalR")]
public class WS2812Hub : Hub<IWS2812Receiver>, IWS2812Hub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<WS2812Hub, IWS2812Receiver> _hubContext;
private readonly Database.UserManager _userManager = new();
private ConcurrentDictionary<(string, string), WS2812ScanTaskInfo> _scanTasks = new();
public WS2812Hub(IHubContext<WS2812Hub, IWS2812Receiver> hubContext)
{
_hubContext = hubContext;
}
private Optional<Database.Board> TryGetBoard()
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
{
logger.Error("User name is null or empty");
return null;
}
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
{
logger.Error($"User '{userName}' not found");
return null;
}
var user = userRet.Value.Value;
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Board not found");
return null;
}
return boardRet.Value.Value;
}
public async Task<RGBColor[]?> GetAllLedColors()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var client = new WS2812Client(board.IpAddr, board.Port, 0);
var result = await client.GetAllLedColors();
if (!result.IsSuccessful)
{
logger.Error($"GetAllLedColors failed: {result.Error}");
return null;
}
return result.Value;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to get all LED colors");
return null;
}
}
public async Task<RGBColor?> GetLedColor(int ledIndex)
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var client = new WS2812Client(board.IpAddr, board.Port, 0);
var result = await client.GetLedColor(ledIndex);
if (!result.IsSuccessful)
{
logger.Error($"GetLedColor failed: {result.Error}");
return null;
}
return result.Value;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to get LED color");
return null;
}
}
}

View File

@@ -1,27 +1,57 @@
using server.Services;
/// <summary>
/// 多线程通信总线
/// </summary>
public static class MsgBus
public sealed class MsgBus
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
// private static RtspStreamService _rtspStreamService = new RtspStreamService(new UsbCameraCapture());
private static readonly UDPServer udpServer = new UDPServer(1234, 12);
/// <summary>
/// 获取UDP服务器
/// </summary>
public static UDPServer UDPServer { get { return udpServer; } }
// 添加静态ProgressTracker引用
private static ProgressTracker? _progressTracker;
/// <summary>
/// 设置全局ProgressTracker实例
/// </summary>
public static void SetProgressTracker(ProgressTracker progressTracker)
{
_progressTracker = progressTracker;
}
public static ProgressTracker ProgressTracker
{
get
{
if (_progressTracker == null)
{
throw new InvalidOperationException("ProgressTracker is not set.");
}
return _progressTracker;
}
}
private static bool isRunning = false;
/// <summary>
/// 获取通信总线运行状态
/// </summary>
public static bool IsRunning { get { return isRunning; } }
private MsgBus() { }
static MsgBus() { }
/// <summary>
/// 通信总线初始化
/// </summary>
/// <returns>无</returns>
public async static void Init()
public static async void Init()
{
if (!ArpClient.IsAdministrator())
{
@@ -29,6 +59,10 @@ public static class MsgBus
// throw new Exception($"非管理员运行ARP无法更新请用管理员权限运行");
}
udpServer.Start();
// _rtspStreamService.ConfigureVideo(1920, 1080, 30);
// await _rtspStreamService.StartAsync();
isRunning = true;
}

View File

@@ -1,6 +1,6 @@
using System.Net;
using DotNext;
using Peripherals.PowerClient;
using WebProtocol;
namespace Peripherals.CameraClient;
@@ -15,12 +15,12 @@ static class CameraAddr
public const UInt32 CAMERA_POWER = BASE + 0x10; //[0]: rstn, 0 is reset. [8]: power down, 1 is down.
}
class Camera
public class Camera
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 2000;
readonly int taskID;
readonly int timeout = 500;
readonly int taskID = 8;
readonly int port;
readonly string address;
private IPEndPoint ep;
@@ -43,7 +43,7 @@ class Camera
/// <param name="address">摄像头设备IP地址</param>
/// <param name="port">摄像头设备端口</param>
/// <param name="timeout">超时时间(毫秒)</param>
public Camera(string address, int port, int timeout = 2000)
public Camera(string address, int port, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
@@ -225,6 +225,7 @@ class Camera
this.taskID, // taskID
FrameAddr,
(int)_currentFrameLength, // 使用当前分辨率的动态大小
BurstType.ExtendBurst,
this.timeout);
if (!result.IsSuccessful)
@@ -274,7 +275,7 @@ class Camera
{
var currentAddress = (UInt16)(baseAddress + i - 1);
var data = (byte)cmd[i];
logger.Debug($"ConfigureRegisters: 写入地址=0x{currentAddress:X4}, 数据=0x{data:X2}");
// 准备I2C数据16位地址 + 8位数据
@@ -320,14 +321,14 @@ class Camera
public async ValueTask<Result<byte>> ReadRegister(UInt16 register)
{
var i2c = new Peripherals.I2cClient.I2c(this.address, this.port, this.taskID, this.timeout);
// Convert 16-bit register address to byte array
var registerBytes = new byte[] { (byte)(register >> 8), (byte)(register & 0xFF) };
var ret = await i2c.ReadData(CAM_I2C_ADDR, registerBytes, 1, CAM_PROTO);
if (!ret.IsSuccessful)
return new(ret.Error);
return new Result<byte>(ret.Value[0]);
}
@@ -410,25 +411,25 @@ class Camera
[0x3801, unchecked((byte)(hStart & 0xFF))],
[0x3802, unchecked((byte)((vStart >> 8) & 0xFF))],
[0x3803, unchecked((byte)(vStart & 0xFF))],
// H_END/V_END
[0x3804, unchecked((byte)((hEnd >> 8) & 0xFF))],
[0x3805, unchecked((byte)(hEnd & 0xFF))],
[0x3806, unchecked((byte)((vEnd >> 8) & 0xFF))],
[0x3807, unchecked((byte)(vEnd & 0xFF))],
// 输出像素个数
[0x3808, unchecked((byte)((dvpHo >> 8) & 0xFF))],
[0x3809, unchecked((byte)(dvpHo & 0xFF))],
[0x380A, unchecked((byte)((dvpVo >> 8) & 0xFF))],
[0x380B, unchecked((byte)(dvpVo & 0xFF))],
// 总像素
[0x380C, unchecked((byte)((hts >> 8) & 0xFF))],
[0x380D, unchecked((byte)(hts & 0xFF))],
[0x380E, unchecked((byte)((vts >> 8) & 0xFF))],
[0x380F, unchecked((byte)(vts & 0xFF))],
// H_OFFSET/V_OFFSET
[0x3810, unchecked((byte)((hOffset >> 8) & 0xFF))],
[0x3811, unchecked((byte)(hOffset & 0xFF))],
@@ -462,6 +463,20 @@ class Camera
);
}
/// <summary>
/// 配置为960x540分辨率
/// </summary>
/// <returns>配置结果</returns>
public async ValueTask<Result<bool>> ConfigureResolution960x540()
{
return await ConfigureResolution(
hStart: 0, vStart: 0,
dvpHo: 960, dvpVo: 540,
hts: 1700, vts: 1500,
hOffset: 16, vOffset: 4
);
}
/// <summary>
/// 配置为320x240分辨率
/// </summary>
@@ -505,7 +520,7 @@ class Camera
hOffset: 16, vOffset: 4,
hWindow: 2624, vWindow: 1456
);
}
/// <summary>
@@ -521,7 +536,7 @@ class Camera
hOffset: 16, vOffset: 4,
hWindow: 2624, vWindow: 1456
);
}
/// <summary>
@@ -543,6 +558,9 @@ class Camera
case "640x480":
result = await ConfigureResolution640x480();
break;
case "960x540":
result = await ConfigureResolution960x540();
break;
case "1280x720":
result = await ConfigureResolution1280x720();
break;
@@ -618,7 +636,7 @@ class Camera
[0x3008, 0x42] // 休眠命令
};
return await ConfigureRegisters(sleepRegisters, customDelayMs: 50);
return await ConfigureRegisters(sleepRegisters, customDelayMs: 50);
}
/// <summary>
@@ -1286,7 +1304,7 @@ class Camera
UInt16 firmwareAddr = 0x8000;
var firmwareCommand = new UInt16[1 + OV5640_AF_FIRMWARE.Length];
firmwareCommand[0] = firmwareAddr;
// 将固件数据复制到命令数组中
for (int i = 0; i < OV5640_AF_FIRMWARE.Length; i++)
{
@@ -1406,7 +1424,7 @@ class Camera
logger.Error($"自动对焦超时,状态: 0x{readResult.Value:X2}");
return new(new Exception($"自动对焦超时,状态: 0x{readResult.Value:X2}"));
}
await Task.Delay(100);
}

View File

@@ -0,0 +1,9 @@
# CommandID
示波器12
逻辑分析仪: 11
Jtag: 10
矩阵键盘1
HDMI9
Camera: 8
Debugger: 7
七段数码港6

View File

@@ -55,28 +55,28 @@ class DebuggerCmd
public const UInt32 ClearSignal = 0xFFFF_FFFF;
}
/// <summary>
/// <summary>
/// 信号捕获模式枚举
/// </summary>
public enum CaptureMode : byte
{
/// <summary>
/// <summary>
/// 无捕获模式
/// </summary>
None = 0,
/// <summary>
/// <summary>
/// 低电平触发模式
/// </summary>
Logic0 = 1,
/// <summary>
/// <summary>
/// 高电平触发模式
/// </summary>
Logic1 = 2,
/// <summary>
/// <summary>
/// 上升沿触发模式
/// </summary>
Rise = 3,
/// <summary>
/// <summary>
/// 下降沿触发模式
/// </summary>
Fall = 4,
@@ -170,7 +170,7 @@ public class DebuggerClient
/// <returns>操作结果,成功返回状态标志字节,失败返回错误信息</returns>
public async ValueTask<Result<byte>> ReadFlag()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, DebuggerAddr.Signal, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, DebuggerAddr.Signal, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read flag: {ret.Error}");

View File

@@ -0,0 +1,295 @@
using System.Net;
using DotNext;
using WebProtocol;
namespace Peripherals.HdmiInClient;
static class HdmiInAddr
{
public const UInt32 BASE = 0xA000_0000;
public const UInt32 CAPTURE_RD_CTRL = BASE + 0x0;
public const UInt32 START_WR_ADDR0 = BASE + 0x20;
public const UInt32 END_WR_ADDR0 = BASE + 0x21;
public const UInt32 HDMI_NOT_READY = BASE + 0x26;
public const UInt32 HDMI_HEIGHT_WIDTH = BASE + 0x27;
public const UInt32 CAPTURE_HEIGHT_WIDTH = BASE + 0x28;
public const UInt32 ADDR_HDMI_WD_START = 0x0400_0000;
}
public class HdmiIn
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
public int Width { get; private set; }
public int Height { get; private set; }
public int FrameLength => Width * Height / 2;
/// <summary>
/// 初始化HDMI输入客户端
/// </summary>
/// <param name="address">HDMI输入设备IP地址</param>
/// <param name="port">HDMI输入设备端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="timeout">超时时间(毫秒)</param>
public HdmiIn(string address, int port, int taskID, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.taskID = taskID;
this.timeout = timeout;
}
public async ValueTask<Result<bool>> Init(bool enable = true)
{
{
var ret = await CheckHdmiIsReady();
if (!ret.IsSuccessful)
{
logger.Error($"Failed to check HDMI ready: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("HDMI not ready");
return new(false);
}
}
int width = -1, height = -1;
{
var ret = await GetHdmiResolution();
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get HDMI resolution: {ret.Error}");
return new(ret.Error);
}
(width, height) = ret.Value;
}
{
var ret = await ConnectJpeg2Hdmi(width, height);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to connect JPEG to HDMI: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("Failed to connect JPEG to HDMI");
return false;
}
}
if (enable) return await SetTransEnable(true);
else return true;
}
public async ValueTask<Result<bool>> SetTransEnable(bool isEnable)
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, HdmiInAddr.CAPTURE_RD_CTRL, (isEnable ? 0x00000001u : 0x00000000u));
if (!ret.IsSuccessful)
{
logger.Error($"Failed to write HdmiIn_CTRL to HdmiIn at {this.address}:{this.port}, error: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"HdmiIn_CTRL write returned false for HdmiIn at {this.address}:{this.port}");
return false;
}
return true;
}
/// <summary>
/// 读取一帧图像数据
/// </summary>
/// <returns>包含图像数据的字节数组</returns>
public async ValueTask<Result<byte[]>> ReadFrame()
{
// 只在第一次或出错时清除UDP缓冲区避免每帧都清除造成延迟
// MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Reading frame from HdmiIn {this.address}");
// 使用UDPClientPool读取图像帧数据
var result = await UDPClientPool.ReadAddr4BytesAsync(
this.ep,
this.taskID, // taskID
HdmiInAddr.ADDR_HDMI_WD_START,
FrameLength, // 使用当前分辨率的动态大小
BurstType.ExtendBurst,
this.timeout);
if (!result.IsSuccessful)
{
logger.Error($"Failed to read frame from HdmiIn {this.address}:{this.port}, error: {result.Error}");
// 读取失败时清除缓冲区,为下次读取做准备
try
{
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
}
catch (Exception ex)
{
logger.Warn($"Failed to clear UDP data after read error: {ex.Message}");
}
return new(result.Error);
}
logger.Trace($"Successfully read frame from HdmiIn {this.address}:{this.port}, data length: {result.Value.Length} bytes");
return result.Value;
}
public async ValueTask<Optional<(byte[] header, byte[] data, byte[] footer)>> GetMJpegFrame()
{
// 从HDMI读取RGB24数据
var readStartTime = DateTime.UtcNow;
var frameResult = await ReadFrame();
var readEndTime = DateTime.UtcNow;
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
if (!frameResult.IsSuccessful || frameResult.Value == null)
{
logger.Warn("HDMI帧读取失败或为空");
return Optional<(byte[] header, byte[] data, byte[] footer)>.None;
}
var rgb565Data = frameResult.Value;
// 验证数据长度是否正确 (RGB24为每像素2字节)
var expectedLength = Width * Height * 2;
if (rgb565Data.Length != expectedLength)
{
logger.Warn("HDMI数据长度不匹配期望: {Expected}, 实际: {Actual}",
expectedLength, rgb565Data.Length);
}
// 将RGB24转换为JPEG参考Camera版本的处理
var jpegResult = Common.Image.ConvertRGB565ToJpeg(rgb565Data, Width, Height, 80, false);
if (!jpegResult.IsSuccessful)
{
logger.Error("HDMI RGB565转JPEG失败: {Error}", jpegResult.Error);
return Optional<(byte[] header, byte[] data, byte[] footer)>.None;
}
var jpegData = jpegResult.Value;
var mjpegFrameHeader = Common.Image.CreateMjpegFrameHeader(jpegData.Length);
var mjpegFrameFooter = Common.Image.CreateMjpegFrameFooter();
return (mjpegFrameHeader, jpegData, mjpegFrameFooter);
}
public async ValueTask<Result<bool>> CheckHdmiIsReady()
{
var ret = await UDPClientPool.ReadAddrWithWait(
this.ep, this.taskID, HdmiInAddr.HDMI_NOT_READY, 0b00, 0b01, 100, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to check HDMI status: {ret.Error}");
return new(ret.Error);
}
return ret.Value;
}
public async ValueTask<Result<(int, int)>> GetHdmiResolution()
{
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, HdmiInAddr.HDMI_HEIGHT_WIDTH, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get HDMI resolution: {ret.Error}");
return new(ret.Error);
}
var data = ret.Value.Options.Data;
if (data == null || data.Length != 4)
{
logger.Error($"Invalid HDMI resolution data length: {data?.Length ?? 0}");
return new(new Exception("Invalid HDMI resolution data length"));
}
var width = (data[3] | (data[2] << 8)) - 1 - (((data[3] | (data[2] << 8)) - 1)%2);
var height = (data[1] | (data[0] << 8)) - 1 - (((data[1] | (data[0] << 8)) - 1)%2);
this.Width = width;
this.Height = height;
logger.Info($"HDMI resolution: {width}x{height}");
return new((width, height));
}
public async ValueTask<Result<bool>> ConnectJpeg2Hdmi(int width, int height)
{
if (width <= 0 || height <= 0)
{
logger.Error($"Invalid HDMI resolution: {width}x{height}");
return new(new ArgumentException("Invalid HDMI resolution"));
}
var frameSize = (UInt32)(width * height) / 2;
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, HdmiInAddr.CAPTURE_HEIGHT_WIDTH, (uint)((height << 16) + width), this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set CAPTURE_HEIGHT_WIDTH: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, HdmiInAddr.START_WR_ADDR0, HdmiInAddr.ADDR_HDMI_WD_START, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, HdmiInAddr.END_WR_ADDR0,
HdmiInAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output end address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output address");
return false;
}
}
return true;
}
}

View File

@@ -8,8 +8,8 @@ static class I2cAddr
const UInt32 Base = 0x6000_0000;
/// <summary>
/// 0x0000_0000:
/// <summary>
/// 0x0000_0000:
/// [7:0] 本次传输的i2c地址(最高位总为0);
/// [8] 1为读0为写;
/// [16] 1为SCCB协议0为I2C协议;
@@ -17,45 +17,45 @@ static class I2cAddr
/// </summary>
public const UInt32 BaseConfig = Base + 0x0000_0000;
/// <summary>
/// <summary>
/// 0x0000_0001:
/// [15:0] 本次传输的数据量以字节为单位0为传1个字节;
/// [31:16] 若本次传输为读的DUMMY数据量字节为单位0为传1个字节
/// </summary>
public const UInt32 TranConfig = Base + 0x0000_0001;
/// <summary>
/// <summary>
/// 0x0000_0002: [0] cmd_done; [8] cmd_error;
/// </summary>
public const UInt32 Flag = Base + 0x0000_0002;
/// <summary>
/// <summary>
/// 0x0000_0003: FIFO写入口仅低8位有效只写
/// </summary>
public const UInt32 Write = Base + 0x0000_0003;
/// <summary>
/// <summary>
/// 0x0000_0004: FIFO读出口仅低8位有效只读
/// </summary>
public const UInt32 Read = Base + 0x0000_0004;
/// <summary>
/// <summary>
/// 0x0000_0005: [0] FIFO写入口清空[8] FIFO读出口清空
/// </summary>
public const UInt32 Clear = Base + 0x0000_0005;
}
/// <summary>
/// <summary>
/// [TODO:Enum]
/// </summary>
public enum I2cProtocol
{
/// <summary>
/// <summary>
/// [TODO:Enum]
/// </summary>
I2c = 0,
/// <summary>
/// <summary>
/// [TODO:Enum]
/// </summary>
SCCB = 1
@@ -296,7 +296,7 @@ public class I2c
// 读取数据
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, I2cAddr.Read);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, I2cAddr.Read);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read data from I2C FIFO: {ret.Error}");

View File

@@ -0,0 +1,608 @@
using System.Net;
using DotNext;
using Common;
namespace Peripherals.JpegClient;
static class JpegAddr
{
const UInt32 BASE = 0xA000_0000;
public const UInt32 CAPTURE_RD_CTRL = BASE + 0x0;
public const UInt32 CAPTURE_WR_CTRL = BASE + 0x1;
public const UInt32 START_WR_ADDR0 = BASE + 0x2;
public const UInt32 END_WR_ADDR0 = BASE + 0x3;
public const UInt32 START_WR_ADDR1 = BASE + 0x4;
public const UInt32 END_WR_ADDR1 = BASE + 0x5;
public const UInt32 START_RD_ADDR0 = BASE + 0x6;
public const UInt32 END_RD_ADDR0 = BASE + 0x7;
public const UInt32 HDMI_NOT_READY = BASE + 0x8;
public const UInt32 HDMI_HEIGHT_WIDTH = BASE + 0x9;
public const UInt32 JPEG_HEIGHT_WIDTH = BASE + 0xA;
public const UInt32 JPEG_ADD_NEED_FRAME_NUM = BASE + 0xB;
public const UInt32 JPEG_FRAME_SAVE_NUM = BASE + 0xC;
public const UInt32 JPEG_FIFO_FRAME_INFO = BASE + 0xD;
public const UInt32 JPEG_QUANTIZATION_TABLE = BASE + 0x100;
public const UInt32 ADDR_HDMI_WD_START = 0x0400_0000;
public const UInt32 ADDR_JPEG_START = 0x0800_0000;
public const UInt32 ADDR_JPEG_END = 0x09FF_FFFF;
}
public class JpegInfo
{
public UInt32 Width { get; set; }
public UInt32 Height { get; set; }
public UInt32 Size { get; set; }
public JpegInfo(UInt32 width, UInt32 height, UInt32 size)
{
Width = width;
Height = height;
Size = size;
}
public JpegInfo(byte[] data)
{
if (data.Length < 8)
throw new ArgumentException("Invalid data length", nameof(data));
Width = ((UInt32)(data[5] << 8 + data[6] & 0xF0));
Height = ((UInt32)((data[6] & 0x0F) << 4 + data[7]));
Size = Number.BytesToUInt32(data, 0, 4).Value;
}
}
public enum JpegSampleRate : UInt32
{
RATE_1_1 = 0b1111_1111_1111_1111_1111_1111_1111_1111,
RATE_1_2 = 0b1010_1010_1010_1010_1010_1010_1010_1010,
RATE_1_4 = 0b1000_1000_1000_1000_1000_1000_1000_1000,
RATE_3_4 = 0b1110_1110_1110_1110_1110_1110_1110_1110,
RATE_1_8 = 0b1000_0000_1000_0000_1000_0000_1000_0000,
RATE_3_8 = 0b1001_0010_0100_1001_1001_0010_0100_1001,
RATE_7_8 = 0b1111_1110_1111_1110_1111_1110_1111_1110,
RATE_1_16 = 0b1000_0000_0000_0000_1000_0000_0000_0000,
RATE_3_16 = 0b1000_0100_0010_0000_1000_0100_0010_0000,
RATE_5_16 = 0b1001_0001_0010_0010_0100_0100_1000_1001,
RATE_15_16 = 0b1111_1111_1111_1110_1111_1111_1111_1110,
RATE_1_32 = 0b1000_0000_0000_0000_0000_0000_0000_0000,
RATE_31_32 = 0b1111_1111_1111_1111_1111_1111_1111_1110,
}
public class Jpeg
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 2000;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
public int Width { get; set; }
public int Height { get; set; }
public Jpeg(string address, int port, int taskID, int timeout = 2000)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.taskID = taskID;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.timeout = timeout;
}
public async ValueTask<Result<bool>> Init(bool enable = true)
{
{
var ret = await CheckHdmiIsReady();
if (!ret.IsSuccessful)
{
logger.Error($"Failed to check HDMI ready: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("HDMI not ready");
return new(false);
}
}
int width = -1, height = -1;
{
var ret = await GetHdmiResolution();
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get HDMI resolution: {ret.Error}");
return new(ret.Error);
}
(width, height) = ret.Value;
}
{
var ret = await ConnectJpeg2Hdmi(width, height);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to connect JPEG to HDMI: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("Failed to connect JPEG to HDMI");
return false;
}
}
if (enable)
return await SetEnable(true);
else return true;
}
public async ValueTask<Result<bool>> SetEnable(bool enable)
{
if (enable)
{
{
var ret = await UDPClientPool.WriteAddrSeq(
this.ep,
this.taskID,
[JpegAddr.CAPTURE_RD_CTRL, JpegAddr.CAPTURE_WR_CTRL],
[0b11, 0b01],
this.timeout
);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set JPEG enable: {ret.Error}");
return ret.Value;
}
}
{
var ret = await AddFrameNum2Process(1);
if (!ret)
{
logger.Error($"Failed to AddFrameNum2Process: {ret}");
return ret.Value;
}
}
return true;
}
else
{
var ret = await UDPClientPool.WriteAddrSeq(
this.ep,
this.taskID,
[JpegAddr.CAPTURE_RD_CTRL, JpegAddr.CAPTURE_WR_CTRL],
[0b00, 0b00],
this.timeout
);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set JPEG disable: {ret.Error}");
return false;
}
return ret.Value;
}
}
public async ValueTask<Result<bool>> CheckHdmiIsReady()
{
var ret = await UDPClientPool.ReadAddrWithWait(
this.ep, this.taskID, JpegAddr.HDMI_NOT_READY, 0b00, 0b01, 100, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to check HDMI status: {ret.Error}");
return new(ret.Error);
}
return ret.Value;
}
public async ValueTask<Result<(int, int)>> GetHdmiResolution()
{
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, JpegAddr.HDMI_HEIGHT_WIDTH, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get HDMI resolution: {ret.Error}");
return new(ret.Error);
}
var data = ret.Value.Options.Data;
if (data == null || data.Length != 4)
{
logger.Error($"Invalid HDMI resolution data length: {data?.Length ?? 0}");
return new(new Exception("Invalid HDMI resolution data length"));
}
var width = data[3] | (data[2] << 8);
var height = data[1] | (data[0] << 8);
this.Width = width;
this.Height = height;
logger.Info($"HDMI resolution: {width}x{height}");
return new((width, height));
}
public async ValueTask<Result<bool>> ConnectJpeg2Hdmi(int width, int height)
{
if (width <= 0 || height <= 0)
{
logger.Error($"Invalid HDMI resolution: {width}x{height}");
return new(new ArgumentException("Invalid HDMI resolution"));
}
var frameSize = (UInt32)(width * height);
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.JPEG_HEIGHT_WIDTH, (uint)((height << 16) + width), this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.START_WR_ADDR0, JpegAddr.ADDR_HDMI_WD_START, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_WR_ADDR0,
JpegAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output end address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.START_RD_ADDR0, JpegAddr.ADDR_HDMI_WD_START, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg input start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set jpeg input address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_RD_ADDR0,
JpegAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg input end address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set jpeg input end address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.START_WR_ADDR1, JpegAddr.ADDR_JPEG_START, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg output start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set jpeg output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_WR_ADDR1, JpegAddr.ADDR_JPEG_END, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg output end address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set jpeg output end address");
return false;
}
}
return true;
}
// public async ValueTask<bool> SetSampleRate(uint rate)
// {
// var ret = await UDPClientPool.WriteAddr(
// this.ep, this.taskID, JpegAddr.FRAME_SAMPLE_RATE, rate, this.timeout);
// if (!ret.IsSuccessful)
// {
// logger.Error($"Failed to set JPEG sample rate: {ret.Error}");
// return false;
// }
// return ret.Value;
// }
// public async ValueTask<bool> SetSampleRate(JpegSampleRate rate)
// {
// return await SetSampleRate((uint)rate);
// }
public async ValueTask<uint> GetFrameNumber()
{
const int maxAttempts = 10;
const int delayMs = 5;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, JpegAddr.JPEG_FRAME_SAVE_NUM, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame number on attempt {attempt + 1}: {ret.Error}");
if (attempt < maxAttempts - 1)
{
await Task.Delay(delayMs);
continue;
}
return 0;
}
var frameNumber = Number.BytesToUInt32(ret.Value.Options.Data ?? Array.Empty<byte>()).Value;
if (frameNumber != 0)
{
return frameNumber;
}
// 如果不是最后一次尝试等待5ms后重试
if (attempt < maxAttempts - 1)
{
await Task.Delay(delayMs);
}
}
// 所有尝试都失败或返回0
return 0;
}
public async ValueTask<Optional<List<JpegInfo>>> GetFrameInfo(int num)
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, JpegAddr.JPEG_FIFO_FRAME_INFO, num, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame info: {ret.Error}");
return new(null);
}
var data = ret.Value.Options.Data;
if (data == null || data.Length == 0)
{
logger.Error($"Data is null or empty");
return new(null);
}
if (data.Length != num * 2)
{
logger.Error(
$"Data length should be {num * 2} bytes, instead of {data.Length} bytes");
return new(null);
}
var infos = new List<JpegInfo>();
for (int i = 0; i < num; i++)
{
infos.Add(new JpegInfo(data[i..(i + 1)]));
}
return new(infos);
}
public async ValueTask<Result<bool>> AddFrameNum2Process(uint cnt)
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.JPEG_ADD_NEED_FRAME_NUM, cnt, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to update pointer: {ret.Error}");
return ret.Value;
}
return ret.Value;
}
public async ValueTask<Result<byte[]?>> GetFrame(uint offset, uint length)
{
if (!MsgBus.IsRunning)
{
logger.Error("Message bus is not running");
return new(new Exception("Message bus is not running"));
}
MsgBus.UDPServer.ClearUDPData(this.ep.Address.ToString(), this.ep.Port);
var firstReadLength = (int)(Math.Min(
length,
JpegAddr.ADDR_JPEG_END - JpegAddr.ADDR_JPEG_START - offset
));
var secondReadLength = (int)(length - firstReadLength);
var dataBytes = new byte[length];
{
var ret = await UDPClientPool.ReadAddr4Bytes(
this.ep, this.taskID, JpegAddr.ADDR_JPEG_START + offset, firstReadLength, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame data: {ret.Error}");
return null;
}
if (ret.Value.Length != firstReadLength)
{
logger.Error($"Data length should be {firstReadLength} bytes, instead of {ret.Value.Length} bytes");
return null;
}
Buffer.BlockCopy(ret.Value, 0, dataBytes, 0, firstReadLength);
}
if (secondReadLength > 0)
{
var ret = await UDPClientPool.ReadAddr4Bytes(
this.ep, this.taskID, JpegAddr.ADDR_JPEG_START, secondReadLength, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame data: {ret.Error}");
return null;
}
if (ret.Value.Length != secondReadLength)
{
logger.Error($"Data length should be {secondReadLength} bytes, instead of {ret.Value.Length} bytes");
return null;
}
Buffer.BlockCopy(ret.Value, 0, dataBytes, firstReadLength, secondReadLength);
}
return dataBytes;
}
public async ValueTask<List<byte[]>> GetMultiFrames(uint offset, uint[] sizes)
{
var frames = new List<byte[]>();
for (int i = 0; i < sizes.Length; i++)
{
var ret = await GetFrame(offset, sizes[i]);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame {i} data: {ret.Error}");
continue;
}
if (ret.Value == null)
{
logger.Error($"Frame {i} data is null");
continue;
}
if (ret.Value.Length != sizes[i])
{
logger.Error(
$"Frame {i} data length should be {sizes[i]} bytes, instead of {ret.Value.Length} bytes");
continue;
}
frames.Add(ret.Value);
offset += sizes[i];
}
{
var ret = await AddFrameNum2Process((uint)sizes.Length);
if (!ret) logger.Error($"Failed to update pointer");
}
return frames;
}
public async ValueTask<Result<List<byte[]>?>> GetMultiFrames(uint offset)
{
if (!MsgBus.IsRunning)
{
logger.Error("Message bus is not running");
return new(new Exception("Message bus is not running"));
}
MsgBus.UDPServer.ClearUDPData(this.ep.Address.ToString(), this.ep.Port);
var frameNum = await GetFrameNumber();
if (frameNum == 0) return null;
List<uint>? frameSizes = null;
{
var ret = await GetFrameInfo((int)frameNum);
if (!ret.HasValue || ret.Value.Count == 0)
{
logger.Error($"Failed to get frame info");
return null;
}
frameSizes = ret.Value.Select(x => x.Size).ToList();
}
var frames = await GetMultiFrames(offset, frameSizes.ToArray());
if (frames.Count == 0)
{
logger.Error($"Failed to get frames");
return null;
}
return frames;
}
public async ValueTask<Result<uint[]?>> GetQuantizationTable()
{
const int totalQuantValues = 8 * 8 * 3; // Y(64) + Cb(64) + Cr(64) = 192个量化值
const int bytesPerValue = 4; // 每个量化值32bit = 4字节
const int totalBytes = totalQuantValues * bytesPerValue; // 总共768字节
try
{
var ret = await UDPClientPool.ReadAddr4Bytes(
this.ep, this.taskID, JpegAddr.JPEG_QUANTIZATION_TABLE, totalBytes, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read JPEG quantization table: {ret.Error}");
return new(ret.Error);
}
var data = ret.Value;
if (data == null || data.Length != totalBytes)
{
logger.Error($"Invalid quantization table data length: expected {totalBytes}, got {data?.Length ?? 0}");
return new(new Exception("Invalid quantization table data length"));
}
var quantTable = new uint[totalQuantValues];
for (int i = 0; i < totalQuantValues; i++)
{
// 每32bit为一个量化值按小端序读取
var offset = i * bytesPerValue;
quantTable[i] = (uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24));
}
logger.Debug($"Successfully read JPEG quantization table with {totalQuantValues} values");
return quantTable;
}
catch (Exception ex)
{
logger.Error(ex, "Exception occurred while reading JPEG quantization table");
return new(ex);
}
}
}

View File

@@ -2,7 +2,7 @@ using System.Collections;
using System.Net;
using DotNext;
using Newtonsoft.Json;
using server;
using server.Services;
using WebProtocol;
namespace Peripherals.JtagClient;
@@ -380,15 +380,21 @@ public class JtagStatusReg
public class Jtag
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ProgressTracker _progressTracker = MsgBus.ProgressTracker;
private const int CLOCK_FREQ = 50; // MHz
readonly int timeout;
readonly int taskID = 10;
readonly int port;
readonly string address;
/// <summary>
/// Jtag控制器IP地址
/// </summary>
public readonly string address;
private IPEndPoint ep;
/// <summary>
/// Jtag 构造函数
/// </summary>
@@ -410,7 +416,7 @@ public class Jtag
{
BurstType = BurstType.FixedBurst,
BurstLength = 0,
CommandID = 0,
CommandID = (byte)this.taskID,
Address = devAddr,
IsWrite = false,
};
@@ -424,7 +430,7 @@ public class Jtag
if (!MsgBus.IsRunning)
return new(new Exception("Message Bus not Working!"));
var retPack = await MsgBus.UDPServer.WaitForDataAsync(address, 0, port);
var retPack = await MsgBus.UDPServer.WaitForDataAsync(this.ep, this.taskID, this.timeout);
if (!retPack.IsSuccessful || !retPack.Value.IsSuccessful)
return new(new Exception("Send address package failed"));
@@ -436,14 +442,15 @@ public class Jtag
if (retPackLen != 4)
return new(new Exception($"RecvDataPackage BodyData Length not Equal to 4: Total {retPackLen} bytes"));
return Convert.ToUInt32(Common.Number.BytesToUInt64(retPackOpts.Data).Value);
return Convert.ToUInt32(Common.Number.BytesToUInt32(retPackOpts.Data).Value);
}
async ValueTask<Result<bool>> WriteFIFO
(UInt32 devAddr, UInt32 data, UInt32 result, UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0)
async ValueTask<Result<bool>> WriteFIFO(
UInt32 devAddr, UInt32 data, UInt32 result,
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, string progressId = "")
{
{
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout);
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, devAddr, data, this.timeout, progressId);
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value) return new(new Exception("Write FIFO failed"));
}
@@ -452,17 +459,20 @@ public class Jtag
await Task.Delay(TimeSpan.FromMilliseconds(delayMilliseconds));
{
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout);
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, this.taskID, JtagAddr.STATE, result, resultMask, 0, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
_progressTracker?.AdvanceProgress(progressId, 10);
return ret.Value;
}
}
async ValueTask<Result<bool>> WriteFIFO
(UInt32 devAddr, byte[] data, UInt32 result, UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0)
async ValueTask<Result<bool>> WriteFIFO(
UInt32 devAddr, byte[] data, UInt32 result,
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, string progressId = "")
{
{
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout);
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, devAddr, data, this.timeout, progressId);
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value) return new(new Exception("Write FIFO failed"));
}
@@ -471,8 +481,9 @@ public class Jtag
await Task.Delay(TimeSpan.FromMilliseconds(delayMilliseconds));
{
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout);
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, this.taskID, JtagAddr.STATE, result, resultMask, 0, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
_progressTracker.AdvanceProgress(progressId, 10);
return ret.Value;
}
}
@@ -556,7 +567,8 @@ public class Jtag
return await ClearWriteDataReg();
}
async ValueTask<Result<bool>> LoadDRCareInput(byte[] bytesArray, UInt32 timeout = 10_000, UInt32 cycle = 500)
async ValueTask<Result<bool>> LoadDRCareInput(
byte[] bytesArray, UInt32 timeout = 10_000, UInt32 cycle = 500, string progressId = "")
{
var bytesLen = ((uint)(bytesArray.Length * 8));
if (bytesLen > Math.Pow(2, 28)) return new(new Exception("Length is over 2^(28 - 3)"));
@@ -571,11 +583,16 @@ public class Jtag
else if (!ret.Value) return new(new Exception("Write CMD_JTAG_LOAD_DR_CAREI Failed"));
}
_progressTracker.AdvanceProgress(progressId, 10);
{
var ret = await WriteFIFO(
JtagAddr.WRITE_DATA,
bytesArray, 0x01_00_00_00,
JtagState.CMD_EXEC_FINISH);
JtagState.CMD_EXEC_FINISH,
0,
progressId
);
if (!ret.IsSuccessful) return new(ret.Error);
return ret.Value;
@@ -609,13 +626,10 @@ public class Jtag
if (ret.Value)
{
var array = new UInt32[UInt32Num];
for (int i = 0; i < UInt32Num; i++)
{
var retData = await ReadFIFO(JtagAddr.READ_DATA);
if (!retData.IsSuccessful)
return new(new Exception("Read FIFO failed when Load DR"));
array[i] = retData.Value;
}
var retData = await UDPClientPool.ReadAddr4Bytes(ep, this.taskID, JtagAddr.READ_DATA, (int)UInt32Num);
if (!retData.IsSuccessful)
return new(new Exception("Read FIFO failed when Load DR"));
Buffer.BlockCopy(retData.Value, 0, array, 0, (int)UInt32Num * 4);
return array;
}
else
@@ -629,9 +643,9 @@ public class Jtag
public async ValueTask<Result<uint>> ReadIDCode()
{
// Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0);
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Clear up udp server {this.address,0} receive data");
logger.Trace($"Clear up udp server {this.address} {this.taskID} receive data");
Result<bool> ret;
@@ -667,9 +681,9 @@ public class Jtag
public async ValueTask<Result<uint>> ReadStatusReg()
{
// Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0);
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Clear up udp server {this.address,0} receive data");
logger.Trace($"Clear up udp server {this.address} {this.taskID} receive data");
Result<bool> ret;
@@ -700,45 +714,55 @@ public class Jtag
/// 下载比特流到 JTAG 设备
/// </summary>
/// <param name="bitstream">比特流数据</param>
/// <param name="progressId">进度ID</param>
/// <returns>指示下载是否成功的异步结果</returns>
public async ValueTask<Result<bool>> DownloadBitstream(byte[] bitstream)
public async ValueTask<Result<bool>> DownloadBitstream(
byte[] bitstream, string progressId = "")
{
// Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0);
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Clear up udp server {this.address,0} receive data");
logger.Trace($"Clear up udp server {this.address} {this.taskID} receive data");
_progressTracker.AdvanceProgress(progressId, 10);
Result<bool> ret;
ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
logger.Trace("Jtag initialize");
ret = await ExecRDCmd(JtagCmd.JTAG_DR_JRST);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_JRST Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
ret = await ExecRDCmd(JtagCmd.JTAG_DR_CFGI);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_CFGI Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
logger.Trace("Jtag ready to write bitstream");
ret = await IdleDelay(100000);
ret = await IdleDelay(1000);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
ret = await LoadDRCareInput(bitstream);
ret = await LoadDRCareInput(bitstream, progressId: progressId);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Load Data Failed"));
@@ -747,32 +771,40 @@ public class Jtag
ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
ret = await ExecRDCmd(JtagCmd.JTAG_DR_JWAKEUP);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_JWAKEUP Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
logger.Trace("Jtag reset device");
ret = await IdleDelay(10000);
ret = await IdleDelay(1000);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
var retCode = await ReadStatusReg();
if (!retCode.IsSuccessful) return new(retCode.Error);
var jtagStatus = new JtagStatusReg(retCode.Value);
if (!(jtagStatus.done && jtagStatus.wakeup_over && jtagStatus.init_complete))
return new(new Exception("Jtag download bitstream failed"));
_progressTracker.AdvanceProgress(progressId, 10);
ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
logger.Trace("Jtag download bitstream successfully");
_progressTracker.AdvanceProgress(progressId, 10);
// Finish
_progressTracker.AdvanceProgress(progressId, 10);
return true;
}
@@ -785,12 +817,12 @@ public class Jtag
{
var paser = new BsdlParser.Parser();
var portNum = paser.GetBoundaryRegsNum().Value;
logger.Debug($"Get boundar scan registers number: {portNum}");
logger.Debug($"Get boundary scan registers number: {portNum}");
// Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0);
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Clear up udp server {this.address,0} receive data");
logger.Trace($"Clear up udp server {this.address} {this.taskID} receive data");
Result<bool> ret;
@@ -855,9 +887,9 @@ public class Jtag
public async ValueTask<Result<bool>> SetSpeed(UInt32 speed)
{
// Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0);
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Clear up udp server {this.address,0} receive data");
logger.Trace($"Clear up udp server {this.address} {this.taskID} receive data");
var ret = await WriteFIFO(
JtagAddr.SPEED_CTRL, (speed << 16) | speed,

View File

@@ -2,16 +2,17 @@ using System.Collections;
using System.Net;
using Common;
using DotNext;
using WebProtocol;
namespace Peripherals.LogicAnalyzerClient;
static class AnalyzerAddr
{
const UInt32 BASE = 0x9000_0000;
const UInt32 DMA1_BASE = 0x7000_0000;
const UInt32 DMA_BASE = 0xA000_0000;
const UInt32 DDR_BASE = 0x0000_0000;
/// <summary>
/// <summary>
/// 0x0000_0000 R/W [ 0] capture on: 置1开始等待捕获0停止捕获。捕获到信号后该位自动清零。 <br/>
/// [ 8] capture force: 置1则强制捕获信号自动置0。 <br/>
/// [16] capture busy: 1为逻辑分析仪正在捕获信号。 <br/>
@@ -20,7 +21,7 @@ static class AnalyzerAddr
/// </summary>
public const UInt32 CAPTURE_MODE = BASE + 0x0000_0000;
/// <summary>
/// <summary>
/// 0x0000_0001 R/W [1:0] global trig mode: 00: 全局与 (&amp;) <br/>
/// 01: 全局或 (&#124;) <br/>
/// 10: 全局非与(~&amp;) <br/>
@@ -28,7 +29,7 @@ static class AnalyzerAddr
/// </summary>
public const UInt32 GLOBAL_TRIG_MODE = BASE + 0x0000_0001;
/// <summary>
/// <summary>
/// 0x0000_0010 - 0x0000_0017 R/W [5:0] 信号M的触发操作符共8路 <br/>
/// [5:3] M's Operator: 000 == <br/>
/// 001 != <br/>
@@ -66,12 +67,13 @@ static class AnalyzerAddr
public const UInt32 LOAD_NUM_ADDR = BASE + 0x0000_0002;
public const UInt32 PRE_LOAD_NUM_ADDR = BASE + 0x0000_0003;
public const UInt32 CAHNNEL_DIV_ADDR = BASE + 0x0000_0004;
public const UInt32 DMA1_START_WRITE_ADDR = DMA1_BASE + 0x0000_0012;
public const UInt32 DMA1_END_WRITE_ADDR = DMA1_BASE + 0x0000_0013;
public const UInt32 DMA1_CAPTURE_CTRL_ADDR = DMA1_BASE + 0x0000_0014;
public const UInt32 STORE_OFFSET_ADDR = DDR_BASE + 0x0010_0000;
public const UInt32 CLOCK_DIV_ADDR = BASE + 0x0000_0005;
public const UInt32 DMA_CAPTURE_RD_CTRL1 = DMA_BASE + 0x1;
public const UInt32 DMA_START_WRITE_ADDR1 = DMA_BASE + 0x22;
public const UInt32 DMA_END_WRITE_ADDR1 = DMA_BASE + 0x23;
public const UInt32 STORE_OFFSET_ADDR = DDR_BASE + 0x0100_0000;
/// <summary>
/// <summary>
/// 0x0100_0000 - 0x0100_03FF 只读 32位波形存储得到的32位数据中低八位最先捕获高八位最后捕获。<br/>
/// 共1024个地址每个地址存储4组深度为4096。<br/>
/// </summary>
@@ -85,84 +87,130 @@ static class AnalyzerAddr
[Flags]
public enum CaptureStatus
{
/// <summary>
/// <summary>
/// 无状态标志
/// </summary>
None = 0,
/// <summary>
/// <summary>
/// 捕获使能位置1开始等待捕获0停止捕获。捕获到信号后该位自动清零
/// </summary>
CaptureOn = 1 << 0, // [0] 捕获使能
/// <summary>
/// <summary>
/// 强制捕获位置1则强制捕获信号自动置0
/// </summary>
CaptureForce = 1 << 8, // [8] 强制捕获
/// <summary>
/// <summary>
/// 捕获忙碌位1为逻辑分析仪正在捕获信号
/// </summary>
CaptureBusy = 1 << 16, // [16] 捕获进行中
/// <summary>
/// <summary>
/// 捕获完成位1为逻辑分析仪内存完整存储了此次捕获的信号
/// </summary>
CaptureDone = 1 << 24 // [24] 捕获完成
}
/// <summary>
/// <summary>
/// 全局触发模式枚举,定义多路信号触发条件的逻辑组合方式
/// </summary>
public enum GlobalCaptureMode
{
/// <summary>
/// <summary>
/// 全局与模式,所有触发条件都必须满足
/// </summary>
AND = 0b00,
/// <summary>
/// <summary>
/// 全局或模式,任一触发条件满足即可
/// </summary>
OR = 0b01,
/// <summary>
/// <summary>
/// 全局非与模式,不是所有触发条件都满足
/// </summary>
NAND = 0b10,
/// <summary>
/// <summary>
/// 全局非或模式,所有触发条件都不满足
/// </summary>
NOR = 0b11
}
/// <summary>
/// 逻辑分析仪采样时钟分频系数
/// </summary>
public enum AnalyzerClockDiv
{
/// <summary>
/// 1分频
/// </summary>
DIV1 = 0x0000_0000,
/// <summary>
/// 2分频
/// </summary>
DIV2 = 0x0000_0001,
/// <summary>
/// 4分频
/// </summary>
DIV4 = 0x0000_0002,
/// <summary>
/// 8分频
/// </summary>
DIV8 = 0x0000_0003,
/// <summary>
/// 16分频
/// </summary>
DIV16 = 0x0000_0004,
/// <summary>
/// 32分频
/// </summary>
DIV32 = 0x0000_0005,
/// <summary>
/// 64分频
/// </summary>
DIV64 = 0x0000_0006,
/// <summary>
/// 128分频
/// </summary>
DIV128 = 0x0000_0007
}
/// <summary>
/// 信号M的操作符枚举
/// </summary>
public enum SignalOperator : byte
{
/// <summary>
/// <summary>
/// 等于操作符
/// </summary>
Equal = 0b000, // ==
/// <summary>
/// <summary>
/// 不等于操作符
/// </summary>
NotEqual = 0b001, // !=
/// <summary>
/// <summary>
/// 小于操作符
/// </summary>
LessThan = 0b010, // <
/// <summary>
/// <summary>
/// 小于等于操作符
/// </summary>
LessThanOrEqual = 0b011, // <=
/// <summary>
/// <summary>
/// 大于操作符
/// </summary>
GreaterThan = 0b100, // >
/// <summary>
/// <summary>
/// 大于等于操作符
/// </summary>
GreaterThanOrEqual = 0b101 // >=
@@ -173,35 +221,35 @@ public enum SignalOperator : byte
/// </summary>
public enum SignalValue : byte
{
/// <summary>
/// <summary>
/// 逻辑0电平
/// </summary>
Logic0 = 0b000, // LOGIC 0
/// <summary>
/// <summary>
/// 逻辑1电平
/// </summary>
Logic1 = 0b001, // LOGIC 1
/// <summary>
/// <summary>
/// 不关心该信号状态
/// </summary>
NotCare = 0b010, // X(not care)
/// <summary>
/// <summary>
/// 上升沿触发
/// </summary>
Rise = 0b011, // RISE
/// <summary>
/// <summary>
/// 下降沿触发
/// </summary>
Fall = 0b100, // FALL
/// <summary>
/// <summary>
/// 上升沿或下降沿触发
/// </summary>
RiseOrFall = 0b101, // RISE OR FALL
/// <summary>
/// <summary>
/// 信号无变化
/// </summary>
NoChange = 0b110, // NOCHANGE
/// <summary>
/// <summary>
/// 特定数值
/// </summary>
SomeNumber = 0b111 // SOME NUMBER
@@ -212,11 +260,11 @@ public enum SignalValue : byte
/// </summary>
public enum AnalyzerChannelDiv
{
/// <summary>
/// <summary>
/// 1路
/// </summary>
ONE = 0x0000_0000,
/// <summary>
/// <summary>
/// 2路
/// </summary>
TWO = 0x0000_0001,
@@ -279,20 +327,34 @@ public class Analyzer
/// <returns>操作结果成功返回true否则返回异常信息</returns>
public async ValueTask<Result<bool>> SetCaptureMode(bool captureOn, bool force)
{
// 构造寄存器值
UInt32 value = 0;
if (captureOn) value |= 1 << 0;
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.DMA1_CAPTURE_CTRL_ADDR, value, this.timeout);
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.DMA_CAPTURE_RD_CTRL1, 0x00000000u, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set DMA1_CAPTURE_CTRL_ADDR: {ret.Error}");
logger.Error($"Failed to set DMA_CAPTURE_RD_CTRL to 0: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("WriteAddr to DMA1_CAPTURE_CTRL_ADDR returned false");
return new(new Exception("Failed to set DMA1_CAPTURE_CTRL_ADDR"));
logger.Error("WriteAddr to DMA_CAPTURE_RD_CTRL returned false");
return new(new Exception("Failed to set DMA_CAPTURE_RD_CTRL"));
}
}
await Task.Delay(5);
// 构造寄存器值
UInt32 value = 0;
if (captureOn) value |= 1 << 0;
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.DMA_CAPTURE_RD_CTRL1, value, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set DMA_CAPTURE_RD_CTRL: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("WriteAddr to DMA_CAPTURE_RD_CTRL returned false");
return new(new Exception("Failed to set DMA_CAPTURE_RD_CTRL"));
}
}
if (force) value |= 1 << 8;
@@ -318,7 +380,7 @@ public class Analyzer
/// <returns>操作结果,成功返回寄存器值,否则返回异常信息</returns>
public async ValueTask<Result<CaptureStatus>> ReadCaptureStatus()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, AnalyzerAddr.CAPTURE_MODE, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, AnalyzerAddr.CAPTURE_MODE, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read capture status: {ret.Error}");
@@ -386,13 +448,14 @@ public class Analyzer
}
/// <summary>
/// 设置逻辑分析仪的深度、预采样深度、有效通道
/// 设置逻辑分析仪的深度、预采样深度、有效通道、分频系数
/// </summary>
/// <param name="capture_length">深度</param>
/// <param name="pre_capture_length">预采样深度</param>
/// <param name="channel_div">有效通道(0-[1],1-[2],2-[4],3-[8],4-[16],5-[32])</param>
/// <param name="clock_div">采样时钟分频系数</param>
/// <returns>操作结果成功返回true否则返回异常信息</returns>
public async ValueTask<Result<bool>> SetCaptureParams(int capture_length, int pre_capture_length, AnalyzerChannelDiv channel_div)
public async ValueTask<Result<bool>> SetCaptureParams(int capture_length, int pre_capture_length, AnalyzerChannelDiv channel_div, AnalyzerClockDiv clock_div)
{
if (capture_length == 0) capture_length = 1;
if (pre_capture_length == 0) pre_capture_length = 1;
@@ -423,29 +486,29 @@ public class Analyzer
}
}
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.DMA1_START_WRITE_ADDR, AnalyzerAddr.STORE_OFFSET_ADDR, this.timeout);
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.DMA_START_WRITE_ADDR1, AnalyzerAddr.STORE_OFFSET_ADDR, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set DMA1_START_WRITE_ADDR: {ret.Error}");
logger.Error($"Failed to set DMA_START_WRITE_ADDR: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("WriteAddr to DMA1_START_WRITE_ADDR returned false");
return new(new Exception("Failed to set DMA1_START_WRITE_ADDR"));
logger.Error("WriteAddr to DMA_START_WRITE_ADDR returned false");
return new(new Exception("Failed to set DMA_START_WRITE_ADDR"));
}
}
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.DMA1_END_WRITE_ADDR, AnalyzerAddr.STORE_OFFSET_ADDR + (UInt32)(capture_length - 1), this.timeout);
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.DMA_END_WRITE_ADDR1, AnalyzerAddr.STORE_OFFSET_ADDR + (UInt32)(capture_length - 1), this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set DMA1_END_WRITE_ADDR: {ret.Error}");
logger.Error($"Failed to set DMA_END_WRITE_ADDR: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("WriteAddr to DMA1_END_WRITE_ADDR returned false");
return new(new Exception("Failed to set DMA1_END_WRITE_ADDR"));
logger.Error("WriteAddr to DMA_END_WRITE_ADDR returned false");
return new(new Exception("Failed to set DMA_END_WRITE_ADDR"));
}
}
{
@@ -461,6 +524,19 @@ public class Analyzer
return new(new Exception("Failed to set CAHNNEL_DIV_ADDR"));
}
}
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.CLOCK_DIV_ADDR, (UInt32)clock_div, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set CLOCK_DIV_ADDR: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("WriteAddr to CLOCK_DIV_ADDR returned false");
return new(new Exception("Failed to set CLOCK_DIV_ADDR"));
}
}
return true;
}
@@ -475,6 +551,7 @@ public class Analyzer
this.taskID,
AnalyzerAddr.STORE_OFFSET_ADDR,
capture_length,
BurstType.ExtendBurst, // 使用扩展突发读取
this.timeout
);
if (!ret.IsSuccessful)

View File

@@ -1,76 +1,93 @@
using System.Net;
using Common;
using DotNext;
using WebProtocol;
using Tapper;
namespace Peripherals.OscilloscopeClient;
public class OscilloscopeConfig
{
public bool CaptureEnabled { get; set; }
public byte TriggerLevel { get; set; }
public bool TriggerRisingEdge { get; set; }
public ushort HorizontalShift { get; set; }
public ushort DecimationRate { get; set; }
// public bool AutoRefreshRAM { get; set; }
}
static class OscilloscopeAddr
{
const UInt32 BASE = 0x8000_0000;
/// <summary>
/// <summary>
/// 0x0000_0000:R/W[0] wave_run 启动捕获/关闭
/// </summary>
public const UInt32 START_CAPTURE = BASE + 0x0000_0000;
/// <summary>
/// <summary>
/// 0x0000_0001: R/W[7:0] trig_level 触发电平
/// </summary>
public const UInt32 TRIG_LEVEL = BASE + 0x0000_0001;
/// <summary>
/// <summary>
/// 0x0000_0002:R/W[0] trig_edge 触发边沿0-下降沿1-上升沿
/// </summary>
public const UInt32 TRIG_EDGE = BASE + 0x0000_0002;
/// <summary>
/// 0x0000_0003: R/W[9:0] h shift 水平偏移量
/// </summary>
public const UInt32 H_SHIFT = BASE + 0x0000_0003;
/// <summary>
/// <summary>
/// 0x0000_0004: R/W[9:0] deci rate 抽样率0—1023
/// </summary>
public const UInt32 DECI_RATE = BASE + 0x0000_0004;
public const UInt32 DECI_RATE = BASE + 0x0000_0003;
/// <summary>
/// <summary>
/// 0x0000_0005:R/W[0] ram refresh RAM刷新
/// </summary>
public const UInt32 RAM_FRESH = BASE + 0x0000_0005;
public const UInt32 RAM_FRESH = BASE + 0x0000_0004;
/// <summary>
/// <summary>
/// 0x0000_0005:R/W[0] wave ready 波形数据就绪
/// </summary>
public const UInt32 WAVE_READY = BASE + 0x0000_0005;
/// <summary>
/// 0x0000_0005:R/W[0] trig postion 触发地址
/// </summary>
public const UInt32 TRIG_POSIION = BASE + 0x0000_0006;
/// <summary>
/// 0x0000 0006:R[19: 0] ad_freq AD采样频率
/// </summary>
public const UInt32 AD_FREQ = BASE + 0x0000_0006;
public const UInt32 AD_FREQ = BASE + 0x0000_0007;
/// <summary>
/// <summary>
/// Ox0000_0007: R[7:0] ad_vpp AD采样幅度
/// </summary>
public const UInt32 AD_VPP = BASE + 0x0000_0007;
public const UInt32 AD_VPP = BASE + 0x0000_0008;
/// <summary>
/// <summary>
/// 0x0000_0008: R[7:0] ad max AD采样最大值
/// </summary>
public const UInt32 AD_MAX = BASE + 0x0000_0008;
public const UInt32 AD_MAX = BASE + 0x0000_0009;
/// <summary>
/// <summary>
/// 0x0000_0009: R[7:0] ad_min AD采样最小值
/// </summary>
public const UInt32 AD_MIN = BASE + 0x0000_0009;
public const UInt32 AD_MIN = BASE + 0x0000_000A;
/// <summary>
/// <summary>
/// 0x0000_1000-0x0000_13FF:R[7:0] wave_rd_data 共1024个字节
/// </summary>
public const UInt32 RD_DATA_ADDR = BASE + 0x0000_1000;
public const UInt32 RD_DATA_LENGTH = 0x0000_0400;
}
class Oscilloscope
class OscilloscopeCtrl
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 2000;
readonly int taskID = 0;
readonly int taskID = 12;
readonly int port;
readonly string address;
@@ -82,7 +99,7 @@ class Oscilloscope
/// <param name="address">示波器设备IP地址</param>
/// <param name="port">示波器设备端口</param>
/// <param name="timeout">超时时间(毫秒)</param>
public Oscilloscope(string address, int port, int timeout = 2000)
public OscilloscopeCtrl(string address, int port, int timeout = 2000)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
@@ -92,6 +109,49 @@ class Oscilloscope
this.timeout = timeout;
}
/// <summary>
/// 一次性初始化/配置示波器
/// </summary>
/// <param name="config">完整配置</param>
/// <returns>操作结果全部成功返回true否则返回异常信息</returns>
public async ValueTask<Result<bool>> Init(OscilloscopeConfig config)
{
// 1. 捕获使能
var ret = await SetCaptureEnable(config.CaptureEnabled);
if (!ret.IsSuccessful || !ret.Value)
return new(ret.Error ?? new Exception("Failed to set capture enable"));
// 2. 触发电平
ret = await SetTriggerLevel(config.TriggerLevel);
if (!ret.IsSuccessful || !ret.Value)
return new(ret.Error ?? new Exception("Failed to set trigger level"));
// 3. 触发边沿
ret = await SetTriggerEdge(config.TriggerRisingEdge);
if (!ret.IsSuccessful || !ret.Value)
return new(ret.Error ?? new Exception("Failed to set trigger edge"));
// 4. 水平偏移
ret = await SetHorizontalShift(config.HorizontalShift);
if (!ret.IsSuccessful || !ret.Value)
return new(ret.Error ?? new Exception("Failed to set horizontal shift"));
// 5. 抽样率
ret = await SetDecimationRate(config.DecimationRate);
if (!ret.IsSuccessful || !ret.Value)
return new(ret.Error ?? new Exception("Failed to set decimation rate"));
// 6. RAM刷新如果需要
// if (config.AutoRefreshRAM)
// {
// ret = await RefreshRAM();
// if (!ret.IsSuccessful || !ret.Value)
// return new(ret.Error ?? new Exception("Failed to refresh RAM"));
// }
return true;
}
/// <summary>
/// 控制示波器的捕获开关
/// </summary>
@@ -164,20 +224,6 @@ class Oscilloscope
/// <returns>操作结果成功返回true否则返回异常信息</returns>
public async ValueTask<Result<bool>> SetHorizontalShift(UInt16 shift)
{
if (shift > 1023)
return new(new ArgumentException("Horizontal shift must be 0-1023", nameof(shift)));
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, OscilloscopeAddr.H_SHIFT, shift, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set horizontal shift: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("WriteAddr to H_SHIFT returned false");
return new(new Exception("Failed to set horizontal shift"));
}
return true;
}
@@ -231,7 +277,7 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样频率值,否则返回异常信息</returns>
public async ValueTask<Result<UInt32>> GetADFrequency()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, OscilloscopeAddr.AD_FREQ, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.AD_FREQ, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read AD frequency: {ret.Error}");
@@ -254,7 +300,7 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样幅度值,否则返回异常信息</returns>
public async ValueTask<Result<byte>> GetADVpp()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, OscilloscopeAddr.AD_VPP, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.AD_VPP, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read AD VPP: {ret.Error}");
@@ -274,7 +320,7 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样最大值,否则返回异常信息</returns>
public async ValueTask<Result<byte>> GetADMax()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, OscilloscopeAddr.AD_MAX, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.AD_MAX, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read AD max: {ret.Error}");
@@ -294,7 +340,7 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样最小值,否则返回异常信息</returns>
public async ValueTask<Result<byte>> GetADMin()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, OscilloscopeAddr.AD_MIN, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.AD_MIN, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read AD min: {ret.Error}");
@@ -314,11 +360,29 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样数据数组,否则返回异常信息</returns>
public async ValueTask<Result<byte[]>> GetWaveformData()
{
// 等待WAVE_READY[0]位为1最多等待50ms5次x10ms间隔
var readyResult = await UDPClientPool.ReadAddrWithWait(
this.ep, this.taskID, OscilloscopeAddr.WAVE_READY, 0b01, 0x01, 10, 50);
if (!readyResult.IsSuccessful)
{
logger.Error($"Failed to wait for wave ready: {readyResult.Error}");
return new(readyResult.Error);
}
// 无论准备好与否都继续读取数据readyResult.Value表示是否在超时前准备好
if (!readyResult.Value)
{
logger.Warn("Wave data may not be ready, but continuing to read");
}
// 无论准备好与否,都继续读取数据
var ret = await UDPClientPool.ReadAddr4BytesAsync(
this.ep,
this.taskID,
OscilloscopeAddr.RD_DATA_ADDR,
(int)OscilloscopeAddr.RD_DATA_LENGTH / 32,
BurstType.ExtendBurst, // 使用扩展突发读取
this.timeout
);
if (!ret.IsSuccessful)
@@ -343,6 +407,42 @@ class Oscilloscope
waveformData[i] = data[4 * i + 3];
}
return waveformData;
// 获取触发地址用作数据偏移量
var trigPosResult = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.TRIG_POSIION, this.timeout);
if (!trigPosResult.IsSuccessful)
{
logger.Error($"Failed to read trigger position: {trigPosResult.Error}");
return new(trigPosResult.Error);
}
if (trigPosResult.Value.Options.Data == null || trigPosResult.Value.Options.Data.Length < 4)
{
logger.Error("ReadAddr returned invalid data for trigger position");
return new(new Exception("Failed to read trigger position"));
}
UInt32 trigAddr = Number.BytesToUInt32(trigPosResult.Value.Options.Data).Value;
// 根据触发地址对数据进行偏移,使触发点位于数据中间
int targetPos = sampleCount / 2; // 目标位置:数据中间
int actualTrigPos = (int)(trigAddr % (UInt32)sampleCount); // 实际触发位置
int shiftAmount = targetPos - actualTrigPos;
// 创建偏移后的数据数组
byte[] offsetData = new byte[sampleCount];
for (int i = 0; i < sampleCount; i++)
{
int sourceIndex = (i - shiftAmount + sampleCount) % sampleCount;
offsetData[i] = waveformData[sourceIndex];
}
// 刷新RAM
var refreshResult = await RefreshRAM();
if (!refreshResult.IsSuccessful)
{
logger.Error($"Failed to refresh RAM after reading waveform data: {refreshResult.Error}");
return new(refreshResult.Error);
}
return offsetData;
}
}

View File

@@ -7,20 +7,20 @@ static class RemoteUpdaterAddr
{
public const UInt32 Base = 0x20_00_00_00;
/// <summary>
/// <summary>
/// ADDR: 0X00: 写Flash-读写地址——控制位 <br/>
/// [31:16]: wr_sector_num <br/>
/// [15: 0]: {flash_wr_en,-,-,-, start_wr_sector} <br/>
/// </summary>
public const UInt32 WriteCtrl = Base + 0x00;
/// <summary>
/// <summary>
/// ADDR: 0X01: 写Flash-只写地址——FIFO入口 <br/>
/// [31:0]: 写比特流数据入口 <br/>
/// </summary>
public const UInt32 WriteFIFO = Base + 0x01;
/// <summary>
/// <summary>
/// ADDR: 0X02: 写Flash-只读地址——标志位 <br/>
/// [31:24]: {-, -, -, -, -, -, -, wr_fifo_full} <br/>
/// [23:16]: {-, -, -, -, -, -, -, wr_fifo_empty} <br/>
@@ -29,14 +29,14 @@ static class RemoteUpdaterAddr
/// </summary>
public const UInt32 WriteSign = Base + 0x02;
/// <summary>
/// <summary>
/// ADDR: 0X03: 读Flash-读写地址——控制位1 <br/>
/// [31:16]: rd_sector_num <br/>
/// [15: 0]: {flash_rd_en,-,-,-, start_rd_sub_sector} <br/>
/// </summary>
public const UInt32 ReadCtrl1 = Base + 0x03;
/// <summary>
/// <summary>
/// ADDR: 0X04: 读Flash-读写地址——控制位2 <br/>
/// [31:24]: { } <br/>
/// [23:16]: {-, -, -, -, -, -,{ bs_crc32_ok }} <br/>
@@ -45,19 +45,19 @@ static class RemoteUpdaterAddr
/// </summary>
public const UInt32 ReadCtrl2 = Base + 0x04;
/// <summary>
/// <summary>
/// ADDR: 0X05: 读Flash-只读地址——FIFO出口 <br/>
/// [31:0]: 读比特流数据出口 <br/>
/// </summary>
public const UInt32 ReadFIFO = Base + 0x05;
/// <summary>
/// <summary>
/// ADDR: 0X06: 读Flash-只读地址——CRC校验值 <br/>
/// [31:0]: CRC校验值 bs_readback_crc <br/>
/// </summary>
public const UInt32 ReadCRC = Base + 0x06;
/// <summary>
/// <summary>
/// ADDR: 0X07: 读Flash-只读地址——标志位 <br/>
/// [31:24]: {-, -, -, -, -, -, -, rd_fifo_afull} <br/>
/// [23:16]: {-, -, -, -, -, -, -, rd_fifo_empty} <br/>
@@ -66,14 +66,14 @@ static class RemoteUpdaterAddr
/// </summary>
public const UInt32 ReadSign = Base + 0x07;
/// <summary>
/// <summary>
/// ADDR: 0X08: 热启动开关-读写地址——控制位 <br/>
/// [31: 8]: hotreset_addr <br/>
/// [ 7: 0]: {-, -, -, -, -, -, -, hotreset_en} <br/>
/// </summary>
public const UInt32 HotResetCtrl = Base + 0x08;
/// <summary>
/// <summary>
/// ADDR: 0X09: 只读地址 版本号 <br/>
/// [31: 0]: FPGA_VERSION[31:0] <br/>
/// </summary>
@@ -339,7 +339,7 @@ public class RemoteUpdater
}
{
var ret = await UDPClientPool.ReadAddr(this.ep, 0, RemoteUpdaterAddr.ReadCRC, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, 0, RemoteUpdaterAddr.ReadCRC, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
var bytes = ret.Value.Options.Data;
@@ -543,7 +543,7 @@ public class RemoteUpdater
logger.Trace("Clear udp data finished");
{
var ret = await UDPClientPool.ReadAddr(this.ep, 0, RemoteUpdaterAddr.Version, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, 0, RemoteUpdaterAddr.Version, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
var retData = ret.Value.Options.Data;

View File

@@ -0,0 +1,106 @@
using System.Net;
using DotNext;
using Tapper;
namespace Peripherals.RotaryEncoderClient;
class RotaryEncoderCtrlAddr
{
public const UInt32 BASE = 0xB0_00_00_30;
public const UInt32 PRESS_BASE = 0xB0_00_00_40;
public const UInt32 ENABLE = BASE;
public const UInt32 PRESS_ENABLE = PRESS_BASE;
}
[TranspilationSource]
public enum RotaryEncoderDirection : uint
{
CounterClockwise = 0,
Clockwise = 1,
}
[TranspilationSource]
public enum RotaryEncoderPressStatus : uint
{
Press = 0,
Release = 1,
}
public class RotaryEncoderCtrl
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
public RotaryEncoderCtrl(string address, int port, int taskID, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.taskID = taskID;
this.timeout = timeout;
}
public async ValueTask<Result<bool>> SetEnable(bool enable)
{
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else return new(new Exception("Message Bus not work!"));
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, RotaryEncoderCtrlAddr.ENABLE, enable ? 0x1U : 0x0U, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value)
{
logger.Error($"Set Rotary Encoder Enable failed: {ret.Error}");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, RotaryEncoderCtrlAddr.PRESS_ENABLE, enable ? 0x1U : 0x0U, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
return ret.Value;
}
}
public async ValueTask<Result<bool>> RotateEncoderOnce(int num, RotaryEncoderDirection direction)
{
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else return new(new Exception("Message Bus not work!"));
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, RotaryEncoderCtrlAddr.BASE + (UInt32)num, (UInt32)direction, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Set Rotary Encoder Rotate {num} {direction.ToString()} failed: {ret.Error}");
return new(ret.Error);
}
return ret.Value;
}
public async ValueTask<Result<bool>> PressEncoderOnce(int num, RotaryEncoderPressStatus press)
{
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else return new(new Exception("Message Bus not work!"));
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, RotaryEncoderCtrlAddr.PRESS_BASE + (UInt32)num, (UInt32)press, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Set Rotary Encoder Set {num} {press.ToString()} failed: {ret.Error}");
return new(ret.Error);
}
return ret.Value;
}
}

View File

@@ -0,0 +1,79 @@
using System.Net;
using DotNext;
using Common;
namespace Peripherals.SevenDigitalTubesClient;
static class SevenDigitalTubesAddr
{
public const UInt32 BASE = 0xB000_0000;
}
public class SevenDigitalTubesCtrl
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
/// <summary>
/// 初始化七段数码管控制器
/// </summary>
/// <param name="address">七段数码管控制器IP地址</param>
/// <param name="port">七段数码管控制器端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="timeout">超时时间(毫秒)</param>
public SevenDigitalTubesCtrl(string address, int port, int taskID, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.taskID = taskID;
this.timeout = timeout;
}
public async ValueTask<Result<byte>> ReadTube(int num)
{
if (num < 0 || num > 31)
throw new ArgumentOutOfRangeException(nameof(num), "Tube number must be between 0 and 31");
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, SevenDigitalTubesAddr.BASE + (UInt32)num, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Read tubes failed: {ret.Error}");
return new(ret.Error);
}
if (ret.Value.Options.Data == null || ret.Value.Options.Data.Length < 4)
return new(new Exception("Data length is too short"));
var data = Number.BytesToUInt32(ret.Value.Options.Data, 0, 4).Value;
if ((data >> 8) != num)
{
logger.Error($"Read wrong tube number: {num} != {data >> 8}");
return new(new Exception($"Read wrong tube number: {num} != {data >> 8}"));
}
return (byte)(data & 0xFF);
}
public async ValueTask<Result<byte[]>> ScanAllTubes()
{
var tubes = new byte[32];
for (int i = 0; i < 32; i++)
{
var ret = await ReadTube(i);
if (!ret.IsSuccessful)
return new(ret.Error);
tubes[i] = ret.Value;
}
return tubes;
}
}

View File

@@ -0,0 +1,61 @@
using System.Net;
using DotNext;
namespace Peripherals.SwitchClient;
class SwitchCtrlAddr
{
public const UInt32 BASE = 0xB0_00_00_20;
public const UInt32 ENABLE = BASE;
}
public class SwitchCtrl
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
public SwitchCtrl(string address, int port, int taskID, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.taskID = taskID;
this.timeout = timeout;
}
public async ValueTask<Result<bool>> SetEnable(bool enable)
{
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else return new(new Exception("Message Bus not work!"));
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, SwitchCtrlAddr.ENABLE, enable ? 0x1U : 0x0U, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
return ret.Value;
}
public async ValueTask<Result<bool>> SetSwitchOnOff(int num, bool onOff)
{
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else return new(new Exception("Message Bus not work!"));
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, SwitchCtrlAddr.BASE + (UInt32)num, onOff ? 0x1U : 0x0U, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Set Switch {onOff} failed: {ret.Error}");
return new(ret.Error);
}
return ret.Value;
}
}

View File

@@ -0,0 +1,170 @@
using System.Net;
using DotNext;
using Tapper;
namespace Peripherals.WS2812Client;
class WS2812Addr
{
public const UInt32 BASE = 0xB0_00_01_00;
public const int LED_COUNT = 128;
}
/// <summary>
/// RGB颜色结构体包含红、绿、蓝三个颜色分量
/// </summary>
[TranspilationSource]
public class RGBColor
{
public byte Red { get; set; }
public byte Green { get; set; }
public byte Blue { get; set; }
public RGBColor(byte red, byte green, byte blue)
{
Red = red;
Green = green;
Blue = blue;
}
/// <summary>
/// 从32位数据的低24位提取RGB颜色
/// </summary>
/// <param name="data">32位数据</param>
/// <returns>RGB颜色</returns>
public static RGBColor FromUInt32(UInt32 data)
{
return new RGBColor(
(byte)((data >> 16) & 0xFF), // Red
(byte)((data >> 8) & 0xFF), // Green
(byte)(data & 0xFF) // Blue
);
}
/// <summary>
/// 转换为32位数据格式
/// </summary>
/// <returns>32位数据</returns>
public UInt32 ToUInt32()
{
return ((UInt32)Red << 16) | ((UInt32)Green << 8) | (UInt32)Blue;
}
public override string ToString()
{
return $"RGB({Red}, {Green}, {Blue})";
}
}
public class WS2812Client
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
public WS2812Client(string address, int port, int taskID, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.taskID = taskID;
this.timeout = timeout;
}
/// <summary>
/// 获取指定灯珠的RGB颜色
/// </summary>
/// <param name="ledIndex">灯珠索引范围0-127</param>
/// <returns>RGB颜色结果</returns>
public async ValueTask<Result<RGBColor>> GetLedColor(int ledIndex)
{
if (ledIndex < 0 || ledIndex >= WS2812Addr.LED_COUNT)
{
return new(new ArgumentOutOfRangeException(nameof(ledIndex),
$"LED index must be between 0 and {WS2812Addr.LED_COUNT - 1}"));
}
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else
return new(new Exception("Message Bus not work!"));
var addr = WS2812Addr.BASE + (UInt32)(ledIndex * 4); // 每个地址32位步长为4字节
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, addr, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Get LED {ledIndex} color failed: {ret.Error}");
return new(ret.Error);
}
var retData = ret.Value.Options.Data;
if (retData is null)
return new(new Exception($"Device {address} receive none"));
if (retData.Length < 4)
{
var error = new Exception($"Invalid data length: expected 4 bytes, got {retData.Length}");
logger.Error($"Get LED {ledIndex} color failed: {error}");
return new(error);
}
var colorData = Convert.ToUInt32(Common.Number.BytesToUInt64(retData).Value);
var color = RGBColor.FromUInt32(colorData);
return new(color);
}
/// <summary>
/// 获取所有灯珠的RGB颜色
/// </summary>
/// <returns>包含所有灯珠颜色的数组</returns>
public async ValueTask<Result<RGBColor[]>> GetAllLedColors()
{
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else
return new(new Exception("Message Bus not work!"));
try
{
// 一次性读取所有LED数据每个LED占用4字节总共128*4=512字节
var ret = await UDPClientPool.ReadAddr4Bytes(this.ep, this.taskID, WS2812Addr.BASE, WS2812Addr.LED_COUNT, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Get all LED colors failed: {ret.Error}");
return new(ret.Error);
}
var data = ret.Value;
var expectedLength = WS2812Addr.LED_COUNT * 4; // 128 * 4 = 512 bytes
if (data.Length < expectedLength)
{
var error = new Exception($"Invalid data length: expected {expectedLength} bytes, got {data.Length}");
logger.Error(error.Message);
return new(error);
}
var colors = new RGBColor[WS2812Addr.LED_COUNT];
for (int i = 0; i < WS2812Addr.LED_COUNT; i++)
{
var offset = i * 4;
// 将4字节数据转换为UInt32
var colorData = BitConverter.ToUInt32(data, offset);
colors[i] = RGBColor.FromUInt32(colorData);
}
return new(colors);
}
catch (Exception ex)
{
logger.Error($"Get all LED colors failed: {ex}");
return new(ex);
}
}
}

View File

@@ -0,0 +1,496 @@
using System.Net;
using System.Collections.Concurrent;
using Peripherals.HdmiInClient;
using Peripherals.JpegClient;
namespace server.Services;
public class HdmiVideoStreamEndpoint
{
public string BoardId { get; set; } = "";
public string MjpegUrl { get; set; } = "";
public string VideoUrl { get; set; } = "";
public string SnapshotUrl { get; set; } = "";
}
public class HdmiVideoStreamClient
{
public required HdmiIn HdmiInClient { get; set; }
// public required Jpeg JpegClient { get; set; }
public required CancellationTokenSource CTS { get; set; }
public required int Offset { get; set; }
public int Width { get; set; }
public int Height { get; set; }
}
public class HttpHdmiVideoStreamService : BackgroundService
{
private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private HttpListener? _httpListener;
private readonly int _serverPort = 4322;
private readonly ConcurrentDictionary<string, HdmiVideoStreamClient> _clientDict = new();
public override async Task StartAsync(CancellationToken cancellationToken)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://{Global.LocalHost}:{_serverPort}/");
_httpListener.Start();
logger.Info($"HDMI Video Stream Service started on port {_serverPort}");
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_httpListener == null) continue;
try
{
logger.Debug("Waiting for HTTP request...");
var contextTask = _httpListener.GetContextAsync();
var completedTask = await Task.WhenAny(contextTask, Task.Delay(-1, stoppingToken));
if (completedTask == contextTask)
{
var context = contextTask.Result;
logger.Debug($"Received request: {context.Request.Url?.AbsolutePath}");
if (context != null)
_ = HandleRequestAsync(context, stoppingToken);
}
else
{
break;
}
}
catch (Exception ex)
{
logger.Error(ex, "Error in GetContextAsync");
break;
}
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
logger.Info("Stopping HDMI Video Stream Service...");
// 禁用所有活跃的HDMI传输
var disableTasks = new List<Task>();
foreach (var hdmiKey in _clientDict.Keys)
{
disableTasks.Add(DisableHdmiTransmissionAsync(hdmiKey));
}
// 等待所有禁用操作完成
await Task.WhenAll(disableTasks);
// 清空字典
_clientDict.Clear();
await base.StopAsync(cancellationToken);
}
public async Task DisableHdmiTransmissionAsync(string key)
{
try
{
var client = _clientDict[key];
client.CTS.Cancel();
// var disableResult = await client.JpegClient.SetEnable(false);
var disableResult = await client.HdmiInClient.SetTransEnable(false);
if (disableResult)
{
logger.Info("Successfully disabled HDMI transmission");
}
else
{
logger.Error($"Failed to disable HDMI transmission");
}
client.CTS = new CancellationTokenSource();
}
catch (Exception ex)
{
logger.Error(ex, "Exception occurred while disabling HDMI transmission");
}
}
private async Task<HdmiVideoStreamClient?> GetOrCreateClientAsync(string boardId)
{
if (!_clientDict.TryGetValue(boardId, out var client))
{
var userManager = new Database.UserManager();
var boardRet = userManager.GetBoardByID(Guid.Parse(boardId));
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Failed to get board with ID {boardId}");
return null;
}
var board = boardRet.Value.Value;
client = new HdmiVideoStreamClient()
{
HdmiInClient = new HdmiIn(board.IpAddr, board.Port, 9),
// JpegClient = new Jpeg(board.IpAddr, board.Port, 1),
CTS = new CancellationTokenSource(),
Offset = 0
};
}
// 启用HDMI传输
try
{
var hdmiEnableRet = await client.HdmiInClient.Init(true);
if (!hdmiEnableRet.IsSuccessful)
{
logger.Error($"Failed to enable HDMI transmission for board {boardId}: {hdmiEnableRet.Error}");
return null;
}
logger.Info($"Successfully enabled HDMI transmission for board {boardId}");
// var jpegEnableRet = await client.JpegClient.Init(true);
// if (!jpegEnableRet.IsSuccessful)
// {
// logger.Error($"Failed to enable JPEG transmission for board {boardId}: {jpegEnableRet.Error}");
// return null;
// }
// logger.Info($"Successfully enabled JPEG transmission for board {boardId}");
client.Width = client.HdmiInClient.Width;
client.Height = client.HdmiInClient.Height;
// client.Width = client.JpegClient.Width;
// client.Height = client.JpegClient.Height;
}
catch (Exception ex)
{
logger.Error(ex, $"Exception occurred while enabling HDMI transmission for board {boardId}");
return null;
}
_clientDict[boardId] = client;
return client;
}
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var path = context.Request.Url?.AbsolutePath ?? "/";
var boardId = context.Request.QueryString["boardId"];
if (string.IsNullOrEmpty(boardId))
{
await SendErrorAsync(context.Response, "Missing boardId");
return;
}
var client = await GetOrCreateClientAsync(boardId);
if (client == null)
{
await SendErrorAsync(context.Response, "Invalid boardId or board not available");
return;
}
var token = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, client.CTS.Token).Token;
if (path == "/snapshot")
{
await HandleSnapshotRequestAsync(context.Response, client, token);
}
else if (path == "/mjpeg")
{
await HandleMjpegStreamAsync(context.Response, client, token);
}
else if (path == "/video")
{
await SendVideoHtmlPageAsync(context.Response, boardId);
}
else
{
await SendIndexHtmlPageAsync(context.Response, boardId);
}
}
private async Task HandleSnapshotRequestAsync(
HttpListenerResponse response, HdmiVideoStreamClient client, CancellationToken cancellationToken)
{
try
{
logger.Debug("处理HDMI快照请求");
// 从HDMI读取RGB565数据
// var frameResult = await client.JpegClient.GetMultiFrames((uint)client.Offset);
// if (!frameResult.IsSuccessful || frameResult.Value == null || frameResult.Value.Count == 0)
// {
// logger.Error("HDMI快照获取失败");
// response.StatusCode = 500;
// var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to get HDMI snapshot");
// await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
// response.Close();
// return;
// }
// var jpegData = frameResult.Value[0];
// var quantTableResult = await client.JpegClient.GetQuantizationTable();
// if (!quantTableResult.IsSuccessful || quantTableResult.Value == null)
// {
// logger.Error("获取JPEG量化表失败: {Error}", quantTableResult.Error);
// response.StatusCode = 500;
// var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to get quantization table");
// await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
// response.Close();
// return;
// }
// var jpegImage = Common.Image.CompleteJpegData(jpegData, client.Width, client.Height, quantTableResult.Value);
// if (!jpegImage.IsSuccessful)
// {
// logger.Error("JPEG数据补全失败");
// response.StatusCode = 500;
// var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to complete JPEG data");
// await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
// response.Close();
// return;
// }
var jpegImage = await client.HdmiInClient.GetMJpegFrame();
if (!jpegImage.HasValue)
{
logger.Error("获取HDMI MJPEG帧失败");
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to get HDMI MJPEG frame");
await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
response.Close();
return;
}
// 设置响应头参考Camera版本
response.ContentType = "image/jpeg";
response.ContentLength64 = jpegImage.Value.data.Length;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
await response.OutputStream.WriteAsync(jpegImage.Value.data, 0, jpegImage.Value.data.Length, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
logger.Debug("已发送HDMI快照图像大小{Size} 字节", jpegImage.Value.data.Length);
}
catch (Exception ex)
{
logger.Error(ex, "处理HDMI快照请求时出错");
response.StatusCode = 500;
}
finally
{
response.StatusCode = 200;
response.Close();
}
}
private async Task HandleMjpegStreamAsync(
HttpListenerResponse response, HdmiVideoStreamClient client, CancellationToken cancellationToken)
{
try
{
// 设置MJPEG流的响应头参考Camera版本
response.ContentType = "multipart/x-mixed-replace; boundary=--boundary";
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
logger.Debug("开始HDMI MJPEG流传输");
// var quantTableResult = await client.JpegClient.GetQuantizationTable();
// if (!quantTableResult.IsSuccessful || quantTableResult.Value == null)
// {
// logger.Error("获取JPEG量化表失败: {Error}", quantTableResult.Error);
// response.StatusCode = 500;
// await response.OutputStream.WriteAsync(
// System.Text.Encoding.UTF8.GetBytes("Failed to get quantization table"), 0, 0, cancellationToken);
// response.Close();
// return;
// }
// var quantTable = quantTableResult.Value;
int frameCounter = 0;
while (!cancellationToken.IsCancellationRequested)
{
var frameStartTime = DateTime.UtcNow;
var frameRet = await client.HdmiInClient.GetMJpegFrame();
if (!frameRet.HasValue)
{
logger.Error("获取HDMI帧失败");
continue;
}
var frame = frameRet.Value;
await response.OutputStream.WriteAsync(frame.header, 0, frame.header.Length, cancellationToken);
await response.OutputStream.WriteAsync(frame.data, 0, frame.data.Length, cancellationToken);
await response.OutputStream.WriteAsync(frame.footer, 0, frame.footer.Length, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
frameCounter++;
var totalTime = (DateTime.UtcNow - frameStartTime).TotalMilliseconds;
// 性能统计日志每30帧记录一次
if (frameCounter % 30 == 0)
{
logger.Debug("HDMI帧 {FrameNumber} 性能统计 - 总计: {TotalTime:F1}ms, JPEG大小: {JpegSize} 字节",
frameCounter, totalTime, frame.data.Length);
}
// var frameResult =
// await client.JpegClient.GetMultiFrames((uint)client.Offset);
// if (!frameResult.IsSuccessful || frameResult.Value == null || frameResult.Value.Count == 0)
// {
// logger.Error("获取HDMI帧失败");
// await Task.Delay(100, cancellationToken);
// continue;
// }
// foreach (var framebytes in frameResult.Value)
// {
// var jpegImage = Common.Image.CompleteJpegData(framebytes, client.Width, client.Height, quantTable);
// if (!jpegImage.IsSuccessful)
// {
// logger.Error("JPEG数据不完整");
// await Task.Delay(100, cancellationToken);
// continue;
// }
// var frameRet = Common.Image.CreateMjpegFrameFromJpeg(jpegImage.Value);
// if (!frameRet.IsSuccessful)
// {
// logger.Error("创建MJPEG帧失败");
// await Task.Delay(100, cancellationToken);
// continue;
// }
// var frame = frameRet.Value;
// await response.OutputStream.WriteAsync(frame.header, 0, frame.header.Length, cancellationToken); // await response.OutputStream.WriteAsync(frame.data, 0, frame.data.Length, cancellationToken);
// await response.OutputStream.WriteAsync(frame.footer, 0, frame.footer.Length, cancellationToken);
// await response.OutputStream.FlushAsync(cancellationToken);
// frameCounter++;
// var totalTime = (DateTime.UtcNow - frameStartTime).TotalMilliseconds;
// // 性能统计日志每30帧记录一次
// if (frameCounter % 30 == 0)
// {
// logger.Debug("HDMI帧 {FrameNumber} 性能统计 - 总计: {TotalTime:F1}ms, JPEG大小: {JpegSize} 字节",
// frameCounter, totalTime, frame.data.Length);
// }
// }
}
}
catch (Exception ex)
{
logger.Error(ex, "HDMI MJPEG流处理异常");
}
finally
{
try
{
// 停止传输时禁用HDMI传输
await client.HdmiInClient.SetTransEnable(false);
logger.Info("已禁用HDMI传输");
}
catch (Exception ex)
{
logger.Error(ex, "禁用HDMI传输时出错");
}
try
{
response.Close();
}
catch
{
// 忽略关闭时的错误
}
logger.Debug("HDMI MJPEG流连接已关闭");
}
}
private async Task SendVideoHtmlPageAsync(HttpListenerResponse response, string boardId)
{
string html = $@"<html><body>
<h1>HDMI Video Stream for Board {boardId}</h1>
<img src='/mjpeg?boardId={boardId}' />
</body></html>";
response.ContentType = "text/html";
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(html));
response.Close();
}
private async Task SendIndexHtmlPageAsync(HttpListenerResponse response, string boardId)
{
string html = $@"<html><body>
<h1>Welcome to HDMI Video Stream Service</h1>
<a href='/video?boardId={boardId}'>View Video Stream for Board {boardId}</a>
</body></html>";
response.ContentType = "text/html";
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(html));
response.Close();
}
private async Task SendErrorAsync(HttpListenerResponse response, string message)
{
response.StatusCode = 400;
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(message));
response.Close();
}
/// <summary>
/// 获取所有可用的HDMI视频流终端点
/// </summary>
/// <returns>返回所有可用的HDMI视频流终端点列表</returns>
public List<HdmiVideoStreamEndpoint>? GetAllVideoEndpoints()
{
var userManager = new Database.UserManager();
var boards = userManager.GetAllBoard();
if (boards == null)
return null;
var endpoints = new List<HdmiVideoStreamEndpoint>();
foreach (var board in boards)
{
endpoints.Add(new HdmiVideoStreamEndpoint
{
BoardId = board.ID.ToString(),
MjpegUrl = $"http://{Global.LocalHost}:{_serverPort}/mjpeg?boardId={board.ID}",
VideoUrl = $"http://{Global.LocalHost}:{_serverPort}/video?boardId={board.ID}",
SnapshotUrl = $"http://{Global.LocalHost}:{_serverPort}/snapshot?boardId={board.ID}"
});
}
return endpoints;
}
/// <summary>
/// 获取指定板卡ID的HDMI视频流终端点
/// </summary>
/// <param name="boardId">板卡ID</param>
/// <returns>返回指定板卡的HDMI视频流终端点</returns>
public HdmiVideoStreamEndpoint GetVideoEndpoint(string boardId)
{
return new HdmiVideoStreamEndpoint
{
BoardId = boardId,
MjpegUrl = $"http://{Global.LocalHost}:{_serverPort}/mjpeg?boardId={boardId}",
VideoUrl = $"http://{Global.LocalHost}:{_serverPort}/video?boardId={boardId}",
SnapshotUrl = $"http://{Global.LocalHost}:{_serverPort}/snapshot?boardId={boardId}"
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using server.Hubs;
namespace server.Services;
public enum TaskState { Running, Completed, Failed, Cancelled }
public readonly struct TaskProgress
{
public string Id { get; }
public int Current { get; }
public int Total { get; }
public TaskState State { get; }
public long Timestamp { get; }
public string? Error { get; }
public TaskProgress(string id, int current, int total, TaskState state, long timestamp, string? error = null)
{
Id = id;
Current = current;
Total = total;
State = state;
Timestamp = timestamp;
Error = error;
}
public TaskProgress WithUpdate(int? current = null, TaskState? state = null, string? error = null)
{
return new TaskProgress(
Id,
current ?? Current,
Total,
state ?? State,
DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
error ?? Error
);
}
public ProgressInfo ToProgressInfo()
{
return new ProgressInfo
{
TaskId = Id,
Status = State switch
{
TaskState.Running => ProgressStatus.Running,
TaskState.Completed => ProgressStatus.Completed,
TaskState.Failed => ProgressStatus.Failed,
TaskState.Cancelled => ProgressStatus.Canceled,
_ => ProgressStatus.Failed
},
ProgressPercent = Total > 0 ? ((double)Current * 100) / (double)Total : 0,
ErrorMessage = Error ?? string.Empty
};
}
}
public sealed class ProgressTracker
{
private readonly ConcurrentDictionary<string, TaskProgress> _tasks = new();
private readonly Timer _cleaner;
private readonly IHubContext<ProgressHub, IProgressReceiver> _hubContext;
// 构造器支持可选的Hub注入
public ProgressTracker(IHubContext<ProgressHub, IProgressReceiver> hubContext)
{
_hubContext = hubContext;
_cleaner = new Timer(CleanExpiredTasks, null,
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public void CleanExpiredTasks(object? obj)
{
var cutoff = DateTimeOffset.Now.AddMinutes(-3).ToUnixTimeSeconds();
var expired = _tasks.Where(kvp => kvp.Value.Timestamp < cutoff).Select(kvp => kvp.Key).ToList();
foreach (var id in expired)
{
_tasks.TryRemove(id, out _);
}
}
public string CreateTask(int total)
{
var id = Guid.NewGuid().ToString();
var task = new TaskProgress(id, 0, total, TaskState.Running, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
_tasks[id] = task;
NotifyIfNeeded(task);
return id;
}
// 核心更新方法,现在包含自动通知
public bool UpdateTask(string id, Func<TaskProgress, TaskProgress> updater)
{
if (!_tasks.TryGetValue(id, out var current))
return false;
var updated = updater(current);
if (_tasks.TryUpdate(id, updated, current))
{
NotifyIfNeeded(updated);
return true;
}
return false;
}
// 自动通知逻辑 - 简单直接
private void NotifyIfNeeded(TaskProgress task)
{
_hubContext.Clients.Group(task.Id).OnReceiveProgress(task.ToProgressInfo());
}
public bool UpdateProgress(string id, int current)
{
return UpdateTask(id, p => p.WithUpdate(
current: Math.Min(current, p.Total)));
}
public bool AdvanceProgress(string id, int steps)
{
return UpdateTask(id, p => p.WithUpdate(
current: Math.Min(p.Current + steps, p.Total)));
}
public bool CancelProgress(string id)
{
return UpdateTask(id, p => p.WithUpdate(state: TaskState.Cancelled));
}
public bool CompleteProgress(string id)
{
return UpdateTask(id, p => p.WithUpdate(
current: p.Total, state: TaskState.Completed));
}
public bool FailProgress(string id, string? error)
{
return UpdateTask(id, p => p.WithUpdate(
state: TaskState.Failed, error: error));
}
public TaskProgress? GetTask(string id)
{
_tasks.TryGetValue(id, out var task);
return task.Id == null ? null : task;
}
}

View File

@@ -0,0 +1,576 @@
using System.Net;
using System.Net.Sockets;
using System.Collections.Concurrent;
using System.Text;
using Rtsp;
using Rtsp.Messages;
using Rtsp.Sdp;
using server.Services;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace server.Services;
/// <summary>
/// RTSP streaming service that integrates with UsbCameraCapture
/// Uses simplified RTSP server architecture with RTSPDispatcher
/// Provides Motion JPEG stream over RTP/RTSP
/// Compatible with Windows and Linux
/// </summary>
public class RtspStreamService : IDisposable
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly UsbCameraCapture _cameraCapture;
private readonly ConcurrentDictionary<string, RtspListener> _activeListeners = new();
// RTSP configuration
private readonly int _rtspPort;
private readonly string _streamPath;
private TcpListener? _rtspServerListener;
private ManualResetEvent? _stopping;
private Thread? _listenThread;
// Video encoding parameters
private int _videoWidth = 640;
private int _videoHeight = 480;
private int _frameRate = 30;
private int _jpegQuality = 75;
private bool _isStreaming;
private bool _disposed;
// Frame timing and RTP sequencing
private DateTime _lastFrameTime = DateTime.UtcNow;
private readonly TimeSpan _frameInterval;
private uint _rtpTimestamp = 0;
private ushort _sequenceNumber = 0;
private readonly uint _ssrc = (uint)Random.Shared.Next();
// Current frame data for broadcasting
private byte[]? _currentFrame;
private readonly object _frameLock = new object();
public event Action<Exception>? Error;
public event Action<string>? StatusChanged;
public bool IsStreaming => _isStreaming;
public int Port => _rtspPort;
public string StreamUrl => $"rtsp://localhost:{_rtspPort}/{_streamPath}";
public int ActiveSessions => _activeListeners.Count;
public RtspStreamService(UsbCameraCapture cameraCapture, int port = 8554, string streamPath = "camera")
{
_cameraCapture = cameraCapture ?? throw new ArgumentNullException(nameof(cameraCapture));
_rtspPort = port;
_streamPath = streamPath;
_frameInterval = TimeSpan.FromSeconds(1.0 / _frameRate);
// Register RTSP URI scheme
RtspUtils.RegisterUri();
// Subscribe to camera events
_cameraCapture.FrameReady += OnFrameReady;
_cameraCapture.Error += OnCameraError;
}
/// <summary>
/// Configure video encoding parameters
/// </summary>
public void ConfigureVideo(int width, int height, int frameRate, int jpegQuality = 75)
{
if (_isStreaming)
throw new InvalidOperationException("Cannot configure video while streaming");
_videoWidth = width;
_videoHeight = height;
_frameRate = frameRate;
_jpegQuality = jpegQuality;
logger.Info($"Video configured: {width}x{height} @ {frameRate}fps, JPEG quality {jpegQuality}%");
}
/// <summary>
/// Start RTSP server and begin streaming
/// </summary>
public async Task StartAsync()
{
if (_isStreaming)
return;
try
{
// Validate port range
if (_rtspPort < IPEndPoint.MinPort || _rtspPort > IPEndPoint.MaxPort)
throw new ArgumentOutOfRangeException(nameof(_rtspPort), _rtspPort, "Port number must be between System.Net.IPEndPoint.MinPort and System.Net.IPEndPoint.MaxPort");
// Initialize RTSP server
_rtspServerListener = new TcpListener(IPAddress.Any, _rtspPort);
_rtspServerListener.Start();
// Start listening for connections
_stopping = new ManualResetEvent(false);
_listenThread = new Thread(AcceptConnections)
{
Name = "RTSP-Listener",
IsBackground = true
};
_listenThread.Start();
// Start camera capture if not already running
if (!_cameraCapture.IsCapturing)
{
await _cameraCapture.StartAsync(1, _videoWidth, _videoHeight, _frameRate);
}
_isStreaming = true;
StatusChanged?.Invoke("Streaming started");
logger.Info($"RTSP stream started on {StreamUrl}");
}
catch (Exception ex)
{
await StopAsync();
Error?.Invoke(ex);
throw;
}
}
/// <summary>
/// Stop RTSP server and streaming
/// </summary>
public async Task StopAsync()
{
if (!_isStreaming)
return;
_isStreaming = false;
try
{
// Signal stop and wait for listen thread
_stopping?.Set();
if (_listenThread != null && _listenThread.IsAlive)
{
_listenThread.Join(TimeSpan.FromSeconds(5));
}
// Stop RTSP server
_rtspServerListener?.Stop();
// Clean up active listeners
foreach (var listener in _activeListeners.Values.ToArray())
{
try
{
listener.Stop();
}
catch (Exception ex)
{
logger.Warn(ex, "Error stopping RTSP listener");
}
}
_activeListeners.Clear();
StatusChanged?.Invoke("Streaming stopped");
logger.Info("RTSP stream stopped");
}
catch (Exception ex)
{
Error?.Invoke(ex);
}
await Task.CompletedTask;
}
/// <summary>
/// Get current stream statistics
/// </summary>
public StreamStats GetStats()
{
return new StreamStats
{
IsStreaming = _isStreaming,
ActiveSessions = _activeListeners.Count,
VideoWidth = _videoWidth,
VideoHeight = _videoHeight,
FrameRate = _frameRate,
StreamUrl = StreamUrl
};
}
/// <summary>
/// Accept incoming RTSP connections
/// </summary>
private void AcceptConnections()
{
try
{
while (!(_stopping?.WaitOne(0) ?? true))
{
TcpClient client = _rtspServerListener!.AcceptTcpClient();
var transport = new RtspTcpTransport(client);
var listener = new RtspListener(transport);
var listenerId = Guid.NewGuid().ToString();
_activeListeners[listenerId] = listener;
// Handle listener events
listener.MessageReceived += (sender, args) => HandleRtspMessage(listenerId, args);
// Store listener for later cleanup
// We'll rely on exception handling to detect disconnections
// Start the listener
listener.Start();
logger.Info($"New RTSP client connected: {listenerId} from {client.Client.RemoteEndPoint}");
}
}
catch (SocketException ex)
{
if (_isStreaming) // Only log if we're still supposed to be running
{
logger.Warn(ex, "Socket error while accepting connections (may be normal during shutdown)");
}
}
catch (Exception ex)
{
if (_isStreaming)
{
logger.Error(ex, "Error accepting RTSP connections");
Error?.Invoke(ex);
}
}
}
/// <summary>
/// Handle RTSP messages from clients
/// </summary>
private void HandleRtspMessage(string listenerId, RtspChunkEventArgs args)
{
try
{
if (args.Message is RtspRequest request)
{
HandleRtspRequest(listenerId, request);
}
}
catch (Exception ex)
{
logger.Error(ex, $"Error handling RTSP message for listener {listenerId}");
}
}
/// <summary>
/// Handle RTSP requests
/// </summary>
private void HandleRtspRequest(string listenerId, RtspRequest request)
{
if (!_activeListeners.TryGetValue(listenerId, out var listener))
return;
var response = new RtspResponse();
response.OriginalRequest = request;
// 1. 返回 CSeq 字段
if (request.Headers.TryGetValue("CSeq", out var cseq))
{
response.Headers["CSeq"] = cseq;
}
switch (request.RequestTyped)
{
case RtspRequest.RequestType.OPTIONS:
response.Headers["Public"] = "DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE";
response.ReturnCode = 200;
break;
case RtspRequest.RequestType.DESCRIBE:
if (request.RtspUri?.AbsolutePath.TrimStart('/') == _streamPath)
{
var sdp = CreateSdp();
response.Headers["Content-Type"] = "application/sdp";
response.Data = Encoding.UTF8.GetBytes(sdp);
response.ReturnCode = 200;
}
else
{
response.ReturnCode = 404;
}
break;
case RtspRequest.RequestType.SETUP:
// 2. 解析客户端 Transport 字段
string clientTransport = request.Headers.TryGetValue("Transport", out var transport) ? transport : "";
string serverTransport;
if (clientTransport.Contains("TCP", StringComparison.OrdinalIgnoreCase) || clientTransport.Contains("interleaved"))
{
// 客户端要求TCP
serverTransport = "RTP/AVP/TCP;unicast;interleaved=0-1";
}
else if (clientTransport.Contains("UDP", StringComparison.OrdinalIgnoreCase) || clientTransport.Contains("client_port"))
{
// 客户端要求UDP
// 这里假设端口号格式为 client_port=xxxx-xxxx
var match = System.Text.RegularExpressions.Regex.Match(clientTransport, @"client_port=(\d+)-(\d+)");
if (match.Success)
{
var clientPort1 = match.Groups[1].Value;
var clientPort2 = match.Groups[2].Value;
// 你可以自定义 server_port
serverTransport = $"RTP/AVP;unicast;client_port={clientPort1}-{clientPort2};server_port=9000-9001";
}
else
{
// 默认UDP
serverTransport = "RTP/AVP;unicast;client_port=8000-8001;server_port=9000-9001";
}
}
else
{
// 默认TCP
serverTransport = "RTP/AVP/TCP;unicast;interleaved=0-1";
}
response.Headers["Transport"] = serverTransport;
response.Headers["Session"] = listenerId;
response.ReturnCode = 200;
break;
case RtspRequest.RequestType.PLAY:
response.Headers["Session"] = listenerId;
response.ReturnCode = 200;
// Start sending frames to this client
StartFrameBroadcastForListener(listenerId);
break;
case RtspRequest.RequestType.TEARDOWN:
response.ReturnCode = 200;
// Stop and remove the listener
Task.Run(() =>
{
listener.Stop();
_activeListeners.TryRemove(listenerId, out _);
});
break;
default:
response.ReturnCode = 501; // Not implemented
break;
}
// Send response
try
{
listener.SendMessage(response);
}
catch (Exception ex)
{
logger.Error(ex, $"Error sending RTSP response to listener {listenerId}");
}
}
/// <summary>
/// Create SDP description for the stream
/// </summary>
private string CreateSdp()
{
var sessionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return $@"v=0
o=- {sessionId} {sessionId} IN IP4 127.0.0.1
s=FPGA WebLab Camera Stream
c=IN IP4 0.0.0.0
t=0 0
m=video 0 RTP/AVP 26
a=rtpmap:26 JPEG/90000
a=control:track1
a=framerate:{_frameRate}";
}
/// <summary>
/// Start broadcasting frames to a specific listener
/// </summary>
private void StartFrameBroadcastForListener(string listenerId)
{
// For now, we'll use a simple approach where we send the current frame
// In a full implementation, you'd want to manage RTP streaming per client
lock (_frameLock)
{
if (_currentFrame != null && _activeListeners.TryGetValue(listenerId, out var listener))
{
try
{
// Send current frame (simplified - in real implementation you'd send RTP packets)
// This is a placeholder for actual RTP packet creation and sending
logger.Debug($"Started frame broadcast for listener {listenerId}");
}
catch (Exception ex)
{
logger.Error(ex, $"Error starting frame broadcast for listener {listenerId}");
}
}
}
}
/// <summary>
/// Handle new frame from camera
/// </summary>
private void OnFrameReady(byte[] frameData)
{
if (!_isStreaming || frameData == null || _activeListeners.IsEmpty)
return;
try
{
// Throttle frame rate
var now = DateTime.UtcNow;
if (now - _lastFrameTime < _frameInterval)
return;
_lastFrameTime = now;
// Process and encode frame
var processedFrame = ProcessFrame(frameData);
if (processedFrame != null)
{
lock (_frameLock)
{
_currentFrame = processedFrame;
}
BroadcastFrame(processedFrame);
}
}
catch (Exception ex)
{
logger.Error(ex, "Error processing camera frame");
Error?.Invoke(ex);
}
}
/// <summary>
/// Process raw frame data
/// </summary>
private byte[]? ProcessFrame(byte[] frameData)
{
try
{
// Convert frame to JPEG for Motion JPEG streaming
using var image = Image.Load<Rgb24>(frameData);
// Resize if necessary
if (image.Width != _videoWidth || image.Height != _videoHeight)
{
image.Mutate(x => x.Resize(_videoWidth, _videoHeight));
}
// Encode as JPEG with specified quality
using var stream = new MemoryStream();
image.SaveAsJpeg(stream, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
{
Quality = _jpegQuality
});
return stream.ToArray();
}
catch (Exception ex)
{
logger.Error(ex, "Error processing frame");
return null;
}
}
/// <summary>
/// Broadcast frame to all active listeners
/// </summary>
private void BroadcastFrame(byte[] frameData)
{
if (_activeListeners.IsEmpty)
return;
var timestamp = _rtpTimestamp;
_rtpTimestamp += (uint)(90000 / _frameRate); // 90kHz clock
var sequenceNumber = ++_sequenceNumber;
var listenersToRemove = new List<string>();
foreach (var kvp in _activeListeners)
{
try
{
var listener = kvp.Value;
// Try to send data to test if listener is still active
// In a full implementation, you would create and send RTP packets here
// For now, this is a placeholder that just checks if we can access the listener
try
{
var _ = listener.RemoteEndPoint; // Test if listener is still valid
// SendRtpFrame(listener, frameData, timestamp, sequenceNumber, _ssrc);
}
catch
{
listenersToRemove.Add(kvp.Key);
}
}
catch (Exception ex)
{
logger.Warn(ex, $"Error sending frame to listener {kvp.Key}");
listenersToRemove.Add(kvp.Key);
}
}
// Remove failed listeners
foreach (var listenerId in listenersToRemove)
{
if (_activeListeners.TryRemove(listenerId, out var listener))
{
try
{
listener.Stop();
}
catch (Exception ex)
{
logger.Warn(ex, $"Error stopping failed listener {listenerId}");
}
}
}
}
/// <summary>
/// Handle camera capture errors
/// </summary>
private void OnCameraError(Exception error)
{
logger.Error(error, "Camera capture error");
Error?.Invoke(error);
}
public void Dispose()
{
if (_disposed) return;
StopAsync().Wait();
_cameraCapture.FrameReady -= OnFrameReady;
_cameraCapture.Error -= OnCameraError;
_rtspServerListener?.Stop();
_stopping?.Dispose();
_disposed = true;
}
}
/// <summary>
/// Stream statistics data structure
/// </summary>
public class StreamStats
{
public bool IsStreaming { get; set; }
public int ActiveSessions { get; set; }
public int VideoWidth { get; set; }
public int VideoHeight { get; set; }
public int FrameRate { get; set; }
public string StreamUrl { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,202 @@
using FlashCap;
namespace server.Services;
/// <summary>
/// Simple USB camera capture service following Linus principles:
/// - Single responsibility: just capture frames
/// - No special cases: uniform error handling
/// - Good taste: clean data structures
/// </summary>
public class UsbCameraCapture : IDisposable
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly CaptureDevices _captureDevices;
private CaptureDevice? _device;
private CaptureDeviceDescriptor? _descriptor;
private VideoCharacteristics? _characteristics;
// Single source of truth for latest frame - no redundant buffering
private volatile byte[]? _latestFrame;
private volatile bool _isCapturing;
private bool _disposed;
public event Action<byte[]>? FrameReady;
public event Action<Exception>? Error;
public bool IsCapturing => _isCapturing;
public VideoCharacteristics? CurrentCharacteristics => _characteristics;
public CaptureDeviceDescriptor? CurrentDevice => _descriptor;
public UsbCameraCapture()
{
_captureDevices = new CaptureDevices();
}
/// <summary>
/// Get all available camera devices
/// </summary>
public IReadOnlyList<CaptureDeviceDescriptor> GetDevices()
{
return _captureDevices.EnumerateDescriptors().ToArray();
}
/// <summary>
/// Start capturing from specified device with best matching characteristics
/// </summary>
public async Task StartAsync(int deviceIndex, int width = 640, int height = 480, int frameRate = 30)
{
var devices = GetDevices();
if (deviceIndex >= devices.Count)
throw new ArgumentOutOfRangeException(nameof(deviceIndex));
var descriptor = devices[deviceIndex];
var characteristics = FindBestMatch(descriptor, width, height, frameRate);
await StartAsync(descriptor, characteristics);
}
/// <summary>
/// Start capturing with exact device and characteristics
/// </summary>
public async Task StartAsync(CaptureDeviceDescriptor descriptor, VideoCharacteristics characteristics)
{
if (_isCapturing)
await StopAsync();
try
{
_descriptor = descriptor;
_characteristics = characteristics;
_device = await descriptor.OpenAsync(
characteristics, TranscodeFormats.DoNotTranscode, true, 10, OnFrameCaptured);
await _device.StartAsync();
_isCapturing = true;
logger.Debug("Started capturing");
}
catch (Exception ex)
{
await CleanupAsync();
Error?.Invoke(ex);
throw;
}
}
/// <summary>
/// Stop capturing and cleanup
/// </summary>
public async Task StopAsync()
{
if (!_isCapturing)
return;
_isCapturing = false;
await CleanupAsync();
}
/// <summary>
/// Get the latest captured frame (returns copy for thread safety)
/// </summary>
public byte[]? GetLatestFrame()
{
return _latestFrame;
}
/// <summary>
/// Get supported video characteristics for current device
/// </summary>
public IReadOnlyList<VideoCharacteristics> GetSupportedCharacteristics()
{
return _descriptor?.Characteristics.ToArray() ?? Array.Empty<VideoCharacteristics>();
}
private VideoCharacteristics FindBestMatch(CaptureDeviceDescriptor descriptor, int width, int height, int frameRate)
{
var characteristics = descriptor.Characteristics;
// Exact match first
var exact = characteristics.FirstOrDefault(c =>
c.Width == width && c.Height == height && Math.Abs(c.FramesPerSecond - frameRate) < 1);
if (exact != null)
return exact;
// Resolution match with best framerate
var resolution = characteristics
.Where(c => c.Width == width && c.Height == height)
.OrderByDescending(c => c.FramesPerSecond)
.FirstOrDefault();
if (resolution != null)
return resolution;
// Closest resolution
try
{
var closest = characteristics
.OrderBy(c => Math.Abs(c.Width - width) + Math.Abs(c.Height - height))
.ThenByDescending(c => c.FramesPerSecond)
.First();
return closest;
}
catch
{
for (int i = 0; i < characteristics.Length; i++)
logger.Error($"Characteristics[{i}]: {characteristics[i].Width}x{characteristics[i].Height} @ {characteristics[i].FramesPerSecond}fps");
throw;
}
}
private void OnFrameCaptured(PixelBufferScope bufferScope)
{
if (!_isCapturing)
return;
try
{
// Simple: extract and store. No queues, no locks, no complexity.
var imageData = bufferScope.Buffer.CopyImage();
_latestFrame = imageData;
FrameReady?.Invoke(imageData);
// logger.Info("USB Camera frame captured");
}
catch (Exception ex)
{
Error?.Invoke(ex);
}
}
private async Task CleanupAsync()
{
try
{
if (_device != null)
{
await _device.StopAsync();
_device.Dispose();
_device = null;
}
}
catch (Exception ex)
{
Error?.Invoke(ex);
}
finally
{
_latestFrame = null;
_descriptor = null;
_characteristics = null;
}
}
public void Dispose()
{
if (_disposed) return;
if (_isCapturing) StopAsync().Wait();
_device?.Dispose();
_disposed = true;
}
}

View File

@@ -3,15 +3,16 @@ using System.Net.Sockets;
using System.Text;
using DotNext;
using WebProtocol;
using server.Services;
/// <summary>
/// UDP客户端发送池
/// </summary>
public class UDPClientPool
public sealed class UDPClientPool
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private static IPAddress localhost = IPAddress.Parse("127.0.0.1");
private static ProgressTracker _progressTracker = MsgBus.ProgressTracker;
/// <summary>
/// 发送字符串
@@ -183,37 +184,67 @@ public class UDPClientPool
}
/// <summary>
/// 发送字符串到本地
/// 发送重置信号
/// </summary>
/// <param name="port">端口</param>
/// <param name="stringArray">字符串数组</param>
/// <param name="endPoint">IP端点IP地址与端口</param>
/// <returns>是否成功</returns>
public static bool SendStringLocalHost(int port, string[] stringArray)
public async static ValueTask<bool> SendResetSignal(IPEndPoint endPoint)
{
return SendString(new IPEndPoint(localhost, port), stringArray);
return await Task.Run(async () =>
{
var ret = SendAddrPack(endPoint,
new WebProtocol.SendAddrPackage(BurstType.FixedBurst, 0, true, 0, 0xF0F0F0F0));
await Task.Delay(100);
return ret;
});
}
/// <summary>
/// 循环发送字符串到本地
/// 读取设备地址数据
/// </summary>
/// <param name="times">发送总次数</param>
/// <param name="sleepMilliSeconds">间隔时间</param>
/// <param name="port">端口</param>
/// <param name="stringArray">字符串数组</param>
/// <returns>是否成功</returns>
public static bool CycleSendStringLocalHost(int times, int sleepMilliSeconds, int port, string[] stringArray)
/// <param name="endPoint">IP端点IP地址与端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="devAddr">设备地址</param>
/// <param name="dataLength">数据长度(0~255)</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>读取结果,包含接收到的数据包</returns>
public static async ValueTask<Result<RecvDataPackage>> ReadAddr(
IPEndPoint endPoint, int taskID, uint devAddr, int dataLength, int timeout = 1000)
{
var isSuccessful = true;
if (dataLength <= 0) return new(new ArgumentException(
$"Data length must be greater than 0, instead of {dataLength}"));
while (times-- >= 0)
if (dataLength > 255) return new(new ArgumentException(
$"Data length must be less than or equal to 255, instead of {dataLength}"));
var ret = false;
var opts = new SendAddrPackOptions()
{
isSuccessful = SendStringLocalHost(port, stringArray);
if (!isSuccessful) break;
BurstType = BurstType.FixedBurst,
BurstLength = ((byte)(dataLength - 1)),
CommandID = Convert.ToByte(taskID),
Address = devAddr,
IsWrite = false,
};
Thread.Sleep(sleepMilliSeconds);
}
// Read Register
ret = await UDPClientPool.SendAddrPackAsync(endPoint, new SendAddrPackage(opts));
if (!ret) return new(new Exception("Send Address Package Failed!"));
return isSuccessful;
// Wait for Read Ack
if (!MsgBus.IsRunning)
return new(new Exception("Message Bus not Working!"));
var retPack = await MsgBus.UDPServer.WaitForDataAsync(endPoint, taskID, timeout);
if (!retPack.IsSuccessful) return new(retPack.Error);
else if (!retPack.Value.IsSuccessful)
return new(new Exception("Send address package failed"));
var retPackOpts = retPack.Value.Options;
if (retPackOpts.Data is null)
return new(new Exception($"Data is Null, package: {retPackOpts.ToString()}"));
return retPack;
}
/// <summary>
@@ -224,39 +255,10 @@ public class UDPClientPool
/// <param name="devAddr">设备地址</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>读取结果,包含接收到的数据包</returns>
public static async ValueTask<Result<RecvDataPackage>> ReadAddr(
public static async ValueTask<Result<RecvDataPackage>> ReadAddrByte(
IPEndPoint endPoint, int taskID, uint devAddr, int timeout = 1000)
{
var ret = false;
var opts = new SendAddrPackOptions()
{
BurstType = BurstType.FixedBurst,
BurstLength = 0,
CommandID = Convert.ToByte(taskID),
Address = devAddr,
IsWrite = false,
};
// Read Register
ret = await UDPClientPool.SendAddrPackAsync(endPoint, new SendAddrPackage(opts));
if (!ret) return new(new Exception("Send Address Package Failed!"));
// Wait for Read Ack
if (!MsgBus.IsRunning)
return new(new Exception("Message Bus not Working!"));
var retPack = await MsgBus.UDPServer.WaitForDataAsync(
endPoint.Address.ToString(), taskID, endPoint.Port, timeout);
if (!retPack.IsSuccessful) return new(retPack.Error);
else if (!retPack.Value.IsSuccessful)
return new(new Exception("Send address package failed"));
var retPackOpts = retPack.Value.Options;
if (retPackOpts.Data is null)
return new(new Exception($"Data is Null, package: {retPackOpts.ToString()}"));
return retPack;
return await ReadAddr(endPoint, taskID, devAddr, 1, timeout);
}
/// <summary>
@@ -270,11 +272,11 @@ public class UDPClientPool
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>校验结果true表示数据匹配期望值</returns>
public static async ValueTask<Result<bool>> ReadAddr(
IPEndPoint endPoint, int taskID, uint devAddr, UInt32 result, UInt32 resultMask, int timeout = 1000)
IPEndPoint endPoint, int taskID, uint devAddr, UInt32 result, UInt32 resultMask, int timeout = 1000)
{
var address = endPoint.Address.ToString();
var ret = await ReadAddr(endPoint, taskID, devAddr, timeout);
var ret = await ReadAddrByte(endPoint, taskID, devAddr, timeout);
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value.IsSuccessful)
return new(new Exception($"Read device {address} address {devAddr} failed"));
@@ -310,7 +312,9 @@ public class UDPClientPool
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>校验结果true表示在超时前数据匹配期望值</returns>
public static async ValueTask<Result<bool>> ReadAddrWithWait(
IPEndPoint endPoint, int taskID, uint devAddr, UInt32 result, UInt32 resultMask, int waittime = 100, int timeout = 1000)
IPEndPoint endPoint, int taskID, uint devAddr,
UInt32 result, UInt32 resultMask,
int waittime = 100, int timeout = 1000)
{
var address = endPoint.Address.ToString();
@@ -323,7 +327,7 @@ public class UDPClientPool
await Task.Delay(waittime);
try
{
var ret = await ReadAddr(endPoint, taskID, devAddr, Convert.ToInt32(timeleft.TotalMilliseconds));
var ret = await ReadAddrByte(endPoint, taskID, devAddr, Convert.ToInt32(timeleft.TotalMilliseconds));
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value.IsSuccessful)
return new(new Exception($"Read device {address} address {devAddr} failed"));
@@ -336,7 +340,7 @@ public class UDPClientPool
$"Device {address} receive data is {retData.Length} bytes instead of 4 bytes"));
// Check result
var retCode = Convert.ToUInt32(Common.Number.BytesToUInt64(retData).Value);
var retCode = Convert.ToUInt32(Common.Number.BytesToUInt32(retData).Value);
if (Common.Number.BitsCheck(retCode, result, resultMask)) return true;
}
catch (Exception error)
@@ -400,8 +404,7 @@ public class UDPClientPool
if (!ret) return new(new Exception($"Send address package failed at segment {i}!"));
// Wait for data response
var retPack = await MsgBus.UDPServer.WaitForDataAsync(
endPoint.Address.ToString(), taskID, endPoint.Port, timeout);
var retPack = await MsgBus.UDPServer.WaitForDataAsync(endPoint, taskID, timeout);
if (!retPack.IsSuccessful) return new(retPack.Error);
if (!retPack.Value.IsSuccessful)
@@ -433,11 +436,12 @@ public class UDPClientPool
/// <param name="endPoint">IP端点IP地址与端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="devAddr">设备地址</param>
/// <param name="burstType">突发类型</param>
/// <param name="dataLength">要读取的数据长度4字节</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>读取结果,包含接收到的字节数组</returns>
public static async ValueTask<Result<byte[]>> ReadAddr4BytesAsync(
IPEndPoint endPoint, int taskID, UInt32 devAddr, int dataLength, int timeout = 1000)
IPEndPoint endPoint, int taskID, UInt32 devAddr, int dataLength, BurstType burstType, int timeout = 1000)
{
var pkgList = new List<SendAddrPackage>();
var resultData = new List<byte>();
@@ -460,11 +464,12 @@ public class UDPClientPool
var opts = new SendAddrPackOptions
{
BurstType = BurstType.FixedBurst,
BurstType = burstType,
CommandID = Convert.ToByte(taskID),
IsWrite = false,
BurstLength = (byte)(currentSegmentSize - 1),
Address = devAddr + (uint)(i * max4BytesPerRead)
Address = (burstType == BurstType.ExtendBurst) ? (devAddr + (uint)(i * max4BytesPerRead)) : (devAddr),
// Address = devAddr + (uint)(i * max4BytesPerRead),
};
pkgList.Add(new SendAddrPackage(opts));
}
@@ -552,7 +557,7 @@ public class UDPClientPool
var resultData = new List<byte>();
for (int i = 0; i < length; i++)
{
var ret = await ReadAddr(endPoint, taskID, addr[i], timeout);
var ret = await ReadAddrByte(endPoint, taskID, addr[i], timeout);
if (!ret.IsSuccessful)
{
logger.Error($"ReadAddrSeq failed at index {i}: {ret.Error}");
@@ -582,9 +587,11 @@ public class UDPClientPool
/// <param name="devAddr">设备地址</param>
/// <param name="data">要写入的32位数据</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <param name="progressId">进度报告器</param>
/// <returns>写入结果true表示写入成功</returns>
public static async ValueTask<Result<bool>> WriteAddr(
IPEndPoint endPoint, int taskID, UInt32 devAddr, UInt32 data, int timeout = 1000)
IPEndPoint endPoint, int taskID, UInt32 devAddr,
UInt32 data, int timeout = 1000, string progressId = "")
{
var ret = false;
var opts = new SendAddrPackOptions()
@@ -595,23 +602,27 @@ public class UDPClientPool
Address = devAddr,
IsWrite = true,
};
_progressTracker.AdvanceProgress(progressId, 10);
// Write Register
ret = await UDPClientPool.SendAddrPackAsync(endPoint, new SendAddrPackage(opts));
if (!ret) return new(new Exception("Send 1st address package failed!"));
_progressTracker.AdvanceProgress(progressId, 10);
// Send Data Package
ret = await UDPClientPool.SendDataPackAsync(endPoint,
new SendDataPackage(Common.Number.NumberToBytes(data, 4).Value));
if (!ret) return new(new Exception("Send data package failed!"));
_progressTracker.AdvanceProgress(progressId, 10);
// Check Msg Bus
if (!MsgBus.IsRunning)
return new(new Exception("Message bus not working!"));
// Wait for Write Ack
var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync(
endPoint.Address.ToString(), taskID, endPoint.Port, timeout);
var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync(endPoint, taskID, timeout);
if (!udpWriteAck.IsSuccessful) return new(udpWriteAck.Error);
_progressTracker.AdvanceProgress(progressId, 10);
return udpWriteAck.Value.IsSuccessful;
}
@@ -624,9 +635,11 @@ public class UDPClientPool
/// <param name="devAddr">设备地址</param>
/// <param name="dataArray">要写入的字节数组</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <param name="progressId">进度报告器</param>
/// <returns>写入结果true表示写入成功</returns>
public static async ValueTask<Result<bool>> WriteAddr(
IPEndPoint endPoint, int taskID, UInt32 devAddr, byte[] dataArray, int timeout = 1000)
IPEndPoint endPoint, int taskID, UInt32 devAddr,
byte[] dataArray, int timeout = 1000, string progressId = "")
{
var ret = false;
var opts = new SendAddrPackOptions()
@@ -671,11 +684,13 @@ public class UDPClientPool
if (!ret) return new(new Exception("Send data package failed!"));
// Wait for Write Ack
var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync(endPoint.Address.ToString(), taskID, endPoint.Port, timeout);
var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync(endPoint, taskID, timeout);
if (!udpWriteAck.IsSuccessful) return new(udpWriteAck.Error);
if (!udpWriteAck.Value.IsSuccessful)
return false;
_progressTracker.AdvanceProgress(progressId, 1);
}
return true;

View File

@@ -194,14 +194,15 @@ public class UDPServer
var startTime = DateTime.Now;
var isTimeout = false;
while (!isTimeout)
{
var elapsed = DateTime.Now - startTime;
isTimeout = elapsed >= TimeSpan.FromMilliseconds(timeout);
if (isTimeout) break;
try
try
{
while (!isTimeout)
{
var elapsed = DateTime.Now - startTime;
isTimeout = elapsed >= TimeSpan.FromMilliseconds(timeout);
if (isTimeout) break;
using (await udpDataLock.AcquireWriteLockAsync(TimeSpan.FromMilliseconds(timeout)))
{
if (udpData.TryGetValue(key, out var sortedList) && sortedList.Count > 0)
@@ -214,23 +215,16 @@ public class UDPServer
}
}
}
catch
{
logger.Trace("Get nothing even after time out");
return new(null);
}
if (data is null)
throw new TimeoutException("Get nothing even after time out");
else return new(data.DeepClone());
}
if (data is null)
catch
{
logger.Trace("Get nothing even after time out");
return new(null);
}
else
{
return new(data.DeepClone());
}
}
/// <summary>
@@ -367,17 +361,22 @@ public class UDPServer
/// <summary>
/// 异步等待写响应
/// </summary>
/// <param name="address">IP地址</param>
/// <param name="endPoint">IP地址及端口</param>
/// <param name="taskID">[TODO:parameter]</param>
/// <param name="port">UDP 端口</param>
/// <param name="timeout">超时时间范围</param>
/// <returns>接收响应包</returns>
public async ValueTask<Result<WebProtocol.RecvRespPackage>> WaitForAckAsync
(string address, int taskID, int port = -1, int timeout = 1000)
(IPEndPoint endPoint, int taskID, int timeout = 1000)
{
var address = endPoint.Address.ToString();
var port = endPoint.Port;
var data = await FindDataAsync(address, taskID, timeout);
if (!data.HasValue)
{
await UDPClientPool.SendResetSignal(endPoint);
return new(new Exception("Get None even after time out!"));
}
var recvData = data.Value;
if (recvData.Address != address || (port > 0 && recvData.Port != port))
@@ -393,17 +392,22 @@ public class UDPServer
/// <summary>
/// 异步等待数据
/// </summary>
/// <param name="address">IP地址</param>
/// <param name="taskID">[TODO:parameter]</param>
/// <param name="port">UDP 端口</param>
/// <param name="endPoint">IP地址</param>
/// <param name="taskID">任务ID</param>
/// <param name="timeout">超时时间范围</param>
/// <returns>接收数据包</returns>
public async ValueTask<Result<RecvDataPackage>> WaitForDataAsync
(string address, int taskID, int port = -1, int timeout = 1000)
(IPEndPoint endPoint, int taskID, int timeout = 1000)
{
var address = endPoint.Address.ToString();
var port = endPoint.Port;
var data = await FindDataAsync(address, taskID, timeout);
if (!data.HasValue)
{
await UDPClientPool.SendResetSignal(endPoint);
return new(new Exception("Get None even after time out!"));
}
var recvData = data.Value;
if (recvData.Address != address || (port >= 0 && recvData.Port != port))
@@ -523,7 +527,7 @@ public class UDPServer
return $@"
Receive Data from {data.Address}:{data.Port} at {data.DateTime.ToString()}:
Original Data : {BitConverter.ToString(bytes).Replace("-", " ")}
Decoded Data : {recvData}
Decoded Data : {recvData}
";
}

View File

@@ -131,7 +131,7 @@ namespace WebProtocol
readonly byte sign = (byte)PackSign.SendAddr;
readonly byte commandType;
readonly byte burstLength;
readonly byte _reserved = 0;
readonly byte commandID;
readonly UInt32 address;
/// <summary>
@@ -140,10 +140,10 @@ namespace WebProtocol
/// <param name="opts"> 地址包选项 </param>
public SendAddrPackage(SendAddrPackOptions opts)
{
byte byteBurstType = Convert.ToByte((byte)opts.BurstType << 6);
byte byteCommandID = Convert.ToByte((opts.CommandID & 0x03) << 4);
byte byteBurstType = Convert.ToByte((byte)opts.BurstType << 4);
byte byteIsWrite = (opts.IsWrite ? (byte)0x01 : (byte)0x00);
this.commandType = Convert.ToByte(byteBurstType | byteCommandID | byteIsWrite);
this.commandType = Convert.ToByte(byteBurstType | byteIsWrite);
this.commandID = opts.CommandID;
this.burstLength = opts.BurstLength;
this.address = opts.Address;
}
@@ -158,10 +158,10 @@ namespace WebProtocol
/// <param name="address"> 设备地址 </param>
public SendAddrPackage(BurstType burstType, byte commandID, bool isWrite, byte burstLength, UInt32 address)
{
byte byteBurstType = Convert.ToByte((byte)burstType << 6);
byte byteCommandID = Convert.ToByte((commandID & 0x03) << 4);
byte byteBurstType = Convert.ToByte((byte)burstType << 4);
byte byteIsWrite = (isWrite ? (byte)0x01 : (byte)0x00);
this.commandType = Convert.ToByte(byteBurstType | byteCommandID | byteIsWrite);
this.commandType = Convert.ToByte(byteBurstType | byteIsWrite);
this.commandID = commandID;
this.burstLength = burstLength;
this.address = address;
}
@@ -172,9 +172,10 @@ namespace WebProtocol
/// <param name="commandType">二进制命令类型</param>
/// <param name="burstLength">突发长度</param>
/// <param name="address">写入或读取的地址</param>
public SendAddrPackage(byte commandType, byte burstLength, UInt32 address)
public SendAddrPackage(byte commandType, byte burstLength, byte commandID, UInt32 address)
{
this.commandType = commandType;
this.commandID = commandID;
this.burstLength = burstLength;
this.address = address;
}
@@ -190,8 +191,8 @@ namespace WebProtocol
{
Address = this.address,
BurstLength = this.burstLength,
BurstType = (BurstType)(this.commandType >> 6),
CommandID = Convert.ToByte((this.commandType >> 4) & 0b11),
BurstType = (BurstType)(this.commandType >> 4),
CommandID = this.commandID,
IsWrite = Convert.ToBoolean(this.commandType & 1)
};
}
@@ -207,7 +208,7 @@ namespace WebProtocol
arr[0] = sign;
arr[1] = commandType;
arr[2] = burstLength;
arr[3] = _reserved;
arr[3] = commandID;
var bytesAddr = Common.Number.NumberToBytes(address, 4).Value;
Array.Copy(bytesAddr, 0, arr, 4, bytesAddr.Length);
@@ -223,8 +224,8 @@ namespace WebProtocol
{
var opts = new SendAddrPackOptions()
{
BurstType = (BurstType)(commandType >> 6),
CommandID = Convert.ToByte((commandType >> 4) & 0b0011),
BurstType = (BurstType)(commandType >> 4),
CommandID = this.commandID,
IsWrite = Convert.ToBoolean(commandType & 0x01),
BurstLength = burstLength,
Address = address,
@@ -258,7 +259,7 @@ namespace WebProtocol
}
var address = Common.Number.BytesToUInt64(bytes[4..]).Value;
return new SendAddrPackage(bytes[1], bytes[2], Convert.ToUInt32(address));
return new SendAddrPackage(bytes[1], bytes[2], bytes[3], Convert.ToUInt32(address));
}
}
@@ -316,7 +317,7 @@ namespace WebProtocol
readonly byte[] bodyData;
/// <summary>
/// FPGA->Server 读响应包
/// FPGA->Server 读响应包
/// 构造函数
/// </summary>
/// <param name="timestamp"> 时间戳 </param>

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,21 @@ import Dialog from "./components/Dialog.vue";
import { Alert, useAlertProvider } from "./components/Alert";
import { ref, provide, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useThemeStore } from "./stores/theme";
const router = useRouter();
const theme = useThemeStore();
// 主题切换状态管理
const isDarkMode = ref(
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
const isDarkMode = ref(theme.isDarkTheme());
// Navbar显示状态管理
const showNavbar = ref(true);
// 切换Navbar显示状态
const toggleNavbar = () => {
showNavbar.value = !showNavbar.value;
};
// 初始化主题设置
onMounted(() => {
@@ -38,6 +46,7 @@ const applyTheme = () => {
// 切换主题
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value;
theme.toggleTheme();
applyTheme();
};
@@ -47,6 +56,12 @@ provide("theme", {
toggleTheme,
});
// 提供Navbar控制给子组件
provide("navbar", {
showNavbar,
toggleNavbar,
});
const currentRoutePath = computed(() => {
return router.currentRoute.value.path;
});
@@ -56,8 +71,8 @@ useAlertProvider();
<template>
<div>
<header class="relative">
<Navbar />
<header class="relative" :class="{ 'navbar-hidden': !showNavbar }">
<Navbar v-show="showNavbar" />
<Dialog />
<Alert />
</header>
@@ -71,7 +86,7 @@ useAlertProvider();
class="footer footer-center p-4 bg-base-300 text-base-content"
>
<div>
<p>Copyright © 2023 - All right reserved by OurEDA</p>
<p>Copyright © 2025 - All right reserved by OurEDA</p>
</div>
</footer>
</div>
@@ -79,4 +94,25 @@ useAlertProvider();
<style scoped>
/* 特定于App.vue的样式 */
header {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: top;
}
.navbar-hidden {
transform: scaleY(0);
height: 0;
overflow: hidden;
}
/* Navbar显示/隐藏动画 */
header .navbar {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: top;
}
/* 当header被隐藏时确保navbar也相应变化 */
.navbar-hidden .navbar {
opacity: 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="fixed left-1/2 top-30 z-999 -translate-x-1/2">
<div class="fixed left-1/2 top-30 z-[9999] -translate-x-1/2">
<transition
name="alert"
enter-active-class="alert-enter-active"

View File

@@ -119,6 +119,7 @@
componentManager.prepareComponentProps(
component.attrs || {},
component.id,
props.examId,
)
"
@update:bindKey="
@@ -175,9 +176,7 @@ import {
ref,
reactive,
onMounted,
onUnmounted,
computed,
watch,
provide,
} from "vue";
import { useEventListener } from "@vueuse/core";
@@ -188,7 +187,6 @@ import { useAlertStore } from "@/components/Alert";
// 导入 diagram 管理器
import {
loadDiagramData,
saveDiagramData,
updatePartPosition,
updatePartAttribute,
parseConnectionPin,
@@ -217,6 +215,7 @@ const emit = defineEmits(["toggle-doc-panel", "open-components"]);
// 定义组件接受的属性
const props = defineProps<{
showDocPanel?: boolean; // 添加属性接收文档面板的显示状态
examId?: string; // 新增examId属性
}>();
// 获取componentManager实例
@@ -606,14 +605,13 @@ function onComponentDrag(e: MouseEvent) {
// 停止拖拽组件
function stopComponentDrag() {
// 如果有组件被拖拽,保存当前状态
// 如果有组件被拖拽,仅清除拖拽状态(不保存)
if (draggingComponentId.value) {
draggingComponentId.value = null;
}
isComponentDragEventActive.value = false;
saveDiagramData(diagramData.value);
// 移除自动保存功能 - 不再自动保存到localStorage
}
// 更新组件属性
@@ -977,7 +975,8 @@ function exportDiagram() {
onMounted(async () => {
// 加载图表数据
try {
diagramData.value = await loadDiagramData();
// 传入examId参数让diagramManager处理动态加载
diagramData.value = await loadDiagramData(props.examId);
// 预加载所有组件模块
const componentTypes = new Set<string>();

View File

@@ -1,7 +1,6 @@
import { ref, shallowRef, computed, reactive } from "vue";
import { createInjectionState } from "@vueuse/core";
import {
saveDiagramData,
type DiagramData,
type DiagramPart,
} from "./diagramManager";
@@ -302,7 +301,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
// 使用 updateDiagramDataDirectly 避免触发加载状态
canvasInstance.updateDiagramDataDirectly(currentData);
saveDiagramData(currentData);
// 移除自动保存功能
console.log("组件添加完成:", newComponent);
@@ -431,7 +430,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
"=== 更新图表数据完成,新组件数量:",
currentData.parts.length,
);
saveDiagramData(currentData);
// 移除自动保存功能
return { success: true, message: `已添加 ${templateData.name} 模板` };
} else {
@@ -504,7 +503,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
canvasInstance.updateDiagramDataDirectly(currentData);
saveDiagramData(currentData);
// 移除自动保存功能
}
/**
@@ -763,11 +762,15 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
function prepareComponentProps(
attrs: Record<string, any>,
componentId?: string,
examId?: string,
): Record<string, any> {
const result: Record<string, any> = { ...attrs };
if (componentId) {
result.componentId = componentId;
}
if (examId) {
result.examId = examId;
}
return result;
}

View File

@@ -1,3 +1,6 @@
import { ResourceClient, ResourcePurpose } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
// 定义 diagram.json 的类型结构
export interface DiagramData {
version: number;
@@ -27,37 +30,42 @@ export interface DiagramPart {
export type ConnectionArray = [string, string, number, string[]];
// 解析连接字符串为组件ID和引脚ID
export function parseConnectionPin(connectionPin: string): { componentId: string; pinId: string } {
const [componentId, pinId] = connectionPin.split(':');
export function parseConnectionPin(connectionPin: string): {
componentId: string;
pinId: string;
} {
const [componentId, pinId] = connectionPin.split(":");
return { componentId, pinId };
}
// 将连接数组转换为适用于渲染的格式
export function connectionArrayToWireItem(
connection: ConnectionArray,
index: number,
startPos = { x: 0, y: 0 },
endPos = { x: 0, y: 0 }
connection: ConnectionArray,
index: number,
startPos = { x: 0, y: 0 },
endPos = { x: 0, y: 0 },
): WireItem {
const [startPinStr, endPinStr, width, path] = connection;
const { componentId: startComponentId, pinId: startPinId } = parseConnectionPin(startPinStr);
const { componentId: endComponentId, pinId: endPinId } = parseConnectionPin(endPinStr);
const { componentId: startComponentId, pinId: startPinId } =
parseConnectionPin(startPinStr);
const { componentId: endComponentId, pinId: endPinId } =
parseConnectionPin(endPinStr);
return {
id: `wire-${index}`,
startX: startPos.x,
startY: startPos.y,
endX: endPos.x,
endX: endPos.x,
endY: endPos.y,
startComponentId,
startPinId,
endComponentId,
endPinId,
strokeWidth: width,
color: '#4a5568', // 默认颜色
routingMode: 'path',
color: "#4a5568", // 默认颜色
routingMode: "path",
pathCommands: path,
showLabel: false
showLabel: false,
};
}
@@ -74,30 +82,76 @@ export interface WireItem {
endPinId?: string;
strokeWidth: number;
color: string;
routingMode: 'orthogonal' | 'path';
routingMode: "orthogonal" | "path";
constraint?: string;
pathCommands?: string[];
showLabel: boolean;
}
// 从本地存储加载图表数据
export async function loadDiagramData(): Promise<DiagramData> {
// 从本地存储或动态API加载图表数据
export async function loadDiagramData(examId?: string): Promise<DiagramData> {
try {
// 先尝试从本地存储加载
const savedData = localStorage.getItem('diagramData');
if (savedData) {
return JSON.parse(savedData);
// 如果提供了examId优先从API加载实验的diagram
if (examId) {
try {
const resourceClient = AuthManager.createClient(ResourceClient);
// 获取diagram类型的资源列表
const resources = await resourceClient.getResourceList(
examId,
"canvas",
ResourcePurpose.Template,
);
if (resources && resources.length > 0) {
// 获取第一个diagram资源
const diagramResource = resources[0];
// 使用动态API获取资源文件内容
const response = await resourceClient.getResourceById(
diagramResource.id,
);
if (response && response.data) {
const text = await response.data.text();
const data = JSON.parse(text);
// 验证数据格式
const validation = validateDiagramData(data);
if (validation.isValid) {
console.log("成功从API加载实验diagram:", examId);
return data;
} else {
console.warn("API返回的diagram数据格式无效:", validation.errors);
}
}
} else {
console.log("未找到实验diagram资源使用默认加载方式");
}
} catch (error) {
console.warn("从API加载实验diagram失败使用默认加载方式:", error);
}
}
// 如果本地存储没有,从文件加载
const response = await fetch('/src/components/diagram.json');
// 如果没有examId或API加载失败尝试从静态文件加载不再使用本地存储
// 从静态文件加载(作为备选方案)
const response = await fetch("/src/components/diagram.json");
if (!response.ok) {
throw new Error(`Failed to load diagram.json: ${response.statusText}`);
}
const data = await response.json();
return data;
// 验证静态文件数据
const validation = validateDiagramData(data);
if (validation.isValid) {
return data;
} else {
console.warn("静态diagram文件数据格式无效:", validation.errors);
throw new Error("所有diagram数据源都无效");
}
} catch (error) {
console.error('Error loading diagram data:', error);
console.error("Error loading diagram data:", error);
// 返回空的默认数据结构
return createEmptyDiagram();
}
@@ -107,36 +161,31 @@ export async function loadDiagramData(): Promise<DiagramData> {
export function createEmptyDiagram(): DiagramData {
return {
version: 1,
author: 'user',
editor: 'user',
author: "user",
editor: "user",
parts: [],
connections: []
connections: [],
};
}
// 保存图表数据本地存储
// 保存图表数据(已禁用本地存储
export function saveDiagramData(data: DiagramData): void {
try {
localStorage.setItem('diagramData', JSON.stringify(data));
} catch (error) {
console.error('Error saving diagram data:', error);
}
// 本地存储功能已禁用 - 不再保存到localStorage
console.debug("saveDiagramData called but localStorage saving is disabled");
}
// 更新组件位置
export function updatePartPosition(
data: DiagramData,
partId: string,
x: number,
y: number
data: DiagramData,
partId: string,
x: number,
y: number,
): DiagramData {
return {
...data,
parts: data.parts.map(part =>
part.id === partId
? { ...part, x, y }
: part
)
parts: data.parts.map((part) =>
part.id === partId ? { ...part, x, y } : part,
),
};
}
@@ -145,21 +194,21 @@ export function updatePartAttribute(
data: DiagramData,
partId: string,
attrName: string,
value: any
value: any,
): DiagramData {
return {
...data,
parts: data.parts.map(part =>
part.id === partId
? {
...part,
attrs: {
...part.attrs,
[attrName]: value
}
}
: part
)
parts: data.parts.map((part) =>
part.id === partId
? {
...part,
attrs: {
...part.attrs,
[attrName]: value,
},
}
: part,
),
};
}
@@ -171,72 +220,79 @@ export function addConnection(
endComponentId: string,
endPinId: string,
width: number = 2,
path: string[] = []
path: string[] = [],
): DiagramData {
const newConnection: ConnectionArray = [
`${startComponentId}:${startPinId}`,
`${endComponentId}:${endPinId}`,
width,
path
path,
];
return {
...data,
connections: [...data.connections, newConnection]
connections: [...data.connections, newConnection],
};
}
// 删除连接
export function deleteConnection(
data: DiagramData,
connectionIndex: number
connectionIndex: number,
): DiagramData {
return {
...data,
connections: data.connections.filter((_, index) => index !== connectionIndex)
connections: data.connections.filter(
(_, index) => index !== connectionIndex,
),
};
}
// 查找与组件关联的所有连接
export function findConnectionsByPart(
data: DiagramData,
partId: string
partId: string,
): { connection: ConnectionArray; index: number }[] {
return data.connections
.map((connection, index) => ({ connection, index }))
.filter(({ connection }) => {
const [startPin, endPin] = connection;
const startCompId = startPin.split(':')[0];
const endCompId = endPin.split(':')[0];
const startCompId = startPin.split(":")[0];
const endCompId = endPin.split(":")[0];
return startCompId === partId || endCompId === partId;
});
}
// 添加验证diagram.json文件的函数
export function validateDiagramData(data: any): { isValid: boolean; errors: string[] } {
export function validateDiagramData(data: any): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
// 检查版本号
if (!data.version) {
errors.push('缺少version字段');
errors.push("缺少version字段");
}
// 检查parts数组
if (!Array.isArray(data.parts)) {
errors.push('parts字段不是数组');
errors.push("parts字段不是数组");
} else {
// 验证parts中的每个对象
data.parts.forEach((part: any, index: number) => {
if (!part.id) errors.push(`parts[${index}]缺少id`);
if (!part.type) errors.push(`parts[${index}]缺少type`);
if (typeof part.x !== 'number') errors.push(`parts[${index}]缺少有效的x坐标`);
if (typeof part.y !== 'number') errors.push(`parts[${index}]缺少有效的y坐标`);
if (typeof part.x !== "number")
errors.push(`parts[${index}]缺少有效的x坐标`);
if (typeof part.y !== "number")
errors.push(`parts[${index}]缺少有效的y坐标`);
});
}
// 检查connections数组
if (!Array.isArray(data.connections)) {
errors.push('connections字段不是数组');
errors.push("connections字段不是数组");
} else {
// 验证connections中的每个数组
data.connections.forEach((conn: any, index: number) => {
@@ -244,25 +300,25 @@ export function validateDiagramData(data: any): { isValid: boolean; errors: stri
errors.push(`connections[${index}]不是有效的连接数组`);
return;
}
const [startPin, endPin, width] = conn;
if (typeof startPin !== 'string' || !startPin.includes(':')) {
if (typeof startPin !== "string" || !startPin.includes(":")) {
errors.push(`connections[${index}]的起始针脚格式无效`);
}
if (typeof endPin !== 'string' || !endPin.includes(':')) {
if (typeof endPin !== "string" || !endPin.includes(":")) {
errors.push(`connections[${index}]的结束针脚格式无效`);
}
if (typeof width !== 'number') {
if (typeof width !== "number") {
errors.push(`connections[${index}]的宽度不是有效的数字`);
}
});
}
return {
isValid: errors.length === 0,
errors
errors,
};
}

View File

@@ -29,9 +29,10 @@ export interface TemplateConfig {
export const previewSizes: Record<string, number> = {
MechanicalButton: 0.4,
Switch: 0.35,
EC11RotaryEncoder: 0.4,
Pin: 0.8,
SMT_LED: 0.7,
SevenSegmentDisplay: 0.4,
SevenSegmentDisplayUltimate: 0.4,
HDMI: 0.5,
DDR: 0.5,
ETH: 0.5,
@@ -48,9 +49,10 @@ export const previewSizes: Record<string, number> = {
export const availableComponents: ComponentConfig[] = [
{ type: "MechanicalButton", name: "机械按钮" },
{ type: "Switch", name: "开关" },
{ type: "EC11RotaryEncoder", name: "EC11旋转编码器" },
{ type: "Pin", name: "引脚" },
{ type: "SMT_LED", name: "贴片LED" },
{ type: "SevenSegmentDisplay", name: "数码管" },
{ type: "SevenSegmentDisplayUltimate", name: "数码管" },
{ type: "HDMI", name: "HDMI接口" },
{ type: "DDR", name: "DDR内存" },
{ type: "ETH", name: "以太网接口" },

View File

@@ -10,6 +10,7 @@ import {
SignalTriggerConfig,
SignalValue,
AnalyzerChannelDiv,
AnalyzerClockDiv,
} from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert";
@@ -76,28 +77,55 @@ const channelDivOptions = [
{ value: 32, label: "32通道", description: "启用32个通道 (CH0-CH31)" },
];
// 捕获深度选项
const captureLengthOptions = [
{ value: 256, label: "256" },
{ value: 512, label: "512" },
{ value: 1024, label: "1K" },
{ value: 2048, label: "2K" },
{ value: 4096, label: "4K" },
{ value: 8192, label: "8K" },
{ value: 16384, label: "16K" },
{ value: 32768, label: "32K" },
const ClockDivOptions = [
{
value: AnalyzerClockDiv.DIV1,
label: "120MHz",
description: "采样频率120MHz",
},
{
value: AnalyzerClockDiv.DIV2,
label: "60MHz",
description: "采样频率60MHz",
},
{
value: AnalyzerClockDiv.DIV4,
label: "30MHz",
description: "采样频率30MHz",
},
{
value: AnalyzerClockDiv.DIV8,
label: "15MHz",
description: "采样频率15MHz",
},
{
value: AnalyzerClockDiv.DIV16,
label: "7.5MHz",
description: "采样频率7.5MHz",
},
{
value: AnalyzerClockDiv.DIV32,
label: "3.75MHz",
description: "采样频率3.75MHz",
},
{
value: AnalyzerClockDiv.DIV64,
label: "1.875MHz",
description: "采样频率1.875MHz",
},
{
value: AnalyzerClockDiv.DIV128,
label: "937.5KHz",
description: "采样频率937.5KHz",
},
];
// 捕获深度选项
const preCaptureLengthOptions = [
{ value: 0, label: "0" },
{ value: 16, label: "16" },
{ value: 32, label: "32" },
{ value: 64, label: "64" },
{ value: 128, label: "128" },
{ value: 256, label: "256" },
{ value: 512, label: "512" },
];
// 捕获深度限制常量
const CAPTURE_LENGTH_MIN = 1024; // 最小捕获深度 1024
const CAPTURE_LENGTH_MAX = 0x10000000 - 0x01000000; // 最大捕获深度
// 预捕获深度限制常量
const PRE_CAPTURE_LENGTH_MIN = 2; // 最小预捕获深度 2
// 默认颜色数组
const defaultColors = [
@@ -111,9 +139,8 @@ const defaultColors = [
"#8C33FF",
];
// 添加逻辑分析仪频率常量
const LOGIC_ANALYZER_FREQUENCY = 125_000_000; // 125MHz
const SAMPLE_PERIOD_NS = 1_000_000_000 / LOGIC_ANALYZER_FREQUENCY; // 采样周期,单位:纳秒
// 添加逻辑分析仪基础频率常量
const BASE_LOGIC_ANALYZER_FREQUENCY = 120_000_000; // 120MHz基础频率
const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
() => {
@@ -126,8 +153,9 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 触发设置相关状态
const currentGlobalMode = ref<GlobalCaptureMode>(GlobalCaptureMode.AND);
const currentChannelDiv = ref<number>(8); // 默认启用8个通道
const captureLength = ref<number>(1024); // 捕获深度,默认1024
const preCaptureLength = ref<number>(0); // 预捕获深度默认0
const captureLength = ref<number>(CAPTURE_LENGTH_MIN); // 捕获深度,默认为最小值
const preCaptureLength = ref<number>(PRE_CAPTURE_LENGTH_MIN); // 预捕获深度默认0
const currentclockDiv = ref<AnalyzerClockDiv>(AnalyzerClockDiv.DIV1); // 默认时钟分频为1
const isApplying = ref(false);
const isCapturing = ref(false); // 添加捕获状态标识
@@ -168,28 +196,121 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
channels.filter((channel) => channel.enabled),
);
// 计算属性:根据当前时钟分频获取实际采样频率
const currentSampleFrequency = computed(() => {
const divValue = Math.pow(2, currentclockDiv.value);
return BASE_LOGIC_ANALYZER_FREQUENCY / divValue;
});
// 计算属性:获取当前采样周期(纳秒)
const currentSamplePeriodNs = computed(() => {
return 1_000_000_000 / currentSampleFrequency.value;
});
// 转换通道数字到枚举值
const getChannelDivEnum = (channelCount: number): AnalyzerChannelDiv => {
switch (channelCount) {
case 1: return AnalyzerChannelDiv.ONE;
case 2: return AnalyzerChannelDiv.TWO;
case 4: return AnalyzerChannelDiv.FOUR;
case 8: return AnalyzerChannelDiv.EIGHT;
case 16: return AnalyzerChannelDiv.XVI;
case 32: return AnalyzerChannelDiv.XXXII;
default: return AnalyzerChannelDiv.EIGHT;
case 1:
return AnalyzerChannelDiv.ONE;
case 2:
return AnalyzerChannelDiv.TWO;
case 4:
return AnalyzerChannelDiv.FOUR;
case 8:
return AnalyzerChannelDiv.EIGHT;
case 16:
return AnalyzerChannelDiv.XVI;
case 32:
return AnalyzerChannelDiv.XXXII;
default:
return AnalyzerChannelDiv.EIGHT;
}
};
// 验证捕获深度
const validateCaptureLength = (
value: number,
): { valid: boolean; message?: string } => {
if (!Number.isInteger(value)) {
return { valid: false, message: "捕获深度必须是整数" };
}
if (value < CAPTURE_LENGTH_MIN) {
return {
valid: false,
message: `捕获深度不能小于 ${CAPTURE_LENGTH_MIN}`,
};
}
if (value > CAPTURE_LENGTH_MAX) {
return {
valid: false,
message: `捕获深度不能大于 ${CAPTURE_LENGTH_MAX.toLocaleString()}`,
};
}
return { valid: true };
};
// 验证预捕获深度
const validatePreCaptureLength = (
value: number,
currentCaptureLength: number,
): { valid: boolean; message?: string } => {
if (!Number.isInteger(value)) {
return { valid: false, message: "预捕获深度必须是整数" };
}
if (value < PRE_CAPTURE_LENGTH_MIN) {
return {
valid: false,
message: `预捕获深度不能小于 ${PRE_CAPTURE_LENGTH_MIN}`,
};
}
if (value >= currentCaptureLength) {
return {
valid: false,
message: `预捕获深度不能大于等于捕获深度 (${currentCaptureLength})`,
};
}
return { valid: true };
};
// 设置捕获深度
const setCaptureLength = (value: number) => {
const validation = validateCaptureLength(value);
if (!validation.valid) {
alert?.error(validation.message!, 3000);
return false;
}
// 检查预捕获深度是否仍然有效
if (preCaptureLength.value >= value) {
preCaptureLength.value = Math.max(0, value - 1);
alert?.warn(`预捕获深度已自动调整为 ${preCaptureLength.value}`, 3000);
}
captureLength.value = value;
return true;
};
// 设置预捕获深度
const setPreCaptureLength = (value: number) => {
const validation = validatePreCaptureLength(value, captureLength.value);
if (!validation.valid) {
alert?.error(validation.message!, 3000);
return false;
}
preCaptureLength.value = value;
return true;
};
// 设置通道组
const setChannelDiv = (channelCount: number) => {
// 验证通道数量是否有效
if (!channelDivOptions.find(option => option.value === channelCount)) {
if (!channelDivOptions.find((option) => option.value === channelCount)) {
console.error(`无效的通道组设置: ${channelCount}`);
return;
}
currentChannelDiv.value = channelCount;
// 禁用所有通道
channels.forEach((channel) => {
channel.enabled = false;
@@ -200,7 +321,9 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
channels[i].enabled = true;
}
const option = channelDivOptions.find(opt => opt.value === channelCount);
const option = channelDivOptions.find(
(opt) => opt.value === channelCount,
);
alert?.success(`已设置为${option?.label}`, 2000);
};
@@ -210,9 +333,16 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
alert?.info(`全局触发模式已设置为 ${modeOption?.label}`, 2000);
};
const setClockDiv = (mode: AnalyzerClockDiv) => {
currentclockDiv.value = mode;
const modeOption = ClockDivOptions.find((m) => m.value === mode);
alert?.info(`时钟分频已设置为 ${modeOption?.label}`, 2000);
};
const resetConfiguration = () => {
currentGlobalMode.value = GlobalCaptureMode.AND;
currentChannelDiv.value = 8; // 重置为默认的8通道
currentclockDiv.value = AnalyzerClockDiv.DIV1; // 重置为默认采样频率
setChannelDiv(8); // 重置为默认的8通道
signalConfigs.forEach((signal) => {
@@ -230,7 +360,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
const getCaptureData = async () => {
try {
const client = AuthManager.createAuthenticatedLogicAnalyzerClient();
const client = AuthManager.createClient(LogicAnalyzerClient);
// 获取捕获数据,使用当前设置的捕获长度
const base64Data = await client.getCaptureData(captureLength.value);
@@ -243,8 +373,8 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 根据当前通道数量解析数据
const channelCount = currentChannelDiv.value;
const timeStepNs = SAMPLE_PERIOD_NS;
const timeStepNs = currentSamplePeriodNs.value;
let sampleCount: number;
let x: number[];
let y: number[][];
@@ -252,19 +382,16 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
if (channelCount === 1) {
// 1通道每个字节包含8个时间单位的数据
sampleCount = bytes.length * 8;
// 创建时间轴
x = Array.from(
{ length: sampleCount },
(_, i) => (i * timeStepNs) / 1000,
); // 转换为微秒
// 创建通道数据数组
y = Array.from(
{ length: 1 },
() => new Array(sampleCount),
);
y = Array.from({ length: 1 }, () => new Array(sampleCount));
// 解析数据每个字节的8个位对应8个时间单位
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
const byte = bytes[byteIndex];
@@ -276,19 +403,16 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
} else if (channelCount === 2) {
// 2通道每个字节包含4个时间单位的数据
sampleCount = bytes.length * 4;
// 创建时间轴
x = Array.from(
{ length: sampleCount },
(_, i) => (i * timeStepNs) / 1000,
); // 转换为微秒
// 创建通道数据数组
y = Array.from(
{ length: 2 },
() => new Array(sampleCount),
);
y = Array.from({ length: 2 }, () => new Array(sampleCount));
// 解析数据每个字节的8个位对应4个时间单位的2通道数据
// 位分布:[T3_CH1, T3_CH0, T2_CH1, T2_CH0, T1_CH1, T1_CH0, T0_CH1, T0_CH0]
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
@@ -296,37 +420,34 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
for (let timeUnit = 0; timeUnit < 4; timeUnit++) {
const timeIndex = byteIndex * 4 + timeUnit;
const bitOffset = timeUnit * 2;
y[0][timeIndex] = (byte >> bitOffset) & 1; // CH0
y[0][timeIndex] = (byte >> bitOffset) & 1; // CH0
y[1][timeIndex] = (byte >> (bitOffset + 1)) & 1; // CH1
}
}
} else if (channelCount === 4) {
// 4通道每个字节包含2个时间单位的数据
sampleCount = bytes.length * 2;
// 创建时间轴
x = Array.from(
{ length: sampleCount },
(_, i) => (i * timeStepNs) / 1000,
); // 转换为微秒
// 创建通道数据数组
y = Array.from(
{ length: 4 },
() => new Array(sampleCount),
);
y = Array.from({ length: 4 }, () => new Array(sampleCount));
// 解析数据每个字节的8个位对应2个时间单位的4通道数据
// 位分布:[T1_CH3, T1_CH2, T1_CH1, T1_CH0, T0_CH3, T0_CH2, T0_CH1, T0_CH0]
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
const byte = bytes[byteIndex];
// 处理第一个时间单位低4位
const timeIndex1 = byteIndex * 2;
for (let channel = 0; channel < 4; channel++) {
y[channel][timeIndex1] = (byte >> channel) & 1;
}
// 处理第二个时间单位高4位
const timeIndex2 = byteIndex * 2 + 1;
for (let channel = 0; channel < 4; channel++) {
@@ -336,19 +457,16 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
} else if (channelCount === 8) {
// 8通道每个字节包含1个时间单位的8个通道数据
sampleCount = bytes.length;
// 创建时间轴
x = Array.from(
{ length: sampleCount },
(_, i) => (i * timeStepNs) / 1000,
); // 转换为微秒
// 创建8个通道的数据
y = Array.from(
{ length: 8 },
() => new Array(sampleCount),
);
y = Array.from({ length: 8 }, () => new Array(sampleCount));
// 解析每个字节的8个位到对应通道
for (let i = 0; i < sampleCount; i++) {
const byte = bytes[i];
@@ -360,30 +478,27 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
} else if (channelCount === 16) {
// 16通道每2个字节包含1个时间单位的16个通道数据
sampleCount = bytes.length / 2;
// 创建时间轴
x = Array.from(
{ length: sampleCount },
(_, i) => (i * timeStepNs) / 1000,
); // 转换为微秒
// 创建16个通道的数据
y = Array.from(
{ length: 16 },
() => new Array(sampleCount),
);
y = Array.from({ length: 16 }, () => new Array(sampleCount));
// 解析数据每2个字节为一个时间单位
for (let timeIndex = 0; timeIndex < sampleCount; timeIndex++) {
const byteIndex = timeIndex * 2;
const byte1 = bytes[byteIndex]; // [7:0]
const byte1 = bytes[byteIndex]; // [7:0]
const byte2 = bytes[byteIndex + 1]; // [15:8]
// 处理低8位通道 [7:0]
for (let channel = 0; channel < 8; channel++) {
y[channel][timeIndex] = (byte1 >> channel) & 1;
}
// 处理高8位通道 [15:8]
for (let channel = 0; channel < 8; channel++) {
y[channel + 8][timeIndex] = (byte2 >> channel) & 1;
@@ -392,42 +507,39 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
} else if (channelCount === 32) {
// 32通道每4个字节包含1个时间单位的32个通道数据
sampleCount = bytes.length / 4;
// 创建时间轴
x = Array.from(
{ length: sampleCount },
(_, i) => (i * timeStepNs) / 1000,
); // 转换为微秒
// 创建32个通道的数据
y = Array.from(
{ length: 32 },
() => new Array(sampleCount),
);
y = Array.from({ length: 32 }, () => new Array(sampleCount));
// 解析数据每4个字节为一个时间单位
for (let timeIndex = 0; timeIndex < sampleCount; timeIndex++) {
const byteIndex = timeIndex * 4;
const byte1 = bytes[byteIndex]; // [7:0]
const byte1 = bytes[byteIndex]; // [7:0]
const byte2 = bytes[byteIndex + 1]; // [15:8]
const byte3 = bytes[byteIndex + 2]; // [23:16]
const byte4 = bytes[byteIndex + 3]; // [31:24]
// 处理 [7:0]
for (let channel = 0; channel < 8; channel++) {
y[channel][timeIndex] = (byte1 >> channel) & 1;
}
// 处理 [15:8]
for (let channel = 0; channel < 8; channel++) {
y[channel + 8][timeIndex] = (byte2 >> channel) & 1;
}
// 处理 [23:16]
for (let channel = 0; channel < 8; channel++) {
y[channel + 16][timeIndex] = (byte3 >> channel) & 1;
}
// 处理 [31:24]
for (let channel = 0; channel < 8; channel++) {
y[channel + 24][timeIndex] = (byte4 >> channel) & 1;
@@ -461,11 +573,11 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
isCapturing.value = true;
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedLogicAnalyzerClient();
const client = AuthManager.createClient(LogicAnalyzerClient);
// 1. 先应用配置
alert?.info("正在应用配置...", 2000);
// 准备配置数据 - 包含所有32个通道未启用的通道设置为默认值
const allSignals = signalConfigs.map((signal, index) => {
if (channels[index].enabled) {
@@ -486,6 +598,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
channelDiv: getChannelDivEnum(currentChannelDiv.value),
captureLength: captureLength.value,
preCaptureLength: preCaptureLength.value,
clockDiv: currentclockDiv.value,
signalConfigs: allSignals,
});
@@ -567,7 +680,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedLogicAnalyzerClient();
const client = AuthManager.createClient(LogicAnalyzerClient);
// 执行强制捕获来停止当前捕获
const forceSuccess = await client.setCaptureMode(false, false);
@@ -588,15 +701,15 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
};
const forceCapture = async () => {
// 检查是否有其他操作正在进行
if (operationMutex.isLocked()) {
alert.warn("有其他操作正在进行中,请稍后再试", 3000);
// 检查是否正在捕获
if (!isCapturing.value) {
alert.warn("当前没有正在进行的捕获操作", 2000);
return;
}
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedLogicAnalyzerClient();
const client = AuthManager.createClient(LogicAnalyzerClient);
// 执行强制捕获来停止当前捕获
const forceSuccess = await client.setCaptureMode(true, true);
@@ -612,7 +725,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
`强制捕获失败: ${error instanceof Error ? error.message : "未知错误"}`,
3000,
);
} finally{
} finally {
release();
}
};
@@ -624,13 +737,13 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 添加生成测试数据的方法
const generateTestData = () => {
const sampleRate = LOGIC_ANALYZER_FREQUENCY; // 使用实际的逻辑分析仪频率
const sampleRate = currentSampleFrequency.value; // 使用当前设置的采样频率
const duration = 0.001; // 1ms的数据
const points = Math.floor(sampleRate * duration);
const x = Array.from(
{ length: points },
(_, i) => (i * SAMPLE_PERIOD_NS) / 1000, // 时间轴,单位:微秒
(_, i) => (i * currentSamplePeriodNs.value) / 1000, // 时间轴,单位:微秒
);
// Generate 8 channels with different digital patterns
@@ -703,6 +816,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
currentChannelDiv, // 导出当前通道组状态
captureLength, // 导出捕获深度
preCaptureLength, // 导出预捕获深度
currentclockDiv, // 导出当前采样频率状态
isApplying,
isCapturing, // 导出捕获状态
isOperationInProgress, // 导出操作进行状态
@@ -711,18 +825,29 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
enabledChannelCount,
channelNames,
enabledChannels,
currentSampleFrequency, // 导出当前采样频率
currentSamplePeriodNs, // 导出当前采样周期
// 选项数据
globalModes,
operators,
signalValues,
channelDivOptions, // 导出通道组选项
captureLengthOptions, // 导出捕获深度选项
preCaptureLengthOptions, // 导出预捕获深度选项
ClockDivOptions, // 导出采样频率选项
// 捕获深度常量和验证
CAPTURE_LENGTH_MIN,
CAPTURE_LENGTH_MAX,
PRE_CAPTURE_LENGTH_MIN,
validateCaptureLength,
validatePreCaptureLength,
setCaptureLength,
setPreCaptureLength,
// 触发设置方法
setChannelDiv, // 导出设置通道组方法
setGlobalMode,
setClockDiv, // 导出设置采样频率方法
resetConfiguration,
setLogicData,
startCapture,

View File

@@ -3,89 +3,220 @@
<!-- 通道配置 -->
<div class="form-control">
<!-- 全局触发模式选择和通道组配置 -->
<div class="flex flex-col lg:flex-row justify-between gap-4 my-4 mx-2">
<!-- 左侧全局触发模式和通道组选择 -->
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex flex-row gap-2 items-center">
<label class="label">
<span class="label-text text-sm">全局触发逻辑</span>
<div class="flex flex-col gap-6 my-4 mx-2">
<div class="flex flex-col lg:flex-row gap-6">
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
全局触发逻辑
</label>
<select
v-model="currentGlobalMode"
@change="setGlobalMode(currentGlobalMode)"
class="select select-sm select-bordered"
>
<option
v-for="mode in globalModes"
:key="mode.value"
:value="mode.value"
<div class="relative w-[200px]">
<button
tabindex="0"
type="button"
class="flex items-center gap-4 justify-between h-max outline-none focus:outline-none bg-transparent ring-transparent border border-slate-200 transition-all duration-300 ease-in disabled:opacity-50 disabled:pointer-events-none select-none text-start text-sm rounded-md py-2 px-2.5 ring shadow-sm hover:border-slate-800 hover:ring-slate-800/10 focus:border-slate-800 focus:ring-slate-800/10 w-full"
@click="toggleGlobalModeDropdown"
:aria-expanded="showGlobalModeDropdown"
aria-haspopup="listbox"
role="combobox"
>
{{ mode.label }} - {{ mode.description }}
</option>
</select>
<span>{{ currentGlobalModeLabel }}</span>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-[1em] w-[1em] translate-x-0.5 stroke-[1.5]">
<path d="M17 8L12 3L7 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M17 16L12 21L7 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<input readonly style="display:none" :value="currentGlobalMode" />
<!-- 下拉菜单 -->
<div v-if="showGlobalModeDropdown" class="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-md shadow-lg z-50">
<div
v-for="mode in globalModes"
:key="mode.value"
@click="selectGlobalMode(mode.value)"
class="px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 cursor-pointer"
:class="{ 'bg-slate-100': mode.value === currentGlobalMode }"
>
{{ mode.label }}
</div>
</div>
</div>
<p class="flex items-center text-xs text-slate-400">
{{ currentGlobalModeDescription }}
</p>
</div>
<div class="flex flex-row gap-2 items-center">
<label class="label">
<span class="label-text text-sm">通道组</span>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
通道组
</label>
<select
v-model="currentChannelDiv"
@change="setChannelDiv(currentChannelDiv)"
class="select select-sm select-bordered"
>
<option
v-for="option in channelDivOptions"
:key="option.value"
:value="option.value"
<div class="relative w-[200px]">
<button
tabindex="0"
type="button"
class="flex items-center gap-4 justify-between h-max outline-none focus:outline-none bg-transparent ring-transparent border border-slate-200 transition-all duration-300 ease-in disabled:opacity-50 disabled:pointer-events-none select-none text-start text-sm rounded-md py-2 px-2.5 ring shadow-sm hover:border-slate-800 hover:ring-slate-800/10 focus:border-slate-800 focus:ring-slate-800/10 w-full"
@click="toggleChannelDivDropdown"
:aria-expanded="showChannelDivDropdown"
aria-haspopup="listbox"
role="combobox"
>
{{ option.label }}
</option>
</select>
<span>{{ currentChannelDivLabel }}</span>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-[1em] w-[1em] translate-x-0.5 stroke-[1.5]">
<path d="M17 8L12 3L7 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M17 16L12 21L7 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<input readonly style="display:none" :value="currentChannelDiv" />
<!-- 下拉菜单 -->
<div v-if="showChannelDivDropdown" class="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-md shadow-lg z-50">
<div
v-for="option in channelDivOptions"
:key="option.value"
@click="selectChannelDiv(option.value)"
class="px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 cursor-pointer"
:class="{ 'bg-slate-100': option.value === currentChannelDiv }"
>
{{ option.label }}
</div>
</div>
</div>
<p class="flex items-center text-xs text-slate-400">
{{ currentChannelDivDescription }}
</p>
</div>
<div class="flex flex-row gap-2 items-center">
<label class="label">
<span class="label-text text-sm">捕获深度</span>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased text-slate-800">
采样频率
</label>
<select
v-model="captureLength"
class="select select-sm select-bordered"
>
<option
v-for="option in captureLengthOptions"
:key="option.value"
:value="option.value"
<div class="relative w-[200px]">
<button
tabindex="0"
type="button"
class="flex items-center gap-4 justify-between h-max outline-none focus:outline-none text-slate-600 bg-transparent ring-transparent border border-slate-200 transition-all duration-300 ease-in disabled:opacity-50 disabled:pointer-events-none select-none text-start text-sm rounded-md py-2 px-2.5 ring shadow-sm hover:border-slate-800 hover:ring-slate-800/10 focus:border-slate-800 focus:ring-slate-800/10 w-full"
@click="toggleClockDivDropdown"
:aria-expanded="showClockDivDropdown"
aria-haspopup="listbox"
role="combobox"
>
{{ option.label }}
</option>
</select>
<span>{{ currentClockDivLabel }}</span>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-[1em] w-[1em] translate-x-0.5 stroke-[1.5]">
<path d="M17 8L12 3L7 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M17 16L12 21L7 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<input readonly style="display:none" :value="currentclockDiv" />
<!-- 下拉菜单 -->
<div v-if="showClockDivDropdown" class="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-md shadow-lg z-50">
<div
v-for="option in ClockDivOptions"
:key="option.value"
@click="selectClockDiv(option.value)"
class="px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 cursor-pointer"
:class="{ 'bg-slate-100': option.value === currentclockDiv }"
>
{{ option.label }}
</div>
</div>
</div>
<p class="flex items-center text-xs text-slate-400">
{{ currentClockDivDescription }}
</p>
</div>
<div class="flex flex-row gap-2 items-center">
<label class="label">
<span class="label-text text-sm">预捕获深度</span>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
捕获深度
</label>
<select
v-model="preCaptureLength"
class="select select-sm select-bordered"
>
<option
v-for="option in preCaptureLengthOptions"
:key="option.value"
:value="option.value"
<div class="relative w-[200px]">
<button
@click="decreaseCaptureLength"
class="absolute right-9 top-1 rounded-md border border-transparent p-1.5 text-center text-sm transition-all hover:bg-slate-100 focus:bg-slate-100 active:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
:disabled="captureLength <= CAPTURE_LENGTH_MIN"
>
{{ option.label }}
</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
<input
v-model.number="captureLength"
@change="handleCaptureLengthChange"
type="number"
:min="CAPTURE_LENGTH_MIN"
:max="CAPTURE_LENGTH_MAX"
class="w-full bg-transparent placeholder:text-sm border border-slate-200 rounded-md pl-3 pr-20 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 ring ring-transparent hover:ring-slate-800/10 focus:ring-slate-800/10 hover:border-slate-800 shadow-sm focus:shadow appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="CAPTURE_LENGTH_MIN.toString()"
/>
<button
@click="increaseCaptureLength"
class="absolute right-1 top-1 rounded-md border border-transparent p-1.5 text-center text-sm transition-all hover:bg-slate-100 focus:bg-slate-100 active:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
:disabled="captureLength >= CAPTURE_LENGTH_MAX"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
</svg>
</button>
</div>
<p class="flex items-center text-xs text-slate-400">
范围: {{ CAPTURE_LENGTH_MIN.toLocaleString() }} - {{ CAPTURE_LENGTH_MAX.toLocaleString() }}
</p>
</div>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
预捕获深度
</label>
<div class="relative w-[200px]">
<button
@click="decreasePreCaptureLength"
class="absolute right-9 top-1 rounded-md border border-transparent p-1.5 text-center text-sm transition-all hover:bg-slate-100 focus:bg-slate-100 active:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
:disabled="preCaptureLength <= PRE_CAPTURE_LENGTH_MIN"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
<input
v-model.number="preCaptureLength"
@change="handlePreCaptureLengthChange"
type="number"
:min="PRE_CAPTURE_LENGTH_MIN"
:max="Math.max(0, captureLength - 1)"
class="w-full bg-transparent placeholder:text-sm border border-slate-200 rounded-md pl-3 pr-20 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 ring ring-transparent hover:ring-slate-800/10 focus:ring-slate-800/10 hover:border-slate-800 shadow-sm focus:shadow appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="PRE_CAPTURE_LENGTH_MIN.toString()"
/>
<button
@click="increasePreCaptureLength"
class="absolute right-1 top-1 rounded-md border border-transparent p-1.5 text-center text-sm transition-all hover:bg-slate-100 focus:bg-slate-100 active:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
:disabled="preCaptureLength >= Math.max(0, captureLength - 1)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
</svg>
</button>
</div>
<p class="flex items-center text-xs text-slate-400">
范围: {{ PRE_CAPTURE_LENGTH_MIN }} - {{ Math.max(0, captureLength - 1) }}
</p>
</div>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
重置配置
</label>
<div class="relative w-[200px]">
<button
@click="resetConfiguration"
class="w-10 h-10 bg-transparent text-red-600 text-sm border border-red-200 rounded-md py-2 px-2.5 transition duration-300 ease ring ring-transparent hover:ring-red-600/10 focus:ring-red-600/10 hover:border-red-600 shadow-sm focus:shadow flex items-center justify-center"
type="button"
title="重置配置"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<p class="flex items-center text-xs text-slate-400">
恢复所有设置到默认值
</p>
</div>
</div>
<!-- 右侧操作按钮 -->
<div class="flex flex-row gap-2">
<button @click="resetConfiguration" class="btn btn-outline btn-sm">
重置配置
</button>
</div>
</div>
<!-- 通道列表 -->
@@ -177,12 +308,14 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useRequiredInjection } from "@/utils/Common";
import { useLogicAnalyzerState } from "./LogicAnalyzerManager";
const {
currentGlobalMode,
currentChannelDiv,
currentclockDiv,
captureLength,
preCaptureLength,
isApplying,
@@ -193,10 +326,153 @@ const {
operators,
signalValues,
channelDivOptions,
captureLengthOptions,
preCaptureLengthOptions,
ClockDivOptions,
CAPTURE_LENGTH_MIN,
CAPTURE_LENGTH_MAX,
PRE_CAPTURE_LENGTH_MIN,
validateCaptureLength,
validatePreCaptureLength,
setCaptureLength,
setPreCaptureLength,
setChannelDiv,
setGlobalMode,
setClockDiv,
resetConfiguration,
} = useRequiredInjection(useLogicAnalyzerState);
// 下拉菜单状态
const showGlobalModeDropdown = ref(false);
const showChannelDivDropdown = ref(false);
const showClockDivDropdown = ref(false);
// 处理捕获深度变化
const handleCaptureLengthChange = () => {
setCaptureLength(captureLength.value);
};
// 处理预捕获深度变化
const handlePreCaptureLengthChange = () => {
setPreCaptureLength(preCaptureLength.value);
};
// 增加捕获深度
const increaseCaptureLength = () => {
const newValue = Math.min(captureLength.value + 1024, CAPTURE_LENGTH_MAX);
setCaptureLength(newValue);
};
// 减少捕获深度
const decreaseCaptureLength = () => {
const newValue = Math.max(captureLength.value - 1024, CAPTURE_LENGTH_MIN);
setCaptureLength(newValue);
};
// 增加预捕获深度
const increasePreCaptureLength = () => {
const maxValue = Math.max(0, captureLength.value - 1);
const newValue = Math.min(preCaptureLength.value + 64, maxValue);
setPreCaptureLength(newValue);
};
// 减少预捕获深度
const decreasePreCaptureLength = () => {
const newValue = Math.max(preCaptureLength.value - 64, PRE_CAPTURE_LENGTH_MIN);
setPreCaptureLength(newValue);
};
// 计算属性:获取当前全局模式的标签
const currentGlobalModeLabel = computed(() => {
const mode = globalModes.find(m => m.value === currentGlobalMode.value);
return mode ? mode.label : '';
});
// 计算属性:获取当前全局模式的描述
const currentGlobalModeDescription = computed(() => {
const mode = globalModes.find(m => m.value === currentGlobalMode.value);
return mode ? mode.description : '';
});
// 计算属性:获取当前通道组的标签
const currentChannelDivLabel = computed(() => {
const option = channelDivOptions.find(opt => opt.value === currentChannelDiv.value);
return option ? option.label : '';
});
// 计算属性:获取当前通道组的描述
const currentChannelDivDescription = computed(() => {
const option = channelDivOptions.find(opt => opt.value === currentChannelDiv.value);
return option ? option.description : '';
});
// 计算属性:获取当前采样频率的标签
const currentClockDivLabel = computed(() => {
const option = ClockDivOptions.find(opt => opt.value === currentclockDiv.value);
return option ? option.label : '';
});
// 计算属性:获取当前采样频率的描述
const currentClockDivDescription = computed(() => {
const option = ClockDivOptions.find(opt => opt.value === currentclockDiv.value);
return option ? option.description : '';
});
// 全局模式下拉菜单相关函数
const toggleGlobalModeDropdown = () => {
showGlobalModeDropdown.value = !showGlobalModeDropdown.value;
if (showGlobalModeDropdown.value) {
showChannelDivDropdown.value = false;
showClockDivDropdown.value = false;
}
};
const selectGlobalMode = (mode: any) => {
setGlobalMode(mode);
showGlobalModeDropdown.value = false;
};
// 通道组下拉菜单相关函数
const toggleChannelDivDropdown = () => {
showChannelDivDropdown.value = !showChannelDivDropdown.value;
if (showChannelDivDropdown.value) {
showGlobalModeDropdown.value = false;
showClockDivDropdown.value = false;
}
};
const selectChannelDiv = (value: number) => {
setChannelDiv(value);
showChannelDivDropdown.value = false;
};
// 采样频率下拉菜单相关函数
const toggleClockDivDropdown = () => {
showClockDivDropdown.value = !showClockDivDropdown.value;
if (showClockDivDropdown.value) {
showGlobalModeDropdown.value = false;
showChannelDivDropdown.value = false;
}
};
const selectClockDiv = (value: any) => {
setClockDiv(value);
showClockDivDropdown.value = false;
};
// 点击其他地方关闭下拉菜单
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.relative')) {
showGlobalModeDropdown.value = false;
showChannelDivDropdown.value = false;
showClockDivDropdown.value = false;
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref } from "vue";
import { MdEditor } from "md-editor-v3";
import "md-editor-v3/lib/style.css";
import { useThemeStore } from "@/stores/theme";
const theme = useThemeStore();
const text = ref("# Hello Editor");
async function handleSaveEvent(v: string, h: Promise<string>) {}
async function loadMarkdownFromString(markdown: string) {
text.value = markdown;
}
async function loadMarkdownFromUrl(url: string) {
const response = await fetch(url);
const markdown = await response.text();
text.value = markdown;
}
defineExpose({
loadMarkdownFromString,
loadMarkdownFromUrl,
});
</script>
<template>
<MdEditor v-model="text" :theme="theme.currentMode" />
</template>
<style lang="postcss" scoped></style>

View File

@@ -1,21 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { marked } from 'marked';
import hljs from 'highlight.js';
import { computed, onMounted, ref, watch } from "vue";
import { marked } from "marked";
import hljs from "highlight.js";
// 导入亮色主题样式
import 'highlight.js/styles/github.css'; // 亮色主题
import "highlight.js/styles/github.css"; // 亮色主题
// 导入主题存储
import { useThemeStore } from '@/stores/theme';
import { useThemeStore } from "@/stores/theme";
import { AuthManager } from "@/utils/AuthManager";
import { ResourceClient, ResourcePurpose } from "@/APIClient";
const props = defineProps({
content: {
type: String,
required: true
},
removeFirstH1: {
type: Boolean,
default: false
}
content: {
type: String,
required: true,
},
removeFirstH1: {
type: Boolean,
default: false,
},
examId: {
type: String,
default: "",
},
});
// 使用主题存储
@@ -23,68 +29,204 @@ const themeStore = useThemeStore();
// 使用 isDarkTheme 函数来检查当前是否为暗色主题
const isDarkMode = computed(() => themeStore.isDarkTheme());
// 图片资源缓存
const imageResourceCache = ref<Map<string, string>>(new Map());
// 获取图片资源ID的函数
async function getImageResourceId(
examId: string,
imagePath: string,
): Promise<string | null> {
try {
const client = AuthManager.createClient(ResourceClient);
const resources = await client.getResourceList(
examId,
"images",
ResourcePurpose.Template,
);
// 查找匹配的图片资源
const imageResource = resources.find(
(r) => r.name === imagePath || r.name.endsWith(imagePath),
);
return imageResource ? imageResource.id.toString() : null;
} catch (error) {
console.error("获取图片资源ID失败:", error);
return null;
}
}
// 通过资源ID获取图片数据URL
async function getImageDataUrl(resourceId: string): Promise<string | null> {
try {
const client = AuthManager.createClient(ResourceClient);
const response = await client.getResourceById(parseInt(resourceId));
if (response && response.data) {
return URL.createObjectURL(response.data);
}
return null;
} catch (error) {
console.error("获取图片数据失败:", error);
return null;
}
}
// 监听主题变化
watch(() => themeStore.currentTheme, () => {
watch(
() => themeStore.currentTheme,
() => {
// 主题变化时更新代码高亮样式
updateCodeBlocksTheme();
});
},
);
// 更新代码块主题样式
const updateCodeBlocksTheme = () => {
// 这个函数可以在需要时手动更新代码块的样式
// 由于我们使用CSS变量控制样式可能不需要特定实现
// 但如果需要,可以在这里添加额外逻辑
// 这个函数可以在需要时手动更新代码块的样式
// 由于我们使用CSS变量控制样式可能不需要特定实现
// 但如果需要,可以在这里添加额外逻辑
};
const renderedContent = computed(() => {
if (!props.content) return '<p>没有内容</p>';
let processedContent = props.content;
// 如果需要,移除第一个一级标题
if (props.removeFirstH1) {
const lines = processedContent.split('\n');
const firstH1Index = lines.findIndex(line => line.startsWith('# '));
if (firstH1Index !== -1) {
processedContent = lines.slice(firstH1Index + 1).join('\n');
}
if (!props.content) return "<p>没有内容</p>";
let processedContent = props.content;
// 如果需要,移除第一个一级标题
if (props.removeFirstH1) {
const lines = processedContent.split("\n");
const firstH1Index = lines.findIndex((line) => line.startsWith("# "));
if (firstH1Index !== -1) {
processedContent = lines.slice(firstH1Index + 1).join("\n");
}
// 创建自定义渲染器
const renderer = new marked.Renderer();
// 重写代码块渲染方法,添加语言信息
renderer.code = (code, incomingLanguage) => {
// 确保语言参数是字符串
const language = incomingLanguage || 'plaintext';
// 验证语言
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
// 高亮代码
const highlightedCode = hljs.highlight(code, { language: validLanguage }).value;
// 添加语言标签到代码块
return `<pre class="hljs" data-language="${validLanguage}"><code class="language-${validLanguage}">${highlightedCode}</code></pre>`;
};
// 设置 marked 选项
marked.use({
renderer: renderer,
gfm: true,
breaks: true
}
// 创建自定义渲染器
const renderer = new marked.Renderer();
// 重写图片渲染方法,处理相对路径
renderer.image = (href, title, text) => {
let src = href;
console.log(`原始图片路径: ${href}, examId: ${props.examId}`);
// 如果是相对路径且有实验ID需要通过动态API获取
if (props.examId && href && href.startsWith("./")) {
// 对于相对路径的图片我们需要先获取图片资源ID然后通过动态API获取
// 暂时保留原始路径,在后处理中进行替换
src = href;
console.log(`保留原始路径用于后处理: ${src}`);
}
const titleAttr = title ? ` title="${title}"` : "";
const altAttr = text ? ` alt="${text}"` : "";
const dataOriginal =
href && href.startsWith("./") ? ` data-original-src="${href}"` : "";
console.log(
`最终渲染的HTML: <img src="${src}"${titleAttr}${altAttr}${dataOriginal} />`,
);
return `<img src="${src}"${titleAttr}${altAttr}${dataOriginal} />`;
};
// 重写代码块渲染方法,添加语言信息
renderer.code = (code, incomingLanguage) => {
// 确保语言参数是字符串
const language = incomingLanguage || "plaintext";
// 验证语言
const validLanguage = hljs.getLanguage(language) ? language : "plaintext";
// 高亮代码
const highlightedCode = hljs.highlight(code, {
language: validLanguage,
}).value;
// 添加语言标签到代码块
return `<pre class="hljs" data-language="${validLanguage}"><code class="language-${validLanguage}">${highlightedCode}</code></pre>`;
};
// 设置 marked 选项并解析内容
let html = marked.parse(processedContent, {
renderer: renderer,
gfm: true,
breaks: true,
}) as string;
// 后处理HTML异步处理图片
if (props.examId) {
// 查找所有需要处理的图片
const imgMatches = Array.from(
html.matchAll(
/(<img[^>]+data-original-src=["'])\.\/([^"']+)(["'][^>]*>)/g,
),
);
// 异步处理每个图片
imgMatches.forEach(async (match) => {
const [fullMatch, prefix, path, suffix] = match;
const imagePath = path.replace("images/", "");
// 检查缓存
if (imageResourceCache.value.has(imagePath)) {
const cachedUrl = imageResourceCache.value.get(imagePath)!;
html = html.replace(
fullMatch,
`${prefix}${cachedUrl}${suffix.replace(' data-original-src="./' + path + '"', "")}`,
);
return;
}
try {
// 获取图片资源ID
const resourceId = await getImageResourceId(props.examId, imagePath);
if (resourceId) {
// 获取图片数据URL
const dataUrl = await getImageDataUrl(resourceId);
if (dataUrl) {
// 缓存URL
imageResourceCache.value.set(imagePath, dataUrl);
// 更新HTML中的图片src
const updatedHtml = html.replace(
fullMatch,
`${prefix}${dataUrl}${suffix.replace(' data-original-src="./' + path + '"', "")}`,
);
// 触发重新渲染
setTimeout(() => {
const imgElements = document.querySelectorAll(
`img[data-original-src="./${path}"]`,
);
imgElements.forEach((img) => {
(img as HTMLImageElement).src = dataUrl;
img.removeAttribute("data-original-src");
});
}, 0);
}
}
} catch (error) {
console.error(`处理图片 ${imagePath} 失败:`, error);
}
});
return marked(processedContent);
}
return html;
});
// 页面挂载后,确保应用正确的主题样式
onMounted(() => {
updateCodeBlocksTheme();
updateCodeBlocksTheme();
});
</script>
<template>
<div class="markdown-content" :data-theme="themeStore.currentTheme" v-html="renderedContent"></div>
<div
class="markdown-content"
:data-theme="themeStore.currentTheme"
v-html="renderedContent"
></div>
</template>
<style scoped>
@@ -103,7 +245,7 @@ onMounted(() => {
display: block;
margin: 1rem auto;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.markdown-content :deep(h1) {
@@ -115,7 +257,7 @@ onMounted(() => {
line-height: 1.3;
padding-bottom: 0.7rem;
border-bottom: 2px solid hsl(var(--p) / 0.7);
text-shadow: 1px 1px 2px rgba(0,0,0,0.05);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.05);
}
.markdown-content :deep(h2) {
@@ -160,7 +302,7 @@ onMounted(() => {
.markdown-content :deep(h4::before),
.markdown-content :deep(h5::before),
.markdown-content :deep(h6::before) {
content: '▶';
content: "▶";
color: hsl(var(--p) / 0.7);
position: absolute;
left: 0.2rem;
@@ -235,13 +377,13 @@ onMounted(() => {
overflow-x: auto;
border: 1px solid hsl(var(--b2));
margin: 1.5rem 0;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
position: relative;
color: var(--code-color, hsl(var(--bc)));
}
.markdown-content :deep(pre::before) {
content: '';
content: "";
position: absolute;
top: 0;
left: 0;
@@ -267,7 +409,9 @@ onMounted(() => {
/* 内联代码样式 */
.markdown-content :deep(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
background-color: var(--inline-code-bg, hsl(var(--b3) / 0.7));
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
@@ -284,7 +428,9 @@ onMounted(() => {
color: inherit;
font-size: 0.95em;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
/* 为常见语言添加一些特殊的高亮效果 */
@@ -335,7 +481,7 @@ onMounted(() => {
background-color: hsl(var(--b1));
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid hsl(var(--b2));
}
@@ -373,7 +519,7 @@ onMounted(() => {
color: hsl(var(--bc) / 0.9);
font-style: italic;
border-radius: 0 0.5rem 0.5rem 0;
box-shadow: 0 2px 5px rgba(0,0,0,0.03);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03);
position: relative;
}
@@ -564,7 +710,7 @@ onMounted(() => {
background-color: hsl(var(--b1));
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid hsl(var(--b2));
}
@@ -602,7 +748,7 @@ onMounted(() => {
color: hsl(var(--bc) / 0.9);
font-style: italic;
border-radius: 0 0.5rem 0.5rem 0;
box-shadow: 0 2px 5px rgba(0,0,0,0.03);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03);
position: relative;
}
@@ -643,7 +789,7 @@ onMounted(() => {
background-color: hsl(var(--b3));
border-color: hsl(var(--b1) / 0.7);
}
.markdown-content :deep(code) {
background-color: hsl(var(--b2) / 0.7);
}

View File

@@ -44,7 +44,7 @@
</router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/markdown-test" class="text-base font-medium">
<router-link to="/markdown" class="text-base font-medium">
<FileText class="icon" />
Markdown测试
</router-link>
@@ -145,6 +145,7 @@ import {
ChevronDownIcon,
} from "lucide-vue-next";
import { AuthManager } from "@/utils/AuthManager";
import { DataClient } from "@/APIClient";
const router = useRouter();
@@ -158,7 +159,7 @@ const loadUserInfo = async () => {
try {
const authenticated = await AuthManager.isAuthenticated();
if (authenticated) {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const userInfo = await client.getUserInfo();
userName.value = userInfo.name;
isLoggedIn.value = true;

View File

@@ -1,13 +1,35 @@
import { autoResetRef, createInjectionState } from "@vueuse/core";
import { shallowRef, reactive, ref, computed } from "vue";
import { Mutex } from "async-mutex";
import {
OscilloscopeFullConfig,
OscilloscopeDataResponse,
} from "@/APIClient";
autoResetRef,
createInjectionState,
watchDebounced,
} from "@vueuse/core";
import {
shallowRef,
reactive,
ref,
computed,
onMounted,
onUnmounted,
watchEffect,
} from "vue";
import { Mutex } from "async-mutex";
import { OscilloscopeApiClient } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert";
import { useRequiredInjection } from "@/utils/Common";
import type { HubConnection } from "@microsoft/signalr";
import type {
IOscilloscopeHub,
IOscilloscopeReceiver,
} from "@/utils/signalR/TypedSignalR.Client/server.Hubs";
import {
getHubProxyFactory,
getReceiverRegister,
} from "@/utils/signalR/TypedSignalR.Client";
import type {
OscilloscopeDataResponse,
OscilloscopeFullConfig,
} from "@/utils/signalR/server.Hubs";
export type OscilloscopeDataType = {
x: number[];
@@ -21,78 +43,154 @@ export type OscilloscopeDataType = {
};
// 默认配置
const DEFAULT_CONFIG: OscilloscopeFullConfig = new OscilloscopeFullConfig({
const DEFAULT_CONFIG: OscilloscopeFullConfig = {
captureEnabled: false,
triggerLevel: 128,
triggerRisingEdge: true,
horizontalShift: 0,
decimationRate: 50,
autoRefreshRAM: false,
});
captureFrequency: 100,
};
// 采样频率常量(后端返回)
const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() => {
const oscData = shallowRef<OscilloscopeDataType>();
const alert = useRequiredInjection(useAlertStore);
const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
() => {
// Global Store
const alert = useRequiredInjection(useAlertStore);
// 互斥锁
const operationMutex = new Mutex();
// Data
const oscData = shallowRef<OscilloscopeDataType>();
const clearOscilloscopeData = () => {
oscData.value = undefined;
};
// 状态
const isApplying = ref(false);
const isCapturing = ref(false);
// SignalR Hub
const oscilloscopeHub = shallowRef<{
connection: HubConnection;
proxy: IOscilloscopeHub;
} | null>(null);
// 配置
const config = reactive<OscilloscopeFullConfig>(new OscilloscopeFullConfig({ ...DEFAULT_CONFIG }));
const oscilloscopeReceiver: IOscilloscopeReceiver = {
onDataReceived: async (data) => {
analyzeOscilloscopeData(data);
},
};
// 采样点数(由后端数据决定)
const sampleCount = ref(0);
onMounted(() => {
initHub();
});
// 采样周期ns由adFrequency计算
const samplePeriodNs = computed(() =>
oscData.value?.adFrequency ? 1_000_000_000 / oscData.value.adFrequency : 200
);
onUnmounted(() => {
clearHub();
});
// 应用配置
const applyConfiguration = async () => {
if (operationMutex.isLocked()) {
alert.warn("有其他操作正在进行中,请稍后再试", 3000);
return;
async function initHub() {
if (oscilloscopeHub.value) return;
const connection = AuthManager.createHubConnection("OscilloscopeHub");
const proxy =
getHubProxyFactory("IOscilloscopeHub").createHubProxy(connection);
getReceiverRegister("IOscilloscopeReceiver").register(
connection,
oscilloscopeReceiver,
);
await connection.start();
oscilloscopeHub.value = { connection, proxy };
}
const release = await operationMutex.acquire();
isApplying.value = true;
try {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const success = await client.initialize({ ...config });
if (success) {
alert.success("示波器配置已应用", 2000);
} else {
throw new Error("应用失败");
function clearHub() {
if (!oscilloscopeHub.value) return;
oscilloscopeHub.value.connection.stop();
oscilloscopeHub.value = null;
}
function reinitializeHub() {
clearHub();
initHub();
}
function getHubProxy() {
if (!oscilloscopeHub.value) {
reinitializeHub();
throw new Error("Hub not initialized");
}
} catch (error) {
alert.error("应用配置失败", 3000);
} finally {
isApplying.value = false;
release();
return oscilloscopeHub.value.proxy;
}
};
// 重置配置
const resetConfiguration = () => {
Object.assign(config, { ...DEFAULT_CONFIG });
alert.info("配置已重置", 2000);
};
// 互斥锁
const operationMutex = new Mutex();
const clearOscilloscopeData = () => {
oscData.value = undefined;
}
// 状态
const isApplying = ref(false);
const isCapturing = ref(false);
const isAutoApplying = ref(false);
// 获取数据
const getOscilloscopeData = async () => {
try {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const resp: OscilloscopeDataResponse = await client.getData();
// 配置
const config = reactive<OscilloscopeFullConfig>({ ...DEFAULT_CONFIG });
watchDebounced(
config,
() => {
if (!isAutoApplying.value) return;
if (
!isApplying.value ||
!isCapturing.value ||
!operationMutex.isLocked()
) {
applyConfiguration();
}
},
{ debounce: 200, maxWait: 1000 },
);
// 应用配置
const applyConfiguration = async () => {
if (operationMutex.isLocked()) {
alert.warn("有其他操作正在进行中,请稍后再试", 3000);
return;
}
const release = await operationMutex.acquire();
isApplying.value = true;
try {
const proxy = getHubProxy();
// console.log("Applying configuration", config);
const success = await proxy.initialize(config);
if (success) {
alert.success("示波器配置已应用", 2000);
} else {
throw new Error("应用失败");
}
} catch (error) {
if (error instanceof Error && error.message === "Hub not initialized")
reinitializeHub();
alert.error("应用配置失败", 3000);
} finally {
isApplying.value = false;
release();
}
};
// 重置配置
const resetConfiguration = () => {
Object.assign(config, { ...DEFAULT_CONFIG });
alert.info("配置已重置", 2000);
};
// 采样点数(由后端数据决定)
const sampleCount = ref(0);
// 采样周期ns由adFrequency计算
const samplePeriodNs = computed(() =>
oscData.value?.adFrequency
? 1_000_000_000 / oscData.value.adFrequency
: 200,
);
const analyzeOscilloscopeData = (resp: OscilloscopeDataResponse) => {
// 解析波形数据
const binaryString = atob(resp.waveformData);
const bytes = new Uint8Array(binaryString.length);
@@ -101,10 +199,16 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
}
sampleCount.value = bytes.length;
const aDFrequency = resp.adFrequency;
// 计算采样周期ns
const samplePeriodNs =
aDFrequency > 0 ? 1_000_000_000 / aDFrequency : 200;
// 构建时间轴
const x = Array.from(
{ length: bytes.length },
(_, i) => (i * samplePeriodNs.value) / 1000 // us
(_, i) => (i * samplePeriodNs) / 1000, // us
);
const y = Array.from(bytes);
@@ -113,175 +217,174 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
y,
xUnit: "us",
yUnit: "V",
adFrequency: resp.adFrequency,
adFrequency: aDFrequency,
adVpp: resp.adVpp,
adMax: resp.adMax,
adMin: resp.adMin,
};
} catch (error) {
alert.error("获取示波器数据失败", 3000);
}
};
// 定时器引用
let refreshIntervalId: number | undefined;
// 刷新间隔(毫秒),可根据需要调整
const refreshIntervalMs = ref(1000);
// 定时刷新函数
const startAutoRefresh = () => {
if (refreshIntervalId !== undefined) return;
refreshIntervalId = window.setInterval(async () => {
await refreshRAM();
await getOscilloscopeData();
}, refreshIntervalMs.value);
};
const stopAutoRefresh = () => {
if (refreshIntervalId !== undefined) {
clearInterval(refreshIntervalId);
refreshIntervalId = undefined;
isCapturing.value = false;
}
};
// 启动捕获
const startCapture = async () => {
if (operationMutex.isLocked()) {
alert.warn("有其他操作正在进行中,请稍后再试", 3000);
return;
}
isCapturing.value = true;
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const started = await client.startCapture();
if (!started) throw new Error("无法启动捕获");
alert.info("开始捕获...", 2000);
// 启动定时刷新
startAutoRefresh();
} catch (error) {
alert.error("捕获失败", 3000);
isCapturing.value = false;
stopAutoRefresh();
} finally {
release();
}
};
// 停止捕获
const stopCapture = async () => {
if (!isCapturing.value) {
alert.warn("当前没有正在进行的捕获操作", 2000);
return;
}
isCapturing.value = false;
stopAutoRefresh();
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const stopped = await client.stopCapture();
if (!stopped) throw new Error("无法停止捕获");
alert.info("捕获已停止", 2000);
} catch (error) {
alert.error("停止捕获失败", 3000);
} finally {
release();
}
};
// 更新触发参数
const updateTrigger = async (level: number, risingEdge: boolean) => {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
try {
const ok = await client.updateTrigger(level, risingEdge);
if (ok) {
config.triggerLevel = level;
config.triggerRisingEdge = risingEdge;
alert.success("触发参数已更新", 2000);
} else {
throw new Error();
}
} catch {
alert.error("更新触发参数失败", 2000);
}
};
// 更新采样参数
const updateSampling = async (horizontalShift: number, decimationRate: number) => {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
try {
const ok = await client.updateSampling(horizontalShift, decimationRate);
if (ok) {
config.horizontalShift = horizontalShift;
config.decimationRate = decimationRate;
alert.success("采样参数已更新", 2000);
} else {
throw new Error();
}
} catch {
alert.error("更新采样参数失败", 2000);
}
};
// 手动刷新RAM
const refreshRAM = async () => {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
try {
const ok = await client.refreshRAM();
if (ok) {
// alert.success("RAM已刷新", 2000);
} else {
throw new Error();
}
} catch {
alert.error("刷新RAM失败", 2000);
}
};
// 生成测试数据
const generateTestData = () => {
const freq = 5_000_000;
const duration = 0.001; // 1ms
const points = Math.floor(freq * duration);
const x = Array.from({ length: points }, (_, i) => (i * 1_000_000_000 / freq) / 1000);
const y = Array.from({ length: points }, (_, i) =>
Math.floor(Math.sin(i * 0.01) * 127 + 128)
);
oscData.value = {
x,
y,
xUnit: "us",
yUnit: "V",
adFrequency: freq,
adVpp: 2.0,
adMax: 255,
adMin: 0,
console.log("解析后的参数:", resp, oscData.value); // 添加调试日志
};
alert.success("测试数据生成成功", 2000);
};
return {
oscData,
config,
isApplying,
isCapturing,
sampleCount,
samplePeriodNs,
refreshIntervalMs,
// 获取数据
const getOscilloscopeData = async () => {
try {
const proxy = getHubProxy();
const resp = await proxy.getData();
analyzeOscilloscopeData(resp);
} catch (error) {
alert.error("获取示波器数据失败", 3000);
}
};
applyConfiguration,
resetConfiguration,
clearOscilloscopeData,
getOscilloscopeData,
startCapture,
stopCapture,
updateTrigger,
updateSampling,
refreshRAM,
generateTestData,
};
});
// 启动捕获
const startCapture = async () => {
if (operationMutex.isLocked()) {
alert.warn("有其他操作正在进行中,请稍后再试", 3000);
return;
}
isCapturing.value = true;
const release = await operationMutex.acquire();
try {
const proxy = getHubProxy();
const started = await proxy.startCapture();
if (!started) throw new Error("无法启动捕获");
alert.info("开始捕获...", 2000);
} catch (error) {
alert.error("捕获失败", 3000);
isCapturing.value = false;
} finally {
release();
}
};
export { useProvideOscilloscope, useOscilloscopeState, DEFAULT_CONFIG };
// 停止捕获
const stopCapture = async () => {
if (!isCapturing.value) {
alert.warn("当前没有正在进行的捕获操作", 2000);
return;
}
const release = await operationMutex.acquire();
try {
const proxy = getHubProxy();
const stopped = await proxy.stopCapture();
if (!stopped) throw new Error("无法停止捕获");
isCapturing.value = false;
alert.info("捕获已停止", 2000);
} catch (error) {
alert.error("停止捕获失败", 3000);
} finally {
release();
}
};
const toggleCapture = async () => {
if (isCapturing.value) {
await stopCapture();
} else {
await startCapture();
}
};
// 更新触发参数
const updateTrigger = async (level: number, risingEdge: boolean) => {
const client = AuthManager.createClient(OscilloscopeApiClient);
try {
const ok = await client.updateTrigger(level, risingEdge);
if (ok) {
config.triggerLevel = level;
config.triggerRisingEdge = risingEdge;
alert.success("触发参数已更新", 2000);
} else {
throw new Error();
}
} catch {
alert.error("更新触发参数失败", 2000);
}
};
// 更新采样参数
const updateSampling = async (
horizontalShift: number,
decimationRate: number,
) => {
const client = AuthManager.createClient(OscilloscopeApiClient);
try {
const ok = await client.updateSampling(horizontalShift, decimationRate);
if (ok) {
config.horizontalShift = horizontalShift;
config.decimationRate = decimationRate;
alert.success("采样参数已更新", 2000);
} else {
throw new Error();
}
} catch {
alert.error("更新采样参数失败", 2000);
}
};
// 手动刷新RAM
const refreshRAM = async () => {
const client = AuthManager.createClient(OscilloscopeApiClient);
try {
const ok = await client.refreshRAM();
if (ok) {
// alert.success("RAM已刷新", 2000);
} else {
throw new Error();
}
} catch {
alert.error("刷新RAM失败", 2000);
}
};
// 生成测试数据
const generateTestData = () => {
const freq = 5_000_000;
const duration = 0.001; // 1ms
const points = Math.floor(freq * duration);
const x = Array.from(
{ length: points },
(_, i) => (i * 1_000_000_000) / freq / 1000,
);
const y = Array.from({ length: points }, (_, i) =>
Math.floor(Math.sin(i * 0.01) * 127 + 128),
);
oscData.value = {
x,
y,
xUnit: "us",
yUnit: "V",
adFrequency: freq,
adVpp: 2.0,
adMax: 255,
adMin: 0,
};
alert.success("测试数据生成成功", 2000);
};
return {
oscData,
config,
isApplying,
isCapturing,
isAutoApplying,
sampleCount,
samplePeriodNs,
applyConfiguration,
resetConfiguration,
clearOscilloscopeData,
getOscilloscopeData,
startCapture,
stopCapture,
toggleCapture,
updateTrigger,
updateSampling,
refreshRAM,
generateTestData,
};
},
);
export { useProvideOscilloscope, useOscilloscopeState, DEFAULT_CONFIG };

View File

@@ -1,36 +1,154 @@
<template>
<div class="w-full h-100 flex flex-col">
<!-- 原有内容 -->
<v-chart v-if="hasData" class="w-full h-full" :option="option" autoresize />
<div v-else class="w-full h-full flex flex-col gap-4 items-center justify-center text-gray-500">
<span> 暂无数据 </span>
<!-- 采集控制按钮 -->
<div class="flex justify-center items-center mb-2">
<div
class="waveform-container w-full h-full relative overflow-hidden rounded-lg"
>
<!-- 波形图表 -->
<v-chart
v-if="hasData"
class="w-full h-full transition-all duration-500 ease-in-out"
:option="option"
autoresize
/>
<!-- 无数据状态 -->
<div
v-else
class="w-full h-full flex flex-col items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 dark:from-slate-900 dark:to-slate-800"
>
<!-- 动画图标 -->
<div class="relative mb-6">
<div
class="w-24 h-24 rounded-full border-4 border-blue-200 dark:border-blue-800 animate-pulse"
></div>
<div class="absolute inset-0 flex items-center justify-center">
<Activity class="w-12 h-12 text-blue-500 animate-bounce" />
</div>
<!-- 扫描线效果 -->
<div
class="absolute inset-0 rounded-full border-2 border-transparent border-t-blue-500 animate-spin"
></div>
</div>
<!-- 状态文本 -->
<div class="text-center space-y-2 mb-8">
<h3 class="text-xl font-semibold text-slate-700 dark:text-slate-300">
等待信号输入
</h3>
<p class="text-slate-500 dark:text-slate-400">
请启动数据采集以显示波形
</p>
</div>
<!-- 快速启动按钮 -->
<div class="flex justify-center items-center">
<button
class="group relative px-8 py-3 bg-gradient-to-r text-white font-medium rounded-lg shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 active:scale-95"
class="group relative px-8 py-4 bg-gradient-to-r text-white font-semibold rounded-xl shadow-xl hover:shadow-2xl transform hover:scale-110 transition-all duration-300 ease-out focus:outline-none focus:ring-4 active:scale-95 overflow-hidden"
:class="{
'from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 focus:ring-blue-300':
'from-emerald-500 via-blue-500 to-purple-600 hover:from-emerald-600 hover:via-blue-600 hover:to-purple-700 focus:ring-blue-300':
!oscManager.isCapturing.value,
'from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 focus:ring-red-300':
'from-red-500 via-pink-500 to-red-600 hover:from-red-600 hover:via-pink-600 hover:to-red-700 focus:ring-red-300':
oscManager.isCapturing.value,
}" @click="
}"
@click="
oscManager.isCapturing.value
? oscManager.stopCapture()
: oscManager.startCapture()
">
<span class="flex items-center gap-2">
"
>
<!-- 背景动画效果 -->
<div
class="absolute inset-0 bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-300"
></div>
<!-- 按钮内容 -->
<span class="relative flex items-center gap-3">
<template v-if="oscManager.isCapturing.value">
<Square class="w-5 h-5" />
<Square class="w-6 h-6 animate-pulse" />
停止采集
</template>
<template v-else>
<Play class="w-5 h-5" />
<Play class="w-6 h-6 group-hover:animate-pulse" />
开始采集
</template>
</span>
<!-- 光晕效果 -->
<div
class="absolute inset-0 rounded-xl bg-gradient-to-r from-transparent via-white to-transparent opacity-0 group-hover:opacity-30 transform -skew-x-12 translate-x-full group-hover:translate-x-[-200%] transition-transform duration-700"
></div>
</button>
</div>
</div>
<!-- 数据采集状态指示器 -->
<div
v-if="hasData && oscManager.isCapturing.value"
class="absolute top-4 right-4 flex items-center gap-2 bg-red-500/90 text-white px-3 py-1 rounded-full text-sm font-medium backdrop-blur-sm"
>
<div class="w-2 h-2 bg-white rounded-full animate-pulse"></div>
采集中
</div>
<!-- 测量数据展示面板 -->
<div
v-if="hasData"
class="absolute top-4 left-4 bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-lg shadow-lg border border-slate-200/50 dark:border-slate-700/50 p-3 min-w-[200px]"
>
<h4 class="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 flex items-center gap-2">
<Activity class="w-4 h-4 text-blue-500" />
测量参数
</h4>
<div class="space-y-2 text-xs">
<!-- 采样频率 -->
<div class="flex justify-between items-center">
<span class="text-slate-600 dark:text-slate-400">采样频率:</span>
<span class="font-mono font-semibold text-blue-600 dark:text-blue-400">
{{ formatFrequency(oscData?.adFrequency || 0) }}
</span>
</div>
<!-- 电压范围 -->
<div class="flex justify-between items-center">
<span class="text-slate-600 dark:text-slate-400">Vpp:</span>
<span class="font-mono font-semibold text-emerald-600 dark:text-emerald-400">
{{ (oscData?.adVpp || 0).toFixed(2) }}V
</span>
</div>
<!-- 最大值 -->
<div class="flex justify-between items-center">
<span class="text-slate-600 dark:text-slate-400">最大值:</span>
<span class="font-mono font-semibold text-orange-600 dark:text-orange-400">
{{ formatAdcValue(oscData?.adMax || 0) }}
</span>
</div>
<!-- 最小值 -->
<div class="flex justify-between items-center">
<span class="text-slate-600 dark:text-slate-400">最小值:</span>
<span class="font-mono font-semibold text-purple-600 dark:text-purple-400">
{{ formatAdcValue(oscData?.adMin || 0) }}
</span>
</div>
<!-- 采样点数 -->
<div class="flex justify-between items-center pt-1 border-t border-slate-200 dark:border-slate-700">
<span class="text-slate-600 dark:text-slate-400">采样点:</span>
<span class="font-mono font-semibold text-slate-700 dark:text-slate-300">
{{ formatSampleCount(oscManager.sampleCount.value) }}
</span>
</div>
<!-- 采样周期 -->
<div class="flex justify-between items-center">
<span class="text-slate-600 dark:text-slate-400">周期:</span>
<span class="font-mono font-semibold text-slate-700 dark:text-slate-300">
{{ formatPeriod(oscManager.samplePeriodNs.value) }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -61,7 +179,7 @@ import type {
GridComponentOption,
} from "echarts/components";
import { useRequiredInjection } from "@/utils/Common";
import { Play, Square } from "lucide-vue-next";
import { Play, Square, Activity } from "lucide-vue-next";
use([
TooltipComponent,
@@ -99,6 +217,44 @@ const hasData = computed(() => {
);
});
// 格式化频率显示
const formatFrequency = (frequency: number): string => {
if (frequency >= 1_000_000) {
return `${(frequency / 1_000_000).toFixed(1)}MHz`;
} else if (frequency >= 1_000) {
return `${(frequency / 1_000).toFixed(1)}kHz`;
} else {
return `${frequency}Hz`;
}
};
// 格式化ADC值显示
const formatAdcValue = (value: number): string => {
return `${value} (${((value / 255) * 3.3).toFixed(2)}V)`;
};
// 格式化采样点数显示
const formatSampleCount = (count: number): string => {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
} else if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}k`;
} else {
return `${count}`;
}
};
// 格式化周期显示
const formatPeriod = (periodNs: number): string => {
if (periodNs >= 1_000_000) {
return `${(periodNs / 1_000_000).toFixed(2)}ms`;
} else if (periodNs >= 1_000) {
return `${(periodNs / 1_000).toFixed(2)}μs`;
} else {
return `${periodNs.toFixed(2)}ns`;
}
};
const option = computed((): EChartsOption => {
if (!oscData.value || !oscData.value.x || !oscData.value.y) {
return {};
@@ -113,12 +269,23 @@ const option = computed((): EChartsOption => {
? (oscData.value.y as number[][])
: [oscData.value.y as number[]];
// 预定义的通道颜色
const channelColors = [
"#3B82F6", // blue-500
"#EF4444", // red-500
"#10B981", // emerald-500
"#F59E0B", // amber-500
"#8B5CF6", // violet-500
"#06B6D4", // cyan-500
];
forEach(yChannels, (yData, index) => {
if (!oscData.value || !yData) return;
const seriesData = oscData.value.x.map((xValue, i) => [
xValue,
yData && yData[i] !== undefined ? yData[i] : 0,
]);
series.push({
type: "line",
name: `通道 ${index + 1}`,
@@ -126,41 +293,84 @@ const option = computed((): EChartsOption => {
smooth: false,
symbol: "none",
lineStyle: {
width: 2,
width: 2.5,
color: channelColors[index % channelColors.length],
shadowColor: channelColors[index % channelColors.length],
shadowBlur: isCapturing ? 0 : 4,
shadowOffsetY: 2,
},
// 关闭系列动画
itemStyle: {
color: channelColors[index % channelColors.length],
},
// 动画配置
animation: !isCapturing,
animationDuration: isCapturing ? 0 : 1000,
animationDuration: isCapturing ? 0 : 1200,
animationEasing: isCapturing ? "linear" : "cubicOut",
animationDelay: index * 100, // 错开动画时间
});
});
return {
backgroundColor: "transparent",
grid: {
left: "10%",
right: "10%",
top: "15%",
bottom: "25%",
left: "8%",
right: "5%",
top: "12%",
bottom: "20%",
borderWidth: 1,
borderColor: "#E2E8F0",
backgroundColor: "rgba(248, 250, 252, 0.8)",
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(255, 255, 255, 0.95)",
borderColor: "#E2E8F0",
borderWidth: 1,
textStyle: {
color: "#334155",
fontSize: 12,
},
formatter: (params: any) => {
if (!oscData.value) return "";
let result = `时间: ${params[0].data[0].toFixed(2)} ${oscData.value.xUnit}<br/>`;
let result = `<div style="font-weight: 600; margin-bottom: 4px;">时间: ${params[0].data[0].toFixed(2)} ${oscData.value.xUnit}</div>`;
params.forEach((param: any) => {
result += `${param.seriesName}: ${param.data[1].toFixed(3)} ${oscData.value?.yUnit ?? ""}<br/>`;
const adcValue = param.data[1];
const voltage = ((adcValue / 255) * 3.3).toFixed(3);
result += `<div style="color: ${param.color};">● ${param.seriesName}: ${adcValue} (${voltage}V)</div>`;
});
return result;
},
},
legend: {
top: "5%",
top: "2%",
left: "center",
textStyle: {
color: "#64748B",
fontSize: 12,
fontWeight: 500,
},
itemGap: 20,
data: series.map((s) => s.name) as string[],
},
toolbox: {
right: "2%",
top: "2%",
feature: {
restore: {},
saveAsImage: {},
restore: {
title: "重置缩放",
},
saveAsImage: {
title: "保存图片",
name: `oscilloscope_${new Date().toISOString().slice(0, 19)}`,
},
},
iconStyle: {
borderColor: "#64748B",
},
emphasis: {
iconStyle: {
borderColor: "#3B82F6",
},
},
},
dataZoom: [
@@ -168,47 +378,299 @@ const option = computed((): EChartsOption => {
type: "inside",
start: 0,
end: 100,
filterMode: "weakFilter",
},
{
start: 0,
end: 100,
height: 25,
bottom: "8%",
borderColor: "#E2E8F0",
fillerColor: "rgba(59, 130, 246, 0.1)",
handleStyle: {
color: "#3B82F6",
borderColor: "#1E40AF",
},
textStyle: {
color: "#64748B",
fontSize: 11,
},
},
],
xAxis: {
type: "value",
name: oscData.value ? `时间 (${oscData.value.xUnit})` : "时间",
nameLocation: "middle",
nameGap: 30,
nameGap: 35,
nameTextStyle: {
color: "#64748B",
fontSize: 12,
fontWeight: 500,
},
axisLine: {
show: true,
lineStyle: {
color: "#CBD5E1",
width: 1.5,
},
},
axisTick: {
show: true,
lineStyle: {
color: "#E2E8F0",
},
},
axisLabel: {
color: "#64748B",
fontSize: 11,
},
splitLine: {
show: false,
show: true,
lineStyle: {
color: "#F1F5F9",
type: "dashed",
},
},
},
yAxis: {
type: "value",
name: oscData.value ? `电压 (${oscData.value.yUnit})` : "电压",
name: oscData.value ? `ADC值 (0-255)` : "ADC值",
nameLocation: "middle",
nameGap: 40,
nameGap: 50,
nameTextStyle: {
color: "#64748B",
fontSize: 12,
fontWeight: 500,
},
axisLine: {
show: true,
lineStyle: {
color: "#CBD5E1",
width: 1.5,
},
},
axisTick: {
show: true,
lineStyle: {
color: "#E2E8F0",
},
},
axisLabel: {
color: "#64748B",
fontSize: 11,
formatter: (value: number) => {
return `${value} (${((value / 255) * 3.3).toFixed(1)}V)`;
},
},
splitLine: {
show: false,
show: true,
lineStyle: {
color: "#F1F5F9",
type: "dashed",
},
},
},
// 全局动画开关
animation: !isCapturing,
animationDuration: isCapturing ? 0 : 1000,
animationDuration: isCapturing ? 0 : 1200,
animationEasing: isCapturing ? "linear" : "cubicOut",
series: series,
};
});
</script>
<style scoped lang="postcss">
@import "@/assets/main.css";
/* 波形容器样式 */
.waveform-container {
background: linear-gradient(
135deg,
rgba(248, 250, 252, 0.8) 0%,
rgba(241, 245, 249, 0.8) 100%
);
border: 1px solid rgba(226, 232, 240, 0.5);
position: relative;
}
.waveform-container::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
45deg,
transparent 48%,
rgba(59, 130, 246, 0.05) 50%,
transparent 52%
);
pointer-events: none;
z-index: 1;
}
/* 无数据状态的背景动画 */
.waveform-container:not(:has(canvas)) {
background: linear-gradient(
135deg,
rgba(248, 250, 252, 1) 0%,
rgba(239, 246, 255, 1) 25%,
rgba(219, 234, 254, 1) 50%,
rgba(239, 246, 255, 1) 75%,
rgba(248, 250, 252, 1) 100%
);
background-size: 200% 200%;
animation: gradient-shift 8s ease-in-out infinite;
}
@keyframes gradient-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.waveform-container {
background: linear-gradient(
135deg,
rgba(15, 23, 42, 0.8) 0%,
rgba(30, 41, 59, 0.8) 100%
);
border-color: rgba(71, 85, 105, 0.5);
}
.waveform-container:not(:has(canvas)) {
background: linear-gradient(
135deg,
rgba(15, 23, 42, 1) 0%,
rgba(30, 41, 59, 1) 25%,
rgba(51, 65, 85, 1) 50%,
rgba(30, 41, 59, 1) 75%,
rgba(15, 23, 42, 1) 100%
);
}
}
/* 按钮光晕效果增强 */
button {
position: relative;
overflow: hidden;
}
button::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition:
width 0.6s,
height 0.6s;
}
button:active::after {
width: 300px;
height: 300px;
}
/* 扫描线动画优化 */
@keyframes scan-line {
0% {
transform: rotate(0deg) scale(1);
opacity: 1;
}
50% {
transform: rotate(180deg) scale(1.1);
opacity: 0.7;
}
100% {
transform: rotate(360deg) scale(1);
opacity: 1;
}
}
.animate-spin {
animation: scan-line 3s linear infinite;
}
/* 状态指示器增强 */
.absolute.top-4.right-4 {
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.25);
animation: float 2s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-2px);
}
}
/* 图表容器增强 */
.w-full.h-full.transition-all {
border-radius: 8px;
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* 响应式调整 */
@media (max-width: 768px) {
.waveform-container {
min-height: 300px;
}
button {
padding: 12px 20px;
font-size: 14px;
}
.absolute.top-4.right-4 {
top: 8px;
right: 8px;
font-size: 12px;
padding: 4px 8px;
}
/* 移动端测量面板调整 */
.absolute.top-4.left-4 {
top: 8px;
left: 8px;
min-width: 180px;
font-size: 11px;
}
}
/* 平滑过渡效果 */
* {
transition: all 0.2s ease-in-out;
}
/* 焦点样式 */
button:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.5);
outline-offset: 2px;
}
/* 测量面板样式增强 */
.absolute.top-4.left-4 {
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease-in-out;
z-index: 10;
}
.absolute.top-4.left-4:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
</style>

View File

@@ -1,11 +0,0 @@
<template>
<button class="btn transition-transform duration-150 ease-in-out hover:scale-120">
Button A
</button>
</template>
<script lang="ts" setup></script>
<style scoped lang="postcss">
@import "@/assets/main.css";
</style>

View File

@@ -1,162 +0,0 @@
<template>
<div
class="card card-dash shadow-xl h-screen"
:class="[sidebar.isClose ? 'w-31' : 'w-80']"
>
<div
class="card-body flex relative transition-all duration-500 ease-in-out"
>
<!-- Avatar and Name -->
<div class="relative" :class="sidebar.isClose ? 'h-50' : 'h-20'">
<!-- Img -->
<div
class="avatar h-10 fixed top-10"
:class="sidebar.isClose ? 'left-10' : 'left-7'"
>
<div class="rounded-full">
<img src="../assets/user.svg" alt="User" />
</div>
</div>
<!-- Text -->
<Transition>
<div v-if="!sidebar.isClose" class="mx-5 grow fixed left-20 top-11">
<label class="text-2xl">用户名</label>
</div>
</Transition>
</div>
<!-- Toggle Button -->
<button
class="btn btn-square rounded-lg p-2 m-3 fixed"
:class="sidebar.isClose ? 'left-7 top-23' : 'left-60 top-7'"
@click="sidebar.toggleSidebar"
>
<svg
t="1741694970690"
:class="sidebar.isClose ? 'rotate-0' : 'rotate-540'"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4546"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="200"
height="200"
>
<path
d="M803.758 514.017c-0.001-0.311-0.013-0.622-0.018-0.933-0.162-23.974-9.386-47.811-27.743-65.903-0.084-0.082-0.172-0.157-0.256-0.239-0.154-0.154-0.296-0.315-0.451-0.468L417.861 94.096c-37.685-37.153-99.034-37.476-136.331-0.718-37.297 36.758-36.979 97.231 0.707 134.384l290.361 286.257-290.362 286.257c-37.685 37.153-38.004 97.625-0.707 134.383 37.297 36.758 98.646 36.435 136.331-0.718l357.43-352.378c0.155-0.153 0.297-0.314 0.451-0.468 0.084-0.082 0.172-0.157 0.256-0.239 18.354-18.089 27.578-41.922 27.743-65.892 0.004-0.315 0.017-0.631 0.018-0.947z"
:fill="theme.isLightTheme() ? '#828282' : '#C0C3C8'"
p-id="4547"
></path>
</svg>
</button>
<div class="divider"></div>
<ul class="menu h-full w-full">
<li v-for="item in props.items" class="text-xl my-1">
<a @click="router.push(item.page)">
<svg
t="1741694797806"
class="icon h-[1.5em] w-[1.5em] opacity-50 mx-1"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="2622"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="200"
height="200"
>
<path
d="M192 192m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z"
p-id="2623"
></path>
<path
d="M192 512m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z"
p-id="2624"
></path>
<path
d="M192 832m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z"
p-id="2625"
></path>
<path
d="M864 160H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 480H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 800H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32z"
p-id="2626"
></path>
</svg>
<Transition>
<p class="break-keep" v-if="!sidebar.isClose">{{ item.text }}</p>
</Transition>
</a>
</li>
</ul>
<div class="divider"></div>
<ul class="menu w-full">
<li>
<a @click="theme.toggleTheme" class="text-xl">
<ThemeControlButton />
<Transition>
<p v-if="!sidebar.isClose" class="break-keep">改变主题</p>
</Transition>
<Transition>
<ThemeControlToggle v-if="!sidebar.isClose" />
</Transition>
</a>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import iconMenu from "../assets/menu.svg";
import "../router";
import { useThemeStore } from "@/stores/theme";
import { useSidebarStore } from "@/stores/sidebar";
import ThemeControlButton from "./ThemeControlButton.vue";
import ThemeControlToggle from "./ThemeControlToggle.vue";
import router from "../router";
const theme = useThemeStore();
const sidebar = useSidebarStore();
type Item = {
id: number;
icon: string;
text: string;
page: string;
};
interface Props {
items?: Array<Item>;
}
const props = withDefaults(defineProps<Props>(), {
items: () => [
{ id: 1, icon: iconMenu, text: "btn1", page: "/" },
{ id: 2, icon: iconMenu, text: "btn2", page: "/" },
{ id: 3, icon: iconMenu, text: "btn3", page: "/" },
{ id: 4, icon: iconMenu, text: "btn4", page: "/" },
],
});
</script>
<style scoped lang="postcss">
@reference "../assets/main.css";
* {
@apply transition-all duration-500 ease-in-out;
}
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,14 +1,15 @@
<template>
<div
<div
class="tutorial-carousel relative"
@wheel.prevent="handleWheel"
@mouseenter="pauseAutoRotation"
@mouseleave="resumeAutoRotation"
> <!-- 例程卡片堆叠 -->
>
<!-- 例程卡片堆叠 -->
<div class="card-stack relative mx-auto">
<div
v-for="(tutorial, index) in tutorials"
:key="index"
<div
v-for="(tutorial, index) in tutorials"
:key="index"
class="tutorial-card absolute transition-all duration-500 ease-in-out rounded-2xl shadow-2xl border-4 border-base-300 overflow-hidden"
:class="getCardClass(index)"
:style="getCardStyle(index)"
@@ -16,32 +17,57 @@
>
<!-- 卡片内容 -->
<div class="relative">
<!-- 图片 --> <img
:src="tutorial.thumbnail || `https://placehold.co/600x400?text=${tutorial.title}`"
<!-- 图片 -->
<img
:src="
tutorial.thumbnail ||
`https://kaifage.com/api/placeholder/600/400?text=${tutorial.title}&color=000000&bgColor=ffffff&fontSize=72`
"
class="w-full object-contain"
:alt="tutorial.title"
style="width: 600px; height: 400px;"
style="width: 600px; height: 400px"
/>
<!-- 卡片蒙层 -->
<div
<div
class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300"
:class="{'opacity-10': index === currentIndex}"
:class="{ 'opacity-10': index === currentIndex }"
></div>
<!-- 标题覆盖层 -->
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent">
<h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3>
<p class="text-sm opacity-80 truncate">{{ tutorial.description }}</p>
<div
class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent"
>
<div class="flex flex-col gap-2">
<h3 class="text-lg font-bold text-base-content">
{{ tutorial.title }}
</h3>
<p class="text-sm opacity-80 truncate">
{{ tutorial.description }}
</p>
<!-- 标签显示 -->
<div
v-if="tutorial.tags && tutorial.tags.length > 0"
class="flex flex-wrap gap-1"
>
<span
v-for="tag in tutorial.tags.slice(0, 3)"
:key="tag"
class="badge badge-outline badge-xs text-xs"
>
{{ tag }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 导航指示器 -->
<div class="indicators flex justify-center gap-2 mt-4">
<button
v-for="(_, index) in tutorials"
<button
v-for="(_, index) in tutorials"
:key="index"
@click="setActiveCard(index)"
class="w-3 h-3 rounded-full transition-all duration-300"
@@ -52,8 +78,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { ref, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { AuthManager } from "@/utils/AuthManager";
import {
ExamClient,
ResourceClient,
ResourcePurpose,
type ExamInfo,
} from "@/APIClient";
// 接口定义
interface Tutorial {
@@ -61,7 +94,7 @@ interface Tutorial {
title: string;
description: string;
thumbnail?: string;
docPath: string;
tags: string[];
}
// Props
@@ -81,87 +114,101 @@ let autoRotationTimer: number | null = null;
// 处理卡片点击
const handleCardClick = (index: number, tutorialId: string) => {
if (index === currentIndex.value) {
goToTutorial(tutorialId);
goToExam(tutorialId);
} else {
setActiveCard(index);
}
};
// 从 public/doc 目录加载例程信息
// 从数据库加载实验数据
onMounted(async () => {
try {
// 尝试从API获取教程目录
let tutorialIds: string[] = [];
try {
const response = await fetch('/api/tutorial');
if (response.ok) {
const data = await response.json();
tutorialIds = data.tutorials || [];
}
} catch (error) {
console.warn('无法从API获取教程目录使用默认值:', error);
console.log("正在从数据库加载实验数据...");
// 创建认证客户端
const client = AuthManager.createClient(ExamClient);
// 获取实验列表
const examList: ExamInfo[] = await client.getExamList();
// 筛选可见的实验并转换为Tutorial格式
const visibleExams = examList
.filter((exam) => exam.isVisibleToUsers)
.slice(0, 6); // 限制轮播显示最多6个实验
if (visibleExams.length === 0) {
console.warn("没有找到可见的实验");
return;
}
// 如果API调用失败或返回空列表使用默认值
if (tutorialIds.length === 0) {
console.log('使用默认教程列表');
tutorialIds = ['01', '02', '03', '04', '05', '06', '11', '12', '13']; // 默认例程
} else {
console.log('使用API获取的教程列表:', tutorialIds);
}
// 为每个例程创建对象并尝试获取文档标题
const tutorialPromises = tutorialIds.map(async (id) => {
// 尝试读取doc.md获取标题
let title = `例程 ${id}`;
let description = "点击加载此例程";
let thumbnail = `/doc/${id}/cover.png`; // 默认使用第一张图片作为缩略图
// 转换数据格式并获取封面图片
const tutorialPromises = visibleExams.map(async (exam) => {
let thumbnail: string | undefined;
try {
// 尝试读取文档内容获取标题
const response = await fetch(`/doc/${id}/doc.md`);
if (response.ok) {
const text = await response.text();
// 从Markdown提取标题
const titleMatch = text.match(/^#\s+(.+)$/m);
if (titleMatch && titleMatch[1]) {
title = titleMatch[1].trim();
}
// 提取第一段作为描述
const descMatch = text.match(/\n\n([^#\n][^\n]+)/);
if (descMatch && descMatch[1]) {
description = descMatch[1].substring(0, 100).trim();
if (description.length === 100) description += '...';
}
// 获取实验的封面资源(模板资源)
const resourceClient = AuthManager.createClient(ResourceClient);
const resourceList = await resourceClient.getResourceList(
exam.id,
"cover",
ResourcePurpose.Template,
);
if (resourceList && resourceList.length > 0) {
// 使用第一个封面资源
const coverResource = resourceList[0];
const fileResponse = await resourceClient.getResourceById(
coverResource.id,
);
// 创建Blob URL作为缩略图
thumbnail = URL.createObjectURL(fileResponse.data);
}
} catch (error) {
console.warn(`无法读取例程${id}文档内容:`, error);
console.warn(`无法获取实验${exam.id}封面图片:`, error);
}
return {
id,
title,
description,
id: exam.id,
title: exam.name,
description: "点击查看实验详情",
thumbnail,
docPath: `/doc/${id}/doc.md`
tags: exam.tags || [],
};
});
tutorials.value = await Promise.all(tutorialPromises);
console.log("成功加载实验数据:", tutorials.value.length, "个实验");
// 启动自动旋转
startAutoRotation();
} catch (error) {
console.error('加载例程失败:', error);
console.error("加载实验数据失败:", error);
// 如果加载失败,显示默认的占位内容
tutorials.value = [
{
id: "placeholder",
title: "实验数据加载中...",
description: "请稍后或刷新页面重试",
thumbnail: undefined,
tags: [],
},
];
}
});
// 在组件销毁时清除计时器
// 在组件销毁时清除计时器和Blob URLs
onUnmounted(() => {
if (autoRotationTimer) {
clearInterval(autoRotationTimer);
}
// 清理创建的Blob URLs
tutorials.value.forEach((tutorial) => {
if (tutorial.thumbnail && tutorial.thumbnail.startsWith("blob:")) {
URL.revokeObjectURL(tutorial.thumbnail);
}
});
});
// 鼠标滚轮处理
@@ -180,7 +227,8 @@ const nextCard = () => {
// 上一张卡片
const prevCard = () => {
currentIndex.value = (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
currentIndex.value =
(currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
};
// 设置活动卡片
@@ -210,69 +258,77 @@ const resumeAutoRotation = () => {
}
};
// 前往例程
const goToTutorial = (tutorialId: string) => {
// 跳转到工程页面,并通过 query 参数传递文档路径
// 前往实验
const goToExam = (examId: string) => {
// 跳转到实验列表页面并传递examId参数页面将自动打开对应的实验详情模态框
router.push({
path: '/project',
query: { tutorial: tutorialId }
path: "/exam",
query: { examId: examId },
});
};
// 计算卡片类和样式
const getCardClass = (index: number) => {
const isActive = index === currentIndex.value;
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
const isPrev =
index === currentIndex.value - 1 ||
(currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext =
index === currentIndex.value + 1 ||
(currentIndex.value === tutorials.value.length - 1 && index === 0);
return {
'z-30': isActive,
'z-20': isPrev || isNext,
'z-10': !isActive && !isPrev && !isNext,
'hover:scale-105': isActive,
'cursor-pointer': true
"z-30": isActive,
"z-20": isPrev || isNext,
"z-10": !isActive && !isPrev && !isNext,
"hover:scale-105": isActive,
"cursor-pointer": true,
};
};
const getCardStyle = (index: number) => {
const isActive = index === currentIndex.value;
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
const isPrev =
index === currentIndex.value - 1 ||
(currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext =
index === currentIndex.value + 1 ||
(currentIndex.value === tutorials.value.length - 1 && index === 0);
// 基本样式
let style = {
transform: 'scale(1) translateY(0) rotate(0deg)',
opacity: '1',
filter: 'blur(0)'
transform: "scale(1) translateY(0) rotate(0deg)",
opacity: "1",
filter: "blur(0)",
};
// 活动卡片
if (isActive) {
return style;
}
// 上一张卡片
if (isPrev) {
style.transform = 'scale(0.85) translateY(-10%) rotate(-5deg)';
style.opacity = '0.7';
style.filter = 'blur(1px)';
style.transform = "scale(0.85) translateY(-10%) rotate(-5deg)";
style.opacity = "0.7";
style.filter = "blur(1px)";
return style;
}
// 下一张卡片
if (isNext) {
style.transform = 'scale(0.85) translateY(10%) rotate(5deg)';
style.opacity = '0.7';
style.filter = 'blur(1px)';
style.transform = "scale(0.85) translateY(10%) rotate(5deg)";
style.opacity = "0.7";
style.filter = "blur(1px)";
return style;
}
// 其他卡片
style.transform = 'scale(0.7) translateY(0) rotate(0deg)';
style.opacity = '0.4';
style.filter = 'blur(2px)';
style.transform = "scale(0.7) translateY(0) rotate(0deg)";
style.opacity = "0.4";
style.filter = "blur(2px)";
return style;
}
};
</script>
<style scoped>

View File

@@ -1,135 +1,286 @@
<template>
<div class="flex flex-col bg-base-100 justify-center items-center">
<!-- Title -->
<h1 class="font-bold text-2xl">上传比特流文件</h1>
<!-- Input File -->
<fieldset class="fieldset w-full">
<legend class="fieldset-legend text-sm">选择或拖拽上传文件</legend>
<input type="file" ref="fileInput" class="file-input w-full" @change="handleFileChange" />
<label class="fieldset-label">文件最大容量: {{ maxMemory }}MB</label>
</fieldset>
<!-- Upload Button -->
<div class="card-actions w-full">
<button @click="handleClick" class="btn btn-primary grow" :disabled="isUploading">
<div v-if="isUploading">
<span class="loading loading-spinner"></span>
下载中...
</div>
<div v-else>
{{ buttonText }}
</div>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, useTemplateRef, onMounted } from "vue";
import { useDialogStore } from "@/stores/dialog";
import { isNull, isUndefined } from "lodash";
interface Props {
uploadEvent?: (file: File) => Promise<boolean>;
downloadEvent?: () => Promise<boolean>;
maxMemory?: number;
}
const props = withDefaults(defineProps<Props>(), {
maxMemory: 4,
});
const emits = defineEmits<{
finishedUpload: [file: File];
}>();
const dialog = useDialogStore();
const isUploading = ref(false);
const buttonText = computed(() => {
return isUndefined(props.downloadEvent) ? "上传" : "上传并下载";
});
const fileInput = useTemplateRef("fileInput");
const bitstream = defineModel("bitstreamFile", {
type: File,
default: undefined,
});
onMounted(() => {
if (!isUndefined(bitstream.value) && !isNull(fileInput.value)) {
let fileList = new DataTransfer();
fileList.items.add(bitstream.value);
fileInput.value.files = fileList.files;
}
});
function handleFileChange(event: Event): void {
const target = event.target as HTMLInputElement;
const file = target.files?.[0]; // 获取选中的第一个文件
if (!file) {
return;
}
bitstream.value = file;
}
function checkFile(file: File): boolean {
const maxBytes = props.maxMemory! * 1024 * 1024; // 将最大容量从 MB 转换为字节
if (file.size > maxBytes) {
dialog.error(`文件大小超过最大限制: ${props.maxMemory}MB`);
return false;
}
return true;
}
async function handleClick(event: Event): Promise<void> {
if (isNull(bitstream.value) || isUndefined(bitstream.value)) {
dialog.error(`未选择文件`);
return;
}
if (!checkFile(bitstream.value)) return;
if (isUndefined(props.uploadEvent)) {
dialog.error("无法上传");
return;
}
isUploading.value = true;
try {
const ret = await props.uploadEvent(bitstream.value);
if (isUndefined(props.downloadEvent)) {
if (ret) {
dialog.info("上传成功");
emits("finishedUpload", bitstream.value);
} else dialog.error("上传失败");
return;
}
if (!ret) {
isUploading.value = false;
return;
}
} catch (e) {
dialog.error("上传失败");
console.error(e);
return;
}
// Download
try {
const ret = await props.downloadEvent();
if (ret) dialog.info("下载成功");
else dialog.error("下载失败");
} catch (e) {
dialog.error("下载失败");
console.error(e);
}
isUploading.value = false;
}
</script>
<style scoped lang="postcss">
@import "../assets/main.css";
</style>
<template>
<div class="flex flex-col bg-base-100 justify-center items-center gap-4">
<!-- Title -->
<h1 class="font-bold text-2xl">比特流文件</h1>
<!-- 示例比特流下载区域 (仅在有examId时显示) -->
<div v-if="examId && availableBitstreams.length > 0" class="w-full">
<fieldset class="fieldset w-full">
<legend class="fieldset-legend text-sm">示例比特流文件</legend>
<div class="space-y-2">
<div
v-for="bitstream in availableBitstreams"
:key="bitstream.id"
class="flex items-center justify-between p-2 border-2 border-base-300 rounded-lg"
>
<span class="text-sm">{{ bitstream.name }}</span>
<div class="flex gap-2">
<button
@click="handleExampleBitstream('download', bitstream)"
class="btn btn-sm btn-secondary"
:disabled="currentTask !== 'none'"
>
<div
v-if="
currentTask === 'downloading' &&
currentBitstreamId === bitstream.id
"
>
<span class="loading loading-spinner loading-xs"></span>
下载中...
</div>
<div v-else>下载示例</div>
</button>
<button
@click="handleExampleBitstream('program', bitstream)"
class="btn btn-sm btn-primary"
:disabled="currentTask !== 'none'"
>
<div
v-if="
currentTask === 'programming' &&
currentBitstreamId === bitstream.id
"
>
<span class="loading loading-spinner loading-xs"></span>
烧录中...
</div>
<div v-else>直接烧录</div>
</button>
</div>
</div>
</div>
</fieldset>
</div>
<!-- 分割线 -->
<div v-if="examId && availableBitstreams.length > 0" class="divider">
</div>
<!-- Input File -->
<fieldset class="fieldset w-full">
<legend class="fieldset-legend text-sm">上传自定义比特流文件</legend>
<input
type="file"
ref="fileInput"
class="file-input w-full"
@change="handleFileChange"
/>
<label class="fieldset-label">文件最大容量: {{ maxMemory }}MB</label>
</fieldset>
<!-- Upload Button -->
<div class="card-actions w-full">
<button
@click="handleUploadAndDownload"
class="btn btn-primary grow"
:disabled="currentTask !== 'none'"
>
<div v-if="currentTask === 'uploading'">
<span class="loading loading-spinner"></span>
上传中...
</div>
<div v-else-if="currentTask === 'programming'">
<span class="loading loading-spinner"></span>
{{ currentProgressPercent }}% ...
</div>
<div v-else>上传并下载</div>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, onMounted } from "vue";
import { AuthManager } from "@/utils/AuthManager";
import { useDialogStore } from "@/stores/dialog";
import { useEquipments } from "@/stores/equipments";
import { useRequiredInjection } from "@/utils/Common";
import { useAlertStore } from "./Alert";
import { ResourceClient, ResourcePurpose } from "@/APIClient";
import { useProgressStore } from "@/stores/progress";
import { ProgressStatus, type ProgressInfo } from "@/utils/signalR/server.Hubs";
interface Props {
maxMemory?: number;
examId?: string;
}
const props = withDefaults(defineProps<Props>(), {
maxMemory: 4,
examId: "",
});
const emits = defineEmits<{
finishedUpload: [file: File];
}>();
const alert = useRequiredInjection(useAlertStore);
const progressTracker = useProgressStore();
const dialog = useDialogStore();
const eqps = useEquipments();
const availableBitstreams = ref<{ id: string; name: string }[]>([]);
const fileInput = useTemplateRef("fileInput");
const bitstream = ref<File | undefined>(undefined);
// 用一个状态变量替代多个
const currentTask = ref<"none" | "uploading" | "downloading" | "programming">(
"none",
);
const currentBitstreamId = ref<string>("");
const currentProgressId = ref<string>("");
const currentProgressPercent = ref<number>(0);
onMounted(async () => {
if (bitstream.value && fileInput.value) {
let fileList = new DataTransfer();
fileList.items.add(bitstream.value);
fileInput.value.files = fileList.files;
}
await loadAvailableBitstreams();
});
function handleFileChange(event: Event): void {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
bitstream.value = file || undefined;
}
function checkFileInput(): boolean {
if (!bitstream.value) {
dialog.error(`未选择文件`);
return false;
}
const maxBytes = props.maxMemory! * 1024 * 1024;
if (bitstream.value.size > maxBytes) {
dialog.error(`文件大小超过最大限制: ${props.maxMemory}MB`);
return false;
}
return true;
}
async function downloadBitstream() {
currentTask.value = "programming";
try {
currentProgressId.value = await eqps.jtagDownloadBitstream(
currentBitstreamId.value,
);
progressTracker.register(
currentProgressId.value,
"programBitstream",
handleProgressUpdate,
);
} catch {
dialog.error("比特流烧录失败");
cleanProgressTracker();
}
}
function cleanProgressTracker() {
currentTask.value = "none";
currentProgressId.value = "";
currentBitstreamId.value = "";
currentProgressPercent.value = 0;
progressTracker.unregister(currentProgressId.value, "programBitstream");
}
async function loadAvailableBitstreams() {
if (!props.examId) {
availableBitstreams.value = [];
return;
}
try {
const resourceClient = AuthManager.createClient(ResourceClient);
const resources = await resourceClient.getResourceList(
props.examId,
"bitstream",
ResourcePurpose.Template,
);
availableBitstreams.value =
resources.map((r) => ({ id: r.id, name: r.name })) || [];
} catch (error) {
availableBitstreams.value = [];
}
}
// 统一处理示例比特流的下载/烧录
async function handleExampleBitstream(
action: "download" | "program",
bitstreamObj: { id: string; name: string },
) {
if (currentTask.value !== "none") return;
currentBitstreamId.value = bitstreamObj.id;
if (action === "download") {
currentTask.value = "downloading";
try {
const resourceClient = AuthManager.createClient(ResourceClient);
const response = await resourceClient.getResourceById(bitstreamObj.id);
if (response && response.data) {
const url = URL.createObjectURL(response.data);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || bitstreamObj.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
alert.info("示例比特流下载成功");
} else {
alert.error("下载失败:响应数据为空");
}
} catch {
alert.error("下载示例比特流失败");
} finally {
currentTask.value = "none";
currentBitstreamId.value = "";
}
} else if (action === "program") {
currentBitstreamId.value = bitstreamObj.id;
await downloadBitstream();
}
}
// 上传并下载
async function handleUploadAndDownload() {
if (currentTask.value !== "none") return;
if (!checkFileInput()) return;
currentTask.value = "uploading";
let uploadedBitstreamId: string | null = null;
try {
uploadedBitstreamId = await eqps.jtagUploadBitstream(
bitstream.value!,
props.examId || "",
);
if (!uploadedBitstreamId) throw new Error("上传失败");
emits("finishedUpload", bitstream.value!);
} catch {
dialog.error("上传失败");
currentTask.value = "none";
return;
}
currentBitstreamId.value = uploadedBitstreamId;
await downloadBitstream();
}
function handleProgressUpdate(msg: ProgressInfo) {
// console.log(msg);
if (msg.status === ProgressStatus.Running)
currentProgressPercent.value = msg.progressPercent;
else if (msg.status === ProgressStatus.Failed) {
dialog.error(`比特流烧录失败: ${msg.errorMessage}`);
cleanProgressTracker();
} else if (msg.status === ProgressStatus.Completed) {
dialog.info("比特流烧录成功");
cleanProgressTracker();
}
}
</script>
<style scoped lang="postcss">
@import "../assets/main.css";
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { useRequiredInjection } from "@/utils/Common";
import { templateRef } from "@vueuse/core";
import { File, UploadIcon, XIcon } from "lucide-vue-next";
import { isNull } from "mathjs";
import { useSlots } from "vue";
import { useAlertStore } from "./Alert";
const alert = useRequiredInjection(useAlertStore);
const slots = useSlots();
interface Props {
autoUpload?: boolean;
closeAfterUpload?: boolean;
callback: (files: File[]) => void;
}
const props = withDefaults(defineProps<Props>(), {
autoUpload: false,
closeAfterUpload: false,
});
const emits = defineEmits<{
finishedUpload: [];
}>();
const inputFiles = defineModel<File[] | null>("inputFiles", { default: null });
const isShowModal = defineModel<boolean>("isShowModal", { default: false });
const fileInputRef = templateRef("fileInputRef");
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (!files) return;
inputFiles.value = Array.from(files);
if (props.autoUpload) handleUpload();
}
function handleFileDrop(event: DragEvent) {
const files = event.dataTransfer?.files;
if (!files) return;
inputFiles.value = Array.from(files);
if (props.autoUpload) handleUpload();
}
function handleUpload() {
if (!inputFiles.value) return;
props.callback(inputFiles.value);
if (props.closeAfterUpload) close();
alert.info("上传成功");
emits("finishedUpload");
}
function show() {
isShowModal.value = true;
}
function close() {
isShowModal.value = false;
}
defineExpose({
show,
close,
});
</script>
<template>
<div v-if="isShowModal" class="modal modal-open overflow-hidden">
<div class="modal-box overflow-hidden flex flex-col gap-3">
<div
class="flex justify-between items-center pb-3 border-b border-base-300"
>
<h2 class="text-2xl font-bold text-base-content">文件上传</h2>
<button @click="close" class="btn btn-sm btn-circle btn-ghost">
<XIcon class="w-6 h-6" />
</button>
</div>
<div
class="border-2 border-dashed border-base-300 rounded-lg text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-4/2 flex items-center justify-center"
@click="fileInputRef.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
>
<div v-if="slots.content">
<slot name="content"></slot>
</div>
<div v-else class="flex flex-col items-center gap-3">
<File class="w-12 h-12 text-base-content opacity-40" />
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<File class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success text-center">
{{ inputFiles?.[0]?.name }}
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="fileInputRef"
@change="handleFileChange"
accept=""
class="hidden"
/>
<button
v-if="!autoUpload"
class="btn btn-primary btn-sm w-full h-10"
@click="handleUpload"
:disabled="isNull(inputFiles) || inputFiles.length === 0"
>
<UploadIcon class="w-6 h-6" />
上传
</button>
</div>
<div class="modal-backdrop" @click="close"></div>
</div>
</template>
<style lang="postcss" scoped></style>

View File

@@ -212,6 +212,7 @@ import { useEquipments } from "@/stores/equipments";
import { useDialogStore } from "@/stores/dialog";
import { toInteger } from "lodash";
import { AuthManager } from "@/utils/AuthManager";
import { DDSClient } from "@/APIClient";
// Component Attributes
const props = defineProps<{
@@ -221,7 +222,7 @@ const props = defineProps<{
const emit = defineEmits(["update:modelValue"]);
// Global varibles
const dds = AuthManager.createAuthenticatedDDSClient();
const dds = AuthManager.createClient(DDSClient);
const eqps = useEquipments();
const dialog = useDialogStore();

View File

@@ -0,0 +1,318 @@
<template>
<div
class="inline-block select-none"
:style="{
width: width + 'px',
height: height + 'px',
position: 'relative',
}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
viewBox="0 0 100 100"
class="ec11-encoder"
>
<defs>
<!-- 发光效果滤镜 -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feFlood
result="flood"
flood-color="#00ff88"
flood-opacity="1"
></feFlood>
<feComposite
in="flood"
result="mask"
in2="SourceGraphic"
operator="in"
></feComposite>
<feMorphology
in="mask"
result="dilated"
operator="dilate"
radius="1"
></feMorphology>
<feGaussianBlur in="dilated" stdDeviation="2" result="blur1" />
<feGaussianBlur in="dilated" stdDeviation="4" result="blur2" />
<feGaussianBlur in="dilated" stdDeviation="8" result="blur3" />
<feMerge>
<feMergeNode in="blur3" />
<feMergeNode in="blur2" />
<feMergeNode in="blur1" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- 编码器主体渐变 -->
<radialGradient id="encoderGradient" cx="50%" cy="30%">
<stop offset="0%" stop-color="#666666" />
<stop offset="70%" stop-color="#333333" />
<stop offset="100%" stop-color="#1a1a1a" />
</radialGradient>
<!-- 旋钮渐变 -->
<radialGradient id="knobGradient" cx="30%" cy="30%">
<stop offset="0%" stop-color="#555555" />
<stop offset="70%" stop-color="#222222" />
<stop offset="100%" stop-color="#111111" />
</radialGradient>
<!-- 按下状态渐变 -->
<radialGradient id="knobPressedGradient" cx="50%" cy="50%">
<stop offset="0%" stop-color="#333333" />
<stop offset="70%" stop-color="#555555" />
<stop offset="100%" stop-color="#888888" />
</radialGradient>
</defs>
<!-- 编码器底座 -->
<rect
x="10"
y="30"
width="80"
height="60"
rx="8"
ry="8"
fill="#2a2a2a"
stroke="#444444"
stroke-width="1"
/>
<!-- 编码器主体外壳 -->
<circle
cx="50"
cy="60"
r="32"
fill="url(#encoderGradient)"
stroke="#555555"
stroke-width="1"
/>
<!-- 编码器接线端子 -->
<rect x="5" y="75" width="4" height="8" fill="#c9c9c9" rx="1" />
<rect x="15" y="85" width="4" height="8" fill="#c9c9c9" rx="1" />
<rect x="25" y="85" width="4" height="8" fill="#c9c9c9" rx="1" />
<rect x="81" y="85" width="4" height="8" fill="#c9c9c9" rx="1" />
<rect x="91" y="75" width="4" height="8" fill="#c9c9c9" rx="1" />
<!-- 旋钮 -->
<circle
cx="50"
cy="60"
r="22"
:fill="isPressed ? 'url(#knobPressedGradient)' : 'url(#knobGradient)'"
stroke="#666666"
stroke-width="1"
:transform="`rotate(${rotationStep * 7.5} 50 60)`"
class="interactive"
@mousedown="handleMouseDown"
@mouseup="handlePress(false)"
@mouseleave="handlePress(false)"
/>
<!-- 旋钮指示器 -->
<line
x1="50"
y1="42"
x2="50"
y2="48"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
:transform="`rotate(${rotationStep * 15} 50 60)`"
/>
<!-- 旋钮上的纹理刻度 -->
<g :transform="`rotate(${rotationStep * 15} 50 60)`">
<circle
cx="50"
cy="60"
r="18"
fill="none"
stroke="#777777"
stroke-width="0.5"
/>
<!-- 刻度线 -->
<g v-for="i in 16" :key="i">
<line
:x1="50 + 16 * Math.cos(((i - 1) * Math.PI) / 8)"
:y1="60 + 16 * Math.sin(((i - 1) * Math.PI) / 8)"
:x2="50 + 18 * Math.cos(((i - 1) * Math.PI) / 8)"
:y2="60 + 18 * Math.sin(((i - 1) * Math.PI) / 8)"
stroke="#999999"
stroke-width="0.5"
/>
</g>
</g>
<!-- 编码器编号标签 -->
<text
x="50"
y="15"
text-anchor="middle"
font-family="Arial"
font-size="10"
fill="#cccccc"
font-weight="bold"
>
EC11-{{ encoderNumber }}
</text>
<!-- 状态指示器 -->
<circle
cx="85"
cy="20"
r="3"
:fill="isPressed ? '#ff4444' : '#444444'"
:filter="isPressed ? 'url(#glow)' : ''"
stroke="#666666"
stroke-width="0.5"
/>
</svg>
</div>
</template>
<script lang="ts" setup>
import { useRotaryEncoder } from "@/stores/Peripherals/RotaryEncoder";
import {
RotaryEncoderDirection,
RotaryEncoderPressStatus,
} from "@/utils/signalR/Peripherals.RotaryEncoderClient";
import { watch } from "vue";
import { watchEffect } from "vue";
import { ref, computed } from "vue";
const rotataryEncoderStore = useRotaryEncoder();
interface Props {
size?: number;
componentId?: string;
enableDigitalTwin?: boolean;
encoderNumber?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1,
enableDigitalTwin: false,
encoderNumber: 1,
});
// 组件状态
const isPressed = ref(false);
const rotationStep = ref(0); // 步进计数1步=15度
// 拖动状态对象,增加 hasRotated 标记
const drag = ref<{
active: boolean;
startX: number;
hasRotated: boolean;
} | null>(null);
const dragThreshold = 20; // 每20像素触发一次旋转
// 计算宽高
const width = computed(() => 100 * props.size);
const height = computed(() => 100 * props.size);
// 鼠标按下处理
function handleMouseDown(event: MouseEvent) {
drag.value = { active: true, startX: event.clientX, hasRotated: false };
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}
// 鼠标移动处理
function handleMouseMove(event: MouseEvent) {
if (!drag.value?.active) return;
const dx = event.clientX - drag.value.startX;
if (Math.abs(dx) >= dragThreshold) {
rotationStep.value += dx > 0 ? 1 : -1;
drag.value.startX = event.clientX;
drag.value.hasRotated = true;
}
}
// 鼠标松开处理
function handleMouseUp() {
if (drag.value && drag.value.active) {
// 仅在未发生旋转时才触发按压
if (!drag.value.hasRotated) {
isPressed.value = true;
rotataryEncoderStore.pressOnce(
props.encoderNumber,
RotaryEncoderPressStatus.Press,
);
setTimeout(() => {
isPressed.value = false;
rotataryEncoderStore.pressOnce(
props.encoderNumber,
RotaryEncoderPressStatus.Release,
);
}, 100);
}
}
drag.value = null;
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
}
// 按压处理用于鼠标离开和mouseup
function handlePress(pressed: boolean) {
isPressed.value = pressed;
}
watchEffect(() => {
if (!props.enableDigitalTwin) return;
if (props.componentId)
rotataryEncoderStore.setEnable(props.enableDigitalTwin);
});
watch(
() => rotationStep.value,
(newStep, oldStep) => {
if (!props.enableDigitalTwin) return;
if (newStep > oldStep) {
rotataryEncoderStore.rotateOnce(
props.encoderNumber,
RotaryEncoderDirection.Clockwise,
);
} else if (newStep < oldStep) {
rotataryEncoderStore.rotateOnce(
props.encoderNumber,
RotaryEncoderDirection.CounterClockwise,
);
}
},
);
</script>
<script lang="ts">
// 添加一个静态方法来获取默认props
export function getDefaultProps() {
return {
size: 1,
enableDigitalTwin: false,
encoderNumber: 1,
};
}
</script>
<style scoped lang="postcss">
.ec11-container {
display: inline-block;
user-select: none;
}
.ec11-encoder {
display: block;
overflow: visible;
}
.interactive {
cursor: pointer;
}
</style>

View File

@@ -18,8 +18,8 @@
</feMerge>
</filter>
<linearGradient id="normal" gradientTransform="rotate(45 0 0)">
<stop stop-color="#4b4b4b" offset="0" />
<stop stop-color="#171717" offset="1" />
<stop stop-color="#FFFFFF" offset="0" />
<stop stop-color="#333333" offset="1" />
</linearGradient>
<linearGradient id="pressed" gradientTransform="rotate(45 0 0)">
<stop stop-color="#171717" offset="0" />
@@ -42,7 +42,6 @@
fill-opacity="0.9" @mousedown="toggleButtonState(true)" @mouseup="toggleButtonState(false)"
@mouseleave="toggleButtonState(false)" style="
pointer-events: auto;
transition: all 20ms ease-in-out;
cursor: pointer;
" />
<!-- 按键文字 - 仅显示绑定的按键 -->

View File

@@ -27,7 +27,7 @@
to="#ComponentCapabilities"
v-if="selectecComponentID === props.componentId"
>
<MotherBoardCaps :jtagFreq="jtagFreq" @change-jtag-freq="changeJtagFreq" />
<MotherBoardCaps :jtagFreq="jtagFreq" :exam-id="examId" @change-jtag-freq="changeJtagFreq" />
</Teleport>
</template>
@@ -41,6 +41,7 @@ import { toNumber } from "lodash";
export interface MotherBoardProps {
size: number;
componentId?: string;
examId?: string; // 新增examId属性
}
const emit = defineEmits<{

View File

@@ -8,20 +8,34 @@
<p>
IDCode: 0x{{ jtagIDCode.toString(16).padStart(8, "0").toUpperCase() }}
</p>
<button class="btn btn-circle w-6 h-6" :disabled="isGettingIDCode" :onclick="getIDCode">
<RefreshCcwIcon class="icon" :class="{ 'animate-spin': isGettingIDCode }" />
<button
class="btn btn-circle w-6 h-6"
:disabled="isGettingIDCode"
:onclick="getIDCode"
>
<RefreshCcwIcon
class="icon"
:class="{ 'animate-spin': isGettingIDCode }"
/>
</button>
</div>
</div>
<div class="divider"></div>
<UploadCard class="bg-base-200" :upload-event="eqps.jtagUploadBitstream"
:download-event="eqps.jtagDownloadBitstream" :bitstream-file="eqps.jtagBitstream"
@update:bitstream-file="handleBitstreamChange">
<UploadCard
:exam-id="props.examId"
:upload-event="eqps.jtagUploadBitstream"
:bitstream-file="eqps.jtagBitstream"
@update:bitstream-file="handleBitstreamChange"
>
</UploadCard>
<div class="divider"></div>
<div class="w-full">
<legend class="fieldset-legend text-sm mb-0.3">Jtag运行频率</legend>
<select class="select w-full" @change="handleSelectJtagSpeed" :value="props.jtagFreq">
<select
class="select w-full"
@change="handleSelectJtagSpeed"
:value="props.jtagFreq"
>
<option v-for="option in selectJtagSpeedOptions" :value="option.id">
{{ option.text }}
</option>
@@ -30,23 +44,47 @@
<div class="flex flex-row items-center">
<fieldset class="fieldset w-70">
<legend class="fieldset-legend text-sm">边界扫描刷新率 / Hz</legend>
<input type="number" class="input validator" required placeholder="Type a number between 1 to 1000" min="1"
max="1000" v-model="jtagBoundaryScanFreq" title="Type a number between 1 to 1000" />
<input
type="number"
class="input validator"
required
placeholder="Type a number between 1 to 1000"
min="1"
max="1000"
v-model="jtagBoundaryScanFreq"
title="Type a number between 1 to 1000"
/>
<p class="validator-hint">输入一个1 ~ 1000的数</p>
</fieldset>
<button class="btn btn-primary grow mx-4" :class="eqps.enableJtagBoundaryScan ? '' : 'btn-soft'"
:onclick="toggleJtagBoundaryScan">
<button
class="btn btn-primary grow mx-4"
:class="eqps.enableJtagBoundaryScan ? '' : 'btn-soft'"
:onclick="toggleJtagBoundaryScan"
>
{{ eqps.enableJtagBoundaryScan ? "关闭边界扫描" : "启动边界扫描" }}
</button>
</div>
<div class="divider"></div>
<h1 class="font-bold text-center text-2xl">外设</h1>
<div class="flex flex-row justify-center">
<div class="flex flex-row justify-between columns-2">
<div class="flex flex-row">
<input type="checkbox" class="checkbox" :checked="eqps.enableMatrixKey"
@change="handleMatrixkeyCheckboxChange" />
<input
type="checkbox"
class="checkbox"
:checked="eqps.enableMatrixKey"
@change="handleMatrixkeyCheckboxChange"
/>
<p class="mx-2">启用矩阵键盘</p>
</div>
<div class="flex flex-row">
<input
type="checkbox"
class="checkbox"
:checked="eqps.enableSevenSegmentDisplay"
@change="handleSevenSegmentDisplayCheckboxChange"
/>
<p class="mx-2">启用数码管</p>
</div>
</div>
</div>
</template>
@@ -61,6 +99,7 @@ import { RefreshCcwIcon } from "lucide-vue-next";
interface CapsProps {
jtagFreq?: string;
examId?: string; // 新增examId属性
}
const emits = defineEmits<{
@@ -116,8 +155,17 @@ async function handleMatrixkeyCheckboxChange(event: Event) {
}
}
async function handleSevenSegmentDisplayCheckboxChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.checked) {
await eqps.sevenSegmentDisplaySetOnOff(true);
} else {
await eqps.sevenSegmentDisplaySetOnOff(false);
}
}
async function toggleJtagBoundaryScan() {
eqps.enableJtagBoundaryScan = !eqps.enableJtagBoundaryScan;
eqps.jtagBoundaryScanSetOnOff(!eqps.enableJtagBoundaryScan);
}
const isGettingIDCode = ref(false);

View File

@@ -1,65 +1,114 @@
<template>
<div class="seven-segment-display" :style="{
width: width + 'px',
height: height + 'px',
position: 'relative',
}">
<svg xmlns="http://www.w3.org/2000/svg" :width="width" :height="height" viewBox="0 0 120 220" class="display">
<div
class="seven-segment-display"
:style="{
width: width + 'px',
height: height + 'px',
position: 'relative',
}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
viewBox="0 0 120 220"
class="display"
>
<!-- 数码管基座 -->
<rect width="120" height="180" x="0" y="0" fill="#222" rx="10" ry="10" />
<rect width="110" height="170" x="5" y="5" fill="#333" rx="5" ry="5" />
<!-- 7 + 小数点每个段由多边形表示重新设计点位置使其更接近实际数码管 -->
<!-- a段 (顶部横线) -->
<polygon :points="'30,20 90,20 98,28 82,36 38,36 22,28'"
<polygon
:points="'30,20 90,20 98,28 82,36 38,36 22,28'"
:fill="isSegmentActive('a') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('a') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('a') ? 1 : 0.15 }"
class="segment"
/>
<!-- b段 (右上竖线) -->
<polygon :points="'100,30 108,38 108,82 100,90 92,82 92,38'"
<polygon
:points="'100,30 108,38 108,82 100,90 92,82 92,38'"
:fill="isSegmentActive('b') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('b') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('b') ? 1 : 0.15 }"
class="segment"
/>
<!-- c段 (右下竖线) -->
<polygon :points="'100,90 108,98 108,142 100,150 92,142 92,98'"
<polygon
:points="'100,90 108,98 108,142 100,150 92,142 92,98'"
:fill="isSegmentActive('c') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('c') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('c') ? 1 : 0.15 }"
class="segment"
/>
<!-- d段 (底部横线) -->
<polygon :points="'30,160 90,160 98,152 82,144 38,144 22,152'"
<polygon
:points="'30,160 90,160 98,152 82,144 38,144 22,152'"
:fill="isSegmentActive('d') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('d') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('d') ? 1 : 0.15 }"
class="segment"
/>
<!-- e段 (左下竖线) -->
<polygon :points="'20,90 28,98 28,142 20,150 12,142 12,98'"
<polygon
:points="'20,90 28,98 28,142 20,150 12,142 12,98'"
:fill="isSegmentActive('e') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('e') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('e') ? 1 : 0.15 }"
class="segment"
/>
<!-- f段 (左上竖线) -->
<polygon :points="'20,30 28,38 28,82 20,90 12,82 12,38'"
<polygon
:points="'20,30 28,38 28,82 20,90 12,82 12,38'"
:fill="isSegmentActive('f') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('f') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('f') ? 1 : 0.15 }"
class="segment"
/>
<!-- g段 (中间横线) -->
<polygon :points="'30,90 38,82 82,82 90,90 82,98 38,98'"
<polygon
:points="'30,90 38,82 82,82 90,90 82,98 38,98'"
:fill="isSegmentActive('g') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('g') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('g') ? 1 : 0.15 }"
class="segment"
/>
<!-- dp段 (小数点) -->
<circle cx="108" cy="154" r="6" :fill="isSegmentActive('dp') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('dp') ? 1 : 0.15 }" class="segment" />
<circle
cx="108"
cy="154"
r="6"
:fill="isSegmentActive('dp') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('dp') ? 1 : 0.15 }"
class="segment"
/>
</svg>
<!-- 引脚 -->
<div v-for="pin in pins" :key="pin.pinId" :style="{
position: 'absolute',
left: `${pin.x * props.size}px`,
top: `${pin.y * props.size}px`,
transform: 'translate(-50%, -50%)',
}" :data-pin-wrapper="`${pin.pinId}`" :data-pin-x="`${pin.x * props.size}`"
:data-pin-y="`${pin.y * props.size}`">
<Pin :ref="(el) => {
if (el) pinRefs[pin.pinId] = el;
}
" :label="pin.pinId" :constraint="pin.constraint" :pinId="pin.pinId" @pin-click="$emit('pin-click', $event)" />
<div
v-for="pin in pins"
:key="pin.pinId"
:style="{
position: 'absolute',
left: `${pin.x * props.size}px`,
top: `${pin.y * props.size}px`,
transform: 'translate(-50%, -50%)',
}"
:data-pin-wrapper="`${pin.pinId}`"
:data-pin-x="`${pin.x * props.size}`"
:data-pin-y="`${pin.y * props.size}`"
>
<Pin
:ref="
(el) => {
if (el) pinRefs[pin.pinId] = el;
}
"
:label="pin.pinId"
:constraint="pin.constraint"
:pinId="pin.pinId"
@pin-click="$emit('pin-click', $event)"
/>
</div>
</div>
</template>
@@ -217,12 +266,12 @@ function isSegmentActive(
if (isInAfterglowMode.value) {
return afterglowStates.value[segment];
}
// 如果COM口未激活所有段都不显示
if (!currentComActive.value) {
return false;
}
// 否则使用稳定状态
return stableSegmentStates.value[segment];
}
@@ -232,7 +281,7 @@ function updateSegmentStates() {
// 先获取COM口状态
const comPin = props.pins.find((p) => p.pinId === "COM");
let comActive = false; // 默认未激活
if (comPin && comPin.constraint) {
const comState = getConstraintState(comPin.constraint);
if (props.cathodeType === "anode") {
@@ -274,7 +323,8 @@ function updateSegmentStates() {
for (const pin of props.pins) {
if (["a", "b", "c", "d", "e", "f", "g", "dp"].includes(pin.pinId)) {
if (!pin.constraint) {
segmentStates.value[pin.pinId as keyof typeof segmentStates.value] = false;
segmentStates.value[pin.pinId as keyof typeof segmentStates.value] =
false;
continue;
}
const pinState = getConstraintState(pin.constraint);
@@ -285,7 +335,8 @@ function updateSegmentStates() {
newState = pinState === "low";
}
// 段状态只有在COM激活时才有效
segmentStates.value[pin.pinId as keyof typeof segmentStates.value] = newState;
segmentStates.value[pin.pinId as keyof typeof segmentStates.value] =
newState;
}
}
@@ -328,22 +379,25 @@ function updateAfterglowBuffers() {
// 进入余晖模式
function enterAfterglowMode() {
isInAfterglowMode.value = true;
// 保存当前稳定状态作为余晖状态
for (const segmentId of ["a", "b", "c", "d", "e", "f", "g", "dp"]) {
const typedSegmentId = segmentId as keyof typeof stableSegmentStates.value;
afterglowStates.value[typedSegmentId] = stableSegmentStates.value[typedSegmentId];
afterglowStates.value[typedSegmentId] =
stableSegmentStates.value[typedSegmentId];
// 设置定时器,在余晖持续时间后退出余晖模式
if (afterglowTimers.value[segmentId]) {
clearTimeout(afterglowTimers.value[segmentId]!);
}
afterglowTimers.value[segmentId] = setTimeout(() => {
afterglowStates.value[typedSegmentId] = false;
// 检查是否所有段都已经关闭
const allSegmentsOff = Object.values(afterglowStates.value).every(state => !state);
const allSegmentsOff = Object.values(afterglowStates.value).every(
(state) => !state,
);
if (allSegmentsOff) {
exitAfterglowMode();
}
@@ -354,14 +408,14 @@ function enterAfterglowMode() {
// 退出余晖模式
function exitAfterglowMode() {
isInAfterglowMode.value = false;
// 清除所有定时器
for (const segmentId of ["a", "b", "c", "d", "e", "f", "g", "dp"]) {
if (afterglowTimers.value[segmentId]) {
clearTimeout(afterglowTimers.value[segmentId]!);
afterglowTimers.value[segmentId] = null;
}
// 重置余晖状态
const typedSegmentId = segmentId as keyof typeof afterglowStates.value;
afterglowStates.value[typedSegmentId] = false;
@@ -397,11 +451,6 @@ onUnmounted(() => {
}
}
});
// 暴露属性和方法
defineExpose({
updateSegmentStates,
});
</script>
<style scoped>
@@ -418,7 +467,8 @@ defineExpose({
/* 数码管发光效果 */
.segment[style*="opacity: 1"] {
filter: drop-shadow(0 0 4px v-bind(segmentColor)) drop-shadow(0 0 2px v-bind(segmentColor));
filter: drop-shadow(0 0 4px v-bind(segmentColor))
drop-shadow(0 0 2px v-bind(segmentColor));
}
</style>

View File

@@ -0,0 +1,418 @@
<template>
<div
class="seven-segment-display"
:style="{
width: width + 'px',
height: height + 'px',
position: 'relative',
}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
viewBox="0 0 120 220"
class="display"
>
<!-- 数码管基座 -->
<rect width="120" height="180" x="0" y="0" fill="#222" rx="10" ry="10" />
<rect width="110" height="170" x="5" y="5" fill="#333" rx="5" ry="5" />
<!-- 7段显示 -->
<polygon
v-for="(segment, id) in segmentPaths"
:key="id"
:points="segment.points"
:fill="isSegmentActive(id) ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive(id) ? 1 : 0.15 }"
:class="{ segment: true, active: isSegmentActive(id) }"
/>
<!-- 小数点 -->
<circle
cx="108"
cy="154"
r="6"
:fill="isSegmentActive('dp') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('dp') ? 1 : 0.15 }"
:class="{ segment: true, active: isSegmentActive('dp') }"
/>
</svg>
<!-- 引脚仅在非数字孪生模式下显示 -->
<div
v-if="!eqps.enableSevenSegmentDisplay"
v-for="pin in props.pins"
:key="pin.pinId"
:style="{
position: 'absolute',
left: `${pin.x * props.size}px`,
top: `${pin.y * props.size}px`,
transform: 'translate(-50%, -50%)',
}"
:data-pin-wrapper="`${pin.pinId}`"
:data-pin-x="`${pin.x * props.size}`"
:data-pin-y="`${pin.y * props.size}`"
>
<Pin
:ref="
(el) => {
if (el) pinRefs[pin.pinId] = el;
}
"
:label="pin.pinId"
:constraint="pin.constraint"
:pinId="pin.pinId"
@pin-click="$emit('pin-click', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useConstraintsStore } from "../../stores/constraints";
import Pin from "./Pin.vue";
import { useEquipments } from "@/stores/equipments";
import { watchEffect } from "vue";
import { useRequiredInjection } from "@/utils/Common";
import { useComponentManager } from "../LabCanvas";
const eqps = useEquipments();
const componentManger = useRequiredInjection(useComponentManager);
// ============================================================================
// Linus式极简数据结构一个byte解决一切
// ============================================================================
interface Props {
size?: number;
color?: string;
// enableDigitalTwin?: boolean;
digitalTwinNum?: number;
// afterglowDuration?: number;
cathodeType?: "common" | "anode";
pins?: Array<{
pinId: string;
constraint: string;
x: number;
y: number;
}>;
}
const props = withDefaults(defineProps<Props>(), {
size: 1,
color: "red",
// enableDigitalTwin: false,
digitalTwinNum: 1,
afterglowDuration: 500,
cathodeType: "common",
pins: () => [
{ pinId: "a", constraint: "", x: 10, y: 170 },
{ pinId: "b", constraint: "", x: 24, y: 170 },
{ pinId: "c", constraint: "", x: 38, y: 170 },
{ pinId: "d", constraint: "", x: 52, y: 170 },
{ pinId: "e", constraint: "", x: 66, y: 170 },
{ pinId: "f", constraint: "", x: 80, y: 170 },
{ pinId: "g", constraint: "", x: 94, y: 170 },
{ pinId: "dp", constraint: "", x: 108, y: 170 },
{ pinId: "COM", constraint: "", x: 60, y: 10 },
],
});
// ============================================================================
// 核心状态:简单到极致
// ============================================================================
// 当前显示状态 - 8bit对应8个段
const displayByte = ref<number>(0);
// 余晖状态
const afterglowByte = ref<number>(0);
const afterglowTimer = ref<number | null>(null);
// 约束系统状态(兼容模式)
const constraintStates = ref<Record<string, boolean>>({
a: false,
b: false,
c: false,
d: false,
e: false,
f: false,
g: false,
dp: false,
});
// ============================================================================
// Bit操作硬件工程师的好品味
// ============================================================================
// 段到bit位的映射 (标准7段数码管编码)
const SEGMENT_BITS = {
a: 0, // bit 0
b: 1, // bit 1
c: 2, // bit 2
d: 3, // bit 3
e: 4, // bit 4
f: 5, // bit 5
g: 6, // bit 6
dp: 7, // bit 7
} as const;
function isBitSet(byte: number, bit: number): boolean {
return (byte & (1 << bit)) !== 0;
}
function isSegmentActive(segmentId: keyof typeof SEGMENT_BITS): boolean {
if (eqps.enableSevenSegmentDisplay) {
// 数字孪生模式余晖优先然后是当前byte
const bit = SEGMENT_BITS[segmentId];
return (
isBitSet(afterglowByte.value, bit) || isBitSet(displayByte.value, bit)
);
} else {
// 约束模式:使用传统逻辑
return constraintStates.value[segmentId] || false;
}
}
// ============================================================================
// SignalR数字孪生集成
// ============================================================================
async function initDigitalTwin() {
if (
!eqps.enableSevenSegmentDisplay ||
props.digitalTwinNum <= 0 ||
props.digitalTwinNum > 32
)
return;
try {
eqps.sevenSegmentDisplaySetOnOff(eqps.enableSevenSegmentDisplay);
console.log(
`Digital twin initialized for address: ${props.digitalTwinNum}`,
);
} catch (error) {
console.warn("Failed to initialize digital twin:", error);
}
}
watch(
() => [eqps.sevenSegmentDisplayData],
() => {
if (
!eqps.sevenSegmentDisplayData ||
props.digitalTwinNum <= 0 ||
props.digitalTwinNum > 32
)
return;
handleDigitalTwinData(
eqps.sevenSegmentDisplayData[props.digitalTwinNum - 1],
);
},
);
function handleDigitalTwinData(data: any) {
let newByte = 0;
if (typeof data === "number") {
// 直接是byte数据
newByte = data & 0xff; // 确保只取低8位
} else if (data && typeof data.value === "number") {
// 包装在对象中的byte数据
newByte = data.value & 0xff;
} else if (data && data.segments) {
// 段状态对象格式
Object.keys(SEGMENT_BITS).forEach((segment) => {
if (data.segments[segment]) {
newByte |= 1 << SEGMENT_BITS[segment as keyof typeof SEGMENT_BITS];
}
});
}
updateDisplayByte(newByte);
}
function updateDisplayByte(newByte: number) {
const oldByte = displayByte.value;
displayByte.value = newByte;
// // 启动余晖效果
// if (oldByte !== 0 && newByte !== oldByte) {
// startAfterglow(oldByte);
// }
}
// function startAfterglow(byte: number) {
// afterglowByte.value = byte;
// if (afterglowTimer.value) {
// clearTimeout(afterglowTimer.value);
// }
// afterglowTimer.value = setTimeout(() => {
// afterglowByte.value = 0;
// afterglowTimer.value = null;
// }, props.afterglowDuration);
// }
function cleanupDigitalTwin() {
eqps.sevenSegmentDisplaySetOnOff(false);
}
// ============================================================================
// 约束系统兼容(传统模式)
// ============================================================================
const { getConstraintState, onConstraintStateChange } = useConstraintsStore();
let constraintUnsubscribe: (() => void) | null = null;
function updateConstraintStates() {
if (eqps.enableSevenSegmentDisplay) return; // 数字孪生模式下忽略约束
// 获取COM状态
const comPin = props.pins.find((p) => p.pinId === "COM");
const comActive = isComActive(comPin);
if (!comActive) {
// COM不活跃所有段关闭
Object.keys(constraintStates.value).forEach((key) => {
constraintStates.value[key] = false;
});
return;
}
// 更新各段状态
props.pins.forEach((pin) => {
if (Object.hasOwnProperty.call(SEGMENT_BITS, pin.pinId)) {
constraintStates.value[pin.pinId] = isPinActive(pin);
}
});
}
function isComActive(comPin: any): boolean {
if (!comPin?.constraint) return true;
const state = getConstraintState(comPin.constraint);
return props.cathodeType === "common" ? state === "low" : state === "low";
}
function isPinActive(pin: any): boolean {
if (!pin.constraint) return false;
const state = getConstraintState(pin.constraint);
return props.cathodeType === "common" ? state === "high" : state === "low";
}
// ============================================================================
// 渲染数据
// ============================================================================
const segmentPaths = {
a: { points: "30,20 90,20 98,28 82,36 38,36 22,28" },
b: { points: "100,30 108,38 108,82 100,90 92,82 92,38" },
c: { points: "100,90 108,98 108,142 100,150 92,142 92,98" },
d: { points: "30,160 90,160 98,152 82,144 38,144 22,152" },
e: { points: "20,90 28,98 28,142 20,150 12,142 12,98" },
f: { points: "20,30 28,38 28,82 20,90 12,82 12,38" },
g: { points: "30,90 38,82 82,82 90,90 82,98 38,98" },
} as const;
// ============================================================================
// 计算属性
// ============================================================================
const width = computed(() => 120 * props.size);
const height = computed(() => 220 * props.size);
const segmentColor = computed(() => props.color);
const inactiveColor = computed(() => "#FFFFFF");
const pinRefs = ref<Record<string, any>>({});
// ============================================================================
// 生命周期
// ============================================================================
onMounted(async () => {
if (eqps.enableSevenSegmentDisplay) {
await initDigitalTwin();
} else {
constraintUnsubscribe = onConstraintStateChange(updateConstraintStates);
updateConstraintStates();
}
});
onUnmounted(() => {
cleanupDigitalTwin();
if (constraintUnsubscribe) {
constraintUnsubscribe();
}
if (afterglowTimer.value) {
clearTimeout(afterglowTimer.value);
}
});
// 监听模式切换
// watch(
// () => [eqps.enableSevenSegmentDisplay],
// async () => {
// // 清理旧模式
// if (constraintUnsubscribe) {
// constraintUnsubscribe();
// constraintUnsubscribe = null;
// }
// // 初始化新模式
// if (eqps.enableSevenSegmentDisplay) {
// await initDigitalTwin();
// } else {
// constraintUnsubscribe = onConstraintStateChange(updateConstraintStates);
// updateConstraintStates();
// }
// },
// );
</script>
<style scoped>
.seven-segment-display {
display: inline-block;
position: relative;
}
.segment {
transition:
opacity 0.2s,
fill 0.2s;
}
.segment.active {
filter: drop-shadow(0 0 4px v-bind(segmentColor))
drop-shadow(0 0 2px v-bind(segmentColor));
}
</style>
<script lang="ts">
export function getDefaultProps() {
return {
size: 1,
color: "red",
// enableDigitalTwin: false,
digitalTwinNum: 1,
// afterglowDuration: 500,
cathodeType: "common",
pins: [
{ pinId: "a", constraint: "", x: 10, y: 170 },
{ pinId: "b", constraint: "", x: 24, y: 170 },
{ pinId: "c", constraint: "", x: 38, y: 170 },
{ pinId: "d", constraint: "", x: 52, y: 170 },
{ pinId: "e", constraint: "", x: 66, y: 170 },
{ pinId: "f", constraint: "", x: 80, y: 170 },
{ pinId: "g", constraint: "", x: 94, y: 170 },
{ pinId: "dp", constraint: "", x: 108, y: 170 },
{ pinId: "COM", constraint: "", x: 60, y: 10 },
],
};
}
</script>

View File

@@ -1,17 +1,30 @@
// filepath: c:\_Project\FPGA_WebLab\FPGA_WebLab\src\components\equipments\Switch.vue
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
:viewBox="`4 6 ${props.switchCount + 2} 4`"
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
:viewBox="`4 6 ${switchCount + 2} 4`"
class="dip-switch"
>
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feFlood result="flood" flood-color="#f08a5d" flood-opacity="1"></feFlood>
<feComposite in="flood" result="mask" in2="SourceGraphic" operator="in"></feComposite>
<feMorphology in="mask" result="dilated" operator="dilate" radius="0.02"></feMorphology>
<feFlood
result="flood"
flood-color="#f08a5d"
flood-opacity="1"
></feFlood>
<feComposite
in="flood"
result="mask"
in2="SourceGraphic"
operator="in"
></feComposite>
<feMorphology
in="mask"
result="dilated"
operator="dilate"
radius="0.02"
></feMorphology>
<feGaussianBlur in="dilated" stdDeviation="0.05" result="blur1" />
<feGaussianBlur in="dilated" stdDeviation="0.1" result="blur2" />
<feGaussianBlur in="dilated" stdDeviation="0.2" result="blur3" />
@@ -23,29 +36,41 @@
</feMerge>
</filter>
</defs>
<g>
<!-- 红色背景随开关数量变化宽度 -->
<rect :width="props.switchCount + 2" height="4" x="4" y="6" fill="#c01401" rx="0.1" />
<text v-if="props.showLabels" fill="white" font-size="0.7" x="4.25" y="6.75">ON</text>
<rect
:width="switchCount + 2"
height="4"
x="4"
y="6"
fill="#c01401"
rx="0.1"
/>
<text
v-if="props.showLabels"
fill="white"
font-size="0.7"
x="4.25"
y="6.75"
>
ON
</text>
<g>
<template v-for="(_, index) in Array(props.switchCount)" :key="index">
<rect
class="glow interactive"
@click="toggleBtnStatus(index)"
width="0.7"
height="2"
fill="#68716f"
:x="5.15 + index"
y="7"
rx="0.1"
<template v-for="(_, index) in Array(switchCount)" :key="index">
<rect
class="glow interactive"
@click="toggleBtnStatus(index)"
width="0.7"
height="2"
fill="#68716f"
:x="5.15 + index"
y="7"
rx="0.1"
/>
<text
<text
v-if="props.showLabels"
:x="5.5 + index"
y="9.5"
font-size="0.4"
:x="5.5 + index"
y="9.5"
font-size="0.4"
text-anchor="middle"
fill="#444"
>
@@ -53,19 +78,21 @@
</text>
</template>
</g>
<g>
<template v-for="(location, index) in btnLocation" :key="`btn-${index}`">
<rect
<template
v-for="(location, index) in btnLocation"
:key="`btn-${index}`"
>
<rect
class="interactive"
@click="toggleBtnStatus(index)"
width="0.65"
height="0.65"
fill="white"
:x="5.175 + index"
:y="location"
@click="toggleBtnStatus(index)"
width="0.65"
height="0.65"
fill="white"
:x="5.175 + index"
:y="location"
rx="0.1"
opacity="1"
opacity="1"
/>
</template>
</g>
@@ -74,119 +101,115 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue";
import { SwitchClient } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
import { isUndefined } from "lodash";
import { ref, computed, watch, onMounted } from "vue";
interface Props {
size?: number;
componentId?: string;
enableDigitalTwin?: boolean;
switchCount?: number;
// 新增属性
initialValues?: boolean[] | string; // 开关的初始状态,可以是布尔数组或逗号分隔的字符串
showLabels?: boolean; // 是否显示标签
initialValues?: string;
showLabels?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 1,
enableDigitalTwin: false,
switchCount: 6,
initialValues: () => [],
showLabels: true
initialValues: "",
showLabels: true,
});
// 计算实际宽高
const width = computed(() => {
// 每个开关占用25px宽度再加上两侧边距(20px)
return (props.switchCount * 25 + 20) * props.size;
const switchCount = computed(() => {
if (props.enableDigitalTwin) return 5;
else return props.switchCount;
});
const height = computed(() => 85 * props.size); // 高度保持固定比例
// 定义发出的事件
const emit = defineEmits(['change', 'switch-toggle']);
function getClient() {
return AuthManager.createClient(SwitchClient);
}
// 解析初始值,支持字符串和数组两种格式
const parseInitialValues = () => {
// 解析初始值
function parseInitialValues(): boolean[] {
if (Array.isArray(props.initialValues)) {
return [...props.initialValues].slice(0, props.switchCount);
} else if (typeof props.initialValues === 'string' && props.initialValues.trim() !== '') {
// 将逗号分隔的字符串转换为布尔数组
const values = props.initialValues.split(',')
.map(val => val.trim() === '1' || val.trim().toLowerCase() === 'true')
.slice(0, props.switchCount);
// 如果数组长度小于开关数量,用 false 填充
while (values.length < props.switchCount) {
values.push(false);
return [...props.initialValues].slice(0, switchCount.value);
}
if (
typeof props.initialValues === "string" &&
props.initialValues.trim() !== ""
) {
const arr = props.initialValues
.split(",")
.map((val) => val.trim() === "1" || val.trim().toLowerCase() === "true");
while (arr.length < props.switchCount) arr.push(false);
return arr.slice(0, props.switchCount);
}
return Array(switchCount.value).fill(false);
}
// 状态唯一真相
const btnStatus = ref<boolean[]>(parseInitialValues());
// 计算宽高
const width = computed(() => (switchCount.value * 25 + 20) * props.size);
const height = computed(() => 85 * props.size);
// 按钮位置
const btnLocation = computed(() =>
btnStatus.value.map((status) => (status ? 7.025 : 8.325)),
);
// 状态变更统一处理
function updateStatus(newStates: boolean[], index?: number) {
btnStatus.value = newStates.slice(0, switchCount.value);
if (props.enableDigitalTwin) {
try {
const client = getClient();
if (!isUndefined(index))
client.setSwitchOnOff(index + 1, newStates[index]);
else client.setMultiSwitchsOnOff(btnStatus.value);
} catch (error: any) {}
}
}
// 切换单个
function toggleBtnStatus(idx: number) {
if (idx < 0 || idx >= btnStatus.value.length) return;
const newStates = [...btnStatus.value];
newStates[idx] = !newStates[idx];
updateStatus(newStates, idx);
}
// 单个设置
function setBtnStatus(idx: number, isOn: boolean) {
if (idx < 0 || idx >= btnStatus.value.length) return;
const newStates = [...btnStatus.value];
newStates[idx] = isOn;
updateStatus(newStates, idx);
}
// 监听 props 变化只同步一次
watch(
() => props.enableDigitalTwin,
(newVal) => {
if (props.componentId) {
const client = getClient();
client.setEnable(newVal);
}
return values;
}
// 默认返回全部为 false 的数组
return Array(props.switchCount).fill(false);
};
},
{ immediate: true },
);
// 初始化按钮状态
const btnStatus = ref(parseInitialValues());
// 监听 switchCount 变化,调整开关状态数组
watch(() => props.switchCount, (newCount) => {
if (newCount !== btnStatus.value.length) {
// 如果新数量大于当前数量,则扩展数组
if (newCount > btnStatus.value.length) {
btnStatus.value = [
...btnStatus.value,
...Array(newCount - btnStatus.value.length).fill(false)
];
} else {
// 如果新数量小于当前数量,则截断数组
btnStatus.value = btnStatus.value.slice(0, newCount);
}
}
}, { immediate: true });
// 监听 initialValues 变化,更新开关状态
watch(() => props.initialValues, () => {
btnStatus.value = parseInitialValues();
});
const btnLocation = computed(() => {
return btnStatus.value.map((status) => {
return status ? 7.025 : 8.325;
});
});
function setBtnStatus(btnNum: number, isOn: boolean): void {
if (btnNum >= 0 && btnNum < btnStatus.value.length) {
btnStatus.value[btnNum] = isOn;
emit('change', { index: btnNum, value: isOn, states: [...btnStatus.value] });
}
}
function toggleBtnStatus(btnNum: number): void {
if (btnNum >= 0 && btnNum < btnStatus.value.length) {
btnStatus.value[btnNum] = !btnStatus.value[btnNum];
emit('switch-toggle', {
index: btnNum,
value: btnStatus.value[btnNum],
states: [...btnStatus.value]
});
}
}
// 一次性设置所有开关状态
function setAllStates(states: boolean[]): void {
const newStates = states.slice(0, props.switchCount);
while (newStates.length < props.switchCount) {
newStates.push(false);
}
btnStatus.value = newStates;
emit('change', { states: [...btnStatus.value] });
}
// 暴露组件方法和状态
defineExpose({
setBtnStatus,
toggleBtnStatus,
setAllStates,
getBtnStatus: () => [...btnStatus.value]
});
watch(
() => [switchCount.value, props.initialValues],
() => {
btnStatus.value = parseInitialValues();
if (props.componentId) updateStatus(btnStatus.value);
},
);
</script>
<style scoped lang="postcss">
@@ -194,17 +217,27 @@ defineExpose({
display: block;
padding: 0;
margin: 0;
line-height: 0; /* 移除行高导致的额外间距 */
font-size: 0; /* 防止文本节点造成的间距 */
line-height: 0;
font-size: 0;
box-sizing: content-box;
overflow: visible;
}
rect {
transition: all 100ms ease-in-out;
}
.interactive {
cursor: pointer;
}
</style>
<script lang="ts">
export function getDefaultProps() {
return {
size: 1,
enableDigitalTwin: false,
switchCount: 6,
initialValues: "",
showLabels: true,
};
}
</script>

View File

@@ -1,10 +1,9 @@
import './assets/main.css'
import "./assets/main.css";
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from '@/App.vue'
import router from './router'
const app = createApp(App).use(router).use(createPinia()).mount('#app')
import App from "@/App.vue";
import router from "./router";
const app = createApp(App).use(router).use(createPinia()).mount("#app");

View File

@@ -1,10 +1,11 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/HomeView.vue";
import AuthView from "../views/AuthView.vue";
import ProjectView from "../views/Project/Index.vue";
import TestView from "../views/TestView.vue";
import UserView from "@/views/User/Index.vue";
import ExamView from "@/views/ExamView.vue";
const HomeView = () => import("../views/HomeView.vue");
const AuthView = () => import("../views/AuthView.vue");
const ProjectView = () => import("../views/Project/Index.vue");
const TestView = () => import("../views/TestView.vue");
const UserView = () => import("@/views/User/Index.vue");
const ExamView = () => import("@/views/Exam/Index.vue");
const MarkdownEditor = () => import("@/components/MarkdownEditor.vue");
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -13,8 +14,9 @@ const router = createRouter({
{ path: "/login", name: "login", component: AuthView },
{ path: "/project", name: "project", component: ProjectView },
{ path: "/test", name: "test", component: TestView },
{ path: "/user", name: "user", component: UserView },
{ path: "/exam", name: "exam", component: ExamView },
{ path: "/user/:page*", name: "user", component: UserView },
{ path: "/exam/:page*", name: "exam", component: ExamView },
{ path: "/markdown", name: "markdown", component: MarkdownEditor },
],
});

View File

@@ -0,0 +1,104 @@
import { AuthManager } from "@/utils/AuthManager";
import type {
RotaryEncoderDirection,
RotaryEncoderPressStatus,
} from "@/utils/signalR/Peripherals.RotaryEncoderClient";
import {
getHubProxyFactory,
getReceiverRegister,
} from "@/utils/signalR/TypedSignalR.Client";
import type {
IRotaryEncoderHub,
IRotaryEncoderReceiver,
} from "@/utils/signalR/TypedSignalR.Client/server.Hubs";
import { HubConnectionState, type HubConnection } from "@microsoft/signalr";
import { isUndefined } from "mathjs";
import { defineStore } from "pinia";
import { onMounted, onUnmounted, ref, shallowRef } from "vue";
export const useRotaryEncoder = defineStore("RotaryEncoder", () => {
const rotaryEncoderHub = shallowRef<{
connection: HubConnection;
proxy: IRotaryEncoderHub;
} | null>(null);
const rotaryEncoderReceiver: IRotaryEncoderReceiver = {
onReceiveRotate: async (data) => {},
};
onMounted(() => {
initHub();
});
onUnmounted(() => {
clearHub();
});
async function initHub() {
if (rotaryEncoderHub.value) return;
const connection = AuthManager.createHubConnection("RotaryEncoderHub");
const proxy =
getHubProxyFactory("IRotaryEncoderHub").createHubProxy(connection);
getReceiverRegister("IRotaryEncoderReceiver").register(
connection,
rotaryEncoderReceiver,
);
await connection.start();
rotaryEncoderHub.value = { connection, proxy };
}
function clearHub() {
if (!rotaryEncoderHub.value) return;
rotaryEncoderHub.value.connection.stop();
rotaryEncoderHub.value = null;
}
function reinitializeHub() {
clearHub();
initHub();
}
function getHubProxy() {
if (!rotaryEncoderHub.value) {
reinitializeHub();
throw new Error("Hub not initialized");
}
return rotaryEncoderHub.value.proxy;
}
async function setEnable(enabled: boolean) {
const proxy = getHubProxy();
return await proxy.setEnable(enabled);
}
async function rotateOnce(num: number, direction: RotaryEncoderDirection) {
const proxy = getHubProxy();
return await proxy.rotateEncoderOnce(num, direction);
}
async function pressOnce(num: number, pressStatus: RotaryEncoderPressStatus) {
const proxy = getHubProxy();
return await proxy.pressEncoderOnce(num, pressStatus);
}
async function enableCycleRotate(
num: number,
direction: RotaryEncoderDirection,
freq: number,
) {
const proxy = getHubProxy();
return await proxy.enableCycleRotateEncoder(num, direction, freq);
}
async function disableCycleRotate() {
const proxy = getHubProxy();
return await proxy.disableCycleRotateEncoder();
}
return {
setEnable,
rotateOnce,
pressOnce,
enableCycleRotate,
disableCycleRotate,
};
});

View File

@@ -1,26 +1,25 @@
import { ref, computed, reactive } from 'vue'
import { defineStore } from 'pinia'
import { isBoolean } from 'lodash';
import { ref, computed, reactive } from "vue";
import { defineStore } from "pinia";
import { isBoolean } from "lodash";
// 约束电平状态类型
export type ConstraintLevel = 'high' | 'low' | 'undefined';
export const useConstraintsStore = defineStore('constraints', () => {
export type ConstraintLevel = "high" | "low" | "undefined";
export const useConstraintsStore = defineStore("constraints", () => {
// 约束状态存储
const constraintStates = reactive<Record<string, ConstraintLevel>>({});
// 约束颜色映射
const constraintColors = {
high: '#ff3333', // 高电平为红色
low: '#3333ff', // 低电平为蓝色
undefined: '#999999' // 未定义为灰色
high: "#ff3333", // 高电平为红色
low: "#3333ff", // 低电平为蓝色
undefined: "#999999", // 未定义为灰色
};
// 获取约束状态
function getConstraintState(constraint: string): ConstraintLevel {
if (!constraint) return 'undefined';
return constraintStates[constraint] || 'undefined';
if (!constraint) return "undefined";
return constraintStates[constraint] || "undefined";
}
// 设置约束状态
@@ -30,7 +29,9 @@ export const useConstraintsStore = defineStore('constraints', () => {
}
// 批量设置约束状态
function batchSetConstraintStates(states: Record<string, ConstraintLevel> | Record<string, boolean>) {
function batchSetConstraintStates(
states: Record<string, ConstraintLevel> | Partial<Record<string, boolean>>,
) {
// 收集发生变化的约束
const changedConstraints: [string, ConstraintLevel][] = [];
@@ -38,6 +39,8 @@ export const useConstraintsStore = defineStore('constraints', () => {
Object.entries(states).forEach(([constraint, level]) => {
if (isBoolean(level)) {
level = level ? "high" : "low";
} else {
level = "low";
}
if (constraintStates[constraint] !== level) {
@@ -48,7 +51,7 @@ export const useConstraintsStore = defineStore('constraints', () => {
// 通知所有变化
changedConstraints.forEach(([constraint, level]) => {
stateChangeCallbacks.forEach(callback => callback(constraint, level));
stateChangeCallbacks.forEach((callback) => callback(constraint, level));
});
}
@@ -60,7 +63,7 @@ export const useConstraintsStore = defineStore('constraints', () => {
// 清除所有约束状态
function clearAllConstraintStates() {
Object.keys(constraintStates).forEach(key => {
Object.keys(constraintStates).forEach((key) => {
delete constraintStates[key];
});
}
@@ -71,9 +74,14 @@ export const useConstraintsStore = defineStore('constraints', () => {
}
// 注册约束状态变化回调
const stateChangeCallbacks: ((constraint: string, level: ConstraintLevel) => void)[] = [];
const stateChangeCallbacks: ((
constraint: string,
level: ConstraintLevel,
) => void)[] = [];
function onConstraintStateChange(callback: (constraint: string, level: ConstraintLevel) => void) {
function onConstraintStateChange(
callback: (constraint: string, level: ConstraintLevel) => void,
) {
stateChangeCallbacks.push(callback);
return () => {
const index = stateChangeCallbacks.indexOf(callback);
@@ -86,7 +94,7 @@ export const useConstraintsStore = defineStore('constraints', () => {
// 触发约束变化
function notifyConstraintChange(constraint: string, level: ConstraintLevel) {
setConstraintState(constraint, level);
stateChangeCallbacks.forEach(callback => callback(constraint, level));
stateChangeCallbacks.forEach((callback) => callback(constraint, level));
}
return {
@@ -98,6 +106,5 @@ export const useConstraintsStore = defineStore('constraints', () => {
getAllConstraintStates,
onConstraintStateChange,
notifyConstraintChange,
}
})
};
});

View File

@@ -1,15 +1,34 @@
import { ref, reactive, watchPostEffect } from "vue";
import { ref, reactive, shallowRef, onMounted, onUnmounted } from "vue";
import { defineStore } from "pinia";
import { useLocalStorage } from "@vueuse/core";
import { isString, toNumber } from "lodash";
import { isString, toNumber, isUndefined, type Dictionary } from "lodash";
import z from "zod";
import { isNumber } from "mathjs";
import { JtagClient, MatrixKeyClient, PowerClient } from "@/APIClient";
import { Mutex, withTimeout } from "async-mutex";
import { useConstraintsStore } from "@/stores/constraints";
import { useDialogStore } from "./dialog";
import { toFileParameterOrUndefined } from "@/utils/Common";
import {
base64ToArrayBuffer,
toFileParameterOrUndefined,
} from "@/utils/Common";
import { AuthManager } from "@/utils/AuthManager";
import { HubConnection, HubConnectionState } from "@microsoft/signalr";
import {
getHubProxyFactory,
getReceiverRegister,
} from "@/utils/signalR/TypedSignalR.Client";
import {
JtagClient,
MatrixKeyClient,
PowerClient,
ResourceClient,
ResourcePurpose,
type ResourceInfo,
} from "@/APIClient";
import type {
IDigitalTubesHub,
IJtagHub,
} from "@/utils/signalR/TypedSignalR.Client/server.Hubs";
export const useEquipments = defineStore("equipments", () => {
// Global Stores
@@ -20,71 +39,173 @@ export const useEquipments = defineStore("equipments", () => {
const boardPort = useLocalStorage("fpga-board-port", 1234);
// Jtag
const enableJtagBoundaryScan = ref(false);
const jtagBitstream = ref<File>();
const jtagBoundaryScanFreq = ref(100);
const jtagBoundaryScanErrorCount = ref(0); // 边界扫描连续错误计数
const maxJtagBoundaryScanErrors = 5; // 最大允许连续错误次数
const jtagUserBitstreams = ref<ResourceInfo[]>([]);
const jtagClientMutex = withTimeout(
new Mutex(),
1000,
new Error("JtagClient Mutex Timeout!"),
);
// jtag Hub
const jtagHubConnection = ref<HubConnection>();
const jtagHubProxy = ref<IJtagHub>();
onMounted(async () => {
// 每次挂载都重新创建连接
jtagHubConnection.value = AuthManager.createHubConnection("JtagHub");
jtagHubProxy.value = getHubProxyFactory("IJtagHub").createHubProxy(
jtagHubConnection.value,
);
getReceiverRegister("IJtagReceiver").register(jtagHubConnection.value, {
onReceiveBoundaryScanData: async (msg) => {
constrainsts.batchSetConstraintStates(msg);
},
});
await jtagHubConnection.value.start();
});
onUnmounted(() => {
// 断开连接,清理资源
if (jtagHubConnection.value) {
jtagHubConnection.value.stop();
jtagHubConnection.value = undefined;
jtagHubProxy.value = undefined;
}
});
async function jtagBoundaryScanSetOnOff(enable: boolean) {
if (isUndefined(jtagHubProxy.value)) {
console.error("JtagHub Not Initialize...");
return;
}
if (enable) {
const ret = await jtagHubProxy.value.startBoundaryScan(
jtagBoundaryScanFreq.value,
);
if (!ret) {
console.error("Failed to start boundary scan");
return;
}
} else {
const ret = await jtagHubProxy.value.stopBoundaryScan();
if (!ret) {
console.error("Failed to stop boundary scan");
return;
}
}
enableJtagBoundaryScan.value = enable;
}
async function jtagUploadBitstream(
bitstream: File,
examId?: string,
): Promise<string | null> {
try {
// 自动开启电源
await powerSetOnOff(true);
const resourceClient = AuthManager.createClient(ResourceClient);
const resp = await resourceClient.addResource(
"bitstream",
ResourcePurpose.User,
examId || null,
toFileParameterOrUndefined(bitstream),
);
// 如果上传成功,设置为当前选中的比特流
if (resp && resp.id !== undefined && resp.id !== null) {
return resp.id;
}
return null;
} catch (e) {
dialog.error("上传错误");
console.error(e);
return null;
}
}
async function jtagDownloadBitstream(bitstreamId?: string): Promise<string> {
if (bitstreamId === null || bitstreamId === undefined) {
dialog.error("请先选择要下载的比特流");
return "";
}
const release = await jtagClientMutex.acquire();
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.downloadBitstream(
boardAddr.value,
boardPort.value,
bitstreamId,
);
return resp;
} catch (e) {
dialog.error("下载错误");
console.error(e);
throw e;
} finally {
release();
}
}
async function jtagGetIDCode(isQuiet: boolean = false): Promise<number> {
const release = await jtagClientMutex.acquire();
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.getDeviceIDCode(
boardAddr.value,
boardPort.value,
);
return resp;
} catch (e) {
if (!isQuiet) dialog.error("获取IDCode错误");
return 0xffff_ffff;
} finally {
release();
}
}
async function jtagSetSpeed(speed: number): Promise<boolean> {
const release = await jtagClientMutex.acquire();
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.setSpeed(
boardAddr.value,
boardPort.value,
speed,
);
return resp;
} catch (e) {
dialog.error("设置Jtag速度失败");
return false;
} finally {
release();
}
}
// Matrix Key
const enableMatrixKey = ref(false);
const matrixKeyStates = reactive(new Array<boolean>(16).fill(false));
const matrixKeypadClientMutex = withTimeout(
new Mutex(),
1000,
new Error("Matrixkeyclient Mutex Timeout!"),
);
// Power
const powerClientMutex = withTimeout(
new Mutex(),
1000,
new Error("Matrixkeyclient Mutex Timeout!"),
);
// Enable Setting
const enableJtagBoundaryScan = ref(false);
const enableMatrixKey = ref(false);
const enablePower = ref(false);
// Watch
watchPostEffect(async () => {
if (true === enableJtagBoundaryScan.value) {
// 重新启用时重置错误计数器
jtagBoundaryScanErrorCount.value = 0;
jtagBoundaryScan();
}
});
// Parse and Set
function setAddr(address: string | undefined): boolean {
if (isString(address) && z.string().ip("4").safeParse(address).success) {
boardAddr.value = address;
return true;
}
return false;
}
function setPort(port: string | number | undefined): boolean {
if (isString(port) && port.length != 0) {
const portNumber = toNumber(port);
if (z.number().nonnegative().max(65535).safeParse(portNumber).success) {
boardPort.value = portNumber;
return true;
}
} else if (isNumber(port)) {
if (z.number().nonnegative().max(65535).safeParse(port).success) {
boardPort.value = port;
return true;
}
}
return false;
}
function setMatrixKey(
keyNum: number | string | undefined,
keyValue: boolean,
@@ -105,126 +226,10 @@ export const useEquipments = defineStore("equipments", () => {
return false;
}
async function jtagBoundaryScan() {
const release = await jtagClientMutex.acquire();
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const portStates = await jtagClient.boundaryScanLogicalPorts(
boardAddr.value,
boardPort.value,
);
constrainsts.batchSetConstraintStates(portStates);
// 扫描成功,重置错误计数器
jtagBoundaryScanErrorCount.value = 0;
} catch (error) {
jtagBoundaryScanErrorCount.value++;
console.error(`边界扫描错误 (${jtagBoundaryScanErrorCount.value}/${maxJtagBoundaryScanErrors}):`, error);
// 如果错误次数超过最大允许次数,才停止扫描并显示错误
if (jtagBoundaryScanErrorCount.value >= maxJtagBoundaryScanErrors) {
dialog.error("边界扫描发生连续错误,已自动停止");
enableJtagBoundaryScan.value = false;
jtagBoundaryScanErrorCount.value = 0; // 重置错误计数器
}
} finally {
release();
if (enableJtagBoundaryScan.value)
setTimeout(jtagBoundaryScan, 1000 / jtagBoundaryScanFreq.value);
}
}
async function jtagUploadBitstream(bitstream: File): Promise<boolean> {
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const resp = await jtagClient.uploadBitstream(
boardAddr.value,
toFileParameterOrUndefined(bitstream),
);
return resp;
} catch (e) {
dialog.error("上传错误");
console.error(e);
return false;
}
}
async function jtagDownloadBitstream(): Promise<boolean> {
const release = await jtagClientMutex.acquire();
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const resp = await jtagClient.downloadBitstream(
boardAddr.value,
boardPort.value,
);
return resp;
} catch (e) {
dialog.error("上传错误");
console.error(e);
return false;
} finally {
release();
}
}
async function jtagGetIDCode(isQuiet: boolean = false): Promise<number> {
const release = await jtagClientMutex.acquire();
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const resp = await jtagClient.getDeviceIDCode(
boardAddr.value,
boardPort.value,
);
return resp;
} catch (e) {
if (!isQuiet) dialog.error("获取IDCode错误");
return 0xffff_ffff;
} finally {
release();
}
}
async function jtagSetSpeed(speed: number): Promise<boolean> {
const release = await jtagClientMutex.acquire();
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const resp = await jtagClient.setSpeed(
boardAddr.value,
boardPort.value,
speed,
);
return resp;
} catch (e) {
dialog.error("设置Jtag速度失败");
return false;
} finally {
release();
}
}
async function matrixKeypadSetKeyStates(keyStates: boolean[]) {
const release = await matrixKeypadClientMutex.acquire();
console.log("set Key !!!!!!!!!!!!");
try {
const matrixKeypadClient = AuthManager.createAuthenticatedMatrixKeyClient();
const matrixKeypadClient = AuthManager.createClient(MatrixKeyClient);
const resp = await matrixKeypadClient.setMatrixKeyStatus(
boardAddr.value,
boardPort.value,
@@ -242,8 +247,8 @@ export const useEquipments = defineStore("equipments", () => {
async function matrixKeypadEnable(enable: boolean) {
const release = await matrixKeypadClientMutex.acquire();
try {
const matrixKeypadClient = AuthManager.createClient(MatrixKeyClient);
if (enable) {
const matrixKeypadClient = AuthManager.createAuthenticatedMatrixKeyClient();
const resp = await matrixKeypadClient.enabelMatrixKey(
boardAddr.value,
boardPort.value,
@@ -251,7 +256,6 @@ export const useEquipments = defineStore("equipments", () => {
enableMatrixKey.value = resp;
return resp;
} else {
const matrixKeypadClient = AuthManager.createAuthenticatedMatrixKeyClient();
const resp = await matrixKeypadClient.disableMatrixKey(
boardAddr.value,
boardPort.value,
@@ -268,10 +272,17 @@ export const useEquipments = defineStore("equipments", () => {
}
}
// Power
const powerClientMutex = withTimeout(
new Mutex(),
1000,
new Error("Matrixkeyclient Mutex Timeout!"),
);
const enablePower = ref(false);
async function powerSetOnOff(enable: boolean) {
const release = await powerClientMutex.acquire();
try {
const powerClient = AuthManager.createAuthenticatedPowerClient();
const powerClient = AuthManager.createClient(PowerClient);
const resp = await powerClient.setPowerOnOff(
boardAddr.value,
boardPort.value,
@@ -287,19 +298,96 @@ export const useEquipments = defineStore("equipments", () => {
}
}
// Seven Segment Display
const enableSevenSegmentDisplay = ref(false);
const sevenSegmentDisplayFrequency = ref(100);
const sevenSegmentDisplayData = ref<Uint8Array>();
const sevenSegmentDisplayHub = shallowRef<{
connection: HubConnection;
proxy: IDigitalTubesHub;
} | null>(null);
async function initSevenDigitalTubesHub() {
// 每次挂载都重新创建连接
if (sevenSegmentDisplayHub.value) return;
const connection = AuthManager.createHubConnection("DigitalTubesHub");
const proxy =
getHubProxyFactory("IDigitalTubesHub").createHubProxy(connection);
getReceiverRegister("IDigitalTubesReceiver").register(connection, {
onReceive: handleSevenSegmentDisplayOnReceive,
});
await connection.start();
sevenSegmentDisplayHub.value = { connection, proxy };
}
async function clearSevenDigitalTubesHub() {
if (!sevenSegmentDisplayHub.value) return;
sevenSegmentDisplayHub.value.connection.stop();
sevenSegmentDisplayHub.value = null;
}
async function reinitializeSevenDigitalTubesHub() {
await clearSevenDigitalTubesHub();
await initSevenDigitalTubesHub();
}
function getSevenDigitalTubesHubProxy() {
if (!sevenSegmentDisplayHub.value) {
reinitializeSevenDigitalTubesHub();
throw new Error("Hub not initialized");
}
return sevenSegmentDisplayHub.value.proxy;
}
onMounted(async () => {
await initSevenDigitalTubesHub();
});
onUnmounted(async () => {
// 断开连接,清理资源
await clearSevenDigitalTubesHub();
});
async function sevenSegmentDisplaySetOnOff(enable: boolean) {
const proxy = getSevenDigitalTubesHubProxy();
if (enable) {
await proxy.startScan();
enableSevenSegmentDisplay.value = true;
} else {
await proxy.stopScan();
enableSevenSegmentDisplay.value = false;
}
}
async function sevenSegmentDisplaySetFrequency(frequency: number) {
const proxy = getSevenDigitalTubesHubProxy();
return await proxy.setFrequency(frequency);
}
async function sevenSegmentDisplayGetStatus() {
const proxy = getSevenDigitalTubesHubProxy();
return await proxy.getStatus();
}
async function handleSevenSegmentDisplayOnReceive(msg: string) {
const bytes = base64ToArrayBuffer(msg);
sevenSegmentDisplayData.value = new Uint8Array(bytes);
}
return {
boardAddr,
boardPort,
setAddr,
setPort,
setMatrixKey,
// Jtag
enableJtagBoundaryScan,
jtagBoundaryScanSetOnOff,
jtagBitstream,
jtagBoundaryScanFreq,
jtagBoundaryScanErrorCount,
jtagClientMutex,
jtagUserBitstreams,
jtagUploadBitstream,
jtagDownloadBitstream,
jtagGetIDCode,
@@ -316,5 +404,13 @@ export const useEquipments = defineStore("equipments", () => {
enablePower,
powerClientMutex,
powerSetOnOff,
// Seven Segment Display
enableSevenSegmentDisplay,
sevenSegmentDisplayData,
sevenSegmentDisplayFrequency,
sevenSegmentDisplaySetOnOff,
sevenSegmentDisplaySetFrequency,
sevenSegmentDisplayGetStatus,
};
});

83
src/stores/progress.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { HubConnection } from "@microsoft/signalr";
import type {
IProgressHub,
IProgressReceiver,
} from "@/utils/signalR/TypedSignalR.Client/server.Hubs";
import {
getHubProxyFactory,
getReceiverRegister,
} from "@/utils/signalR/TypedSignalR.Client";
import { ProgressStatus, type ProgressInfo } from "@/utils/signalR/server.Hubs";
import { onMounted, onUnmounted, ref, shallowRef } from "vue";
import { defineStore } from "pinia";
import { AuthManager } from "@/utils/AuthManager";
import { forEach, isUndefined } from "lodash";
export type ProgressCallback = (msg: ProgressInfo) => void;
export const useProgressStore = defineStore("progress", () => {
// taskId -> name -> callback
const progressCallbackFuncs = shallowRef<
Map<string, Map<string, ProgressCallback>>
>(new Map());
const progressHubConnection = shallowRef<HubConnection>();
const progressHubProxy = shallowRef<IProgressHub>();
const progressHubReceiver: IProgressReceiver = {
onReceiveProgress: async (msg) => {
const taskMap = progressCallbackFuncs.value.get(msg.taskId);
if (taskMap) {
for (const func of taskMap.values()) {
func(msg);
}
}
},
};
onMounted(async () => {
progressHubConnection.value =
AuthManager.createHubConnection("ProgressHub");
progressHubProxy.value = getHubProxyFactory("IProgressHub").createHubProxy(
progressHubConnection.value,
);
getReceiverRegister("IProgressReceiver").register(
progressHubConnection.value,
progressHubReceiver,
);
progressHubConnection.value.start();
});
onUnmounted(() => {
if (progressHubConnection.value) {
progressHubConnection.value.stop();
progressHubConnection.value = undefined;
progressHubProxy.value = undefined;
}
});
function register(progressId: string, name: string, func: ProgressCallback) {
progressHubProxy.value?.join(progressId);
let taskMap = progressCallbackFuncs.value.get(progressId);
if (!taskMap) {
taskMap = new Map();
progressCallbackFuncs.value?.set(progressId, taskMap);
}
taskMap.set(name, func);
}
function unregister(taskId: string, name: string) {
progressHubProxy.value?.leave(taskId);
const taskMap = progressCallbackFuncs.value.get(taskId);
if (taskMap) {
taskMap.delete(name);
if (taskMap.size === 0) {
progressCallbackFuncs.value?.delete(taskId);
}
}
}
return {
register,
unregister,
};
});

View File

@@ -1,29 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useSidebarStore = defineStore('sidebar', () => {
const isClose = ref(false);
function closeSidebar() {
isClose.value = true;
console.info("Close sidebar");
}
function openSidebar() {
isClose.value = false;
console.info("Open sidebar");
}
function toggleSidebar() {
if (isClose.value) {
openSidebar();
// themeSidebar.value = "card-dash sidebar-base sidebar-open"
} else {
closeSidebar();
// themeSidebar.value = "card-dash sidebar-base sidebar-close"
}
}
return { isClose, closeSidebar, openSidebar, toggleSidebar }
})

Some files were not shown because too many files have changed in this diff Show More